Compare commits
No commits in common. "fa513be7f53e4c4849f7c164a2a2ec7d4a7cb154" and "c032b9a3fbb25a4c81e0578688d5fabf735e89bd" have entirely different histories.
fa513be7f5
...
c032b9a3fb
9 changed files with 15 additions and 1493 deletions
|
|
@ -2398,52 +2398,6 @@ def _migrate(conn_factory):
|
|||
except Exception as e:
|
||||
logger.warning(f"Migration route_dogs fehlgeschlagen: {e}")
|
||||
|
||||
# Rechnungs-System
|
||||
try:
|
||||
conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS invoices (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
invoice_number TEXT NOT NULL UNIQUE,
|
||||
user_id INTEGER REFERENCES users(id),
|
||||
recipient_name TEXT NOT NULL,
|
||||
recipient_email TEXT NOT NULL,
|
||||
recipient_address TEXT,
|
||||
description TEXT NOT NULL,
|
||||
service_period TEXT,
|
||||
amount_net REAL NOT NULL,
|
||||
discount_pct REAL DEFAULT 0,
|
||||
discount_amount REAL DEFAULT 0,
|
||||
amount_after_discount REAL NOT NULL,
|
||||
tax_rate REAL DEFAULT 0,
|
||||
tax_amount REAL DEFAULT 0,
|
||||
amount_gross REAL NOT NULL,
|
||||
status TEXT DEFAULT 'draft',
|
||||
notes TEXT,
|
||||
created_at TEXT DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
|
||||
sent_at TEXT,
|
||||
paid_at TEXT,
|
||||
paid_amount REAL,
|
||||
cancelled_at TEXT,
|
||||
cancellation_reason TEXT,
|
||||
cancellation_number TEXT
|
||||
)
|
||||
""")
|
||||
conn.execute("CREATE INDEX IF NOT EXISTS idx_invoices_status ON invoices(status)")
|
||||
conn.execute("CREATE INDEX IF NOT EXISTS idx_invoices_user ON invoices(user_id)")
|
||||
conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS invoice_items (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
invoice_id INTEGER NOT NULL REFERENCES invoices(id) ON DELETE CASCADE,
|
||||
description TEXT NOT NULL,
|
||||
quantity REAL NOT NULL DEFAULT 1,
|
||||
unit_price REAL NOT NULL,
|
||||
total REAL NOT NULL
|
||||
)
|
||||
""")
|
||||
logger.info("Migration: invoices + invoice_items bereit.")
|
||||
except Exception as e:
|
||||
logger.warning(f"Migration invoices: {e}")
|
||||
|
||||
|
||||
def _seed_help_articles(conn):
|
||||
"""Befüllt help_articles mit Starter-FAQs — nur wenn die Tabelle noch leer ist."""
|
||||
|
|
|
|||
|
|
@ -6,13 +6,11 @@ Unterstützt zwei Backends (wird automatisch gewählt):
|
|||
"""
|
||||
|
||||
import os
|
||||
import base64
|
||||
import smtplib
|
||||
import asyncio
|
||||
import logging
|
||||
from email.mime.text import MIMEText
|
||||
from email.mime.multipart import MIMEMultipart
|
||||
from email.mime.application import MIMEApplication
|
||||
|
||||
import httpx
|
||||
|
||||
|
|
@ -35,7 +33,9 @@ APP_URL = os.getenv("APP_URL", "https://banyaro.app")
|
|||
# ------------------------------------------------------------------
|
||||
# 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
|
||||
if "<" in from_raw:
|
||||
from_name = from_raw[:from_raw.index("<")].strip()
|
||||
|
|
@ -52,14 +52,6 @@ async def _send_brevo(to: str, subject: str, html: str, plain: str, attachments:
|
|||
"textContent": plain,
|
||||
"headers": {"X-Mailin-Track-Click": "0", "X-Mailin-Track-Opens": "0"},
|
||||
}
|
||||
if attachments:
|
||||
payload["attachment"] = [
|
||||
{
|
||||
"name": a["filename"],
|
||||
"content": base64.b64encode(a["content"]).decode("ascii"),
|
||||
}
|
||||
for a in attachments
|
||||
]
|
||||
async with httpx.AsyncClient(timeout=15) as client:
|
||||
resp = await client.post(
|
||||
BREVO_API_URL,
|
||||
|
|
@ -72,25 +64,13 @@ async def _send_brevo(to: str, subject: str, html: str, plain: str, attachments:
|
|||
# ------------------------------------------------------------------
|
||||
# SMTP Fallback
|
||||
# ------------------------------------------------------------------
|
||||
def _send_smtp_sync(to: str, subject: str, html: str, plain: str, attachments: list | None = None):
|
||||
if attachments:
|
||||
msg = MIMEMultipart("mixed")
|
||||
alt = MIMEMultipart("alternative")
|
||||
alt.attach(MIMEText(plain, "plain", "utf-8"))
|
||||
alt.attach(MIMEText(html, "html", "utf-8"))
|
||||
msg.attach(alt)
|
||||
for a in attachments:
|
||||
part = MIMEApplication(a["content"], Name=a["filename"])
|
||||
part["Content-Disposition"] = f'attachment; filename="{a["filename"]}"'
|
||||
msg.attach(part)
|
||||
else:
|
||||
msg = MIMEMultipart("alternative")
|
||||
msg.attach(MIMEText(plain, "plain", "utf-8"))
|
||||
msg.attach(MIMEText(html, "html", "utf-8"))
|
||||
|
||||
def _send_smtp_sync(to: str, subject: str, html: str, plain: str):
|
||||
msg = MIMEMultipart("alternative")
|
||||
msg["Subject"] = subject
|
||||
msg["From"] = SMTP_FROM
|
||||
msg["To"] = to
|
||||
msg.attach(MIMEText(plain, "plain", "utf-8"))
|
||||
msg.attach(MIMEText(html, "html", "utf-8"))
|
||||
|
||||
with smtplib.SMTP(SMTP_HOST, SMTP_PORT, timeout=10) as s:
|
||||
s.ehlo()
|
||||
|
|
@ -103,10 +83,10 @@ def _send_smtp_sync(to: str, subject: str, html: str, plain: str, attachments: l
|
|||
# ------------------------------------------------------------------
|
||||
# Ö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:
|
||||
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}")
|
||||
return
|
||||
except Exception as e:
|
||||
|
|
@ -116,9 +96,7 @@ async def send_email(to: str, subject: str, html: str, plain: str = "", attachme
|
|||
if SMTP_HOST:
|
||||
loop = asyncio.get_event_loop()
|
||||
try:
|
||||
await loop.run_in_executor(
|
||||
None, _send_smtp_sync, to, subject, html, plain, attachments
|
||||
)
|
||||
await loop.run_in_executor(None, _send_smtp_sync, to, subject, html, plain)
|
||||
logger.info(f"Mail via SMTP gesendet: «{subject}» → {to}")
|
||||
return
|
||||
except Exception as e:
|
||||
|
|
|
|||
|
|
@ -253,7 +253,6 @@ from routes.challenges import router as challenges_router
|
|||
from routes.gassi_zeiten import router as gassi_zeiten_router
|
||||
from routes.help import router as help_router
|
||||
from routes.feedback import router as feedback_router
|
||||
from routes.invoices import router as invoices_router
|
||||
|
||||
app.include_router(auth_router, prefix="/api/auth", tags=["Auth"])
|
||||
app.include_router(dogs_router, prefix="/api/dogs", tags=["Hunde"])
|
||||
|
|
@ -318,7 +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(help_router, prefix="/api/help", tags=["Hilfe/FAQ"])
|
||||
app.include_router(feedback_router, prefix="/api/feedback", tags=["Feedback"])
|
||||
app.include_router(invoices_router)
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
|
|
@ -408,7 +406,7 @@ async def serve_media(path: str, request: _Request):
|
|||
raise _HE(404, "Nicht gefunden.")
|
||||
return _media_response(filepath)
|
||||
|
||||
APP_VER = "966" # muss mit APP_VER in app.js übereinstimmen
|
||||
APP_VER = "965" # muss mit APP_VER in app.js übereinstimmen
|
||||
|
||||
@app.get("/.well-known/assetlinks.json")
|
||||
async def assetlinks():
|
||||
|
|
|
|||
|
|
@ -1,8 +1,5 @@
|
|||
"""BAN YARO — Admin / Moderator Backend"""
|
||||
import asyncio
|
||||
import csv
|
||||
import io
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
|
|
@ -11,14 +8,11 @@ from datetime import datetime
|
|||
from zoneinfo import ZoneInfo
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from fastapi.responses import Response
|
||||
from pydantic import BaseModel
|
||||
from typing import Optional, List
|
||||
from typing import Optional
|
||||
from database import db, DB_PATH
|
||||
from auth import get_current_user
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
_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"}
|
||||
|
||||
class QuarterlyReportBody(BaseModel):
|
||||
year: int
|
||||
quarter: int
|
||||
email: str
|
||||
|
||||
class UserPatch(BaseModel):
|
||||
rolle: Optional[str] = None # user | moderator | admin
|
||||
is_moderator: Optional[int] = None
|
||||
|
|
@ -141,12 +130,6 @@ async def action_items(user=Depends(require_mod)):
|
|||
).fetchone()[0]
|
||||
except Exception:
|
||||
upgrades_pending = 0
|
||||
try:
|
||||
invoices_unpaid = conn.execute(
|
||||
"SELECT COUNT(*) FROM invoices WHERE status='sent'"
|
||||
).fetchone()[0]
|
||||
except Exception:
|
||||
invoices_unpaid = 0
|
||||
return {
|
||||
"jobs_pending": jobs,
|
||||
"breeder_pending": breeders,
|
||||
|
|
@ -155,7 +138,6 @@ async def action_items(user=Depends(require_mod)):
|
|||
"poi_edits_pending": poi_edits,
|
||||
"users_today": users_today,
|
||||
"upgrades_pending": upgrades_pending,
|
||||
"invoices_unpaid": invoices_unpaid,
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -1260,189 +1242,3 @@ async def fulfill_upgrade_request(req_id: int, user=Depends(require_admin)):
|
|||
logging.getLogger(__name__).warning(f"Bestätigungsmail fehlgeschlagen: {e}")
|
||||
|
||||
return {"ok": True, "tier": req["tier"], "user": req["name"]}
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 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),
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,489 +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
|
||||
|
||||
|
||||
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:
|
||||
KLEINUNTERNEHMER = os.getenv("KLEINUNTERNEHMER", "true").lower() == "true"
|
||||
STEUERNUMMER = os.getenv("STEUERNUMMER", "")
|
||||
INHABER = os.getenv("RECHNUNG_INHABER", "René Degelmann")
|
||||
GESCHAEFTSNAME = os.getenv("RECHNUNG_GESCHAEFTSNAME", "Ban Yaro")
|
||||
STRASSE = os.getenv("RECHNUNG_STRASSE", "")
|
||||
PLZ_ORT = os.getenv("RECHNUNG_PLZ_ORT", "")
|
||||
EMAIL_ABSENDER = os.getenv("RECHNUNG_EMAIL", "hallo@banyaro.app")
|
||||
BANK_IBAN = os.getenv("RECHNUNG_IBAN", "")
|
||||
BANK_BIC = os.getenv("RECHNUNG_BIC", "")
|
||||
BANK_BANK = os.getenv("RECHNUNG_BANK", "")
|
||||
|
||||
from fpdf import FPDF
|
||||
pdf = FPDF()
|
||||
pdf.add_page()
|
||||
pdf.set_margins(20, 20, 20)
|
||||
pdf.set_auto_page_break(auto=True, margin=20)
|
||||
|
||||
pdf.set_font("Helvetica", "B", 18)
|
||||
pdf.cell(0, 10, GESCHAEFTSNAME, new_x="LMARGIN", new_y="NEXT")
|
||||
pdf.set_font("Helvetica", "", 10)
|
||||
pdf.cell(0, 5, INHABER, new_x="LMARGIN", new_y="NEXT")
|
||||
if STRASSE:
|
||||
pdf.cell(0, 5, STRASSE, new_x="LMARGIN", new_y="NEXT")
|
||||
if PLZ_ORT:
|
||||
pdf.cell(0, 5, PLZ_ORT, new_x="LMARGIN", new_y="NEXT")
|
||||
pdf.cell(0, 5, EMAIL_ABSENDER, new_x="LMARGIN", new_y="NEXT")
|
||||
pdf.ln(10)
|
||||
|
||||
pdf.set_font("Helvetica", "B", 10)
|
||||
pdf.cell(0, 5, "Rechnungsempfänger:", new_x="LMARGIN", new_y="NEXT")
|
||||
pdf.set_font("Helvetica", "", 10)
|
||||
pdf.cell(0, 5, invoice["recipient_name"], new_x="LMARGIN", new_y="NEXT")
|
||||
if invoice["recipient_address"]:
|
||||
for line in invoice["recipient_address"].split("\n"):
|
||||
pdf.cell(0, 5, line, new_x="LMARGIN", new_y="NEXT")
|
||||
pdf.cell(0, 5, invoice["recipient_email"], new_x="LMARGIN", new_y="NEXT")
|
||||
pdf.ln(8)
|
||||
|
||||
pdf.set_font("Helvetica", "B", 10)
|
||||
pdf.cell(0, 5, f"Rechnungsnummer: {invoice['invoice_number']}", new_x="LMARGIN", new_y="NEXT")
|
||||
pdf.set_font("Helvetica", "", 10)
|
||||
rg_date = invoice["created_at"][:10] if invoice["created_at"] else ""
|
||||
pdf.cell(0, 5, f"Rechnungsdatum: {rg_date}", new_x="LMARGIN", new_y="NEXT")
|
||||
if invoice["service_period"]:
|
||||
pdf.cell(0, 5, f"Leistungszeitraum: {invoice['service_period']}", new_x="LMARGIN", new_y="NEXT")
|
||||
if STEUERNUMMER:
|
||||
pdf.cell(0, 5, f"Steuernummer: {STEUERNUMMER}", new_x="LMARGIN", new_y="NEXT")
|
||||
pdf.ln(8)
|
||||
|
||||
pdf.set_font("Helvetica", "B", 14)
|
||||
pdf.cell(0, 10, "RECHNUNG", new_x="LMARGIN", new_y="NEXT")
|
||||
pdf.ln(4)
|
||||
|
||||
pdf.set_fill_color(240, 240, 240)
|
||||
pdf.set_font("Helvetica", "B", 9)
|
||||
pdf.cell(90, 7, "Beschreibung", border=1, fill=True)
|
||||
pdf.cell(20, 7, "Menge", border=1, fill=True, align="C")
|
||||
pdf.cell(35, 7, "Einzelpreis", border=1, fill=True, align="R")
|
||||
pdf.cell(35, 7, "Gesamt", border=1, fill=True, align="R", new_x="LMARGIN", new_y="NEXT")
|
||||
|
||||
pdf.set_font("Helvetica", "", 9)
|
||||
for item in items:
|
||||
qty_str = f"{item['quantity']:.2f}".rstrip("0").rstrip(".")
|
||||
pdf.cell(90, 7, str(item["description"])[:60], border=1)
|
||||
pdf.cell(20, 7, qty_str, border=1, align="C")
|
||||
pdf.cell(35, 7, f"{item['unit_price']:.2f} EUR", border=1, align="R")
|
||||
pdf.cell(35, 7, f"{item['total']:.2f} EUR", border=1, align="R", new_x="LMARGIN", new_y="NEXT")
|
||||
|
||||
pdf.ln(4)
|
||||
|
||||
def right_row(label, value, bold=False):
|
||||
pdf.set_font("Helvetica", "B" if bold else "", 10)
|
||||
pdf.cell(120, 6, "")
|
||||
pdf.cell(40, 6, label, align="R")
|
||||
pdf.cell(10, 6, "")
|
||||
pdf.cell(0, 6, value, align="R", new_x="LMARGIN", new_y="NEXT")
|
||||
|
||||
right_row("Nettobetrag:", f"{invoice['amount_net']:.2f} EUR")
|
||||
if invoice["discount_pct"] and invoice["discount_pct"] > 0:
|
||||
right_row(f"Rabatt ({invoice['discount_pct']:.0f}%):", f"-{invoice['discount_amount']:.2f} EUR")
|
||||
right_row("Nach Rabatt:", f"{invoice['amount_after_discount']:.2f} EUR")
|
||||
|
||||
if not KLEINUNTERNEHMER:
|
||||
right_row(f"MwSt. {invoice['tax_rate']:.0f}%:", f"{invoice['tax_amount']:.2f} EUR")
|
||||
|
||||
pdf.ln(2)
|
||||
pdf.set_draw_color(0, 0, 0)
|
||||
right_row("Gesamtbetrag:", f"{invoice['amount_gross']:.2f} EUR", bold=True)
|
||||
|
||||
pdf.ln(8)
|
||||
|
||||
if KLEINUNTERNEHMER:
|
||||
pdf.set_font("Helvetica", "I", 9)
|
||||
pdf.multi_cell(0, 5, "Gemäß § 19 UStG wird keine Umsatzsteuer berechnet.")
|
||||
pdf.ln(4)
|
||||
|
||||
if BANK_IBAN:
|
||||
pdf.set_font("Helvetica", "", 9)
|
||||
pdf.cell(0, 5, "Bitte überweisen Sie den Betrag innerhalb von 14 Tagen auf:", new_x="LMARGIN", new_y="NEXT")
|
||||
pdf.cell(0, 5, f"IBAN: {BANK_IBAN}", new_x="LMARGIN", new_y="NEXT")
|
||||
if BANK_BIC:
|
||||
pdf.cell(0, 5, f"BIC: {BANK_BIC}", new_x="LMARGIN", new_y="NEXT")
|
||||
if BANK_BANK:
|
||||
pdf.cell(0, 5, f"Bank: {BANK_BANK}", new_x="LMARGIN", new_y="NEXT")
|
||||
pdf.cell(0, 5, f"Verwendungszweck: {invoice['invoice_number']}", new_x="LMARGIN", new_y="NEXT")
|
||||
|
||||
if invoice["notes"]:
|
||||
pdf.ln(4)
|
||||
pdf.set_font("Helvetica", "I", 9)
|
||||
pdf.multi_cell(0, 5, invoice["notes"])
|
||||
|
||||
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)
|
||||
|
||||
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(amount_gross) 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(amount_gross),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:
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM invoices WHERE created_at >= ? AND created_at <= ? ORDER BY id",
|
||||
(from_date, to_date + "T23:59:59Z")
|
||||
).fetchall()
|
||||
|
||||
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)
|
||||
|
||||
return {
|
||||
"period": period,
|
||||
"invoices": [_row_to_dict(r) for r in rows],
|
||||
"total_net": round(total_net, 2),
|
||||
"total_tax": round(total_tax, 2),
|
||||
"total_gross": round(total_gross, 2),
|
||||
"count": len(rows),
|
||||
}
|
||||
|
||||
|
||||
@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.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.")
|
||||
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.")
|
||||
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")
|
||||
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()
|
||||
return _row_to_dict(row)
|
||||
|
|
@ -3,8 +3,8 @@
|
|||
Router, State-Management, Navigation, Initialisierung.
|
||||
============================================================ */
|
||||
|
||||
const APP_VER = '966'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen
|
||||
const APP_VERSION = '1.6.0'; // ← semantische Version, wird bei make release gesetzt
|
||||
const APP_VER = '965'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen
|
||||
const APP_VERSION = '1.5.1'; // ← semantische Version, wird bei make release gesetzt
|
||||
const IS_STAGING = location.hostname === 'staging.banyaro.app';
|
||||
// Cache-Bust-Parameter nach Update-Reload sofort entfernen
|
||||
if (location.search.includes('_t=')) history.replaceState(null, '', '/');
|
||||
|
|
|
|||
|
|
@ -27,7 +27,6 @@ window.Page_admin = (() => {
|
|||
{ id: 'uebungen_admin', label: 'Übungen', icon: 'barbell' },
|
||||
{ id: 'referrals', label: 'Referrals', icon: 'share-network' },
|
||||
{ id: 'upgrades', label: 'Upgrades', icon: 'crown-simple' },
|
||||
{ id: 'rechnungen', label: 'Rechnungen', icon: 'receipt' },
|
||||
];
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
|
|
@ -98,7 +97,6 @@ window.Page_admin = (() => {
|
|||
{ key: 'reports_open', label: 'Meldungen', tab: 'moderation', icon: 'warning' },
|
||||
{ key: 'fotos_pending', label: 'Foto-Einreichungen',tab: 'moderation', icon: 'image' },
|
||||
{ key: 'poi_edits_pending', label: 'POI-Korrekturen', tab: 'moderation', icon: 'map-pin' },
|
||||
{ key: 'invoices_unpaid', label: 'Offene Rechnungen', tab: 'rechnungen', icon: 'receipt' },
|
||||
];
|
||||
|
||||
const open = items.filter(i => d[i.key] > 0);
|
||||
|
|
@ -168,7 +166,6 @@ window.Page_admin = (() => {
|
|||
case 'uebungen_admin': await _renderUebungenAdmin(el); break;
|
||||
case 'referrals': await _renderReferrals(el); break;
|
||||
case 'upgrades': await _renderUpgrades(el); break;
|
||||
case 'rechnungen': await _renderRechnungen(el); break;
|
||||
}
|
||||
} catch (e) {
|
||||
el.innerHTML = _emptyState('warning', 'Fehler', e.message || 'Unbekannter Fehler.');
|
||||
|
|
@ -3612,717 +3609,6 @@ window.Page_admin = (() => {
|
|||
});
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// 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-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-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)}
|
||||
</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;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// 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) {
|
||||
const id = `inv-new-${Date.now()}`;
|
||||
|
||||
UI.modal.open({
|
||||
title: `${UI.icon('receipt')} Neue Rechnung erstellen`,
|
||||
body: `
|
||||
<form id="${id}" style="display:flex;flex-direction:column;gap:var(--space-3)">
|
||||
|
||||
<!-- 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">
|
||||
</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">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="form-label" style="font-size:var(--text-xs)">Adresse <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"></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="01.01.2026 – 31.12.2026">
|
||||
</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="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"></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')} 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 hinzufügen
|
||||
_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 {
|
||||
await API.post('/admin/invoices', {
|
||||
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,
|
||||
});
|
||||
UI.modal.close();
|
||||
UI.toast.success('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)">Betrag (€) *</label>
|
||||
<input class="form-control" name="paid_amount" type="number" min="0" step="0.01"
|
||||
value="${defaultAmount.toFixed(2)}" required>
|
||||
</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>
|
||||
`,
|
||||
});
|
||||
|
||||
document.getElementById(id)?.addEventListener('submit', async e => {
|
||||
e.preventDefault();
|
||||
const fd = new FormData(e.target);
|
||||
const submitBtn = document.querySelector(`button[form="${id}"]`);
|
||||
if (submitBtn) submitBtn.disabled = true;
|
||||
try {
|
||||
await API.post(`/admin/invoices/${invoiceId}/pay`, {
|
||||
paid_at: fd.get('paid_at'),
|
||||
paid_amount: parseFloat(fd.get('paid_amount')),
|
||||
});
|
||||
UI.modal.close();
|
||||
UI.toast.success('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 header = 'Nummer;Empfänger;E-Mail;Betrag (netto);Betrag (brutto);Status;Erstellt;Versendet;Bezahlt\n';
|
||||
const csvRows = data.invoices.map(inv =>
|
||||
[inv.invoice_number, inv.recipient_name, inv.recipient_email || '',
|
||||
fmtEur(inv.amount_gross), fmtEur(inv.amount_gross), inv.status,
|
||||
fmtDate(inv.created_at), 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' };
|
||||
const rows2 = data.invoices.map((inv, i) => `
|
||||
<tr style="${i%2===1?'background:var(--c-surface-2)':''}">
|
||||
<td class="adm-td" style="font-family:monospace;font-size:var(--text-xs)">${_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">${_fmtE(inv.amount_gross)}</td>
|
||||
<td class="adm-td">${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} Rechnung(en) · Brutto: ${_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 };
|
||||
|
||||
})();
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
Offline-Cache + Push Notifications + Tile-Cache
|
||||
============================================================ */
|
||||
|
||||
const CACHE_VERSION = 'by-v966';
|
||||
const CACHE_VERSION = 'by-v965';
|
||||
const CACHE_STATIC = `${CACHE_VERSION}-static`;
|
||||
const CACHE_TILES = 'ban-yaro-tiles-v1'; // bleibt über SW-Updates erhalten
|
||||
const CACHE_API = 'ban-yaro-api-v1'; // API-Response-Cache
|
||||
|
|
|
|||
|
|
@ -7,7 +7,6 @@ services:
|
|||
- "3010:8000" # DS-intern, NPM leitet banyaro.app weiter
|
||||
volumes:
|
||||
- ./data:/data # SQLite + Media persistent
|
||||
- /volume1/scaninput:/scaninput
|
||||
env_file:
|
||||
- .env
|
||||
environment:
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue