Feat: Entwurf bearbeiten (PATCH), erneut senden; SW by-v968
This commit is contained in:
parent
a2d089bce4
commit
b14a251bdc
5 changed files with 96 additions and 11 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 = "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")
|
@app.get("/.well-known/assetlinks.json")
|
||||||
async def assetlinks():
|
async def assetlinks():
|
||||||
|
|
|
||||||
|
|
@ -444,6 +444,58 @@ def get_invoice(invoice_id: int, admin=Depends(require_admin)):
|
||||||
return result
|
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)
|
@router.post("", status_code=201)
|
||||||
def create_invoice(data: InvoiceCreate, admin=Depends(require_admin)):
|
def create_invoice(data: InvoiceCreate, admin=Depends(require_admin)):
|
||||||
if not data.items:
|
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.")
|
raise HTTPException(404, "Rechnung nicht gefunden.")
|
||||||
if row["status"] == "cancelled":
|
if row["status"] == "cancelled":
|
||||||
raise HTTPException(400, "Stornierte Rechnung kann nicht gesendet werden.")
|
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)
|
items = _fetch_items(conn, invoice_id)
|
||||||
|
|
||||||
invoice = _row_to_dict(row)
|
invoice = _row_to_dict(row)
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@
|
||||||
Router, State-Management, Navigation, Initialisierung.
|
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 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
|
||||||
|
|
|
||||||
|
|
@ -3726,10 +3726,19 @@ window.Page_admin = (() => {
|
||||||
const rows = invoices.map((inv, i) => {
|
const rows = invoices.map((inv, i) => {
|
||||||
const actions = [];
|
const actions = [];
|
||||||
if (inv.status === 'draft') {
|
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">
|
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
|
${UI.icon('paper-plane-tilt')} Senden
|
||||||
</button>`);
|
</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') {
|
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">
|
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
|
${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
|
// Als bezahlt markieren
|
||||||
el.querySelectorAll('.adm-inv-pay').forEach(btn => {
|
el.querySelectorAll('.adm-inv-pay').forEach(btn => {
|
||||||
btn.addEventListener('click', () => _openBezahltModal(btn.dataset.id, Number(btn.dataset.amount), reload));
|
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 id = `inv-new-${Date.now()}`;
|
||||||
const p = prefill || {};
|
const p = prefill || {};
|
||||||
|
const isEdit = !!invoiceId;
|
||||||
|
|
||||||
UI.modal.open({
|
UI.modal.open({
|
||||||
title: `${UI.icon('receipt')} Neue Rechnung erstellen`,
|
title: `${UI.icon('receipt')} ${isEdit ? 'Rechnung bearbeiten' : 'Neue Rechnung erstellen'}`,
|
||||||
body: `
|
body: `
|
||||||
<form id="${id}" style="display:flex;flex-direction:column;gap:var(--space-3)">
|
<form id="${id}" style="display:flex;flex-direction:column;gap:var(--space-3)">
|
||||||
|
|
||||||
|
|
@ -3904,7 +3930,7 @@ window.Page_admin = (() => {
|
||||||
`,
|
`,
|
||||||
footer: `
|
footer: `
|
||||||
<button class="btn btn-secondary" data-modal-close>Abbrechen</button>
|
<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;
|
if (submitBtn) submitBtn.disabled = true;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await API.post('/admin/invoices', {
|
const payload = {
|
||||||
recipient_name: fd.get('recipient_name'),
|
recipient_name: fd.get('recipient_name'),
|
||||||
recipient_email: fd.get('recipient_email') || null,
|
recipient_email: fd.get('recipient_email') || null,
|
||||||
recipient_address: fd.get('recipient_address') || null,
|
recipient_address: fd.get('recipient_address') || null,
|
||||||
|
|
@ -3996,9 +4022,14 @@ window.Page_admin = (() => {
|
||||||
discount_pct: parseFloat(fd.get('discount_pct')) || 0,
|
discount_pct: parseFloat(fd.get('discount_pct')) || 0,
|
||||||
notes: fd.get('notes') || null,
|
notes: fd.get('notes') || null,
|
||||||
items,
|
items,
|
||||||
});
|
};
|
||||||
|
if (isEdit) {
|
||||||
|
await API.patch(`/admin/invoices/${invoiceId}`, payload);
|
||||||
|
} else {
|
||||||
|
await API.post('/admin/invoices', payload);
|
||||||
|
}
|
||||||
UI.modal.close();
|
UI.modal.close();
|
||||||
UI.toast.success('Rechnung erstellt.');
|
UI.toast.success(isEdit ? 'Rechnung gespeichert.' : 'Rechnung erstellt.');
|
||||||
reload();
|
reload();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
UI.toast.error(err.message || 'Fehler beim Erstellen.');
|
UI.toast.error(err.message || 'Fehler beim Erstellen.');
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@
|
||||||
Offline-Cache + Push Notifications + Tile-Cache
|
Offline-Cache + Push Notifications + Tile-Cache
|
||||||
============================================================ */
|
============================================================ */
|
||||||
|
|
||||||
const CACHE_VERSION = 'by-v967';
|
const CACHE_VERSION = 'by-v968';
|
||||||
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