Feat: Cashflow auf paid_amount, Differenz-Badge, Kulanz-Abschreibung im Bezahlt-Modal (SW by-v984)
This commit is contained in:
parent
0f6b5afd6a
commit
e714580d77
5 changed files with 70 additions and 14 deletions
|
|
@ -408,7 +408,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 = "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")
|
@app.get("/.well-known/assetlinks.json")
|
||||||
async def assetlinks():
|
async def assetlinks():
|
||||||
|
|
|
||||||
|
|
@ -38,6 +38,7 @@ class InvoiceCreate(BaseModel):
|
||||||
class PayBody(BaseModel):
|
class PayBody(BaseModel):
|
||||||
paid_at: str
|
paid_at: str
|
||||||
paid_amount: float
|
paid_amount: float
|
||||||
|
notes: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
class CancelBody(BaseModel):
|
class CancelBody(BaseModel):
|
||||||
|
|
@ -373,7 +374,9 @@ def get_cashflow(admin=Depends(require_admin)):
|
||||||
with db() as conn:
|
with db() as conn:
|
||||||
monthly = conn.execute("""
|
monthly = conn.execute("""
|
||||||
SELECT substr(created_at, 1, 7) AS month,
|
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
|
COUNT(*) AS count
|
||||||
FROM invoices
|
FROM invoices
|
||||||
WHERE status IN ('sent', 'paid')
|
WHERE status IN ('sent', 'paid')
|
||||||
|
|
@ -383,7 +386,10 @@ def get_cashflow(admin=Depends(require_admin)):
|
||||||
|
|
||||||
year = datetime.now().year
|
year = datetime.now().year
|
||||||
total_year = conn.execute(
|
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}%",)
|
(f"{year}%",)
|
||||||
).fetchone()[0]
|
).fetchone()[0]
|
||||||
|
|
||||||
|
|
@ -705,10 +711,16 @@ def pay_invoice(invoice_id: int, data: PayBody, admin=Depends(require_admin)):
|
||||||
raise HTTPException(404, "Rechnung nicht gefunden.")
|
raise HTTPException(404, "Rechnung nicht gefunden.")
|
||||||
if row["status"] == "cancelled":
|
if row["status"] == "cancelled":
|
||||||
raise HTTPException(400, "Stornierte Rechnung kann nicht als bezahlt markiert werden.")
|
raise HTTPException(400, "Stornierte Rechnung kann nicht als bezahlt markiert werden.")
|
||||||
conn.execute(
|
if data.notes:
|
||||||
"UPDATE invoices SET status='paid', paid_at=?, paid_amount=? WHERE id=?",
|
conn.execute(
|
||||||
(data.paid_at, data.paid_amount, invoice_id)
|
"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()
|
row = conn.execute("SELECT * FROM invoices WHERE id=?", (invoice_id,)).fetchone()
|
||||||
return _row_to_dict(row)
|
return _row_to_dict(row)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@
|
||||||
Router, State-Management, Navigation, Initialisierung.
|
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 APP_VERSION = '1.6.0'; // ← 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
|
||||||
|
|
|
||||||
|
|
@ -3797,6 +3797,14 @@ window.Page_admin = (() => {
|
||||||
</td>
|
</td>
|
||||||
<td class="adm-td" style="text-align:right;font-weight:700;white-space:nowrap">
|
<td class="adm-td" style="text-align:right;font-weight:700;white-space:nowrap">
|
||||||
${_fmtEur(inv.amount_gross)}
|
${_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>
|
||||||
<td class="adm-td">${_statusBadge(inv.status)}</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">
|
<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>
|
<input class="form-control" name="paid_at" type="date" value="${today}" required>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label class="form-label" style="font-size:var(--text-xs)">Betrag (€) *</label>
|
<label class="form-label" style="font-size:var(--text-xs)">Eingegangener Betrag (€) *</label>
|
||||||
<input class="form-control" name="paid_amount" type="number" min="0" step="0.01"
|
<input class="form-control" name="paid_amount" id="${id}-amt" type="number" min="0" step="0.01"
|
||||||
value="${defaultAmount.toFixed(2)}" required>
|
value="${defaultAmount.toFixed(2)}" required>
|
||||||
</div>
|
</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>
|
</form>
|
||||||
`,
|
`,
|
||||||
footer: `
|
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 => {
|
document.getElementById(id)?.addEventListener('submit', async e => {
|
||||||
e.preventDefault();
|
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}"]`);
|
const submitBtn = document.querySelector(`button[form="${id}"]`);
|
||||||
if (submitBtn) submitBtn.disabled = true;
|
if (submitBtn) submitBtn.disabled = true;
|
||||||
try {
|
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`, {
|
await API.post(`/admin/invoices/${invoiceId}/pay`, {
|
||||||
paid_at: fd.get('paid_at'),
|
paid_at: fd.get('paid_at'),
|
||||||
paid_amount: parseFloat(fd.get('paid_amount')),
|
paid_amount: paidAmount,
|
||||||
|
...(kulanzNote ? { notes: kulanzNote } : {}),
|
||||||
});
|
});
|
||||||
UI.modal.close();
|
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();
|
reload();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
UI.toast.error(err.message || 'Fehler.');
|
UI.toast.error(err.message || 'Fehler.');
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@
|
||||||
Offline-Cache + Push Notifications + Tile-Cache
|
Offline-Cache + Push Notifications + Tile-Cache
|
||||||
============================================================ */
|
============================================================ */
|
||||||
|
|
||||||
const CACHE_VERSION = 'by-v983';
|
const CACHE_VERSION = 'by-v984';
|
||||||
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
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue