Compare commits

...

115 commits

Author SHA1 Message Date
9677d1e71a Fix: Pfoten-Button bleibt rechts (display:flex statt '' beim Einblenden), SW by-v1070 2026-05-19 19:04:32 +02:00
9f47cfe1d4 Fix: Pfoten-Button rechts neben Suchzeile (Dog-Chip eigene Zeile), SW by-v1069 2026-05-19 19:02:22 +02:00
b1e4d7fce1 Bump: SW by-v1068 (Übungen-Redesign) 2026-05-19 18:58:12 +02:00
c5bb3ee2cb UX: 'Stand erfassen' als Pfoten-Button rechts neben Dog-Chip+Suche 2026-05-19 18:51:17 +02:00
81b5199e18 Fix: Suche+Stand-erfassen Zeile als Flexbox (kein Table-Overflow mehr) 2026-05-19 18:46:32 +02:00
4407b9b27f Fix: 'Stand erfassen' Button auf Mobile sichtbar (kein zweizeiliger Text) 2026-05-19 18:43:59 +02:00
6fcc9d34d3 UX: Übung des Tages zeigt 'Stand erfassen →' statt '--' wenn kein Stand 2026-05-19 18:41:44 +02:00
dcb966ca54 Feature: Protokoll-Tab Toggle 'Nach Datum / Nach Übung'
- Toggle-Buttons oben im Protokoll-Tab
- 'Nach Übung': gruppiert alle Sessions pro Übung, sortiert nach zuletzt trainiert
- Pro Übung: Ø-Erfolgsquote als Kreis, Trend-Pfeil (↑↓→★), Anzahl Einheiten + TOP-Count
- Aufklappbare Session-Liste pro Übung (Datum · Emoji · % · Wdh.)
- Hinweis wenn mehr Sessions vorhanden als geladen
2026-05-19 18:34:30 +02:00
738571d958 Fix: Verlauf-Tab kein Endlos-Spinner + Save-422 bei null-Feldern
- Verlauf: _verlaufLoading-Flag verhindert parallele Loads
- Verlauf: el nach await neu holen (stale DOM-Referenz nach Re-Render)
- Verlauf: bei _renderContent() Shell nur rendern wenn keine Sessions im Cache
- Backend: hund_stimmung/zufriedenheit als Optional[str/int] → akzeptiert null
2026-05-19 18:28:56 +02:00
cc841ef6d7 Feature: Trainingsprotokoll-Tab in Übungen, kein Tagebuch-Spam
- Neuer Tab 'Protokoll' in der Übungen-Seite: zeigt alle Trainingseinheiten
  chronologisch nach Datum gruppiert (Heute/Gestern/Datum-Label)
- Jede Einheit: Übungsname, Wdh., Erfolgs-Emoji, Stimmung, Sterne, Notiz, TOP-Badge
- 'Weitere laden' Pagination (30 Einheiten pro Seite)
- Backend: Training erstellt keine Tagebuch-Einträge mehr (weder bei ist_top noch manuell)
- Frontend: 'Als Meilenstein ins Tagebuch' Checkbox komplett entfernt
- onDogChange setzt Verlauf-State zurück
2026-05-19 18:17:50 +02:00
d2c2c59abb Fix: Züchter-Header (Wurfverwaltung/Zuchtkartei/Läufigkeit) Hell-Mode kompatibel (CSS-Variablen statt hardcoded Dark-Gradient), SW by-v1036 2026-05-16 13:59:07 +02:00
21b65a1e39 Fix: Asphalttemperatur-Formel temperaturabhängig (t_factor 0..1 zwischen 5-30°C), nicht mehr temperaturblind 2026-05-16 13:53:05 +02:00
416f32b056 Fix: Chips gleichförmig bei großer iOS-Schriftgröße (height 74px statt min-height, max-height px statt em), SW by-v1035 2026-05-16 13:51:03 +02:00
a736d66c47 UX: Panorama-Hinweis bleibt 4s sichtbar (erste 2x), Text + Querformat, SW by-v1034 2026-05-16 13:03:42 +02:00
a65e5a2723 Feature: Dark-Mode Overlay auf Welten-Panorama (rgba 0,0,0,0.45), SW by-v1033 2026-05-16 13:01:33 +02:00
a1c4d2ab2b Fix: /me-Endpoint gibt geburtstag zurück (war vergessen) 2026-05-16 12:57:19 +02:00
f0cf0f2243 Fix: users.geburtstag Migration robuster (PRAGMA table_info Check)
Statt try/except mit pass: PRAGMA table_info prüft ob Spalte existiert,
loggt explizit ob hinzugefügt oder bereits vorhanden.
SW by-v1032, APP_VER 1032
2026-05-16 12:18:48 +02:00
fc8d396247 Fix: Geburtstags-Banner nur für aktiven Hund
bday/bdayYear: nur truthy wenn bdayDog.id === dog.id (aktiver Hund)
otherBdayDog: zeigt Hinweis wenn ein ANDERER Hund Geburtstag hat
SW by-v1031, APP_VER 1031
2026-05-16 12:13:15 +02:00
a50158d522 Fix: Worlds.refresh() nach Profil-Speichern aufrufen
Nach Object.assign(_appState.user, updated) wurde Worlds.refresh() nie
aufgerufen → JETZT-Welt zeigte alten Render ohne Geburtstags-Greeting.
SW by-v1030, APP_VER 1030
2026-05-16 12:07:31 +02:00
b54d9fda99 Fix: users.geburtstag Migration + Format TT.MM statt MM-DD
- database.py: ALTER TABLE users ADD COLUMN geburtstag TEXT (fehlte!)
- profile.py: Validierung auf \d{2}\.\d{2} (TT.MM Format)
- settings.js: Placeholder/Pattern auf TT.MM geändert
- worlds.js: Birthday-Check auf DD.MM Format angepasst
SW by-v1029, APP_VER 1029
2026-05-16 12:02:52 +02:00
a4377033ec Feature: User-Geburtstag im Profil + Glückwunsch in JETZT-Welt
Settings:
- Feld 'Dein Geburtstag (optional)' im Profil-Formular (Format MM-TT)
- Hinweis: nur für Geburtstagsgrüße, kein Jahr nötig
- profile.py: geburtstag gespeichert + Format-Validierung MM-DD

JETZT-Welt wenn heute User-Geburtstag:
- Greet-Text: 'Herzlichen Glückwunsch' statt Tageszeit-Gruß
- Animiertes Geburtstags-Reminder-Card (confetti + cake Icons)
- 'Alles Gute zum Geburtstag, [Name]!'

SW by-v1028, APP_VER 1028
2026-05-16 11:55:34 +02:00
1328e2c4e3 Feature: HUND-Geburtstag-Hint für Nicht-aktiven Hund
Wenn ein anderer Hund (nicht der angezeigte) Geburtstag hat:
- Cake-Icon (Phosphor) animiert bounce in der Info-Karte unten
- "[Name] hat heute/morgen Geburtstag!" + Pfeil-Button
- Klick → wechselt direkt zum Geburtstagshund + zeigt Birthday-Banner
Kein Tab-Indicator (nur HUND-Welt). SW by-v1027, APP_VER 1027
2026-05-16 11:51:06 +02:00
d8b75fbcab Fix: Globaler Dark-Mode-Filter für alle Leaflet-Karten
design-system.css: .leaflet-tile-pane bekommt den invert/hue-rotate-Filter
im Dark-Mode — gilt für walks, lost, poison, forum, routes und alle
anderen Seiten mit eingebetteten Leaflet-Karten.
design-system.css ?v=1025, SW by-v1026, APP_VER 1026
2026-05-16 11:41:12 +02:00
19640af288 Style: Marker + Cluster Rand Tactical-Olive statt Weiß
rgba(255,255,255,0.7/.8) → rgba(52,68,36,0.55/.65)
Dezentes Dunkeloliv auf allen Marker-Icons und Cluster-Bubbles.
SW by-v1025, APP_VER 1025
2026-05-16 11:35:01 +02:00
059002670a Feature: Karten-UI vollständig Dark-Mode-fähig
Filter-Chips, Status-Pille und Popup-Ballons passen sich dem Dark-Mode an:
- .map-legend-btn: dunkel frosted-glass (rgba(24,20,16,0.88)) statt weiß
- .map-legend-all (Filter-Btn): entsprechend dunkler Ton
- .map-statusbar: dunkler Hintergrund + helle Schrift
- .leaflet-popup-content-wrapper/.tip: --c-surface (#241C14) statt weiß
Aktive Tabs behalten Layer-Farbe, Marker-Farben bleiben unverändert.
components.css ?v=1015, SW by-v1024, APP_VER 1024
2026-05-16 11:25:04 +02:00
2cf0bc0d97 Fix: Karte CSS-Filter statt CartoDB Dark, Social Wrapper 860px
Karte Dark-Mode:
- CSS-Filter statt CartoDB Dark Matter (war zu dunkel)
  invert(93%) hue-rotate(180deg) brightness(0.88) contrast(0.88) saturate(0.85)
  Gleiche OSM-Tiles, Marker bleiben unbeeinflusst (tilePane separater Layer)
- Filter sofort bei Init + bei Theme-Wechsel via MutationObserver

Social:
- Outer Wrapper width:100%;max-width:860px;margin:0 auto
  Header-Karte und Tabs sind jetzt konsistent gleich breit
SW by-v1023, APP_VER 1023
2026-05-16 11:19:37 +02:00
721e630a34 Fix: Dark-Mode Karte + Badge-Farben + --c-bg-secondary
Karte:
- Dark-Mode: CartoDB Dark Matter Tiles statt OSM Standard
- MutationObserver + matchMedia watchee für Live-Theme-Wechsel
- _buildTileLayer() / _applyTileTheme() / _isDarkMode()

Badges litters.js:
- Hardgekodete dunkle Hintergründe → CSS-Klassen (badge-warning/-success/-muted)
- Funktioniert jetzt in Light + Dark Mode korrekt

movies.css:
- .movie-tag-stirbt/.movie-tag-ueberlebt → CSS-Variablen (danger-/success-subtle)
- Kein weißer Hintergrund mehr in Dark Mode

--c-bg-secondary: Zoom-Control 30px bleiben aus dem letzten Commit
SW by-v1022, APP_VER 1022
2026-05-16 11:11:09 +02:00
161c1e3f73 Fix: --c-bg-secondary definiert, Zoom-Control 30px, Dark-Mode Karten
- design-system.css: --c-bg-secondary hinzugefügt (fehlte → transparente Karten)
  Light: #F2EDE4, Dark: #221A12 — sichtbarer Kontrast zu --c-bg in beiden Modi
  Gilt für laeufi.js, litters.js, breeder.js Karten
- Zoom-Control: padding-top 28→30px (besser mittig zu 2 Filter-Reihen)
- components.css ?v=1014, SW by-v1021, APP_VER 1021
2026-05-16 10:50:10 +02:00
1c187675a9 Fix: Zoom-Control padding-top zurück auf 28px (war 33 = zu tief) 2026-05-16 10:42:44 +02:00
e44414015a Fix: Karte — Zoom-Ausrichtung, 'Z14' kürzer, Marker-Count ausgeblendet
- leaflet-top padding-top 28→33px (Zoom-Buttons mittig zu 2 Filter-Reihen)
- Zoom-Anzeige: 'Zoom 14 · ab 14: alle Layer' → 'Z14' (Hinweis als title)
- #map-osm-status ausgeblendet (Marker-Anzahl nimmt Platz von Wetter etc.)
- components.css ?v=1012, SW by-v1019, APP_VER 1019
2026-05-16 10:39:12 +02:00
562d64979f Fix: Karte — Filter-Button mit Label, kürzere Chip-Texte
- #map-legend-all: 'Filter' Text neben dem List-Icon
- 'Hundefreundl. Café/Restaurant' → 'Café & Restaurant'
- 'Hundefreundl. Hotel' → 'Hotel'
- .map-legend-all: padding angepasst für Text-Label
- components.css ?v=1011, SW by-v1018, APP_VER 1018
2026-05-16 10:32:11 +02:00
9a60c160a1 Feature: Läufigkeit-Spotlight in Züchter-Sektion (landing.html)
Zeigt ein realistisches Beispiel (Luna vom Bergwald) mit Deckdaten,
Trächtigkeits-Meilensteinen und Progesteronkurve — im bestehenden
Züchter-Abschnitt, kein neuer Section-Break.
SW by-v1017, APP_VER 1017
2026-05-16 10:23:46 +02:00
835c0ada21 Fix: laeufi.js width:100% für konsistente Breite kollabiert/aufgeklappt
SW by-v1016, APP_VER 1016
2026-05-16 10:20:19 +02:00
af4a61f5b3 Fix: laeufi.js Desktop — margin:0 auto + padding für Zentrierung
Äußerer Wrapper hatte max-width:860px aber kein margin:0 auto → klebte links.
Toolbar und List-Container bekamen fehlende Seitenabstände.
SW by-v1015, APP_VER 1015
2026-05-16 10:10:55 +02:00
ed9dd08c14 Desktop: Einheitliche Seitenbreite 860px via pc-desktop Klasse
- app.js: _applyDesktopWidth() setzt nach Page-Init die Klasse pc-desktop
  auf dem ersten Kind-Div aller Standard-Seiten (excl. admin/map/chat/etc.)
- layout.css: .pc-desktop { max-width:860px !important; margin:0 auto }
- layout.css: .page-container ab 768px auf 860px (statt erst 1024px)
- main.py: /force-update Text "Service Worker wird entfernt" →
  "Wir besorgen neue Leckerlis 🦴"
- layout.css ?v=1013, components.css ?v=1010, SW by-v1014, APP_VER 1014
2026-05-16 09:57:00 +02:00
9816075c7e Desktop JETZT: world-top max-width 860px zentriert
Greeting-Karte + Wetter/Route/Übung-Chips stretchen nicht mehr auf volle
Desktopbreite. Chips sind je ~280px breit — deutlich kompakter als vorher.
components.css ?v=1010, SW by-v1013, APP_VER 1013
2026-05-16 09:36:28 +02:00
f36bac7ded Desktop: W3-Overlays als zentrierte Dialogs (FAB + All-Chips)
@media 768px:
- .w3-sheet-overlay: justify-content/align-items:center statt flex-end
- .w3-sheet-panel: border-radius:20px, max-width:480px, padding:24px
- .w3-sheet-panel--scroll: max-width:680px (all-chips breiter)
- all-chips Grid: auto-fill minmax(100px) statt repeat(4,1fr)
components.css ?v=1009, SW by-v1012, APP_VER 1012
2026-05-16 09:28:03 +02:00
82869e3f12 Fix: Drag-and-Drop in Welten-Konfig für Desktop (Pointer Events)
touchstart/touchmove/touchend → pointerdown/pointermove/pointerup:
- Pointer Events funktionieren auf Mouse (Desktop) + Touch (Mobile) gleich
- setPointerCapture() für sauberes Drag auch wenn Maus das Element verlässt
- e.touches[0] → e.clientX/clientY direkt aus dem Pointer Event
- Nur linke Maustaste (e.button === 0) startet Drag
SW by-v1011, APP_VER 1011
2026-05-16 09:24:08 +02:00
1b3b150b50 Desktop: Welten-Konfig als zentrierter Dialog, Chips kompakter
- Desktop (_isDesktop = innerWidth >= 768):
  Overlay zentriert (nicht Bottom-Sheet), max-width 1100px, border-radius 20px
  Grid: repeat(auto-fill, minmax(120px, 1fr)) statt repeat(4, 1fr)
  Chip-Höhe: 64px statt 80px, Gap 6px statt 8px
- Mobile: unverändert (Bottom-Sheet wie bisher)
- SW by-v1010, APP_VER 1010
2026-05-16 09:20:20 +02:00
6721597779 Fix Desktop Chips: !important overrides für flex + grid-template-columns unset
Das grid blieb wegen Spezifität aktiv. Explizite !important auf display:flex,
flex-wrap:nowrap, grid-template-columns:unset erzwingen die Eine-Zeile-Darstellung.
components.css ?v=1008, SW by-v1009, APP_VER 1009
2026-05-16 09:15:41 +02:00
9168d982d0 Desktop Welten: Chips eine Zeile + Nav vertikal zentriert
- .world-chips-grid @768: flex nowrap, alle Chips in einer Zeile (80px Basis,
  shrinks bis 60px), justify-content:center — egal wie viele aktiv
- #world-labels bottom: 22px→33px (vertikal zentriert zwischen Chips und Footer)
- components.css ?v=1007, SW by-v1008, APP_VER 1008
2026-05-16 09:12:05 +02:00
8c69143b09 Layout: Nav vertikal zentriert, Info-Karte höher
- #world-labels bottom: 20px→33px (vertikal zentriert zwischen Chips und Footer)
- .world-panel top-padding: 32px→14px (Info-Karte näher an Statusleiste)
- components.css ?v=1006, SW by-v1007, APP_VER 1007
2026-05-16 09:06:28 +02:00
c29b2c1752 Fix: Nav zentriert (mobile) + Desktop Nav unten zwischen Chips und Footer
- #world-labels: right:80px→right:0 (Nav war durch halbe Breite nach links versetzt)
- @media 768px: Nav bleibt unten statt zurück nach oben — Chips+Nav+Footer
  gleiches Layout wie Mobile, nur top-padding 48px und chip-grid max-width 480px
- components.css ?v=1005, SW by-v1006, APP_VER 1006
2026-05-16 09:02:41 +02:00
ad942e81b2 Fix Desktop-Layout Welten: Top-Padding + Chips-Breite
@media 768px:
- .world-panel top-padding zurück auf safe-area+58px (Nav ist oben)
- .world-chips-grid max-width:480px zentriert (kein Full-Width Bottom-Bar)
SW by-v1005, APP_VER 1005
2026-05-16 08:56:17 +02:00
d6eb2bcf98 Layout: JETZT/HUND/WELT Nav nach unten, Info-Karte höher
- #world-dots: ausgeblendet auf Mobile (Labels dienen als Tab-Indikator)
- #world-labels: von top→bottom (safe-area+20px), pill-Style für active
  right:80px damit FAB nicht überlappt, backdrop-blur auf active label
- .world-panel top-padding: 58→32px (Info-Karte startet weiter oben)
- Desktop @media 768px: Nav bleibt oben (dots+labels wie vorher)
- components.css ?v=1003, SW by-v1004, APP_VER 1004
2026-05-16 08:53:14 +02:00
d03e49800d Layout: Footer-Links absolut positioniert, Chips dichter am FAB
- .world-panel: bottom-padding 88→76px, position:relative als Anker
- .world-footer-links: position:absolute bottom:safe-area+4px —
  nimmt keinen Platz im Fluss mehr ein, klebt am unteren Bildschirmrand
- Chips sitzen jetzt näher am FAB, Hintergrundbild bekommt mehr Raum
- components.css ?v=1002, SW by-v1003, APP_VER 1003
2026-05-15 22:01:41 +02:00
a8c63e87da Experiment: Chip-Layout unten — Hintergrundbild bekommt mehr Raum
- Section-Labels entfernt ('Deine Bereiche', 'Alles über X', 'Die Welt da draußen')
- Chip-Grid: Höhe 80→74px, Gap 8→7px
- world-bottom gap 8→5px
- Footer-Links: Padding reduziert, Schrift 11→10px
- CSS-Version ?v=1001 für immutable-Cache-Busting
- SW by-v1002, APP_VER 1002
2026-05-15 21:18:44 +02:00
f962cf2f4d Fix: Chip-Sichtbarkeit geräteübergreifend konsistent
Root cause: _mergeDefaults() interpretierte fehlende Chips als 'neu'
und fügte sie wieder ein — auch bewusst ausgeblendete.

Fix:
- _saveConfig(): berechnet cfg.hidden = alle Default-Chips die keiner
  Welt zugewiesen sind; wird mit der Config auf dem Server gespeichert
- _mergeDefaults(): prüft hidden-Set und allAssigned-Set; fügt nur echte
  Neu-Chips ein (nicht in hidden, nicht bereits anderer Welt zugewiesen)
- Verhindert auch Doppelzuweisung wenn ein Chip zwischen Welten verschoben

SW by-v1001, APP_VER 1001
2026-05-15 19:08:34 +02:00
2caab31797 Feature: Hero-Stats dynamisch aufsteigend sortiert
Statt fixer Reihenfolge werden alle 5 Werte nach dem API-Fetch
per .sort() aufsteigend geordnet und der Streifen neu aufgebaut.
Damit steht immer die kleinste Zahl links, die größte rechts —
unabhängig davon wie die Zahlen wachsen.
SW by-v1000, APP_VER 1000
2026-05-15 18:40:25 +02:00
64127bf395 Fix: Tagebuch-Einträge im Hero-Stats-Streifen (stat-diary)
Statt in der Stats-Band weiter unten erscheint die Zahl jetzt im
Hero-Streifen direkt unter den CTAs — sichtbar ohne Scrollen.
SW by-v999, APP_VER 999
2026-05-15 18:38:59 +02:00
be9f263e0d Feature: Stats-Band + Tagebuch-Einträge, km alle Routen (public+privat)
- landing.html: neues Stats-Band-Element 'Tagebuch-Einträge' (#big-diary)
  mit Wert aus diary_entries (war bereits im API-Response vorhanden)
- stats.py km-Query: explizit WHERE is_valid=1 (kein is_public-Filter —
  private Routen werden mitgezählt, nur ungültige Aufzeichnungen ausgeschlossen)
- SW by-v998, APP_VER 998
2026-05-15 18:34:03 +02:00
902e6b8602 Feature: Einmaliger Offline-Hinweis 'App im Vordergrund lassen'
Beim ersten offline-Event pro Session erscheint ein blauer Info-Toast (8s):
'App im Vordergrund lassen — so bleiben Offline-Funktionen wie GPS und
Datenspeicherung aktiv.'
sessionStorage-Guard verhindert Wiederholung. SW by-v997, APP_VER 997
2026-05-15 18:28:50 +02:00
1d67d6307e Fix: Routen-Aufzeichnung offline — Dim-Screen + WakeLock + GPS funktionieren
_startRecInOvl() crashte bei null _recMap auf L.polyline().addTo(_recMap) →
WakeLock, watchPosition, _resetRecInactTimer() wurden nie erreicht → Dim-Screen
wurde nie aktiviert, GPS-Track lief nicht.

- L.polyline nur erstellen wenn _recMap && window.L vorhanden
- watchPosition-Callback: _recPolyline?.addLatLng, _recLocMarker?.setLatLng,
  _recMap?.setView alle mit Optional Chaining gesichert
- SW by-v996, APP_VER 996
2026-05-15 18:19:55 +02:00
c59326af17 Fix+Polish: Phosphor-Icons Danke-Overlay, Quartalsbericht paid_amount
Giftköder Danke-Overlay (poison.js):
- Emoji 🚨/🐾/📡 durch Phosphor-Icons ersetzt: siren, paw-print, wifi-slash

Quartalsbericht (invoices.py + admin.js):
- Backend: _effective_gross() — für bezahlte Rechnungen wird paid_amount statt
  amount_gross für die Quartalssumme verwendet (Kulanz/Teilzahlung korrekt)
- Admin-Preview: effectiveAmt in der Vorschau-Tabelle, bei Abweichung Hinweis
  "(RG: xx,xx €)" für Nachvollziehbarkeit
- CSV: Spalte "Betrag (eingegangen)" + separate Spalte "Rechnungsbetrag"
- SW by-v995, APP_VER 995
2026-05-15 18:18:22 +02:00
57192ea010 Fix: Routen-Aufzeichnung offline — Buttons Abbruch/Start reagieren nicht
L.map() warf ReferenceError wenn Leaflet offline nicht geladen → _openRecOvl()
crashte, Event-Listener für #rk-rec-cancel und #rk-rec-startbtn wurden nie
angehängt. Fix:
- Listener direkt nach appendChild() registrieren (vor jeder async-Operation)
- Map-Setup in try/catch; bei fehlendem Leaflet: Offline-Platzhalter im Map-Bereich
- _recMap?.setView / _recLocMarker?.setLatLng mit Optional Chaining (null-safe)
- SW by-v994, APP_VER 994
2026-05-15 18:11:52 +02:00
0878684402 Feature: Giftköder — Danke-Overlay nach erfolgreicher Meldung
Statt sofort zu schließen zeigt das Modal nach dem Submit eine Bestätigung:
'Wir kümmern uns darum und melden es den anderen Nutzern in der Umgebung.
Vielen Dank, dass du die Community schützt!'
Auto-Close nach 5 Sekunden, OK-Button zum sofortigen Schließen.
Bei gequeuter Meldung (offline) zusätzlicher Hinweis auf spätere Synchronisierung.
SW by-v993, APP_VER 993
2026-05-15 18:03:28 +02:00
32fde79a40 Fix: _queued-Guard in poison/walks/diary — kein Crash bei SW-gequeuten Requests
Wenn der SW einen POST in die Offline-Queue legt, gibt api.js { _queued: true }
zurück (202). Ohne Guard versuchten poison/walks/diary den Response als echtes
Server-Objekt zu nutzen → undefined lat/lon → Leaflet-Crash, undefined id → Upload-Fehler.
Nach dem Guard wird das Modal nur geschlossen; der QUEUE_PROCESSED-Toast informiert
den User sobald synchronisiert.

- poison.js: _queued guard nach API.poison.report()
- walks.js: _queued guard + try-catch statt navigator.onLine
- diary.js: _queued guard nach API.diary.create()
- SW by-v992, APP_VER 992
2026-05-15 17:56:29 +02:00
f2856b8acb Fix: Lost — Puls-Animation (box-shadow), false-offline, Pending-Guard
- Pulsierender Marker: Wechsel von position:absolute-Ring auf box-shadow-Animation
  (by-lost-pulse-r/p), kein Overflow-Problem mit Leaflet divIcon, iOS-kompatibel
- navigator.onLine iOS-Falsch-Positiv: Formular-Submit versucht API zuerst,
  fällt nur bei TypeError (fetch failed) auf Pending-Modus zurück
- _openDetail(): früher Return für Pending-Einträge (verhindert delete mit
  string-ID "pending_..." → Backend-Fehler "unable to parse integer")
- SW by-v991, APP_VER 991
2026-05-15 17:44:59 +02:00
be12550df1 Fix: Lost-Hund — kein Doppeleintrag nach Sync, pulsierender Marker, Verwerfen-Button, 20km-Alert
- Deduplication in _loadReports(): Pending-Einträge die bereits auf dem Server
  sind (Race-Condition beim Sync) werden automatisch aus dem Pending-Store entfernt
- Verwerfen-Button für offline-gespeicherte Meldungen (pending), Notiz-Button nur
  für Server-Einträge sichtbar
- Pulsierender Kreis-Marker (CSS @keyframes by-lost-ping) statt statischem Pin;
  Pending-Einträge in Orange, Server-Einträge in Rot
- Card-Click für pending deaktiviert (kein Detail-Modal für unsynchronisierte Daten)
- worlds.js: Alert-Radius für vermisste Hunde von 5 auf 20 km erhöht (wie Giftköder)
- SW by-v990, APP_VER 990
2026-05-15 17:37:16 +02:00
f0c1ee3386 Fix: Offline-UX — 📡 statt 🚧 bei offline-Seiten, schnellerer Warm-up, mehr Endpoints gecacht (SW by-v989) 2026-05-15 17:25:24 +02:00
8ad3ca8a74 Perf: Offline-Support alle Prioritäts-Seiten — Cache+Queue+Sync (SW by-v988) 2026-05-15 17:04:06 +02:00
53fcb61933 Offline-Fallbacks für diary, poison, map + SW-Erweiterung
- sw.js: /api/places, /api/breeder/map-markers, /api/gassi-zeiten in _CACHEABLE_GET; /api/lost/report und /api/walks in _QUEUEABLE
- diary.js: localStorage-Cache pro Hund, Fallback bei Offline mit Toast
- poison.js: localStorage-Cache, Fallback bei Offline mit Toast (sicherheitsrelevant)
- map.js: POI-Cache (places/poison/breeders) in localStorage, Offline-Toast + Fallback auf gecachte Daten
2026-05-15 17:02:26 +02:00
0c0daaad6b Feat: Routen offline aufzeichnen — LocalStorage-Queue, Cache-Fallback, Auto-Sync (SW by-v987) 2026-05-15 16:53:38 +02:00
3fae57a0e2 Feat: Kontaktformular im Impressum + /api/contact Endpoint ohne Auth (SW by-v986) 2026-05-15 16:46:37 +02:00
0f09f5a8dd Rechtliche Seiten überarbeitet: Impressum, Datenschutz, AGB
Impressum: Telefon-Placeholder entfernt, Kontakt auf E-Mail reduziert,
UGC-Haftungsklausel auf §§ 7 ff. DDG-Basis präzisiert.

Datenschutz: Neue Abschnitte Hosting & Infrastruktur (Brevo, Umami),
Technische Speicherung (TTDSG), Mindestalter, Moderation & Community;
KI-Abschnitt um US-Behörden-Restrisiko-Hinweis ergänzt; BayLDA-Adresse
korrigiert (Promenade 18) und E-Mail ergänzt; Version 3.

AGB: Neue Abschnitte Mindestalter (§ 2), Nutzungsregeln/Community (§ 4),
Nutzerinhalte/Lizenzen (§ 5), KI-Haftung (§ 11); Widerrufsrecht
auf aktive Protokollierung präzisiert; Kündigungsbutton-Hinweis nach
§ 312k BGB; Erstattungsausschluss um gesetzliche Ansprüche ergänzt;
Abschnitte neu durchnummeriert (1–15), Version 2.
2026-05-15 16:41:19 +02:00
738e354b0a Fix: APP_VER 984→985 in main.py (Dauerschleife) 2026-05-15 16:24:18 +02:00
d7f7a7e454 Neu: AGB-Seite + Impressum/Datenschutz aktualisiert (SW by-v985)
- Neue Seite agb.js mit 11 Abschnitten (Laufzeit, Zahlung, Widerruf etc.)
- Datenschutz: 'Abonnement & Kündigung' → 'Zahlungsdaten' (DSGVO-Fokus), DDG-Hinweis ergänzt
- Impressum: ODR-Link entfernt (EU-Plattform eingestellt 2025), Telefon-Pflichthinweis nach §5 DDG, Stand Mai 2026
- AGB-Link in alle Footer (index.html, landing.html, zuechter.html, welcome.js)
- page-section #page-agb in index.html, Route 'agb' in app.js ROUTES
2026-05-15 16:21:04 +02:00
d20e63496c Feat: AGB-Link im Footer (Welt-Welt) + AGB-Checkbox im Upgrade-Modal
- worlds.js: 'Datenschutz · AGB' in der Welt-Welt-Fußzeile
- settings.js: AGB-Checkbox über Widerrufs-Checkbox; beide müssen gecheckt sein bevor 'Anfrage senden' aktiv wird
2026-05-15 16:19:46 +02:00
ee280fdaae Feat: Automatische Zahlungsmahnung (Tag 21) + fristlose Kündigung (Tag 35) per Scheduler (§314 BGB) 2026-05-15 16:10:53 +02:00
e714580d77 Feat: Cashflow auf paid_amount, Differenz-Badge, Kulanz-Abschreibung im Bezahlt-Modal (SW by-v984) 2026-05-15 16:06:08 +02:00
0f6b5afd6a Fix: Syntaxfehler in invoices.py — fehlende schließende Klammer bei _s() 2026-05-15 15:56:25 +02:00
d0b0e2c671 Fix: SW-Install-Fehler — PRIORITY_PAGES nicht-blockierend cachen, kein addAll() (SW by-v983) 2026-05-15 15:53:13 +02:00
68fd9c0e38 Fix: En-Dash in PDF durch Bindestrich ersetzen + _s() Sanitizer für alle Texteingaben (SW by-v982) 2026-05-15 15:50:02 +02:00
1a8716b0b2 Perf: Prioritäts-Seiten pre-cache + Stale-While-Revalidate + Background-Warm-up (SW by-v981) 2026-05-15 15:41:55 +02:00
78f3077317 UX: Freischalten zeigt Rechnungsentwurf-Nummer im Toast + Confirm-Hinweis (SW by-v980) 2026-05-15 14:01:45 +02:00
04d8ed153b UX: Neue Rechnung — Hinweis 'nicht für Abos', neutraler Placeholder, passender Notiz-Default (SW by-v979) 2026-05-15 13:56:13 +02:00
49e01683ad Fix: 'Fällig bis' Umlaut korrigiert im PDF-Info-Block 2026-05-15 13:51:26 +02:00
f0f767e466 Fix: iOS Tastatur — modal-body maxHeight begrenzen + manueller scroll statt scrollIntoView (SW by-v978) 2026-05-15 13:43:17 +02:00
ed6dd8da13 Fix: Quartalssumme korrekt (alle inkl. Storno), Netto ausgeblendet (SW by-v977) 2026-05-15 13:38:08 +02:00
6104132714 Feat: Quartalsbericht — Stornozeilen mit Minusbeträgen, nach Datum sortiert, Summen netten sich heraus (SW by-v976) 2026-05-15 13:27:05 +02:00
b10b3140eb Fix: Stornorechnung referenziert Originalnummer + Datum (§14 UStG Pflicht) 2026-05-15 13:18:32 +02:00
cabb2fd6f7 Fix: iOS Modal scrollIntoView bei Tastatur; CSV Stornierte mit 0€ + Stornonummer (SW by-v975) 2026-05-15 13:15:49 +02:00
2bbf3bc3f6 Fix: CSV-Spalten korrigiert — Netto/Brutto getrennt, Zahlungseingang statt Erstellt (SW by-v974) 2026-05-15 12:53:26 +02:00
8f3d639e34 Fix: Stornierte/Entwurfs-Rechnungen aus Quartalsbericht und Cashflow-Summen ausgeschlossen 2026-05-15 12:49:04 +02:00
5fd86dac4b UX: Admin-Panel Desktop — Sidebar-Navigation, 1200px Breite, keine abgeschnittenen Tabs (SW by-v973) 2026-05-15 12:42:04 +02:00
af039efe82 Fix: Date-Header in ausgehenden Mails (formatdate UTC, Container hat keine lokale TZ) 2026-05-15 12:37:41 +02:00
865407d428 Feat: Alle ausgehenden Mails landen im IMAP-Gesendet-Ordner (SMTP + Brevo) 2026-05-15 12:32:52 +02:00
aea5f04bc1 Fix: Von-Gründer-eingeladen → 100% dauerhaft (statt 50%); SW by-v972 2026-05-15 12:25:41 +02:00
810c78ae8b Feat: Referral/Gründer-Rabatt vollständig integriert (SW by-v971) 2026-05-15 12:22:31 +02:00
63a1fc585c Feat: Referral/Gründer-Rabatt in Rechnungssystem integriert (Discount-Badge, Auto-Vorausfüllung) 2026-05-15 12:22:15 +02:00
2163169b73 Feat: Rabattsystem in Rechnungserstellung integriert (Gründer/Referral)
- _get_discount_info() Hilfsfunktion in admin.py (Gründer 100%, Referral-Stufen 20/30/50%, von Gründer eingeladen 50%)
- list_upgrade_requests liefert discount_pct + discount_reason pro User
- GET /admin/users/{user_id}/discount Endpoint
- _handle_upgrade_invoices nutzt Rabatt für amount_net/discount_pct/after_disc + passende Notiz
- scheduler.py _create_renewal_invoice_draft: inline Rabattberechnung + korrekte Beträge
- admin.js: Discount-Badge in Upgrade-Card, data-Attribute am Invoice-Button, _discountNote(), discount_pct + notes im Modal vorbelegt
2026-05-15 12:21:33 +02:00
db4d5cb1b6 Legal: Widerrufs-Checkbox, AGB-Abschnitt, Rechnungsnotiz (SW by-v970) 2026-05-15 12:07:13 +02:00
9a7f100855 Legal: Widerrufs-Checkbox im Upgrade-Modal + AGB-Abschnitt in Datenschutz
- Upgrade-Modal: Checkbox §356 Abs.4 BGB muss aktiv bestätigt werden,
  "Anfrage senden" bleibt bis dahin deaktiviert
- Akzeptanz-Zeitstempel wird mit der upgradeRequest-Message mitgeschickt
- datenschutz.js: neuer Abschnitt "Abonnement & Kündigung" mit Laufzeit,
  Verlängerung, Zahlung, Kündigung, Erstattung und Widerrufsrecht
2026-05-15 12:06:14 +02:00
699926cd76 Fix: Rechnung-Hinweistext auf AGB-konforme Jahresbeitrags-Notiz umgestellt
Alle drei Rechnungs-Einstiegspunkte (Admin-Upgrade-Button, automatische
Verlängerung via Scheduler, manuelles Upgrade via admin.py) erhalten jetzt
den einheitlichen Hinweis zum Jahresbeitrag gem. AGB ohne Rückerstattung.
2026-05-15 12:06:05 +02:00
a9f7923716 Feat: Kündigung blockt Erneuerungsentwurf; Upgrade storniert alte Rechnungen + legt neuen Entwurf an 2026-05-15 12:00:27 +02:00
b1dbde332f Feat: Erneuerungsrechnung-Entwurf 30 Tage vor Abo-Ablauf + 7-Tage-Erinnerung an Admin 2026-05-15 11:53:29 +02:00
96030304d4 Fix: Leistungszeitraum als konkreter Datumszeitraum (Rechnungsdatum bis +12 Monate) 2026-05-15 11:49:48 +02:00
24a1aecda4 Fix: Leistungszeitraum '12 Monate ab Rechnungsdatum' statt festem Datum (SW by-v969) 2026-05-15 11:45:13 +02:00
b14a251bdc Feat: Entwurf bearbeiten (PATCH), erneut senden; SW by-v968 2026-05-15 11:33:48 +02:00
a2d089bce4 Fix: SMTP_SUPPORT_PASS Fallback in mailer, scaninput Logging 2026-05-15 11:29:16 +02:00
41a4808ad5 Fix: Storno sendet PDF+Mail+Scaninput; /scaninput Volume in staging ergänzt 2026-05-15 11:18:48 +02:00
8e36eb0611 Fix: PDF — breiter Header-Balken mit Logo, eine Seite, Hinweis-Prefix, Footer fix 2026-05-15 11:13:28 +02:00
0a466ef6ce Feat: Rechnungsadresse — Profil, Upgrade-Modal Hinweis, Rechnung-erstellen-Button in Upgrade-Cards (SW by-v967) 2026-05-15 10:59:12 +02:00
95b70d5119 Fix: Rechnungs-PDF komplett neu — DIN-5008-Layout, Überlagerung behoben, Bankverbindung, Footer, Deutsche Formatierung 2026-05-15 10:45:49 +02:00
fa513be7f5 Release v1.6.0 — Rechnungs-System (SW by-v966)
- Rechnungserstellung mit fortlaufender Nr. RG-YYYY-NNNN
- PDF-Generierung (fpdf2) mit §14-UStG-Pflichtangaben
- Kleinunternehmer-Modus (§19 UStG) via ENV
- Mail-Versand mit PDF-Anhang (Brevo/SMTP)
- Speicherung in /scaninput + Paperless-ngx REST-API
- Admin-Panel: Invoice-Tab, Pay/Cancel-Modals, Cashflow
- Quartalsbericht als CSV-Download + Mail-Versand
- Action-Items: offene Rechnungen im To-Do-Band
- Stornonummern ST-YYYY-NNNN
- docker-compose: /volume1/scaninput:/scaninput
2026-05-15 10:07:10 +02:00
5dddacff96 Feat: Admin action-items invoices_unpaid, CSV-Download, Quartalsbericht-Versand 2026-05-15 10:06:04 +02:00
77093774f9 Feat: Admin-Rechnungs-Endpoints — invoices_unpaid, CSV-Download, Quartalsbericht-Versand
- action_items(): invoices_unpaid (status='sent') zum Return-Dict hinzugefügt
- GET /api/admin/invoices/quarterly/{year}/{q}/csv — CSV-Download (Semikolon-getrennt, UTF-8-BOM für Excel)
- POST /api/admin/invoices/send-quarterly-report — sendet CSV-Anhang an Steuerberater + Zusammenfassung an René (SMTP_FROM); graceful fallback wenn attachments noch nicht von mailer.py unterstützt
2026-05-15 10:04:46 +02:00
b68a12587a Feature: Rechnungs-System (invoices) — Backend komplett
- DB-Migration: invoices + invoice_items Tabellen inkl. Indizes
- routes/invoices.py: vollständiger Admin-Router (prefix /api/admin/invoices)
  - CRUD: Liste, Detail, Erstellen, Senden, Bezahlen, Stornieren
  - PDF-Generierung via fpdf2 mit §14-UStG-Pflichtangaben (Kleinunternehmer-Hinweis)
  - Cashflow-Übersicht und Quartalsbericht
  - PDF-Download-Endpunkt
  - Speicherung in /scaninput + optionaler Paperless-Upload
- mailer.py: send_email() + Backends um optionale PDF-Anhänge erweitert
  (Brevo: base64, SMTP: MIMEApplication)
- main.py: invoices_router registriert
- docker-compose.yml: /volume1/scaninput:/scaninput Volume hinzugefügt
2026-05-15 10:04:23 +02:00
9c359bb07e Feat: Rechnungs-Management Tab in Admin-Oberfläche
Neuer 'Rechnungen'-Tab mit vollständiger Invoice-Verwaltung:
- Invoice-Liste mit Status-Badges (draft/sent/paid/cancelled) und kontextuellen Aktionen
- Modal: Neue Rechnung erstellen (dynamische Positionen, Live-Vorschau Netto/Brutto)
- Modal: Als bezahlt markieren (Datum + Betrag)
- Modal: Stornieren mit Pflichtgrund
- Modal: Detail-Ansicht mit Positionen-Tabelle
- Cashflow-View: Übersichtskacheln + Monatstabelle + Quartalsbericht-CSV-Download
- Action-Items Badge für offene Rechnungen (invoices_unpaid aus action-items API)
2026-05-15 09:56:42 +02:00
c032b9a3fb Fix: Stats-Kotbeutel → waste_basket Typ in osm_pois (zeigt echte Mülleimer-Zahl) 2026-05-14 22:47:14 +02:00
6495a5ff6b Fix: Stats-Kotbeutel aus osm_pois statt user_map_pois (zeigt 125.750 statt 2) 2026-05-14 22:45:50 +02:00
129badf010 Fix: Hero-Stats zeigt vollständigen Text 'Mülleimer für Kotbeutel' (SW by-v965) 2026-05-14 22:37:29 +02:00
cf6e5920ae Fix: APP_VER-Mismatch (Dauer-Aktualisieren-Bug), Mülleimer-Zahl im Hero (SW by-v964) 2026-05-14 22:15:17 +02:00
58046ce0c7 Fix: Kotbeutel-Stationen → Mülleimer für Kotbeutel in Stats-Band (SW by-v963) 2026-05-14 22:07:17 +02:00
07db68aea2 Fix: Geburtstag aller Hunde + Kotbeutel-Stationen in Stats (SW by-v962)
- worlds.js: bdayDog = _dogs.find(...) — Geburtstag gilt für alle Hunde, nicht nur den aktiven
- Banner, KI-Call, "Was hat sich X gewünscht?" nutzen bdayDog.name
- stats.py: kotbeutel-Count aus user_map_pois WHERE type='kotbeutel'
- landing: Stats-Band 5. Kachel "Kotbeutel-Stationen"
2026-05-14 22:00:52 +02:00
41 changed files with 4626 additions and 476 deletions

View file

@ -2398,6 +2398,65 @@ def _migrate(conn_factory):
except Exception as e:
logger.warning(f"Migration route_dogs fehlgeschlagen: {e}")
# Rechnungs-System
try:
conn.execute("""
CREATE TABLE IF NOT EXISTS invoices (
id INTEGER PRIMARY KEY AUTOINCREMENT,
invoice_number TEXT NOT NULL UNIQUE,
user_id INTEGER REFERENCES users(id),
recipient_name TEXT NOT NULL,
recipient_email TEXT NOT NULL,
recipient_address TEXT,
description TEXT NOT NULL,
service_period TEXT,
amount_net REAL NOT NULL,
discount_pct REAL DEFAULT 0,
discount_amount REAL DEFAULT 0,
amount_after_discount REAL NOT NULL,
tax_rate REAL DEFAULT 0,
tax_amount REAL DEFAULT 0,
amount_gross REAL NOT NULL,
status TEXT DEFAULT 'draft',
notes TEXT,
created_at TEXT DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
sent_at TEXT,
paid_at TEXT,
paid_amount REAL,
cancelled_at TEXT,
cancellation_reason TEXT,
cancellation_number TEXT
)
""")
conn.execute("CREATE INDEX IF NOT EXISTS idx_invoices_status ON invoices(status)")
conn.execute("CREATE INDEX IF NOT EXISTS idx_invoices_user ON invoices(user_id)")
conn.execute("""
CREATE TABLE IF NOT EXISTS invoice_items (
id INTEGER PRIMARY KEY AUTOINCREMENT,
invoice_id INTEGER NOT NULL REFERENCES invoices(id) ON DELETE CASCADE,
description TEXT NOT NULL,
quantity REAL NOT NULL DEFAULT 1,
unit_price REAL NOT NULL,
total REAL NOT NULL
)
""")
logger.info("Migration: invoices + invoice_items bereit.")
except Exception as e:
logger.warning(f"Migration invoices: {e}")
try:
conn.execute("ALTER TABLE users ADD COLUMN billing_address TEXT")
logger.info("Migration: billing_address bereit.")
except Exception:
pass
existing_u_gb = [row[1] for row in conn.execute("PRAGMA table_info(users)").fetchall()]
if 'geburtstag' not in existing_u_gb:
conn.execute("ALTER TABLE users ADD COLUMN geburtstag TEXT")
logger.info("Migration: users.geburtstag hinzugefügt.")
else:
logger.info("Migration: users.geburtstag bereits vorhanden.")
def _seed_help_articles(conn):
"""Befüllt help_articles mit Starter-FAQs — nur wenn die Tabelle noch leer ist."""

View file

@ -5,12 +5,18 @@ Unterstützt zwei Backends (wird automatisch gewählt):
2. SMTP wenn SMTP_HOST gesetzt (Fallback)
"""
import imaplib
import os
import base64
import smtplib
import asyncio
import logging
import ssl
from datetime import datetime
from email.mime.text import MIMEText
from email.mime.multipart import MIMEMultipart
from email.mime.application import MIMEApplication
from email.utils import formatdate
import httpx
@ -24,18 +30,77 @@ BREVO_API_URL = "https://api.brevo.com/v3/smtp/email"
SMTP_HOST = os.getenv("SMTP_HOST", "")
SMTP_PORT = int(os.getenv("SMTP_PORT", "587"))
SMTP_USER = os.getenv("SMTP_USER", "")
SMTP_PASS = os.getenv("SMTP_PASS", "")
SMTP_PASS = os.getenv("SMTP_PASS", "") or os.getenv("SMTP_SUPPORT_PASS", "")
# IMAP für Gesendet-Ordner
IMAP_HOST = os.getenv("IMAP_HOST", SMTP_HOST)
IMAP_PORT = int(os.getenv("IMAP_PORT", "993"))
_SENT_CANDIDATES = ["Sent", "Sent Messages", "Sent Items", "INBOX.Sent", "Gesendete Objekte"]
SMTP_FROM = os.getenv("SMTP_FROM", "Ban Yaro <noreply@banyaro.app>")
APP_URL = os.getenv("APP_URL", "https://banyaro.app")
# ------------------------------------------------------------------
# IMAP: Mail in Gesendet-Ordner speichern
# ------------------------------------------------------------------
def _imap_save_sent(msg_bytes: bytes):
if not IMAP_HOST or not SMTP_USER or not SMTP_PASS:
return
try:
ctx = ssl.create_default_context()
with imaplib.IMAP4_SSL(IMAP_HOST, IMAP_PORT, ssl_context=ctx) as imap:
imap.login(SMTP_USER, SMTP_PASS)
_, raw_folders = imap.list()
available = [f.decode(errors="replace") for f in (raw_folders or [])]
folder = None
for line in available:
name = line.rsplit('"." ', 1)[-1].strip().strip('"')
for candidate in _SENT_CANDIDATES:
if candidate.lower() in name.lower():
folder = name
break
if folder:
break
if not folder:
folder = "INBOX.Sent"
imap.append(
folder,
r"\Seen",
imaplib.Time2Internaldate(datetime.now().timestamp()),
msg_bytes,
)
except Exception as e:
logger.warning(f"IMAP Gesendet-Speicherung fehlgeschlagen: {e}")
def _build_mime_copy(to: str, subject: str, html: str, plain: str, attachments: list | None) -> MIMEMultipart:
"""Baut eine MIME-Nachricht für die Gesendet-Ablage (Brevo-Pfad)."""
if attachments:
msg = MIMEMultipart("mixed")
alt = MIMEMultipart("alternative")
alt.attach(MIMEText(plain, "plain", "utf-8"))
alt.attach(MIMEText(html, "html", "utf-8"))
msg.attach(alt)
for a in attachments:
part = MIMEApplication(a["content"], Name=a["filename"])
part["Content-Disposition"] = f'attachment; filename="{a["filename"]}"'
msg.attach(part)
else:
msg = MIMEMultipart("alternative")
msg.attach(MIMEText(plain, "plain", "utf-8"))
msg.attach(MIMEText(html, "html", "utf-8"))
msg["Subject"] = subject
msg["From"] = SMTP_FROM
msg["To"] = to
msg["Date"] = formatdate(localtime=False)
return msg
# ------------------------------------------------------------------
# Brevo REST-API
# ------------------------------------------------------------------
async def _send_brevo(to: str, subject: str, html: str, plain: str):
# Absender-Name und -Adresse aus SMTP_FROM parsen
# Format: "Ban Yaro <noreply@banyaro.app>" oder "noreply@banyaro.app"
async def _send_brevo(to: str, subject: str, html: str, plain: str, attachments: list | None = None):
from_raw = SMTP_FROM
if "<" in from_raw:
from_name = from_raw[:from_raw.index("<")].strip()
@ -52,6 +117,14 @@ async def _send_brevo(to: str, subject: str, html: str, plain: str):
"textContent": plain,
"headers": {"X-Mailin-Track-Click": "0", "X-Mailin-Track-Opens": "0"},
}
if attachments:
payload["attachment"] = [
{
"name": a["filename"],
"content": base64.b64encode(a["content"]).decode("ascii"),
}
for a in attachments
]
async with httpx.AsyncClient(timeout=15) as client:
resp = await client.post(
BREVO_API_URL,
@ -64,30 +137,50 @@ async def _send_brevo(to: str, subject: str, html: str, plain: str):
# ------------------------------------------------------------------
# SMTP Fallback
# ------------------------------------------------------------------
def _send_smtp_sync(to: str, subject: str, html: str, plain: str):
def _send_smtp_sync(to: str, subject: str, html: str, plain: str, attachments: list | None = None):
if attachments:
msg = MIMEMultipart("mixed")
alt = MIMEMultipart("alternative")
alt.attach(MIMEText(plain, "plain", "utf-8"))
alt.attach(MIMEText(html, "html", "utf-8"))
msg.attach(alt)
for a in attachments:
part = MIMEApplication(a["content"], Name=a["filename"])
part["Content-Disposition"] = f'attachment; filename="{a["filename"]}"'
msg.attach(part)
else:
msg = MIMEMultipart("alternative")
msg["Subject"] = subject
msg["From"] = SMTP_FROM
msg["To"] = to
msg.attach(MIMEText(plain, "plain", "utf-8"))
msg.attach(MIMEText(html, "html", "utf-8"))
msg["Subject"] = subject
msg["From"] = SMTP_FROM
msg["To"] = to
msg["Date"] = formatdate(localtime=False)
msg_bytes = msg.as_bytes()
with smtplib.SMTP(SMTP_HOST, SMTP_PORT, timeout=10) as s:
s.ehlo()
s.starttls()
if SMTP_USER:
s.login(SMTP_USER, SMTP_PASS)
s.sendmail(SMTP_FROM, [to], msg.as_string())
s.sendmail(SMTP_FROM, [to], msg_bytes)
_imap_save_sent(msg_bytes)
# ------------------------------------------------------------------
# Öffentliche Funktion
# ------------------------------------------------------------------
async def send_email(to: str, subject: str, html: str, plain: str = ""):
async def send_email(to: str, subject: str, html: str, plain: str = "", attachments: list | None = None):
if BREVO_API_KEY:
try:
await _send_brevo(to, subject, html, plain)
await _send_brevo(to, subject, html, plain, attachments)
logger.info(f"Mail via Brevo gesendet: «{subject}» → {to}")
# MIME-Kopie für Gesendet-Ordner konstruieren
loop = asyncio.get_event_loop()
await loop.run_in_executor(None, lambda: _imap_save_sent(
_build_mime_copy(to, subject, html, plain, attachments).as_bytes()
))
return
except Exception as e:
logger.error(f"Brevo-Fehler: {e}")
@ -96,7 +189,9 @@ async def send_email(to: str, subject: str, html: str, plain: str = ""):
if SMTP_HOST:
loop = asyncio.get_event_loop()
try:
await loop.run_in_executor(None, _send_smtp_sync, to, subject, html, plain)
await loop.run_in_executor(
None, _send_smtp_sync, to, subject, html, plain, attachments
)
logger.info(f"Mail via SMTP gesendet: «{subject}» → {to}")
return
except Exception as e:

View file

@ -253,6 +253,8 @@ from routes.challenges import router as challenges_router
from routes.gassi_zeiten import router as gassi_zeiten_router
from routes.help import router as help_router
from routes.feedback import router as feedback_router
from routes.contact import router as contact_router
from routes.invoices import router as invoices_router
app.include_router(auth_router, prefix="/api/auth", tags=["Auth"])
app.include_router(dogs_router, prefix="/api/dogs", tags=["Hunde"])
@ -317,6 +319,8 @@ app.include_router(challenges_router, prefix="/api/challenges", ta
app.include_router(gassi_zeiten_router, prefix="/api/gassi-zeiten", tags=["Gassi-Zeiten"])
app.include_router(help_router, prefix="/api/help", tags=["Hilfe/FAQ"])
app.include_router(feedback_router, prefix="/api/feedback", tags=["Feedback"])
app.include_router(contact_router, prefix="/api/contact", tags=["Kontakt"])
app.include_router(invoices_router)
# ------------------------------------------------------------------
@ -406,7 +410,7 @@ async def serve_media(path: str, request: _Request):
raise _HE(404, "Nicht gefunden.")
return _media_response(filepath)
APP_VER = "961" # muss mit APP_VER in app.js übereinstimmen
APP_VER = "1070" # muss mit APP_VER in app.js übereinstimmen
@app.get("/.well-known/assetlinks.json")
async def assetlinks():
@ -1720,8 +1724,8 @@ async def force_update():
height:100vh;margin:0;background:#0f1623;color:#fff;flex-direction:column;gap:16px}
p{color:#94a3b8;font-size:14px}</style></head>
<body>
<div> Aktualisiere Ban Yaro</div>
<p id="s">Service Worker wird entfernt</p>
<div> Einen Moment</div>
<p id="s">Wir besorgen neue Leckerlis 🦴</p>
<script>
// Zweiten Reload durch SW-updatefound verhindern
sessionStorage.setItem('by_skip_sw_reload','1');

View file

@ -1,5 +1,8 @@
"""BAN YARO — Admin / Moderator Backend"""
import asyncio
import csv
import io
import logging
import os
import sys
import time
@ -8,11 +11,14 @@ from datetime import datetime
from zoneinfo import ZoneInfo
from fastapi import APIRouter, Depends, HTTPException
from fastapi.responses import Response
from pydantic import BaseModel
from typing import Optional
from typing import Optional, List
from database import db, DB_PATH
from auth import get_current_user
logger = logging.getLogger(__name__)
router = APIRouter()
_TZ = ZoneInfo("Europe/Berlin")
@ -83,6 +89,11 @@ def require_admin(user=Depends(get_current_user)):
# ------------------------------------------------------------------
_VALID_TIERS = {"standard", "pro", "breeder", "standard_test", "pro_test", "breeder_test"}
class QuarterlyReportBody(BaseModel):
year: int
quarter: int
email: str
class UserPatch(BaseModel):
rolle: Optional[str] = None # user | moderator | admin
is_moderator: Optional[int] = None
@ -130,6 +141,12 @@ async def action_items(user=Depends(require_mod)):
).fetchone()[0]
except Exception:
upgrades_pending = 0
try:
invoices_unpaid = conn.execute(
"SELECT COUNT(*) FROM invoices WHERE status='sent'"
).fetchone()[0]
except Exception:
invoices_unpaid = 0
return {
"jobs_pending": jobs,
"breeder_pending": breeders,
@ -138,6 +155,7 @@ async def action_items(user=Depends(require_mod)):
"poi_edits_pending": poi_edits,
"users_today": users_today,
"upgrades_pending": upgrades_pending,
"invoices_unpaid": invoices_unpaid,
}
@ -1119,20 +1137,35 @@ async def list_upgrade_requests(user=Depends(require_admin)):
with db() as conn:
rows = conn.execute("""
SELECT r.id, r.user_id, r.tier, r.message, r.created_at, r.fulfilled_at,
u.name, u.email
u.name, u.email, u.billing_address,
u.is_founder, u.is_founder_pending, u.referred_by,
COALESCE((SELECT COUNT(*) FROM users WHERE referred_by=u.id), 0) AS referral_count
FROM upgrade_requests r
JOIN users u ON u.id = r.user_id
ORDER BY r.fulfilled_at IS NOT NULL, r.created_at DESC
LIMIT 100
""").fetchall()
return [dict(r) for r in rows]
result = []
for r in rows:
d = dict(r)
d_info = _get_discount_info(conn, r["user_id"])
d["discount_pct"] = d_info["discount_pct"]
d["discount_reason"] = d_info["reason"]
result.append(d)
return result
@router.get("/users/{user_id}/discount")
def get_user_discount(user_id: int, admin=Depends(require_admin)):
with db() as conn:
return _get_discount_info(conn, user_id)
@router.post("/upgrade-requests/{req_id}/fulfill")
async def fulfill_upgrade_request(req_id: int, user=Depends(require_admin)):
with db() as conn:
req = conn.execute(
"SELECT r.*, u.name, u.email FROM upgrade_requests r JOIN users u ON u.id=r.user_id WHERE r.id=?",
"SELECT r.*, u.name, u.email, u.subscription_tier AS old_tier FROM upgrade_requests r JOIN users u ON u.id=r.user_id WHERE r.id=?",
(req_id,)
).fetchone()
if not req:
@ -1241,4 +1274,296 @@ async def fulfill_upgrade_request(req_id: int, user=Depends(require_admin)):
import logging
logging.getLogger(__name__).warning(f"Bestätigungsmail fehlgeschlagen: {e}")
return {"ok": True, "tier": req["tier"], "user": req["name"]}
# Offene Rechnungen (sent/draft) des alten Tiers stornieren + neuen Entwurf anlegen
inv_number = None
try:
inv_number = await _handle_upgrade_invoices(req, tier_label)
except Exception as e:
logger.warning(f"Upgrade-Rechnungslogik fehlgeschlagen für {req['name']}: {e}")
return {"ok": True, "tier": req["tier"], "user": req["name"], "invoice_number": inv_number}
def _get_discount_info(conn, user_id: int) -> dict:
"""Berechnet Rabatt für einen User basierend auf Gründer-Status und Referrals."""
row = conn.execute(
"""SELECT u.is_founder, u.is_founder_pending, u.referred_by,
COALESCE((SELECT COUNT(*) FROM users WHERE referred_by=u.id), 0) AS referral_count
FROM users u WHERE u.id=?""",
(user_id,)
).fetchone()
if not row:
return {"discount_pct": 0, "reason": None, "referral_count": 0}
if row["is_founder"] or row["is_founder_pending"]:
return {"discount_pct": 100, "reason": "founder", "referral_count": row["referral_count"]}
referred_by = row["referred_by"] or 0
if referred_by > 0:
referrer = conn.execute(
"SELECT is_founder, is_founder_pending FROM users WHERE id=?", (referred_by,)
).fetchone()
if referrer and (referrer["is_founder"] or referrer["is_founder_pending"]):
return {"discount_pct": 100, "reason": "referred_by_founder", "referral_count": row["referral_count"]}
count = row["referral_count"]
for threshold, pct in [(50, 50), (20, 30), (10, 20)]:
if count >= threshold:
return {"discount_pct": pct, "reason": "referral", "referral_count": count}
return {"discount_pct": 0, "reason": None, "referral_count": count}
async def _handle_upgrade_invoices(req: dict, new_tier_label: str):
"""Storniert offene Rechnungen des alten Tiers und legt neuen Entwurf an."""
from routes.invoices import _next_invoice_number
from datetime import timedelta
with db() as conn:
# Offene Rechnungen (draft + sent) dieses Users finden
open_invoices = conn.execute(
"SELECT * FROM invoices WHERE user_id=? AND status IN ('draft','sent')",
(req["user_id"],)
).fetchall()
for inv in open_invoices:
cancel_num = _next_invoice_number(conn, "ST")
conn.execute(
"""UPDATE invoices SET status='cancelled', cancelled_at=strftime('%Y-%m-%dT%H:%M:%SZ','now'),
cancellation_reason=?, cancellation_number=? WHERE id=?""",
(f"Tarif-Upgrade auf {new_tier_label}", cancel_num, inv["id"])
)
logger.info(f"Rechnung {inv['invoice_number']} storniert ({cancel_num}) — Upgrade auf {new_tier_label}")
# Neuen Entwurf für den neuen Tier anlegen
tier = req["tier"]
price = {"pro": 29.00, "breeder": 49.00}.get(tier, 29.00)
today = datetime.now(_TZ).date()
end_date = today.replace(year=today.year + 1) - timedelta(days=1)
period = f"{today.strftime('%d.%m.%Y')} - {end_date.strftime('%d.%m.%Y')}"
description = f"{new_tier_label} Jahresabo"
billing = conn.execute(
"SELECT billing_address FROM users WHERE id=?", (req["user_id"],)
).fetchone()
billing_address = billing["billing_address"] if billing else None
disc_info = _get_discount_info(conn, req["user_id"])
discount_pct = disc_info["discount_pct"]
discount_amt = round(price * discount_pct / 100, 2)
after_disc = round(price - discount_amt, 2)
_AGB = "Jahresbeitrag gem. AGB. Bei vorzeitiger Kündigung keine anteilige Rückerstattung; Zugang bleibt bis Laufzeitende bestehen."
if disc_info["reason"] == "founder":
note = f"Gründer-Sonderkonditionen: {new_tier_label} kostenfrei als Dankeschön für deine Unterstützung als Gründer! {_AGB}"
elif disc_info["reason"] == "referred_by_founder":
note = f"Willkommen in der Gründer-Community! Als persönlich von einem Gründer eingeladenes Mitglied erhältst du dauerhaft {discount_pct}% Rabatt. {_AGB}"
elif disc_info["reason"] == "referral":
note = f"Herzlichen Dank für deine Unterstützung! Für {disc_info['referral_count']} geworbene Freunde erhältst du {discount_pct}% Rabatt. {_AGB}"
else:
note = f"{_AGB} (Upgrade von {req.get('old_tier','Standard')} auf {new_tier_label})"
inv_number = _next_invoice_number(conn)
conn.execute("""
INSERT INTO invoices
(invoice_number, user_id, recipient_name, recipient_email, recipient_address,
description, service_period, amount_net, discount_pct, discount_amount,
amount_after_discount, tax_rate, tax_amount, amount_gross, notes)
VALUES (?,?,?,?,?,?,?,?,?,?,?,0,0,?,?)
""", (
inv_number, req["user_id"], req["name"], req["email"], billing_address,
description, period, price, discount_pct, discount_amt, after_disc, after_disc, note,
))
invoice_id = conn.execute("SELECT last_insert_rowid()").fetchone()[0]
conn.execute(
"INSERT INTO invoice_items (invoice_id, description, quantity, unit_price, total) VALUES (?,?,1,?,?)",
(invoice_id, description, price, price)
)
logger.info(f"Neuer Rechnungsentwurf {inv_number} für {req['email']} nach Upgrade auf {new_tier_label}")
return inv_number
# ------------------------------------------------------------------
# Helpers: Quartalsdaten
# ------------------------------------------------------------------
def _quarter_bounds(year: int, q: int):
"""Gibt (start_date, end_date) als ISO-Strings zurück (YYYY-MM-DD)."""
if q not in (1, 2, 3, 4):
raise HTTPException(400, "Quartal muss 14 sein.")
month_start = (q - 1) * 3 + 1
month_end = month_start + 2
# Letzter Tag des Endmonats
import calendar
last_day = calendar.monthrange(year, month_end)[1]
return (
f"{year:04d}-{month_start:02d}-01",
f"{year:04d}-{month_end:02d}-{last_day:02d}",
)
def _fetch_quarter_invoices(conn, year: int, q: int):
"""Liest alle bezahlten/gesendeten Rechnungen des Quartals."""
start, end = _quarter_bounds(year, q)
rows = conn.execute("""
SELECT invoice_number, created_at, recipient_name, recipient_email,
amount_net, tax_amount, amount_gross,
status, paid_at, paid_amount
FROM invoices
WHERE status IN ('paid', 'sent')
AND DATE(created_at) BETWEEN ? AND ?
ORDER BY created_at ASC
""", (start, end)).fetchall()
return rows, start, end
def _build_csv(rows) -> bytes:
"""Erstellt CSV-Bytes aus den Rechnungszeilen."""
buf = io.StringIO()
writer = csv.writer(buf, delimiter=";", quoting=csv.QUOTE_MINIMAL)
writer.writerow([
"Rechnungsnummer", "Datum", "Empfänger", "E-Mail",
"Nettobetrag", "Steuer", "Bruttobetrag",
"Status", "Bezahlt-am", "Gezahlter-Betrag",
])
for r in rows:
# Datum auf YYYY-MM-DD kürzen
datum = (r["created_at"] or "")[:10]
paid_at = (r["paid_at"] or "")[:10] if r["paid_at"] else ""
writer.writerow([
r["invoice_number"],
datum,
r["recipient_name"],
r["recipient_email"],
f"{r['amount_net']:.2f}".replace(".", ","),
f"{r['tax_amount']:.2f}".replace(".", ","),
f"{r['amount_gross']:.2f}".replace(".", ","),
r["status"],
paid_at,
f"{r['paid_amount']:.2f}".replace(".", ",") if r["paid_amount"] is not None else "",
])
return buf.getvalue().encode("utf-8-sig") # BOM für Excel-Kompatibilität
# ------------------------------------------------------------------
# GET /api/admin/invoices/quarterly/{year}/{q}/csv
# ------------------------------------------------------------------
@router.get("/invoices/quarterly/{year}/{q}/csv")
async def invoice_quarterly_csv(year: int, q: int, user=Depends(require_admin)):
"""CSV-Download aller Rechnungen eines Quartals (paid + sent)."""
with db() as conn:
rows, start, end = _fetch_quarter_invoices(conn, year, q)
csv_bytes = _build_csv(rows)
filename = f"rechnungen_{year}_Q{q}.csv"
logger.info(f"CSV-Download Q{q}/{year}: {len(rows)} Rechnungen → {filename}")
return Response(
content=csv_bytes,
media_type="text/csv; charset=utf-8",
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
)
# ------------------------------------------------------------------
# POST /api/admin/invoices/send-quarterly-report
# ------------------------------------------------------------------
@router.post("/invoices/send-quarterly-report")
async def send_quarterly_report(data: QuarterlyReportBody, user=Depends(require_admin)):
"""Sendet Quartalsbericht als CSV-Anhang an Steuerberater + Zusammenfassung an René."""
if data.quarter not in (1, 2, 3, 4):
raise HTTPException(400, "Quartal muss 14 sein.")
with db() as conn:
rows, start, end = _fetch_quarter_invoices(conn, data.year, data.quarter)
csv_bytes = _build_csv(rows)
filename = f"rechnungen_{data.year}_Q{data.quarter}.csv"
# Zusammenfassungs-Zahlen
total_net = sum(r["amount_net"] for r in rows)
total_tax = sum(r["tax_amount"] for r in rows)
total_gross = sum(r["amount_gross"] for r in rows)
count_paid = sum(1 for r in rows if r["status"] == "paid")
count_sent = sum(1 for r in rows if r["status"] == "sent")
subject_stb = (
f"Ban Yaro - Rechnungen Q{data.quarter}/{data.year} "
f"({start} bis {end})"
)
body_stb = (
f"Hallo,\n\n"
f"anbei die Rechnungsübersicht für Q{data.quarter}/{data.year} "
f"({start} bis {end}).\n\n"
f"Anzahl Rechnungen: {len(rows)}\n"
f" davon bezahlt: {count_paid}\n"
f" davon ausstehend: {count_sent}\n\n"
f"Summe Netto: {total_net:>10.2f} EUR\n"
f"Summe Steuer: {total_tax:>10.2f} EUR\n"
f"Summe Brutto: {total_gross:>10.2f} EUR\n\n"
f"Die vollständige Liste finden Sie als CSV-Anhang.\n\n"
f"Viele Grüße\nRené Nitzsche / Ban Yaro"
)
from mailer import send_email, SMTP_FROM
# Steuerberater-Mail (mit CSV-Anhang wenn unterstützt)
try:
await send_email(
data.email,
subject_stb,
f"<pre style='font-family:monospace'>{body_stb}</pre>",
body_stb,
attachments=[{
"filename": filename,
"content": csv_bytes,
"content_type": "text/csv",
}],
)
logger.info(f"Quartalsbericht Q{data.quarter}/{data.year}{data.email} (mit Anhang)")
except TypeError:
# send_email unterstützt noch kein attachments-Argument → ohne Anhang senden
await send_email(
data.email,
subject_stb,
f"<pre style='font-family:monospace'>{body_stb}</pre>",
body_stb,
)
logger.warning(f"Quartalsbericht Q{data.quarter}/{data.year}{data.email} (OHNE Anhang, attachments nicht unterstützt)")
# Zusammenfassung an René (SMTP_FROM-Adresse)
# Reine E-Mail-Adresse aus "Name <addr>" extrahieren
from_addr = SMTP_FROM
if "<" in from_addr:
from_addr = from_addr[from_addr.index("<") + 1 : from_addr.index(">")].strip()
subject_rene = f"[Ban Yaro Admin] Quartalsbericht Q{data.quarter}/{data.year} versendet"
body_rene = (
f"Der Quartalsbericht Q{data.quarter}/{data.year} wurde an {data.email} gesendet.\n\n"
f"Zeitraum: {start} bis {end}\n"
f"Rechnungen gesamt: {len(rows)} (bezahlt: {count_paid}, ausstehend: {count_sent})\n\n"
f"Netto: {total_net:>10.2f} EUR\n"
f"Steuer: {total_tax:>10.2f} EUR\n"
f"Brutto: {total_gross:>10.2f} EUR\n"
)
try:
await send_email(
from_addr,
subject_rene,
f"<pre style='font-family:monospace'>{body_rene}</pre>",
body_rene,
)
except Exception as e:
logger.warning(f"Zusammenfassungs-Mail an René fehlgeschlagen: {e}")
return {
"ok": True,
"sent_to": data.email,
"year": data.year,
"quarter": data.quarter,
"period": f"{start} - {end}",
"count": len(rows),
"count_paid": count_paid,
"count_sent": count_sent,
"total_net": round(total_net, 2),
"total_tax": round(total_tax, 2),
"total_gross": round(total_gross, 2),
}

View file

@ -241,7 +241,8 @@ async def me(user=Depends(get_current_user)):
is_founder, is_partner, founder_number, is_founder_pending,
notes_ki_enabled, gassi_stunde_push,
preferred_theme, subscription_tier,
subscription_expires_at, subscription_cancelled_at, needs_dog_selection
subscription_expires_at, subscription_cancelled_at, needs_dog_selection,
billing_address, geburtstag
FROM users WHERE id=?""",
(user["id"],)
).fetchone()

52
backend/routes/contact.py Normal file
View file

@ -0,0 +1,52 @@
"""
BAN YARO Öffentliches Kontaktformular (kein Login erforderlich)
Für Impressum-Kontaktpflicht nach § 5 DDG.
"""
from fastapi import APIRouter, Request
from pydantic import BaseModel, EmailStr, Field
from typing import Annotated
from mailer import send_email, email_html
from ratelimit import check as rl_check
router = APIRouter()
CONTACT_MAIL = "hallo@banyaro.app"
class ContactIn(BaseModel):
name: Annotated[str, Field(min_length=2, max_length=100)]
email: EmailStr
subject: Annotated[str, Field(min_length=3, max_length=150)]
message: Annotated[str, Field(min_length=10, max_length=3000)]
@router.post("")
async def submit_contact(payload: ContactIn, request: Request):
rl_check(request, max_requests=3, window_seconds=3600, key=f"contact_{payload.email}")
body = f"""
<p style="margin:0 0 16px">Neue Kontaktanfrage über das Impressum-Formular:</p>
<table style="width:100%;border-collapse:collapse;font-size:13px;margin-bottom:20px">
<tr><td style="padding:6px 10px;background:#f5f0ea;font-weight:600;width:100px">Name</td>
<td style="padding:6px 10px;border-bottom:1px solid #eee">{payload.name}</td></tr>
<tr><td style="padding:6px 10px;background:#f5f0ea;font-weight:600">E-Mail</td>
<td style="padding:6px 10px;border-bottom:1px solid #eee">{payload.email}</td></tr>
<tr><td style="padding:6px 10px;background:#f5f0ea;font-weight:600">Betreff</td>
<td style="padding:6px 10px;border-bottom:1px solid #eee">{payload.subject}</td></tr>
</table>
<div style="background:#fdf6ef;border-left:4px solid #C4843A;padding:14px 16px;
border-radius:0 8px 8px 0;white-space:pre-wrap;font-size:14px;line-height:1.6">
{payload.message}
</div>"""
plain = f"Kontakt von {payload.name} ({payload.email})\nBetreff: {payload.subject}\n\n{payload.message}"
await send_email(
CONTACT_MAIL,
f"Kontakt: {payload.subject}{payload.name}",
email_html(body),
plain,
)
return {"ok": True}

819
backend/routes/invoices.py Normal file
View file

@ -0,0 +1,819 @@
"""BAN YARO — Rechnungs-System (Admin)"""
import os
import logging
from datetime import datetime
from typing import Optional, List
from fastapi import APIRouter, Depends, HTTPException
from fastapi.responses import Response
from pydantic import BaseModel
from database import db
from auth import require_admin
import mailer
router = APIRouter(prefix="/api/admin/invoices", tags=["invoices"])
logger = logging.getLogger(__name__)
# ------------------------------------------------------------------
# Schemas
# ------------------------------------------------------------------
class InvoiceItem(BaseModel):
description: str
quantity: float = 1.0
unit_price: float
class InvoiceCreate(BaseModel):
user_id: Optional[int] = None
recipient_name: str
recipient_email: str
recipient_address: Optional[str] = None
items: List[InvoiceItem]
discount_pct: Optional[float] = 0.0
service_period: Optional[str] = None
notes: Optional[str] = None
class PayBody(BaseModel):
paid_at: str
paid_amount: float
notes: Optional[str] = None
class CancelBody(BaseModel):
reason: str
# ------------------------------------------------------------------
# Hilfsfunktionen
# ------------------------------------------------------------------
def _next_invoice_number(conn, prefix="RG"):
year = datetime.now().year
last = conn.execute(
"SELECT invoice_number FROM invoices WHERE invoice_number LIKE ? ORDER BY id DESC LIMIT 1",
(f"{prefix}-{year}-%",)
).fetchone()
if last:
n = int(last[0].split("-")[-1]) + 1
else:
n = 1
return f"{prefix}-{year}-{n:04d}"
def _generate_pdf(invoice, items) -> bytes:
from fpdf import FPDF
from datetime import datetime, timedelta
KLEINUNTERNEHMER = os.getenv("KLEINUNTERNEHMER", "true").lower() == "true"
STEUERNUMMER = os.getenv("STEUERNUMMER", "")
INHABER = os.getenv("RECHNUNG_INHABER", "Rene Degelmann")
FIRMA = os.getenv("RECHNUNG_GESCHAEFTSNAME", "Ban Yaro")
STRASSE = os.getenv("RECHNUNG_STRASSE", "")
PLZ_ORT = os.getenv("RECHNUNG_PLZ_ORT", "")
EMAIL = os.getenv("RECHNUNG_EMAIL", "hallo@banyaro.app")
WEBSITE = os.getenv("RECHNUNG_WEBSITE", "banyaro.app")
IBAN = os.getenv("RECHNUNG_IBAN", "")
BIC = os.getenv("RECHNUNG_BIC", "")
BANKNAME = os.getenv("RECHNUNG_BANK", "")
OR = (230, 126, 34)
DK = (30, 30, 30)
GY = (130, 130, 130)
LG = (245, 245, 245)
WH = (255, 255, 255)
def _s(text) -> str:
"""Nicht-Latin1-Zeichen ersetzen bevor sie an fpdf Helvetica übergeben werden."""
if not text:
return ""
return (str(text)
.replace("", "-").replace("", "-") # En/Em-Dash
.replace("", "'").replace("", "'") # Typogr. Anf.zeichen
.replace("", '"').replace("", '"')
.replace("", "...").replace("·", ".")
.replace("", "EUR") # € falls doch
)
def eur(v: float) -> str:
s = f"{v:,.2f}".replace(",", "X").replace(".", ",").replace("X", ".")
return f"{s} EUR"
def fdate(iso: str) -> str:
try:
y, m, d = (iso or "")[:10].split("-")
return f"{d}.{m}.{y}"
except Exception:
return (iso or "")[:10]
try:
due_date = (datetime.fromisoformat(invoice["created_at"][:10]) + timedelta(days=14)).strftime("%d.%m.%Y")
except Exception:
due_date = ""
icon_path = os.path.join(os.path.dirname(__file__), "..", "static", "icons", "icon-192.png")
icon_path = os.path.abspath(icon_path)
pdf = FPDF()
pdf.add_page()
pdf.set_margins(20, 0, 20)
pdf.set_auto_page_break(auto=True, margin=22)
W = 170
# ── Header-Balken (volle Breite, 16mm) ───────────────────────
pdf.set_fill_color(*OR)
pdf.rect(0, 0, 210, 16, "F")
# App-Icon links im Balken
if os.path.exists(icon_path):
pdf.image(icon_path, x=18, y=1, w=14, h=14)
# "Ban Yaro" in Weiss rechts im Balken
pdf.set_xy(20, 1)
pdf.set_font("Helvetica", "B", 20)
pdf.set_text_color(*WH)
pdf.cell(W, 14, FIRMA, align="R")
# ── Absenderadresse rechts (unterhalb Balken) ─────────────────
pdf.set_font("Helvetica", "", 8)
pdf.set_text_color(*GY)
y_addr = 19
for line in filter(None, [INHABER, STRASSE, PLZ_ORT, EMAIL, WEBSITE]):
pdf.set_xy(20, y_addr)
pdf.cell(W, 4, line, align="R")
y_addr += 4.2
# ── Absenderzeile + Trennstrich (DIN 5008) ────────────────────
sender_ref = " · ".join(filter(None, [FIRMA, INHABER, STRASSE, PLZ_ORT]))
pdf.set_xy(20, 46)
pdf.set_font("Helvetica", "", 6.5)
pdf.set_text_color(*GY)
pdf.cell(85, 3.5, sender_ref)
pdf.set_draw_color(*GY)
pdf.set_line_width(0.15)
pdf.line(20, 50, 105, 50)
# ── Empfänger links ───────────────────────────────────────────
pdf.set_xy(20, 52)
pdf.set_font("Helvetica", "B", 10)
pdf.set_text_color(*DK)
pdf.cell(85, 5.5, _s(invoice["recipient_name"]), new_x="LMARGIN", new_y="NEXT")
pdf.set_font("Helvetica", "", 10)
if invoice.get("recipient_address"):
for line in str(invoice["recipient_address"]).split("\n"):
if line.strip():
pdf.set_x(20)
pdf.cell(85, 5, _s(line.strip()), new_x="LMARGIN", new_y="NEXT")
pdf.set_x(20)
pdf.set_font("Helvetica", "", 8.5)
pdf.set_text_color(*GY)
pdf.cell(85, 5, _s(invoice["recipient_email"]))
# ── Info-Block rechts ─────────────────────────────────────────
info_rows = [
("Rechnungsnummer", invoice["invoice_number"]),
("Datum", fdate(invoice.get("created_at", ""))),
("Fällig bis", due_date),
]
if invoice.get("service_period"):
info_rows.append(("Leistungszeitraum", _s(invoice["service_period"])))
y_info = 52
for lbl, val in info_rows:
pdf.set_xy(110, y_info)
pdf.set_font("Helvetica", "", 8.5)
pdf.set_text_color(*GY)
pdf.cell(35, 5.5, lbl + ":")
pdf.set_font("Helvetica", "B", 8.5)
pdf.set_text_color(*DK)
pdf.cell(25, 5.5, val)
y_info += 6
# ── Betreff ───────────────────────────────────────────────────
pdf.set_xy(20, 90)
pdf.set_font("Helvetica", "B", 13)
pdf.set_text_color(*DK)
pdf.cell(W, 7, f"Rechnung {invoice['invoice_number']}", new_x="LMARGIN", new_y="NEXT")
pdf.set_draw_color(*OR)
pdf.set_line_width(0.6)
pdf.line(20, pdf.get_y(), 190, pdf.get_y())
pdf.ln(4)
# ── Positionen-Tabelle ────────────────────────────────────────
CW = (90, 18, 32, 30)
pdf.set_fill_color(*OR)
pdf.set_text_color(*WH)
pdf.set_font("Helvetica", "B", 9)
pdf.cell(CW[0], 7, " Beschreibung", fill=True)
pdf.cell(CW[1], 7, "Menge", fill=True, align="C")
pdf.cell(CW[2], 7, "Einzelpreis", fill=True, align="R")
pdf.cell(CW[3], 7, "Gesamt", fill=True, align="R", new_x="LMARGIN", new_y="NEXT")
pdf.set_text_color(*DK)
pdf.set_font("Helvetica", "", 9)
pdf.set_line_width(0.2)
pdf.set_draw_color(200, 200, 200)
for i, item in enumerate(items):
pdf.set_fill_color(*(LG if i % 2 == 0 else WH))
qty = f"{item['quantity']:.2f}".rstrip("0").rstrip(".")
pdf.cell(CW[0], 7, f" {_s(str(item['description']))[:64]}", border="B", fill=True)
pdf.cell(CW[1], 7, qty, border="B", fill=True, align="C")
pdf.cell(CW[2], 7, eur(item["unit_price"]), border="B", fill=True, align="R")
pdf.cell(CW[3], 7, eur(item["total"]), border="B", fill=True, align="R",
new_x="LMARGIN", new_y="NEXT")
pdf.ln(4)
# ── Summenblock ───────────────────────────────────────────────
def srow(lbl, val, bold=False, txt_color=None, bg=None):
pdf.set_x(110)
pdf.set_fill_color(*(bg or WH))
pdf.set_text_color(*(txt_color or DK))
pdf.set_font("Helvetica", "B" if bold else "", 10 if bold else 9)
pdf.cell(50, 6, lbl, align="R", fill=bool(bg))
pdf.cell(30, 6, val, align="R", fill=bool(bg), new_x="LMARGIN", new_y="NEXT")
srow("Nettobetrag:", eur(invoice["amount_net"]))
if invoice.get("discount_pct") and invoice["discount_pct"] > 0:
srow(f"Rabatt ({invoice['discount_pct']:.0f}%):", f"- {eur(invoice['discount_amount'])}", txt_color=OR)
srow("Nach Rabatt:", eur(invoice["amount_after_discount"]))
if not KLEINUNTERNEHMER and invoice.get("tax_rate", 0) > 0:
srow(f"MwSt. {invoice['tax_rate']:.0f}%:", eur(invoice["tax_amount"]))
pdf.set_draw_color(*OR)
pdf.set_line_width(0.5)
pdf.line(110, pdf.get_y(), 190, pdf.get_y())
pdf.ln(1)
srow("Gesamtbetrag:", eur(invoice["amount_gross"]), bold=True, bg=LG)
pdf.ln(3)
# ── §19-Hinweis ───────────────────────────────────────────────
if KLEINUNTERNEHMER:
pdf.set_x(20)
pdf.set_font("Helvetica", "I", 8.5)
pdf.set_text_color(*GY)
pdf.multi_cell(W, 5, _s("Hinweis: Gem. § 19 UStG wird keine Umsatzsteuer berechnet."))
# ── Zahlungsinfo-Box ──────────────────────────────────────────
pdf.ln(5)
y_box = pdf.get_y()
pdf.set_x(24)
pdf.set_font("Helvetica", "B", 9)
pdf.set_text_color(*OR)
pdf.cell(W - 4, 6, "Zahlungsinformationen", new_x="LMARGIN", new_y="NEXT")
pdf.set_font("Helvetica", "", 9)
pdf.set_text_color(*DK)
pay_rows = []
if due_date: pay_rows.append(("Zahlbar bis:", due_date))
if IBAN: pay_rows.append(("IBAN:", IBAN))
if BIC: pay_rows.append(("BIC:", BIC))
if BANKNAME: pay_rows.append(("Bank:", BANKNAME))
pay_rows.append( ("Verwendungszweck:", invoice["invoice_number"]))
for lbl, val in pay_rows:
pdf.set_x(24)
pdf.set_font("Helvetica", "", 9)
pdf.cell(45, 5.5, lbl)
pdf.set_font("Helvetica", "" if lbl == "Verwendungszweck:" else "B", 9)
pdf.cell(0, 5.5, val, new_x="LMARGIN", new_y="NEXT")
pdf.set_fill_color(*OR)
pdf.rect(20, y_box, 2, pdf.get_y() - y_box + 1, "F")
# ── Notizen ───────────────────────────────────────────────────
if invoice.get("notes"):
pdf.ln(4)
pdf.set_x(20)
pdf.set_font("Helvetica", "I", 9)
pdf.set_text_color(*GY)
pdf.multi_cell(W, 5, _s(str(invoice["notes"])))
# ── Footer (fixiert auf Seite 1, kein auto-break) ─────────────
pdf.set_auto_page_break(False)
pdf.set_y(277)
pdf.set_draw_color(*OR)
pdf.set_line_width(0.4)
pdf.line(20, pdf.get_y(), 190, pdf.get_y())
pdf.ln(1.5)
footer_parts = [FIRMA, INHABER]
if STEUERNUMMER:
footer_parts.append(f"Steuernr.: {STEUERNUMMER}")
if EMAIL:
footer_parts.append(EMAIL)
if WEBSITE:
footer_parts.append(WEBSITE)
pdf.set_font("Helvetica", "", 7.5)
pdf.set_text_color(*GY)
pdf.set_x(20)
pdf.cell(W, 4, " | ".join(footer_parts), align="C")
return bytes(pdf.output())
async def _save_to_paperless(pdf_bytes: bytes, invoice_number: str, filename: str):
scaninput = os.getenv("SCANINPUT_DIR", "/scaninput")
os.makedirs(scaninput, exist_ok=True)
path = os.path.join(scaninput, filename)
with open(path, "wb") as f:
f.write(pdf_bytes)
logger.info(f"PDF gespeichert: {path} ({len(pdf_bytes)} Bytes)")
paperless_url = os.getenv("PAPERLESS_URL", "")
paperless_token = os.getenv("PAPERLESS_TOKEN", "")
if paperless_url and paperless_token:
try:
import httpx
async with httpx.AsyncClient(timeout=30) as client:
await client.post(
f"{paperless_url}/api/documents/post_document/",
headers={"Authorization": f"Token {paperless_token}"},
files={"document": (filename, pdf_bytes, "application/pdf")},
data={"title": invoice_number, "tags": "banyaro,Rechnung"},
)
except Exception as e:
logger.warning(f"Paperless upload failed: {e}")
def _row_to_dict(row) -> dict:
return dict(row)
def _fetch_items(conn, invoice_id: int) -> list:
rows = conn.execute(
"SELECT * FROM invoice_items WHERE invoice_id=? ORDER BY id",
(invoice_id,)
).fetchall()
return [dict(r) for r in rows]
# ------------------------------------------------------------------
# Endpoints
# ------------------------------------------------------------------
@router.get("")
def list_invoices(status: Optional[str] = None, admin=Depends(require_admin)):
with db() as conn:
if status:
rows = conn.execute(
"SELECT * FROM invoices WHERE status=? ORDER BY id DESC",
(status,)
).fetchall()
else:
rows = conn.execute(
"SELECT * FROM invoices ORDER BY id DESC"
).fetchall()
return [_row_to_dict(r) for r in rows]
@router.get("/cashflow")
def get_cashflow(admin=Depends(require_admin)):
with db() as conn:
monthly = conn.execute("""
SELECT substr(created_at, 1, 7) AS month,
SUM(CASE WHEN status='paid'
THEN COALESCE(paid_amount, amount_gross)
ELSE amount_gross END) AS revenue,
COUNT(*) AS count
FROM invoices
WHERE status IN ('sent', 'paid')
GROUP BY month
ORDER BY month DESC
""").fetchall()
year = datetime.now().year
total_year = conn.execute(
"""SELECT COALESCE(SUM(CASE WHEN status='paid'
THEN COALESCE(paid_amount, amount_gross)
ELSE amount_gross END), 0)
FROM invoices WHERE status IN ('sent','paid') AND created_at LIKE ?""",
(f"{year}%",)
).fetchone()[0]
total_outstanding = conn.execute(
"SELECT COALESCE(SUM(amount_gross),0) FROM invoices WHERE status='sent'"
).fetchone()[0]
total_paid = conn.execute(
"SELECT COALESCE(SUM(COALESCE(paid_amount, amount_gross)),0) FROM invoices WHERE status='paid'"
).fetchone()[0]
counts_rows = conn.execute(
"SELECT status, COUNT(*) AS n FROM invoices GROUP BY status"
).fetchall()
counts = {r["status"]: r["n"] for r in counts_rows}
return {
"monthly": [_row_to_dict(r) for r in monthly],
"total_year": round(total_year, 2),
"total_outstanding": round(total_outstanding, 2),
"total_paid": round(total_paid, 2),
"counts": counts,
}
@router.get("/quarterly/{year}/{q}")
def get_quarterly(year: int, q: int, admin=Depends(require_admin)):
if q not in (1, 2, 3, 4):
raise HTTPException(400, "Quartal muss 14 sein.")
month_start = (q - 1) * 3 + 1
month_end = month_start + 2
from_date = f"{year}-{month_start:02d}-01"
import calendar
last_day = calendar.monthrange(year, month_end)[1]
to_date = f"{year}-{month_end:02d}-{last_day:02d}"
labels = {1: "01.01.", 2: "01.04.", 3: "01.07.", 4: "01.10."}
ends = {1: "31.03.", 2: "30.06.", 3: "30.09.", 4: "31.12."}
period = f"Q{q} {year} ({labels[q]} - {ends[q]})"
with db() as conn:
# Alle Rechnungen außer Entwürfe im Quartal (nach Ausstellungsdatum)
rows = conn.execute(
"SELECT * FROM invoices WHERE status != 'draft' AND created_at >= ? AND created_at <= ? ORDER BY created_at ASC",
(from_date, to_date + "T23:59:59Z")
).fetchall()
# Stornorechnungen die im Quartal ausgestellt wurden (cancelled_at im Zeitraum,
# auch wenn die Originalrechnung außerhalb des Quartals liegt)
storno_rows = conn.execute(
"SELECT * FROM invoices WHERE status = 'cancelled' AND cancelled_at >= ? AND cancelled_at <= ?",
(from_date, to_date + "T23:59:59Z")
).fetchall()
# Buchungseinträge aufbauen
entries = []
# Originalrechnungen (paid, sent — mit positivem Betrag)
for r in rows:
d = _row_to_dict(r)
if d["status"] in ("paid", "sent"):
entries.append(d)
elif d["status"] == "cancelled":
# Originalrechnung erscheint mit positivem Betrag (wurde ausgestellt)
entries.append(d)
# Stornozeilen: negative Beträge, Datum = cancelled_at, Nummer = cancellation_number
storno_ids_already = {r["id"] for r in rows}
for r in storno_rows:
d = _row_to_dict(r)
storno_entry = {
"invoice_number": d["cancellation_number"] or f"ST-{d['invoice_number']}",
"recipient_name": d["recipient_name"],
"recipient_email": d["recipient_email"],
"created_at": d["cancelled_at"],
"service_period": d["service_period"],
"amount_net": -round(d["amount_net"], 2),
"tax_amount": -round(d.get("tax_amount") or 0, 2),
"amount_gross": -round(d["amount_gross"], 2),
"paid_amount": None,
"status": "storno",
"sent_at": None,
"paid_at": None,
"cancellation_number": d["cancellation_number"],
"notes": f"Storno zu {d['invoice_number']}",
}
entries.append(storno_entry)
# Wenn Original NICHT im Quartal aber Storno schon → Original trotzdem zeigen
if r["id"] not in storno_ids_already:
orig = _row_to_dict(r)
entries.append(orig)
# Nach Datum sortieren
entries.sort(key=lambda e: (e.get("created_at") or ""))
# Summen: alle Einträge — Storno (-) und Original (+) heben sich gegenseitig auf
# Für bezahlte Rechnungen den tatsächlich eingegangenen Betrag verwenden
def _effective_gross(e):
if e.get("status") == "paid" and e.get("paid_amount") is not None:
return e["paid_amount"]
return e.get("amount_gross") or 0
total_gross = sum(_effective_gross(e) for e in entries)
total_tax = sum(e.get("tax_amount") or 0 for e in entries)
return {
"period": period,
"invoices": entries,
"total_tax": round(total_tax, 2),
"total_gross": round(total_gross, 2),
"count": len(entries),
}
@router.get("/{invoice_id}")
def get_invoice(invoice_id: int, admin=Depends(require_admin)):
with db() as conn:
row = conn.execute("SELECT * FROM invoices WHERE id=?", (invoice_id,)).fetchone()
if not row:
raise HTTPException(404, "Rechnung nicht gefunden.")
items = _fetch_items(conn, invoice_id)
result = _row_to_dict(row)
result["items"] = items
return result
@router.patch("/{invoice_id}")
def update_invoice(invoice_id: int, data: InvoiceCreate, admin=Depends(require_admin)):
with db() as conn:
row = conn.execute("SELECT * FROM invoices WHERE id=?", (invoice_id,)).fetchone()
if not row:
raise HTTPException(404, "Rechnung nicht gefunden.")
if row["status"] != "draft":
raise HTTPException(400, "Nur Entwürfe können bearbeitet werden.")
if not data.items:
raise HTTPException(400, "Mindestens eine Position erforderlich.")
KLEINUNTERNEHMER = os.getenv("KLEINUNTERNEHMER", "true").lower() == "true"
TAX_RATE = 0.0 if KLEINUNTERNEHMER else float(os.getenv("RECHNUNG_MWST", "19"))
amount_net = round(sum(i.quantity * i.unit_price for i in data.items), 2)
discount_pct = data.discount_pct or 0.0
discount_amount = round(amount_net * discount_pct / 100, 2)
amount_after_discount = round(amount_net - discount_amount, 2)
tax_amount = round(amount_after_discount * TAX_RATE / 100, 2)
amount_gross = round(amount_after_discount + tax_amount, 2)
description = data.items[0].description if len(data.items) == 1 else f"{len(data.items)} Positionen"
conn.execute("""
UPDATE invoices SET
recipient_name=?, recipient_email=?, recipient_address=?,
description=?, service_period=?,
amount_net=?, discount_pct=?, discount_amount=?,
amount_after_discount=?, tax_rate=?, tax_amount=?, amount_gross=?,
notes=?
WHERE id=?
""", (
data.recipient_name, data.recipient_email, data.recipient_address,
description, data.service_period,
amount_net, discount_pct, discount_amount,
amount_after_discount, TAX_RATE, tax_amount, amount_gross,
data.notes, invoice_id,
))
conn.execute("DELETE FROM invoice_items WHERE invoice_id=?", (invoice_id,))
for item in data.items:
total = round(item.quantity * item.unit_price, 2)
conn.execute(
"INSERT INTO invoice_items (invoice_id, description, quantity, unit_price, total) VALUES (?,?,?,?,?)",
(invoice_id, item.description, item.quantity, item.unit_price, total)
)
row = conn.execute("SELECT * FROM invoices WHERE id=?", (invoice_id,)).fetchone()
items = _fetch_items(conn, invoice_id)
result = _row_to_dict(row)
result["items"] = items
return result
@router.post("", status_code=201)
def create_invoice(data: InvoiceCreate, admin=Depends(require_admin)):
if not data.items:
raise HTTPException(400, "Mindestens eine Position erforderlich.")
KLEINUNTERNEHMER = os.getenv("KLEINUNTERNEHMER", "true").lower() == "true"
TAX_RATE = 0.0 if KLEINUNTERNEHMER else float(os.getenv("RECHNUNG_MWST", "19"))
amount_net = round(sum(i.quantity * i.unit_price for i in data.items), 2)
discount_pct = data.discount_pct or 0.0
discount_amount = round(amount_net * discount_pct / 100, 2)
amount_after_discount = round(amount_net - discount_amount, 2)
tax_amount = round(amount_after_discount * TAX_RATE / 100, 2)
amount_gross = round(amount_after_discount + tax_amount, 2)
description = data.items[0].description if len(data.items) == 1 else f"{len(data.items)} Positionen"
with db() as conn:
invoice_number = _next_invoice_number(conn)
conn.execute("""
INSERT INTO invoices
(invoice_number, user_id, recipient_name, recipient_email, recipient_address,
description, service_period, amount_net, discount_pct, discount_amount,
amount_after_discount, tax_rate, tax_amount, amount_gross, notes)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
""", (
invoice_number, data.user_id, data.recipient_name, data.recipient_email,
data.recipient_address, description, data.service_period,
amount_net, discount_pct, discount_amount,
amount_after_discount, TAX_RATE, tax_amount, amount_gross,
data.notes,
))
invoice_id = conn.execute("SELECT last_insert_rowid()").fetchone()[0]
for item in data.items:
total = round(item.quantity * item.unit_price, 2)
conn.execute(
"INSERT INTO invoice_items (invoice_id, description, quantity, unit_price, total) VALUES (?,?,?,?,?)",
(invoice_id, item.description, item.quantity, item.unit_price, total)
)
row = conn.execute("SELECT * FROM invoices WHERE id=?", (invoice_id,)).fetchone()
items = _fetch_items(conn, invoice_id)
result = _row_to_dict(row)
result["items"] = items
return result
@router.post("/{invoice_id}/send")
async def send_invoice(invoice_id: int, admin=Depends(require_admin)):
with db() as conn:
row = conn.execute("SELECT * FROM invoices WHERE id=?", (invoice_id,)).fetchone()
if not row:
raise HTTPException(404, "Rechnung nicht gefunden.")
if row["status"] == "cancelled":
raise HTTPException(400, "Stornierte Rechnung kann nicht gesendet werden.")
if row["status"] == "paid":
raise HTTPException(400, "Bezahlte Rechnung kann nicht erneut gesendet werden.")
items = _fetch_items(conn, invoice_id)
invoice = _row_to_dict(row)
try:
pdf_bytes = _generate_pdf(invoice, items)
except Exception as e:
logger.error(f"PDF-Generierung fehlgeschlagen: {e}")
raise HTTPException(500, f"PDF-Generierung fehlgeschlagen: {e}")
filename = f"{invoice['invoice_number']}_banyaro.pdf"
try:
await _save_to_paperless(pdf_bytes, invoice["invoice_number"], filename)
except Exception as e:
logger.warning(f"Paperless-Speicherung fehlgeschlagen: {e}")
import base64
body_html = f"""
<p style="margin:0 0 16px">Hallo <b>{invoice['recipient_name']}</b>,</p>
<p style="margin:0 0 16px">
anbei erhalten Sie Ihre Rechnung <b>{invoice['invoice_number']}</b>
über <b>{invoice['amount_gross']:.2f} EUR</b>.
</p>
<p style="margin:0 0 8px">Bitte überweisen Sie den Betrag innerhalb von 14 Tagen.</p>
<p style="margin:0;font-size:13px;color:#888">Verwendungszweck: {invoice['invoice_number']}</p>
"""
html = mailer.email_html(body_html)
plain = (
f"Hallo {invoice['recipient_name']},\n\n"
f"anbei Ihre Rechnung {invoice['invoice_number']} über {invoice['amount_gross']:.2f} EUR.\n\n"
f"Bitte überweisen Sie den Betrag innerhalb von 14 Tagen.\n"
f"Verwendungszweck: {invoice['invoice_number']}\n"
)
attachments = [{
"filename": filename,
"content": pdf_bytes,
"content_type": "application/pdf",
}]
try:
await mailer.send_email(
to=invoice["recipient_email"],
subject=f"Ihre Rechnung {invoice['invoice_number']} von Ban Yaro",
html=html,
plain=plain,
attachments=attachments,
)
except Exception as e:
logger.error(f"Mail-Versand fehlgeschlagen: {e}")
raise HTTPException(500, f"Mail-Versand fehlgeschlagen: {e}")
now = datetime.utcnow().strftime("%Y-%m-%dT%H:%M:%S.000Z")
with db() as conn:
conn.execute(
"UPDATE invoices SET status='sent', sent_at=? WHERE id=?",
(now, invoice_id)
)
row = conn.execute("SELECT * FROM invoices WHERE id=?", (invoice_id,)).fetchone()
return _row_to_dict(row)
@router.get("/{invoice_id}/pdf")
def download_pdf(invoice_id: int, admin=Depends(require_admin)):
with db() as conn:
row = conn.execute("SELECT * FROM invoices WHERE id=?", (invoice_id,)).fetchone()
if not row:
raise HTTPException(404, "Rechnung nicht gefunden.")
items = _fetch_items(conn, invoice_id)
invoice = _row_to_dict(row)
pdf_bytes = _generate_pdf(invoice, items)
filename = f"{invoice['invoice_number']}_banyaro.pdf"
return Response(
content=pdf_bytes,
media_type="application/pdf",
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
)
@router.post("/{invoice_id}/pay")
def pay_invoice(invoice_id: int, data: PayBody, admin=Depends(require_admin)):
with db() as conn:
row = conn.execute("SELECT * FROM invoices WHERE id=?", (invoice_id,)).fetchone()
if not row:
raise HTTPException(404, "Rechnung nicht gefunden.")
if row["status"] == "cancelled":
raise HTTPException(400, "Stornierte Rechnung kann nicht als bezahlt markiert werden.")
if data.notes:
conn.execute(
"UPDATE invoices SET status='paid', paid_at=?, paid_amount=?, notes=? WHERE id=?",
(data.paid_at, data.paid_amount, data.notes, invoice_id)
)
else:
conn.execute(
"UPDATE invoices SET status='paid', paid_at=?, paid_amount=? WHERE id=?",
(data.paid_at, data.paid_amount, invoice_id)
)
row = conn.execute("SELECT * FROM invoices WHERE id=?", (invoice_id,)).fetchone()
return _row_to_dict(row)
@router.post("/{invoice_id}/cancel")
async def cancel_invoice(invoice_id: int, data: CancelBody, admin=Depends(require_admin)):
with db() as conn:
row = conn.execute("SELECT * FROM invoices WHERE id=?", (invoice_id,)).fetchone()
if not row:
raise HTTPException(404, "Rechnung nicht gefunden.")
if row["status"] == "cancelled":
raise HTTPException(400, "Rechnung ist bereits storniert.")
cancellation_number = _next_invoice_number(conn, "ST")
now = datetime.utcnow().strftime("%Y-%m-%dT%H:%M:%S.000Z")
conn.execute(
"UPDATE invoices SET status='cancelled', cancelled_at=?, cancellation_reason=?, cancellation_number=? WHERE id=?",
(now, data.reason, cancellation_number, invoice_id)
)
row = conn.execute("SELECT * FROM invoices WHERE id=?", (invoice_id,)).fetchone()
items = _fetch_items(conn, invoice_id)
invoice = _row_to_dict(row)
# Storno-PDF: invoice-Dict als Stornobeleg aufbereiten
orig_date = (invoice.get("created_at") or "")[:10]
try:
from datetime import datetime as _dt
y, m, d = orig_date.split("-")
orig_date_de = f"{d}.{m}.{y}"
except Exception:
orig_date_de = orig_date
storno_invoice = dict(invoice)
storno_invoice["invoice_number"] = cancellation_number
storno_invoice["notes"] = (
f"Stornorechnung zu Rechnung {invoice['invoice_number']} vom {orig_date_de}\n"
f"Grund: {data.reason}"
)
storno_invoice["amount_net"] = -invoice["amount_net"]
storno_invoice["discount_amount"] = -invoice.get("discount_amount", 0)
storno_invoice["amount_after_discount"] = -invoice["amount_after_discount"]
storno_invoice["tax_amount"] = -invoice.get("tax_amount", 0)
storno_invoice["amount_gross"] = -invoice["amount_gross"]
for item in items:
item["unit_price"] = -item["unit_price"]
item["total"] = -item["total"]
try:
pdf_bytes = _generate_pdf(storno_invoice, items)
except Exception as e:
logger.error(f"Storno-PDF fehlgeschlagen: {e}")
return _row_to_dict(row)
filename = f"{cancellation_number}_banyaro.pdf"
try:
await _save_to_paperless(pdf_bytes, cancellation_number, filename)
except Exception as e:
logger.warning(f"Storno Paperless fehlgeschlagen: {e}")
# Mail an Kunden
try:
body_html = mailer.email_html(f"""
<p style="margin:0 0 16px">Hallo <b>{invoice['recipient_name']}</b>,</p>
<p style="margin:0 0 16px">
Ihre Rechnung <b>{invoice['invoice_number']}</b> wurde storniert
(Stornonummer: <b>{cancellation_number}</b>).
</p>
<p style="margin:0 0 8px;color:#666;font-size:13px">Grund: {data.reason}</p>
<p style="margin:0;color:#666;font-size:13px">
Das Stornodokument liegt diesem Schreiben bei.
</p>
""")
plain = (
f"Hallo {invoice['recipient_name']},\n\n"
f"Ihre Rechnung {invoice['invoice_number']} wurde storniert "
f"(Stornonummer: {cancellation_number}).\n"
f"Grund: {data.reason}\n"
)
await mailer.send_email(
to=invoice["recipient_email"],
subject=f"Stornierung Rechnung {invoice['invoice_number']} — Ban Yaro",
html=body_html,
plain=plain,
attachments=[{"filename": filename, "content": pdf_bytes, "content_type": "application/pdf"}],
)
except Exception as e:
logger.error(f"Storno-Mail fehlgeschlagen: {e}")
return invoice

View file

@ -2,6 +2,7 @@
import io
import os
import re
import uuid
from typing import Optional
@ -28,6 +29,8 @@ class ProfileUpdate(BaseModel):
notes_ki_enabled: Optional[int] = None
gassi_stunde_push: Optional[int] = None
preferred_theme: Optional[str] = None
billing_address: Optional[str] = None
geburtstag: Optional[str] = None
def _load_user(user_id: int) -> dict:
@ -35,7 +38,8 @@ def _load_user(user_id: int) -> dict:
row = conn.execute(
"""SELECT id, name, real_name, email, rolle, is_premium, email_verified,
bio, wohnort, erfahrung, social_link,
profil_sichtbarkeit, avatar_url, created_at
profil_sichtbarkeit, avatar_url, created_at, billing_address,
geburtstag
FROM users WHERE id=?""",
(user_id,)
).fetchone()
@ -63,6 +67,9 @@ async def update_profile(data: ProfileUpdate, user=Depends(get_current_user)):
raise HTTPException(400, "wohnort darf maximal 60 Zeichen lang sein.")
if "social_link" in fields and len(fields["social_link"]) > 120:
raise HTTPException(400, "social_link darf maximal 120 Zeichen lang sein.")
if "geburtstag" in fields and fields["geburtstag"]:
if not re.fullmatch(r"\d{2}\.\d{2}", fields["geburtstag"]):
raise HTTPException(400, "geburtstag muss im Format TT.MM sein (z.B. 16.05).")
if not fields:
return _load_user(user["id"])

View file

@ -33,16 +33,21 @@ async def public_stats():
users = conn.execute("SELECT COUNT(*) FROM users").fetchone()[0]
dogs = conn.execute("SELECT COUNT(*) FROM dogs").fetchone()[0]
km = conn.execute(
"SELECT ROUND(COALESCE(SUM(distanz_km),0),0) FROM routes"
# Alle Routen (öffentlich + privat), nur valide Aufzeichnungen
"SELECT ROUND(COALESCE(SUM(distanz_km),0),0) FROM routes WHERE is_valid=1"
).fetchone()[0]
posts = conn.execute("SELECT COUNT(*) FROM forum_posts").fetchone()[0]
diary = conn.execute("SELECT COUNT(*) FROM diary").fetchone()[0]
kotbeutel = conn.execute(
"SELECT COUNT(*) FROM osm_pois WHERE type='waste_basket'"
).fetchone()[0]
data = {
"users": users,
"dogs": dogs,
"km": int(km or 0),
"forum_posts": posts,
"diary_entries": diary,
"kotbeutel": kotbeutel,
}
_pub_cache["data"] = data
_pub_cache["ts"] = now

View file

@ -323,10 +323,10 @@ class SessionCreate(BaseModel):
datum: Optional[str] = None
wiederholungen: int = 1
erfolgsquote: int = 50
hund_stimmung: str = "aufmerksam"
zufriedenheit: int = 3
hund_stimmung: Optional[str] = "aufmerksam"
zufriedenheit: Optional[int] = 3
notiz: Optional[str] = None
tagebuch_eintrag: bool = False
tagebuch_eintrag: bool = False # ignoriert — Training hat eigenes Protokoll
@router.post("/sessions")
@ -363,42 +363,6 @@ async def log_session(body: SessionCreate, user=Depends(get_current_user)):
# Badges prüfen
new_badges = _check_badges(conn, uid, dog_name)
# Tagebucheintrag erstellen?
diary_entry_id = None
if body.tagebuch_eintrag or ist_top:
stimmung_label = STIMMUNGS_LABELS.get(body.hund_stimmung, body.hund_stimmung)
if ist_top:
titel = f"\U0001f3af {body.exercise_name} \u2014 Top-Training!"
else:
titel = f"\U0001f3af Training: {body.exercise_name}"
text_parts = [
f"{body.wiederholungen} Wiederholungen \u00b7 "
f"Erfolgsquote: {body.erfolgsquote}% \u00b7 "
f"Stimmung: {stimmung_label}"
]
if body.notiz:
text_parts.append(f"\n\n{body.notiz}")
eintrag_text = "".join(text_parts)
diary_cur = conn.execute(
"""
INSERT INTO diary (dog_id, datum, typ, titel, text)
VALUES (?,?,?,?,?)
""",
(body.dog_id, datum, "training", titel, eintrag_text)
)
diary_entry_id = diary_cur.lastrowid
conn.execute(
"INSERT OR IGNORE INTO diary_dogs (diary_id, dog_id) VALUES (?,?)",
(diary_entry_id, body.dog_id)
)
conn.execute(
"UPDATE training_sessions SET diary_entry_id=? WHERE id=?",
(diary_entry_id, session_id)
)
session = {
"id": session_id,
"user_id": uid,
@ -412,14 +376,12 @@ async def log_session(body: SessionCreate, user=Depends(get_current_user)):
"zufriedenheit": body.zufriedenheit,
"notiz": body.notiz,
"ist_top": bool(ist_top),
"diary_entry_id": diary_entry_id,
}
return {
"session": session,
"ist_top": bool(ist_top),
"badges": new_badges,
"diary_entry_id": diary_entry_id,
}

View file

@ -195,6 +195,13 @@ def start():
replace_existing=True,
misfire_grace_time=3600,
)
_scheduler.add_job(
_job_invoice_reminder,
CronTrigger(hour=8, minute=30), # täglich 08:30 Uhr
id="invoice_reminder",
replace_existing=True,
misfire_grace_time=3600,
)
_scheduler.start()
logger.info("Scheduler gestartet — Health-Reminder 08:00, Giftköder-Archiv 03:00, Wetter-Alert 07:30, Meilenstein-Check 00:05, Event-Import So 02:00, Rassen-Seed monatlich 1. des Monats, Status-Report täglich 06:00, Moderation-Overdue 12:00, Quartalsbericht 1. Feb/Mai/Aug/Nov 07:00, Streak-Reminder 19:00, Rückruf-Check 08:00, Goldene-Gassi-Stunde 07:00, Jahrestags-Erinnerungen 09:00, Monatlicher-Rückblick 1. des Monats 10:00, Foto-Challenge Mo 08:00, Abo-Check 03:00. OSM-Cache: on-demand (kein Prewarm).")
@ -207,6 +214,278 @@ def stop():
# ------------------------------------------------------------------
# JOB: Abo-Ablauf prüfen (täglich 03:00)
# ------------------------------------------------------------------
_TIER_PRICE = {"pro": 29.00, "breeder": 49.00}
async def _create_renewal_invoice_draft(user: dict, expires: date, tier_label: str):
"""Legt einen Rechnungs-Entwurf für die Abo-Verlängerung an, sofern noch keiner existiert."""
import os
from mailer import send_email, email_html
from routes.invoices import _next_invoice_number
# Gekündigte Abos bekommen keine Erneuerungsrechnung
if user.get("subscription_cancelled_at"):
logger.info(f"Kein Erneuerungsentwurf für {user['email']} — Abo ist gekündigt.")
return
tier = user["subscription_tier"]
price = _TIER_PRICE.get(tier, 29.00)
# Verlängerungszeitraum: Folgetag nach Ablauf bis +1 Jahr
start = expires + timedelta(days=1)
end = start.replace(year=start.year + 1) - timedelta(days=1)
period = f"{start.strftime('%d.%m.%Y')} - {end.strftime('%d.%m.%Y')}"
with db() as conn:
# Nur anlegen wenn noch kein Entwurf/offener Eintrag für diesen User + Zeitraum
existing = conn.execute(
"""SELECT id FROM invoices
WHERE user_id=? AND status IN ('draft','sent')
AND service_period=?""",
(user["id"], period)
).fetchone()
if existing:
logger.info(f"Erneuerungsrechnung bereits vorhanden für user {user['id']}")
return
# Billing-Adresse des Users laden
row = conn.execute(
"SELECT billing_address FROM users WHERE id=?", (user["id"],)
).fetchone()
billing_address = row["billing_address"] if row else None
# Rabatt berechnen (inline, da kein Admin-Import möglich)
disc_row = conn.execute(
"""SELECT u.is_founder, u.is_founder_pending, u.referred_by,
COALESCE((SELECT COUNT(*) FROM users WHERE referred_by=u.id), 0) AS referral_count
FROM users u WHERE u.id=?""",
(user["id"],)
).fetchone()
discount_pct = 0
discount_reason = None
referral_count = 0
if disc_row:
referral_count = disc_row["referral_count"]
if disc_row["is_founder"] or disc_row["is_founder_pending"]:
discount_pct = 100
discount_reason = "founder"
elif (disc_row["referred_by"] or 0) > 0:
ref = conn.execute(
"SELECT is_founder, is_founder_pending FROM users WHERE id=?",
(disc_row["referred_by"],)
).fetchone()
if ref and (ref["is_founder"] or ref["is_founder_pending"]):
discount_pct = 50
discount_reason = "referred_by_founder"
if not discount_reason:
for thr, pct in [(50, 50), (20, 30), (10, 20)]:
if referral_count >= thr:
discount_pct = pct
discount_reason = "referral"
break
discount_amt = round(price * discount_pct / 100, 2)
after_disc = round(price - discount_amt, 2)
_AGB = "Jahresbeitrag gem. AGB. Bei vorzeitiger Kündigung keine anteilige Rückerstattung; Zugang bleibt bis Laufzeitende bestehen."
if discount_reason == "founder":
notes = f"Gründer-Sonderkonditionen: {tier_label} kostenfrei als Dankeschön für deine Unterstützung als Gründer! {_AGB} (Automatisch erstellt, Ablauf: {expires.strftime('%d.%m.%Y')})"
elif discount_reason == "referred_by_founder":
notes = f"Willkommen in der Gründer-Community! Als persönlich von einem Gründer eingeladenes Mitglied erhältst du dauerhaft {discount_pct}% Rabatt. {_AGB} (Automatisch erstellt, Ablauf: {expires.strftime('%d.%m.%Y')})"
elif discount_reason == "referral":
notes = f"Herzlichen Dank für deine Unterstützung! Für {referral_count} geworbene Freunde erhältst du {discount_pct}% Rabatt. {_AGB} (Automatisch erstellt, Ablauf: {expires.strftime('%d.%m.%Y')})"
else:
notes = f"{_AGB} (Automatisch erstellt, Ablauf: {expires.strftime('%d.%m.%Y')})"
invoice_number = _next_invoice_number(conn)
description = f"{tier_label} Jahresabo (Verlängerung)"
conn.execute("""
INSERT INTO invoices
(invoice_number, user_id, recipient_name, recipient_email, recipient_address,
description, service_period, amount_net, discount_pct, discount_amount,
amount_after_discount, tax_rate, tax_amount, amount_gross, notes)
VALUES (?,?,?,?,?,?,?,?,?,?,?,0,0,?,?)
""", (
invoice_number, user["id"], user["name"], user["email"], billing_address,
description, period,
price, discount_pct, discount_amt, after_disc, after_disc, notes,
))
conn.execute(
"INSERT INTO invoice_items (invoice_id, description, quantity, unit_price, total) VALUES (?,?,1,?,?)",
(conn.execute("SELECT last_insert_rowid()").fetchone()[0], description, price, price)
)
logger.info(f"Erneuerungsrechnung {invoice_number} als Entwurf angelegt für {user['email']}")
# Admin-Benachrichtigung
admin_email = os.getenv("ADMIN_EMAIL", "")
if admin_email:
app_url = os.getenv("APP_URL", "https://banyaro.app")
body = f"""
<p>Für <strong>{user['name']}</strong> ({user['email']}) wurde automatisch ein
Rechnungsentwurf für die Abo-Verlängerung erstellt.</p>
<table style="border-collapse:collapse;font-size:14px;margin:12px 0">
<tr><td style="padding:4px 12px 4px 0;color:#888">Rechnung:</td><td><strong>{invoice_number}</strong></td></tr>
<tr><td style="padding:4px 12px 4px 0;color:#888">Tarif:</td><td>{tier_label}</td></tr>
<tr><td style="padding:4px 12px 4px 0;color:#888">Betrag:</td><td>{price:.2f} EUR</td></tr>
<tr><td style="padding:4px 12px 4px 0;color:#888">Zeitraum:</td><td>{period}</td></tr>
<tr><td style="padding:4px 12px 4px 0;color:#888">Abo läuft ab:</td><td>{expires.strftime('%d.%m.%Y')} (in 30 Tagen)</td></tr>
</table>
<p>Bitte prüfen, ggf. anpassen und rechtzeitig versenden.</p>"""
html = email_html(body, cta_url=f"{app_url}/#admin", cta_label="Zur Rechnung im Admin")
await send_email(
admin_email,
f"Erneuerungsrechnung {invoice_number} bereit — {user['name']}",
html,
f"Entwurf {invoice_number} für {user['name']} ({tier_label}, {price:.2f} EUR, {period}) bereit."
)
async def _remind_renewal_invoice(user: dict, expires: date, tier_label: str):
"""7-Tage-Erinnerung an René: Entwurf noch nicht versendet."""
import os
from mailer import send_email, email_html
with db() as conn:
draft = conn.execute(
"SELECT invoice_number FROM invoices WHERE user_id=? AND status='draft' LIMIT 1",
(user["id"],)
).fetchone()
if not draft:
return # kein offener Entwurf, nichts zu erinnern
admin_email = os.getenv("ADMIN_EMAIL", "")
if not admin_email:
return
app_url = os.getenv("APP_URL", "https://banyaro.app")
body = f"""
<p><strong>Achtung:</strong> Das Abo von <strong>{user['name']}</strong> ({user['email']})
läuft in <strong>7 Tagen</strong> (am {expires.strftime('%d.%m.%Y')}) ab.</p>
<p>Rechnungsentwurf <strong>{draft['invoice_number']}</strong> wurde noch nicht versendet.
Bitte jetzt versenden damit der Kunde rechtzeitig bezahlen kann.</p>"""
html = email_html(body, cta_url=f"{app_url}/#admin", cta_label="Rechnung jetzt senden")
await send_email(
admin_email,
f"⚠ Noch 7 Tage — Erneuerungsrechnung {draft['invoice_number']} nicht versendet",
html,
f"Entwurf {draft['invoice_number']} für {user['name']} noch nicht versendet. Abo läuft in 7 Tagen ab."
)
logger.info(f"7-Tage-Erinnerung an Admin für {user['email']}: {draft['invoice_number']}")
async def _job_invoice_reminder():
"""
Unbezahlte Rechnungen (status='sent'):
- Nach 21 Tagen: Zahlungsmahnung mit 14-Tage-Frist (§286/314 BGB)
- Nach 35 Tagen (21+14): Fristlose Abo-Kündigung
"""
from database import db as _db
from mailer import send_email, email_html
from routes.invoices import _next_invoice_number
import html as _html
import os
APP_URL = os.getenv("APP_URL", "https://banyaro.app")
IBAN = os.getenv("RECHNUNG_IBAN", "")
ADMIN_MAIL = os.getenv("ADMIN_EMAIL", "")
today = datetime.now(_TZ).date()
with db() as conn:
open_invoices = conn.execute(
"""SELECT i.*, u.name AS user_name, u.subscription_tier, u.id AS uid
FROM invoices i
LEFT JOIN users u ON u.id = i.user_id
WHERE i.status = 'sent'
AND i.sent_at IS NOT NULL"""
).fetchall()
for inv in open_invoices:
try:
sent_date = datetime.fromisoformat(inv["sent_at"].replace("Z", "+00:00")).date()
days_open = (today - sent_date).days
rg = inv["invoice_number"]
name = inv["recipient_name"]
email = inv["recipient_email"]
amount = inv["amount_gross"]
frist = (today + timedelta(days=14)).strftime("%d.%m.%Y")
# ── 21 Tage: Zahlungsmahnung mit 14-Tage-Frist ───────────
if days_open == 21:
iban_line = f"<p style='margin:0 0 8px;font-size:13px'>IBAN: <strong>{IBAN}</strong> · Verwendungszweck: {rg}</p>" if IBAN else ""
body = f"""
<p style='margin:0 0 12px'>Hallo <b>{_html.escape(name)}</b>,</p>
<p style='margin:0 0 12px'>
unsere Rechnung <b>{rg}</b> vom {datetime.fromisoformat(inv['created_at'][:10]).strftime('%d.%m.%Y')}
über <b>{amount:.2f} EUR</b> ist leider noch offen.
</p>
<p style='margin:0 0 12px'>
Bitte überweisen Sie den Betrag bis zum <b>{frist}</b>.
{iban_line}
</p>
<p style='margin:0;font-size:13px;color:#888'>
Sollte die Zahlung bis zu diesem Datum nicht eingehen, sind wir leider gezwungen,
Ihr Abonnement fristlos zu kündigen (§&nbsp;314 BGB).
</p>"""
html = email_html(body, cta_url=APP_URL, cta_label="Ban Yaro öffnen")
await send_email(
email,
f"Zahlungserinnerung: Rechnung {rg} — Ban Yaro",
html,
f"Hallo {name},\n\nRechnung {rg} über {amount:.2f} EUR ist noch offen.\n"
f"Bitte bis {frist} überweisen. Andernfalls kündigen wir fristlos.\n"
+ (f"IBAN: {IBAN}, Verwendungszweck: {rg}\n" if IBAN else "")
)
logger.info(f"Zahlungsmahnung gesendet: {rg} an {email} (21 Tage offen)")
# ── 35 Tage: Fristlose Kündigung ─────────────────────────
elif days_open == 35:
# Abo kündigen wenn Nutzer zugeordnet und aktives Abo
if inv["uid"] and inv["subscription_tier"] not in (None, "standard", "standard_test"):
with db() as conn2:
conn2.execute(
"""UPDATE users SET subscription_tier='standard',
subscription_expires_at=NULL,
subscription_cancelled_at=strftime('%Y-%m-%dT%H:%M:%SZ','now')
WHERE id=?""",
(inv["uid"],)
)
logger.info(f"Fristlose Kündigung: user {inv['uid']} wegen unbezahlter Rechnung {rg}")
body = f"""
<p style='margin:0 0 12px'>Hallo <b>{_html.escape(name)}</b>,</p>
<p style='margin:0 0 12px'>
da die Zahlung für Rechnung <b>{rg}</b> ({amount:.2f} EUR)
trotz unserer Zahlungserinnerung nicht eingegangen ist,
haben wir Ihr Abonnement gemäß §&nbsp;314 BGB fristlos gekündigt.
</p>
<p style='margin:0 0 12px'>
Ihre Daten bleiben vollständig erhalten. Sie können jederzeit ein neues Abonnement abschließen.
</p>
<p style='margin:0;font-size:13px;color:#888'>
Bei Rückfragen antworten Sie einfach auf diese E-Mail.
</p>"""
html = email_html(body, cta_url=APP_URL, cta_label="Ban Yaro öffnen")
await send_email(
email,
f"Ihr Ban Yaro Abonnement wurde gekündigt — Rechnung {rg}",
html,
f"Hallo {name},\n\nIhr Abo wurde wegen unbezahlter Rechnung {rg} fristlos gekündigt.\n"
f"Ihre Daten sind erhalten. Neue Buchung jederzeit möglich.\n"
)
if ADMIN_MAIL:
await send_email(
ADMIN_MAIL,
f"Fristlose Kündigung: {name}{rg} ({amount:.2f} EUR unbezahlt)",
email_html(f"<p>Abo von <b>{_html.escape(name)}</b> ({email}) wurde automatisch fristlos gekündigt (§314 BGB). Rechnung {rg} seit 35 Tagen offen.</p>"),
f"Abo {name} gekündigt wegen unbezahlter Rechnung {rg}."
)
except Exception as e:
logger.warning(f"Invoice-Reminder Fehler für {inv.get('invoice_number','?')}: {e}")
async def _job_subscription_check():
"""Abgelaufene Abos auf Standard setzen; Warnmails 30 und 7 Tage vorher."""
from database import db as _db
@ -217,7 +496,8 @@ async def _job_subscription_check():
with _db() as conn:
users = conn.execute(
"""SELECT id, name, email, subscription_tier, subscription_expires_at
"""SELECT id, name, email, subscription_tier, subscription_expires_at,
subscription_cancelled_at
FROM users
WHERE subscription_tier IN ('pro','breeder')
AND subscription_expires_at IS NOT NULL"""
@ -253,7 +533,7 @@ async def _job_subscription_check():
await send_email(u["email"], f"Dein {tier_label}-Abo ist abgelaufen", html,
f"Hallo {u['name']},\ndein {tier_label}-Abo ist abgelaufen. Daten bleiben erhalten.")
# 30 Tage Warnung
# 30 Tage Warnung + Erneuerungsrechnung als Entwurf anlegen
elif days_left == 30:
body = f"""
<p>Hallo {_html.escape(u['name'])},</p>
@ -265,7 +545,10 @@ async def _job_subscription_check():
await send_email(u["email"], f"Dein {tier_label}-Abo läuft in 30 Tagen ab", html,
f"Hallo {u['name']},\ndein {tier_label}-Abo läuft in 30 Tagen ab ({expires}).")
# 7 Tage Warnung
# Erneuerungsrechnung als Entwurf anlegen (nur wenn noch keine existiert)
await _create_renewal_invoice_draft(u, expires, tier_label)
# 7 Tage — Warnung an User + Erinnerung an René falls Entwurf noch nicht versendet
elif days_left == 7:
body = f"""
<p>Hallo {_html.escape(u['name'])},</p>
@ -275,6 +558,7 @@ async def _job_subscription_check():
html = email_html(body, cta_url="https://banyaro.app", cta_label="Abo verlängern")
await send_email(u["email"], f"Nur noch 7 Tage — {tier_label}-Abo läuft ab", html,
f"Hallo {u['name']},\nnur noch 7 Tage für dein {tier_label}-Abo.")
await _remind_renewal_invoice(u, expires, tier_label)
except Exception as e:
logger.warning(f"subscription_check Fehler für {u['email']}: {e}")

View file

@ -3087,8 +3087,8 @@ html.modal-open {
}
@media (min-width: 768px) {
.map-full-layout { top: 0; left: var(--nav-sidebar-width); bottom: 0; }
/* Zoom-Control und Filter-Tabs unter die Statusleiste schieben */
.map-full-layout .leaflet-top { padding-top: 28px; }
/* Zoom-Control: mittig zu beiden Filter-Reihen */
.map-full-layout .leaflet-top { padding-top: 30px; }
}
.map-full { width: 100%; height: 100%; }
@ -3137,8 +3137,7 @@ html.modal-open {
.map-legend-label { font-size: 10px; }
.map-legend-all {
font-size: 1rem;
min-width: 32px;
padding: 0 var(--space-2);
padding: 5px 10px;
background: var(--c-surface-2);
border-color: var(--c-border);
color: var(--c-text-secondary);
@ -3150,6 +3149,73 @@ html.modal-open {
color: #fff;
}
/* Dark-Mode: Karten-UI-Elemente (manuell + System) */
:root[data-theme="dark"] .map-legend-btn,
:root:not([data-theme="light"]) .map-legend-btn.dark-map {
background: rgba(24,20,16,0.88);
color: rgba(255,255,255,0.8);
border-color: rgba(255,255,255,0.15);
}
:root[data-theme="dark"] .map-legend-btn {
background: rgba(24,20,16,0.88);
color: rgba(255,255,255,0.8);
border-color: rgba(255,255,255,0.15);
}
:root[data-theme="dark"] .map-legend-btn.active {
background: var(--layer-color, var(--c-primary));
color: #fff;
border-color: var(--layer-color, var(--c-primary));
}
:root[data-theme="dark"] .map-legend-all {
background: rgba(36,28,20,0.92);
border-color: rgba(255,255,255,0.2);
color: rgba(255,255,255,0.7);
}
:root[data-theme="dark"] .map-legend-all.all-off {
background: rgba(10,8,6,0.92);
border-color: rgba(255,255,255,0.1);
color: rgba(255,255,255,0.9);
}
:root[data-theme="dark"] .map-statusbar {
background: rgba(24,20,16,0.92);
border-color: rgba(255,255,255,0.1);
color: rgba(255,255,255,0.7);
}
:root[data-theme="dark"] .leaflet-popup-content-wrapper,
:root[data-theme="dark"] .leaflet-popup-tip {
background: #241C14;
color: rgba(255,255,255,0.85);
box-shadow: 0 4px 16px rgba(0,0,0,0.5);
}
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) .map-legend-btn {
background: rgba(24,20,16,0.88);
color: rgba(255,255,255,0.8);
border-color: rgba(255,255,255,0.15);
}
:root:not([data-theme="light"]) .map-legend-btn.active {
background: var(--layer-color, var(--c-primary));
color: #fff;
border-color: var(--layer-color, var(--c-primary));
}
:root:not([data-theme="light"]) .map-legend-all {
background: rgba(36,28,20,0.92);
border-color: rgba(255,255,255,0.2);
color: rgba(255,255,255,0.7);
}
:root:not([data-theme="light"]) .map-statusbar {
background: rgba(24,20,16,0.92);
border-color: rgba(255,255,255,0.1);
color: rgba(255,255,255,0.7);
}
:root:not([data-theme="light"]) .leaflet-popup-content-wrapper,
:root:not([data-theme="light"]) .leaflet-popup-tip {
background: #241C14;
color: rgba(255,255,255,0.85);
box-shadow: 0 4px 16px rgba(0,0,0,0.5);
}
}
/* FAB-Gruppe rechts unten — direkt über dem Zurück-Button */
.map-fabs {
position: absolute;
@ -5161,9 +5227,9 @@ html.modal-open {
/* "Stirbt der Hund?" Tags */
.movie-tag-stirbt {
background: #fef2f2;
color: #dc2626;
border: 1.5px solid #dc2626;
background: var(--c-danger-subtle);
color: var(--c-danger);
border: 1.5px solid var(--c-danger-border);
border-radius: var(--radius-md);
padding: var(--space-2) var(--space-3);
font-size: var(--text-xs);
@ -5173,9 +5239,9 @@ html.modal-open {
}
.movie-tag-ueberlebt {
background: #f0fdf4;
color: #16a34a;
border: 1.5px solid #16a34a;
background: var(--c-success-subtle);
color: var(--c-success);
border: 1.5px solid color-mix(in srgb, var(--c-success) 30%, transparent);
border-radius: var(--radius-md);
padding: var(--space-2) var(--space-3);
font-size: var(--text-xs);
@ -7876,76 +7942,83 @@ svg.empty-state-icon {
}
#wp-welt { overflow: hidden; position: relative; }
/* Navigation-Punkte */
/* Navigation-Punkte — auf Mobile ausgeblendet, Labels übernehmen */
#world-dots {
position: fixed;
top: calc(env(safe-area-inset-top, 0px) + 14px);
left: 0; right: 0;
display: flex;
justify-content: center;
gap: 5px;
z-index: 60;
pointer-events: none;
}
.wdot {
width: 6px; height: 6px;
border-radius: 50%;
background: var(--c-text);
opacity: 0.2;
transition: all 0.25s cubic-bezier(0.34, 1.56, 0.64, 1);
cursor: pointer;
}
.wdot.active {
width: 22px;
border-radius: 3px;
opacity: 1;
background: var(--c-primary);
display: none;
}
/* Welt-Labels */
/* Welt-Labels — jetzt unten als Tab-Bar */
#world-labels {
position: fixed;
top: calc(env(safe-area-inset-top, 0px) + 28px);
bottom: calc(env(safe-area-inset-bottom, 0px) + 33px);
top: auto;
left: 0; right: 0;
display: flex;
justify-content: center;
gap: 28px;
gap: 8px;
z-index: 59;
pointer-events: none;
}
.wlabel {
font-size: 9px;
font-size: 10px;
font-weight: 800;
letter-spacing: 0.12em;
letter-spacing: 0.1em;
color: white;
opacity: 0.4;
opacity: 0.45;
text-transform: uppercase;
transition: opacity 0.18s;
}
.wlabel.active { opacity: 1; }
@media (min-width: 768px) {
#world-labels {
gap: 40px;
top: calc(env(safe-area-inset-top, 0px) + 18px);
}
.wlabel {
font-size: 13px;
letter-spacing: 0.18em;
opacity: 0.55;
padding: 6px 14px;
border-radius: 20px;
text-shadow: 0 1px 6px rgba(0,0,0,0.7);
transition: opacity 0.18s, background 0.18s;
}
.wlabel:hover {
opacity: 0.85;
background: rgba(255, 255, 255, 0.12);
padding: 6px 16px;
border-radius: 20px;
}
.wlabel.active {
opacity: 1;
background: rgba(255, 255, 255, 0.18);
text-shadow: 0 1px 8px rgba(0,0,0,0.5);
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
}
@media (min-width: 768px) {
/* Desktop: Nav bleibt unten — nur Abstände anpassen */
.world-panel {
padding-top: calc(env(safe-area-inset-top, 0px) + 48px);
}
/* Top-Bereich (Greeting + Wetter/Route/Übung) zentriert und begrenzt */
.world-top {
max-width: 860px;
margin-left: auto;
margin-right: auto;
width: 100%;
}
/* Alle Chips in einer Zeile, zentriert, egal wie viele aktiv */
.world-chips-grid {
display: flex !important;
flex-direction: row !important;
flex-wrap: nowrap !important;
justify-content: center !important;
grid-template-columns: unset !important;
max-width: none !important;
margin: 0 !important;
gap: 7px !important;
}
.world-chip {
flex: 0 1 80px !important;
min-width: 60px !important;
width: 80px !important;
height: 74px !important;
}
/* Nav vertikal zentriert zwischen Chips und Footer */
#world-labels {
gap: 24px;
bottom: calc(env(safe-area-inset-bottom, 0px) + 33px);
}
.wlabel {
font-size: 12px;
letter-spacing: 0.14em;
padding: 7px 18px;
}
.wlabel:hover {
opacity: 0.85;
background: rgba(255, 255, 255, 0.12);
}
}
@ -8041,12 +8114,13 @@ svg.empty-state-icon {
display: flex;
flex-direction: column;
justify-content: space-between; /* Info oben, Chips unten */
padding: calc(env(safe-area-inset-top, 0px) + 58px) 14px
calc(env(safe-area-inset-bottom, 0px) + 88px);
padding: calc(env(safe-area-inset-top, 0px) + 14px) 14px
calc(env(safe-area-inset-bottom, 0px) + 76px);
overflow-y: auto;
overflow-x: hidden;
-webkit-overflow-scrolling: touch;
overscroll-behavior-y: contain;
position: relative; /* Anker für absolut positionierte Footer-Links */
}
/* Content-Divs füllen den Panel und verteilen Top/Bottom */
@ -8061,8 +8135,8 @@ svg.empty-state-icon {
/* Oberer Bereich: Info + Reminders */
.world-top { display: flex; flex-direction: column; gap: 10px; }
/* Unterer Bereich: Chips (Daumen-Zone) */
.world-bottom { display: flex; flex-direction: column; gap: 8px; }
/* Unterer Bereich: Chips (Daumen-Zone) — kompakt, ganz unten */
.world-bottom { display: flex; flex-direction: column; gap: 5px; }
/* Frosted-Glass Info-Card (oben in jeder Welt) */
.world-info-card {
@ -8107,9 +8181,8 @@ svg.empty-state-icon {
.world-chips-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
grid-auto-rows: 80px; /* alle Chips gleich hoch */
gap: 8px;
margin-top: auto;
grid-auto-rows: 74px;
gap: 7px;
}
/* Einzelner Chip: Frosted Glass */
@ -8130,7 +8203,8 @@ svg.empty-state-icon {
transition: background 0.12s, transform 0.1s;
-webkit-tap-highlight-color: transparent;
user-select: none;
min-height: 80px; /* alle Chips gleich hoch */
height: 74px;
overflow: hidden;
}
.world-chip:active {
background: rgba(0, 0, 0, 0.6);
@ -8142,7 +8216,7 @@ svg.empty-state-icon {
font-weight: 600;
color: rgba(255, 255, 255, 0.9);
line-height: 1.2;
max-height: 2.4em; /* max. 2 Zeilen */
max-height: 24px; /* 2 Zeilen bei 10px — px statt em, damit iOS-Schriftgröße nicht skaliert */
overflow: hidden;
display: -webkit-box;
-webkit-line-clamp: 2;
@ -8208,11 +8282,14 @@ svg.empty-state-icon {
/* Footer-Links (Impressum / Die 100 / Datenschutz) */
.world-footer-links {
position: absolute;
bottom: calc(env(safe-area-inset-bottom, 0px) + 4px);
left: 0; right: 0;
text-align: center;
padding: 10px 0 2px;
padding: 0;
}
.world-footer-links span {
font-size: 11px;
font-size: 10px;
color: rgba(255, 255, 255, 0.6);
cursor: pointer;
letter-spacing: 0.05em;
@ -8646,6 +8723,34 @@ svg.empty-state-icon {
font-weight: 600;
}
/* ----------------------------------------------------------
W3-Overlays Desktop: zentrierte Dialogs statt Bottom-Sheets
---------------------------------------------------------- */
@media (min-width: 768px) {
.w3-sheet-overlay {
justify-content: center;
align-items: center;
}
.w3-backdrop {
backdrop-filter: blur(4px);
}
.w3-sheet-panel {
border-radius: 20px;
width: 90%;
max-width: 480px;
padding: 24px;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.4);
}
.w3-sheet-panel--scroll {
max-width: 680px;
max-height: 80vh;
}
/* all-chips Grid auf Desktop: auto-fill statt repeat(4,1fr) */
.w3-sheet-panel--scroll [style*="grid-template-columns:repeat(4,1fr)"] {
grid-template-columns: repeat(auto-fill, minmax(100px, 1fr)) !important;
}
}
/* ----------------------------------------------------------
Settings / Dog-Profile: Card-Sektion-Header
(uppercase Label mit Border-Bottom)

View file

@ -17,6 +17,7 @@
/* Oberflächen — Warmweiß und Strohbeige */
--c-bg: #FAF7F2;
--c-bg-secondary: #F2EDE4; /* Karten auf Seitenhintergrund */
--c-surface: #FFFFFF;
--c-surface-2: #EDE5D4;
--c-surface-3: #DDD0BB;
@ -122,6 +123,7 @@
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]):not([data-theme="dark"]) {
--c-bg: #1A1410;
--c-bg-secondary: #221A12; /* Karten auf Seitenhintergrund (dunkler) */
--c-surface: #241C14;
--c-surface-2: #2E2418;
--c-surface-3: #3A2E20;
@ -158,6 +160,7 @@
/* Manuelles Dark-Theme via data-theme="dark" (überschreibt auch prefers-color-scheme: light) */
:root[data-theme="dark"] {
--c-bg: #1A1410;
--c-bg-secondary: #221A12;
--c-surface: #241C14;
--c-surface-2: #2E2418;
--c-surface-3: #3A2E20;
@ -186,6 +189,16 @@
--shadow-xl: 0 16px 40px rgba(0, 0, 0, 0.50), 0 8px 16px rgba(0, 0, 0, 0.35);
}
/* Global Dark-Mode für alle Leaflet-Karten (map, walks, lost, poison, forum, routes …) */
:root[data-theme="dark"] .leaflet-tile-pane {
filter: invert(93%) hue-rotate(180deg) brightness(0.88) contrast(0.88) saturate(0.85);
}
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) .leaflet-tile-pane {
filter: invert(93%) hue-rotate(180deg) brightness(0.88) contrast(0.88) saturate(0.85);
}
}
/* ------------------------------------------------------------
2. RESET & BASE
------------------------------------------------------------ */

View file

@ -535,6 +535,20 @@
margin: 0 auto;
}
/* Desktop: Standard-Container auf 860px erweitern (768px1023px) */
@media (min-width: 768px) {
.page-container { max-width: 860px; }
}
/* Desktop-Breite: von app.js nach Page-Init gesetzt */
@media (min-width: 768px) {
.pc-desktop {
max-width: 860px !important;
margin-left: auto !important;
margin-right: auto !important;
}
}
/* Wide-Layout für Karte und ähnliches */
.page-container-wide {
width: 100%;
@ -589,8 +603,39 @@
============================================================ */
@media (min-width: 1024px) {
/* Etwas breiterer Standard-Container auf großen Screens */
.page-container { max-width: 860px; }
/* Admin: breit + Sidebar-Layout */
#page-admin .page-container { max-width: 1200px; }
#page-admin .adm-shell {
display: flex;
gap: var(--space-4);
align-items: flex-start;
}
#page-admin .adm-tabs {
display: flex !important;
flex-direction: column;
width: 190px;
flex-shrink: 0;
gap: var(--space-1);
padding-bottom: 0;
position: sticky;
top: var(--space-3);
}
#page-admin .adm-tabs .by-tab {
justify-content: flex-start !important;
text-align: left !important;
padding-left: var(--space-3);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
#page-admin #adm-content {
flex: 1;
min-width: 0;
}
/* ----------------------------------------------------------
WELCOME: 2-spaltige Feature-Sections, zentrierter Hero
@ -688,7 +733,7 @@
white-space: nowrap;
}
/* Admin: Tabs auf 2 Zeilen */
/* Admin: Tabs als 2-zeiliges Grid (Mobile/Tablet) */
#page-admin .adm-tabs {
display: grid;
grid-template-columns: repeat(var(--adm-tab-cols, 4), minmax(0, 1fr));

View file

@ -101,9 +101,9 @@
</script>
<!-- CSS: Reihenfolge ist wichtig — ?v= zwingt Browser zur Neuladung -->
<link rel="stylesheet" href="/css/design-system.css?v=907">
<link rel="stylesheet" href="/css/layout.css?v=907">
<link rel="stylesheet" href="/css/components.css?v=907">
<link rel="stylesheet" href="/css/design-system.css?v=1070">
<link rel="stylesheet" href="/css/layout.css?v=1070">
<link rel="stylesheet" href="/css/components.css?v=1070">
</head>
<body>
@ -296,6 +296,7 @@
<div style="display:flex;gap:var(--space-3);justify-content:center">
<span data-page="impressum" style="cursor:pointer;text-decoration:underline">Impressum</span>
<span data-page="datenschutz" style="cursor:pointer;text-decoration:underline">Datenschutz</span>
<span data-page="agb" style="cursor:pointer;text-decoration:underline">AGB</span>
</div>
<div style="display:flex;justify-content:center">
<span data-page="gruender" style="cursor:pointer;font-weight:600;font-size:var(--text-xs);
@ -459,6 +460,10 @@
<div class="page-body page-container"></div>
</section>
<section class="page" id="page-breeder-editor">
<div class="page-body page-container"></div>
</section>
<section class="page" id="page-social">
<div class="page-body page-container"></div>
</section>
@ -487,6 +492,10 @@
<div class="page-body page-container"></div>
</section>
<section class="page" id="page-agb">
<div class="page-body page-container"></div>
</section>
<section class="page" id="page-widget">
<div class="page-body page-container"></div>
</section>
@ -503,6 +512,14 @@
<div class="page-body page-container"></div>
</section>
<section class="page" id="page-partner">
<div class="page-body page-container"></div>
</section>
<section class="page" id="page-partner-profil">
<div class="page-body page-container"></div>
</section>
<section class="page" id="page-jobs">
<div class="page-body page-container"></div>
</section>
@ -599,10 +616,10 @@
<div id="modal-container"></div>
<!-- JS: Reihenfolge ist wichtig — erst Basis, dann Features -->
<script src="/js/api.js?v=919"></script>
<script src="/js/ui.js?v=919"></script>
<script src="/js/app.js?v=919"></script>
<script src="/js/worlds.js?v=919"></script>
<script src="/js/api.js?v=1070"></script>
<script src="/js/ui.js?v=1070"></script>
<script src="/js/app.js?v=1070"></script>
<script src="/js/worlds.js?v=1070"></script>
<!-- Feature-Seiten werden lazy geladen -->
@ -620,6 +637,16 @@
}
window.addEventListener('offline', function() {
_updateBanner();
// Einmaliger Hinweis pro Session: App im Vordergrund lassen
if (!sessionStorage.getItem('by_offline_hint_shown')) {
sessionStorage.setItem('by_offline_hint_shown', '1');
setTimeout(function() {
window.UI?.toast?.info(
'App im Vordergrund lassen — so bleiben Offline-Funktionen wie GPS und Datenspeicherung aktiv.',
8000
);
}, 800);
}
// Queue-Count abfragen
if (navigator.serviceWorker) {
navigator.serviceWorker.ready.then(function(reg) {
@ -678,7 +705,7 @@
// Backup: controllerchange (falls updatefound nicht feuert)
// NICHT registrieren wenn diese Seite selbst durch einen SW-Reload entstand (_t= im URL)
// — verhindert Dauerschleife wenn clients.claim() erst nach Seitenstart feuert
if (!location.search.includes('_t=')) {
if (!window._BY_SW_RELOAD) {
navigator.serviceWorker.addEventListener('controllerchange', () => {
if (sessionStorage.getItem('by_skip_sw_reload')) {
sessionStorage.removeItem('by_skip_sw_reload');

View file

@ -3,11 +3,13 @@
Router, State-Management, Navigation, Initialisierung.
============================================================ */
const APP_VER = '961'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen
const APP_VERSION = '1.5.1'; // ← semantische Version, wird bei make release gesetzt
const APP_VER = '1070'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen
const APP_VERSION = '1.6.0'; // ← semantische Version, wird bei make release gesetzt
const IS_STAGING = location.hostname === 'staging.banyaro.app';
// Cache-Bust-Parameter nach Update-Reload sofort entfernen
if (location.search.includes('_t=')) history.replaceState(null, '', '/');
// Cache-Bust-Parameter nach Update-Reload sofort entfernen.
// Flag MUSS vor replaceState gesetzt werden — index.html liest es danach.
window._BY_SW_RELOAD = location.search.includes('_t=');
if (window._BY_SW_RELOAD) history.replaceState(null, '', '/');
const App = (() => {
@ -64,15 +66,19 @@ const App = (() => {
moderation: { title: 'Moderation', module: null, requiresAuth: true },
impressum: { title: 'Impressum', module: null },
datenschutz: { title: 'Datenschutz', module: null },
agb: { title: 'AGB', module: null },
widget: { title: 'Widget', module: null, requiresAuth: true },
notifications: { title: 'Aktuelles', module: null, requiresAuth: true },
breeder: { title: 'Züchter-Profil', module: null },
'breeder-editor': { title: 'Profil bearbeiten', module: null, requiresAuth: true },
litters: { title: 'Wurfverwaltung', module: null, requiresAuth: true },
wurfboerse: { title: 'Wurfbörse', module: null },
zuchthunde: { title: 'Zuchtkartei', module: null, requiresAuth: true },
laeufi: { title: 'Läufigkeit', module: null, requiresAuth: true },
'zucht-profil': { title: 'Hunde-Profil', module: null },
gruender: { title: '100 Gründer', module: null },
partner: { title: 'Unsere Partner', module: null },
'partner-profil': { title: 'Partner-Profil', module: null, requiresAuth: true },
jobs: { title: 'Wir suchen dich', module: null },
expenses: { title: 'Ausgaben', module: null, requiresAuth: true },
recalls: { title: 'Rückrufe', module: null },
@ -255,6 +261,8 @@ const App = (() => {
if (mod?.init) {
await mod.init(container, state, params);
page.module = mod;
// Desktop: erste Inhalts-Div auf Standardbreite setzen
_applyDesktopWidth(pageId, container);
} else {
// Platzhalter wenn Seite noch nicht gebaut
container.innerHTML = UI.emptyState({
@ -265,10 +273,13 @@ const App = (() => {
page.module = {}; // verhindert erneutes Laden
}
} catch {
const _offline = !navigator.onLine;
container.innerHTML = UI.emptyState({
icon: '🚧',
icon: _offline ? '📡' : '🚧',
title: pages[pageId].title,
text: 'Diese Seite ist noch in Entwicklung.',
text: _offline
? 'Diese Seite ist offline nicht verfügbar. Bitte öffne sie einmal mit Internetverbindung, damit sie gecacht wird.'
: 'Diese Seite ist noch in Entwicklung.',
});
page.module = {};
} finally {
@ -276,6 +287,23 @@ const App = (() => {
}
}
// ----------------------------------------------------------
// DESKTOP WIDTH — einheitliche Breite auf großen Screens
// ----------------------------------------------------------
const _FULLSCREEN_PAGES = new Set([
'admin','map','chat','forum','wiki','ernaehrung','movies','wurfboerse',
'routes','walks','litters','zucht-profil','widget',
]);
function _applyDesktopWidth(pageId, container) {
if (window.innerWidth < 768) return;
if (_FULLSCREEN_PAGES.has(pageId)) return;
const first = container.querySelector(':scope > div');
if (first && !first.classList.contains('page-container') &&
!first.classList.contains('pc-desktop')) {
first.classList.add('pc-desktop');
}
}
// ----------------------------------------------------------
// LOGIN GATE — wird statt Seiteninhalt angezeigt
// ----------------------------------------------------------
@ -585,11 +613,16 @@ const App = (() => {
_checkNearbyAlerts();
setInterval(() => { _updateNotifBadge(); _updateChatBadge(); }, 30_000);
setInterval(_checkNearbyAlerts, 5 * 60_000);
// App-Heartbeat: last_seen aktualisieren (Nutzungsfrequenz für Admin)
const _sendHeartbeat = () => API.post('/auth/heartbeat', {}).catch(() => {});
_sendHeartbeat();
setInterval(_sendHeartbeat, 5 * 60_000);
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'visible') {
_updateNotifBadge();
_updateChatBadge();
_checkNearbyAlerts();
_sendHeartbeat();
if (state.page === 'chat') {
pages['chat']?.module?.refresh?.();
}
@ -1140,6 +1173,21 @@ const App = (() => {
window.App = App; // Worlds kann App.navigate() aufrufen
// App starten
// Prioritäts-Seiten im Hintergrund vorladen (1s nach Start)
window.addEventListener('load', () => {
setTimeout(() => {
if (!navigator.onLine) return;
// Page-Scripts cachen
[
'admin','erste-hilfe','diary','map','walks','routes','poison','lost',
'expenses','wetter','forum','health','uebungen','trainingsplaene','notes',
].forEach(page => {
const key = `Page_${page.replace(/-/g,'_')}`;
if (!window[key]) fetch(`/js/pages/${page}.js?v=${APP_VER}`).catch(() => {});
});
}, 1000);
});
document.addEventListener('DOMContentLoaded', () => {
App.init();
if (IS_STAGING) {

View file

@ -27,6 +27,7 @@ window.Page_admin = (() => {
{ id: 'uebungen_admin', label: 'Übungen', icon: 'barbell' },
{ id: 'referrals', label: 'Referrals', icon: 'share-network' },
{ id: 'upgrades', label: 'Upgrades', icon: 'crown-simple' },
{ id: 'rechnungen', label: 'Rechnungen', icon: 'receipt' },
];
// ------------------------------------------------------------------
@ -55,7 +56,8 @@ window.Page_admin = (() => {
<!-- Action Items -->
<div id="adm-action-items" style="padding:var(--space-3) var(--space-3) 0"></div>
<!-- Tabs -->
<!-- Sidebar + Content (Desktop: nebeneinander) -->
<div class="adm-shell">
<div class="by-tabs adm-tabs" id="adm-tabs">
${TABS.map(t => `
<button class="by-tab${t.id === _tab ? ' active' : ''}" data-tab="${t.id}">
@ -63,9 +65,8 @@ window.Page_admin = (() => {
</button>
`).join('')}
</div>
<!-- Inhalt -->
<div id="adm-content"></div>
</div>
`;
_container.querySelector('#adm-tabs')
@ -97,6 +98,7 @@ window.Page_admin = (() => {
{ key: 'reports_open', label: 'Meldungen', tab: 'moderation', icon: 'warning' },
{ key: 'fotos_pending', label: 'Foto-Einreichungen',tab: 'moderation', icon: 'image' },
{ key: 'poi_edits_pending', label: 'POI-Korrekturen', tab: 'moderation', icon: 'map-pin' },
{ key: 'invoices_unpaid', label: 'Offene Rechnungen', tab: 'rechnungen', icon: 'receipt' },
];
const open = items.filter(i => d[i.key] > 0);
@ -166,6 +168,7 @@ window.Page_admin = (() => {
case 'uebungen_admin': await _renderUebungenAdmin(el); break;
case 'referrals': await _renderReferrals(el); break;
case 'upgrades': await _renderUpgrades(el); break;
case 'rechnungen': await _renderRechnungen(el); break;
}
} catch (e) {
el.innerHTML = _emptyState('warning', 'Fehler', e.message || 'Unbekannter Fehler.');
@ -3528,8 +3531,14 @@ window.Page_admin = (() => {
<div style="font-size:var(--text-xs);color:var(--c-text-muted);margin-bottom:var(--space-2)">${_esc(r.email)}</div>
<div style="display:flex;align-items:center;gap:var(--space-2);flex-wrap:wrap">
${tierBadge(r.tier)}
${r.discount_pct > 0 ? `<span style="display:inline-block;padding:1px 8px;border-radius:999px;
font-size:11px;font-weight:700;background:#e67e22;color:#fff;margin-left:4px">
${r.discount_pct}% Rabatt</span>` : ''}
<span style="font-size:var(--text-xs);color:var(--c-text-muted)">${r.created_at?.slice(0,10) || ''}</span>
</div>
${r.discount_reason === 'founder' ? `<div style="font-size:10px;color:#e67e22;margin-top:2px">Gründer — kostenfrei</div>` : ''}
${r.discount_reason === 'referred_by_founder' ? `<div style="font-size:10px;color:#e67e22;margin-top:2px">Von Gründer eingeladen</div>` : ''}
${r.discount_reason === 'referral' ? `<div style="font-size:10px;color:var(--c-text-muted);margin-top:2px">${r.referral_count} Freunde geworben</div>` : ''}
${r.message ? `<div style="margin-top:var(--space-2);font-size:var(--text-xs);color:var(--c-text-secondary);
padding:var(--space-2) var(--space-3);border-radius:var(--radius-md);
background:var(--c-surface-raised,rgba(0,0,0,.04))">
@ -3537,12 +3546,25 @@ window.Page_admin = (() => {
</div>` : ''}
</div>
</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:var(--space-2);margin-top:var(--space-3)">
<button class="btn adm-invoice-btn"
data-name="${_esc(r.name)}" data-email="${_esc(r.email)}"
data-tier="${r.tier}" data-address="${_esc(r.billing_address || '')}"
data-discount="${r.discount_pct || 0}"
data-discount-reason="${r.discount_reason || ''}"
data-referral-count="${r.referral_count || 0}"
style="background:#e67e22;color:#fff;border:none;
padding:var(--space-2) var(--space-3);border-radius:var(--radius-md);
cursor:pointer;font-size:var(--text-sm);font-weight:600">
${UI.icon('receipt')} Rechnung erstellen
</button>
<button class="btn adm-fulfill-btn" data-id="${r.id}" data-name="${_esc(r.name)}" data-tier="${r.tier}"
style="width:100%;margin-top:var(--space-3);background:#16a34a;color:#fff;border:none;
style="background:#16a34a;color:#fff;border:none;
padding:var(--space-2) var(--space-3);border-radius:var(--radius-md);
cursor:pointer;font-size:var(--text-sm);font-weight:600">
Freischalten
</button>
</div>
</div>`;
// Erledigte als kompakte Tabellenzeilen
@ -3588,7 +3610,7 @@ window.Page_admin = (() => {
const tierLabel = { pro: 'Pro', breeder: 'Züchter' }[tier] || tier;
const ok = await UI.modal.confirm({
title: `${name} auf ${tierLabel} freischalten?`,
message: `Der Account wird auf ${tierLabel} gesetzt und eine Bestätigungsmail gesendet.`,
message: `Der Account wird auf ${tierLabel} gesetzt und eine Bestätigungsmail gesendet.\n\nFalls noch keine Rechnung gesendet wurde, wird ein Entwurf automatisch angelegt.`,
confirmText: 'Freischalten',
danger: false,
});
@ -3597,7 +3619,14 @@ window.Page_admin = (() => {
btn.textContent = '…';
try {
const res = await API.post(`/admin/upgrade-requests/${id}/fulfill`);
if (res.invoice_number) {
UI.toast.success(
`${res.user} freigeschaltet · Entwurf ${res.invoice_number} unter Rechnungen versenden`,
6000
);
} else {
UI.toast.success(`${res.user} wurde auf ${tierLabel} freigeschaltet.`);
}
_renderTab();
_renderActionItems();
} catch (e) {
@ -3607,6 +3636,867 @@ window.Page_admin = (() => {
}
});
});
// "Rechnung erstellen" — öffnet Invoice-Modal mit vorbefüllten Nutzerdaten
const TIER_ITEMS = {
pro: { description: 'Ban Yaro Pro Jahresabo', unit_price: 29.00 },
breeder: { description: 'Ban Yaro Züchter Jahresabo', unit_price: 49.00 },
};
const _year = new Date().getFullYear();
const _now = new Date();
const _end = new Date(_now.getFullYear() + 1, _now.getMonth(), _now.getDate() - 1);
const _fmt = d => `${String(d.getDate()).padStart(2,'0')}.${String(d.getMonth()+1).padStart(2,'0')}.${d.getFullYear()}`;
const _period = `${_fmt(_now)} - ${_fmt(_end)}`;
function _discountNote(reason, count, pct, tierLabel) {
const agb = 'Jahresbeitrag gem. AGB. Bei vorzeitiger Kündigung keine anteilige Rückerstattung; Zugang bleibt bis Laufzeitende bestehen.';
if (reason === 'founder') return `Gründer-Sonderkonditionen: ${tierLabel} kostenfrei als Dankeschön für deine Unterstützung als Gründer! ${agb}`;
if (reason === 'referred_by_founder') return `Willkommen in der Gründer-Community! Als persönlich von einem Gründer eingeladenes Mitglied ist dein Jahresabo dauerhaft kostenfrei. ${agb}`;
if (reason === 'referral') return `Herzlichen Dank für deine Unterstützung! Für ${count} geworbene Freunde erhältst du ${pct}% Rabatt. ${agb}`;
return agb;
}
el.querySelectorAll('.adm-invoice-btn').forEach(btn => {
btn.addEventListener('click', () => {
const { name, email, tier, address } = btn.dataset;
const discountPct = Number(btn.dataset.discount) || 0;
const discountReason = btn.dataset.discountReason || '';
const referralCount = Number(btn.dataset.referralCount) || 0;
const tierItem = TIER_ITEMS[tier] || { description: 'Ban Yaro Abo', unit_price: 0 };
_openNeueRechnungModal(() => {
_tab = 'rechnungen';
_renderTab();
}, {
recipient_name: name,
recipient_email: email,
recipient_address: address || '',
service_period: _period,
discount_pct: discountPct,
notes: _discountNote(discountReason, referralCount, discountPct, tierItem.description),
items: [{ description: tierItem.description, quantity: 1, unit_price: tierItem.unit_price }],
});
});
});
}
// ------------------------------------------------------------------
// TAB: RECHNUNGEN
// ------------------------------------------------------------------
async function _renderRechnungen(el) {
let _subView = 'liste'; // 'liste' | 'cashflow'
async function _load() {
el.innerHTML = `
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:var(--space-3);flex-wrap:wrap;gap:var(--space-2)">
<div style="display:flex;gap:var(--space-2)">
<button class="btn btn-sm ${_subView === 'liste' ? 'btn-primary' : 'btn-ghost'} adm-inv-nav" data-v="liste">
${UI.icon('list-bullets')} Rechnungen
</button>
<button class="btn btn-sm ${_subView === 'cashflow' ? 'btn-primary' : 'btn-ghost'} adm-inv-nav" data-v="cashflow">
${UI.icon('chart-bar')} Cashflow
</button>
</div>
${_subView === 'liste' ? `
<button class="btn btn-sm btn-secondary" id="adm-inv-new">
${UI.icon('plus')} Neue Rechnung
</button>` : ''}
</div>
<div id="adm-inv-content">
<div style="padding:var(--space-6);text-align:center;color:var(--c-text-muted)">Lade</div>
</div>
`;
el.querySelectorAll('.adm-inv-nav').forEach(btn => {
btn.addEventListener('click', () => {
_subView = btn.dataset.v;
_load();
});
});
el.querySelector('#adm-inv-new')?.addEventListener('click', () => _openNeueRechnungModal(_load));
const content = el.querySelector('#adm-inv-content');
if (_subView === 'liste') {
await _loadInvoiceList(content, _load);
} else {
await _loadCashflow(content);
}
}
await _load();
}
async function _loadInvoiceList(el, reload) {
let invoices;
try {
invoices = await API.get('/admin/invoices');
} catch (e) {
el.innerHTML = _emptyState('warning', 'Fehler', e.message || 'Rechnungen konnten nicht geladen werden.');
return;
}
if (!invoices.length) {
el.innerHTML = _emptyState('receipt', 'Keine Rechnungen', 'Noch keine Rechnungen erstellt.');
return;
}
const _statusBadge = status => {
const cfg = {
draft: ['Entwurf', 'var(--c-text-muted)', 'var(--c-surface-2)', 'var(--c-border)'],
sent: ['Versendet', 'var(--c-primary)', 'var(--c-primary-subtle,#eff6ff)','var(--c-primary)'],
paid: ['Bezahlt', 'var(--c-success,#16a34a)','#d1fae5', 'var(--c-success,#16a34a)'],
cancelled: ['Storniert', 'var(--c-danger,#dc2626)', '#fee2e2', 'var(--c-danger,#dc2626)'],
};
const [label, color, bg, border] = cfg[status] || [status, 'var(--c-text-muted)', 'var(--c-surface-2)', 'var(--c-border)'];
return `<span style="display:inline-block;padding:1px 8px;border-radius:999px;font-size:11px;font-weight:700;
background:${bg};color:${color};border:1px solid ${border}">${label}</span>`;
};
const _fmtDate = iso => iso ? new Date(iso).toLocaleDateString('de-DE', {day:'2-digit',month:'2-digit',year:'numeric'}) : '—';
const _fmtEur = v => v != null ? Number(v).toLocaleString('de-DE', {minimumFractionDigits:2,maximumFractionDigits:2}) + ' €' : '—';
const rows = invoices.map((inv, i) => {
const actions = [];
if (inv.status === 'draft') {
actions.push(`<button class="btn btn-sm btn-ghost adm-inv-edit" data-id="${inv.id}" title="Bearbeiten">
${UI.icon('pencil')} Bearbeiten
</button>`);
actions.push(`<button class="btn btn-sm btn-primary adm-inv-send" data-id="${inv.id}" data-num="${_esc(inv.invoice_number)}" title="Senden">
${UI.icon('paper-plane-tilt')} Senden
</button>`);
}
if (inv.status === 'sent') {
actions.push(`<button class="btn btn-sm btn-ghost adm-inv-send" data-id="${inv.id}" data-num="${_esc(inv.invoice_number)}" title="Erneut senden"
style="color:var(--c-text-muted)">
${UI.icon('paper-plane-tilt')} Erneut senden
</button>`);
}
if (inv.status === 'sent') {
actions.push(`<button class="btn btn-sm btn-secondary adm-inv-pay" data-id="${inv.id}" data-amount="${inv.amount_gross}" title="Als bezahlt markieren">
${UI.icon('check-circle')} Bezahlt
</button>`);
actions.push(`<button class="btn btn-sm btn-ghost adm-inv-cancel" data-id="${inv.id}" data-num="${_esc(inv.invoice_number)}"
style="color:var(--c-danger)" title="Stornieren">
${UI.icon('x-circle')} Storno
</button>`);
}
if (inv.status === 'paid' || inv.status === 'cancelled') {
actions.push(`<button class="btn btn-sm btn-ghost adm-inv-detail" data-id="${inv.id}" title="Details">
${UI.icon('eye')} Details
</button>`);
}
return `
<tr style="${i % 2 === 1 ? 'background:var(--c-surface-2)' : ''}">
<td class="adm-td" style="font-weight:600;font-family:monospace;font-size:var(--text-xs)">
${_esc(inv.invoice_number)}
</td>
<td class="adm-td">
<div style="font-weight:500">${_esc(inv.recipient_name)}</div>
<div style="font-size:var(--text-xs);color:var(--c-text-muted)">${_esc(inv.recipient_email || '')}</div>
</td>
<td class="adm-td" style="text-align:right;font-weight:700;white-space:nowrap">
${_fmtEur(inv.amount_gross)}
${inv.status === 'paid' && inv.paid_amount != null && Math.abs(inv.paid_amount - inv.amount_gross) >= 0.01
? `<div style="font-size:10px;color:var(--c-warning,#d97706);font-weight:500">
erhalten: ${_fmtEur(inv.paid_amount)}
${inv.paid_amount < inv.amount_gross
? `<span style="color:var(--c-danger)">-${_fmtEur(inv.amount_gross - inv.paid_amount)}</span>`
: ''}
</div>`
: ''}
</td>
<td class="adm-td">${_statusBadge(inv.status)}</td>
<td class="adm-td" style="font-size:var(--text-xs);color:var(--c-text-muted);white-space:nowrap">
${_fmtDate(inv.created_at)}
</td>
<td class="adm-td" style="white-space:nowrap">
<div style="display:flex;gap:var(--space-1);justify-content:flex-end">${actions.join('')}</div>
</td>
</tr>`;
}).join('');
el.innerHTML = `
<div class="card adm-table-card">
<div class="adm-table-scroll">
<table class="adm-table">
<thead>
<tr style="background:var(--c-surface-2);text-align:left">
<th class="adm-th">Nummer</th>
<th class="adm-th">Empfänger</th>
<th class="adm-th" style="text-align:right">Betrag</th>
<th class="adm-th">Status</th>
<th class="adm-th">Erstellt</th>
<th class="adm-th"></th>
</tr>
</thead>
<tbody>${rows}</tbody>
</table>
</div>
</div>
`;
// Senden
el.querySelectorAll('.adm-inv-send').forEach(btn => {
btn.addEventListener('click', async () => {
const ok = await UI.modal.confirm({
title: `Rechnung ${btn.dataset.num} versenden?`,
message: 'Die Rechnung wird als PDF erzeugt und per E-Mail an den Empfänger versendet.',
confirmText: 'Jetzt versenden',
});
if (!ok) return;
btn.disabled = true;
try {
await API.post(`/admin/invoices/${btn.dataset.id}/send`, {});
UI.toast.success('Rechnung versendet.');
reload();
} catch (e) {
UI.toast.error(e.message || 'Fehler beim Versenden.');
btn.disabled = false;
}
});
});
// Entwurf bearbeiten
el.querySelectorAll('.adm-inv-edit').forEach(btn => {
btn.addEventListener('click', async () => {
const inv = await API.get(`/admin/invoices/${btn.dataset.id}`);
_openNeueRechnungModal(reload, {
recipient_name: inv.recipient_name,
recipient_email: inv.recipient_email,
recipient_address: inv.recipient_address || '',
service_period: inv.service_period || '',
discount_pct: inv.discount_pct || 0,
notes: inv.notes || '',
items: inv.items.map(it => ({ description: it.description, quantity: it.quantity, unit_price: it.unit_price })),
}, inv.id);
});
});
// Als bezahlt markieren
el.querySelectorAll('.adm-inv-pay').forEach(btn => {
btn.addEventListener('click', () => _openBezahltModal(btn.dataset.id, Number(btn.dataset.amount), reload));
});
// Stornieren
el.querySelectorAll('.adm-inv-cancel').forEach(btn => {
btn.addEventListener('click', () => _openStornoModal(btn.dataset.id, btn.dataset.num, reload));
});
// Details
el.querySelectorAll('.adm-inv-detail').forEach(btn => {
btn.addEventListener('click', () => _openDetailModal(btn.dataset.id));
});
}
function _openNeueRechnungModal(reload, prefill = null, invoiceId = null) {
const id = `inv-new-${Date.now()}`;
const p = prefill || {};
const isEdit = !!invoiceId;
UI.modal.open({
title: `${UI.icon('receipt')} ${isEdit ? 'Rechnung bearbeiten' : 'Neue Rechnung erstellen'}`,
body: `
<form id="${id}" style="display:flex;flex-direction:column;gap:var(--space-3)">
${!isEdit && !p.recipient_name ? `
<div style="padding:var(--space-2) var(--space-3);border-radius:var(--radius-md);
background:#fff8f0;border:1px solid #f0a060;
font-size:var(--text-xs);color:#c05000;line-height:1.6">
Diese Rechnung ist für <strong>sonstige Leistungen</strong> (Beratung, Einmalleistung etc.).<br>
Für Abo-Verlängerungen bitte den Button <strong>Rechnung erstellen"</strong> in der Upgrades-Liste verwenden.
</div>` : ''}
<!-- Empfänger -->
<div style="display:grid;grid-template-columns:1fr 1fr;gap:var(--space-3)">
<div>
<label class="form-label" style="font-size:var(--text-xs)">Empfänger Name *</label>
<input class="form-control" name="recipient_name" type="text" required
placeholder="Max Muster" value="${_esc(p.recipient_name || '')}">
</div>
<div>
<label class="form-label" style="font-size:var(--text-xs)">E-Mail</label>
<input class="form-control" name="recipient_email" type="email"
placeholder="max@example.com" value="${_esc(p.recipient_email || '')}">
</div>
</div>
<div>
<label class="form-label" style="font-size:var(--text-xs)">Adresse
${p.recipient_name && !p.recipient_address
? `<span style="color:var(--c-warning);font-size:10px"> ⚠ Nutzer hat keine Rechnungsadresse hinterlegt</span>`
: '<span style="color:var(--c-text-muted)">(optional)</span>'}
</label>
<textarea class="form-control" name="recipient_address" rows="2"
placeholder="Musterstr. 1&#10;12345 Berlin"
style="resize:vertical;font-family:inherit">${_esc(p.recipient_address || '')}</textarea>
</div>
<div>
<label class="form-label" style="font-size:var(--text-xs)">Leistungszeitraum <span style="color:var(--c-text-muted)">(optional)</span></label>
<input class="form-control" name="service_period" type="text"
placeholder="z.B. 15.05.2026 oder einmalige Leistung"
value="${_esc(p.service_period || '')}">
</div>
<!-- Positionen -->
<div>
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:var(--space-2)">
<label class="form-label" style="font-size:var(--text-xs);margin:0">Positionen *</label>
<button type="button" id="${id}-add-item"
style="font-size:var(--text-xs);color:var(--c-primary);background:none;border:none;cursor:pointer;padding:0;font-weight:600">
+ Position hinzufügen
</button>
</div>
<div id="${id}-items" style="display:flex;flex-direction:column;gap:var(--space-2)">
<!-- Items werden dynamisch eingefügt -->
</div>
</div>
<!-- Rabatt -->
<div style="display:grid;grid-template-columns:auto 1fr;gap:var(--space-3);align-items:center">
<div style="display:flex;align-items:center;gap:var(--space-2)">
<label class="form-label" style="font-size:var(--text-xs);margin:0;white-space:nowrap">Rabatt %</label>
<input class="form-control" name="discount_pct" type="number" min="0" max="100" value="${p.discount_pct ?? 0}"
style="width:80px" id="${id}-discount">
</div>
<!-- Live-Vorschau -->
<div id="${id}-preview" style="background:var(--c-surface-2);border-radius:var(--radius-md);
padding:var(--space-2) var(--space-3);font-size:var(--text-xs);text-align:right">
<span style="color:var(--c-text-muted)">Netto: </span>
</div>
</div>
<div>
<label class="form-label" style="font-size:var(--text-xs)">Notizen <span style="color:var(--c-text-muted)">(optional)</span></label>
<textarea class="form-control" name="notes" rows="2"
style="resize:vertical;font-family:inherit"
placeholder="Interne Notiz / Zahlungshinweis">${_esc(p.notes || (!isEdit && !p.recipient_name ? 'Zahlbar innerhalb von 14 Tagen ab Rechnungsdatum.' : ''))}</textarea>
</div>
</form>
`,
footer: `
<button class="btn btn-secondary" data-modal-close>Abbrechen</button>
<button class="btn btn-primary" form="${id}" type="submit">${UI.icon('receipt')} ${isEdit ? 'Änderungen speichern' : 'Rechnung erstellen'}</button>
`,
});
// Items-Container und Hilfsfunktionen
const itemsContainer = document.getElementById(`${id}-items`);
const previewEl = document.getElementById(`${id}-preview`);
const discountEl = document.getElementById(`${id}-discount`);
function _addItem(desc = '', qty = 1, price = 0) {
const itemEl = document.createElement('div');
itemEl.className = 'adm-inv-item-row';
itemEl.style.cssText = 'display:grid;grid-template-columns:1fr 60px 100px auto;gap:var(--space-2);align-items:center';
itemEl.innerHTML = `
<input class="form-control inv-item-desc" type="text" placeholder="Beschreibung *"
value="${_esc(desc)}" style="font-size:var(--text-sm)">
<input class="form-control inv-item-qty" type="number" min="1" value="${qty}"
style="font-size:var(--text-sm);text-align:right" title="Menge">
<input class="form-control inv-item-price" type="number" min="0" step="0.01" value="${price.toFixed(2)}"
style="font-size:var(--text-sm);text-align:right" title="Einzelpreis €">
<button type="button" class="btn btn-sm btn-ghost inv-item-remove"
style="color:var(--c-danger);padding:4px 8px;flex-shrink:0" title="Entfernen">
${UI.icon('x')}
</button>
`;
itemEl.querySelector('.inv-item-remove').addEventListener('click', () => {
if (itemsContainer.querySelectorAll('.adm-inv-item-row').length > 1) {
itemEl.remove();
_updatePreview();
}
});
itemEl.querySelectorAll('input').forEach(inp => inp.addEventListener('input', _updatePreview));
itemsContainer.appendChild(itemEl);
_updatePreview();
}
function _updatePreview() {
let netto = 0;
itemsContainer.querySelectorAll('.adm-inv-item-row').forEach(row => {
const qty = parseFloat(row.querySelector('.inv-item-qty').value) || 0;
const price = parseFloat(row.querySelector('.inv-item-price').value) || 0;
netto += qty * price;
});
const disc = Math.min(100, Math.max(0, parseFloat(discountEl?.value) || 0));
const rabatt = netto * disc / 100;
const brutto = netto - rabatt;
previewEl.innerHTML = `
<span style="color:var(--c-text-muted)">Netto: </span>
<strong>${netto.toLocaleString('de-DE',{minimumFractionDigits:2})} </strong>
${disc > 0 ? `&nbsp;·&nbsp;<span style="color:var(--c-danger)">-${rabatt.toLocaleString('de-DE',{minimumFractionDigits:2})} € (${disc}%)</span>` : ''}
&nbsp;·&nbsp;<span style="color:var(--c-success);font-weight:700">Brutto: ${brutto.toLocaleString('de-DE',{minimumFractionDigits:2})} </span>
`;
}
// Erste Position — aus Prefill oder Standard
if (p.items && p.items.length) {
p.items.forEach(it => _addItem(it.description, it.quantity ?? 1, it.unit_price ?? 0));
} else {
_addItem('Ban Yaro Pro Jahresabo', 1, 29.00);
}
// Weitere Position
document.getElementById(`${id}-add-item`)?.addEventListener('click', () => _addItem());
discountEl?.addEventListener('input', _updatePreview);
// Form Submit
document.getElementById(id)?.addEventListener('submit', async e => {
e.preventDefault();
const fd = new FormData(e.target);
const items = [];
itemsContainer.querySelectorAll('.adm-inv-item-row').forEach(row => {
const desc = row.querySelector('.inv-item-desc').value.trim();
const qty = parseFloat(row.querySelector('.inv-item-qty').value) || 1;
const price = parseFloat(row.querySelector('.inv-item-price').value) || 0;
if (desc) items.push({ description: desc, quantity: qty, unit_price: price });
});
if (!items.length) { UI.toast.warning('Mindestens eine Position angeben.'); return; }
const submitBtn = e.target.closest('.modal-content, [id]')
? document.querySelector(`button[form="${id}"]`)
: null;
if (submitBtn) submitBtn.disabled = true;
try {
const payload = {
recipient_name: fd.get('recipient_name'),
recipient_email: fd.get('recipient_email') || null,
recipient_address: fd.get('recipient_address') || null,
service_period: fd.get('service_period') || null,
discount_pct: parseFloat(fd.get('discount_pct')) || 0,
notes: fd.get('notes') || null,
items,
};
if (isEdit) {
await API.patch(`/admin/invoices/${invoiceId}`, payload);
} else {
await API.post('/admin/invoices', payload);
}
UI.modal.close();
UI.toast.success(isEdit ? 'Rechnung gespeichert.' : 'Rechnung erstellt.');
reload();
} catch (err) {
UI.toast.error(err.message || 'Fehler beim Erstellen.');
if (submitBtn) submitBtn.disabled = false;
}
});
}
function _openBezahltModal(invoiceId, defaultAmount, reload) {
const today = new Date().toISOString().slice(0, 10);
const id = `inv-pay-${Date.now()}`;
UI.modal.open({
title: `${UI.icon('check-circle')} Als bezahlt markieren`,
body: `
<form id="${id}" style="display:flex;flex-direction:column;gap:var(--space-3)">
<div>
<label class="form-label" style="font-size:var(--text-xs)">Zahlungsdatum *</label>
<input class="form-control" name="paid_at" type="date" value="${today}" required>
</div>
<div>
<label class="form-label" style="font-size:var(--text-xs)">Eingegangener Betrag () *</label>
<input class="form-control" name="paid_amount" id="${id}-amt" type="number" min="0" step="0.01"
value="${defaultAmount.toFixed(2)}" required>
</div>
<div id="${id}-diff" style="display:none;padding:var(--space-2) var(--space-3);
border-radius:var(--radius-md);background:#fff8f0;border:1px solid #f0a060;
font-size:var(--text-xs);color:#c05000;line-height:1.6"></div>
</form>
`,
footer: `
<button class="btn btn-secondary" data-modal-close>Abbrechen</button>
<button class="btn btn-primary" form="${id}" type="submit">${UI.icon('check-circle')} Als bezahlt markieren</button>
`,
});
// Differenz live anzeigen
const amtEl = document.getElementById(`${id}-amt`);
const diffEl = document.getElementById(`${id}-diff`);
const _checkDiff = () => {
const entered = parseFloat(amtEl?.value) || 0;
const diff = defaultAmount - entered;
if (Math.abs(diff) < 0.01) { diffEl.style.display = 'none'; return; }
diffEl.style.display = 'block';
if (diff > 0) {
diffEl.innerHTML = `Differenz: <strong>-${diff.toFixed(2)} €</strong> weniger als fakturiert.<br>
<label style="display:flex;align-items:center;gap:6px;margin-top:4px;cursor:pointer">
<input type="checkbox" id="${id}-kulanz">
<span>Als Kulanz/Forderungsverlust abschreiben (Notiz wird automatisch eingetragen)</span>
</label>`;
} else {
diffEl.innerHTML = `Überzahlung: <strong>+${(-diff).toFixed(2)} €</strong> mehr eingegangen.`;
diffEl.style.background = '#f0fff8';
diffEl.style.borderColor = '#34d399';
diffEl.style.color = '#065f46';
}
};
amtEl?.addEventListener('input', _checkDiff);
document.getElementById(id)?.addEventListener('submit', async e => {
e.preventDefault();
const fd = new FormData(e.target);
const paidAmount = parseFloat(fd.get('paid_amount'));
const diff = defaultAmount - paidAmount;
const kulanz = diff > 0.01 && document.getElementById(`${id}-kulanz`)?.checked;
const submitBtn = document.querySelector(`button[form="${id}"]`);
if (submitBtn) submitBtn.disabled = true;
try {
const kulanzNote = kulanz
? `Forderungsverlust/Kulanz: ${diff.toFixed(2)} EUR nicht eingegangen (${fd.get('paid_at')}). Als Kulanz abgeschrieben.`
: null;
await API.post(`/admin/invoices/${invoiceId}/pay`, {
paid_at: fd.get('paid_at'),
paid_amount: paidAmount,
...(kulanzNote ? { notes: kulanzNote } : {}),
});
UI.modal.close();
UI.toast.success(kulanz
? `Bezahlt (${paidAmount.toFixed(2)} €) · ${diff.toFixed(2)} € als Kulanz notiert.`
: 'Rechnung als bezahlt markiert.');
reload();
} catch (err) {
UI.toast.error(err.message || 'Fehler.');
if (submitBtn) submitBtn.disabled = false;
}
});
}
function _openStornoModal(invoiceId, invoiceNum, reload) {
const id = `inv-cancel-${Date.now()}`;
UI.modal.open({
title: `${UI.icon('x-circle')} Rechnung stornieren`,
body: `
<form id="${id}" style="display:flex;flex-direction:column;gap:var(--space-3)">
<p style="font-size:var(--text-sm);color:var(--c-text-secondary);margin:0">
Rechnung <strong>${_esc(invoiceNum)}</strong> stornieren.
</p>
<div>
<label class="form-label" style="font-size:var(--text-xs)">Stornierungsgrund *</label>
<input class="form-control" name="reason" type="text" required
placeholder="z. B. Kundenwunsch, Doppelabrechnung…">
</div>
</form>
`,
footer: `
<button class="btn btn-secondary" data-modal-close>Abbrechen</button>
<button class="btn btn-primary" form="${id}" type="submit"
style="background:var(--c-danger);border-color:var(--c-danger)">
${UI.icon('x-circle')} Rechnung stornieren
</button>
`,
});
document.getElementById(id)?.addEventListener('submit', async e => {
e.preventDefault();
const fd = new FormData(e.target);
const reason = (fd.get('reason') || '').trim();
if (!reason) { UI.toast.warning('Bitte einen Grund angeben.'); return; }
const submitBtn = document.querySelector(`button[form="${id}"]`);
if (submitBtn) submitBtn.disabled = true;
try {
await API.post(`/admin/invoices/${invoiceId}/cancel`, { reason });
UI.modal.close();
UI.toast.success('Rechnung storniert.');
reload();
} catch (err) {
UI.toast.error(err.message || 'Fehler.');
if (submitBtn) submitBtn.disabled = false;
}
});
}
async function _openDetailModal(invoiceId) {
let inv;
try {
inv = await API.get(`/admin/invoices/${invoiceId}`);
} catch (e) {
UI.toast.error(e.message || 'Detail konnte nicht geladen werden.');
return;
}
const _fmtDate = iso => iso ? new Date(iso).toLocaleDateString('de-DE', {day:'2-digit',month:'2-digit',year:'numeric'}) : '—';
const _fmtEur = v => v != null ? Number(v).toLocaleString('de-DE', {minimumFractionDigits:2,maximumFractionDigits:2}) + ' €' : '—';
const statusColors = {
draft: 'var(--c-text-muted)', sent: 'var(--c-primary)',
paid: 'var(--c-success,#16a34a)', cancelled: 'var(--c-danger,#dc2626)',
};
const statusLabels = { draft: 'Entwurf', sent: 'Versendet', paid: 'Bezahlt', cancelled: 'Storniert' };
const itemsHtml = (inv.items || []).map(item => `
<tr>
<td style="padding:6px 8px">${_esc(item.description)}</td>
<td style="padding:6px 8px;text-align:right">${item.quantity}</td>
<td style="padding:6px 8px;text-align:right">${_fmtEur(item.unit_price)}</td>
<td style="padding:6px 8px;text-align:right;font-weight:600">${_fmtEur(item.total)}</td>
</tr>
`).join('');
UI.modal.open({
title: `${UI.icon('receipt')} ${_esc(inv.invoice_number)}`,
body: `
<div style="display:flex;flex-direction:column;gap:var(--space-3);font-size:var(--text-sm)">
<div style="display:grid;grid-template-columns:1fr 1fr;gap:var(--space-3)">
<div>
<div style="font-size:var(--text-xs);color:var(--c-text-muted);margin-bottom:2px">Empfänger</div>
<div style="font-weight:600">${_esc(inv.recipient_name)}</div>
${inv.recipient_email ? `<div style="color:var(--c-text-muted);font-size:var(--text-xs)">${_esc(inv.recipient_email)}</div>` : ''}
${inv.recipient_address ? `<div style="color:var(--c-text-secondary);font-size:var(--text-xs);white-space:pre-line;margin-top:2px">${_esc(inv.recipient_address)}</div>` : ''}
</div>
<div>
<div style="font-size:var(--text-xs);color:var(--c-text-muted);margin-bottom:2px">Status</div>
<div style="font-weight:700;color:${statusColors[inv.status] || 'inherit'}">${statusLabels[inv.status] || inv.status}</div>
<div style="font-size:var(--text-xs);color:var(--c-text-muted);margin-top:4px">
Erstellt: ${_fmtDate(inv.created_at)}<br>
${inv.sent_at ? `Versendet: ${_fmtDate(inv.sent_at)}<br>` : ''}
${inv.paid_at ? `Bezahlt: ${_fmtDate(inv.paid_at)} · ${_fmtEur(inv.paid_amount)}<br>` : ''}
</div>
</div>
</div>
${inv.service_period ? `
<div>
<div style="font-size:var(--text-xs);color:var(--c-text-muted);margin-bottom:2px">Leistungszeitraum</div>
<div>${_esc(inv.service_period)}</div>
</div>` : ''}
<!-- Positionen -->
<div>
<div style="font-size:var(--text-xs);color:var(--c-text-muted);margin-bottom:var(--space-2)">Positionen</div>
<table style="width:100%;border-collapse:collapse;font-size:var(--text-xs)">
<thead>
<tr style="border-bottom:1px solid var(--c-border);color:var(--c-text-muted)">
<th style="text-align:left;padding:4px 8px">Beschreibung</th>
<th style="text-align:right;padding:4px 8px">Menge</th>
<th style="text-align:right;padding:4px 8px">Preis</th>
<th style="text-align:right;padding:4px 8px">Gesamt</th>
</tr>
</thead>
<tbody>${itemsHtml}</tbody>
<tfoot>
<tr style="border-top:2px solid var(--c-border)">
<td colspan="3" style="padding:6px 8px;text-align:right;font-weight:600">Gesamt (brutto)</td>
<td style="padding:6px 8px;text-align:right;font-weight:700;color:var(--c-primary)">${_fmtEur(inv.amount_gross)}</td>
</tr>
</tfoot>
</table>
</div>
${inv.notes ? `
<div>
<div style="font-size:var(--text-xs);color:var(--c-text-muted);margin-bottom:2px">Notizen</div>
<div style="background:var(--c-surface-2);border-radius:var(--radius-md);padding:var(--space-2) var(--space-3);
font-size:var(--text-xs);white-space:pre-wrap">${_esc(inv.notes)}</div>
</div>` : ''}
</div>
`,
footer: `<button class="btn btn-secondary" data-modal-close>Schließen</button>`,
});
}
async function _loadCashflow(el) {
let cf;
try {
cf = await API.get('/admin/invoices/cashflow');
} catch (e) {
el.innerHTML = _emptyState('warning', 'Fehler', e.message || 'Cashflow konnte nicht geladen werden.');
return;
}
const _fmtEur = v => v != null ? Number(v).toLocaleString('de-DE', {minimumFractionDigits:2,maximumFractionDigits:2}) + ' €' : '—';
const statusLabels = { draft: 'Entwürfe', sent: 'Versendet', paid: 'Bezahlt', cancelled: 'Storniert' };
const statusColors = { draft: 'var(--c-text-muted)', sent: 'var(--c-primary)', paid: 'var(--c-success,#16a34a)', cancelled: 'var(--c-danger,#dc2626)' };
const countKacheln = Object.entries(cf.counts || {}).map(([s, n]) => `
<div class="card" style="padding:var(--space-3);text-align:center">
<div style="font-size:var(--text-xl);font-weight:800;color:${statusColors[s] || 'var(--c-text)'}">${n}</div>
<div style="font-size:var(--text-xs);color:var(--c-text-secondary);margin-top:2px">${statusLabels[s] || s}</div>
</div>`).join('');
const monthRows = (cf.monthly || []).map((m, i) => `
<tr style="${i % 2 === 1 ? 'background:var(--c-surface-2)' : ''}">
<td class="adm-td">${_esc(m.month)}</td>
<td class="adm-td" style="text-align:right">${m.count}</td>
<td class="adm-td" style="text-align:right;font-weight:600">${_fmtEur(m.revenue)}</td>
</tr>`).join('');
// Quartalsbericht-Download
const currentYear = new Date().getFullYear();
const years = [currentYear, currentYear - 1].map(y => `<option value="${y}">${y}</option>`).join('');
el.innerHTML = `
<!-- Übersichtskacheln -->
<div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(150px,1fr));gap:var(--space-3);margin-bottom:var(--space-4)">
<div class="card" style="padding:var(--space-4);text-align:center">
<div style="font-size:var(--text-2xl);font-weight:800;color:var(--c-success,#16a34a)">${_fmtEur(cf.total_paid)}</div>
<div style="font-size:var(--text-xs);color:var(--c-text-secondary);margin-top:2px">Einnahmen (bezahlt)</div>
</div>
<div class="card" style="padding:var(--space-4);text-align:center">
<div style="font-size:var(--text-2xl);font-weight:800;color:var(--c-primary)">${_fmtEur(cf.total_outstanding)}</div>
<div style="font-size:var(--text-xs);color:var(--c-text-secondary);margin-top:2px">Offene Forderungen</div>
</div>
<div class="card" style="padding:var(--space-4);text-align:center">
<div style="font-size:var(--text-2xl);font-weight:800;color:var(--c-text)">${_fmtEur(cf.total_year)}</div>
<div style="font-size:var(--text-xs);color:var(--c-text-secondary);margin-top:2px">Jahresumsatz gesamt</div>
</div>
${countKacheln}
</div>
<!-- Monatliche Tabelle -->
<div class="card adm-table-card" style="margin-bottom:var(--space-4)">
<div style="padding:var(--space-3) var(--space-4);font-size:var(--text-xs);font-weight:700;
text-transform:uppercase;letter-spacing:.05em;color:var(--c-text-secondary);
border-bottom:1px solid var(--c-border)">Monatliche Übersicht</div>
<div class="adm-table-scroll">
<table class="adm-table">
<thead>
<tr style="background:var(--c-surface-2);text-align:left">
<th class="adm-th">Monat</th>
<th class="adm-th" style="text-align:right">Rechnungen</th>
<th class="adm-th" style="text-align:right">Umsatz</th>
</tr>
</thead>
<tbody>
${monthRows || `<tr><td colspan="3" style="padding:var(--space-4);text-align:center;color:var(--c-text-muted)">Keine Daten</td></tr>`}
</tbody>
</table>
</div>
</div>
<!-- Quartalsbericht -->
<div class="card" style="padding:var(--space-4)">
<div style="font-size:var(--text-sm);font-weight:700;margin-bottom:var(--space-3)">
${UI.icon('file-csv')} Quartalsbericht herunterladen
</div>
<div style="display:flex;gap:var(--space-3);align-items:flex-end;flex-wrap:wrap">
<div>
<label class="form-label" style="font-size:var(--text-xs)">Jahr</label>
<select id="adm-inv-year" class="form-control" style="width:auto">${years}</select>
</div>
<div>
<label class="form-label" style="font-size:var(--text-xs)">Quartal</label>
<select id="adm-inv-quarter" class="form-control" style="width:auto">
<option value="1">Q1 (JanMär)</option>
<option value="2">Q2 (AprJun)</option>
<option value="3">Q3 (JulSep)</option>
<option value="4">Q4 (OktDez)</option>
</select>
</div>
<button class="btn btn-secondary btn-sm" id="adm-inv-csv">
${UI.icon('download-simple')} CSV herunterladen
</button>
<button class="btn btn-ghost btn-sm" id="adm-inv-preview-q">
${UI.icon('eye')} Vorschau
</button>
</div>
<div id="adm-inv-q-result" style="margin-top:var(--space-3)"></div>
</div>
`;
// CSV Download
el.querySelector('#adm-inv-csv')?.addEventListener('click', async () => {
const year = el.querySelector('#adm-inv-year').value;
const q = el.querySelector('#adm-inv-quarter').value;
try {
const data = await API.get(`/admin/invoices/quarterly/${year}/${q}`);
if (!data.invoices?.length) { UI.toast.warning('Keine Rechnungen in diesem Quartal.'); return; }
// CSV generieren
const fmtEur = v => v != null ? Number(v).toFixed(2) : '0.00';
const fmtDate = iso => iso ? new Date(iso).toLocaleDateString('de-DE') : '';
const escape = v => `"${String(v || '').replace(/"/g, '""')}"`;
const statusLabel = { paid: 'Bezahlt', sent: 'Versendet', cancelled: 'Storniert (Original)', storno: 'Stornorechnung' };
const header = 'Nummer;Empfaenger;E-Mail;Datum;Leistungszeitraum;Betrag (eingegangen);Rechnungsbetrag;Status;Versendet am;Zahlungseingang\n';
const csvRows = data.invoices.map(inv => {
const effectiveAmt = (inv.status === 'paid' && inv.paid_amount != null) ? inv.paid_amount : inv.amount_gross;
return [
inv.invoice_number,
inv.recipient_name, inv.recipient_email || '',
fmtDate(inv.created_at), inv.service_period || '',
fmtEur(effectiveAmt),
fmtEur(inv.amount_gross),
statusLabel[inv.status] || inv.status,
fmtDate(inv.sent_at), fmtDate(inv.paid_at)
].map(escape).join(';');
}).join('\n');
const blob = new Blob(['' + header + csvRows], { type: 'text/csv;charset=utf-8;' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `banyaro-rechnungen-${year}-Q${q}.csv`;
a.click();
URL.revokeObjectURL(url);
UI.toast.success(`CSV mit ${data.invoices.length} Rechnungen heruntergeladen.`);
} catch (e) {
UI.toast.error(e.message || 'Fehler beim Laden.');
}
});
// Quartals-Vorschau
el.querySelector('#adm-inv-preview-q')?.addEventListener('click', async () => {
const year = el.querySelector('#adm-inv-year').value;
const q = el.querySelector('#adm-inv-quarter').value;
const resultEl = el.querySelector('#adm-inv-q-result');
resultEl.innerHTML = '<div style="color:var(--c-text-muted);font-size:var(--text-xs)">Lade…</div>';
try {
const data = await API.get(`/admin/invoices/quarterly/${year}/${q}`);
if (!data.invoices?.length) {
resultEl.innerHTML = `<div style="font-size:var(--text-xs);color:var(--c-text-muted)">Keine Rechnungen in ${data.period || `Q${q} ${year}`}.</div>`;
return;
}
const _fmtE = v => v != null ? Number(v).toLocaleString('de-DE',{minimumFractionDigits:2}) + ' €' : '—';
const _fmtD = iso => iso ? new Date(iso).toLocaleDateString('de-DE') : '—';
const sL = { draft:'Entwurf', sent:'Versendet', paid:'Bezahlt', cancelled:'Storniert (Orig.)', storno:'Stornorechnung' };
const rows2 = data.invoices.map((inv, i) => {
const isStorno = inv.status === 'storno';
const effectiveAmt = (inv.status === 'paid' && inv.paid_amount != null) ? inv.paid_amount : inv.amount_gross;
const amtColor = isStorno ? 'color:var(--c-danger)' : (effectiveAmt < 0 ? 'color:var(--c-danger)' : '');
const amtNote = (inv.status === 'paid' && inv.paid_amount != null && Math.abs(inv.paid_amount - inv.amount_gross) >= 0.01)
? ` <span style="font-size:var(--text-xs);color:var(--c-text-muted)">(RG: ${_fmtE(inv.amount_gross)})</span>` : '';
return `
<tr style="${i%2===1?'background:var(--c-surface-2)':''}">
<td class="adm-td" style="font-family:monospace;font-size:var(--text-xs);${isStorno?'color:var(--c-danger)':''}">${_esc(inv.invoice_number)}</td>
<td class="adm-td">${_esc(inv.recipient_name)}</td>
<td class="adm-td" style="text-align:right;font-weight:600;${amtColor}">${_fmtE(effectiveAmt)}${amtNote}</td>
<td class="adm-td" style="${isStorno?'color:var(--c-danger)':''}">${sL[inv.status]||inv.status}</td>
<td class="adm-td" style="font-size:var(--text-xs);color:var(--c-text-muted)">${_fmtD(inv.created_at)}</td>
</tr>`;
}).join('');
resultEl.innerHTML = `
<div style="font-size:var(--text-xs);font-weight:700;color:var(--c-text-secondary);margin-bottom:var(--space-2)">
${_esc(data.period || `Q${q} ${year}`)} ${data.count} Buchung(en) · Summe: ${_fmtE(data.total_gross)}
</div>
<div class="adm-table-scroll">
<table class="adm-table">
<thead><tr style="background:var(--c-surface-2)">
<th class="adm-th">Nummer</th><th class="adm-th">Empfänger</th>
<th class="adm-th" style="text-align:right">Betrag</th><th class="adm-th">Status</th>
<th class="adm-th">Erstellt</th>
</tr></thead>
<tbody>${rows2}</tbody>
<tfoot><tr style="border-top:2px solid var(--c-border)">
<td colspan="2" class="adm-td" style="font-weight:600">Gesamt</td>
<td class="adm-td" style="text-align:right;font-weight:700;color:var(--c-primary)">${_fmtE(data.total_gross)}</td>
<td colspan="2" class="adm-td" style="font-size:var(--text-xs);color:var(--c-text-muted)">
Netto: ${_fmtE(data.total_net)} · MwSt: ${_fmtE(data.total_tax)}
</td>
</tr></tfoot>
</table>
</div>`;
} catch (e) {
resultEl.innerHTML = `<div style="color:var(--c-danger);font-size:var(--text-xs)">Fehler: ${_esc(e.message)}</div>`;
}
});
}
return { init, refresh, onDogChange };

View file

@ -0,0 +1,195 @@
/* ============================================================
BAN YARO Allgemeine Geschäftsbedingungen
============================================================ */
window.Page_agb = (() => {
const S = {
h2: `font-size:var(--text-base);font-weight:var(--weight-semibold);color:var(--c-primary);margin:0 0 var(--space-2)`,
p: `font-size:var(--text-sm);color:var(--c-text-secondary);line-height:1.7;margin:0`,
ul: `font-size:var(--text-sm);color:var(--c-text-secondary);line-height:1.7;margin:var(--space-2) 0 0;padding-left:var(--space-5)`,
a: `color:var(--c-primary)`,
};
function sec(title, body) {
return `
<section style="margin-bottom:var(--space-6)">
<h2 style="${S.h2}">${title}</h2>
${body}
</section>`;
}
function init(container) {
container.innerHTML = `
<div style="max-width:640px;margin:0 auto;padding:var(--space-6) var(--space-4)">
<h1 style="font-size:var(--text-2xl);font-weight:var(--weight-bold);
color:var(--c-text);margin:0 0 var(--space-2)">Allgemeine Geschäftsbedingungen</h1>
<p style="${S.p};margin-bottom:var(--space-6)">Gültig ab Mai 2026</p>
${sec('1. Geltungsbereich', `
<p style="${S.p}">
Diese AGB gelten für die Nutzung der Plattform <strong>Ban Yaro</strong>
(<a href="https://banyaro.app" style="${S.a}">banyaro.app</a>), betrieben von:<br><br>
René Degelmann<br>
Ringstr. 26, 85560 Ebersberg<br>
E-Mail: <a href="mailto:hallo@banyaro.app" style="${S.a}">hallo@banyaro.app</a>
</p>
<p style="${S.p};margin-top:var(--space-3)">
Sie gelten ausschließlich für kostenpflichtige Abonnements. Die kostenlose Nutzung
der App setzt lediglich die Registrierung voraus.
</p>`)}
${sec('2. Mindestalter', `
<p style="${S.p}">
Die Nutzung von Ban Yaro, insbesondere die Registrierung und der Abschluss eines
Abonnements, ist nur Personen ab 18 Jahren gestattet. Mit Abschluss des Vertrags
bestätigt der Nutzer, volljährig zu sein.
</p>`)}
${sec('3. Leistungen', `
<p style="${S.p}">Ban Yaro bietet folgende kostenpflichtige Abonnements an:</p>
<ul style="${S.ul}">
<li>
<strong>Ban Yaro Pro 29 EUR/Jahr:</strong> Erweiterte App-Funktionen für mehrere
Hunde, KI-Features, zusätzliche Karten-Layer, Chat und Playdate-Funktion sowie
alle weiteren Pro-Funktionen laut aktuellem Funktionsumfang.
</li>
<li>
<strong>Ban Yaro Züchter 49 EUR/Jahr:</strong> Alle Pro-Funktionen plus
Zuchtkartei, Stammbaum, Wurfverwaltung und Züchterprofil.
</li>
</ul>
<p style="${S.p};margin-top:var(--space-3)">
Änderungen am Funktionsumfang werden vorab per E-Mail angekündigt. Wesentliche
Leistungsminderungen berechtigen zur außerordentlichen Kündigung.
</p>`)}
${sec('4. Nutzungsregeln / Community', `
<p style="${S.p}">Die Nutzung der Plattform-Features (Forum, Chat, Fotos, Kommentare) unterliegt folgenden Regeln:</p>
<ul style="${S.ul}">
<li>Keine rechtswidrigen, beleidigenden, diskriminierenden oder irreführenden Inhalte</li>
<li>Kein Spam, keine Werbung ohne Genehmigung, keine Fake-Accounts</li>
<li>Respektvoller Umgang mit anderen Nutzern</li>
<li>Keine Verletzung von Urheberrechten Dritter bei hochgeladenen Inhalten</li>
</ul>
<p style="${S.p};margin-top:var(--space-3)">
Bei Verstoß sind wir berechtigt, Inhalte zu entfernen und Accounts zu sperren oder
zu kündigen. Rechtswidrige Inhalte werden unverzüglich entfernt und ggf. Behörden
gemeldet. Meldungen können an
<a href="mailto:hallo@banyaro.app" style="${S.a}">hallo@banyaro.app</a>
gerichtet werden.
</p>`)}
${sec('5. Nutzerinhalte und Lizenzen', `
<p style="${S.p}">
Durch das Hochladen von Inhalten (Fotos, Texte, Beiträge) räumt der Nutzer Ban Yaro
eine nicht-exklusive, kostenlose, weltweite Lizenz ein, diese Inhalte im Rahmen der
Plattform zu speichern, anzuzeigen und technisch zu verarbeiten. Diese Lizenz erlischt
mit Löschung des Inhalts oder Löschung des Accounts. Urheberrechte und sonstige
Rechte der Nutzer an ihren Inhalten bleiben unberührt.
</p>`)}
${sec('6. Preise und Zahlung', `
<p style="${S.p}">
Der Jahresbeitrag ist bei Vertragsschluss für die gesamte Laufzeit im Voraus fällig.
Die Zahlung erfolgt per Überweisung IBAN und Verwendungszweck stehen auf der
Rechnung, die per E-Mail zugestellt wird. Der Betrag ist innerhalb von
<strong>14 Tagen</strong> nach Rechnungsstellung zu überweisen.
</p>
<p style="${S.p};margin-top:var(--space-3)">
Bei Zahlungsverzug erhalten Sie zunächst eine Zahlungserinnerung. Bleibt der Betrag
danach weiterhin ausstehend, behalten wir uns die fristlose Kündigung des Vertrags
gemäß § 314 BGB vor.
</p>`)}
${sec('7. Vertragslaufzeit und Kündigung', `
<p style="${S.p}">
Die Erstlaufzeit beträgt <strong>12 Monate</strong> ab dem Tag der Freischaltung.
Nach Ablauf verlängert sich der Vertrag auf unbestimmte Zeit kündbar jederzeit
mit einer Frist von <strong>einem Monat zum Monatsende</strong> (§ 309 Nr. 9 BGB).
</p>
<p style="${S.p};margin-top:var(--space-3)">
Die Kündigung ist jederzeit in den App-Einstellungen unter
<strong>Einstellungen Abonnement Kündigen</strong> möglich (§ 312k BGB).
Eine Kündigungsbestätigung wird per E-Mail zugesandt.
Der Zugang bleibt bis zum Ende der bereits bezahlten Laufzeit vollständig aktiv.
</p>`)}
${sec('8. Kein Erstattungsanspruch', `
<p style="${S.p}">
Bei vorzeitiger Kündigung durch den Nutzer erfolgt keine anteilige Rückerstattung
des Jahresbeitrags. Der Zugang bleibt bis zum Ende der Laufzeit vollständig nutzbar
du verlierst also nichts, was du bereits bezahlt hast.
Gesetzliche Ansprüche bei vertragswidrigen Leistungen bleiben unberührt.
</p>`)}
${sec('9. Widerrufsrecht', `
<p style="${S.p}">
Da die Nutzung unmittelbar nach Freischaltung beginnt und du beim Kauf ausdrücklich
zustimmst, dass die Vertragserfüllung vor Ablauf der Widerrufsfrist beginnt, erlischt
dein 14-tägiges Widerrufsrecht mit Beginn der Nutzung (§ 356 Abs. 4 BGB). Dir ist
bekannt, dass du durch diese Zustimmung dein Widerrufsrecht verlierst. Die Zustimmung
wird beim Kauf aktiv protokolliert.
</p>`)}
${sec('10. Fristlose Kündigung durch den Anbieter', `
<p style="${S.p}">
Wir sind berechtigt, den Vertrag aus wichtigem Grund fristlos zu kündigen
(§ 314 BGB). Ein wichtiger Grund liegt insbesondere vor, wenn nach einer
Zahlungserinnerung der offene Betrag weiterhin nicht beglichen wird.
In diesem Fall endet der Zugang mit Wirkung der Kündigung.
</p>`)}
${sec('11. KI-Funktionen / Haftung für KI-Inhalte', `
<p style="${S.p}">
KI-generierte Inhalte (Trainer-Empfehlungen, Gesundheitshinweise, Züchter-Analysen)
können fehlerhaft oder unvollständig sein. Sie dienen ausschließlich der allgemeinen
Information und ersetzen keine tierärztliche, veterinärmedizinische oder fachliche
Beratung. Ban Yaro haftet nicht für Schäden, die aus der Nutzung KI-generierter
Inhalte entstehen.
</p>`)}
${sec('12. Verfügbarkeit', `
<p style="${S.p}">
Wir streben eine hohe Verfügbarkeit von Ban Yaro an und arbeiten kontinuierlich
daran, die App stabil zu halten. Eine Garantie für ununterbrochene Verfügbarkeit
können wir jedoch nicht übernehmen. Geplante Wartungsarbeiten werden nach
Möglichkeit vorab in der App angekündigt.
</p>`)}
${sec('13. Änderungen dieser AGB', `
<p style="${S.p}">
Änderungen der AGB werden per <strong>E-Mail und in der App</strong> angekündigt
mindestens 4 Wochen vor Inkrafttreten. Widersprichst du den Änderungen nicht
innerhalb dieser Frist, gelten sie als angenommen. Dein Widerspruchsrecht und
das Recht zur außerordentlichen Kündigung bleiben unberührt.
</p>`)}
${sec('14. Anwendbares Recht', `
<p style="${S.p}">
Es gilt ausschließlich <strong>deutsches Recht</strong>. Als Verbraucher hast du
deinen allgemeinen Gerichtsstand. Die EU-Plattform zur Online-Streitbeilegung
(ec.europa.eu/consumers/odr) wurde eingestellt. Wir nehmen nicht an alternativen
Streitbeilegungsverfahren teil (§ 36 VSBG).
</p>`)}
${sec('15. Kontakt', `
<p style="${S.p}">
René Degelmann<br>
Ringstr. 26, 85560 Ebersberg<br>
E-Mail: <a href="mailto:hallo@banyaro.app" style="${S.a}">hallo@banyaro.app</a>
</p>`)}
<p style="font-size:var(--text-xs);color:var(--c-text-muted);margin:0">
Stand: Mai 2026 · Version 2
</p>
</div>
`;
}
function refresh() {}
return { init, refresh };
})();

View file

@ -32,6 +32,26 @@ window.Page_datenschutz = (() => {
E-Mail: <a href="mailto:hallo@banyaro.app" style="${S.a}">hallo@banyaro.app</a>
</p>`)}
${sec('Hosting &amp; Infrastruktur', `
<p style="${S.p}">
Die App wird auf einem eigenen Server (Synology DiskStation) in Deutschland betrieben.
Alle Daten werden ausschließlich auf diesem Server gespeichert und nicht an externe
Hoster übermittelt.
</p>
<p style="${S.p};margin-top:var(--space-3)">
Für den E-Mail-Versand (Kontobestätigung, Benachrichtigungen, Rechnungen) nutzen wir
<strong>Brevo</strong> (Sendinblue SAS, 55 rue d'Amsterdam, 75008 Paris, Frankreich).
Brevo ist nach EU-Standardvertragsklauseln zertifiziert. Dabei werden E-Mail-Adresse
und Name übermittelt. Datenschutzinformationen:
<a href="https://www.brevo.com/de/legal/privacypolicy/" target="_blank" rel="noopener"
style="${S.a}">brevo.com/de/legal/privacypolicy/</a>.
</p>
<p style="${S.p};margin-top:var(--space-3)">
Für anonymisierte Nutzungsstatistiken betreiben wir <strong>Umami Analytics</strong>
auf unserem eigenen Server. Es werden keine personenbezogenen Daten oder IP-Adressen
gespeichert. Kein Tracking über Sitzungen hinweg.
</p>`)}
${sec('Deine Daten gehören dir', `
<p style="${S.p}">
Ban Yaro ist eine private Community-App. Dein <strong>Tagebuch</strong>, deine
@ -70,6 +90,9 @@ window.Page_datenschutz = (() => {
Push-Benachrichtigungen. Einwilligungen können jederzeit mit Wirkung für die Zukunft
widerrufen werden (Art. 7 Abs. 3 DSGVO) einfach die entsprechende Funktion in den
Einstellungen deaktivieren oder die Browser-Freigabe entziehen.
</p>
<p style="${S.p};margin-top:var(--space-3)">
Impressum und rechtliche Grundlage nach § 5 DDG (Digitale-Dienste-Gesetz).
</p>`)}
${sec('Datenweitergabe', `
@ -92,6 +115,13 @@ window.Page_datenschutz = (() => {
Du kannst Gespräche jederzeit selbst löschen.
</p>`)}
${sec('Moderation &amp; Community', `
<p style="${S.p}">
Zur Sicherstellung der Plattformqualität und Einhaltung unserer Nutzungsregeln können
Moderatoren und automatische Systeme Inhalte prüfen. Rechtsgrundlage ist
Art. 6 Abs. 1 lit. f DSGVO (berechtigtes Interesse an sicherer Plattform).
</p>`)}
${sec('KI-Funktionen', `
<p style="${S.p}">
Ban Yaro bietet KI-gestützte Funktionen (Trainingsempfehlungen, Terminvorschläge,
@ -127,6 +157,12 @@ window.Page_datenschutz = (() => {
KI-Empfehlungen sind Vorschläge und ersetzen keine tierärztliche Beratung.
Eine automatisierte Entscheidungsfindung mit rechtlicher Wirkung (Art. 22 DSGVO)
findet nicht statt.
</p>
<p style="${S.p};margin-top:var(--space-3)">
KI-Antworten können fehlerhaft oder unvollständig sein und dienen ausschließlich
allgemeinen Informationszwecken. Sie ersetzen keine tierärztliche oder fachliche
Beratung. Trotz EU-Standardvertragsklauseln besteht bei US-Anbietern ein Restrisiko,
dass US-Behörden auf übermittelte Daten zugreifen könnten.
</p>`)}
${sec('Wetterdaten & Kartendienste', `
@ -179,6 +215,16 @@ window.Page_datenschutz = (() => {
style="${S.a}">openrouteservice.org/privacy-policy</a>
</p>`)}
${sec('Technische Speicherung', `
<p style="${S.p}">
Ban Yaro verwendet technisch notwendige Speichermechanismen für den Betrieb der App:
Session-Tokens und Authentifizierungsdaten werden im Local Storage des Browsers
gespeichert. Ein Service Worker speichert App-Inhalte lokal für die Offline-Nutzung
(Cache). Push-Benachrichtigungs-Token werden für die Zustellung von Hinweisen benötigt.
Diese Speicherung ist für die Kernfunktion der App erforderlich; eine Einwilligung ist
nach § 25 Abs. 2 TTDSG nicht erforderlich. Es werden keine Tracking-Cookies eingesetzt.
</p>`)}
${sec('Push-Benachrichtigungen', `
<p style="${S.p}">
Wenn du Push-Benachrichtigungen aktivierst, wird ein Abonnement-Token an den
@ -233,11 +279,28 @@ window.Page_datenschutz = (() => {
Du hast außerdem das Recht, bei der zuständigen Datenschutz-Aufsichtsbehörde
Beschwerde einzulegen:<br>
<strong>Bayerisches Landesamt für Datenschutzaufsicht (BayLDA)</strong><br>
Promenade 27, 91522 Ansbach<br>
Promenade 18, 91522 Ansbach<br>
<a href="mailto:poststelle@lda.bayern.de"
style="${S.a}">poststelle@lda.bayern.de</a> ·
<a href="https://www.lda.bayern.de" target="_blank" rel="noopener"
style="${S.a}">www.lda.bayern.de</a>
</p>`)}
${sec('Zahlungsdaten', `
<p style="${S.p}">
Wenn du ein kostenpflichtiges Abonnement abschließt, verarbeiten wir folgende Daten:
Name, E-Mail-Adresse, Rechnungsadresse und den Zahlungseingang. Rechtsgrundlage ist
Art. 6 Abs. 1 lit. b DSGVO (Vertragserfüllung). Rechnungsdaten werden gemäß
§ 147 AO <strong>10 Jahre</strong> aufbewahrt. Rechnungen werden per E-Mail mit
TLS-Verschlüsselung zugestellt.
</p>
<p style="${S.p};margin-top:var(--space-3)">
Deine Zahlungsdaten (IBAN) werden nur für die Zuordnung des Zahlungseingangs intern
verwendet und nicht an Dritte weitergegeben. Die vertraglichen Bedingungen (Laufzeit,
Kündigung, Erstattung) findest du in unseren
<a href="#agb" style="${S.a}">AGB</a>.
</p>`)}
${sec('Speicherdauer', `
<p style="${S.p}">
Deine Daten werden vollständig gelöscht, sobald du deinen Account löschst
@ -246,8 +309,14 @@ window.Page_datenschutz = (() => {
Server-Logs werden nach 30 Tagen rotiert.
</p>`)}
${sec('Mindestalter', `
<p style="${S.p}">
Die Nutzung von Ban Yaro ist nur Personen ab 18 Jahren gestattet. Durch die
Registrierung bestätigt der Nutzer, das 18. Lebensjahr vollendet zu haben.
</p>`)}
<p style="font-size:var(--text-xs);color:var(--c-text-muted);margin:0">
Stand: Mai 2026 · Version 2
Stand: Mai 2026 · Version 3
</p>
</div>

View file

@ -6,6 +6,8 @@
window.Page_diary = (() => {
const _CACHE_KEY = 'by_diary_cache';
// ----------------------------------------------------------
// MODUL-STATE
// ----------------------------------------------------------
@ -324,6 +326,7 @@ window.Page_diary = (() => {
async function _load() {
const dog = _appState.activeDog;
if (!dog) return;
const cacheKey = _CACHE_KEY + '_' + dog.id;
try {
const params = { limit: LIMIT, offset: _offset };
if (_searchQuery) params.q = _searchQuery;
@ -331,6 +334,10 @@ window.Page_diary = (() => {
const batch = await API.diary.list(dog.id, params);
_entries = _entries.concat(batch);
if (_offset === 0 && !_searchQuery && !_filterMilestone) {
try { localStorage.setItem(cacheKey, JSON.stringify({ ts: Date.now(), data: batch })); } catch {}
}
// "Mehr laden" anzeigen wenn volle Page geladen wurde
const loadMore = _container.querySelector('#diary-load-more');
if (loadMore) {
@ -339,7 +346,17 @@ window.Page_diary = (() => {
// Stats-Bar befüllen
_renderStatsBar();
} catch (err) {
} catch {
try {
const raw = localStorage.getItem(cacheKey);
if (raw) {
const cached = JSON.parse(raw).data || [];
_entries = cached;
_renderStatsBar();
UI.toast.info('Offline — zeige zuletzt geladene Einträge.');
return;
}
} catch {}
UI.toast.error('Einträge konnten nicht geladen werden.');
}
}
@ -1748,6 +1765,7 @@ window.Page_diary = (() => {
UI.toast.success('Eintrag gespeichert.');
} else {
const created = await API.diary.create(_appState.activeDog.id, payload);
if (created?._queued) { UI.modal.close(); return; }
if (_newFiles.length > 0) {
const { uploaded, exifGps } = await _uploadNewFiles(created.id);
created.media_items = uploaded;

View file

@ -24,12 +24,58 @@ window.Page_impressum = (() => {
<section style="margin-bottom:var(--space-6)">
<h2 style="font-size:var(--text-base);font-weight:var(--weight-semibold);
color:var(--c-text);margin:0 0 var(--space-2)">Kontakt</h2>
<p style="font-size:var(--text-sm);color:var(--c-text-secondary);line-height:1.7;margin:0">
<p style="font-size:var(--text-sm);color:var(--c-text-secondary);line-height:1.7;margin:0 0 var(--space-4)">
E-Mail: <a href="mailto:hallo@banyaro.app"
style="color:var(--c-primary)">hallo@banyaro.app</a><br>
Kontaktformular: <a href="mailto:hallo@banyaro.app"
style="color:var(--c-primary)">Nachricht senden</a>
Oder nutze das Formular wir antworten in der Regel innerhalb von 24 Stunden.
</p>
<form id="contact-form" style="display:flex;flex-direction:column;gap:var(--space-3)">
<div style="display:grid;grid-template-columns:1fr 1fr;gap:var(--space-3)">
<div>
<label style="display:block;font-size:var(--text-xs);font-weight:600;margin-bottom:4px;color:var(--c-text)">Name *</label>
<input id="cf-name" type="text" required maxlength="100"
placeholder="Dein Name"
style="width:100%;padding:var(--space-2) var(--space-3);border-radius:var(--radius-md);
border:1.5px solid var(--c-border);background:var(--c-surface);
color:var(--c-text);font-size:var(--text-sm);box-sizing:border-box">
</div>
<div>
<label style="display:block;font-size:var(--text-xs);font-weight:600;margin-bottom:4px;color:var(--c-text)">E-Mail *</label>
<input id="cf-email" type="email" required maxlength="200"
placeholder="deine@email.de"
style="width:100%;padding:var(--space-2) var(--space-3);border-radius:var(--radius-md);
border:1.5px solid var(--c-border);background:var(--c-surface);
color:var(--c-text);font-size:var(--text-sm);box-sizing:border-box">
</div>
</div>
<div>
<label style="display:block;font-size:var(--text-xs);font-weight:600;margin-bottom:4px;color:var(--c-text)">Betreff *</label>
<input id="cf-subject" type="text" required maxlength="150"
placeholder="Worum geht es?"
style="width:100%;padding:var(--space-2) var(--space-3);border-radius:var(--radius-md);
border:1.5px solid var(--c-border);background:var(--c-surface);
color:var(--c-text);font-size:var(--text-sm);box-sizing:border-box">
</div>
<div>
<label style="display:block;font-size:var(--text-xs);font-weight:600;margin-bottom:4px;color:var(--c-text)">Nachricht *</label>
<textarea id="cf-message" required maxlength="3000" rows="5"
placeholder="Deine Nachricht…"
style="width:100%;padding:var(--space-2) var(--space-3);border-radius:var(--radius-md);
border:1.5px solid var(--c-border);background:var(--c-surface);
color:var(--c-text);font-size:var(--text-sm);resize:vertical;
font-family:inherit;box-sizing:border-box"></textarea>
</div>
<div id="cf-status" style="display:none;padding:var(--space-2) var(--space-3);
border-radius:var(--radius-md);font-size:var(--text-sm)"></div>
<button id="cf-submit" type="submit"
style="align-self:flex-start;padding:var(--space-2) var(--space-5);
border-radius:var(--radius-full);border:none;cursor:pointer;
background:var(--c-primary);color:#fff;font-size:var(--text-sm);
font-weight:600">
Nachricht senden
</button>
</form>
</section>
<section style="margin-bottom:var(--space-6)">
@ -46,9 +92,6 @@ window.Page_impressum = (() => {
<h2 style="font-size:var(--text-base);font-weight:var(--weight-semibold);
color:var(--c-text);margin:0 0 var(--space-2)">Streitschlichtung</h2>
<p style="font-size:var(--text-sm);color:var(--c-text-secondary);line-height:1.7;margin:0">
Die Europäische Kommission stellt eine Plattform zur Online-Streitbeilegung (OS) bereit:
<a href="https://ec.europa.eu/consumers/odr" target="_blank" rel="noopener"
style="color:var(--c-primary)">https://ec.europa.eu/consumers/odr</a>.<br>
Wir sind nicht bereit und nicht verpflichtet, an einem Streitbeilegungsverfahren vor einer
Verbraucherschlichtungsstelle teilzunehmen (§ 36 VSBG).
</p>
@ -61,20 +104,73 @@ window.Page_impressum = (() => {
Die Inhalte dieser App wurden mit größtmöglicher Sorgfalt erstellt. Für die Richtigkeit,
Vollständigkeit und Aktualität der Inhalte übernehmen wir keine Gewähr. Als
Diensteanbieter sind wir gemäß § 7 Abs. 1 DDG für eigene Inhalte verantwortlich.
Für nutzergenerierte Inhalte (z. B. Forenbeiträge, Giftköder-Meldungen) übernehmen wir
keine Haftung; diese liegen in der Verantwortung der jeweiligen Nutzer.
Für nutzergenerierte Inhalte (Forenbeiträge, Fotos, Kommentare) sind ausschließlich
die jeweiligen Nutzer verantwortlich. Bei Bekanntwerden rechtswidriger Inhalte werden
diese im Rahmen der gesetzlichen Vorgaben (§§ 7 ff. DDG) geprüft und gegebenenfalls
unverzüglich entfernt.
</p>
</section>
<p style="font-size:var(--text-xs);color:var(--c-text-muted);margin:0">
Stand: April 2026
Stand: Mai 2026
</p>
</div>
`;
}
function _initContactForm(container) {
const form = container.querySelector('#contact-form');
const statusEl = container.querySelector('#cf-status');
const submitBtn = container.querySelector('#cf-submit');
if (!form) return;
form.addEventListener('submit', async e => {
e.preventDefault();
const name = container.querySelector('#cf-name').value.trim();
const email = container.querySelector('#cf-email').value.trim();
const subject = container.querySelector('#cf-subject').value.trim();
const message = container.querySelector('#cf-message').value.trim();
submitBtn.disabled = true;
submitBtn.textContent = 'Wird gesendet…';
statusEl.style.display = 'none';
try {
const res = await fetch('/api/contact', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name, email, subject, message }),
});
if (!res.ok) {
const err = await res.json().catch(() => ({}));
throw new Error(err.detail || 'Fehler beim Senden.');
}
statusEl.style.display = 'block';
statusEl.style.background = 'var(--c-success-bg, #f0fdf4)';
statusEl.style.color = 'var(--c-success, #16a34a)';
statusEl.textContent = '✓ Nachricht gesendet — wir melden uns bald!';
form.reset();
} catch (err) {
statusEl.style.display = 'block';
statusEl.style.background = '#fef2f2';
statusEl.style.color = '#dc2626';
statusEl.textContent = err.message || 'Fehler beim Senden. Bitte versuche es später erneut.';
submitBtn.disabled = false;
submitBtn.textContent = 'Nachricht senden';
}
});
}
const _origInit = init;
function refresh() {}
return { init, refresh };
return {
init(container) {
_origInit(container);
_initContactForm(container);
},
refresh
};
})();

View file

@ -48,20 +48,20 @@ window.Page_laeufi = (() => {
</svg>
</div>`;
return `
<div id="breeder-private-header" style="background:linear-gradient(135deg,#1a1208,#2d1f0e);
border-bottom:1px solid rgba(196,132,58,.25);
<div id="breeder-private-header" style="background:var(--c-bg-secondary);
border-bottom:1px solid var(--c-border);
padding:var(--space-3) var(--space-4);
display:flex;align-items:center;gap:var(--space-3)">
${logoHtml}
<div style="flex:1;min-width:0">
<h2 style="margin:0 0 2px;font-size:var(--text-lg);font-weight:700;
color:rgba(255,255,255,.95);white-space:nowrap;overflow:hidden;
color:var(--c-text);white-space:nowrap;overflow:hidden;
text-overflow:ellipsis;line-height:1.2">${UI.escape(zwinger)}</h2>
<div style="display:flex;align-items:center;gap:var(--space-2)">
<svg style="width:11px;height:11px;color:var(--c-primary);flex-shrink:0" viewBox="0 0 256 256">
<use href="/icons/phosphor.svg#lock-key"></use>
</svg>
<span style="font-size:var(--text-xs);color:rgba(196,132,58,.7)">Privater Bereich · Nur du siehst das</span>
<span style="font-size:var(--text-xs);color:var(--c-text-secondary)">Privater Bereich · Nur du siehst das</span>
</div>
</div>
</div>`;
@ -69,14 +69,14 @@ window.Page_laeufi = (() => {
function _render() {
_container.innerHTML = `
<div style="max-width:860px">
<div style="width:100%;max-width:860px;margin:0 auto;box-sizing:border-box">
${_privateHeader()}
<div class="by-toolbar" style="margin-bottom:var(--space-4)">
<div class="by-toolbar" style="margin-bottom:var(--space-4);padding:0 var(--space-4)">
<h2 style="margin:0;font-size:var(--text-lg);font-weight:var(--weight-semibold)">
${UI.icon('thermometer')} Läufigkeit & Trächtigkeit
</h2>
</div>
<div id="laeufi-list">
<div id="laeufi-list" style="padding:0 var(--space-4) var(--space-6)">
<p style="color:var(--c-text-secondary);text-align:center;padding:var(--space-8)">Lädt</p>
</div>
</div>`;

View file

@ -31,13 +31,13 @@ window.Page_litters = (() => {
function _statusBadge(status) {
const map = {
geplant: { label: 'Geplant', color: '#6B7280' },
geboren: { label: 'Geboren', color: '#3B82F6' },
verfuegbar: { label: 'Verfügbar', color: '#22C55E' },
abgeschlossen: { label: 'Abgeschlossen', color: '#374151' },
geplant: { label: 'Geplant', cls: 'badge-warning' },
geboren: { label: 'Geboren', cls: 'badge-primary' },
verfuegbar: { label: 'Verfügbar', cls: 'badge-success' },
abgeschlossen: { label: 'Abgeschlossen', cls: 'badge-muted' },
};
const s = map[status] || { label: status, color: '#6B7280' };
return `<span class="litters-badge" style="background:${s.color}">${_esc(s.label)}</span>`;
const s = map[status] || { label: status, cls: 'badge-muted' };
return `<span class="badge ${s.cls}">${_esc(s.label)}</span>`;
}
function _fmtDate(iso) {
@ -54,12 +54,12 @@ window.Page_litters = (() => {
function _puppyStatusBadge(status) {
const map = {
verfuegbar: { label: 'Verfügbar', color: '#22C55E' },
reserviert: { label: 'Reserviert', color: '#F59E0B' },
abgegeben: { label: 'Abgegeben', color: '#6B7280' },
verfuegbar: { label: 'Verfügbar', cls: 'badge-success' },
reserviert: { label: 'Reserviert', cls: 'badge-warning' },
abgegeben: { label: 'Abgegeben', cls: 'badge-muted' },
};
const s = map[status] || { label: status, color: '#9CA3AF' };
return `<span class="litters-badge litters-badge--sm" style="background:${s.color}">${_esc(s.label)}</span>`;
const s = map[status] || { label: status, cls: 'badge-muted' };
return `<span class="badge badge-sm ${s.cls}">${_esc(s.label)}</span>`;
}
// ----------------------------------------------------------
@ -113,20 +113,20 @@ window.Page_litters = (() => {
</svg>
</div>`;
return `
<div id="breeder-private-header" style="background:linear-gradient(135deg,#1a1208,#2d1f0e);
border-bottom:1px solid rgba(196,132,58,.25);
<div id="breeder-private-header" style="background:var(--c-bg-secondary);
border-bottom:1px solid var(--c-border);
padding:var(--space-3) var(--space-4);
display:flex;align-items:center;gap:var(--space-3)">
${logoHtml}
<div style="flex:1;min-width:0">
<h2 style="margin:0 0 2px;font-size:var(--text-lg);font-weight:700;
color:rgba(255,255,255,.95);white-space:nowrap;overflow:hidden;
color:var(--c-text);white-space:nowrap;overflow:hidden;
text-overflow:ellipsis;line-height:1.2">${_esc(zwinger)}</h2>
<div style="display:flex;align-items:center;gap:var(--space-2)">
<svg style="width:11px;height:11px;color:var(--c-primary);flex-shrink:0" viewBox="0 0 256 256">
<use href="/icons/phosphor.svg#lock-key"></use>
</svg>
<span style="font-size:var(--text-xs);color:rgba(196,132,58,.7)">Privater Bereich · Nur du siehst das</span>
<span style="font-size:var(--text-xs);color:var(--c-text-secondary)">Privater Bereich · Nur du siehst das</span>
</div>
</div>
</div>`;

View file

@ -5,6 +5,43 @@
window.Page_lost = (() => {
// ----------------------------------------------------------
// OFFLINE-CACHE
// ----------------------------------------------------------
const _CACHE_KEY = 'by_lost_cache';
const _PENDING_KEY = 'by_lost_pending';
function _getPending() {
try { return JSON.parse(localStorage.getItem(_PENDING_KEY) || '[]'); } catch { return []; }
}
function _setPending(list) {
try { localStorage.setItem(_PENDING_KEY, JSON.stringify(list)); } catch {}
}
function _addPending(data) {
const list = _getPending();
const entry = { ...data, id: `pending_${Date.now()}`, _isPending: true,
created_at: new Date().toISOString() };
list.push(entry);
_setPending(list);
return entry;
}
async function _syncPending() {
if (!navigator.onLine) return;
const list = _getPending();
if (!list.length) return;
let ok = 0;
for (const item of [...list]) {
try {
const { id: _pid, _isPending, ...payload } = item;
await API.lost.report(payload);
_setPending(_getPending().filter(x => x.id !== item.id));
ok++;
} catch {}
}
if (ok > 0) { UI.toast.success(`${ok} Meldung(en) synchronisiert.`); _loadReports(); }
}
window.addEventListener('online', _syncPending);
// ----------------------------------------------------------
// MODUL-STATE
// ----------------------------------------------------------
@ -16,6 +53,24 @@ window.Page_lost = (() => {
let _reports = [];
let _userPos = null;
let _leafletLoaded = false;
let _stylesInjected = false;
function _injectStyles() {
if (_stylesInjected) return;
_stylesInjected = true;
const s = document.createElement('style');
s.textContent = `
@keyframes by-lost-pulse-r {
0%,100% { box-shadow: 0 0 0 0 rgba(231,76,60,.55), 0 2px 6px rgba(0,0,0,.3); }
50% { box-shadow: 0 0 0 11px rgba(231,76,60,0), 0 2px 6px rgba(0,0,0,.3); }
}
@keyframes by-lost-pulse-p {
0%,100% { box-shadow: 0 0 0 0 rgba(217,119,6,.55), 0 2px 6px rgba(0,0,0,.3); }
50% { box-shadow: 0 0 0 11px rgba(217,119,6,0), 0 2px 6px rgba(0,0,0,.3); }
}
`;
document.head.appendChild(s);
}
// ----------------------------------------------------------
// INIT
@ -113,6 +168,7 @@ window.Page_lost = (() => {
// KARTE INITIALISIEREN
// ----------------------------------------------------------
function _initMap() {
_injectStyles();
const mapEl = document.getElementById('lost-map');
if (!mapEl || !window.L || _map) return;
@ -180,7 +236,23 @@ window.Page_lost = (() => {
}
try {
_reports = await API.lost.list(_userPos.lat, _userPos.lon, 25);
const fetched = await API.lost.list(_userPos.lat, _userPos.lon, 25);
try { localStorage.setItem(_CACHE_KEY, JSON.stringify({ ts: Date.now(), data: fetched })); } catch {}
// Remove pending items already on the server (race: sync completed during fetch)
const rawPending = _getPending();
const dedupedPending = rawPending.filter(p =>
!fetched.some(f => f.name === p.name &&
Math.abs(f.lat - p.lat) < 0.0001 &&
Math.abs(f.lon - p.lon) < 0.0001)
);
if (dedupedPending.length < rawPending.length) _setPending(dedupedPending);
const pending = dedupedPending.map(p => ({
...p,
distanz_m: _haversine(_userPos.lat, _userPos.lon, p.lat, p.lon),
}));
_reports = [...pending, ...fetched];
_renderMarkers();
_renderHeld();
_renderList();
@ -191,6 +263,31 @@ window.Page_lost = (() => {
: 'Keine vermissten Hunde in deiner Nähe (25 km Radius). 🐾';
}
} catch {
const offline_pending = _getPending().map(p => ({
...p,
distanz_m: _haversine(_userPos.lat, _userPos.lon, p.lat, p.lon),
}));
try {
const raw = localStorage.getItem(_CACHE_KEY);
if (raw) {
const cached = JSON.parse(raw).data || [];
_reports = [...offline_pending, ...cached];
_renderMarkers();
_renderHeld();
_renderList();
_updateBadge(_reports.length);
if (infoEl) infoEl.textContent = 'Offline — zeige zuletzt geladene Meldungen.';
return;
}
} catch {}
_reports = offline_pending;
if (offline_pending.length) {
_renderMarkers();
_renderHeld();
_renderList();
_updateBadge(_reports.length);
return;
}
UI.toast.error('Meldungen konnten nicht geladen werden.');
}
}
@ -204,20 +301,21 @@ window.Page_lost = (() => {
_markers = [];
_reports.forEach(r => {
const dotColor = r._isPending ? '#d97706' : '#e74c3c';
const anim = r._isPending ? 'by-lost-pulse-p' : 'by-lost-pulse-r';
const icon = L.divIcon({
className : '',
html : `<div style="
background:#e74c3c;color:#fff;border-radius:50%;
html : `<div style="background:${dotColor};color:#fff;border-radius:50%;
width:34px;height:34px;
display:flex;align-items:center;justify-content:center;
font-size:17px;box-shadow:0 2px 6px rgba(0,0,0,.35);
border:2px solid #fff">🐕</div>`,
font-size:17px;border:2px solid #fff;
animation:${anim} 1.8s ease-in-out infinite">🐕</div>`,
iconSize : [34, 34],
iconAnchor : [17, 17],
});
const distStr = r.distanz_m !== undefined
? (r.distanz_m < 1000 ? `${r.distanz_m} m` : `${(r.distanz_m / 1000).toFixed(1)} km`)
? (r.distanz_m < 1000 ? `${Math.round(r.distanz_m)} m` : `${(r.distanz_m / 1000).toFixed(1)} km`)
: '';
const marker = L.marker([r.lat, r.lon], { icon })
@ -226,10 +324,11 @@ window.Page_lost = (() => {
<b>🔍 ${_escape(r.name)}</b><br>
${r.rasse ? _escape(r.rasse) + '<br>' : ''}
${distStr ? `<small>📍 ${distStr} entfernt</small><br>` : ''}
${r._isPending ? '<small>⏳ Sync ausstehend</small><br>' : ''}
<small>📅 ${_fmtDate(r.created_at)}</small>
`);
marker.on('click', () => _openDetail(r));
if (!r._isPending) marker.on('click', () => _openDetail(r));
_markers.push(marker);
});
}
@ -271,10 +370,19 @@ window.Page_lost = (() => {
listEl.innerHTML = _reports.map(r => _reportCard(r)).join('');
listEl.querySelectorAll('[data-lost-id]').forEach(card => {
card.addEventListener('click', () => {
const r = _reports.find(x => x.id === parseInt(card.dataset.lostId));
const id = card.dataset.lostId;
const r = _reports.find(x => String(x.id) === id && !x._isPending);
if (r) _openDetail(r);
});
});
listEl.querySelectorAll('.lost-discard-btn').forEach(btn => {
btn.addEventListener('click', e => {
e.stopPropagation();
const pid = btn.dataset.pendingId;
_setPending(_getPending().filter(x => x.id !== pid));
_loadReports();
});
});
listEl.querySelectorAll('.lost-note-btn').forEach(btn => {
btn.addEventListener('click', e => {
e.stopPropagation();
@ -332,14 +440,24 @@ window.Page_lost = (() => {
Gemeldet ${_fmtDate(r.created_at)}
${r.melder_name ? '· ' + _escape(r.melder_name.split(' ')[0]) : ''}
</div>
${_appState.user ? `<div style="margin-top:var(--space-2)">
${r._isPending
? `<div style="margin-top:var(--space-2);display:flex;align-items:center;gap:var(--space-2);flex-wrap:wrap">
<span style="font-size:10px;color:var(--c-warning,#d97706);font-weight:600"> Sync ausstehend</span>
<button class="btn btn-ghost btn-xs lost-discard-btn"
data-pending-id="${r.id}"
onclick="event.stopPropagation()"
style="color:var(--c-danger,#dc2626)">
🗑 Verwerfen
</button>
</div>`
: (_appState.user ? `<div style="margin-top:var(--space-2)">
<button class="btn btn-ghost btn-xs lost-note-btn"
data-lost-note-id="${r.id}"
data-lost-note-name="${_escape(r.name)}"
title="Notiz" onclick="event.stopPropagation()">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#note-pencil"></use></svg> Notiz
</button>
</div>` : ''}
</div>` : '')}
</div>
</div>
</div>
@ -350,6 +468,7 @@ window.Page_lost = (() => {
// DETAIL-MODAL
// ----------------------------------------------------------
function _openDetail(r) {
if (r._isPending) return; // Pending-Einträge haben keine Server-ID
const isOwn = _appState.user && _appState.user.id === r.user_id;
const isAdmin = _appState.user?.rolle === 'admin';
const distStr = r.distanz_m !== undefined
@ -632,7 +751,23 @@ window.Page_lost = (() => {
client_time : API.clientNow(),
};
const created = await API.lost.report(payload);
let created;
try {
created = await API.lost.report(payload);
} catch (netErr) {
// Netzwerkfehler (TypeError = fetch failed) → offline speichern
if (netErr instanceof TypeError || !navigator.onLine) {
const pending = _addPending(payload);
pending.distanz_m = _userPos
? Math.round(_haversine(_userPos.lat, _userPos.lon, pending.lat, pending.lon))
: 0;
UI.modal.close();
UI.toast.success('Offline gespeichert — wird synchronisiert sobald Verbindung besteht.');
_loadReports();
return;
}
throw netErr; // API-Fehler (z.B. 422) → weitergeben
}
// Foto hochladen
if (photoInput?.files[0]) {

View file

@ -14,6 +14,8 @@ window.Page_map = (() => {
let _weatherLoaded = false;
let _placingMarker = false;
let _tempMarker = null;
let _tileLayer = null;
let _themeObserver = null;
// Standort-Tracking
let _locationMarker = null;
@ -59,6 +61,7 @@ window.Page_map = (() => {
};
const VISIBLE_KEY = 'by_map_visible_v1';
const _MAP_POI_KEY = 'by_map_pois_cache';
let _visible = {};
// Gespeicherten Zustand laden, Fallback: alles sichtbar
@ -75,7 +78,7 @@ window.Page_map = (() => {
// z: zIndexOffset — höher = weiter oben bei Überlappung
const TYPEN = {
restaurant: { icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#fork-knife"></use></svg>', label: 'Hundefreundl. Café/Restaurant', color: '#F97316', z: 10 },
restaurant: { icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#fork-knife"></use></svg>', label: 'Café & Restaurant', color: '#F97316', z: 10 },
freilauf: { icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#dog"></use></svg>', label: 'Freilauf', color: '#22C55E', z: 20 },
shop: { icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#shopping-cart"></use></svg>', label: 'Shop', color: '#3B82F6', z: 15 },
kotbeutel: { icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#bag"></use></svg>', label: 'Kotbeutel', color: '#84A98C', z: 5 },
@ -92,7 +95,7 @@ window.Page_map = (() => {
treffpunkt: { icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#handshake"></use></svg>', label: 'Treffpunkt', color: '#7C3AED', z: 25 },
community: { icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#push-pin"></use></svg>', label: 'Sonstiges', color: '#F59E0B', z: 30 },
zuechter: { icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#certificate"></use></svg>', label: 'Züchter', color: '#7C3AED', z: 50 },
hotel: { icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#bed"></use></svg>', label: 'Hundefreundl. Hotel', color: '#0369a1', z: 20 },
hotel: { icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#bed"></use></svg>', label: 'Hotel', color: '#0369a1', z: 20 },
};
// Frontend-Layer → Backend-Typ Mapping
@ -180,6 +183,7 @@ window.Page_map = (() => {
<div class="map-legend" id="map-legend">
<button class="map-legend-btn map-legend-all" id="map-legend-all" title="Alle ein-/ausblenden">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#list"></use></svg>
<span class="map-legend-label">Filter</span>
</button>
${Object.entries(TYPEN).filter(([k]) => k !== 'giftkoeder').map(([k, t]) => `
<button class="map-legend-btn${_visible[k] !== false ? ' active' : ''}" data-layer="${k}" style="--layer-color:${t.color}">
@ -214,7 +218,7 @@ window.Page_map = (() => {
<div class="map-statusbar" id="map-statusbar">
<span id="map-zoom-info"></span>
<span id="map-osm-status"></span>
<span id="map-osm-status" style="display:none"></span>
<span class="map-statusbar-sep map-weather-chip--hidden" id="map-weather-sep">·</span>
<span class="map-weather-chip--hidden" id="map-weather-info"></span>
</div>
@ -526,7 +530,16 @@ window.Page_map = (() => {
if (!_userPos) {
_frankfurtTimer = setTimeout(() => _map.flyTo(center, 14, { duration: 2.5 }), 1200);
}
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { maxZoom: 19 }).addTo(_map);
_tileLayer = _buildTileLayer();
_tileLayer.addTo(_map);
// Sofort Dark-Filter anwenden wenn nötig (nach Tile-Load)
_tileLayer.on('load', _applyTileTheme);
_applyTileTheme();
// Theme-Wechsel → Filter aktualisieren
_themeObserver = new MutationObserver(() => _applyTileTheme());
_themeObserver.observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme'] });
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', _applyTileTheme);
setTimeout(() => _map.invalidateSize(), 100);
setTimeout(() => _map.invalidateSize(), 600);
@ -600,7 +613,7 @@ window.Page_map = (() => {
width:36px;height:36px;border-radius:50%;
display:flex;align-items:center;justify-content:center;
box-shadow:0 2px 6px rgba(0,0,0,0.4);
border:2px solid rgba(255,255,255,0.8)">${n}</div>`,
border:2px solid rgba(52,68,36,0.65)">${n}</div>`,
iconSize: [36, 36], iconAnchor: [18, 18],
});
},
@ -612,14 +625,34 @@ window.Page_map = (() => {
return _clusterGroups[layerKey];
}
function _isDarkMode() {
const t = document.documentElement.getAttribute('data-theme');
if (t === 'dark') return true;
if (t === 'light') return false;
return window.matchMedia('(prefers-color-scheme: dark)').matches;
}
const _OSM_URL = 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png';
const _DARK_FILTER = 'invert(93%) hue-rotate(180deg) brightness(0.88) contrast(0.88) saturate(0.85)';
function _buildTileLayer() {
return L.tileLayer(_OSM_URL, { maxZoom: 19 });
}
function _applyTileTheme() {
if (!_map) return;
const tilePaneEl = _map.getPane('tilePane');
if (tilePaneEl) tilePaneEl.style.filter = _isDarkMode() ? _DARK_FILTER : '';
}
function _updateZoomDisplay() {
if (!_map) return;
const z = Math.round(_map.getZoom());
const el = document.getElementById('map-zoom-info');
if (!el) return;
if (z < 10) { el.textContent = `Zoom ${z} · ab 10: Giftköder`; el.style.opacity = '0.5'; }
else if (z < 14) { el.textContent = `Zoom ${z} · ab 14: alle Layer`; el.style.opacity = '0.7'; }
else { el.textContent = `Zoom ${z}`; el.style.opacity = '1'; }
if (z < 10) { el.textContent = `Z${z}`; el.title = 'Ab Z10: Giftköder'; el.style.opacity = '0.5'; }
else if (z < 14) { el.textContent = `Z${z}`; el.title = 'Ab Z14: alle Layer'; el.style.opacity = '0.7'; }
else { el.textContent = `Z${z}`; el.title = ''; el.style.opacity = '1'; }
}
function _setOsmStatus(text, pct = null) {
@ -937,7 +970,7 @@ window.Page_map = (() => {
width:32px;height:32px;border-radius:50%;
display:flex;align-items:center;justify-content:center;
box-shadow:0 2px 5px rgba(0,0,0,0.35);
border:2px solid rgba(255,255,255,0.7)">${t.icon}</div>`,
border:2px solid rgba(52,68,36,0.55)">${t.icon}</div>`,
iconSize: [32, 32], iconAnchor: [16, 16],
});
@ -1217,9 +1250,41 @@ window.Page_map = (() => {
API.breeder.mapMarkers(),
]);
if (places.status === 'fulfilled') _addPlaces(places.value);
if (poisonList.status === 'fulfilled') _addPoison(poisonList.value);
if (breederList.status === 'fulfilled') _addBreeders(breederList.value);
const allFailed = [places, poisonList, breederList].every(r => r.status === 'rejected');
if (allFailed) {
try {
const raw = localStorage.getItem(_MAP_POI_KEY);
if (raw) {
const cached = JSON.parse(raw);
_addPlaces(cached.places || []);
_addPoison(cached.poison || []);
_addBreeders(cached.breeders || []);
UI.toast.info('Offline — Karte zeigt gecachte Kacheln. POI-Daten eventuell veraltet.');
_scheduleOsmLoad();
return;
}
} catch {}
}
const placesVal = places.status === 'fulfilled' ? places.value : [];
const poisonVal = poisonList.status === 'fulfilled' ? poisonList.value : [];
const breederVal = breederList.status === 'fulfilled' ? breederList.value : [];
if (places.status === 'fulfilled') _addPlaces(placesVal);
if (poisonList.status === 'fulfilled') _addPoison(poisonVal);
if (breederList.status === 'fulfilled') _addBreeders(breederVal);
if (places.status === 'fulfilled' || poisonList.status === 'fulfilled' || breederList.status === 'fulfilled') {
try {
localStorage.setItem(_MAP_POI_KEY, JSON.stringify({
ts: Date.now(),
places: placesVal,
poison: poisonVal,
breeders: breederVal,
}));
} catch {}
}
_scheduleOsmLoad();
}
@ -1270,7 +1335,7 @@ window.Page_map = (() => {
width:32px;height:32px;border-radius:50%;
display:flex;align-items:center;justify-content:center;
box-shadow:0 2px 5px rgba(0,0,0,0.35);
border:2px solid rgba(255,255,255,0.7)">${t.icon}</div>`,
border:2px solid rgba(52,68,36,0.55)">${t.icon}</div>`,
iconSize: [32, 32], iconAnchor: [16, 16],
});
@ -1314,7 +1379,7 @@ window.Page_map = (() => {
width:32px;height:32px;border-radius:50%;
display:flex;align-items:center;justify-content:center;
box-shadow:0 2px 5px rgba(0,0,0,0.35);
border:2px solid rgba(255,255,255,0.7)">${t.icon}</div>`,
border:2px solid rgba(52,68,36,0.55)">${t.icon}</div>`,
iconSize: [32, 32], iconAnchor: [16, 16],
});
return L.marker([lat, lon], { icon, zIndexOffset: t.z ?? 0 })

View file

@ -5,6 +5,8 @@
window.Page_poison = (() => {
const _CACHE_KEY = 'by_poison_cache';
// ----------------------------------------------------------
// MODUL-STATE
// ----------------------------------------------------------
@ -171,6 +173,7 @@ window.Page_poison = (() => {
try {
_reports = await API.poison.listNearby(_userPos.lat, _userPos.lon, 10000);
try { localStorage.setItem(_CACHE_KEY, JSON.stringify({ ts: Date.now(), data: _reports })); } catch {}
_renderMarkers();
_renderList();
_updateBadge(_reports.length);
@ -180,6 +183,18 @@ window.Page_poison = (() => {
: 'Keine aktiven Giftköder-Meldungen in deiner Nähe (10 km Radius).';
}
} catch {
try {
const raw = localStorage.getItem(_CACHE_KEY);
if (raw) {
_reports = JSON.parse(raw).data || [];
_renderMarkers();
_renderList();
_updateBadge(_reports.length);
if (infoEl) infoEl.textContent = `${_reports.length} gecachte Meldung${_reports.length !== 1 ? 'en' : ''} (Offline)`;
UI.toast.info('Offline — zeige zuletzt geladene Daten.');
return;
}
} catch {}
UI.toast.error('Meldungen konnten nicht geladen werden.');
}
}
@ -528,6 +543,12 @@ window.Page_poison = (() => {
const created = await API.poison.report(payload);
// SW hat Request in Queue gelegt (offline)
if (created?._queued) {
_showPoisonThanks(true);
return;
}
// Foto hochladen
if (photoInput?.files[0]) {
try {
@ -540,8 +561,7 @@ window.Page_poison = (() => {
}
}
// Distanz client-seitig berechnen (für sofortige Anzeige)
// _userPos aktualisieren falls Picker neuen Standort geliefert hat
// Distanz client-seitig berechnen
if (loc.lat && loc.lon) _userPos = { lat: loc.lat, lon: loc.lon };
created.distanz_m = _userPos
? Math.round(_haversine(_userPos.lat, _userPos.lon, created.lat, created.lon))
@ -553,12 +573,45 @@ window.Page_poison = (() => {
_updateBadge(_reports.length);
App.checkNearbyAlerts();
App.callModule('map', 'refresh');
UI.toast.success('Giftköder gemeldet! Danke für die Warnung.');
UI.modal.close();
_showPoisonThanks(false);
});
});
}
// ----------------------------------------------------------
// DANKE-OVERLAY nach Giftköder-Meldung
// ----------------------------------------------------------
function _showPoisonThanks(isQueued) {
const offlineNote = isQueued
? `<p style="font-size:var(--text-xs);color:var(--c-text-muted);margin:var(--space-3) 0 0;display:flex;align-items:center;justify-content:center;gap:var(--space-2)">
<svg class="ph-icon" aria-hidden="true" style="flex-shrink:0"><use href="/icons/phosphor.svg#wifi-slash"></use></svg>
Wird synchronisiert sobald du wieder online bist.
</p>`
: '';
UI.modal.open({
title: 'Danke für deine Meldung!',
body: `
<div style="text-align:center;padding:var(--space-2) 0 var(--space-4)">
<div style="margin-bottom:var(--space-4)">
<svg class="ph-icon" aria-hidden="true" style="width:48px;height:48px;color:var(--c-danger)"><use href="/icons/phosphor.svg#siren"></use></svg>
</div>
<p style="color:var(--c-text);font-size:var(--text-base);line-height:1.7;margin:0">
Wir kümmern uns darum und melden es den anderen Nutzern in der Umgebung.
</p>
<p style="color:var(--c-text-secondary);font-size:var(--text-sm);
margin:var(--space-2) 0 0;line-height:1.5;display:flex;align-items:center;justify-content:center;gap:var(--space-2)">
<svg class="ph-icon" aria-hidden="true" style="flex-shrink:0;color:var(--c-primary)"><use href="/icons/phosphor.svg#paw-print"></use></svg>
Vielen Dank, dass du die Community schützt!
</p>
${offlineNote}
</div>
`,
footer: `<button class="btn btn-primary flex-1" id="poison-thanks-ok">OK</button>`,
});
document.getElementById('poison-thanks-ok')?.addEventListener('click', UI.modal.close);
setTimeout(() => UI.modal.close(), 5000);
}
// ----------------------------------------------------------
// BADGE (Sidebar + Bottom-Nav)
// ----------------------------------------------------------

View file

@ -5,6 +5,40 @@
window.Page_routes = (() => {
const _CACHE_KEY = 'by_routes_cache';
const _PENDING_KEY = 'by_routes_pending';
function _getPending() {
try { return JSON.parse(localStorage.getItem(_PENDING_KEY) || '[]'); } catch { return []; }
}
function _setPending(list) {
try { localStorage.setItem(_PENDING_KEY, JSON.stringify(list)); } catch {}
}
function _addPending(data) {
const list = _getPending();
const entry = { ...data, id: `pending_${Date.now()}`, _isPending: true,
created_at: new Date().toISOString(), user_id: null };
list.push(entry);
_setPending(list);
return entry;
}
async function _syncPending() {
if (!navigator.onLine) return;
const list = _getPending();
if (!list.length) return;
let ok = 0;
for (const r of [...list]) {
try {
const { id: _pid, _isPending, ...payload } = r;
await API.routes.create(payload);
_setPending(_getPending().filter(x => x.id !== r.id));
ok++;
} catch {}
}
if (ok > 0) { UI.toast.success(`${ok} Route(n) synchronisiert.`); _loadData(); }
}
window.addEventListener('online', _syncPending);
let _container = null;
let _appState = null;
let _data = [];
@ -634,7 +668,8 @@ window.Page_routes = (() => {
if (!_appState.user) { UI.toast.warning('Bitte anmelden.'); return; }
if (_recOvl) return;
await UI.loadLeaflet?.() ?? Promise.resolve();
try { await (UI.loadLeaflet?.() ?? Promise.resolve()); }
catch { UI.toast.warning('Karte offline nicht verfügbar — GPS-Aufzeichnung läuft trotzdem.'); }
const ovl = document.createElement('div');
ovl.id = 'rk-rec-ovl';
@ -691,24 +726,37 @@ window.Page_routes = (() => {
document.body.appendChild(ovl);
_recOvl = ovl;
// Listener sofort nach DOM-Einfügen — nicht nach async-Operationen
ovl.querySelector('#rk-rec-cancel').addEventListener('click', () => _closeRecOvlClean());
ovl.querySelector('#rk-rec-startbtn').addEventListener('click', _startRecInOvl);
// Map-Setup: Leaflet könnte offline fehlen → alles in try/catch
const pos = _userPos || { lat: 48.1, lon: 11.5 };
try {
if (!window.L) throw new Error('Leaflet not loaded');
_recMap = L.map(ovl.querySelector('#rk-rec-map-wrap'), { zoomControl: false, attributionControl: false })
.setView([pos.lat, pos.lon], 15);
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { maxZoom: 19 }).addTo(_recMap);
_recLocMarker = L.circleMarker([pos.lat, pos.lon], {
radius: 8, color: '#fff', weight: 2.5, fillColor: '#3b82f6', fillOpacity: 1
}).addTo(_recMap);
} catch {
const mapWrap = ovl.querySelector('#rk-rec-map-wrap');
if (mapWrap) mapWrap.innerHTML =
`<div style="display:flex;align-items:center;justify-content:center;height:100%;
flex-direction:column;gap:8px;color:var(--c-text-secondary);font-size:14px">
<span style="font-size:2rem">📡</span>
Karte offline nicht verfügbar GPS läuft trotzdem
</div>`;
}
// Get accurate position
// Genaueren Standort nachladen (best-effort, klappt auch offline via gespeichertem GPS)
try {
const p = await API.getLocation();
_userPos = p;
_recMap.setView([p.lat, p.lon], 16);
_recLocMarker.setLatLng([p.lat, p.lon]);
_recMap?.setView([p.lat, p.lon], 16);
_recLocMarker?.setLatLng([p.lat, p.lon]);
} catch {}
ovl.querySelector('#rk-rec-cancel').addEventListener('click', () => _closeRecOvlClean());
ovl.querySelector('#rk-rec-startbtn').addEventListener('click', _startRecInOvl);
}
async function _startRecInOvl() {
@ -732,6 +780,7 @@ window.Page_routes = (() => {
setTimeout(() => banner.remove(), 9000);
}
const ctrl = document.getElementById('rk-rec-ctrl');
ctrl.innerHTML = `
<button id="rk-rec-stopbtn" style="${_btnStyle()}flex:1;border-color:var(--c-danger);background:var(--c-danger);color:#fff;position:relative;overflow:hidden;touch-action:none;user-select:none;">
@ -773,7 +822,9 @@ window.Page_routes = (() => {
btn.addEventListener('pointercancel', cancelHold);
document.getElementById('rk-rec-stats-bar').style.display = '';
if (_recMap && window.L) {
_recPolyline = L.polyline([], { color: '#ef4444', weight: 5, opacity: 0.9 }).addTo(_recMap);
}
await _recAcquireWakeLock();
document.addEventListener('visibilitychange', _recOnVisibility);
@ -788,9 +839,9 @@ window.Page_routes = (() => {
_recDistKm += d;
}
_recTrack.push({ lat, lon, ...(alt !== null ? { alt: Math.round(alt) } : {}) });
_recPolyline.addLatLng([lat, lon]);
_recLocMarker.setLatLng([lat, lon]);
if (_recTrack.length === 1) _recMap.setView([lat, lon], 16);
_recPolyline?.addLatLng([lat, lon]);
_recLocMarker?.setLatLng([lat, lon]);
if (_recTrack.length === 1) _recMap?.setView([lat, lon], 16);
_updateRecStats();
}, () => {}, { enableHighAccuracy: true, maximumAge: 2000 });
@ -1011,7 +1062,7 @@ window.Page_routes = (() => {
const btn = document.querySelector('[form="rk-rms-form"][type="submit"]');
const fd = UI.formData(e.target);
await UI.asyncButton(btn, async () => {
const saved = await API.routes.create({
const payload = {
name: fd.name?.trim(),
beschreibung: fd.beschreibung || null,
gps_track: track,
@ -1024,7 +1075,15 @@ window.Page_routes = (() => {
is_public: 'is_public' in fd,
hunde_tauglichkeit: fd.hunde_tauglichkeit || 'sehr_gut',
client_time: API.clientNow(),
});
};
if (!navigator.onLine) {
_addPending(payload);
UI.modal.close();
UI.toast.success(`Route offline gespeichert — wird synchronisiert sobald Verbindung besteht.`);
_loadData();
return;
}
const saved = await API.routes.create(payload);
UI.modal.close();
UI.toast.success(`Route „${saved.name}" gespeichert!`);
_loadData();
@ -1209,20 +1268,36 @@ window.Page_routes = (() => {
// Daten
// ----------------------------------------------------------
async function _loadData() {
const _merge = (online) => {
const pending = _getPending();
if (pending.length) _data = [...pending, ..._data];
if (_appState.user && _browseMode === 'mine')
document.getElementById('rk-mine-group')?.style.setProperty('display', '');
if (_browseMode === 'discover' && _userPos)
document.getElementById('rk-nearby-group')?.style.setProperty('display', '');
if (!online && pending.length)
UI.toast.info('Offline — ' + pending.length + ' Route(n) warten auf Sync.');
_applyFilter();
};
try {
_data = await API.routes.list();
// "Meine Routen"-Filter nur zeigen wenn eingeloggt und im Mine-Modus
if (_appState.user && _browseMode === 'mine') {
document.getElementById('rk-mine-group')?.style.setProperty('display', '');
try { localStorage.setItem(_CACHE_KEY, JSON.stringify({ ts: Date.now(), data: _data })); } catch {}
_merge(true);
} catch {
try {
const raw = localStorage.getItem(_CACHE_KEY);
if (raw) {
_data = JSON.parse(raw).data || [];
UI.toast.info('Offline — zeige zuletzt geladene Routen.');
_merge(false);
return;
}
// Standort-abhängiger Filter im Entdecken-Modus
if (_browseMode === 'discover' && _userPos) {
document.getElementById('rk-nearby-group')?.style.setProperty('display', '');
}
_applyFilter();
} catch (err) {
} catch {}
// Nur Pending-Routen zeigen wenn gar kein Cache
_data = _getPending();
if (_data.length) { _merge(false); return; }
document.getElementById('rk-grid').innerHTML =
`<p style="color:var(--c-danger);padding:var(--space-6)">Fehler: ${UI.escape(err.message)}</p>`;
`<p style="color:var(--c-danger);padding:var(--space-6)">Offline — noch keine Routen gecacht.</p>`;
}
}
@ -1369,10 +1444,13 @@ window.Page_routes = (() => {
: '';
return `
<div class="rk-card" data-id="${r.id}">
<div class="rk-card" data-id="${r.id}" ${r._isPending ? 'data-pending="1"' : ''}>
<div class="rk-card-preview">${previewContent}</div>
<div class="rk-card-body">
${authorLine}
${r._isPending ? `<div style="font-size:10px;font-weight:700;color:var(--c-warning,#d97706);
margin-bottom:3px;display:flex;align-items:center;gap:4px">
${UI.icon('cloud-arrow-up')} Sync ausstehend</div>` : ''}
<div class="rk-card-name">${UI.escape(r.name)}</div>
<div style="display:flex;flex-wrap:wrap;gap:5px;margin:var(--space-2) 0">
${dist ? _pill(dist, 'rgba(107,114,128,0.10)','#9ca3af','rgba(107,114,128,0.30)') : ''}

View file

@ -307,6 +307,36 @@ window.Page_settings = (() => {
Wir schalten deinen Account manuell frei innerhalb von 24 Stunden.
Wir melden uns mit den Zahlungsdetails per E-Mail.
</div>
${!_appState.user?.billing_address ? `
<div style="padding:var(--space-2) var(--space-3);border-radius:var(--radius-md);
background:#fff8f0;border:1px solid #f0a060;
font-size:var(--text-xs);color:#c05000;line-height:1.6;margin-top:var(--space-2)">
💡 Tipp: Trag deine <strong>Rechnungsadresse</strong> im Profil ein dann können wir die Rechnung vollständig ausstellen.
</div>` : ''}
<div style="margin-top:var(--space-3);padding:var(--space-3);border-radius:var(--radius-md);
background:var(--c-surface-raised,rgba(0,0,0,.04));">
<label style="display:flex;align-items:flex-start;gap:var(--space-2);cursor:pointer;
font-size:var(--text-xs);color:var(--c-text-secondary);line-height:1.5">
<input type="checkbox" id="agb-checkbox"
style="margin-top:2px;flex-shrink:0;accent-color:${color}">
<span>
Ich habe die <span style="color:var(--c-primary);cursor:pointer"
onclick="App.navigate('agb')">AGB</span> gelesen und stimme ihnen zu.
</span>
</label>
</div>
<div style="margin-top:var(--space-3);padding:var(--space-3);border-radius:var(--radius-md);
background:var(--c-surface-raised,rgba(0,0,0,.04));">
<label style="display:flex;align-items:flex-start;gap:var(--space-2);cursor:pointer;
font-size:var(--text-xs);color:var(--c-text-secondary);line-height:1.5">
<input type="checkbox" id="widerruf-checkbox"
style="margin-top:2px;flex-shrink:0;accent-color:${color}">
<span>
Ich stimme zu, dass mein Zugang sofort nach Freischaltung beginnt, und bestätige,
dass ich damit mein 14-tägiges Widerrufsrecht verliere (§&nbsp;356 Abs.&nbsp;4 BGB).
</span>
</label>
</div>
${breederForm}
</div>`,
footer: `
@ -324,6 +354,17 @@ window.Page_settings = (() => {
</button>`
});
const agbBox = document.getElementById('agb-checkbox');
const widerrufBox = document.getElementById('widerruf-checkbox');
const sendBtn = document.getElementById('upgrade-request-send-btn');
if (sendBtn) sendBtn.disabled = true;
const _checkBtns = () => {
if (sendBtn) sendBtn.disabled = !(agbBox?.checked && widerrufBox?.checked);
};
agbBox?.addEventListener('change', _checkBtns);
widerrufBox?.addEventListener('change', _checkBtns);
document.getElementById('upgrade-request-send-btn')?.addEventListener('click', async () => {
const btn = document.getElementById('upgrade-request-send-btn');
if (!btn) return;
@ -357,7 +398,8 @@ window.Page_settings = (() => {
}
try {
const res = await API.auth.upgradeRequest(tier);
const widerrufAt = new Date().toLocaleString('de-DE');
const res = await API.auth.upgradeRequest(tier, `[Widerrufsrecht akzeptiert am ${widerrufAt}]`);
UI.modal.close();
if (res.already) {
UI.toast.info('Deine Anfrage liegt bereits vor — wir melden uns bald.');
@ -1135,6 +1177,22 @@ window.Page_settings = (() => {
value="${_esc(u.social_link || '')}"
style="${inputStyle}">
</div>
<div style="border-top:1px solid var(--c-border);padding-top:var(--space-3);margin-top:var(--space-1)">
<label style="display:block;font-size:var(--text-sm);font-weight:600;margin-bottom:2px">Rechnungsadresse</label>
<div style="font-size:var(--text-xs);color:var(--c-text-muted);margin-bottom:var(--space-2)">Wird auf Rechnungen gedruckt. Straße in Zeile 1, PLZ + Ort in Zeile 2.</div>
<textarea name="billing_address" rows="2" maxlength="200"
placeholder="Musterstraße 1&#10;12345 Berlin"
style="${inputStyle};resize:vertical;font-family:inherit">${_esc(u.billing_address || '')}</textarea>
</div>
<div>
<label style="display:block;font-size:var(--text-sm);font-weight:600;margin-bottom:var(--space-1)">Dein Geburtstag <span style="font-weight:400;color:var(--c-text-secondary)">(optional)</span></label>
<input name="geburtstag" type="text" maxlength="5" placeholder="TT.MM"
value="${_esc(u.geburtstag || '')}"
pattern="\\d{2}\\.\\d{2}"
title="Format: TT.MM, z.B. 16.05"
style="${inputStyle}">
<div style="font-size:var(--text-xs);color:var(--c-text-secondary);margin-top:var(--space-1)">Wird nur für Geburtstagsgrüße in der App verwendet.</div>
</div>
<div>
<label style="display:block;font-size:var(--text-sm);font-weight:600;margin-bottom:var(--space-1)">Profil-Sichtbarkeit</label>
<select name="profil_sichtbarkeit" style="${inputStyle}">${sichtbarkeitOpts}</select>
@ -1161,8 +1219,11 @@ window.Page_settings = (() => {
erfahrung: fd.erfahrung || '',
social_link: fd.social_link || '',
profil_sichtbarkeit: fd.profil_sichtbarkeit || 'public',
billing_address: fd.billing_address || '',
geburtstag: fd.geburtstag || '',
});
Object.assign(_appState.user, updated);
window.Worlds?.refresh?.(_appState); // Welten neu rendern (z.B. Geburtstags-Greeting)
UI.modal.close?.();
UI.toast.success('Profil gespeichert.');
_render();

View file

@ -43,6 +43,7 @@ window.Page_social = (() => {
function _render() {
const lvlBar = _stats ? _levelBar(_stats) : '';
_el.innerHTML = `
<div style="width:100%;max-width:860px;margin:0 auto">
<div style="background:var(--c-surface);border-radius:var(--radius-lg);
box-shadow:var(--shadow-sm);padding:var(--space-4);
margin-bottom:var(--space-4)">
@ -71,7 +72,8 @@ window.Page_social = (() => {
color:${_activeTab===t?'var(--c-primary)':'var(--c-text-secondary)'}">
${l}</button>`).join('')}
</div>
<div id="sm-content"></div>`;
<div id="sm-content"></div>
</div>`;
_el.querySelectorAll('.sm-tab').forEach(b =>
b.addEventListener('click', () => { _activeTab = b.dataset.tab; _render(); }));

View file

@ -56,6 +56,7 @@ window.Page_uebungen = (() => {
{ id: 'welpe-basics', label: 'Welpe Basics' },
{ id: 'grundlagen', label: 'Trainingsgrundlagen' },
{ id: 'ki-trainer', label: 'KI-Trainer' },
{ id: 'verlauf', label: 'Protokoll' },
];
// ----------------------------------------------------------
@ -546,6 +547,10 @@ window.Page_uebungen = (() => {
_progressCache = {};
_progressLoaded = false;
_exerciseStats = {};
_verlaufSessions = [];
_verlaufOffset = 0;
_verlaufLoading = false;
_verlaufView = 'datum';
_render();
_loadStatsAndBadges();
_loadVirtualTrainer();
@ -558,34 +563,21 @@ window.Page_uebungen = (() => {
_container.innerHTML = `
<div id="ueb-wrap">
<div style="padding:var(--space-3) var(--space-4) 0">${UI.dogChip(_appState)}</div>
<div style="padding:var(--space-3) var(--space-4) var(--space-2)">
<table style="width:100%;border-collapse:collapse">
<tr>
<td style="width:100%;padding-right:var(--space-2)">
<div style="padding:var(--space-2) var(--space-4) var(--space-2);display:flex;gap:var(--space-2);align-items:stretch">
<input type="search" id="ueb-search" placeholder="Übung suchen…"
style="display:block;width:100%;box-sizing:border-box;
padding:var(--space-2) var(--space-3);
style="flex:1;min-width:0;padding:var(--space-2) var(--space-3);
border:1px solid var(--c-border);border-radius:var(--radius-md);
background:var(--c-surface);color:var(--c-text);font-size:var(--text-sm);
outline:none" value="${_esc(_searchQuery)}">
</td>
<td style="white-space:nowrap;vertical-align:middle">
background:var(--c-surface);color:var(--c-text);font-size:var(--text-sm);outline:none">
<button id="ueb-quicksetup-btn"
style="padding:5px 12px;height:100%;
background:var(--c-surface-2);border:1px solid var(--c-border);
border-radius:var(--radius-sm);cursor:pointer;
display:flex;align-items:center;gap:var(--space-2)">
<svg class="ph-icon" style="width:15px;height:15px;flex-shrink:0;color:var(--c-primary)" aria-hidden="true">
<use href="/icons/phosphor.svg#list-checks"></use>
style="flex-shrink:0;width:64px;
background:var(--c-primary-subtle);border:1.5px solid var(--c-primary-light);
border-radius:var(--radius-md);cursor:pointer;
display:flex;flex-direction:column;align-items:center;justify-content:center;gap:3px">
<svg class="ph-icon" style="width:24px;height:24px;color:var(--c-primary)" aria-hidden="true">
<use href="/icons/phosphor.svg#paw-print"></use>
</svg>
<span style="display:flex;flex-direction:column;align-items:flex-start;gap:1px">
<span style="font-size:var(--text-xs);font-weight:var(--weight-semibold);color:var(--c-text);white-space:nowrap;line-height:1.2">Stand erfassen</span>
<span style="font-size:10px;color:var(--c-text-muted);white-space:nowrap;line-height:1.2">Wo stehst du bei jeder Übung?</span>
</span>
<span style="font-size:9px;font-weight:var(--weight-semibold);color:var(--c-primary);line-height:1.2;text-align:center">Stand<br>erfassen</span>
</button>
</td>
</tr>
</table>
</div>
${_renderTabs()}
<div id="ueb-stats-banner" style="padding:var(--space-2) var(--space-4) 0"></div>
@ -980,10 +972,11 @@ window.Page_uebungen = (() => {
const isExerciseTab = ['grundkommandos','tricks','problemverhalten',
'mentale-auslastung','hundesport','koerperpflege','welpe-basics'].includes(_activeTab);
const isVerlauf = _activeTab === 'verlauf';
const showIf = v => v ? '' : 'none';
const quickWrap = _container.querySelector('#ueb-quicksetup-btn')?.parentElement;
if (quickWrap) quickWrap.style.display = showIf(isExerciseTab);
if (quickWrap) quickWrap.style.display = isExerciseTab ? 'flex' : 'none';
const trainerEl = _container.querySelector('#ueb-trainer');
const suggestEl = _container.querySelector('#ueb-suggestions');
const bannerEl = _container.querySelector('#ueb-stats-banner');
@ -1011,6 +1004,17 @@ window.Page_uebungen = (() => {
break;
}
case 'grundlagen': el.innerHTML = _renderGrundlagen(); break;
case 'verlauf': {
if (_verlaufSessions.length > 0) {
el.innerHTML = `<div id="verlauf-wrap" style="padding:var(--space-4);display:flex;flex-direction:column;gap:var(--space-3)">${_verlaufToggleHtml()}<div id="verlauf-list"></div></div>`;
_renderVerlaufList(el.querySelector('#verlauf-list'));
} else {
el.innerHTML = _renderVerlaufShell();
_loadVerlauf();
}
_bindVerlaufToggle();
break;
}
case 'ki-trainer':
if (!App.hasPro(_appState?.user)) {
el.innerHTML = `<div style="padding:var(--space-6);text-align:center;color:var(--c-text-muted)">
@ -1647,18 +1651,6 @@ window.Page_uebungen = (() => {
background:var(--c-surface);color:var(--c-text);line-height:1.5"></textarea>
</div>
<!-- Meilenstein-Checkbox (initially hidden) -->
<label id="ueb-log-milestone-wrap" hidden
style="display:flex;align-items:flex-start;gap:var(--space-2);cursor:pointer;
padding:var(--space-3);background:var(--c-primary-subtle);
border:1px solid var(--c-primary-light);border-radius:var(--radius-md)">
<input type="checkbox" id="ueb-log-milestone"
style="margin-top:2px;flex-shrink:0;accent-color:var(--c-primary);width:16px;height:16px">
<span style="font-size:var(--text-sm);color:var(--c-text);line-height:1.5">
📖 Als Meilenstein ins Tagebuch eintragen
</span>
</label>
</form>
<!-- Footer Buttons -->
@ -1714,7 +1706,6 @@ window.Page_uebungen = (() => {
btn.style.background = 'var(--c-primary-subtle)';
btn.style.borderColor = 'var(--c-primary)';
btn.style.transform = 'scale(1.15)';
_checkMilestoneVisibility();
});
});
@ -1738,17 +1729,9 @@ window.Page_uebungen = (() => {
overlay.querySelectorAll('.ueb-stern-btn').forEach(b => {
b.style.opacity = parseInt(b.dataset.val, 10) <= zufriedenheit ? '1' : '0.35';
});
_checkMilestoneVisibility();
});
});
function _checkMilestoneVisibility() {
const wrap = overlay.querySelector('#ueb-log-milestone-wrap');
if (!wrap) return;
const show = erfolgsquote != null && erfolgsquote >= 75 && zufriedenheit != null && zufriedenheit >= 4;
wrap.hidden = !show;
}
// Save
overlay.querySelector('#ueb-log-save').addEventListener('click', async () => {
const dogId = _dogId();
@ -1761,8 +1744,6 @@ window.Page_uebungen = (() => {
const exerciseId = `${tab}_${exerciseName.replace(/[\s/]+/g, '_')}`;
const today = new Date().toISOString().slice(0, 10);
const tagebuch = !overlay.querySelector('#ueb-log-milestone-wrap').hidden &&
overlay.querySelector('#ueb-log-milestone').checked;
const body = {
dog_id: dogId,
@ -1774,7 +1755,6 @@ window.Page_uebungen = (() => {
hund_stimmung: stimmung || null,
zufriedenheit: zufriedenheit || null,
notiz: overlay.querySelector('#ueb-log-notiz').value.trim() || null,
tagebuch_eintrag: tagebuch,
};
try {
@ -1806,12 +1786,6 @@ window.Page_uebungen = (() => {
});
}
if (resp.diary_entry_id) {
setTimeout(() => {
UI.toast.success('📖 Als Meilenstein im Tagebuch gespeichert.');
}, resp.badges?.length ? (resp.badges.length + 1) * 1000 : 1000);
}
// Stats-Banner + Trainer aktualisieren
_statsData = null;
_loadStatsAndBadges();
@ -1995,6 +1969,325 @@ window.Page_uebungen = (() => {
});
}
// ----------------------------------------------------------
// TRAININGSPROTOKOLL (Verlauf-Tab)
// ----------------------------------------------------------
let _verlaufSessions = [];
let _verlaufOffset = 0;
let _verlaufHasMore = false;
let _verlaufLoading = false;
let _verlaufView = 'datum'; // 'datum' | 'uebung'
const _VERLAUF_LIMIT = 30;
const _ERFOLG_EMOJI = { 0: '😓', 25: '😐', 50: '🙂', 75: '😊', 100: '🎉' };
const _STIMMUNG_EMOJI = { aufmerksam: '🎯', müde: '😴', abgelenkt: '🌪️', super: '⚡' };
function _renderVerlaufShell() {
const dogId = _dogId();
if (!dogId) {
return `<div style="padding:var(--space-8);text-align:center;color:var(--c-text-muted)">
<p style="font-size:var(--text-sm)">Wähle einen Hund aus um das Protokoll zu sehen.</p>
</div>`;
}
return `<div id="verlauf-wrap" style="padding:var(--space-4);display:flex;flex-direction:column;gap:var(--space-3)">
${_verlaufToggleHtml()}
<div id="verlauf-list" style="display:flex;flex-direction:column;gap:var(--space-2)">
<div style="text-align:center;padding:var(--space-6);color:var(--c-text-muted)">
<svg class="ph-icon" style="width:24px;height:24px;animation:spin 1s linear infinite" aria-hidden="true">
<use href="/icons/phosphor.svg#spinner-gap"></use>
</svg>
</div>
</div>
</div>`;
}
function _verlaufToggleHtml() {
const btnBase = `padding:var(--space-2) var(--space-3);border-radius:var(--radius-md);
font-size:var(--text-xs);font-weight:var(--weight-semibold);cursor:pointer;
border:1px solid var(--c-border);transition:all .15s`;
const active = `background:var(--c-primary);color:#fff;border-color:var(--c-primary)`;
const inactive = `background:var(--c-surface-2);color:var(--c-text-secondary)`;
return `
<div style="display:flex;gap:var(--space-2)">
<button id="verlauf-btn-datum" style="${btnBase};${_verlaufView==='datum'?active:inactive}">
Nach Datum
</button>
<button id="verlauf-btn-uebung" style="${btnBase};${_verlaufView==='uebung'?active:inactive}">
Nach Übung
</button>
</div>`;
}
async function _loadVerlauf(append = false) {
if (_verlaufLoading) return;
const dogId = _dogId();
if (!dogId) return;
if (!append) {
_verlaufSessions = [];
_verlaufOffset = 0;
}
_verlaufLoading = true;
const data = await _apiGet(
`/api/training/sessions?dog_id=${dogId}&limit=${_VERLAUF_LIMIT + 1}&offset=${_verlaufOffset}`
).catch(() => null);
_verlaufLoading = false;
// Element nach await neu holen — könnte durch Re-Render veraltet sein
const el = _container?.querySelector('#verlauf-list');
if (!el) return;
if (!data) {
if (!append) el.innerHTML = `<div style="padding:var(--space-6);text-align:center;color:var(--c-text-muted);font-size:var(--text-sm)">Fehler beim Laden.</div>`;
return;
}
_verlaufHasMore = data.length > _VERLAUF_LIMIT;
const rows = data.slice(0, _VERLAUF_LIMIT);
_verlaufSessions = append ? [..._verlaufSessions, ...rows] : rows;
_verlaufOffset += rows.length;
_renderVerlaufList(el);
}
function _bindVerlaufToggle() {
const wrap = _container?.querySelector('#verlauf-wrap');
if (!wrap) return;
const btnDatum = wrap.querySelector('#verlauf-btn-datum');
const btnUebung = wrap.querySelector('#verlauf-btn-uebung');
const setActive = view => {
_verlaufView = view;
const active = `var(--c-primary)`;
const inBg = `var(--c-surface-2)`;
btnDatum.style.background = view === 'datum' ? active : inBg;
btnDatum.style.color = view === 'datum' ? '#fff' : 'var(--c-text-secondary)';
btnDatum.style.borderColor = view === 'datum' ? active : 'var(--c-border)';
btnUebung.style.background = view === 'uebung' ? active : inBg;
btnUebung.style.color = view === 'uebung' ? '#fff' : 'var(--c-text-secondary)';
btnUebung.style.borderColor = view === 'uebung' ? active : 'var(--c-border)';
const listEl = wrap.querySelector('#verlauf-list');
if (listEl) _renderVerlaufList(listEl);
};
btnDatum?.addEventListener('click', () => setActive('datum'));
btnUebung?.addEventListener('click', () => setActive('uebung'));
}
function _renderVerlaufList(el) {
if (!_verlaufSessions.length) {
el.innerHTML = `
<div style="padding:var(--space-8);text-align:center;color:var(--c-text-muted)">
<svg class="ph-icon" style="width:40px;height:40px;margin-bottom:var(--space-3);color:var(--c-border)" aria-hidden="true">
<use href="/icons/phosphor.svg#clipboard-text"></use>
</svg>
<p style="font-size:var(--text-sm);margin:0">Noch keine Trainingseinheiten geloggt.</p>
<p style="font-size:var(--text-xs);color:var(--c-text-muted);margin-top:var(--space-1)">
Tippe in einer Übung auf "+ Einheit" um zu starten.
</p>
</div>`;
return;
}
if (_verlaufView === 'uebung') {
_renderVerlaufByUebung(el);
} else {
_renderVerlaufByDatum(el);
}
}
function _sessionRow(s) {
const erfolg = _ERFOLG_EMOJI[s.erfolgsquote] || '🙂';
const stimmung = s.hund_stimmung ? (_STIMMUNG_EMOJI[s.hund_stimmung] || '') : '';
const topBadge = s.ist_top
? `<span style="font-size:9px;font-weight:700;padding:1px 5px;border-radius:999px;
background:rgba(22,163,74,0.12);color:#15803d;border:1px solid rgba(22,163,74,0.3)">TOP</span>`
: '';
const noteHtml = s.notiz
? `<div style="font-size:var(--text-xs);color:var(--c-text-muted);margin-top:3px;
line-height:1.4;font-style:italic">${_esc(s.notiz)}</div>`
: '';
return `
<div style="display:flex;align-items:flex-start;gap:var(--space-3);
padding:var(--space-2) var(--space-3);border-radius:var(--radius-md);
background:var(--c-surface-2)">
<span style="font-size:1.2rem;flex-shrink:0;margin-top:1px">${erfolg}</span>
<div style="flex:1;min-width:0">
<div style="display:flex;align-items:center;gap:var(--space-2);flex-wrap:wrap">
<span style="font-size:var(--text-sm);font-weight:var(--weight-semibold);
color:var(--c-text)">${_esc(s.exercise_name)}</span>
${topBadge}
</div>
<div style="font-size:var(--text-xs);color:var(--c-text-secondary);margin-top:1px">
${s.wiederholungen}× Wdh.${stimmung ? ' · ' + stimmung : ''}${s.zufriedenheit ? ' · ' + '⭐'.repeat(s.zufriedenheit) : ''}
</div>
${noteHtml}
</div>
</div>`;
}
function _renderVerlaufByDatum(el) {
const today = new Date().toISOString().slice(0, 10);
const yesterday = new Date(Date.now() - 86400000).toISOString().slice(0, 10);
const groups = {};
_verlaufSessions.forEach(s => {
groups[s.datum] = groups[s.datum] || [];
groups[s.datum].push(s);
});
const html = Object.entries(groups).map(([datum, sessions]) => {
let label;
if (datum === today) label = 'Heute';
else if (datum === yesterday) label = 'Gestern';
else {
const d = new Date(datum + 'T00:00:00');
label = d.toLocaleDateString('de-DE', { weekday: 'short', day: 'numeric', month: 'short' });
}
return `
<div>
<div style="font-size:var(--text-xs);font-weight:var(--weight-semibold);
color:var(--c-text-secondary);text-transform:uppercase;
letter-spacing:.05em;padding:var(--space-2) 0 var(--space-1)">
${_esc(label)}
</div>
<div style="display:flex;flex-direction:column;gap:var(--space-1)">
${sessions.map(_sessionRow).join('')}
</div>
</div>`;
}).join('');
const moreBtn = _verlaufHasMore
? `<button id="verlauf-more"
style="width:100%;padding:var(--space-3);border:1px solid var(--c-border);
background:var(--c-surface-2);border-radius:var(--radius-md);
font-size:var(--text-sm);color:var(--c-text-secondary);cursor:pointer;
margin-top:var(--space-2)">
Weitere laden
</button>`
: '';
el.innerHTML = html + moreBtn;
el.querySelector('#verlauf-more')?.addEventListener('click', () => _loadVerlauf(true));
}
function _renderVerlaufByUebung(el) {
// Sessions nach Übungsname gruppieren
const groups = {};
_verlaufSessions.forEach(s => {
if (!groups[s.exercise_name]) groups[s.exercise_name] = [];
groups[s.exercise_name].push(s);
});
// Pro Gruppe Stats berechnen
const today = new Date().toISOString().slice(0, 10);
const exerciseStats = Object.entries(groups).map(([name, sessions]) => {
const avg = Math.round(sessions.reduce((a, s) => a + s.erfolgsquote, 0) / sessions.length);
const recent = sessions.slice(0, 3);
const older = sessions.slice(3, 6);
let trend = 'new';
if (older.length) {
const rAvg = recent.reduce((a, s) => a + s.erfolgsquote, 0) / recent.length;
const oAvg = older.reduce((a, s) => a + s.erfolgsquote, 0) / older.length;
trend = rAvg - oAvg > 10 ? 'up' : rAvg - oAvg < -10 ? 'down' : 'stable';
}
const lastDate = sessions[0].datum;
const daysSince = Math.floor((new Date(today) - new Date(lastDate)) / 86400000);
return { name, sessions, avg, trend, lastDate, daysSince, topCount: sessions.filter(s => s.ist_top).length };
});
// Sortieren: zuletzt trainiert zuerst
exerciseStats.sort((a, b) => a.daysSince - b.daysSince);
const TREND_ICON = { up: '↑', down: '↓', stable: '→', new: '★' };
const TREND_COLOR = { up: '#15803d', down: '#dc2626', stable: 'var(--c-text-secondary)', new: 'var(--c-primary)' };
const cards = exerciseStats.map((ex, i) => {
const uid = `vl-ex-${i}`;
const barColor = ex.avg >= 75 ? '#15803d' : ex.avg >= 50 ? '#c2410c' : '#dc2626';
const barBg = ex.avg >= 75 ? 'rgba(22,163,74,0.15)' : ex.avg >= 50 ? 'rgba(194,65,12,0.15)' : 'rgba(220,38,38,0.15)';
const lastLabel = ex.daysSince === 0 ? 'Heute'
: ex.daysSince === 1 ? 'Gestern'
: `vor ${ex.daysSince} Tagen`;
const sessionRows = ex.sessions.map(s => {
const d = new Date(s.datum + 'T00:00:00');
const dateLabel = d.toLocaleDateString('de-DE', { day: 'numeric', month: 'short' });
const erfolg = _ERFOLG_EMOJI[s.erfolgsquote] || '🙂';
const stimmung = s.hund_stimmung ? (_STIMMUNG_EMOJI[s.hund_stimmung] || '') : '';
const top = s.ist_top ? ' ★' : '';
return `
<div style="display:flex;align-items:center;gap:var(--space-2);
padding:4px var(--space-2);border-radius:var(--radius-sm);
font-size:var(--text-xs);color:var(--c-text-secondary)">
<span style="flex-shrink:0;min-width:52px">${_esc(dateLabel)}</span>
<span style="flex-shrink:0">${erfolg}</span>
<span style="flex-shrink:0">${s.erfolgsquote}%${top}</span>
<span style="flex:1;min-width:0">${s.wiederholungen}× Wdh.${stimmung ? ' ' + stimmung : ''}</span>
</div>`;
}).join('');
return `
<div class="card" style="padding:0;overflow:hidden">
<!-- Header (klickbar zum Aufklappen) -->
<button class="verlauf-ex-btn" data-uid="${uid}"
style="width:100%;padding:var(--space-3) var(--space-4);display:flex;
align-items:center;gap:var(--space-3);background:none;border:none;
cursor:pointer;text-align:left">
<!-- Fortschrittsring -->
<div style="flex-shrink:0;width:40px;height:40px;border-radius:50%;
background:${barBg};display:flex;flex-direction:column;
align-items:center;justify-content:center">
<span style="font-size:11px;font-weight:700;color:${barColor};line-height:1">${ex.avg}%</span>
<span style="font-size:9px;color:${barColor};line-height:1;margin-top:1px">
${TREND_ICON[ex.trend]}
</span>
</div>
<!-- Info -->
<div style="flex:1;min-width:0">
<div style="font-size:var(--text-sm);font-weight:var(--weight-semibold);
color:var(--c-text);line-height:1.3;
white-space:nowrap;overflow:hidden;text-overflow:ellipsis">
${_esc(ex.name)}
</div>
<div style="font-size:var(--text-xs);color:var(--c-text-secondary);margin-top:2px">
${ex.sessions.length} Einheit${ex.sessions.length !== 1 ? 'en' : ''}
${ex.topCount ? ' · ' + ex.topCount + '× TOP' : ''}
· ${_esc(lastLabel)}
</div>
</div>
<!-- Chevron -->
<svg class="ph-icon verlauf-ex-chevron" data-uid="${uid}"
style="width:16px;height:16px;flex-shrink:0;color:var(--c-text-muted);transition:transform .2s"
aria-hidden="true">
<use href="/icons/phosphor.svg#caret-down"></use>
</svg>
</button>
<!-- Eingeklappte Session-Liste -->
<div id="${uid}" hidden
style="border-top:1px solid var(--c-border);padding:var(--space-2) var(--space-3)">
${sessionRows}
</div>
</div>`;
}).join('');
const hint = _verlaufHasMore
? `<div style="font-size:var(--text-xs);color:var(--c-text-muted);text-align:center;padding:var(--space-2) 0">
Zeigt die letzten ${_verlaufSessions.length} Einheiten ältere nicht berücksichtigt.
</div>`
: '';
el.innerHTML = cards + hint;
// Akkordeon-Binding
el.querySelectorAll('.verlauf-ex-btn').forEach(btn => {
btn.addEventListener('click', () => {
const uid = btn.dataset.uid;
const body = document.getElementById(uid);
const chev = el.querySelector(`.verlauf-ex-chevron[data-uid="${uid}"]`);
const isOpen = !body.hidden;
body.hidden = isOpen;
if (chev) chev.style.transform = isOpen ? '' : 'rotate(180deg)';
});
});
}
// ----------------------------------------------------------
// TRAININGSGRUNDLAGEN
// ----------------------------------------------------------

View file

@ -5,6 +5,45 @@
window.Page_walks = (() => {
// ----------------------------------------------------------
// OFFLINE-CACHE
// ----------------------------------------------------------
const _CACHE_KEY = 'by_walks_cache';
const _PENDING_KEY = 'by_walks_pending';
function _getPending() {
try { return JSON.parse(localStorage.getItem(_PENDING_KEY) || '[]'); } catch { return []; }
}
function _setPending(list) {
try { localStorage.setItem(_PENDING_KEY, JSON.stringify(list)); } catch {}
}
function _addPending(data) {
const list = _getPending();
const entry = { ...data, id: `pending_${Date.now()}`, _isPending: true,
created_at: new Date().toISOString(),
teilnehmer_count: 1, max_teilnehmer: data.max_teilnehmer || 10,
status: 'open' };
list.push(entry);
_setPending(list);
return entry;
}
async function _syncPending() {
if (!navigator.onLine) return;
const list = _getPending();
if (!list.length) return;
let ok = 0;
for (const item of [...list]) {
try {
const { id: _pid, _isPending, created_at: _ca, teilnehmer_count: _tc, status: _st, ...payload } = item;
await API.walks.create(payload);
_setPending(_getPending().filter(x => x.id !== item.id));
ok++;
} catch {}
}
if (ok > 0) { UI.toast.success(`${ok} Treffen synchronisiert.`); _loadData(); }
}
window.addEventListener('online', _syncPending);
let _container = null;
let _appState = null;
let _data = [];
@ -195,14 +234,16 @@ window.Page_walks = (() => {
// Daten laden
// ----------------------------------------------------------
async function _loadData() {
const pending = _getPending();
try {
_data = await API.walks.list(
const fetched = await API.walks.list(
_userPos?.lat ?? null,
_userPos?.lon ?? null
);
try { localStorage.setItem(_CACHE_KEY, JSON.stringify({ ts: Date.now(), data: fetched })); } catch {}
_data = [...pending, ...fetched];
_renderList();
_renderMarkers();
// Desktop: Karte direkt initialisieren (beide Panels sichtbar)
if (window.innerWidth >= 1024) {
UI.loadLeaflet().then(() => {
_initMap();
@ -210,8 +251,20 @@ window.Page_walks = (() => {
setTimeout(() => _map?.invalidateSize(), 400);
});
}
} catch (err) {
UI.toast.error(err.message || 'Fehler beim Laden.');
} catch {
try {
const raw = localStorage.getItem(_CACHE_KEY);
if (raw) {
_data = [...pending, ...(JSON.parse(raw).data || [])];
_renderList();
_renderMarkers();
UI.toast.info('Offline — zeige zuletzt geladene Treffen.');
return;
}
} catch {}
_data = pending;
if (pending.length) { _renderList(); _renderMarkers(); return; }
UI.toast.error('Treffen konnten nicht geladen werden.');
}
}
@ -291,6 +344,7 @@ window.Page_walks = (() => {
</span>
<span class="walks-badge">${UI.icon('paw-print')} ${w.teilnehmer_count}/${w.max_teilnehmer}</span>
${isOwn ? '<span class="walks-badge walks-badge--own">Mein Treffen</span>' : ''}
${w._isPending ? `<span style="font-size:10px;color:var(--c-warning,#d97706);font-weight:600">⏳ Sync ausstehend</span>` : ''}
</div>
</div>
<div style="display:flex;flex-direction:column;align-items:flex-end;gap:var(--space-1)">
@ -1128,15 +1182,30 @@ window.Page_walks = (() => {
const idx = _data.findIndex(w => w.id === walk.id);
if (idx !== -1) _data[idx] = { ..._data[idx], ...updated };
UI.toast.success('Treffen aktualisiert.');
} else {
const created = await API.walks.create(payload);
_data.unshift({ ...created, teilnehmer_count: 0 });
UI.toast.success('Treffen geplant! 🎉');
}
UI.modal.close();
_renderList();
_renderMarkers();
} else {
let created;
try {
created = await API.walks.create(payload);
} catch (netErr) {
if (netErr instanceof TypeError || !navigator.onLine) {
_addPending(payload);
UI.modal.close();
UI.toast.success('Offline gespeichert — wird synchronisiert sobald Verbindung besteht.');
_loadData();
return;
}
throw netErr;
}
if (created?._queued) { UI.modal.close(); _loadData(); return; }
_data.unshift({ ...created, teilnehmer_count: 0 });
UI.toast.success('Treffen geplant! 🎉');
UI.modal.close();
_renderList();
_renderMarkers();
}
});
});
}

View file

@ -167,6 +167,8 @@ window.Page_welcome = (() => {
<a href="/#impressum" style="color:var(--c-text-muted)">Impressum</a>
&nbsp;·&nbsp;
<a href="/#datenschutz" style="color:var(--c-text-muted)">Datenschutz</a>
&nbsp;·&nbsp;
<a href="/#agb" style="color:var(--c-text-muted)">AGB</a>
</p>
</div>
`;

View file

@ -115,20 +115,20 @@ window.Page_zuchthunde = (() => {
</svg>
</div>`;
return `
<div id="breeder-private-header" style="background:linear-gradient(135deg,#1a1208,#2d1f0e);
border-bottom:1px solid rgba(196,132,58,.25);
<div id="breeder-private-header" style="background:var(--c-bg-secondary);
border-bottom:1px solid var(--c-border);
padding:var(--space-3) var(--space-4);
display:flex;align-items:center;gap:var(--space-3)">
${logoHtml}
<div style="flex:1;min-width:0">
<h2 style="margin:0 0 2px;font-size:var(--text-lg);font-weight:700;
color:rgba(255,255,255,.95);white-space:nowrap;overflow:hidden;
color:var(--c-text);white-space:nowrap;overflow:hidden;
text-overflow:ellipsis;line-height:1.2">${_esc(zwinger)}</h2>
<div style="display:flex;align-items:center;gap:var(--space-2)">
<svg style="width:11px;height:11px;color:var(--c-primary);flex-shrink:0" viewBox="0 0 256 256">
<use href="/icons/phosphor.svg#lock-key"></use>
</svg>
<span style="font-size:var(--text-xs);color:rgba(196,132,58,.7)">Privater Bereich · Nur du siehst das</span>
<span style="font-size:var(--text-xs);color:var(--c-text-secondary)">Privater Bereich · Nur du siehst das</span>
</div>
</div>
</div>`;

View file

@ -83,13 +83,19 @@ const UI = (() => {
document.getElementById('modal-container').appendChild(overlay);
document.documentElement.classList.add('modal-open');
// Tastatur auf Mobilgeräten: Modal nach oben schieben wenn Keyboard erscheint
// Tastatur auf Mobilgeräten: Modal-Höhe begrenzen + fokussiertes Feld scrollen
let _vvCleanup = null;
const vv = window.visualViewport;
const modal = overlay.querySelector('.modal');
if (vv) {
const adjust = () => {
const kb = Math.max(0, window.innerHeight - vv.height - vv.offsetTop);
overlay.style.paddingBottom = (kb + 16) + 'px';
const visible = vv.height;
const offset = vv.offsetTop;
const kb = Math.max(0, window.innerHeight - visible - offset);
// Overlay-Padding damit Modal nach oben rückt
overlay.style.paddingBottom = (kb + 8) + 'px';
// Modal-Höhe hart begrenzen damit modal-body scrollbar bleibt
if (modal) modal.style.maxHeight = (visible - 24) + 'px';
};
vv.addEventListener('resize', adjust);
vv.addEventListener('scroll', adjust);
@ -97,19 +103,37 @@ const UI = (() => {
vv.removeEventListener('resize', adjust);
vv.removeEventListener('scroll', adjust);
overlay.style.paddingBottom = '';
if (modal) modal.style.maxHeight = '';
};
}
_current = { overlay, onClose, _vvCleanup };
// Fokussiertes Feld innerhalb modal-body scrollen (iOS scrollIntoView
// arbeitet nicht zuverlässig in overflow-Containern)
const _onFocusin = e => {
const el = e.target;
if (el.tagName !== 'INPUT' && el.tagName !== 'TEXTAREA' && el.tagName !== 'SELECT') return;
setTimeout(() => {
const body = el.closest('.modal-body');
if (!body) { el.scrollIntoView({ block: 'nearest', behavior: 'smooth' }); return; }
const elBottom = el.getBoundingClientRect().bottom;
const vvBottom = vv ? (vv.offsetTop + vv.height) : window.innerHeight;
const gap = elBottom - vvBottom + 56; // 56px Puffer über Tastatur
if (gap > 0) body.scrollTop += gap;
}, 380);
};
overlay.addEventListener('focusin', _onFocusin);
_current = { overlay, onClose, _vvCleanup, _onFocusin };
return overlay.querySelector('.modal');
}
function close() {
if (!_current) return;
const { onClose, _vvCleanup } = _current;
const { onClose, _vvCleanup, _onFocusin } = _current;
onClose?.();
_vvCleanup?.();
if (_onFocusin) _current.overlay.removeEventListener('focusin', _onFocusin);
_current.overlay.remove();
document.documentElement.classList.remove('modal-open');
_current = null;

View file

@ -598,13 +598,21 @@ window.Worlds = (() => {
let _cfgCache = null;
function _mergeDefaults(cfg) {
// Neue Default-Chips die noch nicht in der gespeicherten Config sind → anhängen
const result = JSON.parse(JSON.stringify(cfg));
const hidden = new Set(result.hidden || []);
// Chips die bereits einer Welt zugewiesen sind, nicht nochmal einfügen
const allAssigned = new Set([
...(result.jetzt || []), ...(result.hund || []), ...(result.welt || []),
]);
for (const world of ['jetzt', 'hund', 'welt']) {
const def = _DEFAULT_CONFIG[world] || [];
const saved = result[world] || [];
for (const page of def) {
if (!saved.includes(page)) saved.push(page);
// Nur echte Neu-Chips anhängen: nicht zugewiesen UND nicht bewusst ausgeblendet
if (!allAssigned.has(page) && !hidden.has(page)) {
saved.push(page);
allAssigned.add(page);
}
}
result[world] = saved;
}
@ -637,6 +645,11 @@ window.Worlds = (() => {
}
function _saveConfig(cfg) {
// Bewusst ausgeblendete Chips tracken: Default-Chips die keiner Welt zugewiesen sind
const allAssigned = new Set([...(cfg.jetzt||[]), ...(cfg.hund||[]), ...(cfg.welt||[])]);
const allDefault = [..._DEFAULT_CONFIG.jetzt, ..._DEFAULT_CONFIG.hund, ..._DEFAULT_CONFIG.welt];
cfg.hidden = allDefault.filter(p => !allAssigned.has(p));
_cfgCache = cfg;
try { localStorage.setItem('world_chips', JSON.stringify(cfg)); } catch {}
if (_state?.user) {
@ -710,15 +723,18 @@ window.Worlds = (() => {
const bottomNav = document.getElementById('bottom-nav');
if (bottomNav) bottomNav.style.display = 'none';
const _isDesktop = window.innerWidth >= 768;
const ov = document.createElement('div');
ov.id = 'wc-overlay';
ov.style.cssText = 'position:fixed;inset:0;z-index:500;display:flex;flex-direction:column;justify-content:flex-end';
ov.style.cssText = _isDesktop
? 'position:fixed;inset:0;z-index:500;display:flex;align-items:center;justify-content:center;background:rgba(0,0,0,0.6);backdrop-filter:blur(4px)'
: 'position:fixed;inset:0;z-index:500;display:flex;flex-direction:column;justify-content:flex-end';
document.body.appendChild(ov);
const _removeDragListeners = () => {
document.removeEventListener('touchmove', _onDragMove);
document.removeEventListener('touchend', _onDragEnd);
document.removeEventListener('touchcancel', _onDragEnd);
document.removeEventListener('pointermove', _onDragMove);
document.removeEventListener('pointerup', _onDragEnd);
document.removeEventListener('pointercancel', _onDragEnd);
};
const _cancelDrag = () => {
if (!_drag) return;
@ -736,11 +752,14 @@ window.Worlds = (() => {
};
function _render() {
const _sheetStyle = _isDesktop
? 'position:relative;z-index:1;background:rgba(18,22,32,0.97);border-radius:20px;width:90%;max-width:1100px;max-height:88vh;overflow-y:auto;-webkit-overflow-scrolling:touch;padding:0 0 20px'
: 'position:relative;z-index:1;background:rgba(18,22,32,0.97);border-radius:24px 24px 0 0;max-height:92vh;overflow-y:auto;-webkit-overflow-scrolling:touch;padding:0 0 calc(env(safe-area-inset-bottom,16px)+16px)';
const _gridCols = _isDesktop ? 'repeat(auto-fill,minmax(120px,1fr))' : 'repeat(4,1fr)';
const _chipH = _isDesktop ? '64px' : '80px';
ov.innerHTML = `
<div id="wc-bg" style="position:absolute;inset:0;background:rgba(0,0,0,0.7);backdrop-filter:blur(4px)"></div>
<div id="wc-sheet" style="position:relative;z-index:1;background:rgba(18,22,32,0.97);border-radius:24px 24px 0 0;
max-height:92vh;overflow-y:auto;-webkit-overflow-scrolling:touch;
padding:0 0 calc(env(safe-area-inset-bottom,16px)+16px)">
${!_isDesktop ? '<div id="wc-bg" style="position:absolute;inset:0;background:rgba(0,0,0,0.7);backdrop-filter:blur(4px)"></div>' : ''}
<div id="wc-sheet" style="${_sheetStyle}">
<!-- Header -->
<div style="display:flex;align-items:center;justify-content:space-between;
padding:20px 20px 16px;position:sticky;top:0;background:rgba(18,22,32,0.97);
@ -776,15 +795,15 @@ window.Worlds = (() => {
${w !== 'pool' ? `<span style="opacity:.5">(${chips.length})</span>` : ''}
</div>
<div class="wc-zone" data-zone="${w}"
style="display:grid;grid-template-columns:repeat(4,1fr);grid-auto-rows:80px;gap:8px;
style="display:grid;grid-template-columns:${_gridCols};grid-auto-rows:${_chipH};gap:${_isDesktop?'6px':'8px'};
min-height:${w==='pool'&&chips.length===0?'40px':'auto'};
border:2px dashed transparent;border-radius:16px;padding:4px;
transition:border-color .2s">
${chips.map(c => `
<div class="wc-chip" data-page="${c.page}" data-zone="${w}"
style="background:rgba(38,46,62,0.95);border:1.5px solid ${col};
border-radius:16px;padding:10px 4px 8px;height:80px;box-sizing:border-box;
display:flex;flex-direction:column;align-items:center;justify-content:center;gap:5px;
border-radius:16px;padding:${_isDesktop?'6px 4px':'10px 4px 8px'};height:${_chipH};box-sizing:border-box;
display:flex;flex-direction:column;align-items:center;justify-content:center;gap:${_isDesktop?'3px':'5px'};
cursor:grab;position:relative;min-width:0;overflow:hidden;
user-select:none;-webkit-tap-highlight-color:transparent;touch-action:pan-y">
${!c.pinned ? `
@ -855,29 +874,27 @@ window.Worlds = (() => {
});
});
// Touch-Drag
// Pointer-Drag (funktioniert auf Mouse + Touch)
ov.querySelectorAll('.wc-chip').forEach(chip => {
chip.addEventListener('touchstart', e => _onDragStart(e, chip), { passive: true });
chip.addEventListener('pointerdown', e => _onDragStart(e, chip));
});
document.addEventListener('touchmove', _onDragMove, { passive: false });
document.addEventListener('touchend', _onDragEnd);
}
function _onDragStart(e, chipEl) {
if (e.button !== undefined && e.button !== 0) return; // nur linke Maustaste
if (_drag) _cancelDrag();
const touch = e.touches[0];
// Drag erst nach Bewegungs-Threshold aktivieren (verhindert Scroll-Konflikte)
chipEl.setPointerCapture(e.pointerId);
_drag = {
page: chipEl.dataset.page, zone: chipEl.dataset.zone,
chipEl, ghost: null, dropZone: null, active: false,
startX: touch.clientX, startY: touch.clientY, ox: 0, oy: 0,
startX: e.clientX, startY: e.clientY, ox: 0, oy: 0,
};
document.addEventListener('touchmove', _onDragMove, { passive: false });
document.addEventListener('touchend', _onDragEnd);
document.addEventListener('touchcancel', _onDragEnd);
document.addEventListener('pointermove', _onDragMove);
document.addEventListener('pointerup', _onDragEnd);
document.addEventListener('pointercancel', _onDragEnd);
}
function _activateDrag(touch) {
function _activateDrag(e) {
const rect = _drag.chipEl.getBoundingClientRect();
_drag.ox = _drag.startX - rect.left;
_drag.oy = _drag.startY - rect.top;
@ -891,8 +908,8 @@ window.Worlds = (() => {
ghost.style.transform = 'scale(1.08) rotate(-2deg)';
ghost.style.width = rect.width + 'px';
ghost.style.height = rect.height + 'px';
ghost.style.left = (touch.clientX - _drag.ox) + 'px';
ghost.style.top = (touch.clientY - _drag.oy) + 'px';
ghost.style.left = (e.clientX - _drag.ox) + 'px';
ghost.style.top = (e.clientY - _drag.oy) + 'px';
ghost.style.transition = 'none';
ghost.style.boxShadow = '0 8px 24px rgba(0,0,0,0.5)';
document.body.appendChild(ghost);
@ -902,24 +919,22 @@ window.Worlds = (() => {
function _onDragMove(e) {
if (!_drag) return;
const touch = e.touches[0];
if (!_drag.active) {
const dx = Math.abs(touch.clientX - _drag.startX);
const dy = Math.abs(touch.clientY - _drag.startY);
if (dx < 8 && dy < 8) return; // Threshold noch nicht erreicht
_activateDrag(touch);
const dx = Math.abs(e.clientX - _drag.startX);
const dy = Math.abs(e.clientY - _drag.startY);
if (dx < 8 && dy < 8) return;
_activateDrag(e);
}
e.preventDefault(); // Scroll erst NACH Threshold blockieren
_drag.ghost.style.left = (touch.clientX - _drag.ox) + 'px';
_drag.ghost.style.top = (touch.clientY - _drag.oy) + 'px';
_drag.ghost.style.left = (e.clientX - _drag.ox) + 'px';
_drag.ghost.style.top = (e.clientY - _drag.oy) + 'px';
let foundZone = null;
ov.querySelectorAll('.wc-zone').forEach(z => {
const r = z.getBoundingClientRect();
const over = touch.clientX >= r.left && touch.clientX <= r.right
&& touch.clientY >= r.top && touch.clientY <= r.bottom;
const over = e.clientX >= r.left && e.clientX <= r.right
&& e.clientY >= r.top && e.clientY <= r.bottom;
z.style.borderColor = over ? (worldColors[z.dataset.zone] || 'rgba(196,132,58,0.8)') : 'transparent';
if (over) foundZone = z.dataset.zone;
});
@ -967,6 +982,19 @@ window.Worlds = (() => {
let _bgUrl = null; // aktuell gesetztes Hintergrundbild
function _isDarkMode() {
const t = document.documentElement.getAttribute('data-theme');
if (t === 'dark') return true;
if (t === 'light') return false;
return window.matchMedia('(prefers-color-scheme: dark)').matches;
}
function _bgWithOverlay(url) {
return _isDarkMode()
? `linear-gradient(rgba(0,0,0,0.45),rgba(0,0,0,0.45)), url('${url}')`
: `url('${url}')`;
}
function _applyBgOrientation() {
const ov = document.getElementById('worlds-overlay');
const track = document.getElementById('worlds-track');
@ -975,14 +1003,14 @@ window.Worlds = (() => {
if (portrait) {
// Panorama: Bild über alle drei Welten, scrollt mit dem Swipe
ov.style.backgroundImage = '';
track.style.backgroundImage = `url('${_bgUrl}')`;
track.style.backgroundImage = _bgWithOverlay(_bgUrl);
track.style.backgroundSize = 'cover';
track.style.backgroundPosition = 'center 40%';
track.style.backgroundRepeat = 'no-repeat';
} else {
// Vollbild pro Welt (Landscape / Desktop)
track.style.backgroundImage = '';
ov.style.backgroundImage = `url('${_bgUrl}')`;
ov.style.backgroundImage = _bgWithOverlay(_bgUrl);
ov.style.backgroundSize = 'cover';
ov.style.backgroundPosition = 'center 40%';
ov.style.backgroundRepeat = 'no-repeat';
@ -992,6 +1020,10 @@ window.Worlds = (() => {
// Orientierungswechsel → Bild neu setzen
window.matchMedia('(orientation: portrait)').addEventListener('change', _applyBgOrientation);
// Theme-Wechsel → Overlay-Intensität anpassen
new MutationObserver(_applyBgOrientation)
.observe(document.documentElement, { attributeFilter: ['data-theme'] });
function _applyBgImage(url) {
const ov = document.getElementById('worlds-overlay');
const track = document.getElementById('worlds-track');
@ -1002,7 +1034,20 @@ window.Worlds = (() => {
_hasBgPhoto = true;
_bgUrl = url;
_applyBgOrientation();
document.getElementById('wh-photo-hint')?.remove();
const hint = document.getElementById('wh-photo-hint');
if (hint) {
const seen = parseInt(localStorage.getItem('banyaro_wh_hint_seen') || '0');
if (seen < 2) {
localStorage.setItem('banyaro_wh_hint_seen', String(seen + 1));
setTimeout(() => {
hint.style.transition = 'opacity 0.6s';
hint.style.opacity = '0';
setTimeout(() => hint.remove(), 650);
}, 4000);
} else {
hint.remove();
}
}
};
toLoad.onerror = () => _applyBgImage(null);
toLoad.src = url;
@ -1077,9 +1122,15 @@ window.Worlds = (() => {
} else if (!dog) { _applyBgImage(null); }
const hour = new Date().getHours();
const greet = hour < 5 ? 'Gute Nacht' : hour < 12 ? 'Guten Morgen' : hour < 18 ? 'Hallo' : 'Guten Abend';
const firstName = user?.name?.split(' ')[0] || '';
const dayStr = new Date().toLocaleDateString('de-DE', { weekday:'long', day:'numeric', month:'long' });
// User-Geburtstag heute?
const _todayDdMm = (() => { const d = new Date(); return String(d.getDate()).padStart(2,'0')+'.'+String(d.getMonth()+1).padStart(2,'0'); })();
const userBdayToday = user?.geburtstag && user.geburtstag === _todayDdMm;
const greet = userBdayToday
? `Herzlichen Glückwunsch`
: (hour < 5 ? 'Gute Nacht' : hour < 12 ? 'Guten Morgen' : hour < 18 ? 'Hallo' : 'Guten Abend');
const stale = isOffline && staleMin > 5
? `<span style="font-size:9px;opacity:0.5;margin-left:6px">· Offline</span>` : '';
@ -1104,6 +1155,26 @@ window.Worlds = (() => {
: (w.temp_c ?? 20) < 2 ? '🌨️'
: '☀️';
// User-Geburtstag Reminder
const userBdayHtml = userBdayToday ? `
<div class="world-reminder" style="border-color:rgba(196,132,58,0.6);
flex-direction:column;align-items:center;text-align:center;gap:6px;padding:12px 14px">
<div style="display:flex;gap:8px;align-items:center;justify-content:center">
<svg class="ph-icon bday-fw1" style="width:1.3rem;height:1.3rem;color:#f59e0b">
<use href="/icons/phosphor.svg#confetti"></use></svg>
<svg class="ph-icon bday-pop" style="width:1.8rem;height:1.8rem;color:#fff">
<use href="/icons/phosphor.svg#cake"></use></svg>
<svg class="ph-icon bday-fw2" style="width:1.3rem;height:1.3rem;color:#f59e0b">
<use href="/icons/phosphor.svg#confetti"></use></svg>
</div>
<div style="font-weight:800;font-size:var(--text-sm);color:#fff">
Alles Gute zum Geburtstag, ${_esc(firstName)}!
</div>
<div style="font-size:10px;color:rgba(255,255,255,0.55)">
Wir wünschen dir und deinem Hund einen wunderschönen Tag 🐾
</div>
</div>` : '';
// Alert-Reminder
const alertHtml = alertList.slice(0,1).map(a => `
<div class="world-reminder" data-wnav="${a.page}" style="border-color:rgba(239,68,68,0.5)">
@ -1150,6 +1221,7 @@ window.Worlds = (() => {
${user ? userAvatarHtml : ''}
</div>
</div>
${userBdayHtml}
${alertHtml}
${user && dog ? `
<div class="wj-chip-row">
@ -1186,7 +1258,6 @@ window.Worlds = (() => {
</div>` : ''}
</div>
<div class="world-bottom">
<div class="world-section-label">Deine Bereiche</div>
<div class="world-chips-grid">
${features.map(f => _chip(f.icon, f.label, f.page, false, false, false)).join('')}
</div>
@ -1209,8 +1280,7 @@ window.Worlds = (() => {
try {
const res = await _cachedGet(`dash_${dog.id}`, `/dogs/${dog.id}/welcome-dashboard`);
const ex = res.data?.daily_exercise;
valEl.textContent = ex?.name || '—';
// Chip-Klick mit exercise_id/name damit übungen.js direkt dorthin scrollt
valEl.textContent = ex?.name || 'Stand erfassen →';
const chip = document.getElementById('wj-exercise-chip');
if (chip) {
chip.style.cursor = 'pointer';
@ -1221,7 +1291,7 @@ window.Worlds = (() => {
);
};
}
} catch { valEl.textContent = ''; }
} catch { valEl.textContent = 'Stand erfassen →'; }
}
async function _loadJetztRoute() {
@ -1342,8 +1412,13 @@ window.Worlds = (() => {
if (mmdd === `${mt}-${dt}`) return 'tomorrow';
return null;
}
const bday = _birthdayState(dog.geburtstag);
const bdayYear = dog.geburtstag ? new Date().getFullYear() - parseInt(dog.geburtstag.slice(0, 4)) : null;
const bdayDog = _dogs.find(d => _birthdayState(d.geburtstag)) || null;
// Großes Banner nur wenn der AKTIVE Hund Geburtstag hat
const bday = (bdayDog && bdayDog.id === dog.id) ? _birthdayState(dog.geburtstag) : null;
const bdayYear = bday && dog.geburtstag ? new Date().getFullYear() - parseInt(dog.geburtstag.slice(0, 4)) : null;
// Hinweis in Info-Karte wenn ein ANDERER Hund Geburtstag hat
const otherBdayDog = (bdayDog && bdayDog.id !== dog.id) ? bdayDog : null;
const [streakRes, diaryRes] = await Promise.allSettled([
_cachedGet(`streak_${dog.id}`, `/streak/${dog.id}`),
@ -1402,6 +1477,22 @@ window.Worlds = (() => {
</div>
<div style="justify-self:end;display:flex;align-items:center">${otherAvatarsHtml}</div>
</div>
${otherBdayDog ? `
<div style="margin-top:8px;padding-top:8px;border-top:1px solid rgba(255,255,255,0.12);
display:flex;align-items:center;gap:8px;cursor:pointer"
id="wh-other-bday-hint">
<span style="animation:by-bday-bounce 1.2s ease-in-out infinite;display:inline-flex">
<svg class="ph-icon" style="width:1.2rem;height:1.2rem;color:#f59e0b" aria-hidden="true">
<use href="/icons/phosphor.svg#cake"></use>
</svg>
</span>
<span style="font-size:var(--text-xs);color:rgba(255,255,255,0.75);font-weight:600">
${_esc(otherBdayDog.name)} hat ${_birthdayState(otherBdayDog.geburtstag) === 'today' ? 'heute' : 'morgen'} Geburtstag!
</span>
<svg class="ph-icon" style="width:.9rem;height:.9rem;color:rgba(196,132,58,0.8);margin-left:auto" aria-hidden="true">
<use href="/icons/phosphor.svg#arrow-circle-right"></use>
</svg>
</div>` : ''}
</div>
${bday ? `
<style>
@ -1428,8 +1519,8 @@ window.Worlds = (() => {
</div>
<div style="font-weight:800;font-size:var(--text-sm);color:#fff;letter-spacing:0.01em">
${bday === 'today'
? `Alles Gute zum ${bdayYear}. Geburtstag, ${_esc(dog.name)}!`
: `Morgen hat ${_esc(dog.name)} Geburtstag!`}
? `Alles Gute zum ${bdayYear}. Geburtstag, ${_esc(bdayDog.name)}!`
: `Morgen hat ${_esc(bdayDog.name)} Geburtstag!`}
</div>
<div style="display:flex;gap:8px;align-items:center">
<svg class="ph-icon bday-fw3" style="width:1rem;height:1rem;color:#e8c96e"><use href="/icons/phosphor.svg#sparkle"></use></svg>
@ -1443,7 +1534,7 @@ window.Worlds = (() => {
</div>` : ''}
<div style="display:flex;align-items:center;gap:4px;font-size:10px;color:rgba(196,132,58,0.9);font-weight:700;margin-top:2px">
<svg class="ph-icon" style="width:11px;height:11px"><use href="/icons/phosphor.svg#magic-wand"></use></svg>
${bday === 'today' ? 'Was hat sich Ban Yaro gewünscht?' : 'KI-Überraschungsideen'}
${bday === 'today' ? `Was hat sich ${_esc(bdayDog.name)} gewünscht?` : 'KI-Überraschungsideen'}
</div>
</div>
${bday === 'today' && new Date().getHours() >= 18 ? `
@ -1472,17 +1563,18 @@ window.Worlds = (() => {
Hintergrund-Foto hinzufügen
</div>
<div style="font-size:10px;color:rgba(255,255,255,0.45);margin-top:1px">
Tagebuchfotos erscheinen hier als Panorama
Tagebuchfotos im Querformat erscheinen hier als Panorama
</div>
</div>
</div>
` : ''}
<div class="world-section-label">Alles über ${_esc(dog.name)}</div>
<div class="world-chips-grid">
${chips.map(c => _chip(c.icon, c.label, c.page, false, false, false)).join('')}
</div>
<div class="world-footer-links">
<span data-wnav="gruender">Die 100 Gründer</span>
<span>·</span>
<span data-wnav="partner">Unsere Partner</span>
</div>
</div>
`;
@ -1509,6 +1601,19 @@ window.Worlds = (() => {
if (!isNaN(idx) && idx !== _dogIdx) { _dogIdx = idx; _renderHund(); }
});
});
// Geburtstag-Hinweis → zum Geburtstagshund wechseln
if (otherBdayDog) {
if (!document.getElementById('by-bday-anim-style')) {
const s = document.createElement('style');
s.id = 'by-bday-anim-style';
s.textContent = '@keyframes by-bday-bounce{0%,100%{transform:translateY(0) scale(1)}50%{transform:translateY(-5px) scale(1.3)}}';
document.head.appendChild(s);
}
el.querySelector('#wh-other-bday-hint')?.addEventListener('click', () => {
const idx = _dogs.indexOf(otherBdayDog);
if (idx >= 0) { _dogIdx = idx; _renderHund(); }
});
}
// Geburtstags-Banner → KI
el.querySelector('#wh-bday-banner')?.addEventListener('click', () => _openBdayKI(dog, bday));
@ -1563,10 +1668,10 @@ window.Worlds = (() => {
try {
const res = await API.post('/ki/geburtstag', {
dog_id: dog.id,
name: dog.name,
rasse: dog.rasse || null,
alter: dog.alter_jahre ? Math.round(dog.alter_jahre) : null,
dog_id: bdayDog.id,
name: bdayDog.name,
rasse: bdayDog.rasse || null,
alter: bdayDog.alter_jahre ? Math.round(bdayDog.alter_jahre) : null,
mode: bdayMode,
});
const body = ov.querySelector('#bday-ki-body');
@ -1651,12 +1756,13 @@ window.Worlds = (() => {
</div>
</div>
<div class="world-bottom">
<div class="world-section-label">Die Welt da draußen</div>
<div class="world-chips-grid">
${chips.map(c => _chip(c.icon, c.label, c.page, false, false, false)).join('')}
</div>
<div class="world-footer-links">
<span data-wnav="datenschutz">Datenschutz</span>
<span style="color:var(--c-border)">·</span>
<span data-wnav="agb">AGB</span>
</div>
</div>
`;
@ -1688,7 +1794,7 @@ window.Worlds = (() => {
const pos = await API.getLocation({ timeout: 4000, maximumAge: 600_000 });
const [p, l] = await Promise.allSettled([
API.get(`/poison/nearby?lat=${pos.lat}&lon=${pos.lon}&radius=5`).catch(() => []),
API.get(`/lost/nearby?lat=${pos.lat}&lon=${pos.lon}&radius=5`).catch(() => []),
API.get(`/lost/nearby?lat=${pos.lat}&lon=${pos.lon}&radius=20`).catch(() => []),
]);
if (p.value?.length) out.push({ icon:'skull', color:'#EF4444', title:'Giftköder in der Nähe', sub:`${p.value.length} Meldung${p.value.length>1?'en':''}`, page:'poison' });
if (l.value?.length) out.push({ icon:'dog', color:'#3B82F6', title:'Verlorener Hund', sub:`${l.value.length} Meldung${l.value.length>1?'en':''}`, page:'lost' });
@ -1702,6 +1808,26 @@ window.Worlds = (() => {
`).join('');
}
function _updateBdayTabIndicator(bdayDog) {
if (bdayDog && !document.getElementById('by-bday-tab-style')) {
const s = document.createElement('style');
s.id = 'by-bday-tab-style';
s.textContent = '@keyframes by-bday-bounce{0%,100%{transform:translateY(0) scale(1)}50%{transform:translateY(-5px) scale(1.25)}}' +
'.wlabel-bday-ic{display:inline-block;animation:by-bday-bounce 1.2s ease-in-out infinite;margin-left:3px;font-size:.85em}';
document.head.appendChild(s);
}
const hundTab = document.querySelectorAll('#world-labels .wlabel')[1];
if (!hundTab) return;
hundTab.querySelector('.wlabel-bday-ic')?.remove();
if (bdayDog) {
const ic = document.createElement('span');
ic.className = 'wlabel-bday-ic';
ic.textContent = '🎂';
ic.title = `${bdayDog.name} hat ${_birthdayState(bdayDog.geburtstag) === 'today' ? 'heute' : 'morgen'} Geburtstag!`;
hundTab.appendChild(ic);
}
}
function _fmtDate(d) {
if (!d) return '';
try { return new Date(d).toLocaleDateString('de-DE', { day:'numeric', month:'short' }); }

View file

@ -741,13 +741,7 @@
<span class="badge">Made in Germany</span>
<span class="badge">Offline-fähig</span>
</div>
<div class="hero-stats" id="hero-stats" style="display:none">
<strong id="stat-users"></strong> Hundemenschen
<span class="sep">·</span>
<strong id="stat-dogs"></strong> Hunde
<span class="sep">·</span>
<strong id="stat-km"></strong> km Gassi-Wege
</div>
<div class="hero-stats" id="hero-stats" style="display:none"></div>
</div>
</header>
@ -824,6 +818,14 @@
<div class="stats-band-num" id="big-posts"></div>
<div class="stats-band-label">Forum-Beiträge</div>
</div>
<div class="stats-band-item fade-up">
<div class="stats-band-num" id="big-diary"></div>
<div class="stats-band-label">Tagebuch-Einträge</div>
</div>
<div class="stats-band-item fade-up">
<div class="stats-band-num" id="big-kotbeutel"></div>
<div class="stats-band-label">Mülleimer für Kotbeutel</div>
</div>
</div>
</div>
</section>
@ -1113,6 +1115,77 @@
</div>
</div>
<!-- Läufigkeit Feature Spotlight -->
<div style="margin-top:3rem;display:grid;grid-template-columns:1fr 1fr;gap:2.5rem;align-items:center">
<div>
<p style="font-size:.75rem;font-weight:700;text-transform:uppercase;letter-spacing:.12em;color:#C4843A;margin:0 0 .5rem">Läufigkeit &amp; Trächtigkeit</p>
<h3 style="margin:0 0 1rem;font-size:1.4rem">Kein Zettelkaos mehr.</h3>
<p style="color:#555;line-height:1.7;margin:0 0 1.25rem">Progesterontests, Deckdaten und Trächtigkeits-Meilensteine an einem Ort. Die App berechnet automatisch wann der früheste Ultraschall möglich ist, wann der Bauch sichtbar wird — und erinnert dich rechtzeitig.</p>
<ul style="list-style:none;padding:0;margin:0;display:flex;flex-direction:column;gap:.5rem;color:#444;font-size:.9rem">
<li style="display:flex;gap:.5rem;align-items:flex-start">
<svg viewBox="0 0 256 256" style="width:1rem;height:1rem;flex-shrink:0;margin-top:2px;fill:#C4843A"><use href="/icons/phosphor.svg#check-circle"></use></svg>
Zykluskalender mit Beginn, Ende und Dauer
</li>
<li style="display:flex;gap:.5rem;align-items:flex-start">
<svg viewBox="0 0 256 256" style="width:1rem;height:1rem;flex-shrink:0;margin-top:2px;fill:#C4843A"><use href="/icons/phosphor.svg#check-circle"></use></svg>
Progesteronkurve: Werte, Labor, Übersicht
</li>
<li style="display:flex;gap:.5rem;align-items:flex-start">
<svg viewBox="0 0 256 256" style="width:1rem;height:1rem;flex-shrink:0;margin-top:2px;fill:#C4843A"><use href="/icons/phosphor.svg#check-circle"></use></svg>
8 automatische Trächtigkeits-Meilensteine
</li>
<li style="display:flex;gap:.5rem;align-items:flex-start">
<svg viewBox="0 0 256 256" style="width:1rem;height:1rem;flex-shrink:0;margin-top:2px;fill:#C4843A"><use href="/icons/phosphor.svg#check-circle"></use></svg>
Deckdaten: Rüde, Deckart, Ultraschall-Ergebnis
</li>
</ul>
</div>
<div style="background:linear-gradient(135deg,#1a1208,#2d1f0e);border-radius:16px;padding:1.25rem;font-size:.82rem;color:white">
<div style="font-size:.7rem;font-weight:700;letter-spacing:.1em;text-transform:uppercase;color:rgba(196,132,58,.7);margin-bottom:.75rem">Beispiel — Luna vom Bergwald</div>
<!-- Deckung -->
<div style="background:rgba(255,255,255,.06);border:1px solid rgba(196,132,58,.3);border-radius:10px;padding:.75rem;margin-bottom:.5rem">
<div style="display:flex;align-items:center;gap:.5rem;margin-bottom:.35rem">
<svg viewBox="0 0 256 256" style="width:.9rem;height:.9rem;fill:#e74c7a;flex-shrink:0"><use href="/icons/phosphor.svg#heart"></use></svg>
<span style="font-weight:700">Deckung 14.05.2026</span>
<span style="background:rgba(34,197,94,.2);color:#4ade80;border-radius:4px;padding:1px 6px;font-size:.7rem;font-weight:600;margin-left:auto">Trächtig ✓</span>
</div>
<div style="color:rgba(255,255,255,.55);font-size:.75rem">Blitz vom Schwarzwaldforst ZB 44201 · Natürlich</div>
<!-- Nächster Meilenstein -->
<div style="background:rgba(196,132,58,.15);border:1px solid rgba(196,132,58,.4);border-radius:6px;padding:.4rem .6rem;margin-top:.5rem;font-size:.72rem;display:flex;align-items:center;gap:.35rem">
<svg viewBox="0 0 256 256" style="width:.75rem;height:.75rem;fill:#C4843A;flex-shrink:0"><use href="/icons/phosphor.svg#calendar-check"></use></svg>
<span style="color:#f5c07a;font-weight:600">Frühester Ultraschall · Tag 21 · 04.06.2026</span>
</div>
</div>
<!-- Meilensteine -->
<div style="display:flex;flex-direction:column;gap:.3rem">
<div style="font-size:.7rem;font-weight:700;letter-spacing:.08em;text-transform:uppercase;color:rgba(255,255,255,.35);margin-bottom:.1rem">Trächtigkeits-Meilensteine</div>
<div style="display:flex;align-items:center;gap:.5rem;color:rgba(255,255,255,.7)"><span style="background:rgba(196,132,58,.25);color:#f5c07a;border-radius:50%;width:18px;height:18px;display:flex;align-items:center;justify-content:center;font-size:.65rem;font-weight:700;flex-shrink:0">21</span>04.06. Frühester Ultraschall möglich</div>
<div style="display:flex;align-items:center;gap:.5rem;color:rgba(255,255,255,.7)"><span style="background:rgba(196,132,58,.25);color:#f5c07a;border-radius:50%;width:18px;height:18px;display:flex;align-items:center;justify-content:center;font-size:.65rem;font-weight:700;flex-shrink:0">25</span>08.06. Welpen erkennbar im Ultraschall</div>
<div style="display:flex;align-items:center;gap:.5rem;color:rgba(255,255,255,.7)"><span style="background:rgba(196,132,58,.25);color:#f5c07a;border-radius:50%;width:18px;height:18px;display:flex;align-items:center;justify-content:center;font-size:.65rem;font-weight:700;flex-shrink:0">35</span>18.06. Bauch wird sichtbar</div>
<div style="display:flex;align-items:center;gap:.5rem;color:rgba(255,255,255,.5);font-size:.72rem;margin-top:.1rem">+ 5 weitere Meilensteine bis Geburtstermin</div>
</div>
<!-- Progesterontests -->
<div style="border-top:1px solid rgba(255,255,255,.1);margin-top:.75rem;padding-top:.75rem">
<div style="font-size:.7rem;font-weight:700;letter-spacing:.08em;text-transform:uppercase;color:rgba(255,255,255,.35);margin-bottom:.4rem">Progesteronkurve</div>
<div style="display:flex;gap:.5rem;align-items:flex-end;height:40px">
<div style="display:flex;flex-direction:column;align-items:center;gap:2px;flex:1">
<div style="background:#C4843A;width:100%;border-radius:2px 2px 0 0;height:30%"></div>
<span style="font-size:.6rem;color:rgba(255,255,255,.4)">10.8</span>
</div>
<div style="display:flex;flex-direction:column;align-items:center;gap:2px;flex:1">
<div style="background:#C4843A;width:100%;border-radius:2px 2px 0 0;height:65%"></div>
<span style="font-size:.6rem;color:rgba(255,255,255,.4)">17.5</span>
</div>
<div style="display:flex;flex-direction:column;align-items:center;gap:2px;flex:1">
<div style="background:#f5c07a;width:100%;border-radius:2px 2px 0 0;height:100%"></div>
<span style="font-size:.6rem;color:rgba(255,255,255,.4)">22.3</span>
</div>
</div>
<div style="font-size:.65rem;color:rgba(255,255,255,.3);margin-top:.25rem">ng/ml · Ovulation erreicht</div>
</div>
</div>
</div>
</div>
</section>
@ -1527,6 +1600,7 @@
<div class="footer-links">
<a href="/#impressum">Impressum</a>
<a href="/#datenschutz">Datenschutz</a>
<a href="/#agb">AGB</a>
<a href="/presse">Presse</a>
</div>
</div>
@ -1563,26 +1637,36 @@
fetch('/api/stats/public')
.then(function(r) { return r.json(); })
.then(function(d) {
var ids = {
users: ['stat-users', 'big-users'],
dogs: ['stat-dogs', 'big-dogs'],
km: ['stat-km', 'big-km'],
forum_posts: [null, 'big-posts'],
};
function set(id, val) {
var el = document.getElementById(id);
if (el) el.textContent = fmt.format(val);
}
set('stat-users', d.users);
set('stat-dogs', d.dogs);
set('stat-km', d.km);
// Stats-Band (weiter unten)
set('big-users', d.users);
set('big-dogs', d.dogs);
set('big-km', d.km);
set('big-posts', d.forum_posts);
set('big-diary', d.diary_entries);
set('big-kotbeutel', d.kotbeutel);
// Hero-Streifen: aufsteigend nach Wert sortiert, dynamisch aufgebaut
var heroStats = document.getElementById('hero-stats');
if (heroStats && d.users > 0) heroStats.style.display = 'flex';
if (!heroStats || !d.users) return;
var items = [
{ val: d.users, label: 'Hundemenschen' },
{ val: d.dogs, label: 'Hunde' },
{ val: d.km, label: 'km Gassi-Wege' },
{ val: d.diary_entries, label: 'Tagebuch-Einträge' },
{ val: d.kotbeutel, label: 'Mülleimer für Kotbeutel'},
];
items.sort(function(a, b) { return a.val - b.val; });
heroStats.innerHTML = items.map(function(item, i) {
return (i > 0 ? '<span class="sep">·</span>' : '') +
'<strong>' + fmt.format(item.val) + '</strong> ' + item.label;
}).join('');
heroStats.style.display = 'flex';
})
.catch(function() {});
</script>

View file

@ -3,20 +3,35 @@
Offline-Cache + Push Notifications + Tile-Cache
============================================================ */
const CACHE_VERSION = 'by-v961';
// ← EINZIGE Stelle für die Version — STATIC_ASSETS und CACHE_VERSION leiten sich ab
const VER = '1070';
const CACHE_VERSION = `by-v${VER}`;
const CACHE_STATIC = `${CACHE_VERSION}-static`;
const CACHE_TILES = 'ban-yaro-tiles-v1'; // bleibt über SW-Updates erhalten
const CACHE_API = 'ban-yaro-api-v1'; // API-Response-Cache
// Prioritäts-Seiten: werden nach Install im Hintergrund gecacht (nicht blockierend)
const PRIORITY_PAGES = [
'/js/pages/admin.js',
'/js/pages/erste-hilfe.js',
'/js/pages/diary.js',
'/js/pages/map.js',
'/js/pages/walks.js',
'/js/pages/routes.js',
'/js/pages/poison.js',
'/js/pages/lost.js',
];
// index.html wird NICHT pre-gecacht (immer Network-First)
const STATIC_ASSETS = [
'/css/design-system.css?v=700',
'/css/layout.css?v=700',
'/css/components.css?v=700',
`/css/design-system.css?v=${VER}`,
`/css/layout.css?v=${VER}`,
`/css/components.css?v=${VER}`,
'/icons/phosphor.svg',
'/js/api.js',
'/js/ui.js',
'/js/app.js',
`/js/api.js?v=${VER}`,
`/js/ui.js?v=${VER}`,
`/js/app.js?v=${VER}`,
`/js/worlds.js?v=${VER}`,
'/js/leaflet.markercluster.js',
'/css/MarkerCluster.css',
'/css/MarkerCluster.Default.css',
@ -107,6 +122,8 @@ const _QUEUEABLE = [
{ re: /^\/api\/training\/sessions$/, methods: ['POST'] },
{ re: /^\/api\/training\/progress$/, methods: ['POST'] },
{ re: /^\/api\/poison$/, methods: ['POST'] },
{ re: /^\/api\/lost\/report$/, methods: ['POST'] },
{ re: /^\/api\/walks$/, methods: ['POST'] },
];
function _isQueueable(pathname, method) {
return _QUEUEABLE.some(q => q.methods.includes(method) && q.re.test(pathname));
@ -126,6 +143,14 @@ const _CACHEABLE_GET = [
/^\/api\/training\/plan-progress/,
/^\/api\/wiki\/rassen/,
/^\/api\/dogs\/\d+\/diary\/stats/,
/^\/api\/routes/,
/^\/api\/places/,
/^\/api\/breeder\/map-markers/,
/^\/api\/gassi-zeiten/,
/^\/api\/poison/,
/^\/api\/walks/,
/^\/api\/lost/,
/^\/api\/expenses/,
// Drei Welten — offline-fähig
/^\/api\/streak\/\d+/,
/^\/api\/forum\/threads/,
@ -162,13 +187,22 @@ function _cacheMark(pathname) {
// INSTALL — App Shell cachen
// ----------------------------------------------------------
self.addEventListener('install', event => {
self.skipWaiting(); // Sofort übernehmen — kein Warten auf Cache-Aufbau
self.skipWaiting();
event.waitUntil(
caches.open(CACHE_STATIC)
.then(cache => cache.addAll(STATIC_ASSETS))
.then(() => caches.open(CACHE_API).then(c =>
.then(() => {
// Prioritäts-Seiten nicht-blockierend im Hintergrund cachen
caches.open(CACHE_STATIC).then(cache => {
PRIORITY_PAGES.forEach(page =>
fetch(page).then(r => { if (r.ok) cache.put(page, r.clone()); }).catch(() => {})
);
});
// Training-Exercises vorwärmen
return caches.open(CACHE_API).then(c =>
fetch('/api/training/exercises').then(r => { if (r.ok) c.put('/api/training/exercises', r); }).catch(() => {})
))
);
})
);
});
@ -297,7 +331,7 @@ self.addEventListener('fetch', event => {
return;
}
// CSS, Core-JS + Seiten-Module: immer Network-First — damit iOS nie veraltete Versionen cached
// CSS, Core-JS + Seiten-Module: Network-First mit ignoreSearch-Fallback für Offline
if (url.pathname.startsWith('/css/') || url.pathname.startsWith('/js/pages/')
|| url.pathname.startsWith('/js/app.js') || url.pathname.startsWith('/js/ui.js')
|| url.pathname.startsWith('/js/api.js') || url.pathname.startsWith('/js/worlds.js')) {
@ -310,7 +344,7 @@ self.addEventListener('fetch', event => {
}
return response;
})
.catch(() => caches.match(event.request)
.catch(() => caches.match(event.request, { ignoreSearch: true })
.then(cached => cached || new Response('', { status: 503 })))
);
return;

View file

@ -592,6 +592,7 @@
<div class="footer-links">
<a href="/#impressum">Impressum</a>
<a href="/#datenschutz">Datenschutz</a>
<a href="/#agb">AGB</a>
<a href="/info">Über Ban Yaro</a>
<a href="/presse">Presse</a>
</div>

View file

@ -239,7 +239,9 @@ def _wind_dir(deg: float) -> str:
def _asphalt_temp(air_max: float, uv_max: float) -> tuple[float, str]:
bonus = min(uv_max * 3.0, 30.0)
# UV-Bonus skaliert mit Temperatur: unter 10°C kaum Aufheizung, ab 30°C voll
t_factor = max(0.0, min(1.0, (air_max - 5) / 25))
bonus = min(uv_max * 3.0 * t_factor, 30.0)
asphalt = air_max + bonus
if asphalt <= 30:
warn = 'safe'

View file

@ -8,6 +8,7 @@ services:
volumes:
- ./data:/data
- /volume1/docker/banyaro/data/media:/prod-media:ro
- /volume1/scaninput:/scaninput
env_file:
- .env
environment:

View file

@ -7,6 +7,7 @@ services:
- "3010:8000" # DS-intern, NPM leitet banyaro.app weiter
volumes:
- ./data:/data # SQLite + Media persistent
- /volume1/scaninput:/scaninput
env_file:
- .env
environment: