Compare commits
No commits in common. "9677d1e71a5e911526711c89f65f98230b456aa8" and "ebff9d820dbae011689a4eae60ffd3299267608b" have entirely different histories.
9677d1e71a
...
ebff9d820d
41 changed files with 478 additions and 4628 deletions
|
|
@ -2398,65 +2398,6 @@ def _migrate(conn_factory):
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"Migration route_dogs fehlgeschlagen: {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):
|
def _seed_help_articles(conn):
|
||||||
"""Befüllt help_articles mit Starter-FAQs — nur wenn die Tabelle noch leer ist."""
|
"""Befüllt help_articles mit Starter-FAQs — nur wenn die Tabelle noch leer ist."""
|
||||||
|
|
|
||||||
|
|
@ -5,18 +5,12 @@ Unterstützt zwei Backends (wird automatisch gewählt):
|
||||||
2. SMTP — wenn SMTP_HOST gesetzt (Fallback)
|
2. SMTP — wenn SMTP_HOST gesetzt (Fallback)
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import imaplib
|
|
||||||
import os
|
import os
|
||||||
import base64
|
|
||||||
import smtplib
|
import smtplib
|
||||||
import asyncio
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
import ssl
|
|
||||||
from datetime import datetime
|
|
||||||
from email.mime.text import MIMEText
|
from email.mime.text import MIMEText
|
||||||
from email.mime.multipart import MIMEMultipart
|
from email.mime.multipart import MIMEMultipart
|
||||||
from email.mime.application import MIMEApplication
|
|
||||||
from email.utils import formatdate
|
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
|
|
||||||
|
|
@ -30,77 +24,18 @@ BREVO_API_URL = "https://api.brevo.com/v3/smtp/email"
|
||||||
SMTP_HOST = os.getenv("SMTP_HOST", "")
|
SMTP_HOST = os.getenv("SMTP_HOST", "")
|
||||||
SMTP_PORT = int(os.getenv("SMTP_PORT", "587"))
|
SMTP_PORT = int(os.getenv("SMTP_PORT", "587"))
|
||||||
SMTP_USER = os.getenv("SMTP_USER", "")
|
SMTP_USER = os.getenv("SMTP_USER", "")
|
||||||
SMTP_PASS = os.getenv("SMTP_PASS", "") or os.getenv("SMTP_SUPPORT_PASS", "")
|
SMTP_PASS = os.getenv("SMTP_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>")
|
SMTP_FROM = os.getenv("SMTP_FROM", "Ban Yaro <noreply@banyaro.app>")
|
||||||
APP_URL = os.getenv("APP_URL", "https://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
|
# Brevo REST-API
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
async def _send_brevo(to: str, subject: str, html: str, plain: str, attachments: list | None = None):
|
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"
|
||||||
from_raw = SMTP_FROM
|
from_raw = SMTP_FROM
|
||||||
if "<" in from_raw:
|
if "<" in from_raw:
|
||||||
from_name = from_raw[:from_raw.index("<")].strip()
|
from_name = from_raw[:from_raw.index("<")].strip()
|
||||||
|
|
@ -117,14 +52,6 @@ async def _send_brevo(to: str, subject: str, html: str, plain: str, attachments:
|
||||||
"textContent": plain,
|
"textContent": plain,
|
||||||
"headers": {"X-Mailin-Track-Click": "0", "X-Mailin-Track-Opens": "0"},
|
"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:
|
async with httpx.AsyncClient(timeout=15) as client:
|
||||||
resp = await client.post(
|
resp = await client.post(
|
||||||
BREVO_API_URL,
|
BREVO_API_URL,
|
||||||
|
|
@ -137,50 +64,30 @@ async def _send_brevo(to: str, subject: str, html: str, plain: str, attachments:
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
# SMTP Fallback
|
# SMTP Fallback
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
def _send_smtp_sync(to: str, subject: str, html: str, plain: str, attachments: list | None = None):
|
def _send_smtp_sync(to: str, subject: str, html: str, plain: str):
|
||||||
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 = MIMEMultipart("alternative")
|
||||||
msg.attach(MIMEText(plain, "plain", "utf-8"))
|
|
||||||
msg.attach(MIMEText(html, "html", "utf-8"))
|
|
||||||
|
|
||||||
msg["Subject"] = subject
|
msg["Subject"] = subject
|
||||||
msg["From"] = SMTP_FROM
|
msg["From"] = SMTP_FROM
|
||||||
msg["To"] = to
|
msg["To"] = to
|
||||||
msg["Date"] = formatdate(localtime=False)
|
msg.attach(MIMEText(plain, "plain", "utf-8"))
|
||||||
|
msg.attach(MIMEText(html, "html", "utf-8"))
|
||||||
|
|
||||||
msg_bytes = msg.as_bytes()
|
|
||||||
with smtplib.SMTP(SMTP_HOST, SMTP_PORT, timeout=10) as s:
|
with smtplib.SMTP(SMTP_HOST, SMTP_PORT, timeout=10) as s:
|
||||||
s.ehlo()
|
s.ehlo()
|
||||||
s.starttls()
|
s.starttls()
|
||||||
if SMTP_USER:
|
if SMTP_USER:
|
||||||
s.login(SMTP_USER, SMTP_PASS)
|
s.login(SMTP_USER, SMTP_PASS)
|
||||||
s.sendmail(SMTP_FROM, [to], msg_bytes)
|
s.sendmail(SMTP_FROM, [to], msg.as_string())
|
||||||
_imap_save_sent(msg_bytes)
|
|
||||||
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
# Öffentliche Funktion
|
# Öffentliche Funktion
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
async def send_email(to: str, subject: str, html: str, plain: str = "", attachments: list | None = None):
|
async def send_email(to: str, subject: str, html: str, plain: str = ""):
|
||||||
if BREVO_API_KEY:
|
if BREVO_API_KEY:
|
||||||
try:
|
try:
|
||||||
await _send_brevo(to, subject, html, plain, attachments)
|
await _send_brevo(to, subject, html, plain)
|
||||||
logger.info(f"Mail via Brevo gesendet: «{subject}» → {to}")
|
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
|
return
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Brevo-Fehler: {e}")
|
logger.error(f"Brevo-Fehler: {e}")
|
||||||
|
|
@ -189,9 +96,7 @@ async def send_email(to: str, subject: str, html: str, plain: str = "", attachme
|
||||||
if SMTP_HOST:
|
if SMTP_HOST:
|
||||||
loop = asyncio.get_event_loop()
|
loop = asyncio.get_event_loop()
|
||||||
try:
|
try:
|
||||||
await loop.run_in_executor(
|
await loop.run_in_executor(None, _send_smtp_sync, to, subject, html, plain)
|
||||||
None, _send_smtp_sync, to, subject, html, plain, attachments
|
|
||||||
)
|
|
||||||
logger.info(f"Mail via SMTP gesendet: «{subject}» → {to}")
|
logger.info(f"Mail via SMTP gesendet: «{subject}» → {to}")
|
||||||
return
|
return
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|
|
||||||
|
|
@ -253,8 +253,6 @@ from routes.challenges import router as challenges_router
|
||||||
from routes.gassi_zeiten import router as gassi_zeiten_router
|
from routes.gassi_zeiten import router as gassi_zeiten_router
|
||||||
from routes.help import router as help_router
|
from routes.help import router as help_router
|
||||||
from routes.feedback import router as feedback_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(auth_router, prefix="/api/auth", tags=["Auth"])
|
||||||
app.include_router(dogs_router, prefix="/api/dogs", tags=["Hunde"])
|
app.include_router(dogs_router, prefix="/api/dogs", tags=["Hunde"])
|
||||||
|
|
@ -319,8 +317,6 @@ 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(gassi_zeiten_router, prefix="/api/gassi-zeiten", tags=["Gassi-Zeiten"])
|
||||||
app.include_router(help_router, prefix="/api/help", tags=["Hilfe/FAQ"])
|
app.include_router(help_router, prefix="/api/help", tags=["Hilfe/FAQ"])
|
||||||
app.include_router(feedback_router, prefix="/api/feedback", tags=["Feedback"])
|
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)
|
|
||||||
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
|
|
@ -410,7 +406,7 @@ async def serve_media(path: str, request: _Request):
|
||||||
raise _HE(404, "Nicht gefunden.")
|
raise _HE(404, "Nicht gefunden.")
|
||||||
return _media_response(filepath)
|
return _media_response(filepath)
|
||||||
|
|
||||||
APP_VER = "1070" # muss mit APP_VER in app.js übereinstimmen
|
APP_VER = "961" # muss mit APP_VER in app.js übereinstimmen
|
||||||
|
|
||||||
@app.get("/.well-known/assetlinks.json")
|
@app.get("/.well-known/assetlinks.json")
|
||||||
async def assetlinks():
|
async def assetlinks():
|
||||||
|
|
@ -1724,8 +1720,8 @@ async def force_update():
|
||||||
height:100vh;margin:0;background:#0f1623;color:#fff;flex-direction:column;gap:16px}
|
height:100vh;margin:0;background:#0f1623;color:#fff;flex-direction:column;gap:16px}
|
||||||
p{color:#94a3b8;font-size:14px}</style></head>
|
p{color:#94a3b8;font-size:14px}</style></head>
|
||||||
<body>
|
<body>
|
||||||
<div>⏳ Einen Moment…</div>
|
<div>⏳ Aktualisiere Ban Yaro…</div>
|
||||||
<p id="s">Wir besorgen neue Leckerlis 🦴</p>
|
<p id="s">Service Worker wird entfernt…</p>
|
||||||
<script>
|
<script>
|
||||||
// Zweiten Reload durch SW-updatefound verhindern
|
// Zweiten Reload durch SW-updatefound verhindern
|
||||||
sessionStorage.setItem('by_skip_sw_reload','1');
|
sessionStorage.setItem('by_skip_sw_reload','1');
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,5 @@
|
||||||
"""BAN YARO — Admin / Moderator Backend"""
|
"""BAN YARO — Admin / Moderator Backend"""
|
||||||
import asyncio
|
import asyncio
|
||||||
import csv
|
|
||||||
import io
|
|
||||||
import logging
|
|
||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
import time
|
import time
|
||||||
|
|
@ -11,14 +8,11 @@ from datetime import datetime
|
||||||
from zoneinfo import ZoneInfo
|
from zoneinfo import ZoneInfo
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
from fastapi.responses import Response
|
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
from typing import Optional, List
|
from typing import Optional
|
||||||
from database import db, DB_PATH
|
from database import db, DB_PATH
|
||||||
from auth import get_current_user
|
from auth import get_current_user
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
_TZ = ZoneInfo("Europe/Berlin")
|
_TZ = ZoneInfo("Europe/Berlin")
|
||||||
|
|
@ -89,11 +83,6 @@ def require_admin(user=Depends(get_current_user)):
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
_VALID_TIERS = {"standard", "pro", "breeder", "standard_test", "pro_test", "breeder_test"}
|
_VALID_TIERS = {"standard", "pro", "breeder", "standard_test", "pro_test", "breeder_test"}
|
||||||
|
|
||||||
class QuarterlyReportBody(BaseModel):
|
|
||||||
year: int
|
|
||||||
quarter: int
|
|
||||||
email: str
|
|
||||||
|
|
||||||
class UserPatch(BaseModel):
|
class UserPatch(BaseModel):
|
||||||
rolle: Optional[str] = None # user | moderator | admin
|
rolle: Optional[str] = None # user | moderator | admin
|
||||||
is_moderator: Optional[int] = None
|
is_moderator: Optional[int] = None
|
||||||
|
|
@ -141,12 +130,6 @@ async def action_items(user=Depends(require_mod)):
|
||||||
).fetchone()[0]
|
).fetchone()[0]
|
||||||
except Exception:
|
except Exception:
|
||||||
upgrades_pending = 0
|
upgrades_pending = 0
|
||||||
try:
|
|
||||||
invoices_unpaid = conn.execute(
|
|
||||||
"SELECT COUNT(*) FROM invoices WHERE status='sent'"
|
|
||||||
).fetchone()[0]
|
|
||||||
except Exception:
|
|
||||||
invoices_unpaid = 0
|
|
||||||
return {
|
return {
|
||||||
"jobs_pending": jobs,
|
"jobs_pending": jobs,
|
||||||
"breeder_pending": breeders,
|
"breeder_pending": breeders,
|
||||||
|
|
@ -155,7 +138,6 @@ async def action_items(user=Depends(require_mod)):
|
||||||
"poi_edits_pending": poi_edits,
|
"poi_edits_pending": poi_edits,
|
||||||
"users_today": users_today,
|
"users_today": users_today,
|
||||||
"upgrades_pending": upgrades_pending,
|
"upgrades_pending": upgrades_pending,
|
||||||
"invoices_unpaid": invoices_unpaid,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -1137,35 +1119,20 @@ async def list_upgrade_requests(user=Depends(require_admin)):
|
||||||
with db() as conn:
|
with db() as conn:
|
||||||
rows = conn.execute("""
|
rows = conn.execute("""
|
||||||
SELECT r.id, r.user_id, r.tier, r.message, r.created_at, r.fulfilled_at,
|
SELECT r.id, r.user_id, r.tier, r.message, r.created_at, r.fulfilled_at,
|
||||||
u.name, u.email, u.billing_address,
|
u.name, u.email
|
||||||
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
|
FROM upgrade_requests r
|
||||||
JOIN users u ON u.id = r.user_id
|
JOIN users u ON u.id = r.user_id
|
||||||
ORDER BY r.fulfilled_at IS NOT NULL, r.created_at DESC
|
ORDER BY r.fulfilled_at IS NOT NULL, r.created_at DESC
|
||||||
LIMIT 100
|
LIMIT 100
|
||||||
""").fetchall()
|
""").fetchall()
|
||||||
result = []
|
return [dict(r) for r in rows]
|
||||||
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")
|
@router.post("/upgrade-requests/{req_id}/fulfill")
|
||||||
async def fulfill_upgrade_request(req_id: int, user=Depends(require_admin)):
|
async def fulfill_upgrade_request(req_id: int, user=Depends(require_admin)):
|
||||||
with db() as conn:
|
with db() as conn:
|
||||||
req = conn.execute(
|
req = conn.execute(
|
||||||
"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=?",
|
"SELECT r.*, u.name, u.email FROM upgrade_requests r JOIN users u ON u.id=r.user_id WHERE r.id=?",
|
||||||
(req_id,)
|
(req_id,)
|
||||||
).fetchone()
|
).fetchone()
|
||||||
if not req:
|
if not req:
|
||||||
|
|
@ -1274,296 +1241,4 @@ async def fulfill_upgrade_request(req_id: int, user=Depends(require_admin)):
|
||||||
import logging
|
import logging
|
||||||
logging.getLogger(__name__).warning(f"Bestätigungsmail fehlgeschlagen: {e}")
|
logging.getLogger(__name__).warning(f"Bestätigungsmail fehlgeschlagen: {e}")
|
||||||
|
|
||||||
# Offene Rechnungen (sent/draft) des alten Tiers stornieren + neuen Entwurf anlegen
|
return {"ok": True, "tier": req["tier"], "user": req["name"]}
|
||||||
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 1–4 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 1–4 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),
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -241,8 +241,7 @@ async def me(user=Depends(get_current_user)):
|
||||||
is_founder, is_partner, founder_number, is_founder_pending,
|
is_founder, is_partner, founder_number, is_founder_pending,
|
||||||
notes_ki_enabled, gassi_stunde_push,
|
notes_ki_enabled, gassi_stunde_push,
|
||||||
preferred_theme, subscription_tier,
|
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=?""",
|
FROM users WHERE id=?""",
|
||||||
(user["id"],)
|
(user["id"],)
|
||||||
).fetchone()
|
).fetchone()
|
||||||
|
|
|
||||||
|
|
@ -1,52 +0,0 @@
|
||||||
"""
|
|
||||||
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}
|
|
||||||
|
|
@ -1,819 +0,0 @@
|
||||||
"""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 1–4 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
|
|
||||||
|
|
@ -2,7 +2,6 @@
|
||||||
|
|
||||||
import io
|
import io
|
||||||
import os
|
import os
|
||||||
import re
|
|
||||||
import uuid
|
import uuid
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
|
|
@ -29,8 +28,6 @@ class ProfileUpdate(BaseModel):
|
||||||
notes_ki_enabled: Optional[int] = None
|
notes_ki_enabled: Optional[int] = None
|
||||||
gassi_stunde_push: Optional[int] = None
|
gassi_stunde_push: Optional[int] = None
|
||||||
preferred_theme: Optional[str] = None
|
preferred_theme: Optional[str] = None
|
||||||
billing_address: Optional[str] = None
|
|
||||||
geburtstag: Optional[str] = None
|
|
||||||
|
|
||||||
|
|
||||||
def _load_user(user_id: int) -> dict:
|
def _load_user(user_id: int) -> dict:
|
||||||
|
|
@ -38,8 +35,7 @@ def _load_user(user_id: int) -> dict:
|
||||||
row = conn.execute(
|
row = conn.execute(
|
||||||
"""SELECT id, name, real_name, email, rolle, is_premium, email_verified,
|
"""SELECT id, name, real_name, email, rolle, is_premium, email_verified,
|
||||||
bio, wohnort, erfahrung, social_link,
|
bio, wohnort, erfahrung, social_link,
|
||||||
profil_sichtbarkeit, avatar_url, created_at, billing_address,
|
profil_sichtbarkeit, avatar_url, created_at
|
||||||
geburtstag
|
|
||||||
FROM users WHERE id=?""",
|
FROM users WHERE id=?""",
|
||||||
(user_id,)
|
(user_id,)
|
||||||
).fetchone()
|
).fetchone()
|
||||||
|
|
@ -67,9 +63,6 @@ async def update_profile(data: ProfileUpdate, user=Depends(get_current_user)):
|
||||||
raise HTTPException(400, "wohnort darf maximal 60 Zeichen lang sein.")
|
raise HTTPException(400, "wohnort darf maximal 60 Zeichen lang sein.")
|
||||||
if "social_link" in fields and len(fields["social_link"]) > 120:
|
if "social_link" in fields and len(fields["social_link"]) > 120:
|
||||||
raise HTTPException(400, "social_link darf maximal 120 Zeichen lang sein.")
|
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:
|
if not fields:
|
||||||
return _load_user(user["id"])
|
return _load_user(user["id"])
|
||||||
|
|
|
||||||
|
|
@ -33,21 +33,16 @@ async def public_stats():
|
||||||
users = conn.execute("SELECT COUNT(*) FROM users").fetchone()[0]
|
users = conn.execute("SELECT COUNT(*) FROM users").fetchone()[0]
|
||||||
dogs = conn.execute("SELECT COUNT(*) FROM dogs").fetchone()[0]
|
dogs = conn.execute("SELECT COUNT(*) FROM dogs").fetchone()[0]
|
||||||
km = conn.execute(
|
km = conn.execute(
|
||||||
# Alle Routen (öffentlich + privat), nur valide Aufzeichnungen
|
"SELECT ROUND(COALESCE(SUM(distanz_km),0),0) FROM routes"
|
||||||
"SELECT ROUND(COALESCE(SUM(distanz_km),0),0) FROM routes WHERE is_valid=1"
|
|
||||||
).fetchone()[0]
|
).fetchone()[0]
|
||||||
posts = conn.execute("SELECT COUNT(*) FROM forum_posts").fetchone()[0]
|
posts = conn.execute("SELECT COUNT(*) FROM forum_posts").fetchone()[0]
|
||||||
diary = conn.execute("SELECT COUNT(*) FROM diary").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 = {
|
data = {
|
||||||
"users": users,
|
"users": users,
|
||||||
"dogs": dogs,
|
"dogs": dogs,
|
||||||
"km": int(km or 0),
|
"km": int(km or 0),
|
||||||
"forum_posts": posts,
|
"forum_posts": posts,
|
||||||
"diary_entries": diary,
|
"diary_entries": diary,
|
||||||
"kotbeutel": kotbeutel,
|
|
||||||
}
|
}
|
||||||
_pub_cache["data"] = data
|
_pub_cache["data"] = data
|
||||||
_pub_cache["ts"] = now
|
_pub_cache["ts"] = now
|
||||||
|
|
|
||||||
|
|
@ -323,10 +323,10 @@ class SessionCreate(BaseModel):
|
||||||
datum: Optional[str] = None
|
datum: Optional[str] = None
|
||||||
wiederholungen: int = 1
|
wiederholungen: int = 1
|
||||||
erfolgsquote: int = 50
|
erfolgsquote: int = 50
|
||||||
hund_stimmung: Optional[str] = "aufmerksam"
|
hund_stimmung: str = "aufmerksam"
|
||||||
zufriedenheit: Optional[int] = 3
|
zufriedenheit: int = 3
|
||||||
notiz: Optional[str] = None
|
notiz: Optional[str] = None
|
||||||
tagebuch_eintrag: bool = False # ignoriert — Training hat eigenes Protokoll
|
tagebuch_eintrag: bool = False
|
||||||
|
|
||||||
|
|
||||||
@router.post("/sessions")
|
@router.post("/sessions")
|
||||||
|
|
@ -363,6 +363,42 @@ async def log_session(body: SessionCreate, user=Depends(get_current_user)):
|
||||||
# Badges prüfen
|
# Badges prüfen
|
||||||
new_badges = _check_badges(conn, uid, dog_name)
|
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 = {
|
session = {
|
||||||
"id": session_id,
|
"id": session_id,
|
||||||
"user_id": uid,
|
"user_id": uid,
|
||||||
|
|
@ -376,12 +412,14 @@ async def log_session(body: SessionCreate, user=Depends(get_current_user)):
|
||||||
"zufriedenheit": body.zufriedenheit,
|
"zufriedenheit": body.zufriedenheit,
|
||||||
"notiz": body.notiz,
|
"notiz": body.notiz,
|
||||||
"ist_top": bool(ist_top),
|
"ist_top": bool(ist_top),
|
||||||
|
"diary_entry_id": diary_entry_id,
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"session": session,
|
"session": session,
|
||||||
"ist_top": bool(ist_top),
|
"ist_top": bool(ist_top),
|
||||||
"badges": new_badges,
|
"badges": new_badges,
|
||||||
|
"diary_entry_id": diary_entry_id,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -195,13 +195,6 @@ def start():
|
||||||
replace_existing=True,
|
replace_existing=True,
|
||||||
misfire_grace_time=3600,
|
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()
|
_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).")
|
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).")
|
||||||
|
|
||||||
|
|
@ -214,278 +207,6 @@ def stop():
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
# JOB: Abo-Ablauf prüfen (täglich 03:00)
|
# 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 (§ 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äß § 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():
|
async def _job_subscription_check():
|
||||||
"""Abgelaufene Abos auf Standard setzen; Warnmails 30 und 7 Tage vorher."""
|
"""Abgelaufene Abos auf Standard setzen; Warnmails 30 und 7 Tage vorher."""
|
||||||
from database import db as _db
|
from database import db as _db
|
||||||
|
|
@ -496,8 +217,7 @@ async def _job_subscription_check():
|
||||||
|
|
||||||
with _db() as conn:
|
with _db() as conn:
|
||||||
users = conn.execute(
|
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
|
FROM users
|
||||||
WHERE subscription_tier IN ('pro','breeder')
|
WHERE subscription_tier IN ('pro','breeder')
|
||||||
AND subscription_expires_at IS NOT NULL"""
|
AND subscription_expires_at IS NOT NULL"""
|
||||||
|
|
@ -533,7 +253,7 @@ async def _job_subscription_check():
|
||||||
await send_email(u["email"], f"Dein {tier_label}-Abo ist abgelaufen", html,
|
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.")
|
f"Hallo {u['name']},\ndein {tier_label}-Abo ist abgelaufen. Daten bleiben erhalten.")
|
||||||
|
|
||||||
# 30 Tage Warnung + Erneuerungsrechnung als Entwurf anlegen
|
# 30 Tage Warnung
|
||||||
elif days_left == 30:
|
elif days_left == 30:
|
||||||
body = f"""
|
body = f"""
|
||||||
<p>Hallo {_html.escape(u['name'])},</p>
|
<p>Hallo {_html.escape(u['name'])},</p>
|
||||||
|
|
@ -545,10 +265,7 @@ async def _job_subscription_check():
|
||||||
await send_email(u["email"], f"Dein {tier_label}-Abo läuft in 30 Tagen ab", html,
|
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}).")
|
f"Hallo {u['name']},\ndein {tier_label}-Abo läuft in 30 Tagen ab ({expires}).")
|
||||||
|
|
||||||
# Erneuerungsrechnung als Entwurf anlegen (nur wenn noch keine existiert)
|
# 7 Tage Warnung
|
||||||
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:
|
elif days_left == 7:
|
||||||
body = f"""
|
body = f"""
|
||||||
<p>Hallo {_html.escape(u['name'])},</p>
|
<p>Hallo {_html.escape(u['name'])},</p>
|
||||||
|
|
@ -558,7 +275,6 @@ async def _job_subscription_check():
|
||||||
html = email_html(body, cta_url="https://banyaro.app", cta_label="Abo verlängern")
|
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,
|
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.")
|
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:
|
except Exception as e:
|
||||||
logger.warning(f"subscription_check Fehler für {u['email']}: {e}")
|
logger.warning(f"subscription_check Fehler für {u['email']}: {e}")
|
||||||
|
|
|
||||||
|
|
@ -3087,8 +3087,8 @@ html.modal-open {
|
||||||
}
|
}
|
||||||
@media (min-width: 768px) {
|
@media (min-width: 768px) {
|
||||||
.map-full-layout { top: 0; left: var(--nav-sidebar-width); bottom: 0; }
|
.map-full-layout { top: 0; left: var(--nav-sidebar-width); bottom: 0; }
|
||||||
/* Zoom-Control: mittig zu beiden Filter-Reihen */
|
/* Zoom-Control und Filter-Tabs unter die Statusleiste schieben */
|
||||||
.map-full-layout .leaflet-top { padding-top: 30px; }
|
.map-full-layout .leaflet-top { padding-top: 28px; }
|
||||||
}
|
}
|
||||||
.map-full { width: 100%; height: 100%; }
|
.map-full { width: 100%; height: 100%; }
|
||||||
|
|
||||||
|
|
@ -3137,7 +3137,8 @@ html.modal-open {
|
||||||
.map-legend-label { font-size: 10px; }
|
.map-legend-label { font-size: 10px; }
|
||||||
.map-legend-all {
|
.map-legend-all {
|
||||||
font-size: 1rem;
|
font-size: 1rem;
|
||||||
padding: 5px 10px;
|
min-width: 32px;
|
||||||
|
padding: 0 var(--space-2);
|
||||||
background: var(--c-surface-2);
|
background: var(--c-surface-2);
|
||||||
border-color: var(--c-border);
|
border-color: var(--c-border);
|
||||||
color: var(--c-text-secondary);
|
color: var(--c-text-secondary);
|
||||||
|
|
@ -3149,73 +3150,6 @@ html.modal-open {
|
||||||
color: #fff;
|
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 */
|
/* FAB-Gruppe rechts unten — direkt über dem Zurück-Button */
|
||||||
.map-fabs {
|
.map-fabs {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
|
|
@ -5227,9 +5161,9 @@ html.modal-open {
|
||||||
|
|
||||||
/* "Stirbt der Hund?" Tags */
|
/* "Stirbt der Hund?" Tags */
|
||||||
.movie-tag-stirbt {
|
.movie-tag-stirbt {
|
||||||
background: var(--c-danger-subtle);
|
background: #fef2f2;
|
||||||
color: var(--c-danger);
|
color: #dc2626;
|
||||||
border: 1.5px solid var(--c-danger-border);
|
border: 1.5px solid #dc2626;
|
||||||
border-radius: var(--radius-md);
|
border-radius: var(--radius-md);
|
||||||
padding: var(--space-2) var(--space-3);
|
padding: var(--space-2) var(--space-3);
|
||||||
font-size: var(--text-xs);
|
font-size: var(--text-xs);
|
||||||
|
|
@ -5239,9 +5173,9 @@ html.modal-open {
|
||||||
}
|
}
|
||||||
|
|
||||||
.movie-tag-ueberlebt {
|
.movie-tag-ueberlebt {
|
||||||
background: var(--c-success-subtle);
|
background: #f0fdf4;
|
||||||
color: var(--c-success);
|
color: #16a34a;
|
||||||
border: 1.5px solid color-mix(in srgb, var(--c-success) 30%, transparent);
|
border: 1.5px solid #16a34a;
|
||||||
border-radius: var(--radius-md);
|
border-radius: var(--radius-md);
|
||||||
padding: var(--space-2) var(--space-3);
|
padding: var(--space-2) var(--space-3);
|
||||||
font-size: var(--text-xs);
|
font-size: var(--text-xs);
|
||||||
|
|
@ -7942,84 +7876,77 @@ svg.empty-state-icon {
|
||||||
}
|
}
|
||||||
#wp-welt { overflow: hidden; position: relative; }
|
#wp-welt { overflow: hidden; position: relative; }
|
||||||
|
|
||||||
/* Navigation-Punkte — auf Mobile ausgeblendet, Labels übernehmen */
|
/* Navigation-Punkte */
|
||||||
#world-dots {
|
#world-dots {
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Welt-Labels — jetzt unten als Tab-Bar */
|
|
||||||
#world-labels {
|
|
||||||
position: fixed;
|
position: fixed;
|
||||||
bottom: calc(env(safe-area-inset-bottom, 0px) + 33px);
|
top: calc(env(safe-area-inset-top, 0px) + 14px);
|
||||||
top: auto;
|
|
||||||
left: 0; right: 0;
|
left: 0; right: 0;
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
gap: 8px;
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Welt-Labels */
|
||||||
|
#world-labels {
|
||||||
|
position: fixed;
|
||||||
|
top: calc(env(safe-area-inset-top, 0px) + 28px);
|
||||||
|
left: 0; right: 0;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 28px;
|
||||||
z-index: 59;
|
z-index: 59;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
.wlabel {
|
.wlabel {
|
||||||
font-size: 10px;
|
font-size: 9px;
|
||||||
font-weight: 800;
|
font-weight: 800;
|
||||||
letter-spacing: 0.1em;
|
letter-spacing: 0.12em;
|
||||||
color: white;
|
color: white;
|
||||||
opacity: 0.45;
|
opacity: 0.4;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
transition: opacity 0.18s, background 0.18s;
|
transition: opacity 0.18s;
|
||||||
padding: 6px 16px;
|
|
||||||
border-radius: 20px;
|
|
||||||
}
|
|
||||||
.wlabel.active {
|
|
||||||
opacity: 1;
|
|
||||||
background: rgba(255, 255, 255, 0.18);
|
|
||||||
backdrop-filter: blur(8px);
|
|
||||||
-webkit-backdrop-filter: blur(8px);
|
|
||||||
}
|
}
|
||||||
|
.wlabel.active { opacity: 1; }
|
||||||
|
|
||||||
@media (min-width: 768px) {
|
@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 {
|
#world-labels {
|
||||||
gap: 24px;
|
gap: 40px;
|
||||||
bottom: calc(env(safe-area-inset-bottom, 0px) + 33px);
|
top: calc(env(safe-area-inset-top, 0px) + 18px);
|
||||||
}
|
}
|
||||||
.wlabel {
|
.wlabel {
|
||||||
font-size: 12px;
|
font-size: 13px;
|
||||||
letter-spacing: 0.14em;
|
letter-spacing: 0.18em;
|
||||||
padding: 7px 18px;
|
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 {
|
.wlabel:hover {
|
||||||
opacity: 0.85;
|
opacity: 0.85;
|
||||||
background: rgba(255, 255, 255, 0.12);
|
background: rgba(255, 255, 255, 0.12);
|
||||||
}
|
}
|
||||||
|
.wlabel.active {
|
||||||
|
opacity: 1;
|
||||||
|
background: rgba(255, 255, 255, 0.18);
|
||||||
|
text-shadow: 0 1px 8px rgba(0,0,0,0.5);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Settings-Button */
|
/* Settings-Button */
|
||||||
|
|
@ -8114,13 +8041,12 @@ svg.empty-state-icon {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
justify-content: space-between; /* Info oben, Chips unten */
|
justify-content: space-between; /* Info oben, Chips unten */
|
||||||
padding: calc(env(safe-area-inset-top, 0px) + 14px) 14px
|
padding: calc(env(safe-area-inset-top, 0px) + 58px) 14px
|
||||||
calc(env(safe-area-inset-bottom, 0px) + 76px);
|
calc(env(safe-area-inset-bottom, 0px) + 88px);
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
overflow-x: hidden;
|
overflow-x: hidden;
|
||||||
-webkit-overflow-scrolling: touch;
|
-webkit-overflow-scrolling: touch;
|
||||||
overscroll-behavior-y: contain;
|
overscroll-behavior-y: contain;
|
||||||
position: relative; /* Anker für absolut positionierte Footer-Links */
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Content-Divs füllen den Panel und verteilen Top/Bottom */
|
/* Content-Divs füllen den Panel und verteilen Top/Bottom */
|
||||||
|
|
@ -8135,8 +8061,8 @@ svg.empty-state-icon {
|
||||||
/* Oberer Bereich: Info + Reminders */
|
/* Oberer Bereich: Info + Reminders */
|
||||||
.world-top { display: flex; flex-direction: column; gap: 10px; }
|
.world-top { display: flex; flex-direction: column; gap: 10px; }
|
||||||
|
|
||||||
/* Unterer Bereich: Chips (Daumen-Zone) — kompakt, ganz unten */
|
/* Unterer Bereich: Chips (Daumen-Zone) */
|
||||||
.world-bottom { display: flex; flex-direction: column; gap: 5px; }
|
.world-bottom { display: flex; flex-direction: column; gap: 8px; }
|
||||||
|
|
||||||
/* Frosted-Glass Info-Card (oben in jeder Welt) */
|
/* Frosted-Glass Info-Card (oben in jeder Welt) */
|
||||||
.world-info-card {
|
.world-info-card {
|
||||||
|
|
@ -8181,8 +8107,9 @@ svg.empty-state-icon {
|
||||||
.world-chips-grid {
|
.world-chips-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(4, 1fr);
|
grid-template-columns: repeat(4, 1fr);
|
||||||
grid-auto-rows: 74px;
|
grid-auto-rows: 80px; /* alle Chips gleich hoch */
|
||||||
gap: 7px;
|
gap: 8px;
|
||||||
|
margin-top: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Einzelner Chip: Frosted Glass */
|
/* Einzelner Chip: Frosted Glass */
|
||||||
|
|
@ -8203,8 +8130,7 @@ svg.empty-state-icon {
|
||||||
transition: background 0.12s, transform 0.1s;
|
transition: background 0.12s, transform 0.1s;
|
||||||
-webkit-tap-highlight-color: transparent;
|
-webkit-tap-highlight-color: transparent;
|
||||||
user-select: none;
|
user-select: none;
|
||||||
height: 74px;
|
min-height: 80px; /* alle Chips gleich hoch */
|
||||||
overflow: hidden;
|
|
||||||
}
|
}
|
||||||
.world-chip:active {
|
.world-chip:active {
|
||||||
background: rgba(0, 0, 0, 0.6);
|
background: rgba(0, 0, 0, 0.6);
|
||||||
|
|
@ -8216,7 +8142,7 @@ svg.empty-state-icon {
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: rgba(255, 255, 255, 0.9);
|
color: rgba(255, 255, 255, 0.9);
|
||||||
line-height: 1.2;
|
line-height: 1.2;
|
||||||
max-height: 24px; /* 2 Zeilen bei 10px — px statt em, damit iOS-Schriftgröße nicht skaliert */
|
max-height: 2.4em; /* max. 2 Zeilen */
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
display: -webkit-box;
|
display: -webkit-box;
|
||||||
-webkit-line-clamp: 2;
|
-webkit-line-clamp: 2;
|
||||||
|
|
@ -8282,14 +8208,11 @@ svg.empty-state-icon {
|
||||||
|
|
||||||
/* Footer-Links (Impressum / Die 100 / Datenschutz) */
|
/* Footer-Links (Impressum / Die 100 / Datenschutz) */
|
||||||
.world-footer-links {
|
.world-footer-links {
|
||||||
position: absolute;
|
|
||||||
bottom: calc(env(safe-area-inset-bottom, 0px) + 4px);
|
|
||||||
left: 0; right: 0;
|
|
||||||
text-align: center;
|
text-align: center;
|
||||||
padding: 0;
|
padding: 10px 0 2px;
|
||||||
}
|
}
|
||||||
.world-footer-links span {
|
.world-footer-links span {
|
||||||
font-size: 10px;
|
font-size: 11px;
|
||||||
color: rgba(255, 255, 255, 0.6);
|
color: rgba(255, 255, 255, 0.6);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
letter-spacing: 0.05em;
|
letter-spacing: 0.05em;
|
||||||
|
|
@ -8723,34 +8646,6 @@ svg.empty-state-icon {
|
||||||
font-weight: 600;
|
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
|
Settings / Dog-Profile: Card-Sektion-Header
|
||||||
(uppercase Label mit Border-Bottom)
|
(uppercase Label mit Border-Bottom)
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,6 @@
|
||||||
|
|
||||||
/* Oberflächen — Warmweiß und Strohbeige */
|
/* Oberflächen — Warmweiß und Strohbeige */
|
||||||
--c-bg: #FAF7F2;
|
--c-bg: #FAF7F2;
|
||||||
--c-bg-secondary: #F2EDE4; /* Karten auf Seitenhintergrund */
|
|
||||||
--c-surface: #FFFFFF;
|
--c-surface: #FFFFFF;
|
||||||
--c-surface-2: #EDE5D4;
|
--c-surface-2: #EDE5D4;
|
||||||
--c-surface-3: #DDD0BB;
|
--c-surface-3: #DDD0BB;
|
||||||
|
|
@ -123,7 +122,6 @@
|
||||||
@media (prefers-color-scheme: dark) {
|
@media (prefers-color-scheme: dark) {
|
||||||
:root:not([data-theme="light"]):not([data-theme="dark"]) {
|
:root:not([data-theme="light"]):not([data-theme="dark"]) {
|
||||||
--c-bg: #1A1410;
|
--c-bg: #1A1410;
|
||||||
--c-bg-secondary: #221A12; /* Karten auf Seitenhintergrund (dunkler) */
|
|
||||||
--c-surface: #241C14;
|
--c-surface: #241C14;
|
||||||
--c-surface-2: #2E2418;
|
--c-surface-2: #2E2418;
|
||||||
--c-surface-3: #3A2E20;
|
--c-surface-3: #3A2E20;
|
||||||
|
|
@ -160,7 +158,6 @@
|
||||||
/* Manuelles Dark-Theme via data-theme="dark" (überschreibt auch prefers-color-scheme: light) */
|
/* Manuelles Dark-Theme via data-theme="dark" (überschreibt auch prefers-color-scheme: light) */
|
||||||
:root[data-theme="dark"] {
|
:root[data-theme="dark"] {
|
||||||
--c-bg: #1A1410;
|
--c-bg: #1A1410;
|
||||||
--c-bg-secondary: #221A12;
|
|
||||||
--c-surface: #241C14;
|
--c-surface: #241C14;
|
||||||
--c-surface-2: #2E2418;
|
--c-surface-2: #2E2418;
|
||||||
--c-surface-3: #3A2E20;
|
--c-surface-3: #3A2E20;
|
||||||
|
|
@ -189,16 +186,6 @@
|
||||||
--shadow-xl: 0 16px 40px rgba(0, 0, 0, 0.50), 0 8px 16px rgba(0, 0, 0, 0.35);
|
--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
|
2. RESET & BASE
|
||||||
------------------------------------------------------------ */
|
------------------------------------------------------------ */
|
||||||
|
|
|
||||||
|
|
@ -535,20 +535,6 @@
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Desktop: Standard-Container auf 860px erweitern (768px–1023px) */
|
|
||||||
@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 */
|
/* Wide-Layout für Karte und ähnliches */
|
||||||
.page-container-wide {
|
.page-container-wide {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
|
@ -603,39 +589,8 @@
|
||||||
============================================================ */
|
============================================================ */
|
||||||
@media (min-width: 1024px) {
|
@media (min-width: 1024px) {
|
||||||
|
|
||||||
/* Admin: breit + Sidebar-Layout */
|
/* Etwas breiterer Standard-Container auf großen Screens */
|
||||||
#page-admin .page-container { max-width: 1200px; }
|
.page-container { max-width: 860px; }
|
||||||
|
|
||||||
#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
|
WELCOME: 2-spaltige Feature-Sections, zentrierter Hero
|
||||||
|
|
@ -733,7 +688,7 @@
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Admin: Tabs als 2-zeiliges Grid (Mobile/Tablet) */
|
/* Admin: Tabs auf 2 Zeilen */
|
||||||
#page-admin .adm-tabs {
|
#page-admin .adm-tabs {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(var(--adm-tab-cols, 4), minmax(0, 1fr));
|
grid-template-columns: repeat(var(--adm-tab-cols, 4), minmax(0, 1fr));
|
||||||
|
|
|
||||||
|
|
@ -101,9 +101,9 @@
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<!-- CSS: Reihenfolge ist wichtig — ?v= zwingt Browser zur Neuladung -->
|
<!-- CSS: Reihenfolge ist wichtig — ?v= zwingt Browser zur Neuladung -->
|
||||||
<link rel="stylesheet" href="/css/design-system.css?v=1070">
|
<link rel="stylesheet" href="/css/design-system.css?v=907">
|
||||||
<link rel="stylesheet" href="/css/layout.css?v=1070">
|
<link rel="stylesheet" href="/css/layout.css?v=907">
|
||||||
<link rel="stylesheet" href="/css/components.css?v=1070">
|
<link rel="stylesheet" href="/css/components.css?v=907">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|
||||||
|
|
@ -296,7 +296,6 @@
|
||||||
<div style="display:flex;gap:var(--space-3);justify-content:center">
|
<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="impressum" style="cursor:pointer;text-decoration:underline">Impressum</span>
|
||||||
<span data-page="datenschutz" style="cursor:pointer;text-decoration:underline">Datenschutz</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>
|
||||||
<div style="display:flex;justify-content:center">
|
<div style="display:flex;justify-content:center">
|
||||||
<span data-page="gruender" style="cursor:pointer;font-weight:600;font-size:var(--text-xs);
|
<span data-page="gruender" style="cursor:pointer;font-weight:600;font-size:var(--text-xs);
|
||||||
|
|
@ -460,10 +459,6 @@
|
||||||
<div class="page-body page-container"></div>
|
<div class="page-body page-container"></div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="page" id="page-breeder-editor">
|
|
||||||
<div class="page-body page-container"></div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section class="page" id="page-social">
|
<section class="page" id="page-social">
|
||||||
<div class="page-body page-container"></div>
|
<div class="page-body page-container"></div>
|
||||||
</section>
|
</section>
|
||||||
|
|
@ -492,10 +487,6 @@
|
||||||
<div class="page-body page-container"></div>
|
<div class="page-body page-container"></div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="page" id="page-agb">
|
|
||||||
<div class="page-body page-container"></div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section class="page" id="page-widget">
|
<section class="page" id="page-widget">
|
||||||
<div class="page-body page-container"></div>
|
<div class="page-body page-container"></div>
|
||||||
</section>
|
</section>
|
||||||
|
|
@ -512,14 +503,6 @@
|
||||||
<div class="page-body page-container"></div>
|
<div class="page-body page-container"></div>
|
||||||
</section>
|
</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">
|
<section class="page" id="page-jobs">
|
||||||
<div class="page-body page-container"></div>
|
<div class="page-body page-container"></div>
|
||||||
</section>
|
</section>
|
||||||
|
|
@ -616,10 +599,10 @@
|
||||||
<div id="modal-container"></div>
|
<div id="modal-container"></div>
|
||||||
|
|
||||||
<!-- JS: Reihenfolge ist wichtig — erst Basis, dann Features -->
|
<!-- JS: Reihenfolge ist wichtig — erst Basis, dann Features -->
|
||||||
<script src="/js/api.js?v=1070"></script>
|
<script src="/js/api.js?v=919"></script>
|
||||||
<script src="/js/ui.js?v=1070"></script>
|
<script src="/js/ui.js?v=919"></script>
|
||||||
<script src="/js/app.js?v=1070"></script>
|
<script src="/js/app.js?v=919"></script>
|
||||||
<script src="/js/worlds.js?v=1070"></script>
|
<script src="/js/worlds.js?v=919"></script>
|
||||||
|
|
||||||
<!-- Feature-Seiten werden lazy geladen -->
|
<!-- Feature-Seiten werden lazy geladen -->
|
||||||
|
|
||||||
|
|
@ -637,16 +620,6 @@
|
||||||
}
|
}
|
||||||
window.addEventListener('offline', function() {
|
window.addEventListener('offline', function() {
|
||||||
_updateBanner();
|
_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
|
// Queue-Count abfragen
|
||||||
if (navigator.serviceWorker) {
|
if (navigator.serviceWorker) {
|
||||||
navigator.serviceWorker.ready.then(function(reg) {
|
navigator.serviceWorker.ready.then(function(reg) {
|
||||||
|
|
@ -705,7 +678,7 @@
|
||||||
// Backup: controllerchange (falls updatefound nicht feuert)
|
// Backup: controllerchange (falls updatefound nicht feuert)
|
||||||
// NICHT registrieren wenn diese Seite selbst durch einen SW-Reload entstand (_t= im URL)
|
// NICHT registrieren wenn diese Seite selbst durch einen SW-Reload entstand (_t= im URL)
|
||||||
// — verhindert Dauerschleife wenn clients.claim() erst nach Seitenstart feuert
|
// — verhindert Dauerschleife wenn clients.claim() erst nach Seitenstart feuert
|
||||||
if (!window._BY_SW_RELOAD) {
|
if (!location.search.includes('_t=')) {
|
||||||
navigator.serviceWorker.addEventListener('controllerchange', () => {
|
navigator.serviceWorker.addEventListener('controllerchange', () => {
|
||||||
if (sessionStorage.getItem('by_skip_sw_reload')) {
|
if (sessionStorage.getItem('by_skip_sw_reload')) {
|
||||||
sessionStorage.removeItem('by_skip_sw_reload');
|
sessionStorage.removeItem('by_skip_sw_reload');
|
||||||
|
|
|
||||||
|
|
@ -3,13 +3,11 @@
|
||||||
Router, State-Management, Navigation, Initialisierung.
|
Router, State-Management, Navigation, Initialisierung.
|
||||||
============================================================ */
|
============================================================ */
|
||||||
|
|
||||||
const APP_VER = '1070'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen
|
const APP_VER = '961'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen
|
||||||
const APP_VERSION = '1.6.0'; // ← semantische Version, wird bei make release gesetzt
|
const APP_VERSION = '1.5.1'; // ← semantische Version, wird bei make release gesetzt
|
||||||
const IS_STAGING = location.hostname === 'staging.banyaro.app';
|
const IS_STAGING = location.hostname === 'staging.banyaro.app';
|
||||||
// Cache-Bust-Parameter nach Update-Reload sofort entfernen.
|
// Cache-Bust-Parameter nach Update-Reload sofort entfernen
|
||||||
// Flag MUSS vor replaceState gesetzt werden — index.html liest es danach.
|
if (location.search.includes('_t=')) history.replaceState(null, '', '/');
|
||||||
window._BY_SW_RELOAD = location.search.includes('_t=');
|
|
||||||
if (window._BY_SW_RELOAD) history.replaceState(null, '', '/');
|
|
||||||
|
|
||||||
const App = (() => {
|
const App = (() => {
|
||||||
|
|
||||||
|
|
@ -66,19 +64,15 @@ const App = (() => {
|
||||||
moderation: { title: 'Moderation', module: null, requiresAuth: true },
|
moderation: { title: 'Moderation', module: null, requiresAuth: true },
|
||||||
impressum: { title: 'Impressum', module: null },
|
impressum: { title: 'Impressum', module: null },
|
||||||
datenschutz: { title: 'Datenschutz', module: null },
|
datenschutz: { title: 'Datenschutz', module: null },
|
||||||
agb: { title: 'AGB', module: null },
|
|
||||||
widget: { title: 'Widget', module: null, requiresAuth: true },
|
widget: { title: 'Widget', module: null, requiresAuth: true },
|
||||||
notifications: { title: 'Aktuelles', module: null, requiresAuth: true },
|
notifications: { title: 'Aktuelles', module: null, requiresAuth: true },
|
||||||
breeder: { title: 'Züchter-Profil', module: null },
|
breeder: { title: 'Züchter-Profil', module: null },
|
||||||
'breeder-editor': { title: 'Profil bearbeiten', module: null, requiresAuth: true },
|
|
||||||
litters: { title: 'Wurfverwaltung', module: null, requiresAuth: true },
|
litters: { title: 'Wurfverwaltung', module: null, requiresAuth: true },
|
||||||
wurfboerse: { title: 'Wurfbörse', module: null },
|
wurfboerse: { title: 'Wurfbörse', module: null },
|
||||||
zuchthunde: { title: 'Zuchtkartei', module: null, requiresAuth: true },
|
zuchthunde: { title: 'Zuchtkartei', module: null, requiresAuth: true },
|
||||||
laeufi: { title: 'Läufigkeit', module: null, requiresAuth: true },
|
laeufi: { title: 'Läufigkeit', module: null, requiresAuth: true },
|
||||||
'zucht-profil': { title: 'Hunde-Profil', module: null },
|
'zucht-profil': { title: 'Hunde-Profil', module: null },
|
||||||
gruender: { title: '100 Gründer', 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 },
|
jobs: { title: 'Wir suchen dich', module: null },
|
||||||
expenses: { title: 'Ausgaben', module: null, requiresAuth: true },
|
expenses: { title: 'Ausgaben', module: null, requiresAuth: true },
|
||||||
recalls: { title: 'Rückrufe', module: null },
|
recalls: { title: 'Rückrufe', module: null },
|
||||||
|
|
@ -261,8 +255,6 @@ const App = (() => {
|
||||||
if (mod?.init) {
|
if (mod?.init) {
|
||||||
await mod.init(container, state, params);
|
await mod.init(container, state, params);
|
||||||
page.module = mod;
|
page.module = mod;
|
||||||
// Desktop: erste Inhalts-Div auf Standardbreite setzen
|
|
||||||
_applyDesktopWidth(pageId, container);
|
|
||||||
} else {
|
} else {
|
||||||
// Platzhalter wenn Seite noch nicht gebaut
|
// Platzhalter wenn Seite noch nicht gebaut
|
||||||
container.innerHTML = UI.emptyState({
|
container.innerHTML = UI.emptyState({
|
||||||
|
|
@ -273,13 +265,10 @@ const App = (() => {
|
||||||
page.module = {}; // verhindert erneutes Laden
|
page.module = {}; // verhindert erneutes Laden
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
const _offline = !navigator.onLine;
|
|
||||||
container.innerHTML = UI.emptyState({
|
container.innerHTML = UI.emptyState({
|
||||||
icon: _offline ? '📡' : '🚧',
|
icon: '🚧',
|
||||||
title: pages[pageId].title,
|
title: pages[pageId].title,
|
||||||
text: _offline
|
text: 'Diese Seite ist noch in Entwicklung.',
|
||||||
? '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 = {};
|
page.module = {};
|
||||||
} finally {
|
} finally {
|
||||||
|
|
@ -287,23 +276,6 @@ 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
|
// LOGIN GATE — wird statt Seiteninhalt angezeigt
|
||||||
// ----------------------------------------------------------
|
// ----------------------------------------------------------
|
||||||
|
|
@ -613,16 +585,11 @@ const App = (() => {
|
||||||
_checkNearbyAlerts();
|
_checkNearbyAlerts();
|
||||||
setInterval(() => { _updateNotifBadge(); _updateChatBadge(); }, 30_000);
|
setInterval(() => { _updateNotifBadge(); _updateChatBadge(); }, 30_000);
|
||||||
setInterval(_checkNearbyAlerts, 5 * 60_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', () => {
|
document.addEventListener('visibilitychange', () => {
|
||||||
if (document.visibilityState === 'visible') {
|
if (document.visibilityState === 'visible') {
|
||||||
_updateNotifBadge();
|
_updateNotifBadge();
|
||||||
_updateChatBadge();
|
_updateChatBadge();
|
||||||
_checkNearbyAlerts();
|
_checkNearbyAlerts();
|
||||||
_sendHeartbeat();
|
|
||||||
if (state.page === 'chat') {
|
if (state.page === 'chat') {
|
||||||
pages['chat']?.module?.refresh?.();
|
pages['chat']?.module?.refresh?.();
|
||||||
}
|
}
|
||||||
|
|
@ -1173,21 +1140,6 @@ const App = (() => {
|
||||||
window.App = App; // Worlds kann App.navigate() aufrufen
|
window.App = App; // Worlds kann App.navigate() aufrufen
|
||||||
|
|
||||||
// App starten
|
// 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', () => {
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
App.init();
|
App.init();
|
||||||
if (IS_STAGING) {
|
if (IS_STAGING) {
|
||||||
|
|
|
||||||
|
|
@ -27,7 +27,6 @@ window.Page_admin = (() => {
|
||||||
{ id: 'uebungen_admin', label: 'Übungen', icon: 'barbell' },
|
{ id: 'uebungen_admin', label: 'Übungen', icon: 'barbell' },
|
||||||
{ id: 'referrals', label: 'Referrals', icon: 'share-network' },
|
{ id: 'referrals', label: 'Referrals', icon: 'share-network' },
|
||||||
{ id: 'upgrades', label: 'Upgrades', icon: 'crown-simple' },
|
{ id: 'upgrades', label: 'Upgrades', icon: 'crown-simple' },
|
||||||
{ id: 'rechnungen', label: 'Rechnungen', icon: 'receipt' },
|
|
||||||
];
|
];
|
||||||
|
|
||||||
// ------------------------------------------------------------------
|
// ------------------------------------------------------------------
|
||||||
|
|
@ -56,8 +55,7 @@ window.Page_admin = (() => {
|
||||||
<!-- Action Items -->
|
<!-- Action Items -->
|
||||||
<div id="adm-action-items" style="padding:var(--space-3) var(--space-3) 0"></div>
|
<div id="adm-action-items" style="padding:var(--space-3) var(--space-3) 0"></div>
|
||||||
|
|
||||||
<!-- Sidebar + Content (Desktop: nebeneinander) -->
|
<!-- Tabs -->
|
||||||
<div class="adm-shell">
|
|
||||||
<div class="by-tabs adm-tabs" id="adm-tabs">
|
<div class="by-tabs adm-tabs" id="adm-tabs">
|
||||||
${TABS.map(t => `
|
${TABS.map(t => `
|
||||||
<button class="by-tab${t.id === _tab ? ' active' : ''}" data-tab="${t.id}">
|
<button class="by-tab${t.id === _tab ? ' active' : ''}" data-tab="${t.id}">
|
||||||
|
|
@ -65,8 +63,9 @@ window.Page_admin = (() => {
|
||||||
</button>
|
</button>
|
||||||
`).join('')}
|
`).join('')}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Inhalt -->
|
||||||
<div id="adm-content"></div>
|
<div id="adm-content"></div>
|
||||||
</div>
|
|
||||||
`;
|
`;
|
||||||
|
|
||||||
_container.querySelector('#adm-tabs')
|
_container.querySelector('#adm-tabs')
|
||||||
|
|
@ -98,7 +97,6 @@ window.Page_admin = (() => {
|
||||||
{ key: 'reports_open', label: 'Meldungen', tab: 'moderation', icon: 'warning' },
|
{ key: 'reports_open', label: 'Meldungen', tab: 'moderation', icon: 'warning' },
|
||||||
{ key: 'fotos_pending', label: 'Foto-Einreichungen',tab: 'moderation', icon: 'image' },
|
{ key: 'fotos_pending', label: 'Foto-Einreichungen',tab: 'moderation', icon: 'image' },
|
||||||
{ key: 'poi_edits_pending', label: 'POI-Korrekturen', tab: 'moderation', icon: 'map-pin' },
|
{ 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);
|
const open = items.filter(i => d[i.key] > 0);
|
||||||
|
|
@ -168,7 +166,6 @@ window.Page_admin = (() => {
|
||||||
case 'uebungen_admin': await _renderUebungenAdmin(el); break;
|
case 'uebungen_admin': await _renderUebungenAdmin(el); break;
|
||||||
case 'referrals': await _renderReferrals(el); break;
|
case 'referrals': await _renderReferrals(el); break;
|
||||||
case 'upgrades': await _renderUpgrades(el); break;
|
case 'upgrades': await _renderUpgrades(el); break;
|
||||||
case 'rechnungen': await _renderRechnungen(el); break;
|
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
el.innerHTML = _emptyState('warning', 'Fehler', e.message || 'Unbekannter Fehler.');
|
el.innerHTML = _emptyState('warning', 'Fehler', e.message || 'Unbekannter Fehler.');
|
||||||
|
|
@ -3531,14 +3528,8 @@ 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="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">
|
<div style="display:flex;align-items:center;gap:var(--space-2);flex-wrap:wrap">
|
||||||
${tierBadge(r.tier)}
|
${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>
|
<span style="font-size:var(--text-xs);color:var(--c-text-muted)">${r.created_at?.slice(0,10) || ''}</span>
|
||||||
</div>
|
</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);
|
${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);
|
padding:var(--space-2) var(--space-3);border-radius:var(--radius-md);
|
||||||
background:var(--c-surface-raised,rgba(0,0,0,.04))">
|
background:var(--c-surface-raised,rgba(0,0,0,.04))">
|
||||||
|
|
@ -3546,25 +3537,12 @@ window.Page_admin = (() => {
|
||||||
</div>` : ''}
|
</div>` : ''}
|
||||||
</div>
|
</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}"
|
<button class="btn adm-fulfill-btn" data-id="${r.id}" data-name="${_esc(r.name)}" data-tier="${r.tier}"
|
||||||
style="background:#16a34a;color:#fff;border:none;
|
style="width:100%;margin-top:var(--space-3);background:#16a34a;color:#fff;border:none;
|
||||||
padding:var(--space-2) var(--space-3);border-radius:var(--radius-md);
|
padding:var(--space-2) var(--space-3);border-radius:var(--radius-md);
|
||||||
cursor:pointer;font-size:var(--text-sm);font-weight:600">
|
cursor:pointer;font-size:var(--text-sm);font-weight:600">
|
||||||
✓ Freischalten
|
✓ Freischalten
|
||||||
</button>
|
</button>
|
||||||
</div>
|
|
||||||
</div>`;
|
</div>`;
|
||||||
|
|
||||||
// Erledigte als kompakte Tabellenzeilen
|
// Erledigte als kompakte Tabellenzeilen
|
||||||
|
|
@ -3610,7 +3588,7 @@ window.Page_admin = (() => {
|
||||||
const tierLabel = { pro: 'Pro', breeder: 'Züchter' }[tier] || tier;
|
const tierLabel = { pro: 'Pro', breeder: 'Züchter' }[tier] || tier;
|
||||||
const ok = await UI.modal.confirm({
|
const ok = await UI.modal.confirm({
|
||||||
title: `${name} auf ${tierLabel} freischalten?`,
|
title: `${name} auf ${tierLabel} freischalten?`,
|
||||||
message: `Der Account wird auf ${tierLabel} gesetzt und eine Bestätigungsmail gesendet.\n\nFalls noch keine Rechnung gesendet wurde, wird ein Entwurf automatisch angelegt.`,
|
message: `Der Account wird auf ${tierLabel} gesetzt und eine Bestätigungsmail gesendet.`,
|
||||||
confirmText: 'Freischalten',
|
confirmText: 'Freischalten',
|
||||||
danger: false,
|
danger: false,
|
||||||
});
|
});
|
||||||
|
|
@ -3619,14 +3597,7 @@ window.Page_admin = (() => {
|
||||||
btn.textContent = '…';
|
btn.textContent = '…';
|
||||||
try {
|
try {
|
||||||
const res = await API.post(`/admin/upgrade-requests/${id}/fulfill`);
|
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.`);
|
UI.toast.success(`${res.user} wurde auf ${tierLabel} freigeschaltet.`);
|
||||||
}
|
|
||||||
_renderTab();
|
_renderTab();
|
||||||
_renderActionItems();
|
_renderActionItems();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|
@ -3636,867 +3607,6 @@ 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 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 ? ` · <span style="color:var(--c-danger)">-${rabatt.toLocaleString('de-DE',{minimumFractionDigits:2})} € (${disc}%)</span>` : ''}
|
|
||||||
· <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 (Jan–Mär)</option>
|
|
||||||
<option value="2">Q2 (Apr–Jun)</option>
|
|
||||||
<option value="3">Q3 (Jul–Sep)</option>
|
|
||||||
<option value="4">Q4 (Okt–Dez)</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 };
|
return { init, refresh, onDogChange };
|
||||||
|
|
|
||||||
|
|
@ -1,195 +0,0 @@
|
||||||
/* ============================================================
|
|
||||||
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 };
|
|
||||||
})();
|
|
||||||
|
|
@ -32,26 +32,6 @@ window.Page_datenschutz = (() => {
|
||||||
E-Mail: <a href="mailto:hallo@banyaro.app" style="${S.a}">hallo@banyaro.app</a>
|
E-Mail: <a href="mailto:hallo@banyaro.app" style="${S.a}">hallo@banyaro.app</a>
|
||||||
</p>`)}
|
</p>`)}
|
||||||
|
|
||||||
${sec('Hosting & 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', `
|
${sec('Deine Daten gehören dir', `
|
||||||
<p style="${S.p}">
|
<p style="${S.p}">
|
||||||
Ban Yaro ist eine private Community-App. Dein <strong>Tagebuch</strong>, deine
|
Ban Yaro ist eine private Community-App. Dein <strong>Tagebuch</strong>, deine
|
||||||
|
|
@ -90,9 +70,6 @@ window.Page_datenschutz = (() => {
|
||||||
Push-Benachrichtigungen. Einwilligungen können jederzeit mit Wirkung für die Zukunft
|
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
|
widerrufen werden (Art. 7 Abs. 3 DSGVO) — einfach die entsprechende Funktion in den
|
||||||
Einstellungen deaktivieren oder die Browser-Freigabe entziehen.
|
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>`)}
|
</p>`)}
|
||||||
|
|
||||||
${sec('Datenweitergabe', `
|
${sec('Datenweitergabe', `
|
||||||
|
|
@ -115,13 +92,6 @@ window.Page_datenschutz = (() => {
|
||||||
Du kannst Gespräche jederzeit selbst löschen.
|
Du kannst Gespräche jederzeit selbst löschen.
|
||||||
</p>`)}
|
</p>`)}
|
||||||
|
|
||||||
${sec('Moderation & 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', `
|
${sec('KI-Funktionen', `
|
||||||
<p style="${S.p}">
|
<p style="${S.p}">
|
||||||
Ban Yaro bietet KI-gestützte Funktionen (Trainingsempfehlungen, Terminvorschläge,
|
Ban Yaro bietet KI-gestützte Funktionen (Trainingsempfehlungen, Terminvorschläge,
|
||||||
|
|
@ -157,12 +127,6 @@ window.Page_datenschutz = (() => {
|
||||||
KI-Empfehlungen sind Vorschläge und ersetzen keine tierärztliche Beratung.
|
KI-Empfehlungen sind Vorschläge und ersetzen keine tierärztliche Beratung.
|
||||||
Eine automatisierte Entscheidungsfindung mit rechtlicher Wirkung (Art. 22 DSGVO)
|
Eine automatisierte Entscheidungsfindung mit rechtlicher Wirkung (Art. 22 DSGVO)
|
||||||
findet nicht statt.
|
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>`)}
|
</p>`)}
|
||||||
|
|
||||||
${sec('Wetterdaten & Kartendienste', `
|
${sec('Wetterdaten & Kartendienste', `
|
||||||
|
|
@ -215,16 +179,6 @@ window.Page_datenschutz = (() => {
|
||||||
style="${S.a}">openrouteservice.org/privacy-policy</a>
|
style="${S.a}">openrouteservice.org/privacy-policy</a>
|
||||||
</p>`)}
|
</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', `
|
${sec('Push-Benachrichtigungen', `
|
||||||
<p style="${S.p}">
|
<p style="${S.p}">
|
||||||
Wenn du Push-Benachrichtigungen aktivierst, wird ein Abonnement-Token an den
|
Wenn du Push-Benachrichtigungen aktivierst, wird ein Abonnement-Token an den
|
||||||
|
|
@ -279,28 +233,11 @@ window.Page_datenschutz = (() => {
|
||||||
Du hast außerdem das Recht, bei der zuständigen Datenschutz-Aufsichtsbehörde
|
Du hast außerdem das Recht, bei der zuständigen Datenschutz-Aufsichtsbehörde
|
||||||
Beschwerde einzulegen:<br>
|
Beschwerde einzulegen:<br>
|
||||||
<strong>Bayerisches Landesamt für Datenschutzaufsicht (BayLDA)</strong><br>
|
<strong>Bayerisches Landesamt für Datenschutzaufsicht (BayLDA)</strong><br>
|
||||||
Promenade 18, 91522 Ansbach<br>
|
Promenade 27, 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"
|
<a href="https://www.lda.bayern.de" target="_blank" rel="noopener"
|
||||||
style="${S.a}">www.lda.bayern.de</a>
|
style="${S.a}">www.lda.bayern.de</a>
|
||||||
</p>`)}
|
</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', `
|
${sec('Speicherdauer', `
|
||||||
<p style="${S.p}">
|
<p style="${S.p}">
|
||||||
Deine Daten werden vollständig gelöscht, sobald du deinen Account löschst —
|
Deine Daten werden vollständig gelöscht, sobald du deinen Account löschst —
|
||||||
|
|
@ -309,14 +246,8 @@ window.Page_datenschutz = (() => {
|
||||||
Server-Logs werden nach 30 Tagen rotiert.
|
Server-Logs werden nach 30 Tagen rotiert.
|
||||||
</p>`)}
|
</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">
|
<p style="font-size:var(--text-xs);color:var(--c-text-muted);margin:0">
|
||||||
Stand: Mai 2026 · Version 3
|
Stand: Mai 2026 · Version 2
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -6,8 +6,6 @@
|
||||||
|
|
||||||
window.Page_diary = (() => {
|
window.Page_diary = (() => {
|
||||||
|
|
||||||
const _CACHE_KEY = 'by_diary_cache';
|
|
||||||
|
|
||||||
// ----------------------------------------------------------
|
// ----------------------------------------------------------
|
||||||
// MODUL-STATE
|
// MODUL-STATE
|
||||||
// ----------------------------------------------------------
|
// ----------------------------------------------------------
|
||||||
|
|
@ -326,7 +324,6 @@ window.Page_diary = (() => {
|
||||||
async function _load() {
|
async function _load() {
|
||||||
const dog = _appState.activeDog;
|
const dog = _appState.activeDog;
|
||||||
if (!dog) return;
|
if (!dog) return;
|
||||||
const cacheKey = _CACHE_KEY + '_' + dog.id;
|
|
||||||
try {
|
try {
|
||||||
const params = { limit: LIMIT, offset: _offset };
|
const params = { limit: LIMIT, offset: _offset };
|
||||||
if (_searchQuery) params.q = _searchQuery;
|
if (_searchQuery) params.q = _searchQuery;
|
||||||
|
|
@ -334,10 +331,6 @@ window.Page_diary = (() => {
|
||||||
const batch = await API.diary.list(dog.id, params);
|
const batch = await API.diary.list(dog.id, params);
|
||||||
_entries = _entries.concat(batch);
|
_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
|
// "Mehr laden" anzeigen wenn volle Page geladen wurde
|
||||||
const loadMore = _container.querySelector('#diary-load-more');
|
const loadMore = _container.querySelector('#diary-load-more');
|
||||||
if (loadMore) {
|
if (loadMore) {
|
||||||
|
|
@ -346,17 +339,7 @@ window.Page_diary = (() => {
|
||||||
|
|
||||||
// Stats-Bar befüllen
|
// Stats-Bar befüllen
|
||||||
_renderStatsBar();
|
_renderStatsBar();
|
||||||
} catch {
|
} catch (err) {
|
||||||
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.');
|
UI.toast.error('Einträge konnten nicht geladen werden.');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1765,7 +1748,6 @@ window.Page_diary = (() => {
|
||||||
UI.toast.success('Eintrag gespeichert.');
|
UI.toast.success('Eintrag gespeichert.');
|
||||||
} else {
|
} else {
|
||||||
const created = await API.diary.create(_appState.activeDog.id, payload);
|
const created = await API.diary.create(_appState.activeDog.id, payload);
|
||||||
if (created?._queued) { UI.modal.close(); return; }
|
|
||||||
if (_newFiles.length > 0) {
|
if (_newFiles.length > 0) {
|
||||||
const { uploaded, exifGps } = await _uploadNewFiles(created.id);
|
const { uploaded, exifGps } = await _uploadNewFiles(created.id);
|
||||||
created.media_items = uploaded;
|
created.media_items = uploaded;
|
||||||
|
|
|
||||||
|
|
@ -24,58 +24,12 @@ window.Page_impressum = (() => {
|
||||||
<section style="margin-bottom:var(--space-6)">
|
<section style="margin-bottom:var(--space-6)">
|
||||||
<h2 style="font-size:var(--text-base);font-weight:var(--weight-semibold);
|
<h2 style="font-size:var(--text-base);font-weight:var(--weight-semibold);
|
||||||
color:var(--c-text);margin:0 0 var(--space-2)">Kontakt</h2>
|
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 0 var(--space-4)">
|
<p style="font-size:var(--text-sm);color:var(--c-text-secondary);line-height:1.7;margin:0">
|
||||||
E-Mail: <a href="mailto:hallo@banyaro.app"
|
E-Mail: <a href="mailto:hallo@banyaro.app"
|
||||||
style="color:var(--c-primary)">hallo@banyaro.app</a><br>
|
style="color:var(--c-primary)">hallo@banyaro.app</a><br>
|
||||||
Oder nutze das Formular — wir antworten in der Regel innerhalb von 24 Stunden.
|
Kontaktformular: <a href="mailto:hallo@banyaro.app"
|
||||||
|
style="color:var(--c-primary)">Nachricht senden</a>
|
||||||
</p>
|
</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>
|
||||||
|
|
||||||
<section style="margin-bottom:var(--space-6)">
|
<section style="margin-bottom:var(--space-6)">
|
||||||
|
|
@ -92,6 +46,9 @@ window.Page_impressum = (() => {
|
||||||
<h2 style="font-size:var(--text-base);font-weight:var(--weight-semibold);
|
<h2 style="font-size:var(--text-base);font-weight:var(--weight-semibold);
|
||||||
color:var(--c-text);margin:0 0 var(--space-2)">Streitschlichtung</h2>
|
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">
|
<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
|
Wir sind nicht bereit und nicht verpflichtet, an einem Streitbeilegungsverfahren vor einer
|
||||||
Verbraucherschlichtungsstelle teilzunehmen (§ 36 VSBG).
|
Verbraucherschlichtungsstelle teilzunehmen (§ 36 VSBG).
|
||||||
</p>
|
</p>
|
||||||
|
|
@ -104,73 +61,20 @@ window.Page_impressum = (() => {
|
||||||
Die Inhalte dieser App wurden mit größtmöglicher Sorgfalt erstellt. Für die Richtigkeit,
|
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
|
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.
|
Diensteanbieter sind wir gemäß § 7 Abs. 1 DDG für eigene Inhalte verantwortlich.
|
||||||
Für nutzergenerierte Inhalte (Forenbeiträge, Fotos, Kommentare) sind ausschließlich
|
Für nutzergenerierte Inhalte (z. B. Forenbeiträge, Giftköder-Meldungen) übernehmen wir
|
||||||
die jeweiligen Nutzer verantwortlich. Bei Bekanntwerden rechtswidriger Inhalte werden
|
keine Haftung; diese liegen in der Verantwortung der jeweiligen Nutzer.
|
||||||
diese im Rahmen der gesetzlichen Vorgaben (§§ 7 ff. DDG) geprüft und gegebenenfalls
|
|
||||||
unverzüglich entfernt.
|
|
||||||
</p>
|
</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<p style="font-size:var(--text-xs);color:var(--c-text-muted);margin:0">
|
<p style="font-size:var(--text-xs);color:var(--c-text-muted);margin:0">
|
||||||
Stand: Mai 2026
|
Stand: April 2026
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
</div>
|
</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() {}
|
function refresh() {}
|
||||||
|
|
||||||
return {
|
return { init, refresh };
|
||||||
init(container) {
|
|
||||||
_origInit(container);
|
|
||||||
_initContactForm(container);
|
|
||||||
},
|
|
||||||
refresh
|
|
||||||
};
|
|
||||||
})();
|
})();
|
||||||
|
|
|
||||||
|
|
@ -48,20 +48,20 @@ window.Page_laeufi = (() => {
|
||||||
</svg>
|
</svg>
|
||||||
</div>`;
|
</div>`;
|
||||||
return `
|
return `
|
||||||
<div id="breeder-private-header" style="background:var(--c-bg-secondary);
|
<div id="breeder-private-header" style="background:linear-gradient(135deg,#1a1208,#2d1f0e);
|
||||||
border-bottom:1px solid var(--c-border);
|
border-bottom:1px solid rgba(196,132,58,.25);
|
||||||
padding:var(--space-3) var(--space-4);
|
padding:var(--space-3) var(--space-4);
|
||||||
display:flex;align-items:center;gap:var(--space-3)">
|
display:flex;align-items:center;gap:var(--space-3)">
|
||||||
${logoHtml}
|
${logoHtml}
|
||||||
<div style="flex:1;min-width:0">
|
<div style="flex:1;min-width:0">
|
||||||
<h2 style="margin:0 0 2px;font-size:var(--text-lg);font-weight:700;
|
<h2 style="margin:0 0 2px;font-size:var(--text-lg);font-weight:700;
|
||||||
color:var(--c-text);white-space:nowrap;overflow:hidden;
|
color:rgba(255,255,255,.95);white-space:nowrap;overflow:hidden;
|
||||||
text-overflow:ellipsis;line-height:1.2">${UI.escape(zwinger)}</h2>
|
text-overflow:ellipsis;line-height:1.2">${UI.escape(zwinger)}</h2>
|
||||||
<div style="display:flex;align-items:center;gap:var(--space-2)">
|
<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">
|
<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>
|
<use href="/icons/phosphor.svg#lock-key"></use>
|
||||||
</svg>
|
</svg>
|
||||||
<span style="font-size:var(--text-xs);color:var(--c-text-secondary)">Privater Bereich · Nur du siehst das</span>
|
<span style="font-size:var(--text-xs);color:rgba(196,132,58,.7)">Privater Bereich · Nur du siehst das</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>`;
|
</div>`;
|
||||||
|
|
@ -69,14 +69,14 @@ window.Page_laeufi = (() => {
|
||||||
|
|
||||||
function _render() {
|
function _render() {
|
||||||
_container.innerHTML = `
|
_container.innerHTML = `
|
||||||
<div style="width:100%;max-width:860px;margin:0 auto;box-sizing:border-box">
|
<div style="max-width:860px">
|
||||||
${_privateHeader()}
|
${_privateHeader()}
|
||||||
<div class="by-toolbar" style="margin-bottom:var(--space-4);padding:0 var(--space-4)">
|
<div class="by-toolbar" style="margin-bottom:var(--space-4)">
|
||||||
<h2 style="margin:0;font-size:var(--text-lg);font-weight:var(--weight-semibold)">
|
<h2 style="margin:0;font-size:var(--text-lg);font-weight:var(--weight-semibold)">
|
||||||
${UI.icon('thermometer')} Läufigkeit & Trächtigkeit
|
${UI.icon('thermometer')} Läufigkeit & Trächtigkeit
|
||||||
</h2>
|
</h2>
|
||||||
</div>
|
</div>
|
||||||
<div id="laeufi-list" style="padding:0 var(--space-4) var(--space-6)">
|
<div id="laeufi-list">
|
||||||
<p style="color:var(--c-text-secondary);text-align:center;padding:var(--space-8)">Lädt…</p>
|
<p style="color:var(--c-text-secondary);text-align:center;padding:var(--space-8)">Lädt…</p>
|
||||||
</div>
|
</div>
|
||||||
</div>`;
|
</div>`;
|
||||||
|
|
|
||||||
|
|
@ -31,13 +31,13 @@ window.Page_litters = (() => {
|
||||||
|
|
||||||
function _statusBadge(status) {
|
function _statusBadge(status) {
|
||||||
const map = {
|
const map = {
|
||||||
geplant: { label: 'Geplant', cls: 'badge-warning' },
|
geplant: { label: 'Geplant', color: '#6B7280' },
|
||||||
geboren: { label: 'Geboren', cls: 'badge-primary' },
|
geboren: { label: 'Geboren', color: '#3B82F6' },
|
||||||
verfuegbar: { label: 'Verfügbar', cls: 'badge-success' },
|
verfuegbar: { label: 'Verfügbar', color: '#22C55E' },
|
||||||
abgeschlossen: { label: 'Abgeschlossen', cls: 'badge-muted' },
|
abgeschlossen: { label: 'Abgeschlossen', color: '#374151' },
|
||||||
};
|
};
|
||||||
const s = map[status] || { label: status, cls: 'badge-muted' };
|
const s = map[status] || { label: status, color: '#6B7280' };
|
||||||
return `<span class="badge ${s.cls}">${_esc(s.label)}</span>`;
|
return `<span class="litters-badge" style="background:${s.color}">${_esc(s.label)}</span>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function _fmtDate(iso) {
|
function _fmtDate(iso) {
|
||||||
|
|
@ -54,12 +54,12 @@ window.Page_litters = (() => {
|
||||||
|
|
||||||
function _puppyStatusBadge(status) {
|
function _puppyStatusBadge(status) {
|
||||||
const map = {
|
const map = {
|
||||||
verfuegbar: { label: 'Verfügbar', cls: 'badge-success' },
|
verfuegbar: { label: 'Verfügbar', color: '#22C55E' },
|
||||||
reserviert: { label: 'Reserviert', cls: 'badge-warning' },
|
reserviert: { label: 'Reserviert', color: '#F59E0B' },
|
||||||
abgegeben: { label: 'Abgegeben', cls: 'badge-muted' },
|
abgegeben: { label: 'Abgegeben', color: '#6B7280' },
|
||||||
};
|
};
|
||||||
const s = map[status] || { label: status, cls: 'badge-muted' };
|
const s = map[status] || { label: status, color: '#9CA3AF' };
|
||||||
return `<span class="badge badge-sm ${s.cls}">${_esc(s.label)}</span>`;
|
return `<span class="litters-badge litters-badge--sm" style="background:${s.color}">${_esc(s.label)}</span>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ----------------------------------------------------------
|
// ----------------------------------------------------------
|
||||||
|
|
@ -113,20 +113,20 @@ window.Page_litters = (() => {
|
||||||
</svg>
|
</svg>
|
||||||
</div>`;
|
</div>`;
|
||||||
return `
|
return `
|
||||||
<div id="breeder-private-header" style="background:var(--c-bg-secondary);
|
<div id="breeder-private-header" style="background:linear-gradient(135deg,#1a1208,#2d1f0e);
|
||||||
border-bottom:1px solid var(--c-border);
|
border-bottom:1px solid rgba(196,132,58,.25);
|
||||||
padding:var(--space-3) var(--space-4);
|
padding:var(--space-3) var(--space-4);
|
||||||
display:flex;align-items:center;gap:var(--space-3)">
|
display:flex;align-items:center;gap:var(--space-3)">
|
||||||
${logoHtml}
|
${logoHtml}
|
||||||
<div style="flex:1;min-width:0">
|
<div style="flex:1;min-width:0">
|
||||||
<h2 style="margin:0 0 2px;font-size:var(--text-lg);font-weight:700;
|
<h2 style="margin:0 0 2px;font-size:var(--text-lg);font-weight:700;
|
||||||
color:var(--c-text);white-space:nowrap;overflow:hidden;
|
color:rgba(255,255,255,.95);white-space:nowrap;overflow:hidden;
|
||||||
text-overflow:ellipsis;line-height:1.2">${_esc(zwinger)}</h2>
|
text-overflow:ellipsis;line-height:1.2">${_esc(zwinger)}</h2>
|
||||||
<div style="display:flex;align-items:center;gap:var(--space-2)">
|
<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">
|
<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>
|
<use href="/icons/phosphor.svg#lock-key"></use>
|
||||||
</svg>
|
</svg>
|
||||||
<span style="font-size:var(--text-xs);color:var(--c-text-secondary)">Privater Bereich · Nur du siehst das</span>
|
<span style="font-size:var(--text-xs);color:rgba(196,132,58,.7)">Privater Bereich · Nur du siehst das</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>`;
|
</div>`;
|
||||||
|
|
|
||||||
|
|
@ -5,43 +5,6 @@
|
||||||
|
|
||||||
window.Page_lost = (() => {
|
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
|
// MODUL-STATE
|
||||||
// ----------------------------------------------------------
|
// ----------------------------------------------------------
|
||||||
|
|
@ -53,24 +16,6 @@ window.Page_lost = (() => {
|
||||||
let _reports = [];
|
let _reports = [];
|
||||||
let _userPos = null;
|
let _userPos = null;
|
||||||
let _leafletLoaded = false;
|
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
|
// INIT
|
||||||
|
|
@ -168,7 +113,6 @@ window.Page_lost = (() => {
|
||||||
// KARTE INITIALISIEREN
|
// KARTE INITIALISIEREN
|
||||||
// ----------------------------------------------------------
|
// ----------------------------------------------------------
|
||||||
function _initMap() {
|
function _initMap() {
|
||||||
_injectStyles();
|
|
||||||
const mapEl = document.getElementById('lost-map');
|
const mapEl = document.getElementById('lost-map');
|
||||||
if (!mapEl || !window.L || _map) return;
|
if (!mapEl || !window.L || _map) return;
|
||||||
|
|
||||||
|
|
@ -236,23 +180,7 @@ window.Page_lost = (() => {
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const fetched = await API.lost.list(_userPos.lat, _userPos.lon, 25);
|
_reports = 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();
|
_renderMarkers();
|
||||||
_renderHeld();
|
_renderHeld();
|
||||||
_renderList();
|
_renderList();
|
||||||
|
|
@ -263,31 +191,6 @@ window.Page_lost = (() => {
|
||||||
: 'Keine vermissten Hunde in deiner Nähe (25 km Radius). 🐾';
|
: 'Keine vermissten Hunde in deiner Nähe (25 km Radius). 🐾';
|
||||||
}
|
}
|
||||||
} catch {
|
} 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.');
|
UI.toast.error('Meldungen konnten nicht geladen werden.');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -301,21 +204,20 @@ window.Page_lost = (() => {
|
||||||
_markers = [];
|
_markers = [];
|
||||||
|
|
||||||
_reports.forEach(r => {
|
_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({
|
const icon = L.divIcon({
|
||||||
className : '',
|
className : '',
|
||||||
html : `<div style="background:${dotColor};color:#fff;border-radius:50%;
|
html : `<div style="
|
||||||
|
background:#e74c3c;color:#fff;border-radius:50%;
|
||||||
width:34px;height:34px;
|
width:34px;height:34px;
|
||||||
display:flex;align-items:center;justify-content:center;
|
display:flex;align-items:center;justify-content:center;
|
||||||
font-size:17px;border:2px solid #fff;
|
font-size:17px;box-shadow:0 2px 6px rgba(0,0,0,.35);
|
||||||
animation:${anim} 1.8s ease-in-out infinite">🐕</div>`,
|
border:2px solid #fff">🐕</div>`,
|
||||||
iconSize : [34, 34],
|
iconSize : [34, 34],
|
||||||
iconAnchor : [17, 17],
|
iconAnchor : [17, 17],
|
||||||
});
|
});
|
||||||
|
|
||||||
const distStr = r.distanz_m !== undefined
|
const distStr = r.distanz_m !== undefined
|
||||||
? (r.distanz_m < 1000 ? `${Math.round(r.distanz_m)} m` : `${(r.distanz_m / 1000).toFixed(1)} km`)
|
? (r.distanz_m < 1000 ? `${r.distanz_m} m` : `${(r.distanz_m / 1000).toFixed(1)} km`)
|
||||||
: '';
|
: '';
|
||||||
|
|
||||||
const marker = L.marker([r.lat, r.lon], { icon })
|
const marker = L.marker([r.lat, r.lon], { icon })
|
||||||
|
|
@ -324,11 +226,10 @@ window.Page_lost = (() => {
|
||||||
<b>🔍 ${_escape(r.name)}</b><br>
|
<b>🔍 ${_escape(r.name)}</b><br>
|
||||||
${r.rasse ? _escape(r.rasse) + '<br>' : ''}
|
${r.rasse ? _escape(r.rasse) + '<br>' : ''}
|
||||||
${distStr ? `<small>📍 ${distStr} entfernt</small><br>` : ''}
|
${distStr ? `<small>📍 ${distStr} entfernt</small><br>` : ''}
|
||||||
${r._isPending ? '<small>⏳ Sync ausstehend</small><br>' : ''}
|
|
||||||
<small>📅 ${_fmtDate(r.created_at)}</small>
|
<small>📅 ${_fmtDate(r.created_at)}</small>
|
||||||
`);
|
`);
|
||||||
|
|
||||||
if (!r._isPending) marker.on('click', () => _openDetail(r));
|
marker.on('click', () => _openDetail(r));
|
||||||
_markers.push(marker);
|
_markers.push(marker);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
@ -370,19 +271,10 @@ window.Page_lost = (() => {
|
||||||
listEl.innerHTML = _reports.map(r => _reportCard(r)).join('');
|
listEl.innerHTML = _reports.map(r => _reportCard(r)).join('');
|
||||||
listEl.querySelectorAll('[data-lost-id]').forEach(card => {
|
listEl.querySelectorAll('[data-lost-id]').forEach(card => {
|
||||||
card.addEventListener('click', () => {
|
card.addEventListener('click', () => {
|
||||||
const id = card.dataset.lostId;
|
const r = _reports.find(x => x.id === parseInt(card.dataset.lostId));
|
||||||
const r = _reports.find(x => String(x.id) === id && !x._isPending);
|
|
||||||
if (r) _openDetail(r);
|
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 => {
|
listEl.querySelectorAll('.lost-note-btn').forEach(btn => {
|
||||||
btn.addEventListener('click', e => {
|
btn.addEventListener('click', e => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
|
|
@ -440,24 +332,14 @@ window.Page_lost = (() => {
|
||||||
Gemeldet ${_fmtDate(r.created_at)}
|
Gemeldet ${_fmtDate(r.created_at)}
|
||||||
${r.melder_name ? '· ' + _escape(r.melder_name.split(' ')[0]) : ''}
|
${r.melder_name ? '· ' + _escape(r.melder_name.split(' ')[0]) : ''}
|
||||||
</div>
|
</div>
|
||||||
${r._isPending
|
${_appState.user ? `<div style="margin-top:var(--space-2)">
|
||||||
? `<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"
|
<button class="btn btn-ghost btn-xs lost-note-btn"
|
||||||
data-lost-note-id="${r.id}"
|
data-lost-note-id="${r.id}"
|
||||||
data-lost-note-name="${_escape(r.name)}"
|
data-lost-note-name="${_escape(r.name)}"
|
||||||
title="Notiz" onclick="event.stopPropagation()">
|
title="Notiz" onclick="event.stopPropagation()">
|
||||||
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#note-pencil"></use></svg> Notiz
|
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#note-pencil"></use></svg> Notiz
|
||||||
</button>
|
</button>
|
||||||
</div>` : '')}
|
</div>` : ''}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -468,7 +350,6 @@ window.Page_lost = (() => {
|
||||||
// DETAIL-MODAL
|
// DETAIL-MODAL
|
||||||
// ----------------------------------------------------------
|
// ----------------------------------------------------------
|
||||||
function _openDetail(r) {
|
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 isOwn = _appState.user && _appState.user.id === r.user_id;
|
||||||
const isAdmin = _appState.user?.rolle === 'admin';
|
const isAdmin = _appState.user?.rolle === 'admin';
|
||||||
const distStr = r.distanz_m !== undefined
|
const distStr = r.distanz_m !== undefined
|
||||||
|
|
@ -751,23 +632,7 @@ window.Page_lost = (() => {
|
||||||
client_time : API.clientNow(),
|
client_time : API.clientNow(),
|
||||||
};
|
};
|
||||||
|
|
||||||
let created;
|
const created = await API.lost.report(payload);
|
||||||
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
|
// Foto hochladen
|
||||||
if (photoInput?.files[0]) {
|
if (photoInput?.files[0]) {
|
||||||
|
|
|
||||||
|
|
@ -14,8 +14,6 @@ window.Page_map = (() => {
|
||||||
let _weatherLoaded = false;
|
let _weatherLoaded = false;
|
||||||
let _placingMarker = false;
|
let _placingMarker = false;
|
||||||
let _tempMarker = null;
|
let _tempMarker = null;
|
||||||
let _tileLayer = null;
|
|
||||||
let _themeObserver = null;
|
|
||||||
|
|
||||||
// Standort-Tracking
|
// Standort-Tracking
|
||||||
let _locationMarker = null;
|
let _locationMarker = null;
|
||||||
|
|
@ -61,7 +59,6 @@ window.Page_map = (() => {
|
||||||
};
|
};
|
||||||
|
|
||||||
const VISIBLE_KEY = 'by_map_visible_v1';
|
const VISIBLE_KEY = 'by_map_visible_v1';
|
||||||
const _MAP_POI_KEY = 'by_map_pois_cache';
|
|
||||||
|
|
||||||
let _visible = {};
|
let _visible = {};
|
||||||
// Gespeicherten Zustand laden, Fallback: alles sichtbar
|
// Gespeicherten Zustand laden, Fallback: alles sichtbar
|
||||||
|
|
@ -78,7 +75,7 @@ window.Page_map = (() => {
|
||||||
|
|
||||||
// z: zIndexOffset — höher = weiter oben bei Überlappung
|
// z: zIndexOffset — höher = weiter oben bei Überlappung
|
||||||
const TYPEN = {
|
const TYPEN = {
|
||||||
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 },
|
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 },
|
||||||
freilauf: { icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#dog"></use></svg>', label: 'Freilauf', color: '#22C55E', z: 20 },
|
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 },
|
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 },
|
kotbeutel: { icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#bag"></use></svg>', label: 'Kotbeutel', color: '#84A98C', z: 5 },
|
||||||
|
|
@ -95,7 +92,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 },
|
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 },
|
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 },
|
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: 'Hotel', color: '#0369a1', z: 20 },
|
hotel: { icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#bed"></use></svg>', label: 'Hundefreundl. Hotel', color: '#0369a1', z: 20 },
|
||||||
};
|
};
|
||||||
|
|
||||||
// Frontend-Layer → Backend-Typ Mapping
|
// Frontend-Layer → Backend-Typ Mapping
|
||||||
|
|
@ -183,7 +180,6 @@ window.Page_map = (() => {
|
||||||
<div class="map-legend" id="map-legend">
|
<div class="map-legend" id="map-legend">
|
||||||
<button class="map-legend-btn map-legend-all" id="map-legend-all" title="Alle ein-/ausblenden">
|
<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>
|
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#list"></use></svg>
|
||||||
<span class="map-legend-label">Filter</span>
|
|
||||||
</button>
|
</button>
|
||||||
${Object.entries(TYPEN).filter(([k]) => k !== 'giftkoeder').map(([k, t]) => `
|
${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}">
|
<button class="map-legend-btn${_visible[k] !== false ? ' active' : ''}" data-layer="${k}" style="--layer-color:${t.color}">
|
||||||
|
|
@ -218,7 +214,7 @@ window.Page_map = (() => {
|
||||||
|
|
||||||
<div class="map-statusbar" id="map-statusbar">
|
<div class="map-statusbar" id="map-statusbar">
|
||||||
<span id="map-zoom-info"></span>
|
<span id="map-zoom-info"></span>
|
||||||
<span id="map-osm-status" style="display:none"></span>
|
<span id="map-osm-status"></span>
|
||||||
<span class="map-statusbar-sep map-weather-chip--hidden" id="map-weather-sep">·</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>
|
<span class="map-weather-chip--hidden" id="map-weather-info"></span>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -530,16 +526,7 @@ window.Page_map = (() => {
|
||||||
if (!_userPos) {
|
if (!_userPos) {
|
||||||
_frankfurtTimer = setTimeout(() => _map.flyTo(center, 14, { duration: 2.5 }), 1200);
|
_frankfurtTimer = setTimeout(() => _map.flyTo(center, 14, { duration: 2.5 }), 1200);
|
||||||
}
|
}
|
||||||
_tileLayer = _buildTileLayer();
|
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { maxZoom: 19 }).addTo(_map);
|
||||||
_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(), 100);
|
||||||
setTimeout(() => _map.invalidateSize(), 600);
|
setTimeout(() => _map.invalidateSize(), 600);
|
||||||
|
|
@ -613,7 +600,7 @@ window.Page_map = (() => {
|
||||||
width:36px;height:36px;border-radius:50%;
|
width:36px;height:36px;border-radius:50%;
|
||||||
display:flex;align-items:center;justify-content:center;
|
display:flex;align-items:center;justify-content:center;
|
||||||
box-shadow:0 2px 6px rgba(0,0,0,0.4);
|
box-shadow:0 2px 6px rgba(0,0,0,0.4);
|
||||||
border:2px solid rgba(52,68,36,0.65)">${n}</div>`,
|
border:2px solid rgba(255,255,255,0.8)">${n}</div>`,
|
||||||
iconSize: [36, 36], iconAnchor: [18, 18],
|
iconSize: [36, 36], iconAnchor: [18, 18],
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
@ -625,34 +612,14 @@ window.Page_map = (() => {
|
||||||
return _clusterGroups[layerKey];
|
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() {
|
function _updateZoomDisplay() {
|
||||||
if (!_map) return;
|
if (!_map) return;
|
||||||
const z = Math.round(_map.getZoom());
|
const z = Math.round(_map.getZoom());
|
||||||
const el = document.getElementById('map-zoom-info');
|
const el = document.getElementById('map-zoom-info');
|
||||||
if (!el) return;
|
if (!el) return;
|
||||||
if (z < 10) { el.textContent = `Z${z}`; el.title = 'Ab Z10: Giftköder'; el.style.opacity = '0.5'; }
|
if (z < 10) { el.textContent = `Zoom ${z} · ab 10: 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 if (z < 14) { el.textContent = `Zoom ${z} · ab 14: alle Layer`; el.style.opacity = '0.7'; }
|
||||||
else { el.textContent = `Z${z}`; el.title = ''; el.style.opacity = '1'; }
|
else { el.textContent = `Zoom ${z}`; el.style.opacity = '1'; }
|
||||||
}
|
}
|
||||||
|
|
||||||
function _setOsmStatus(text, pct = null) {
|
function _setOsmStatus(text, pct = null) {
|
||||||
|
|
@ -970,7 +937,7 @@ window.Page_map = (() => {
|
||||||
width:32px;height:32px;border-radius:50%;
|
width:32px;height:32px;border-radius:50%;
|
||||||
display:flex;align-items:center;justify-content:center;
|
display:flex;align-items:center;justify-content:center;
|
||||||
box-shadow:0 2px 5px rgba(0,0,0,0.35);
|
box-shadow:0 2px 5px rgba(0,0,0,0.35);
|
||||||
border:2px solid rgba(52,68,36,0.55)">${t.icon}</div>`,
|
border:2px solid rgba(255,255,255,0.7)">${t.icon}</div>`,
|
||||||
iconSize: [32, 32], iconAnchor: [16, 16],
|
iconSize: [32, 32], iconAnchor: [16, 16],
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -1250,41 +1217,9 @@ window.Page_map = (() => {
|
||||||
API.breeder.mapMarkers(),
|
API.breeder.mapMarkers(),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const allFailed = [places, poisonList, breederList].every(r => r.status === 'rejected');
|
if (places.status === 'fulfilled') _addPlaces(places.value);
|
||||||
if (allFailed) {
|
if (poisonList.status === 'fulfilled') _addPoison(poisonList.value);
|
||||||
try {
|
if (breederList.status === 'fulfilled') _addBreeders(breederList.value);
|
||||||
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();
|
_scheduleOsmLoad();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1335,7 +1270,7 @@ window.Page_map = (() => {
|
||||||
width:32px;height:32px;border-radius:50%;
|
width:32px;height:32px;border-radius:50%;
|
||||||
display:flex;align-items:center;justify-content:center;
|
display:flex;align-items:center;justify-content:center;
|
||||||
box-shadow:0 2px 5px rgba(0,0,0,0.35);
|
box-shadow:0 2px 5px rgba(0,0,0,0.35);
|
||||||
border:2px solid rgba(52,68,36,0.55)">${t.icon}</div>`,
|
border:2px solid rgba(255,255,255,0.7)">${t.icon}</div>`,
|
||||||
iconSize: [32, 32], iconAnchor: [16, 16],
|
iconSize: [32, 32], iconAnchor: [16, 16],
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -1379,7 +1314,7 @@ window.Page_map = (() => {
|
||||||
width:32px;height:32px;border-radius:50%;
|
width:32px;height:32px;border-radius:50%;
|
||||||
display:flex;align-items:center;justify-content:center;
|
display:flex;align-items:center;justify-content:center;
|
||||||
box-shadow:0 2px 5px rgba(0,0,0,0.35);
|
box-shadow:0 2px 5px rgba(0,0,0,0.35);
|
||||||
border:2px solid rgba(52,68,36,0.55)">${t.icon}</div>`,
|
border:2px solid rgba(255,255,255,0.7)">${t.icon}</div>`,
|
||||||
iconSize: [32, 32], iconAnchor: [16, 16],
|
iconSize: [32, 32], iconAnchor: [16, 16],
|
||||||
});
|
});
|
||||||
return L.marker([lat, lon], { icon, zIndexOffset: t.z ?? 0 })
|
return L.marker([lat, lon], { icon, zIndexOffset: t.z ?? 0 })
|
||||||
|
|
|
||||||
|
|
@ -5,8 +5,6 @@
|
||||||
|
|
||||||
window.Page_poison = (() => {
|
window.Page_poison = (() => {
|
||||||
|
|
||||||
const _CACHE_KEY = 'by_poison_cache';
|
|
||||||
|
|
||||||
// ----------------------------------------------------------
|
// ----------------------------------------------------------
|
||||||
// MODUL-STATE
|
// MODUL-STATE
|
||||||
// ----------------------------------------------------------
|
// ----------------------------------------------------------
|
||||||
|
|
@ -173,7 +171,6 @@ window.Page_poison = (() => {
|
||||||
|
|
||||||
try {
|
try {
|
||||||
_reports = await API.poison.listNearby(_userPos.lat, _userPos.lon, 10000);
|
_reports = await API.poison.listNearby(_userPos.lat, _userPos.lon, 10000);
|
||||||
try { localStorage.setItem(_CACHE_KEY, JSON.stringify({ ts: Date.now(), data: _reports })); } catch {}
|
|
||||||
_renderMarkers();
|
_renderMarkers();
|
||||||
_renderList();
|
_renderList();
|
||||||
_updateBadge(_reports.length);
|
_updateBadge(_reports.length);
|
||||||
|
|
@ -183,18 +180,6 @@ window.Page_poison = (() => {
|
||||||
: 'Keine aktiven Giftköder-Meldungen in deiner Nähe (10 km Radius).';
|
: 'Keine aktiven Giftköder-Meldungen in deiner Nähe (10 km Radius).';
|
||||||
}
|
}
|
||||||
} catch {
|
} 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.');
|
UI.toast.error('Meldungen konnten nicht geladen werden.');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -543,12 +528,6 @@ window.Page_poison = (() => {
|
||||||
|
|
||||||
const created = await API.poison.report(payload);
|
const created = await API.poison.report(payload);
|
||||||
|
|
||||||
// SW hat Request in Queue gelegt (offline)
|
|
||||||
if (created?._queued) {
|
|
||||||
_showPoisonThanks(true);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Foto hochladen
|
// Foto hochladen
|
||||||
if (photoInput?.files[0]) {
|
if (photoInput?.files[0]) {
|
||||||
try {
|
try {
|
||||||
|
|
@ -561,7 +540,8 @@ window.Page_poison = (() => {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Distanz client-seitig berechnen
|
// Distanz client-seitig berechnen (für sofortige Anzeige)
|
||||||
|
// _userPos aktualisieren falls Picker neuen Standort geliefert hat
|
||||||
if (loc.lat && loc.lon) _userPos = { lat: loc.lat, lon: loc.lon };
|
if (loc.lat && loc.lon) _userPos = { lat: loc.lat, lon: loc.lon };
|
||||||
created.distanz_m = _userPos
|
created.distanz_m = _userPos
|
||||||
? Math.round(_haversine(_userPos.lat, _userPos.lon, created.lat, created.lon))
|
? Math.round(_haversine(_userPos.lat, _userPos.lon, created.lat, created.lon))
|
||||||
|
|
@ -573,45 +553,12 @@ window.Page_poison = (() => {
|
||||||
_updateBadge(_reports.length);
|
_updateBadge(_reports.length);
|
||||||
App.checkNearbyAlerts();
|
App.checkNearbyAlerts();
|
||||||
App.callModule('map', 'refresh');
|
App.callModule('map', 'refresh');
|
||||||
_showPoisonThanks(false);
|
UI.toast.success('Giftköder gemeldet! Danke für die Warnung.');
|
||||||
|
UI.modal.close();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// ----------------------------------------------------------
|
|
||||||
// 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)
|
// BADGE (Sidebar + Bottom-Nav)
|
||||||
// ----------------------------------------------------------
|
// ----------------------------------------------------------
|
||||||
|
|
|
||||||
|
|
@ -5,40 +5,6 @@
|
||||||
|
|
||||||
window.Page_routes = (() => {
|
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 _container = null;
|
||||||
let _appState = null;
|
let _appState = null;
|
||||||
let _data = [];
|
let _data = [];
|
||||||
|
|
@ -668,8 +634,7 @@ window.Page_routes = (() => {
|
||||||
if (!_appState.user) { UI.toast.warning('Bitte anmelden.'); return; }
|
if (!_appState.user) { UI.toast.warning('Bitte anmelden.'); return; }
|
||||||
if (_recOvl) return;
|
if (_recOvl) return;
|
||||||
|
|
||||||
try { await (UI.loadLeaflet?.() ?? Promise.resolve()); }
|
await UI.loadLeaflet?.() ?? Promise.resolve();
|
||||||
catch { UI.toast.warning('Karte offline nicht verfügbar — GPS-Aufzeichnung läuft trotzdem.'); }
|
|
||||||
|
|
||||||
const ovl = document.createElement('div');
|
const ovl = document.createElement('div');
|
||||||
ovl.id = 'rk-rec-ovl';
|
ovl.id = 'rk-rec-ovl';
|
||||||
|
|
@ -726,37 +691,24 @@ window.Page_routes = (() => {
|
||||||
document.body.appendChild(ovl);
|
document.body.appendChild(ovl);
|
||||||
_recOvl = 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 };
|
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 })
|
_recMap = L.map(ovl.querySelector('#rk-rec-map-wrap'), { zoomControl: false, attributionControl: false })
|
||||||
.setView([pos.lat, pos.lon], 15);
|
.setView([pos.lat, pos.lon], 15);
|
||||||
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { maxZoom: 19 }).addTo(_recMap);
|
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { maxZoom: 19 }).addTo(_recMap);
|
||||||
_recLocMarker = L.circleMarker([pos.lat, pos.lon], {
|
_recLocMarker = L.circleMarker([pos.lat, pos.lon], {
|
||||||
radius: 8, color: '#fff', weight: 2.5, fillColor: '#3b82f6', fillOpacity: 1
|
radius: 8, color: '#fff', weight: 2.5, fillColor: '#3b82f6', fillOpacity: 1
|
||||||
}).addTo(_recMap);
|
}).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>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Genaueren Standort nachladen (best-effort, klappt auch offline via gespeichertem GPS)
|
// Get accurate position
|
||||||
try {
|
try {
|
||||||
const p = await API.getLocation();
|
const p = await API.getLocation();
|
||||||
_userPos = p;
|
_userPos = p;
|
||||||
_recMap?.setView([p.lat, p.lon], 16);
|
_recMap.setView([p.lat, p.lon], 16);
|
||||||
_recLocMarker?.setLatLng([p.lat, p.lon]);
|
_recLocMarker.setLatLng([p.lat, p.lon]);
|
||||||
} catch {}
|
} catch {}
|
||||||
|
|
||||||
|
ovl.querySelector('#rk-rec-cancel').addEventListener('click', () => _closeRecOvlClean());
|
||||||
|
ovl.querySelector('#rk-rec-startbtn').addEventListener('click', _startRecInOvl);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function _startRecInOvl() {
|
async function _startRecInOvl() {
|
||||||
|
|
@ -780,7 +732,6 @@ window.Page_routes = (() => {
|
||||||
setTimeout(() => banner.remove(), 9000);
|
setTimeout(() => banner.remove(), 9000);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
const ctrl = document.getElementById('rk-rec-ctrl');
|
const ctrl = document.getElementById('rk-rec-ctrl');
|
||||||
ctrl.innerHTML = `
|
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;">
|
<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;">
|
||||||
|
|
@ -822,9 +773,7 @@ window.Page_routes = (() => {
|
||||||
btn.addEventListener('pointercancel', cancelHold);
|
btn.addEventListener('pointercancel', cancelHold);
|
||||||
document.getElementById('rk-rec-stats-bar').style.display = '';
|
document.getElementById('rk-rec-stats-bar').style.display = '';
|
||||||
|
|
||||||
if (_recMap && window.L) {
|
|
||||||
_recPolyline = L.polyline([], { color: '#ef4444', weight: 5, opacity: 0.9 }).addTo(_recMap);
|
_recPolyline = L.polyline([], { color: '#ef4444', weight: 5, opacity: 0.9 }).addTo(_recMap);
|
||||||
}
|
|
||||||
|
|
||||||
await _recAcquireWakeLock();
|
await _recAcquireWakeLock();
|
||||||
document.addEventListener('visibilitychange', _recOnVisibility);
|
document.addEventListener('visibilitychange', _recOnVisibility);
|
||||||
|
|
@ -839,9 +788,9 @@ window.Page_routes = (() => {
|
||||||
_recDistKm += d;
|
_recDistKm += d;
|
||||||
}
|
}
|
||||||
_recTrack.push({ lat, lon, ...(alt !== null ? { alt: Math.round(alt) } : {}) });
|
_recTrack.push({ lat, lon, ...(alt !== null ? { alt: Math.round(alt) } : {}) });
|
||||||
_recPolyline?.addLatLng([lat, lon]);
|
_recPolyline.addLatLng([lat, lon]);
|
||||||
_recLocMarker?.setLatLng([lat, lon]);
|
_recLocMarker.setLatLng([lat, lon]);
|
||||||
if (_recTrack.length === 1) _recMap?.setView([lat, lon], 16);
|
if (_recTrack.length === 1) _recMap.setView([lat, lon], 16);
|
||||||
_updateRecStats();
|
_updateRecStats();
|
||||||
}, () => {}, { enableHighAccuracy: true, maximumAge: 2000 });
|
}, () => {}, { enableHighAccuracy: true, maximumAge: 2000 });
|
||||||
|
|
||||||
|
|
@ -1062,7 +1011,7 @@ window.Page_routes = (() => {
|
||||||
const btn = document.querySelector('[form="rk-rms-form"][type="submit"]');
|
const btn = document.querySelector('[form="rk-rms-form"][type="submit"]');
|
||||||
const fd = UI.formData(e.target);
|
const fd = UI.formData(e.target);
|
||||||
await UI.asyncButton(btn, async () => {
|
await UI.asyncButton(btn, async () => {
|
||||||
const payload = {
|
const saved = await API.routes.create({
|
||||||
name: fd.name?.trim(),
|
name: fd.name?.trim(),
|
||||||
beschreibung: fd.beschreibung || null,
|
beschreibung: fd.beschreibung || null,
|
||||||
gps_track: track,
|
gps_track: track,
|
||||||
|
|
@ -1075,15 +1024,7 @@ window.Page_routes = (() => {
|
||||||
is_public: 'is_public' in fd,
|
is_public: 'is_public' in fd,
|
||||||
hunde_tauglichkeit: fd.hunde_tauglichkeit || 'sehr_gut',
|
hunde_tauglichkeit: fd.hunde_tauglichkeit || 'sehr_gut',
|
||||||
client_time: API.clientNow(),
|
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.modal.close();
|
||||||
UI.toast.success(`Route „${saved.name}" gespeichert!`);
|
UI.toast.success(`Route „${saved.name}" gespeichert!`);
|
||||||
_loadData();
|
_loadData();
|
||||||
|
|
@ -1268,36 +1209,20 @@ window.Page_routes = (() => {
|
||||||
// Daten
|
// Daten
|
||||||
// ----------------------------------------------------------
|
// ----------------------------------------------------------
|
||||||
async function _loadData() {
|
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 {
|
try {
|
||||||
_data = await API.routes.list();
|
_data = await API.routes.list();
|
||||||
try { localStorage.setItem(_CACHE_KEY, JSON.stringify({ ts: Date.now(), data: _data })); } catch {}
|
// "Meine Routen"-Filter nur zeigen wenn eingeloggt und im Mine-Modus
|
||||||
_merge(true);
|
if (_appState.user && _browseMode === 'mine') {
|
||||||
} catch {
|
document.getElementById('rk-mine-group')?.style.setProperty('display', '');
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
} catch {}
|
// Standort-abhängiger Filter im Entdecken-Modus
|
||||||
// Nur Pending-Routen zeigen wenn gar kein Cache
|
if (_browseMode === 'discover' && _userPos) {
|
||||||
_data = _getPending();
|
document.getElementById('rk-nearby-group')?.style.setProperty('display', '');
|
||||||
if (_data.length) { _merge(false); return; }
|
}
|
||||||
|
_applyFilter();
|
||||||
|
} catch (err) {
|
||||||
document.getElementById('rk-grid').innerHTML =
|
document.getElementById('rk-grid').innerHTML =
|
||||||
`<p style="color:var(--c-danger);padding:var(--space-6)">Offline — noch keine Routen gecacht.</p>`;
|
`<p style="color:var(--c-danger);padding:var(--space-6)">Fehler: ${UI.escape(err.message)}</p>`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1444,13 +1369,10 @@ window.Page_routes = (() => {
|
||||||
: '';
|
: '';
|
||||||
|
|
||||||
return `
|
return `
|
||||||
<div class="rk-card" data-id="${r.id}" ${r._isPending ? 'data-pending="1"' : ''}>
|
<div class="rk-card" data-id="${r.id}">
|
||||||
<div class="rk-card-preview">${previewContent}</div>
|
<div class="rk-card-preview">${previewContent}</div>
|
||||||
<div class="rk-card-body">
|
<div class="rk-card-body">
|
||||||
${authorLine}
|
${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 class="rk-card-name">${UI.escape(r.name)}</div>
|
||||||
<div style="display:flex;flex-wrap:wrap;gap:5px;margin:var(--space-2) 0">
|
<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)') : ''}
|
${dist ? _pill(dist, 'rgba(107,114,128,0.10)','#9ca3af','rgba(107,114,128,0.30)') : ''}
|
||||||
|
|
|
||||||
|
|
@ -307,36 +307,6 @@ window.Page_settings = (() => {
|
||||||
Wir schalten deinen Account manuell frei — innerhalb von 24 Stunden.
|
Wir schalten deinen Account manuell frei — innerhalb von 24 Stunden.
|
||||||
Wir melden uns mit den Zahlungsdetails per E-Mail.
|
Wir melden uns mit den Zahlungsdetails per E-Mail.
|
||||||
</div>
|
</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 (§ 356 Abs. 4 BGB).
|
|
||||||
</span>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
${breederForm}
|
${breederForm}
|
||||||
</div>`,
|
</div>`,
|
||||||
footer: `
|
footer: `
|
||||||
|
|
@ -354,17 +324,6 @@ window.Page_settings = (() => {
|
||||||
</button>`
|
</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 () => {
|
document.getElementById('upgrade-request-send-btn')?.addEventListener('click', async () => {
|
||||||
const btn = document.getElementById('upgrade-request-send-btn');
|
const btn = document.getElementById('upgrade-request-send-btn');
|
||||||
if (!btn) return;
|
if (!btn) return;
|
||||||
|
|
@ -398,8 +357,7 @@ window.Page_settings = (() => {
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const widerrufAt = new Date().toLocaleString('de-DE');
|
const res = await API.auth.upgradeRequest(tier);
|
||||||
const res = await API.auth.upgradeRequest(tier, `[Widerrufsrecht akzeptiert am ${widerrufAt}]`);
|
|
||||||
UI.modal.close();
|
UI.modal.close();
|
||||||
if (res.already) {
|
if (res.already) {
|
||||||
UI.toast.info('Deine Anfrage liegt bereits vor — wir melden uns bald.');
|
UI.toast.info('Deine Anfrage liegt bereits vor — wir melden uns bald.');
|
||||||
|
|
@ -1177,22 +1135,6 @@ window.Page_settings = (() => {
|
||||||
value="${_esc(u.social_link || '')}"
|
value="${_esc(u.social_link || '')}"
|
||||||
style="${inputStyle}">
|
style="${inputStyle}">
|
||||||
</div>
|
</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 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>
|
<div>
|
||||||
<label style="display:block;font-size:var(--text-sm);font-weight:600;margin-bottom:var(--space-1)">Profil-Sichtbarkeit</label>
|
<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>
|
<select name="profil_sichtbarkeit" style="${inputStyle}">${sichtbarkeitOpts}</select>
|
||||||
|
|
@ -1219,11 +1161,8 @@ window.Page_settings = (() => {
|
||||||
erfahrung: fd.erfahrung || '',
|
erfahrung: fd.erfahrung || '',
|
||||||
social_link: fd.social_link || '',
|
social_link: fd.social_link || '',
|
||||||
profil_sichtbarkeit: fd.profil_sichtbarkeit || 'public',
|
profil_sichtbarkeit: fd.profil_sichtbarkeit || 'public',
|
||||||
billing_address: fd.billing_address || '',
|
|
||||||
geburtstag: fd.geburtstag || '',
|
|
||||||
});
|
});
|
||||||
Object.assign(_appState.user, updated);
|
Object.assign(_appState.user, updated);
|
||||||
window.Worlds?.refresh?.(_appState); // Welten neu rendern (z.B. Geburtstags-Greeting)
|
|
||||||
UI.modal.close?.();
|
UI.modal.close?.();
|
||||||
UI.toast.success('Profil gespeichert.');
|
UI.toast.success('Profil gespeichert.');
|
||||||
_render();
|
_render();
|
||||||
|
|
|
||||||
|
|
@ -43,7 +43,6 @@ window.Page_social = (() => {
|
||||||
function _render() {
|
function _render() {
|
||||||
const lvlBar = _stats ? _levelBar(_stats) : '';
|
const lvlBar = _stats ? _levelBar(_stats) : '';
|
||||||
_el.innerHTML = `
|
_el.innerHTML = `
|
||||||
<div style="width:100%;max-width:860px;margin:0 auto">
|
|
||||||
<div style="background:var(--c-surface);border-radius:var(--radius-lg);
|
<div style="background:var(--c-surface);border-radius:var(--radius-lg);
|
||||||
box-shadow:var(--shadow-sm);padding:var(--space-4);
|
box-shadow:var(--shadow-sm);padding:var(--space-4);
|
||||||
margin-bottom:var(--space-4)">
|
margin-bottom:var(--space-4)">
|
||||||
|
|
@ -72,8 +71,7 @@ window.Page_social = (() => {
|
||||||
color:${_activeTab===t?'var(--c-primary)':'var(--c-text-secondary)'}">
|
color:${_activeTab===t?'var(--c-primary)':'var(--c-text-secondary)'}">
|
||||||
${l}</button>`).join('')}
|
${l}</button>`).join('')}
|
||||||
</div>
|
</div>
|
||||||
<div id="sm-content"></div>
|
<div id="sm-content"></div>`;
|
||||||
</div>`;
|
|
||||||
|
|
||||||
_el.querySelectorAll('.sm-tab').forEach(b =>
|
_el.querySelectorAll('.sm-tab').forEach(b =>
|
||||||
b.addEventListener('click', () => { _activeTab = b.dataset.tab; _render(); }));
|
b.addEventListener('click', () => { _activeTab = b.dataset.tab; _render(); }));
|
||||||
|
|
|
||||||
|
|
@ -56,7 +56,6 @@ window.Page_uebungen = (() => {
|
||||||
{ id: 'welpe-basics', label: 'Welpe Basics' },
|
{ id: 'welpe-basics', label: 'Welpe Basics' },
|
||||||
{ id: 'grundlagen', label: 'Trainingsgrundlagen' },
|
{ id: 'grundlagen', label: 'Trainingsgrundlagen' },
|
||||||
{ id: 'ki-trainer', label: 'KI-Trainer' },
|
{ id: 'ki-trainer', label: 'KI-Trainer' },
|
||||||
{ id: 'verlauf', label: 'Protokoll' },
|
|
||||||
];
|
];
|
||||||
|
|
||||||
// ----------------------------------------------------------
|
// ----------------------------------------------------------
|
||||||
|
|
@ -547,10 +546,6 @@ window.Page_uebungen = (() => {
|
||||||
_progressCache = {};
|
_progressCache = {};
|
||||||
_progressLoaded = false;
|
_progressLoaded = false;
|
||||||
_exerciseStats = {};
|
_exerciseStats = {};
|
||||||
_verlaufSessions = [];
|
|
||||||
_verlaufOffset = 0;
|
|
||||||
_verlaufLoading = false;
|
|
||||||
_verlaufView = 'datum';
|
|
||||||
_render();
|
_render();
|
||||||
_loadStatsAndBadges();
|
_loadStatsAndBadges();
|
||||||
_loadVirtualTrainer();
|
_loadVirtualTrainer();
|
||||||
|
|
@ -563,21 +558,34 @@ window.Page_uebungen = (() => {
|
||||||
_container.innerHTML = `
|
_container.innerHTML = `
|
||||||
<div id="ueb-wrap">
|
<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) 0">${UI.dogChip(_appState)}</div>
|
||||||
<div style="padding:var(--space-2) var(--space-4) var(--space-2);display:flex;gap:var(--space-2);align-items:stretch">
|
<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)">
|
||||||
<input type="search" id="ueb-search" placeholder="Übung suchen…"
|
<input type="search" id="ueb-search" placeholder="Übung suchen…"
|
||||||
style="flex:1;min-width:0;padding:var(--space-2) var(--space-3);
|
style="display:block;width:100%;box-sizing:border-box;
|
||||||
|
padding:var(--space-2) var(--space-3);
|
||||||
border:1px solid var(--c-border);border-radius:var(--radius-md);
|
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">
|
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">
|
||||||
<button id="ueb-quicksetup-btn"
|
<button id="ueb-quicksetup-btn"
|
||||||
style="flex-shrink:0;width:64px;
|
style="padding:5px 12px;height:100%;
|
||||||
background:var(--c-primary-subtle);border:1.5px solid var(--c-primary-light);
|
background:var(--c-surface-2);border:1px solid var(--c-border);
|
||||||
border-radius:var(--radius-md);cursor:pointer;
|
border-radius:var(--radius-sm);cursor:pointer;
|
||||||
display:flex;flex-direction:column;align-items:center;justify-content:center;gap:3px">
|
display:flex;align-items:center;gap:var(--space-2)">
|
||||||
<svg class="ph-icon" style="width:24px;height:24px;color:var(--c-primary)" aria-hidden="true">
|
<svg class="ph-icon" style="width:15px;height:15px;flex-shrink:0;color:var(--c-primary)" aria-hidden="true">
|
||||||
<use href="/icons/phosphor.svg#paw-print"></use>
|
<use href="/icons/phosphor.svg#list-checks"></use>
|
||||||
</svg>
|
</svg>
|
||||||
<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>
|
<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>
|
||||||
</button>
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
</div>
|
</div>
|
||||||
${_renderTabs()}
|
${_renderTabs()}
|
||||||
<div id="ueb-stats-banner" style="padding:var(--space-2) var(--space-4) 0"></div>
|
<div id="ueb-stats-banner" style="padding:var(--space-2) var(--space-4) 0"></div>
|
||||||
|
|
@ -972,11 +980,10 @@ window.Page_uebungen = (() => {
|
||||||
|
|
||||||
const isExerciseTab = ['grundkommandos','tricks','problemverhalten',
|
const isExerciseTab = ['grundkommandos','tricks','problemverhalten',
|
||||||
'mentale-auslastung','hundesport','koerperpflege','welpe-basics'].includes(_activeTab);
|
'mentale-auslastung','hundesport','koerperpflege','welpe-basics'].includes(_activeTab);
|
||||||
const isVerlauf = _activeTab === 'verlauf';
|
|
||||||
const showIf = v => v ? '' : 'none';
|
const showIf = v => v ? '' : 'none';
|
||||||
|
|
||||||
const quickWrap = _container.querySelector('#ueb-quicksetup-btn')?.parentElement;
|
const quickWrap = _container.querySelector('#ueb-quicksetup-btn')?.parentElement;
|
||||||
if (quickWrap) quickWrap.style.display = isExerciseTab ? 'flex' : 'none';
|
if (quickWrap) quickWrap.style.display = showIf(isExerciseTab);
|
||||||
const trainerEl = _container.querySelector('#ueb-trainer');
|
const trainerEl = _container.querySelector('#ueb-trainer');
|
||||||
const suggestEl = _container.querySelector('#ueb-suggestions');
|
const suggestEl = _container.querySelector('#ueb-suggestions');
|
||||||
const bannerEl = _container.querySelector('#ueb-stats-banner');
|
const bannerEl = _container.querySelector('#ueb-stats-banner');
|
||||||
|
|
@ -1004,17 +1011,6 @@ window.Page_uebungen = (() => {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case 'grundlagen': el.innerHTML = _renderGrundlagen(); 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':
|
case 'ki-trainer':
|
||||||
if (!App.hasPro(_appState?.user)) {
|
if (!App.hasPro(_appState?.user)) {
|
||||||
el.innerHTML = `<div style="padding:var(--space-6);text-align:center;color:var(--c-text-muted)">
|
el.innerHTML = `<div style="padding:var(--space-6);text-align:center;color:var(--c-text-muted)">
|
||||||
|
|
@ -1651,6 +1647,18 @@ window.Page_uebungen = (() => {
|
||||||
background:var(--c-surface);color:var(--c-text);line-height:1.5"></textarea>
|
background:var(--c-surface);color:var(--c-text);line-height:1.5"></textarea>
|
||||||
</div>
|
</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>
|
</form>
|
||||||
|
|
||||||
<!-- Footer Buttons -->
|
<!-- Footer Buttons -->
|
||||||
|
|
@ -1706,6 +1714,7 @@ window.Page_uebungen = (() => {
|
||||||
btn.style.background = 'var(--c-primary-subtle)';
|
btn.style.background = 'var(--c-primary-subtle)';
|
||||||
btn.style.borderColor = 'var(--c-primary)';
|
btn.style.borderColor = 'var(--c-primary)';
|
||||||
btn.style.transform = 'scale(1.15)';
|
btn.style.transform = 'scale(1.15)';
|
||||||
|
_checkMilestoneVisibility();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -1729,9 +1738,17 @@ window.Page_uebungen = (() => {
|
||||||
overlay.querySelectorAll('.ueb-stern-btn').forEach(b => {
|
overlay.querySelectorAll('.ueb-stern-btn').forEach(b => {
|
||||||
b.style.opacity = parseInt(b.dataset.val, 10) <= zufriedenheit ? '1' : '0.35';
|
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
|
// Save
|
||||||
overlay.querySelector('#ueb-log-save').addEventListener('click', async () => {
|
overlay.querySelector('#ueb-log-save').addEventListener('click', async () => {
|
||||||
const dogId = _dogId();
|
const dogId = _dogId();
|
||||||
|
|
@ -1744,6 +1761,8 @@ window.Page_uebungen = (() => {
|
||||||
|
|
||||||
const exerciseId = `${tab}_${exerciseName.replace(/[\s/]+/g, '_')}`;
|
const exerciseId = `${tab}_${exerciseName.replace(/[\s/]+/g, '_')}`;
|
||||||
const today = new Date().toISOString().slice(0, 10);
|
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 = {
|
const body = {
|
||||||
dog_id: dogId,
|
dog_id: dogId,
|
||||||
|
|
@ -1755,6 +1774,7 @@ window.Page_uebungen = (() => {
|
||||||
hund_stimmung: stimmung || null,
|
hund_stimmung: stimmung || null,
|
||||||
zufriedenheit: zufriedenheit || null,
|
zufriedenheit: zufriedenheit || null,
|
||||||
notiz: overlay.querySelector('#ueb-log-notiz').value.trim() || null,
|
notiz: overlay.querySelector('#ueb-log-notiz').value.trim() || null,
|
||||||
|
tagebuch_eintrag: tagebuch,
|
||||||
};
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|
@ -1786,6 +1806,12 @@ 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
|
// Stats-Banner + Trainer aktualisieren
|
||||||
_statsData = null;
|
_statsData = null;
|
||||||
_loadStatsAndBadges();
|
_loadStatsAndBadges();
|
||||||
|
|
@ -1969,325 +1995,6 @@ 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
|
// TRAININGSGRUNDLAGEN
|
||||||
// ----------------------------------------------------------
|
// ----------------------------------------------------------
|
||||||
|
|
|
||||||
|
|
@ -5,45 +5,6 @@
|
||||||
|
|
||||||
window.Page_walks = (() => {
|
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 _container = null;
|
||||||
let _appState = null;
|
let _appState = null;
|
||||||
let _data = [];
|
let _data = [];
|
||||||
|
|
@ -234,16 +195,14 @@ window.Page_walks = (() => {
|
||||||
// Daten laden
|
// Daten laden
|
||||||
// ----------------------------------------------------------
|
// ----------------------------------------------------------
|
||||||
async function _loadData() {
|
async function _loadData() {
|
||||||
const pending = _getPending();
|
|
||||||
try {
|
try {
|
||||||
const fetched = await API.walks.list(
|
_data = await API.walks.list(
|
||||||
_userPos?.lat ?? null,
|
_userPos?.lat ?? null,
|
||||||
_userPos?.lon ?? null
|
_userPos?.lon ?? null
|
||||||
);
|
);
|
||||||
try { localStorage.setItem(_CACHE_KEY, JSON.stringify({ ts: Date.now(), data: fetched })); } catch {}
|
|
||||||
_data = [...pending, ...fetched];
|
|
||||||
_renderList();
|
_renderList();
|
||||||
_renderMarkers();
|
_renderMarkers();
|
||||||
|
// Desktop: Karte direkt initialisieren (beide Panels sichtbar)
|
||||||
if (window.innerWidth >= 1024) {
|
if (window.innerWidth >= 1024) {
|
||||||
UI.loadLeaflet().then(() => {
|
UI.loadLeaflet().then(() => {
|
||||||
_initMap();
|
_initMap();
|
||||||
|
|
@ -251,20 +210,8 @@ window.Page_walks = (() => {
|
||||||
setTimeout(() => _map?.invalidateSize(), 400);
|
setTimeout(() => _map?.invalidateSize(), 400);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
} catch {
|
} catch (err) {
|
||||||
try {
|
UI.toast.error(err.message || 'Fehler beim Laden.');
|
||||||
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.');
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -344,7 +291,6 @@ window.Page_walks = (() => {
|
||||||
</span>
|
</span>
|
||||||
<span class="walks-badge">${UI.icon('paw-print')} ${w.teilnehmer_count}/${w.max_teilnehmer}</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>' : ''}
|
${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>
|
</div>
|
||||||
<div style="display:flex;flex-direction:column;align-items:flex-end;gap:var(--space-1)">
|
<div style="display:flex;flex-direction:column;align-items:flex-end;gap:var(--space-1)">
|
||||||
|
|
@ -1182,30 +1128,15 @@ window.Page_walks = (() => {
|
||||||
const idx = _data.findIndex(w => w.id === walk.id);
|
const idx = _data.findIndex(w => w.id === walk.id);
|
||||||
if (idx !== -1) _data[idx] = { ..._data[idx], ...updated };
|
if (idx !== -1) _data[idx] = { ..._data[idx], ...updated };
|
||||||
UI.toast.success('Treffen aktualisiert.');
|
UI.toast.success('Treffen aktualisiert.');
|
||||||
UI.modal.close();
|
|
||||||
_renderList();
|
|
||||||
_renderMarkers();
|
|
||||||
} else {
|
} else {
|
||||||
let created;
|
const created = await API.walks.create(payload);
|
||||||
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 });
|
_data.unshift({ ...created, teilnehmer_count: 0 });
|
||||||
UI.toast.success('Treffen geplant! 🎉');
|
UI.toast.success('Treffen geplant! 🎉');
|
||||||
|
}
|
||||||
|
|
||||||
UI.modal.close();
|
UI.modal.close();
|
||||||
_renderList();
|
_renderList();
|
||||||
_renderMarkers();
|
_renderMarkers();
|
||||||
}
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -167,8 +167,6 @@ window.Page_welcome = (() => {
|
||||||
<a href="/#impressum" style="color:var(--c-text-muted)">Impressum</a>
|
<a href="/#impressum" style="color:var(--c-text-muted)">Impressum</a>
|
||||||
·
|
·
|
||||||
<a href="/#datenschutz" style="color:var(--c-text-muted)">Datenschutz</a>
|
<a href="/#datenschutz" style="color:var(--c-text-muted)">Datenschutz</a>
|
||||||
·
|
|
||||||
<a href="/#agb" style="color:var(--c-text-muted)">AGB</a>
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
|
|
||||||
|
|
@ -115,20 +115,20 @@ window.Page_zuchthunde = (() => {
|
||||||
</svg>
|
</svg>
|
||||||
</div>`;
|
</div>`;
|
||||||
return `
|
return `
|
||||||
<div id="breeder-private-header" style="background:var(--c-bg-secondary);
|
<div id="breeder-private-header" style="background:linear-gradient(135deg,#1a1208,#2d1f0e);
|
||||||
border-bottom:1px solid var(--c-border);
|
border-bottom:1px solid rgba(196,132,58,.25);
|
||||||
padding:var(--space-3) var(--space-4);
|
padding:var(--space-3) var(--space-4);
|
||||||
display:flex;align-items:center;gap:var(--space-3)">
|
display:flex;align-items:center;gap:var(--space-3)">
|
||||||
${logoHtml}
|
${logoHtml}
|
||||||
<div style="flex:1;min-width:0">
|
<div style="flex:1;min-width:0">
|
||||||
<h2 style="margin:0 0 2px;font-size:var(--text-lg);font-weight:700;
|
<h2 style="margin:0 0 2px;font-size:var(--text-lg);font-weight:700;
|
||||||
color:var(--c-text);white-space:nowrap;overflow:hidden;
|
color:rgba(255,255,255,.95);white-space:nowrap;overflow:hidden;
|
||||||
text-overflow:ellipsis;line-height:1.2">${_esc(zwinger)}</h2>
|
text-overflow:ellipsis;line-height:1.2">${_esc(zwinger)}</h2>
|
||||||
<div style="display:flex;align-items:center;gap:var(--space-2)">
|
<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">
|
<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>
|
<use href="/icons/phosphor.svg#lock-key"></use>
|
||||||
</svg>
|
</svg>
|
||||||
<span style="font-size:var(--text-xs);color:var(--c-text-secondary)">Privater Bereich · Nur du siehst das</span>
|
<span style="font-size:var(--text-xs);color:rgba(196,132,58,.7)">Privater Bereich · Nur du siehst das</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>`;
|
</div>`;
|
||||||
|
|
|
||||||
|
|
@ -83,19 +83,13 @@ const UI = (() => {
|
||||||
document.getElementById('modal-container').appendChild(overlay);
|
document.getElementById('modal-container').appendChild(overlay);
|
||||||
document.documentElement.classList.add('modal-open');
|
document.documentElement.classList.add('modal-open');
|
||||||
|
|
||||||
// Tastatur auf Mobilgeräten: Modal-Höhe begrenzen + fokussiertes Feld scrollen
|
// Tastatur auf Mobilgeräten: Modal nach oben schieben wenn Keyboard erscheint
|
||||||
let _vvCleanup = null;
|
let _vvCleanup = null;
|
||||||
const vv = window.visualViewport;
|
const vv = window.visualViewport;
|
||||||
const modal = overlay.querySelector('.modal');
|
|
||||||
if (vv) {
|
if (vv) {
|
||||||
const adjust = () => {
|
const adjust = () => {
|
||||||
const visible = vv.height;
|
const kb = Math.max(0, window.innerHeight - vv.height - vv.offsetTop);
|
||||||
const offset = vv.offsetTop;
|
overlay.style.paddingBottom = (kb + 16) + 'px';
|
||||||
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('resize', adjust);
|
||||||
vv.addEventListener('scroll', adjust);
|
vv.addEventListener('scroll', adjust);
|
||||||
|
|
@ -103,37 +97,19 @@ const UI = (() => {
|
||||||
vv.removeEventListener('resize', adjust);
|
vv.removeEventListener('resize', adjust);
|
||||||
vv.removeEventListener('scroll', adjust);
|
vv.removeEventListener('scroll', adjust);
|
||||||
overlay.style.paddingBottom = '';
|
overlay.style.paddingBottom = '';
|
||||||
if (modal) modal.style.maxHeight = '';
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fokussiertes Feld innerhalb modal-body scrollen (iOS scrollIntoView
|
_current = { overlay, onClose, _vvCleanup };
|
||||||
// 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');
|
return overlay.querySelector('.modal');
|
||||||
}
|
}
|
||||||
|
|
||||||
function close() {
|
function close() {
|
||||||
if (!_current) return;
|
if (!_current) return;
|
||||||
const { onClose, _vvCleanup, _onFocusin } = _current;
|
const { onClose, _vvCleanup } = _current;
|
||||||
onClose?.();
|
onClose?.();
|
||||||
_vvCleanup?.();
|
_vvCleanup?.();
|
||||||
if (_onFocusin) _current.overlay.removeEventListener('focusin', _onFocusin);
|
|
||||||
_current.overlay.remove();
|
_current.overlay.remove();
|
||||||
document.documentElement.classList.remove('modal-open');
|
document.documentElement.classList.remove('modal-open');
|
||||||
_current = null;
|
_current = null;
|
||||||
|
|
|
||||||
|
|
@ -598,21 +598,13 @@ window.Worlds = (() => {
|
||||||
let _cfgCache = null;
|
let _cfgCache = null;
|
||||||
|
|
||||||
function _mergeDefaults(cfg) {
|
function _mergeDefaults(cfg) {
|
||||||
|
// Neue Default-Chips die noch nicht in der gespeicherten Config sind → anhängen
|
||||||
const result = JSON.parse(JSON.stringify(cfg));
|
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']) {
|
for (const world of ['jetzt', 'hund', 'welt']) {
|
||||||
const def = _DEFAULT_CONFIG[world] || [];
|
const def = _DEFAULT_CONFIG[world] || [];
|
||||||
const saved = result[world] || [];
|
const saved = result[world] || [];
|
||||||
for (const page of def) {
|
for (const page of def) {
|
||||||
// Nur echte Neu-Chips anhängen: nicht zugewiesen UND nicht bewusst ausgeblendet
|
if (!saved.includes(page)) saved.push(page);
|
||||||
if (!allAssigned.has(page) && !hidden.has(page)) {
|
|
||||||
saved.push(page);
|
|
||||||
allAssigned.add(page);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
result[world] = saved;
|
result[world] = saved;
|
||||||
}
|
}
|
||||||
|
|
@ -645,11 +637,6 @@ window.Worlds = (() => {
|
||||||
}
|
}
|
||||||
|
|
||||||
function _saveConfig(cfg) {
|
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;
|
_cfgCache = cfg;
|
||||||
try { localStorage.setItem('world_chips', JSON.stringify(cfg)); } catch {}
|
try { localStorage.setItem('world_chips', JSON.stringify(cfg)); } catch {}
|
||||||
if (_state?.user) {
|
if (_state?.user) {
|
||||||
|
|
@ -723,18 +710,15 @@ window.Worlds = (() => {
|
||||||
const bottomNav = document.getElementById('bottom-nav');
|
const bottomNav = document.getElementById('bottom-nav');
|
||||||
if (bottomNav) bottomNav.style.display = 'none';
|
if (bottomNav) bottomNav.style.display = 'none';
|
||||||
|
|
||||||
const _isDesktop = window.innerWidth >= 768;
|
|
||||||
const ov = document.createElement('div');
|
const ov = document.createElement('div');
|
||||||
ov.id = 'wc-overlay';
|
ov.id = 'wc-overlay';
|
||||||
ov.style.cssText = _isDesktop
|
ov.style.cssText = 'position:fixed;inset:0;z-index:500;display:flex;flex-direction:column;justify-content:flex-end';
|
||||||
? '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);
|
document.body.appendChild(ov);
|
||||||
|
|
||||||
const _removeDragListeners = () => {
|
const _removeDragListeners = () => {
|
||||||
document.removeEventListener('pointermove', _onDragMove);
|
document.removeEventListener('touchmove', _onDragMove);
|
||||||
document.removeEventListener('pointerup', _onDragEnd);
|
document.removeEventListener('touchend', _onDragEnd);
|
||||||
document.removeEventListener('pointercancel', _onDragEnd);
|
document.removeEventListener('touchcancel', _onDragEnd);
|
||||||
};
|
};
|
||||||
const _cancelDrag = () => {
|
const _cancelDrag = () => {
|
||||||
if (!_drag) return;
|
if (!_drag) return;
|
||||||
|
|
@ -752,14 +736,11 @@ window.Worlds = (() => {
|
||||||
};
|
};
|
||||||
|
|
||||||
function _render() {
|
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 = `
|
ov.innerHTML = `
|
||||||
${!_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-bg" style="position:absolute;inset:0;background:rgba(0,0,0,0.7);backdrop-filter:blur(4px)"></div>
|
||||||
<div id="wc-sheet" style="${_sheetStyle}">
|
<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)">
|
||||||
<!-- Header -->
|
<!-- Header -->
|
||||||
<div style="display:flex;align-items:center;justify-content:space-between;
|
<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);
|
padding:20px 20px 16px;position:sticky;top:0;background:rgba(18,22,32,0.97);
|
||||||
|
|
@ -795,15 +776,15 @@ window.Worlds = (() => {
|
||||||
${w !== 'pool' ? `<span style="opacity:.5">(${chips.length})</span>` : ''}
|
${w !== 'pool' ? `<span style="opacity:.5">(${chips.length})</span>` : ''}
|
||||||
</div>
|
</div>
|
||||||
<div class="wc-zone" data-zone="${w}"
|
<div class="wc-zone" data-zone="${w}"
|
||||||
style="display:grid;grid-template-columns:${_gridCols};grid-auto-rows:${_chipH};gap:${_isDesktop?'6px':'8px'};
|
style="display:grid;grid-template-columns:repeat(4,1fr);grid-auto-rows:80px;gap:8px;
|
||||||
min-height:${w==='pool'&&chips.length===0?'40px':'auto'};
|
min-height:${w==='pool'&&chips.length===0?'40px':'auto'};
|
||||||
border:2px dashed transparent;border-radius:16px;padding:4px;
|
border:2px dashed transparent;border-radius:16px;padding:4px;
|
||||||
transition:border-color .2s">
|
transition:border-color .2s">
|
||||||
${chips.map(c => `
|
${chips.map(c => `
|
||||||
<div class="wc-chip" data-page="${c.page}" data-zone="${w}"
|
<div class="wc-chip" data-page="${c.page}" data-zone="${w}"
|
||||||
style="background:rgba(38,46,62,0.95);border:1.5px solid ${col};
|
style="background:rgba(38,46,62,0.95);border:1.5px solid ${col};
|
||||||
border-radius:16px;padding:${_isDesktop?'6px 4px':'10px 4px 8px'};height:${_chipH};box-sizing:border-box;
|
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:${_isDesktop?'3px':'5px'};
|
display:flex;flex-direction:column;align-items:center;justify-content:center;gap:5px;
|
||||||
cursor:grab;position:relative;min-width:0;overflow:hidden;
|
cursor:grab;position:relative;min-width:0;overflow:hidden;
|
||||||
user-select:none;-webkit-tap-highlight-color:transparent;touch-action:pan-y">
|
user-select:none;-webkit-tap-highlight-color:transparent;touch-action:pan-y">
|
||||||
${!c.pinned ? `
|
${!c.pinned ? `
|
||||||
|
|
@ -874,27 +855,29 @@ window.Worlds = (() => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// Pointer-Drag (funktioniert auf Mouse + Touch)
|
// Touch-Drag
|
||||||
ov.querySelectorAll('.wc-chip').forEach(chip => {
|
ov.querySelectorAll('.wc-chip').forEach(chip => {
|
||||||
chip.addEventListener('pointerdown', e => _onDragStart(e, chip));
|
chip.addEventListener('touchstart', e => _onDragStart(e, chip), { passive: true });
|
||||||
});
|
});
|
||||||
|
document.addEventListener('touchmove', _onDragMove, { passive: false });
|
||||||
|
document.addEventListener('touchend', _onDragEnd);
|
||||||
}
|
}
|
||||||
|
|
||||||
function _onDragStart(e, chipEl) {
|
function _onDragStart(e, chipEl) {
|
||||||
if (e.button !== undefined && e.button !== 0) return; // nur linke Maustaste
|
|
||||||
if (_drag) _cancelDrag();
|
if (_drag) _cancelDrag();
|
||||||
chipEl.setPointerCapture(e.pointerId);
|
const touch = e.touches[0];
|
||||||
|
// Drag erst nach Bewegungs-Threshold aktivieren (verhindert Scroll-Konflikte)
|
||||||
_drag = {
|
_drag = {
|
||||||
page: chipEl.dataset.page, zone: chipEl.dataset.zone,
|
page: chipEl.dataset.page, zone: chipEl.dataset.zone,
|
||||||
chipEl, ghost: null, dropZone: null, active: false,
|
chipEl, ghost: null, dropZone: null, active: false,
|
||||||
startX: e.clientX, startY: e.clientY, ox: 0, oy: 0,
|
startX: touch.clientX, startY: touch.clientY, ox: 0, oy: 0,
|
||||||
};
|
};
|
||||||
document.addEventListener('pointermove', _onDragMove);
|
document.addEventListener('touchmove', _onDragMove, { passive: false });
|
||||||
document.addEventListener('pointerup', _onDragEnd);
|
document.addEventListener('touchend', _onDragEnd);
|
||||||
document.addEventListener('pointercancel', _onDragEnd);
|
document.addEventListener('touchcancel', _onDragEnd);
|
||||||
}
|
}
|
||||||
|
|
||||||
function _activateDrag(e) {
|
function _activateDrag(touch) {
|
||||||
const rect = _drag.chipEl.getBoundingClientRect();
|
const rect = _drag.chipEl.getBoundingClientRect();
|
||||||
_drag.ox = _drag.startX - rect.left;
|
_drag.ox = _drag.startX - rect.left;
|
||||||
_drag.oy = _drag.startY - rect.top;
|
_drag.oy = _drag.startY - rect.top;
|
||||||
|
|
@ -908,8 +891,8 @@ window.Worlds = (() => {
|
||||||
ghost.style.transform = 'scale(1.08) rotate(-2deg)';
|
ghost.style.transform = 'scale(1.08) rotate(-2deg)';
|
||||||
ghost.style.width = rect.width + 'px';
|
ghost.style.width = rect.width + 'px';
|
||||||
ghost.style.height = rect.height + 'px';
|
ghost.style.height = rect.height + 'px';
|
||||||
ghost.style.left = (e.clientX - _drag.ox) + 'px';
|
ghost.style.left = (touch.clientX - _drag.ox) + 'px';
|
||||||
ghost.style.top = (e.clientY - _drag.oy) + 'px';
|
ghost.style.top = (touch.clientY - _drag.oy) + 'px';
|
||||||
ghost.style.transition = 'none';
|
ghost.style.transition = 'none';
|
||||||
ghost.style.boxShadow = '0 8px 24px rgba(0,0,0,0.5)';
|
ghost.style.boxShadow = '0 8px 24px rgba(0,0,0,0.5)';
|
||||||
document.body.appendChild(ghost);
|
document.body.appendChild(ghost);
|
||||||
|
|
@ -919,22 +902,24 @@ window.Worlds = (() => {
|
||||||
|
|
||||||
function _onDragMove(e) {
|
function _onDragMove(e) {
|
||||||
if (!_drag) return;
|
if (!_drag) return;
|
||||||
|
const touch = e.touches[0];
|
||||||
|
|
||||||
if (!_drag.active) {
|
if (!_drag.active) {
|
||||||
const dx = Math.abs(e.clientX - _drag.startX);
|
const dx = Math.abs(touch.clientX - _drag.startX);
|
||||||
const dy = Math.abs(e.clientY - _drag.startY);
|
const dy = Math.abs(touch.clientY - _drag.startY);
|
||||||
if (dx < 8 && dy < 8) return;
|
if (dx < 8 && dy < 8) return; // Threshold noch nicht erreicht
|
||||||
_activateDrag(e);
|
_activateDrag(touch);
|
||||||
}
|
}
|
||||||
|
|
||||||
_drag.ghost.style.left = (e.clientX - _drag.ox) + 'px';
|
e.preventDefault(); // Scroll erst NACH Threshold blockieren
|
||||||
_drag.ghost.style.top = (e.clientY - _drag.oy) + 'px';
|
_drag.ghost.style.left = (touch.clientX - _drag.ox) + 'px';
|
||||||
|
_drag.ghost.style.top = (touch.clientY - _drag.oy) + 'px';
|
||||||
|
|
||||||
let foundZone = null;
|
let foundZone = null;
|
||||||
ov.querySelectorAll('.wc-zone').forEach(z => {
|
ov.querySelectorAll('.wc-zone').forEach(z => {
|
||||||
const r = z.getBoundingClientRect();
|
const r = z.getBoundingClientRect();
|
||||||
const over = e.clientX >= r.left && e.clientX <= r.right
|
const over = touch.clientX >= r.left && touch.clientX <= r.right
|
||||||
&& e.clientY >= r.top && e.clientY <= r.bottom;
|
&& touch.clientY >= r.top && touch.clientY <= r.bottom;
|
||||||
z.style.borderColor = over ? (worldColors[z.dataset.zone] || 'rgba(196,132,58,0.8)') : 'transparent';
|
z.style.borderColor = over ? (worldColors[z.dataset.zone] || 'rgba(196,132,58,0.8)') : 'transparent';
|
||||||
if (over) foundZone = z.dataset.zone;
|
if (over) foundZone = z.dataset.zone;
|
||||||
});
|
});
|
||||||
|
|
@ -982,19 +967,6 @@ window.Worlds = (() => {
|
||||||
|
|
||||||
let _bgUrl = null; // aktuell gesetztes Hintergrundbild
|
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() {
|
function _applyBgOrientation() {
|
||||||
const ov = document.getElementById('worlds-overlay');
|
const ov = document.getElementById('worlds-overlay');
|
||||||
const track = document.getElementById('worlds-track');
|
const track = document.getElementById('worlds-track');
|
||||||
|
|
@ -1003,14 +975,14 @@ window.Worlds = (() => {
|
||||||
if (portrait) {
|
if (portrait) {
|
||||||
// Panorama: Bild über alle drei Welten, scrollt mit dem Swipe
|
// Panorama: Bild über alle drei Welten, scrollt mit dem Swipe
|
||||||
ov.style.backgroundImage = '';
|
ov.style.backgroundImage = '';
|
||||||
track.style.backgroundImage = _bgWithOverlay(_bgUrl);
|
track.style.backgroundImage = `url('${_bgUrl}')`;
|
||||||
track.style.backgroundSize = 'cover';
|
track.style.backgroundSize = 'cover';
|
||||||
track.style.backgroundPosition = 'center 40%';
|
track.style.backgroundPosition = 'center 40%';
|
||||||
track.style.backgroundRepeat = 'no-repeat';
|
track.style.backgroundRepeat = 'no-repeat';
|
||||||
} else {
|
} else {
|
||||||
// Vollbild pro Welt (Landscape / Desktop)
|
// Vollbild pro Welt (Landscape / Desktop)
|
||||||
track.style.backgroundImage = '';
|
track.style.backgroundImage = '';
|
||||||
ov.style.backgroundImage = _bgWithOverlay(_bgUrl);
|
ov.style.backgroundImage = `url('${_bgUrl}')`;
|
||||||
ov.style.backgroundSize = 'cover';
|
ov.style.backgroundSize = 'cover';
|
||||||
ov.style.backgroundPosition = 'center 40%';
|
ov.style.backgroundPosition = 'center 40%';
|
||||||
ov.style.backgroundRepeat = 'no-repeat';
|
ov.style.backgroundRepeat = 'no-repeat';
|
||||||
|
|
@ -1020,10 +992,6 @@ window.Worlds = (() => {
|
||||||
// Orientierungswechsel → Bild neu setzen
|
// Orientierungswechsel → Bild neu setzen
|
||||||
window.matchMedia('(orientation: portrait)').addEventListener('change', _applyBgOrientation);
|
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) {
|
function _applyBgImage(url) {
|
||||||
const ov = document.getElementById('worlds-overlay');
|
const ov = document.getElementById('worlds-overlay');
|
||||||
const track = document.getElementById('worlds-track');
|
const track = document.getElementById('worlds-track');
|
||||||
|
|
@ -1034,20 +1002,7 @@ window.Worlds = (() => {
|
||||||
_hasBgPhoto = true;
|
_hasBgPhoto = true;
|
||||||
_bgUrl = url;
|
_bgUrl = url;
|
||||||
_applyBgOrientation();
|
_applyBgOrientation();
|
||||||
const hint = document.getElementById('wh-photo-hint');
|
document.getElementById('wh-photo-hint')?.remove();
|
||||||
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.onerror = () => _applyBgImage(null);
|
||||||
toLoad.src = url;
|
toLoad.src = url;
|
||||||
|
|
@ -1122,15 +1077,9 @@ window.Worlds = (() => {
|
||||||
} else if (!dog) { _applyBgImage(null); }
|
} else if (!dog) { _applyBgImage(null); }
|
||||||
|
|
||||||
const hour = new Date().getHours();
|
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 firstName = user?.name?.split(' ')[0] || '';
|
||||||
const dayStr = new Date().toLocaleDateString('de-DE', { weekday:'long', day:'numeric', month:'long' });
|
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
|
const stale = isOffline && staleMin > 5
|
||||||
? `<span style="font-size:9px;opacity:0.5;margin-left:6px">· Offline</span>` : '';
|
? `<span style="font-size:9px;opacity:0.5;margin-left:6px">· Offline</span>` : '';
|
||||||
|
|
||||||
|
|
@ -1155,26 +1104,6 @@ window.Worlds = (() => {
|
||||||
: (w.temp_c ?? 20) < 2 ? '🌨️'
|
: (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
|
// Alert-Reminder
|
||||||
const alertHtml = alertList.slice(0,1).map(a => `
|
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)">
|
<div class="world-reminder" data-wnav="${a.page}" style="border-color:rgba(239,68,68,0.5)">
|
||||||
|
|
@ -1221,7 +1150,6 @@ window.Worlds = (() => {
|
||||||
${user ? userAvatarHtml : ''}
|
${user ? userAvatarHtml : ''}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
${userBdayHtml}
|
|
||||||
${alertHtml}
|
${alertHtml}
|
||||||
${user && dog ? `
|
${user && dog ? `
|
||||||
<div class="wj-chip-row">
|
<div class="wj-chip-row">
|
||||||
|
|
@ -1258,6 +1186,7 @@ window.Worlds = (() => {
|
||||||
</div>` : ''}
|
</div>` : ''}
|
||||||
</div>
|
</div>
|
||||||
<div class="world-bottom">
|
<div class="world-bottom">
|
||||||
|
<div class="world-section-label">Deine Bereiche</div>
|
||||||
<div class="world-chips-grid">
|
<div class="world-chips-grid">
|
||||||
${features.map(f => _chip(f.icon, f.label, f.page, false, false, false)).join('')}
|
${features.map(f => _chip(f.icon, f.label, f.page, false, false, false)).join('')}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -1280,7 +1209,8 @@ window.Worlds = (() => {
|
||||||
try {
|
try {
|
||||||
const res = await _cachedGet(`dash_${dog.id}`, `/dogs/${dog.id}/welcome-dashboard`);
|
const res = await _cachedGet(`dash_${dog.id}`, `/dogs/${dog.id}/welcome-dashboard`);
|
||||||
const ex = res.data?.daily_exercise;
|
const ex = res.data?.daily_exercise;
|
||||||
valEl.textContent = ex?.name || 'Stand erfassen →';
|
valEl.textContent = ex?.name || '—';
|
||||||
|
// Chip-Klick mit exercise_id/name damit übungen.js direkt dorthin scrollt
|
||||||
const chip = document.getElementById('wj-exercise-chip');
|
const chip = document.getElementById('wj-exercise-chip');
|
||||||
if (chip) {
|
if (chip) {
|
||||||
chip.style.cursor = 'pointer';
|
chip.style.cursor = 'pointer';
|
||||||
|
|
@ -1291,7 +1221,7 @@ window.Worlds = (() => {
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
} catch { valEl.textContent = 'Stand erfassen →'; }
|
} catch { valEl.textContent = '—'; }
|
||||||
}
|
}
|
||||||
|
|
||||||
async function _loadJetztRoute() {
|
async function _loadJetztRoute() {
|
||||||
|
|
@ -1412,13 +1342,8 @@ window.Worlds = (() => {
|
||||||
if (mmdd === `${mt}-${dt}`) return 'tomorrow';
|
if (mmdd === `${mt}-${dt}`) return 'tomorrow';
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
const bdayDog = _dogs.find(d => _birthdayState(d.geburtstag)) || null;
|
const bday = _birthdayState(dog.geburtstag);
|
||||||
// Großes Banner nur wenn der AKTIVE Hund Geburtstag hat
|
const bdayYear = dog.geburtstag ? new Date().getFullYear() - parseInt(dog.geburtstag.slice(0, 4)) : null;
|
||||||
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([
|
const [streakRes, diaryRes] = await Promise.allSettled([
|
||||||
_cachedGet(`streak_${dog.id}`, `/streak/${dog.id}`),
|
_cachedGet(`streak_${dog.id}`, `/streak/${dog.id}`),
|
||||||
|
|
@ -1477,22 +1402,6 @@ window.Worlds = (() => {
|
||||||
</div>
|
</div>
|
||||||
<div style="justify-self:end;display:flex;align-items:center">${otherAvatarsHtml}</div>
|
<div style="justify-self:end;display:flex;align-items:center">${otherAvatarsHtml}</div>
|
||||||
</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>
|
</div>
|
||||||
${bday ? `
|
${bday ? `
|
||||||
<style>
|
<style>
|
||||||
|
|
@ -1519,8 +1428,8 @@ window.Worlds = (() => {
|
||||||
</div>
|
</div>
|
||||||
<div style="font-weight:800;font-size:var(--text-sm);color:#fff;letter-spacing:0.01em">
|
<div style="font-weight:800;font-size:var(--text-sm);color:#fff;letter-spacing:0.01em">
|
||||||
${bday === 'today'
|
${bday === 'today'
|
||||||
? `Alles Gute zum ${bdayYear}. Geburtstag, ${_esc(bdayDog.name)}!`
|
? `Alles Gute zum ${bdayYear}. Geburtstag, ${_esc(dog.name)}!`
|
||||||
: `Morgen hat ${_esc(bdayDog.name)} Geburtstag!`}
|
: `Morgen hat ${_esc(dog.name)} Geburtstag!`}
|
||||||
</div>
|
</div>
|
||||||
<div style="display:flex;gap:8px;align-items:center">
|
<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>
|
<svg class="ph-icon bday-fw3" style="width:1rem;height:1rem;color:#e8c96e"><use href="/icons/phosphor.svg#sparkle"></use></svg>
|
||||||
|
|
@ -1534,7 +1443,7 @@ window.Worlds = (() => {
|
||||||
</div>` : ''}
|
</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">
|
<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>
|
<svg class="ph-icon" style="width:11px;height:11px"><use href="/icons/phosphor.svg#magic-wand"></use></svg>
|
||||||
${bday === 'today' ? `Was hat sich ${_esc(bdayDog.name)} gewünscht?` : 'KI-Überraschungsideen'}
|
${bday === 'today' ? 'Was hat sich Ban Yaro gewünscht?' : 'KI-Überraschungsideen'}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
${bday === 'today' && new Date().getHours() >= 18 ? `
|
${bday === 'today' && new Date().getHours() >= 18 ? `
|
||||||
|
|
@ -1563,18 +1472,17 @@ window.Worlds = (() => {
|
||||||
Hintergrund-Foto hinzufügen
|
Hintergrund-Foto hinzufügen
|
||||||
</div>
|
</div>
|
||||||
<div style="font-size:10px;color:rgba(255,255,255,0.45);margin-top:1px">
|
<div style="font-size:10px;color:rgba(255,255,255,0.45);margin-top:1px">
|
||||||
Tagebuchfotos im Querformat erscheinen hier als Panorama
|
Tagebuchfotos erscheinen hier als Panorama
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
` : ''}
|
` : ''}
|
||||||
|
<div class="world-section-label">Alles über ${_esc(dog.name)}</div>
|
||||||
<div class="world-chips-grid">
|
<div class="world-chips-grid">
|
||||||
${chips.map(c => _chip(c.icon, c.label, c.page, false, false, false)).join('')}
|
${chips.map(c => _chip(c.icon, c.label, c.page, false, false, false)).join('')}
|
||||||
</div>
|
</div>
|
||||||
<div class="world-footer-links">
|
<div class="world-footer-links">
|
||||||
<span data-wnav="gruender">Die 100 Gründer</span>
|
<span data-wnav="gruender">Die 100 Gründer</span>
|
||||||
<span>·</span>
|
|
||||||
<span data-wnav="partner">Unsere Partner</span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
|
@ -1601,19 +1509,6 @@ window.Worlds = (() => {
|
||||||
if (!isNaN(idx) && idx !== _dogIdx) { _dogIdx = idx; _renderHund(); }
|
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
|
// Geburtstags-Banner → KI
|
||||||
el.querySelector('#wh-bday-banner')?.addEventListener('click', () => _openBdayKI(dog, bday));
|
el.querySelector('#wh-bday-banner')?.addEventListener('click', () => _openBdayKI(dog, bday));
|
||||||
|
|
@ -1668,10 +1563,10 @@ window.Worlds = (() => {
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await API.post('/ki/geburtstag', {
|
const res = await API.post('/ki/geburtstag', {
|
||||||
dog_id: bdayDog.id,
|
dog_id: dog.id,
|
||||||
name: bdayDog.name,
|
name: dog.name,
|
||||||
rasse: bdayDog.rasse || null,
|
rasse: dog.rasse || null,
|
||||||
alter: bdayDog.alter_jahre ? Math.round(bdayDog.alter_jahre) : null,
|
alter: dog.alter_jahre ? Math.round(dog.alter_jahre) : null,
|
||||||
mode: bdayMode,
|
mode: bdayMode,
|
||||||
});
|
});
|
||||||
const body = ov.querySelector('#bday-ki-body');
|
const body = ov.querySelector('#bday-ki-body');
|
||||||
|
|
@ -1756,13 +1651,12 @@ window.Worlds = (() => {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="world-bottom">
|
<div class="world-bottom">
|
||||||
|
<div class="world-section-label">Die Welt da draußen</div>
|
||||||
<div class="world-chips-grid">
|
<div class="world-chips-grid">
|
||||||
${chips.map(c => _chip(c.icon, c.label, c.page, false, false, false)).join('')}
|
${chips.map(c => _chip(c.icon, c.label, c.page, false, false, false)).join('')}
|
||||||
</div>
|
</div>
|
||||||
<div class="world-footer-links">
|
<div class="world-footer-links">
|
||||||
<span data-wnav="datenschutz">Datenschutz</span>
|
<span data-wnav="datenschutz">Datenschutz</span>
|
||||||
<span style="color:var(--c-border)">·</span>
|
|
||||||
<span data-wnav="agb">AGB</span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
|
@ -1794,7 +1688,7 @@ window.Worlds = (() => {
|
||||||
const pos = await API.getLocation({ timeout: 4000, maximumAge: 600_000 });
|
const pos = await API.getLocation({ timeout: 4000, maximumAge: 600_000 });
|
||||||
const [p, l] = await Promise.allSettled([
|
const [p, l] = await Promise.allSettled([
|
||||||
API.get(`/poison/nearby?lat=${pos.lat}&lon=${pos.lon}&radius=5`).catch(() => []),
|
API.get(`/poison/nearby?lat=${pos.lat}&lon=${pos.lon}&radius=5`).catch(() => []),
|
||||||
API.get(`/lost/nearby?lat=${pos.lat}&lon=${pos.lon}&radius=20`).catch(() => []),
|
API.get(`/lost/nearby?lat=${pos.lat}&lon=${pos.lon}&radius=5`).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 (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' });
|
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' });
|
||||||
|
|
@ -1808,26 +1702,6 @@ window.Worlds = (() => {
|
||||||
`).join('');
|
`).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) {
|
function _fmtDate(d) {
|
||||||
if (!d) return '';
|
if (!d) return '';
|
||||||
try { return new Date(d).toLocaleDateString('de-DE', { day:'numeric', month:'short' }); }
|
try { return new Date(d).toLocaleDateString('de-DE', { day:'numeric', month:'short' }); }
|
||||||
|
|
|
||||||
|
|
@ -741,7 +741,13 @@
|
||||||
<span class="badge">Made in Germany</span>
|
<span class="badge">Made in Germany</span>
|
||||||
<span class="badge">Offline-fähig</span>
|
<span class="badge">Offline-fähig</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="hero-stats" id="hero-stats" style="display:none"></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>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
|
@ -818,14 +824,6 @@
|
||||||
<div class="stats-band-num" id="big-posts">…</div>
|
<div class="stats-band-num" id="big-posts">…</div>
|
||||||
<div class="stats-band-label">Forum-Beiträge</div>
|
<div class="stats-band-label">Forum-Beiträge</div>
|
||||||
</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>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
@ -1115,77 +1113,6 @@
|
||||||
</div>
|
</div>
|
||||||
</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 & 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>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
|
@ -1600,7 +1527,6 @@
|
||||||
<div class="footer-links">
|
<div class="footer-links">
|
||||||
<a href="/#impressum">Impressum</a>
|
<a href="/#impressum">Impressum</a>
|
||||||
<a href="/#datenschutz">Datenschutz</a>
|
<a href="/#datenschutz">Datenschutz</a>
|
||||||
<a href="/#agb">AGB</a>
|
|
||||||
<a href="/presse">Presse</a>
|
<a href="/presse">Presse</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -1637,36 +1563,26 @@
|
||||||
fetch('/api/stats/public')
|
fetch('/api/stats/public')
|
||||||
.then(function(r) { return r.json(); })
|
.then(function(r) { return r.json(); })
|
||||||
.then(function(d) {
|
.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) {
|
function set(id, val) {
|
||||||
var el = document.getElementById(id);
|
var el = document.getElementById(id);
|
||||||
if (el) el.textContent = fmt.format(val);
|
if (el) el.textContent = fmt.format(val);
|
||||||
}
|
}
|
||||||
// Stats-Band (weiter unten)
|
set('stat-users', d.users);
|
||||||
|
set('stat-dogs', d.dogs);
|
||||||
|
set('stat-km', d.km);
|
||||||
set('big-users', d.users);
|
set('big-users', d.users);
|
||||||
set('big-dogs', d.dogs);
|
set('big-dogs', d.dogs);
|
||||||
set('big-km', d.km);
|
set('big-km', d.km);
|
||||||
set('big-posts', d.forum_posts);
|
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');
|
var heroStats = document.getElementById('hero-stats');
|
||||||
if (!heroStats || !d.users) return;
|
if (heroStats && d.users > 0) heroStats.style.display = 'flex';
|
||||||
|
|
||||||
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() {});
|
.catch(function() {});
|
||||||
</script>
|
</script>
|
||||||
|
|
|
||||||
|
|
@ -3,35 +3,20 @@
|
||||||
Offline-Cache + Push Notifications + Tile-Cache
|
Offline-Cache + Push Notifications + Tile-Cache
|
||||||
============================================================ */
|
============================================================ */
|
||||||
|
|
||||||
// ← EINZIGE Stelle für die Version — STATIC_ASSETS und CACHE_VERSION leiten sich ab
|
const CACHE_VERSION = 'by-v961';
|
||||||
const VER = '1070';
|
|
||||||
const CACHE_VERSION = `by-v${VER}`;
|
|
||||||
const CACHE_STATIC = `${CACHE_VERSION}-static`;
|
const CACHE_STATIC = `${CACHE_VERSION}-static`;
|
||||||
const CACHE_TILES = 'ban-yaro-tiles-v1'; // bleibt über SW-Updates erhalten
|
const CACHE_TILES = 'ban-yaro-tiles-v1'; // bleibt über SW-Updates erhalten
|
||||||
const CACHE_API = 'ban-yaro-api-v1'; // API-Response-Cache
|
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)
|
// index.html wird NICHT pre-gecacht (immer Network-First)
|
||||||
const STATIC_ASSETS = [
|
const STATIC_ASSETS = [
|
||||||
`/css/design-system.css?v=${VER}`,
|
'/css/design-system.css?v=700',
|
||||||
`/css/layout.css?v=${VER}`,
|
'/css/layout.css?v=700',
|
||||||
`/css/components.css?v=${VER}`,
|
'/css/components.css?v=700',
|
||||||
'/icons/phosphor.svg',
|
'/icons/phosphor.svg',
|
||||||
`/js/api.js?v=${VER}`,
|
'/js/api.js',
|
||||||
`/js/ui.js?v=${VER}`,
|
'/js/ui.js',
|
||||||
`/js/app.js?v=${VER}`,
|
'/js/app.js',
|
||||||
`/js/worlds.js?v=${VER}`,
|
|
||||||
'/js/leaflet.markercluster.js',
|
'/js/leaflet.markercluster.js',
|
||||||
'/css/MarkerCluster.css',
|
'/css/MarkerCluster.css',
|
||||||
'/css/MarkerCluster.Default.css',
|
'/css/MarkerCluster.Default.css',
|
||||||
|
|
@ -122,8 +107,6 @@ const _QUEUEABLE = [
|
||||||
{ re: /^\/api\/training\/sessions$/, methods: ['POST'] },
|
{ re: /^\/api\/training\/sessions$/, methods: ['POST'] },
|
||||||
{ re: /^\/api\/training\/progress$/, methods: ['POST'] },
|
{ re: /^\/api\/training\/progress$/, methods: ['POST'] },
|
||||||
{ re: /^\/api\/poison$/, methods: ['POST'] },
|
{ re: /^\/api\/poison$/, methods: ['POST'] },
|
||||||
{ re: /^\/api\/lost\/report$/, methods: ['POST'] },
|
|
||||||
{ re: /^\/api\/walks$/, methods: ['POST'] },
|
|
||||||
];
|
];
|
||||||
function _isQueueable(pathname, method) {
|
function _isQueueable(pathname, method) {
|
||||||
return _QUEUEABLE.some(q => q.methods.includes(method) && q.re.test(pathname));
|
return _QUEUEABLE.some(q => q.methods.includes(method) && q.re.test(pathname));
|
||||||
|
|
@ -143,14 +126,6 @@ const _CACHEABLE_GET = [
|
||||||
/^\/api\/training\/plan-progress/,
|
/^\/api\/training\/plan-progress/,
|
||||||
/^\/api\/wiki\/rassen/,
|
/^\/api\/wiki\/rassen/,
|
||||||
/^\/api\/dogs\/\d+\/diary\/stats/,
|
/^\/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
|
// Drei Welten — offline-fähig
|
||||||
/^\/api\/streak\/\d+/,
|
/^\/api\/streak\/\d+/,
|
||||||
/^\/api\/forum\/threads/,
|
/^\/api\/forum\/threads/,
|
||||||
|
|
@ -187,22 +162,13 @@ function _cacheMark(pathname) {
|
||||||
// INSTALL — App Shell cachen
|
// INSTALL — App Shell cachen
|
||||||
// ----------------------------------------------------------
|
// ----------------------------------------------------------
|
||||||
self.addEventListener('install', event => {
|
self.addEventListener('install', event => {
|
||||||
self.skipWaiting();
|
self.skipWaiting(); // Sofort übernehmen — kein Warten auf Cache-Aufbau
|
||||||
event.waitUntil(
|
event.waitUntil(
|
||||||
caches.open(CACHE_STATIC)
|
caches.open(CACHE_STATIC)
|
||||||
.then(cache => cache.addAll(STATIC_ASSETS))
|
.then(cache => cache.addAll(STATIC_ASSETS))
|
||||||
.then(() => {
|
.then(() => caches.open(CACHE_API).then(c =>
|
||||||
// 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(() => {})
|
fetch('/api/training/exercises').then(r => { if (r.ok) c.put('/api/training/exercises', r); }).catch(() => {})
|
||||||
);
|
))
|
||||||
})
|
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -331,7 +297,7 @@ self.addEventListener('fetch', event => {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// CSS, Core-JS + Seiten-Module: Network-First mit ignoreSearch-Fallback für Offline
|
// CSS, Core-JS + Seiten-Module: immer Network-First — damit iOS nie veraltete Versionen cached
|
||||||
if (url.pathname.startsWith('/css/') || url.pathname.startsWith('/js/pages/')
|
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/app.js') || url.pathname.startsWith('/js/ui.js')
|
||||||
|| url.pathname.startsWith('/js/api.js') || url.pathname.startsWith('/js/worlds.js')) {
|
|| url.pathname.startsWith('/js/api.js') || url.pathname.startsWith('/js/worlds.js')) {
|
||||||
|
|
@ -344,7 +310,7 @@ self.addEventListener('fetch', event => {
|
||||||
}
|
}
|
||||||
return response;
|
return response;
|
||||||
})
|
})
|
||||||
.catch(() => caches.match(event.request, { ignoreSearch: true })
|
.catch(() => caches.match(event.request)
|
||||||
.then(cached => cached || new Response('', { status: 503 })))
|
.then(cached => cached || new Response('', { status: 503 })))
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
|
|
|
||||||
|
|
@ -592,7 +592,6 @@
|
||||||
<div class="footer-links">
|
<div class="footer-links">
|
||||||
<a href="/#impressum">Impressum</a>
|
<a href="/#impressum">Impressum</a>
|
||||||
<a href="/#datenschutz">Datenschutz</a>
|
<a href="/#datenschutz">Datenschutz</a>
|
||||||
<a href="/#agb">AGB</a>
|
|
||||||
<a href="/info">Über Ban Yaro</a>
|
<a href="/info">Über Ban Yaro</a>
|
||||||
<a href="/presse">Presse</a>
|
<a href="/presse">Presse</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -239,9 +239,7 @@ def _wind_dir(deg: float) -> str:
|
||||||
|
|
||||||
|
|
||||||
def _asphalt_temp(air_max: float, uv_max: float) -> tuple[float, str]:
|
def _asphalt_temp(air_max: float, uv_max: float) -> tuple[float, str]:
|
||||||
# UV-Bonus skaliert mit Temperatur: unter 10°C kaum Aufheizung, ab 30°C voll
|
bonus = min(uv_max * 3.0, 30.0)
|
||||||
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
|
asphalt = air_max + bonus
|
||||||
if asphalt <= 30:
|
if asphalt <= 30:
|
||||||
warn = 'safe'
|
warn = 'safe'
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,6 @@ services:
|
||||||
volumes:
|
volumes:
|
||||||
- ./data:/data
|
- ./data:/data
|
||||||
- /volume1/docker/banyaro/data/media:/prod-media:ro
|
- /volume1/docker/banyaro/data/media:/prod-media:ro
|
||||||
- /volume1/scaninput:/scaninput
|
|
||||||
env_file:
|
env_file:
|
||||||
- .env
|
- .env
|
||||||
environment:
|
environment:
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,6 @@ services:
|
||||||
- "3010:8000" # DS-intern, NPM leitet banyaro.app weiter
|
- "3010:8000" # DS-intern, NPM leitet banyaro.app weiter
|
||||||
volumes:
|
volumes:
|
||||||
- ./data:/data # SQLite + Media persistent
|
- ./data:/data # SQLite + Media persistent
|
||||||
- /volume1/scaninput:/scaninput
|
|
||||||
env_file:
|
env_file:
|
||||||
- .env
|
- .env
|
||||||
environment:
|
environment:
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue