Feat: Entwurf bearbeiten (PATCH), erneut senden; SW by-v968

This commit is contained in:
rene 2026-05-15 11:33:48 +02:00
parent a2d089bce4
commit b14a251bdc
5 changed files with 96 additions and 11 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 = "967" # muss mit APP_VER in app.js übereinstimmen
APP_VER = "968" # muss mit APP_VER in app.js übereinstimmen
@app.get("/.well-known/assetlinks.json")
async def assetlinks():

View file

@ -444,6 +444,58 @@ def get_invoice(invoice_id: int, admin=Depends(require_admin)):
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:
@ -501,6 +553,8 @@ async def send_invoice(invoice_id: int, admin=Depends(require_admin)):
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)

View file

@ -3,7 +3,7 @@
Router, State-Management, Navigation, Initialisierung.
============================================================ */
const APP_VER = '967'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen
const APP_VER = '968'; // ← 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

@ -3726,10 +3726,19 @@ window.Page_admin = (() => {
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
@ -3808,6 +3817,22 @@ window.Page_admin = (() => {
});
});
// 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));
@ -3824,12 +3849,13 @@ window.Page_admin = (() => {
});
}
function _openNeueRechnungModal(reload, prefill = null) {
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')} Neue Rechnung erstellen`,
title: `${UI.icon('receipt')} ${isEdit ? 'Rechnung bearbeiten' : 'Neue Rechnung erstellen'}`,
body: `
<form id="${id}" style="display:flex;flex-direction:column;gap:var(--space-3)">
@ -3904,7 +3930,7 @@ window.Page_admin = (() => {
`,
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>
<button class="btn btn-primary" form="${id}" type="submit">${UI.icon('receipt')} ${isEdit ? 'Änderungen speichern' : 'Rechnung erstellen'}</button>
`,
});
@ -3988,7 +4014,7 @@ window.Page_admin = (() => {
if (submitBtn) submitBtn.disabled = true;
try {
await API.post('/admin/invoices', {
const payload = {
recipient_name: fd.get('recipient_name'),
recipient_email: fd.get('recipient_email') || null,
recipient_address: fd.get('recipient_address') || null,
@ -3996,9 +4022,14 @@ window.Page_admin = (() => {
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('Rechnung erstellt.');
UI.toast.success(isEdit ? 'Rechnung gespeichert.' : 'Rechnung erstellt.');
reload();
} catch (err) {
UI.toast.error(err.message || 'Fehler beim Erstellen.');

View file

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