Feat: Cashflow auf paid_amount, Differenz-Badge, Kulanz-Abschreibung im Bezahlt-Modal (SW by-v984)

This commit is contained in:
rene 2026-05-15 16:06:08 +02:00
parent 0f6b5afd6a
commit e714580d77
5 changed files with 70 additions and 14 deletions

View file

@ -408,7 +408,7 @@ async def serve_media(path: str, request: _Request):
raise _HE(404, "Nicht gefunden.")
return _media_response(filepath)
APP_VER = "983" # muss mit APP_VER in app.js übereinstimmen
APP_VER = "984" # muss mit APP_VER in app.js übereinstimmen
@app.get("/.well-known/assetlinks.json")
async def assetlinks():

View file

@ -38,6 +38,7 @@ class InvoiceCreate(BaseModel):
class PayBody(BaseModel):
paid_at: str
paid_amount: float
notes: Optional[str] = None
class CancelBody(BaseModel):
@ -373,7 +374,9 @@ 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,
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')
@ -383,7 +386,10 @@ def get_cashflow(admin=Depends(require_admin)):
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 ?",
"""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]
@ -705,10 +711,16 @@ def pay_invoice(invoice_id: int, data: PayBody, admin=Depends(require_admin)):
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)
)
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)

View file

@ -3,7 +3,7 @@
Router, State-Management, Navigation, Initialisierung.
============================================================ */
const APP_VER = '983'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen
const APP_VER = '984'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen
const APP_VERSION = '1.6.0'; // ← semantische Version, wird bei make release gesetzt
const IS_STAGING = location.hostname === 'staging.banyaro.app';
// Cache-Bust-Parameter nach Update-Reload sofort entfernen

View file

@ -3797,6 +3797,14 @@ window.Page_admin = (() => {
</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">
@ -4091,10 +4099,13 @@ window.Page_admin = (() => {
<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"
<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: `
@ -4103,18 +4114,51 @@ window.Page_admin = (() => {
`,
});
// 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 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: parseFloat(fd.get('paid_amount')),
paid_amount: paidAmount,
...(kulanzNote ? { notes: kulanzNote } : {}),
});
UI.modal.close();
UI.toast.success('Rechnung als bezahlt markiert.');
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.');

View file

@ -3,7 +3,7 @@
Offline-Cache + Push Notifications + Tile-Cache
============================================================ */
const CACHE_VERSION = 'by-v983';
const CACHE_VERSION = 'by-v984';
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