From f305541f903c02329309a5d2513eded9792c58c5 Mon Sep 17 00:00:00 2001 From: Luca <102960244+Luca-Timo@users.noreply.github.com> Date: Thu, 11 Jun 2026 12:47:55 +0200 Subject: [PATCH] feat(accounting): frontend rework - separate Incoming invoices vs Expenses (stage 2) Matches the backend split. Incoming invoices and Expenses are now distinct surfaces with no shared rows. Incoming invoices (AccountingInboxPage): triage sets the disposition + booking (event or company) ON the document; "Mark paid" / "Paid" toggle records supplier payment HERE with the outstanding total shown; re-bill via the customer picker + markup. PDF preview still rasterised (last page = QR-bill). Expenses (ExpensesLedgerPage): internal own-costs only. Add form has a Type dropdown (amount / mileage(km) / per-diem); km/per-diem switch the input to a quantity + rate (default from accounting settings, per-entry override) with a live computed amount; optional proof upload (required when the setting says so); localized category; booked to an event or the company. Proof viewable per row. Service: reworked to the new endpoints/shapes; categoryLabel() localizes seed categories (custom stay free-text). i18n: accounting.booking / incoming / expense / expenseKind / category (EN + DE, DE native). Verified: tsc --noEmit clean (0 errors); en/de JSON valid; npm run build green. --- frontend/src/i18n/locales/de.json | 49 ++- frontend/src/i18n/locales/en.json | 49 ++- .../admin/accounting/AccountingInboxPage.tsx | 378 +++++++----------- .../admin/accounting/ExpensesLedgerPage.tsx | 353 ++++++---------- frontend/src/services/accounting.service.ts | 205 +++++----- 5 files changed, 475 insertions(+), 559 deletions(-) diff --git a/frontend/src/i18n/locales/de.json b/frontend/src/i18n/locales/de.json index c17ad474..031a72c6 100644 --- a/frontend/src/i18n/locales/de.json +++ b/frontend/src/i18n/locales/de.json @@ -3475,10 +3475,57 @@ "card": "Karte", "other": "Sonstiges" }, + "booking": { + "label": "Buchen auf", + "company": "Firma", + "event": "Event", + "eventId": "Event-ID" + }, + "incoming": { + "triageTitle": "Eingangsrechnung kategorisieren", + "payTitle": "Lieferant als bezahlt markieren", + "outstanding": "Offen", + "markPaid": "Als bezahlt markieren", + "confirmPaid": "Als bezahlt markieren", + "paid": "Bezahlt", + "paidToast": "Als bezahlt markiert.", + "categorizedToast": "Kategorisiert." + }, + "expense": { + "kind": "Art", + "km": "Kilometer", + "days": "Tage", + "rate": "Satz", + "rateDefault": "Standard {{rate}} — leer lassen, um ihn zu verwenden", + "computed": "Betrag", + "who": "Bezahlt von / Lieferant", + "whoHint": "z. B. Mitarbeitername oder Geschäft", + "proof": "Beleg", + "proofRequired": "Ein Beleg ist erforderlich.", + "viewProof": "Beleg" + }, + "expenseKind": { + "amount": "Betrag", + "mileage": "Kilometer (km)", + "per_diem": "Spesenpauschale" + }, + "category": { + "infrastructure": "Infrastruktur & Miete", + "equipment": "Equipment & Hardware", + "software": "Software & Lizenzen", + "material": "Material & Verbrauch", + "travel": "Reise & Spesen", + "marketing": "Werbung & Marketing", + "services": "Dienstleistungen/Fremdleistungen", + "insurance": "Versicherungen & Gebühren", + "training": "Weiterbildung", + "other": "Sonstiges" + }, "ledger": { "allStatuses": "Alle Status", "allDispositions": "Alle Zuordnungen", - "empty": "Noch keine Aufwände — Dokumente im Eingang kategorisieren.", + "allKinds": "Alle Arten", + "empty": "Noch keine Aufwände — oben einen hinzufügen.", "untitled": "Aufwand", "invoiceLink": "Rechnung", "paid": "Bezahlt", diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json index 5e5caea0..df74dd80 100644 --- a/frontend/src/i18n/locales/en.json +++ b/frontend/src/i18n/locales/en.json @@ -3475,10 +3475,57 @@ "card": "Card", "other": "Other" }, + "booking": { + "label": "Book to", + "company": "Company", + "event": "Event", + "eventId": "Event ID" + }, + "incoming": { + "triageTitle": "Categorize incoming invoice", + "payTitle": "Mark supplier paid", + "outstanding": "Outstanding", + "markPaid": "Mark paid", + "confirmPaid": "Mark paid", + "paid": "Paid", + "paidToast": "Marked as paid.", + "categorizedToast": "Categorized." + }, + "expense": { + "kind": "Type", + "km": "Kilometres", + "days": "Days", + "rate": "Rate", + "rateDefault": "Default {{rate}} — leave blank to use it", + "computed": "Amount", + "who": "Paid by / vendor", + "whoHint": "e.g. coworker name or shop", + "proof": "Proof", + "proofRequired": "A proof file is required.", + "viewProof": "Proof" + }, + "expenseKind": { + "amount": "Amount", + "mileage": "Mileage (km)", + "per_diem": "Per-diem" + }, + "category": { + "infrastructure": "Infrastructure & rent", + "equipment": "Equipment & hardware", + "software": "Software & licenses", + "material": "Materials & supplies", + "travel": "Travel & expenses", + "marketing": "Advertising & marketing", + "services": "Services / subcontracting", + "insurance": "Insurance & fees", + "training": "Training", + "other": "Other" + }, "ledger": { "allStatuses": "All statuses", "allDispositions": "All dispositions", - "empty": "No expenses yet — categorize documents in the inbox.", + "allKinds": "All types", + "empty": "No expenses yet — add one above.", "untitled": "Expense", "invoiceLink": "Invoice", "paid": "Paid", diff --git a/frontend/src/pages/admin/accounting/AccountingInboxPage.tsx b/frontend/src/pages/admin/accounting/AccountingInboxPage.tsx index 70480901..6eeae9b5 100644 --- a/frontend/src/pages/admin/accounting/AccountingInboxPage.tsx +++ b/frontend/src/pages/admin/accounting/AccountingInboxPage.tsx @@ -1,33 +1,28 @@ /** - * Accounting → Incoming invoices inbox ("Neu / Unsortiert"). + * Accounting → Incoming invoices (external supplier invoices). * - * Capture a received supplier invoice via the phone/tablet CAMERA or a file - * upload, then triage it: confirm the best-effort parsed fields and give it a - * disposition (re-bill to a client, pass-through, company expense, duplicate, - * declined). Re-bill mints an editable scheduled invoice on the client's event. - * - * Parsing is assist-only and currently a no-op on the backend (extractionService - * scaffold) — fields are entered/confirmed manually until OCR lands. + * Capture (camera/upload) → triage (confirm fields + disposition + booking) → + * the supplier invoice is the payable: mark it PAID here, or re-bill it to a + * client. PDFs are previewed as server-rasterised page images (never raw). */ import React, { useRef, useState, useEffect } from 'react'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useTranslation } from 'react-i18next'; import { toast } from 'react-toastify'; -import { Camera, Upload, Inbox, X } from 'lucide-react'; +import { Camera, Upload, Inbox, X, CheckCircle2, Circle } from 'lucide-react'; import { Button, Card, CardContent, Input, LocalizedDateInput, Loading } from '../../../components/common'; import { DecimalInput } from '../../../components/common/DecimalInput'; import { CustomerAccountPicker, type SelectedCustomer } from '../../../components/admin/CustomerAccountPicker'; import { formatMoneyMinor } from '../../../utils/money'; import { useLocalizedDate } from '../../../hooks/useLocalizedDate'; import { - accountingService, - type InboundDocument, - type Disposition, - type MarkupType, - type ExpenseCategory, + accountingService, categoryLabel, + type InboundDocument, type Disposition, type MarkupType, type PaymentMethod, type ExpenseCategory, } from '../../../services/accounting.service'; const DISPOSITIONS: Disposition[] = ['rebill', 'durchlaufend', 'eigener_aufwand', 'duplikat', 'abgelehnt']; +const PAYMENT_METHODS: PaymentMethod[] = ['bank_transfer', 'cash', 'twint', 'paypal', 'card', 'other']; +const BOOKING_DISPOSITIONS: Disposition[] = ['rebill', 'durchlaufend', 'eigener_aufwand']; const statusClasses: Record = { unsorted: 'bg-amber-100 text-amber-800 dark:bg-amber-900/40 dark:text-amber-300', @@ -36,12 +31,69 @@ const statusClasses: Record = { duplicate: 'bg-neutral-200 text-neutral-700 dark:bg-neutral-700 dark:text-neutral-300', }; -const TriageModal: React.FC<{ - doc: InboundDocument; - categories: ExpenseCategory[]; - onClose: () => void; - onDone: () => void; -}> = ({ doc, categories, onClose, onDone }) => { +const labelCls = 'block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1'; +const selectCls = 'w-full rounded-md border border-neutral-300 dark:border-neutral-600 bg-white dark:bg-neutral-800 px-3 py-2 text-sm'; + +const BookingField: React.FC<{ eventId: number | null; onChange: (id: number | null) => void }> = ({ eventId, onChange }) => { + const { t } = useTranslation(); + const isEvent = eventId != null; + return ( +
+ +
+ + {isEvent && ( + onChange(Number(e.target.value.replace(/[^0-9]/g, '')) || 0)} /> + )} +
+
+ ); +}; + +const PayModal: React.FC<{ doc: InboundDocument; onClose: () => void; onDone: () => void }> = ({ doc, onClose, onDone }) => { + const { t } = useTranslation(); + const [paidAt, setPaidAt] = useState(''); + const [method, setMethod] = useState('bank_transfer'); + const [reference, setReference] = useState(doc.paymentReference || ''); + const save = useMutation({ + mutationFn: () => accountingService.markInboundPaid(doc.id, { paid: true, paidAt: paidAt || undefined, paymentMethod: method, paymentReference: reference || undefined }), + onSuccess: () => { toast.success(t('accounting.incoming.paidToast', 'Marked as paid.')); onDone(); }, + onError: (e: any) => toast.error(e?.response?.data?.error || e.message || 'Failed'), + }); + return ( +
+
+
+

{t('accounting.incoming.payTitle', 'Mark supplier paid')}

+ +
+
+

+ {t('accounting.incoming.outstanding', 'Outstanding')}: {doc.totalAmountMinor != null ? formatMoneyMinor(doc.totalAmountMinor, doc.currency || 'CHF') : '—'} +

+
+
+ +
+
setReference(e.target.value)} />
+
+
+ + +
+
+
+ ); +}; + +const TriageModal: React.FC<{ doc: InboundDocument; categories: ExpenseCategory[]; onClose: () => void; onDone: () => void }> = ({ doc, categories, onClose, onDone }) => { const { t } = useTranslation(); const [supplier, setSupplier] = useState(doc.supplierName || ''); const [amountMajor, setAmountMajor] = useState(doc.totalAmountMinor != null ? doc.totalAmountMinor / 100 : NaN); @@ -49,75 +101,46 @@ const TriageModal: React.FC<{ const [invoiceDate, setInvoiceDate] = useState(doc.invoiceDate || ''); const [disposition, setDisposition] = useState('eigener_aufwand'); const [categoryId, setCategoryId] = useState(undefined); - const [declineReason, setDeclineReason] = useState(''); + const [eventId, setEventId] = useState(null); const [customer, setCustomer] = useState([]); - const [eventId, setEventId] = useState(''); const [markupType, setMarkupType] = useState('none'); const [markupValue, setMarkupValue] = useState(NaN); const totalMinor = Number.isFinite(amountMajor) ? Math.round(amountMajor * 100) : null; - // Authenticated preview. PDFs are shown as SERVER-RASTERISED page images - // (the raw PDF never reaches the browser); images stream directly. Start on - // the LAST page — the Swiss QR-bill payment part sits at its bottom (no OCR; - // the admin reads the slip and types the fields). + // Rasterised preview (last page = QR-bill). const isPdf = (doc.mimeType || '').includes('pdf'); const pageCount = doc.pageCount || 1; const [page, setPage] = useState(pageCount); const [imgUrl, setImgUrl] = useState(null); const [previewError, setPreviewError] = useState(false); useEffect(() => { - let url: string | null = null; - let cancelled = false; - setImgUrl(null); - setPreviewError(false); - const fetcher = isPdf - ? accountingService.getInboundPageBlob(doc.id, page) - : accountingService.getInboundFileBlob(doc.id); - fetcher - .then((blob) => { if (!cancelled) { url = URL.createObjectURL(blob); setImgUrl(url); } }) + let url: string | null = null; let cancelled = false; + setImgUrl(null); setPreviewError(false); + (isPdf ? accountingService.getInboundPageBlob(doc.id, page) : accountingService.getInboundFileBlob(doc.id)) + .then((b) => { if (!cancelled) { url = URL.createObjectURL(b); setImgUrl(url); } }) .catch(() => { if (!cancelled) setPreviewError(true); }); return () => { cancelled = true; if (url) URL.revokeObjectURL(url); }; }, [doc.id, isPdf, page]); + const markupPayload = () => ({ + markupType, + markupPercent: markupType === 'percent' && Number.isFinite(markupValue) ? markupValue : null, + markupFlatMinor: markupType === 'flat' && Number.isFinite(markupValue) ? Math.round(markupValue * 100) : null, + }); + const save = useMutation({ mutationFn: async () => { - // 1) Confirm the document's fields (assist is never blind-trusted). - await accountingService.updateInbound(doc.id, { - supplierName: supplier || null, - totalAmountMinor: totalMinor, - currency: currency || null, - invoiceDate: invoiceDate || null, - }); - // 2) Create the expense with its disposition. - const expense = await accountingService.categorizeInbound(doc.id, { + await accountingService.updateInbound(doc.id, { supplierName: supplier || null, totalAmountMinor: totalMinor, currency: currency || null, invoiceDate: invoiceDate || null }); + await accountingService.categorizeInbound(doc.id, { disposition, - supplierName: supplier || null, - chfAmountMinor: totalMinor, - grossAmountMinor: totalMinor, + eventId: BOOKING_DISPOSITIONS.includes(disposition) ? eventId : null, categoryId: disposition === 'eigener_aufwand' ? (categoryId ?? null) : null, - declineReason: disposition === 'abgelehnt' ? (declineReason || null) : null, - eventId: eventId ? Number(eventId) : null, customerAccountId: disposition === 'rebill' && customer[0] ? customer[0].id : null, - markupType, - markupPercent: markupType === 'percent' && Number.isFinite(markupValue) ? markupValue : null, - markupFlatMinor: markupType === 'flat' && Number.isFinite(markupValue) ? Math.round(markupValue * 100) : null, + ...markupPayload(), }); - // 3) Re-bill mints the client invoice. - if (disposition === 'rebill') { - await accountingService.rebill(expense.id, { - customerAccountId: customer[0].id, - eventId: eventId ? Number(eventId) : null, - markupType, - markupPercent: markupType === 'percent' && Number.isFinite(markupValue) ? markupValue : null, - markupFlatMinor: markupType === 'flat' && Number.isFinite(markupValue) ? Math.round(markupValue * 100) : null, - }); - } - }, - onSuccess: () => { - toast.success(t('accounting.inbox.categorizedToast', 'Document categorized.')); - onDone(); }, + onSuccess: () => { toast.success(t('accounting.incoming.categorizedToast', 'Categorized.')); onDone(); }, onError: (e: any) => toast.error(e?.response?.data?.error || e.message || 'Failed'), }); @@ -127,29 +150,15 @@ const TriageModal: React.FC<{
-

- {t('accounting.inbox.triageTitle', 'Categorize document')} -

- +

{t('accounting.incoming.triageTitle', 'Categorize incoming invoice')}

+
-
- {/* Document preview — PDFs open at the last page (QR-bill area). */}
- {previewError ? ( -
- {t('accounting.inbox.previewError', 'Preview unavailable — enter the fields manually.')} -
- ) : imgUrl ? ( - document page - ) : ( -
- {t('accounting.inbox.previewLoading', 'Loading preview…')} -
- )} + {previewError ?
{t('accounting.inbox.previewError', 'Preview unavailable — enter the fields manually.')}
+ : imgUrl ? document page + :
{t('accounting.inbox.previewLoading', 'Loading preview…')}
}
{isPdf && pageCount > 1 && (
@@ -158,108 +167,53 @@ const TriageModal: React.FC<{
)} - {isPdf && ( -

- {t('accounting.inbox.qrHint', 'Showing the last page — the Swiss QR-bill usually sits at the bottom.')} -

- )} + {isPdf &&

{t('accounting.inbox.qrHint', 'Showing the last page — the Swiss QR-bill usually sits at the bottom.')}

}
- {/* Triage form */}
-
-
- - setSupplier(e.target.value)} /> +
+
setSupplier(e.target.value)} />
+
+
setCurrency(e.target.value.toUpperCase())} />
+
-
- - -
-
- - setCurrency(e.target.value.toUpperCase())} maxLength={3} /> -
-
- - -
-
-
- - -
- - {disposition === 'eigener_aufwand' && ( -
- - setDisposition(e.target.value as Disposition)} className={selectCls}> + {DISPOSITIONS.map((d) => )}
- )} - {disposition === 'abgelehnt' && ( -
- - setDeclineReason(e.target.value)} /> -
- )} + {BOOKING_DISPOSITIONS.includes(disposition) && } - {disposition === 'rebill' && ( -
-
- - setCustomer(next.slice(-1))} /> + {disposition === 'eigener_aufwand' && ( +
+
-
-
- - setEventId(e.target.value.replace(/[^0-9]/g, ''))} inputMode="numeric" /> -
-
- - setMarkupType(e.target.value as MarkupType)} className={selectCls}>
+ {markupType !== 'none' && }
- {markupType !== 'none' && ( - - )} -

{t('accounting.inbox.rebillHint', 'Creates an editable scheduled invoice on the client. VAT/tax handling is v1 — verify with your Treuhänder.')}

-
- )} + )}
-
- +
@@ -273,32 +227,26 @@ export const AccountingInboxPage: React.FC = () => { const cameraRef = useRef(null); const uploadRef = useRef(null); const [triageDoc, setTriageDoc] = useState(null); + const [payDoc, setPayDoc] = useState(null); - const { data, isLoading } = useQuery({ - queryKey: ['accounting-inbound'], - queryFn: () => accountingService.listInbound({ pageSize: 100 }), - }); - const { data: categories } = useQuery({ - queryKey: ['expense-categories'], - queryFn: () => accountingService.listCategories(), - }); + const { data, isLoading } = useQuery({ queryKey: ['accounting-inbound'], queryFn: () => accountingService.listInbound({ pageSize: 100 }) }); + const { data: categories } = useQuery({ queryKey: ['expense-categories'], queryFn: () => accountingService.listCategories() }); const upload = useMutation({ mutationFn: ({ file, source }: { file: File; source: 'upload' | 'camera' }) => accountingService.uploadInbound(file, source), - onSuccess: (doc) => { - toast.success(t('accounting.inbox.capturedToast', 'Document captured.')); - qc.invalidateQueries({ queryKey: ['accounting-inbound'] }); - if (doc.status === 'unsorted') setTriageDoc(doc); // jump straight into triage - }, + onSuccess: (doc) => { toast.success(t('accounting.inbox.capturedToast', 'Document captured.')); qc.invalidateQueries({ queryKey: ['accounting-inbound'] }); if (doc.status === 'unsorted') setTriageDoc(doc); }, onError: (e: any) => toast.error(e?.response?.data?.error || e.message || 'Upload failed'), }); + const unpay = useMutation({ + mutationFn: (id: number) => accountingService.markInboundPaid(id, { paid: false }), + onSuccess: () => { qc.invalidateQueries({ queryKey: ['accounting-inbound'] }); }, + onError: (e: any) => toast.error(e?.response?.data?.error || e.message || 'Failed'), + }); const onFile = (source: 'upload' | 'camera') => (e: React.ChangeEvent) => { - const file = e.target.files?.[0]; - if (file) upload.mutate({ file, source }); - e.target.value = ''; + const file = e.target.files?.[0]; if (file) upload.mutate({ file, source }); e.target.value = ''; }; - + const refresh = () => qc.invalidateQueries({ queryKey: ['accounting-inbound'] }); const items = data?.items ?? []; return ( @@ -306,24 +254,16 @@ export const AccountingInboxPage: React.FC = () => { - - -
-

{t('accounting.inbox.captureTitle', 'Capture a supplier invoice')}

-

{t('accounting.inbox.captureBody', 'Photograph a paper invoice with your device camera, or upload a PDF / image.')}

-
- - -
-
+ +
+

{t('accounting.inbox.captureTitle', 'Capture a supplier invoice')}

+

{t('accounting.inbox.captureBody', 'Photograph a paper invoice with your device camera, or upload a PDF / image.')}

+
+ + +
- {isLoading ? ( - - ) : items.length === 0 ? ( + {isLoading ? : items.length === 0 ? (

{t('accounting.inbox.empty', 'No documents yet — capture one above.')}

@@ -331,42 +271,32 @@ export const AccountingInboxPage: React.FC = () => { ) : (
{items.map((doc) => ( -
-
+
+
- - {t(`accounting.inbox.status.${doc.status}`, doc.status)} - - - {doc.supplierName || doc.originalFilename || t('accounting.inbox.untitled', 'Untitled document')} - + {t(`accounting.inbox.status.${doc.status}`, doc.status)} + {doc.supplierName || doc.originalFilename || t('accounting.inbox.untitled', 'Untitled document')} {doc.source === 'camera' && }
{doc.totalAmountMinor != null ? formatMoneyMinor(doc.totalAmountMinor, doc.currency || 'CHF') : t('accounting.inbox.noAmount', 'amount not entered')} - {' · '} - {format(doc.createdAt)} + {' · '}{format(doc.createdAt)} + {doc.disposition && <>{' · '}{t(`accounting.disposition.${doc.disposition}`, doc.disposition)}}
- {doc.status === 'unsorted' && ( - + {doc.status !== 'declined' && doc.status !== 'duplicate' && ( + doc.supplierPaid + ? + : )} + {doc.status === 'unsorted' && }
))}
)} - {triageDoc && ( - setTriageDoc(null)} - onDone={() => { - setTriageDoc(null); - qc.invalidateQueries({ queryKey: ['accounting-inbound'] }); - }} - /> - )} + {triageDoc && setTriageDoc(null)} onDone={() => { setTriageDoc(null); refresh(); }} />} + {payDoc && setPayDoc(null)} onDone={() => { setPayDoc(null); refresh(); }} />}
); }; diff --git a/frontend/src/pages/admin/accounting/ExpensesLedgerPage.tsx b/frontend/src/pages/admin/accounting/ExpensesLedgerPage.tsx index 9701f5ec..764a23f5 100644 --- a/frontend/src/pages/admin/accounting/ExpensesLedgerPage.tsx +++ b/frontend/src/pages/admin/accounting/ExpensesLedgerPage.tsx @@ -1,210 +1,129 @@ /** - * Accounting → Expenses ledger. + * Accounting → Expenses (internal own costs). * - * Lists the expenses booked from triaged inbound documents (and manual ones). - * Shows disposition + status, the linked client invoice for re-billed items, - * and a supplier-payment toggle ("Zu zahlen / Bezahlt") with method capture — - * decoupled from categorisation, per the locked design. + * Mileage (km) / per-diem (days) / plain amount, booked to an event or the + * company, with an optional proof file (required when the accounting setting + * says so). Rates default from the Accounting settings tab, overridable per + * entry. No supplier payment here — that lives on incoming invoices. */ -import React, { useState } from 'react'; -import { Link } from 'react-router-dom'; +import React, { useState, useMemo } from 'react'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useTranslation } from 'react-i18next'; import { toast } from 'react-toastify'; -import { CheckCircle2, Circle, X, ExternalLink, Plus } from 'lucide-react'; -import { Button, Card, CardContent, Input, LocalizedDateInput, Loading } from '../../../components/common'; +import { X, Plus, Paperclip, Car, CalendarDays, Coins } from 'lucide-react'; +import { Button, Card, CardContent, Input, Loading } from '../../../components/common'; import { DecimalInput } from '../../../components/common/DecimalInput'; -import { CustomerAccountPicker, type SelectedCustomer } from '../../../components/admin/CustomerAccountPicker'; import { formatMoneyMinor } from '../../../utils/money'; import { useLocalizedDate } from '../../../hooks/useLocalizedDate'; import { - accountingService, - type Expense, - type Disposition, - type MarkupType, - type PaymentMethod, + accountingService, categoryLabel, + type Expense, type ExpenseKind, type ExpenseCategory, } from '../../../services/accounting.service'; -const DISPOSITIONS: Disposition[] = ['rebill', 'durchlaufend', 'eigener_aufwand', 'duplikat', 'abgelehnt']; -const STATUSES = ['open', 'parked', 'billed', 'declined']; -const PAYMENT_METHODS: PaymentMethod[] = ['bank_transfer', 'cash', 'twint', 'paypal', 'card', 'other']; - -const statusClasses: Record = { - open: 'bg-amber-100 text-amber-800 dark:bg-amber-900/40 dark:text-amber-300', - parked: 'bg-blue-100 text-blue-800 dark:bg-blue-900/40 dark:text-blue-300', - billed: 'bg-green-100 text-green-800 dark:bg-green-900/40 dark:text-green-300', - declined: 'bg-neutral-200 text-neutral-700 dark:bg-neutral-700 dark:text-neutral-300', +const KINDS: ExpenseKind[] = ['amount', 'mileage', 'per_diem']; +const labelCls = 'block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1'; +const selectCls = 'w-full rounded-md border border-neutral-300 dark:border-neutral-600 bg-white dark:bg-neutral-800 px-3 py-2 text-sm'; +const kindIcon: Record = { + amount: , mileage: , per_diem: , }; -const PayModal: React.FC<{ expense: Expense; onClose: () => void; onDone: () => void }> = ({ expense, onClose, onDone }) => { - const { t } = useTranslation(); - const [paidAt, setPaidAt] = useState(''); - const [method, setMethod] = useState('bank_transfer'); - const [reference, setReference] = useState(''); - - const save = useMutation({ - mutationFn: () => accountingService.setSupplierPayment(expense.id, { - paid: true, paidAt: paidAt || undefined, paymentMethod: method, paymentReference: reference || undefined, - }), - onSuccess: () => { toast.success(t('accounting.ledger.paidToast', 'Marked as paid.')); onDone(); }, - onError: (e: any) => toast.error(e?.response?.data?.error || e.message || 'Failed'), - }); - - return ( -
-
-
-

{t('accounting.ledger.markPaidTitle', 'Mark supplier paid')}

- -
-
-
- - -
-
- - -
-
- - setReference(e.target.value)} /> -
-
-
- - -
-
-
- ); -}; - -const MANUAL_DISPOSITIONS: Disposition[] = ['eigener_aufwand', 'durchlaufend', 'rebill']; - -const AddExpenseModal: React.FC<{ onClose: () => void; onDone: () => void }> = ({ onClose, onDone }) => { +const AddExpenseModal: React.FC<{ categories: ExpenseCategory[]; onClose: () => void; onDone: () => void }> = ({ categories, onClose, onDone }) => { const { t } = useTranslation(); + const { data: settings } = useQuery({ queryKey: ['accounting-settings'], queryFn: () => accountingService.getSettings() }); + const [kind, setKind] = useState('amount'); const [supplier, setSupplier] = useState(''); const [description, setDescription] = useState(''); const [amountMajor, setAmountMajor] = useState(NaN); - const [currency, setCurrency] = useState('CHF'); - const [disposition, setDisposition] = useState('eigener_aufwand'); + const [quantity, setQuantity] = useState(NaN); + const [rateMajor, setRateMajor] = useState(NaN); // per-entry override (major units) const [categoryId, setCategoryId] = useState(undefined); - const [customer, setCustomer] = useState([]); - const [eventId, setEventId] = useState(''); - const [markupType, setMarkupType] = useState('none'); - const [markupValue, setMarkupValue] = useState(NaN); + const [eventId, setEventId] = useState(null); + const [file, setFile] = useState(null); - const { data: categories } = useQuery({ queryKey: ['expense-categories'], queryFn: () => accountingService.listCategories() }); - const totalMinor = Number.isFinite(amountMajor) ? Math.round(amountMajor * 100) : null; - const selectClass = 'w-full rounded-md border border-neutral-300 dark:border-neutral-600 bg-white dark:bg-neutral-800 px-3 py-2 text-sm'; + const defaultRateMinor = kind === 'mileage' ? (settings?.accounting_km_rate_minor ?? 0) + : kind === 'per_diem' ? (settings?.accounting_per_diem_rate_minor ?? 0) : 0; + const effRateMinor = Number.isFinite(rateMajor) ? Math.round(rateMajor * 100) : defaultRateMinor; + const computedMinor = useMemo(() => { + if (kind === 'amount') return Number.isFinite(amountMajor) ? Math.round(amountMajor * 100) : null; + return Number.isFinite(quantity) ? Math.round(quantity * effRateMinor) : null; + }, [kind, amountMajor, quantity, effRateMinor]); + const requireProof = !!settings?.accounting_require_proof; const save = useMutation({ - mutationFn: async () => { - const expense = await accountingService.createExpense({ - disposition, - supplierName: supplier || null, - description: description || null, - chfAmountMinor: totalMinor, - grossAmountMinor: totalMinor, - categoryId: disposition === 'eigener_aufwand' ? (categoryId ?? null) : null, - eventId: eventId ? Number(eventId) : null, - customerAccountId: disposition === 'rebill' && customer[0] ? customer[0].id : null, - markupType, - markupPercent: markupType === 'percent' && Number.isFinite(markupValue) ? markupValue : null, - markupFlatMinor: markupType === 'flat' && Number.isFinite(markupValue) ? Math.round(markupValue * 100) : null, - }); - if (disposition === 'rebill') { - await accountingService.rebill(expense.id, { - customerAccountId: customer[0].id, - eventId: eventId ? Number(eventId) : null, - markupType, - markupPercent: markupType === 'percent' && Number.isFinite(markupValue) ? markupValue : null, - markupFlatMinor: markupType === 'flat' && Number.isFinite(markupValue) ? Math.round(markupValue * 100) : null, - }); - } - }, + mutationFn: () => accountingService.createExpense({ + kind, + quantity: kind === 'amount' ? undefined : (Number.isFinite(quantity) ? quantity : undefined), + rateMinor: kind === 'amount' ? undefined : (Number.isFinite(rateMajor) ? Math.round(rateMajor * 100) : undefined), + chfAmountMinor: kind === 'amount' && Number.isFinite(amountMajor) ? Math.round(amountMajor * 100) : undefined, + eventId, + categoryId: categoryId ?? null, + supplierName: supplier || null, + description: description || null, + }, file), onSuccess: () => { toast.success(t('accounting.ledger.createdToast', 'Expense added.')); onDone(); }, onError: (e: any) => toast.error(e?.response?.data?.error || e.message || 'Failed'), }); - const rebillNeedsCustomer = disposition === 'rebill' && !customer[0]; + const qtyLabel = kind === 'mileage' ? t('accounting.expense.km', 'Kilometres') : t('accounting.expense.days', 'Days'); + const incomplete = (kind === 'amount' && !Number.isFinite(amountMajor)) || (kind !== 'amount' && !Number.isFinite(quantity)) || (requireProof && !file); return (

{t('accounting.ledger.addTitle', 'Add expense')}

- +
-
- - setSupplier(e.target.value)} /> -
-
- - setDescription(e.target.value)} placeholder={t('accounting.ledger.descriptionHint', 'e.g. mileage, per-diem, cash receipt')} /> -
-
-
- - -
-
- - setCurrency(e.target.value.toUpperCase())} maxLength={3} /> -
-
-
- - setKind(e.target.value as ExpenseKind)} className={selectCls}> + {KINDS.map((k) => )}
- {disposition === 'eigener_aufwand' && ( -
- - setSupplier(e.target.value)} placeholder={t('accounting.expense.whoHint', 'e.g. coworker name or shop')} />
+
setDescription(e.target.value)} />
+ +
+ +
+ +
+
+ + {eventId != null && setEventId(Number(e.target.value.replace(/[^0-9]/g, '')) || 0)} />}
- )} - {disposition === 'rebill' && ( -
-
- - setCustomer(next.slice(-1))} /> -
-
-
- - setEventId(e.target.value.replace(/[^0-9]/g, ''))} inputMode="numeric" /> -
-
- - -
-
- {markupType !== 'none' && ( - - )} -
- )} +
+ +
+ + setFile(e.target.files?.[0] || null)} className="text-sm" /> + {requireProof && !file &&

{t('accounting.expense.proofRequired', 'A proof file is required.')}

} +
- +
@@ -215,97 +134,59 @@ export const ExpensesLedgerPage: React.FC = () => { const { t } = useTranslation(); const qc = useQueryClient(); const { format } = useLocalizedDate(); - const [status, setStatus] = useState(''); - const [disposition, setDisposition] = useState(''); - const [payExpense, setPayExpense] = useState(null); + const [kind, setKind] = useState(''); const [showAdd, setShowAdd] = useState(false); const { data, isLoading } = useQuery({ - queryKey: ['accounting-expenses', status, disposition], - queryFn: () => accountingService.listExpenses({ - status: status || undefined, - disposition: (disposition || undefined) as Disposition | undefined, - pageSize: 100, - }), + queryKey: ['accounting-expenses', kind], + queryFn: () => accountingService.listExpenses({ kind: (kind || undefined) as ExpenseKind | undefined, pageSize: 100 }), }); + const { data: categories } = useQuery({ queryKey: ['expense-categories'], queryFn: () => accountingService.listCategories() }); + const catById = useMemo(() => new Map((categories ?? []).map((c) => [c.id, c])), [categories]); - const unpay = useMutation({ - mutationFn: (id: number) => accountingService.setSupplierPayment(id, { paid: false }), - onSuccess: () => { toast.success(t('accounting.ledger.unpaidToast', 'Marked as not paid.')); qc.invalidateQueries({ queryKey: ['accounting-expenses'] }); }, - onError: (e: any) => toast.error(e?.response?.data?.error || e.message || 'Failed'), - }); + const openProof = async (id: number) => { + try { const blob = await accountingService.getExpenseProofBlob(id); window.open(URL.createObjectURL(blob), '_blank'); } + catch (e: any) { toast.error(e?.response?.data?.error || e.message || 'Failed'); } + }; - const selectClass = 'rounded-md border border-neutral-300 dark:border-neutral-600 bg-white dark:bg-neutral-800 px-3 py-2 text-sm'; const items = data?.items ?? []; return (
- setKind(e.target.value)} className={selectCls} style={{ maxWidth: 200 }}> + + {KINDS.map((k) => )} - - +
- {isLoading ? ( - - ) : items.length === 0 ? ( - {t('accounting.ledger.empty', 'No expenses yet — categorize documents in the inbox.')} + {isLoading ? : items.length === 0 ? ( + {t('accounting.ledger.empty', 'No expenses yet — add one above.')} ) : (
- {items.map((ex) => ( -
-
-
- {t(`accounting.expenseStatus.${ex.status}`, ex.status)} - {ex.supplierName || ex.description || t('accounting.ledger.untitled', 'Expense')} -
-
- {t(`accounting.disposition.${ex.disposition}`, ex.disposition)} - {' · '} - {format(ex.createdAt)} - {ex.billedInvoiceId ? ( - <>{' · '}{t('accounting.ledger.invoiceLink', 'Invoice')} - ) : null} + {items.map((ex: Expense) => { + const cat = ex.categoryId ? catById.get(ex.categoryId) : null; + return ( +
+ {kindIcon[ex.kind]} {t(`accounting.expenseKind.${ex.kind}`, ex.kind)} +
+
{ex.description || ex.supplierName || t('accounting.ledger.untitled', 'Expense')}
+
+ {ex.eventId != null ? `${t('accounting.booking.event', 'Event')} #${ex.eventId}` : t('accounting.booking.company', 'Company')} + {cat && <>{' · '}{categoryLabel(cat, t)}} + {' · '}{format(ex.createdAt)} +
+ {ex.hasProof && } +
{ex.chfAmountMinor != null ? formatMoneyMinor(ex.chfAmountMinor, 'CHF') : '—'}
- -
- {ex.chfAmountMinor != null ? formatMoneyMinor(ex.chfAmountMinor, 'CHF') : '—'} -
- - {/* Supplier-payment toggle (skip for declined/duplicate). */} - {ex.disposition !== 'abgelehnt' && ex.disposition !== 'duplikat' && ( - ex.supplierPaid ? ( - - ) : ( - - ) - )} -
- ))} + ); + })}
)} - {payExpense && ( - setPayExpense(null)} onDone={() => { setPayExpense(null); qc.invalidateQueries({ queryKey: ['accounting-expenses'] }); }} /> - )} - - {showAdd && ( - setShowAdd(false)} onDone={() => { setShowAdd(false); qc.invalidateQueries({ queryKey: ['accounting-expenses'] }); }} /> - )} + {showAdd && setShowAdd(false)} onDone={() => { setShowAdd(false); qc.invalidateQueries({ queryKey: ['accounting-expenses'] }); }} />}
); }; diff --git a/frontend/src/services/accounting.service.ts b/frontend/src/services/accounting.service.ts index ed77eb6b..492bfb8e 100644 --- a/frontend/src/services/accounting.service.ts +++ b/frontend/src/services/accounting.service.ts @@ -1,12 +1,13 @@ import { api } from '../config/api'; -// Mirrors backend transformInbound / transformExpense (camelCase). export type InboundStatus = 'unsorted' | 'categorized' | 'declined' | 'duplicate'; export type Disposition = 'rebill' | 'durchlaufend' | 'eigener_aufwand' | 'duplikat' | 'abgelehnt'; export type TaxTreatment = 'domestic' | 'reverse_charge_service' | 'foreign_vat_non_reclaimable' | 'import_goods'; export type MarkupType = 'none' | 'percent' | 'flat'; export type PaymentMethod = 'bank_transfer' | 'cash' | 'twint' | 'paypal' | 'card' | 'other'; +export type ExpenseKind = 'amount' | 'mileage' | 'per_diem'; +/** Incoming invoice (external supplier document). The payable lives here. */ export interface InboundDocument { id: number; source: 'upload' | 'camera' | 'email' | 'manual'; @@ -14,7 +15,6 @@ export interface InboundDocument { mimeType: string | null; status: InboundStatus; parseStatus: 'pending' | 'parsed' | 'failed' | 'manual'; - parseMethod: string | null; pageCount: number | null; supplierName: string | null; invoiceNumber: string | null; @@ -27,141 +27,152 @@ export interface InboundDocument { qrAmountMinor: number | null; iban: string | null; paymentReference: string | null; - duplicateOfId: number | null; + disposition: Disposition | null; + taxTreatment: TaxTreatment | null; + eventId: number | null; + categoryId: number | null; + markupType: MarkupType | null; + markupPercent: number | null; + markupFlatMinor: number | null; + billedInvoiceId: number | null; + supplierPaid: boolean; + supplierPaidAt: string | null; + supplierPaymentMethod: PaymentMethod | null; + supplierPaymentRef: string | null; createdAt: string; updatedAt: string; } +/** Internal expense (own cost). */ export interface Expense { id: number; - inboundDocumentId: number | null; - disposition: Disposition; - taxTreatment: TaxTreatment; - eventId: number | null; - customerAccountId: number | null; + kind: ExpenseKind; + quantity: number | null; + rateMinor: number | null; + eventId: number | null; // null = company supplierName: string | null; description: string | null; chfAmountMinor: number | null; - grossAmountMinor: number | null; - markupType: MarkupType; - markupPercent: number | null; - markupFlatMinor: number | null; categoryId: number | null; - billedInvoiceId: number | null; - supplierPaid: boolean; - status: 'open' | 'parked' | 'billed' | 'declined'; + hasProof: boolean; + taxTreatment: TaxTreatment | null; + status: string; createdAt: string; updatedAt: string; } -export interface ExpenseCategory { - id: number; - name: string; - color: string | null; - is_seed: boolean; - display_order: number; -} +export interface ExpenseCategory { id: number; name: string; color: string | null; is_seed: boolean; display_order: number; } +export interface Paginated { items: T[]; pagination: { page: number; pageSize: number; total: number; totalPages: number }; } -export interface Paginated { - items: T[]; - pagination: { page: number; pageSize: number; total: number; totalPages: number }; +export interface AccountingSettings { + accounting_km_rate_minor: number; + accounting_per_diem_rate_minor: number; + accounting_require_proof: boolean; } export interface CategorizePayload { disposition: Disposition; - supplierName?: string | null; - description?: string | null; - chfAmountMinor?: number | null; - netAmountMinor?: number | null; - vatAmountMinor?: number | null; - grossAmountMinor?: number | null; taxTreatment?: TaxTreatment; + eventId?: number | null; categoryId?: number | null; - eventId?: number | null; - customerAccountId?: number | null; - declineReason?: string | null; - duplicateOfId?: number | null; - markupType?: MarkupType; - markupPercent?: number | null; - markupFlatMinor?: number | null; -} - -export interface RebillPayload { - customerAccountId: number; - eventId?: number | null; + customerAccountId?: number | null; // re-bill contractId?: number | null; markupType?: MarkupType; markupPercent?: number | null; markupFlatMinor?: number | null; + duplicateOfId?: number | null; +} + +export interface ExpensePayload { + kind: ExpenseKind; + quantity?: number; + rateMinor?: number; // per-entry override + chfAmountMinor?: number; // kind 'amount' + eventId?: number | null; // null = company + categoryId?: number | null; + supplierName?: string | null; + description?: string | null; + taxTreatment?: TaxTreatment; +} + +function expenseFormData(payload: ExpensePayload, file?: File | null): FormData { + const fd = new FormData(); + fd.append('kind', payload.kind); + if (payload.quantity != null && Number.isFinite(payload.quantity)) fd.append('quantity', String(payload.quantity)); + if (payload.rateMinor != null && Number.isFinite(payload.rateMinor)) fd.append('rateMinor', String(payload.rateMinor)); + if (payload.chfAmountMinor != null && Number.isFinite(payload.chfAmountMinor)) fd.append('chfAmountMinor', String(payload.chfAmountMinor)); + if (payload.eventId != null) fd.append('eventId', String(payload.eventId)); + if (payload.categoryId != null) fd.append('categoryId', String(payload.categoryId)); + if (payload.supplierName) fd.append('supplierName', payload.supplierName); + if (payload.description) fd.append('description', payload.description); + if (payload.taxTreatment) fd.append('taxTreatment', payload.taxTreatment); + if (file) fd.append('proof', file); + return fd; } export const accountingService = { + // ── incoming invoices ── async uploadInbound(file: File, source: 'upload' | 'camera' = 'upload'): Promise { - const form = new FormData(); - form.append('file', file); - form.append('source', source); - const { data } = await api.post('/admin/expenses/inbound', form, { - headers: { 'Content-Type': 'multipart/form-data' }, - }); + const fd = new FormData(); fd.append('file', file); fd.append('source', source); + const { data } = await api.post('/admin/expenses/inbound', fd, { headers: { 'Content-Type': 'multipart/form-data' } }); return data.document; }, - async listInbound(params: { status?: InboundStatus; page?: number; pageSize?: number } = {}): Promise> { - const { data } = await api.get('/admin/expenses/inbound', { params }); - return data; + const { data } = await api.get('/admin/expenses/inbound', { params }); return data; }, + async getInbound(id: number): Promise { const { data } = await api.get(`/admin/expenses/inbound/${id}`); return data.document; }, + async updateInbound(id: number, fields: Partial): Promise { const { data } = await api.patch(`/admin/expenses/inbound/${id}`, fields); return data.document; }, + async categorizeInbound(id: number, payload: CategorizePayload): Promise { const { data } = await api.post(`/admin/expenses/inbound/${id}/categorize`, payload); return data.document; }, + async rebillInbound(id: number, payload: CategorizePayload): Promise<{ document: InboundDocument; invoiceId: number }> { const { data } = await api.post(`/admin/expenses/inbound/${id}/rebill`, payload); return data; }, + async markInboundPaid(id: number, payload: { paid: boolean; paidAt?: string; paymentMethod?: PaymentMethod; paymentReference?: string }): Promise { const { data } = await api.post(`/admin/expenses/inbound/${id}/supplier-payment`, payload); return data.document; }, + async getInboundFileBlob(id: number): Promise { const { data } = await api.get(`/admin/expenses/inbound/${id}/file`, { responseType: 'blob' }); return data; }, + async getInboundPageBlob(id: number, page: number): Promise { const { data } = await api.get(`/admin/expenses/inbound/${id}/page/${page}`, { responseType: 'blob' }); return data; }, - async getInbound(id: number): Promise { - const { data } = await api.get(`/admin/expenses/inbound/${id}`); - return data.document; + // ── expenses (internal) ── + async listExpenses(params: { kind?: ExpenseKind; categoryId?: number; eventId?: number | 'company'; page?: number; pageSize?: number } = {}): Promise> { + const { data } = await api.get('/admin/expenses', { params }); return data; }, - - async getInboundFileBlob(id: number): Promise { - const { data } = await api.get(`/admin/expenses/inbound/${id}/file`, { responseType: 'blob' }); - return data; - }, - - // Rasterised PDF page (PNG) — the raw PDF is never sent to the browser. - async getInboundPageBlob(id: number, page: number): Promise { - const { data } = await api.get(`/admin/expenses/inbound/${id}/page/${page}`, { responseType: 'blob' }); - return data; - }, - - async updateInbound(id: number, fields: Partial>): Promise { - const { data } = await api.patch(`/admin/expenses/inbound/${id}`, fields); - return data.document; - }, - - async categorizeInbound(id: number, payload: CategorizePayload): Promise { - const { data } = await api.post(`/admin/expenses/inbound/${id}/categorize`, payload); + async getExpense(id: number): Promise { const { data } = await api.get(`/admin/expenses/${id}`); return data.expense; }, + async createExpense(payload: ExpensePayload, file?: File | null): Promise { + const { data } = await api.post('/admin/expenses', expenseFormData(payload, file), { headers: { 'Content-Type': 'multipart/form-data' } }); return data.expense; }, - - // Create a manual expense (no inbound document). - async createExpense(payload: CategorizePayload): Promise { - const { data } = await api.post('/admin/expenses', payload); + async updateExpense(id: number, payload: ExpensePayload, file?: File | null): Promise { + const { data } = await api.patch(`/admin/expenses/${id}`, expenseFormData(payload, file), { headers: { 'Content-Type': 'multipart/form-data' } }); return data.expense; }, + async getExpenseProofBlob(id: number): Promise { const { data } = await api.get(`/admin/expenses/${id}/proof`, { responseType: 'blob' }); return data; }, - async rebill(expenseId: number, payload: RebillPayload): Promise<{ expense: Expense; invoiceId: number }> { - const { data } = await api.post(`/admin/expenses/${expenseId}/rebill`, payload); - return data; + // ── categories + settings ── + async listCategories(): Promise { const { data } = await api.get('/admin/expenses/categories'); return data.items; }, + async getSettings(): Promise { + const { data } = await api.get('/admin/settings/accounting'); + return { + accounting_km_rate_minor: Number(data.accounting_km_rate_minor) || 0, + accounting_per_diem_rate_minor: Number(data.accounting_per_diem_rate_minor) || 0, + accounting_require_proof: data.accounting_require_proof === true, + }; }, - - async setSupplierPayment(expenseId: number, payload: { paid: boolean; paidAt?: string; paymentMethod?: PaymentMethod; paymentReference?: string }): Promise { - const { data } = await api.post(`/admin/expenses/${expenseId}/supplier-payment`, payload); - return data.expense; - }, - - async listExpenses(params: { status?: string; disposition?: Disposition; customerAccountId?: number; eventId?: number; page?: number; pageSize?: number } = {}): Promise> { - const { data } = await api.get('/admin/expenses', { params }); - return data; - }, - - async listCategories(): Promise { - const { data } = await api.get('/admin/expenses/categories'); - return data.items; + async updateSettings(payload: Partial): Promise<{ updated: string[] }> { + const { data } = await api.put('/admin/settings/accounting', payload); return data; }, }; + +// Seed categories are stored with literal German names; localize them by a +// stable key. Admin-created categories show their own free-text name. +const SEED_CATEGORY_KEYS: Record = { + 'Infrastruktur & Miete': 'infrastructure', + 'Equipment & Hardware': 'equipment', + 'Software & Lizenzen': 'software', + 'Material & Verbrauch': 'material', + 'Reise & Spesen': 'travel', + 'Werbung & Marketing': 'marketing', + 'Dienstleistungen/Fremdleistungen': 'services', + 'Versicherungen & Gebühren': 'insurance', + 'Weiterbildung': 'training', + 'Sonstiges': 'other', +}; +export function categoryLabel(cat: ExpenseCategory, t: (k: string, d?: string) => string): string { + if (cat?.is_seed && SEED_CATEGORY_KEYS[cat.name]) return t(`accounting.category.${SEED_CATEGORY_KEYS[cat.name]}`, cat.name); + return cat?.name ?? ''; +}