From 2b5efebaff0ed05b99b84802811ee662911e9468 Mon Sep 17 00:00:00 2001 From: Luca <102960244+Luca-Timo@users.noreply.github.com> Date: Thu, 11 Jun 2026 00:31:05 +0200 Subject: [PATCH] feat(accounting): incoming-invoices inbox with camera capture + triage/re-bill MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the Accounting → Incoming invoices frontend on top of the existing /api/admin/expenses backend: - accounting.service.ts: typed client (inbound upload/list/get/update/ categorize, expense list, re-bill, supplier-payment, categories). - AccountingInboxPage: capture a supplier invoice via the device CAMERA () or a PDF/image upload; inbox list with status badges + parsed summary; a triage modal to confirm fields and pick a disposition (re-bill / pass-through / company expense / duplicate / declined). Re-bill uses the customer picker and mints an editable scheduled invoice (chains categorize -> rebill). - AccountingLayout: "Incoming invoices" sub-nav item + AccountingIndex that redirects /admin/accounting to the first enabled sub-feature. - App.tsx: /admin/accounting/inbox route (gated by incomingInvoices). - i18n: accounting.inbox/disposition/markup + subnav.incomingInvoices + common.saving (EN + DE, DE authored natively). Camera capture needs no native app — the mobile web input drives the device camera straight into the upload endpoint. OCR/QR auto-extraction is still a backend follow-up (extractionService is a no-op), so fields are confirmed manually in the triage modal for now. Verified: npm run build green; en/de JSON valid. --- frontend/src/App.tsx | 8 +- .../src/components/admin/AccountingLayout.tsx | 28 +- frontend/src/i18n/locales/de.json | 48 +++ frontend/src/i18n/locales/en.json | 48 +++ .../admin/accounting/AccountingInboxPage.tsx | 319 ++++++++++++++++++ frontend/src/services/accounting.service.ts | 148 ++++++++ 6 files changed, 591 insertions(+), 8 deletions(-) create mode 100644 frontend/src/pages/admin/accounting/AccountingInboxPage.tsx create mode 100644 frontend/src/services/accounting.service.ts diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index a15515c2..aee3c7c9 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -64,7 +64,8 @@ import { import { CustomerAuthProvider } from './contexts/CustomerAuthContext'; import { AdminLayout, AdminAuthWrapper } from './components/admin'; import { ClientsLayout } from './components/admin/ClientsLayout'; -import { AccountingLayout } from './components/admin/AccountingLayout'; +import { AccountingLayout, AccountingIndex } from './components/admin/AccountingLayout'; +import { AccountingInboxPage } from './pages/admin/accounting/AccountingInboxPage'; import { RequireFeature } from './components/admin/RequireFeature'; import { PageErrorBoundary, OfflineIndicator, SkipLink, DynamicFavicon, RobotsMetaTags, CMSContentBlock, Loading } from './components/common'; import { MaintenanceWrapper } from './components/MaintenanceWrapper'; @@ -268,10 +269,13 @@ function App() { pages. Each sub-route is independently flagged. */} }> }> + }> + } /> + }> } /> - } /> + } /> diff --git a/frontend/src/components/admin/AccountingLayout.tsx b/frontend/src/components/admin/AccountingLayout.tsx index e098b44b..81f85214 100644 --- a/frontend/src/components/admin/AccountingLayout.tsx +++ b/frontend/src/components/admin/AccountingLayout.tsx @@ -7,9 +7,9 @@ * expenses pages slot in as additional sub-nav entries when their UIs land. */ import React from 'react'; -import { NavLink, Outlet, useLocation, useNavigate } from 'react-router-dom'; +import { NavLink, Outlet, Navigate, useLocation, useNavigate } from 'react-router-dom'; import { useTranslation } from 'react-i18next'; -import { Landmark, Calculator } from 'lucide-react'; +import { Landmark, Calculator, Inbox } from 'lucide-react'; import type { LucideIcon } from 'lucide-react'; import { useFeatureFlags, type FeatureKey } from '../../contexts/FeatureFlagsContext'; @@ -29,6 +29,13 @@ export const AccountingLayout: React.FC = () => { const { flags } = useFeatureFlags(); const navItems: NavItem[] = [ + { + key: 'inbox', + to: '/admin/accounting/inbox', + label: t('accounting.subnav.incomingInvoices', 'Incoming invoices'), + icon: Inbox, + featureFlag: 'incomingInvoices', + }, { key: 'tax-report', to: '/admin/accounting/tax-report', @@ -36,10 +43,7 @@ export const AccountingLayout: React.FC = () => { icon: Calculator, featureFlag: 'taxReport', }, - // Future Accounting sub-features (inbound inbox, expenses) slot in here - // once their pages land, e.g.: - // { key: 'inbox', to: '/admin/accounting/inbox', featureFlag: 'accounting' } - // { key: 'expenses', to: '/admin/accounting/expenses', featureFlag: 'accounting' } + // Future: expenses ledger, Erfolgsrechnung. ]; const enabledItems = navItems.filter((item) => flags[item.featureFlag]); @@ -139,3 +143,15 @@ export const AccountingLayout: React.FC = () => { ); }; + +/** + * Index redirect for /admin/accounting — send to the first enabled + * sub-feature (Incoming invoices preferred, then Tax export). When none + * are on, render nothing; AccountingLayout shows its empty state. + */ +export const AccountingIndex: React.FC = () => { + const { flags } = useFeatureFlags(); + if (flags.incomingInvoices) return ; + if (flags.taxReport) return ; + return null; +}; diff --git a/frontend/src/i18n/locales/de.json b/frontend/src/i18n/locales/de.json index b2d133f0..23f68055 100644 --- a/frontend/src/i18n/locales/de.json +++ b/frontend/src/i18n/locales/de.json @@ -98,6 +98,7 @@ "error": "Fehler", "configureInSettings": "Standards in den Einstellungen anpassen ↗", "save": "Speichern", + "saving": "Speichern…", "cancel": "Abbrechen", "delete": "Löschen", "edit": "Bearbeiten", @@ -3398,7 +3399,54 @@ "body": "Aktiviere die Steuerliste (oder eine andere Buchhaltungs-Unterfunktion) unter Einstellungen → Funktionen, um loszulegen." }, "subnav": { + "incomingInvoices": "Eingangsrechnungen", "taxReport": "Steuer" + }, + "disposition": { + "rebill": "An Kunde weiterverrechnen", + "durchlaufend": "Durchlaufender Posten", + "eigener_aufwand": "Eigener Aufwand", + "duplikat": "Duplikat", + "abgelehnt": "Abgelehnt" + }, + "markup": { + "none": "Keiner / aus Vertrag", + "percent": "Prozent", + "flat": "Pauschal" + }, + "inbox": { + "captureTitle": "Lieferantenrechnung erfassen", + "captureBody": "Fotografiere eine Papierrechnung mit der Gerätekamera oder lade ein PDF / Bild hoch.", + "scanCamera": "Mit Kamera scannen", + "uploadFile": "Datei hochladen", + "capturedToast": "Dokument erfasst.", + "categorizedToast": "Dokument kategorisiert.", + "triageTitle": "Dokument kategorisieren", + "saveCategorize": "Speichern", + "categorize": "Kategorisieren", + "empty": "Noch keine Dokumente — oben eines erfassen.", + "untitled": "Unbenanntes Dokument", + "noAmount": "Betrag nicht erfasst", + "rebillHint": "Erstellt eine bearbeitbare geplante Rechnung beim Kunden. MwSt-/Steuerbehandlung ist v1 — mit Treuhänder prüfen.", + "status": { + "unsorted": "Neu", + "categorized": "Kategorisiert", + "declined": "Abgelehnt", + "duplicate": "Duplikat" + }, + "field": { + "supplier": "Lieferant", + "total": "Total", + "currency": "Währung", + "invoiceDate": "Rechnungsdatum", + "disposition": "Zuordnung", + "category": "Kategorie", + "categoryNone": "— keine —", + "declineReason": "Grund", + "customer": "Kunde", + "eventId": "Event-ID (optional)", + "markup": "Zuschlag" + } } }, "calendar": { diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json index 04438090..06b6f8c4 100644 --- a/frontend/src/i18n/locales/en.json +++ b/frontend/src/i18n/locales/en.json @@ -98,6 +98,7 @@ "error": "Error", "configureInSettings": "Configure defaults in Settings ↗", "save": "Save", + "saving": "Saving…", "cancel": "Cancel", "delete": "Delete", "edit": "Edit", @@ -3398,7 +3399,54 @@ "body": "Enable the Tax report (or another accounting sub-feature) under Settings → Features to get started." }, "subnav": { + "incomingInvoices": "Incoming invoices", "taxReport": "Tax" + }, + "disposition": { + "rebill": "Re-bill to client", + "durchlaufend": "Pass-through", + "eigener_aufwand": "Company expense", + "duplikat": "Duplicate", + "abgelehnt": "Declined" + }, + "markup": { + "none": "None / from contract", + "percent": "Percent", + "flat": "Flat" + }, + "inbox": { + "captureTitle": "Capture a supplier invoice", + "captureBody": "Photograph a paper invoice with your device camera, or upload a PDF / image.", + "scanCamera": "Scan with camera", + "uploadFile": "Upload file", + "capturedToast": "Document captured.", + "categorizedToast": "Document categorized.", + "triageTitle": "Categorize document", + "saveCategorize": "Save", + "categorize": "Categorize", + "empty": "No documents yet — capture one above.", + "untitled": "Untitled document", + "noAmount": "amount not entered", + "rebillHint": "Creates an editable scheduled invoice on the client. VAT/tax handling is v1 — verify with your Treuhänder.", + "status": { + "unsorted": "New", + "categorized": "Categorized", + "declined": "Declined", + "duplicate": "Duplicate" + }, + "field": { + "supplier": "Supplier", + "total": "Total", + "currency": "Currency", + "invoiceDate": "Invoice date", + "disposition": "Disposition", + "category": "Category", + "categoryNone": "— none —", + "declineReason": "Reason", + "customer": "Client", + "eventId": "Event ID (optional)", + "markup": "Markup" + } } }, "calendar": { diff --git a/frontend/src/pages/admin/accounting/AccountingInboxPage.tsx b/frontend/src/pages/admin/accounting/AccountingInboxPage.tsx new file mode 100644 index 00000000..562b40bf --- /dev/null +++ b/frontend/src/pages/admin/accounting/AccountingInboxPage.tsx @@ -0,0 +1,319 @@ +/** + * Accounting → Incoming invoices inbox ("Neu / Unsortiert"). + * + * 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. + */ +import React, { useRef, useState } 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 { 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, +} from '../../../services/accounting.service'; + +const DISPOSITIONS: Disposition[] = ['rebill', 'durchlaufend', 'eigener_aufwand', 'duplikat', 'abgelehnt']; + +const statusClasses: Record = { + unsorted: 'bg-amber-100 text-amber-800 dark:bg-amber-900/40 dark:text-amber-300', + categorized: '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', + 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 { t } = useTranslation(); + const [supplier, setSupplier] = useState(doc.supplierName || ''); + const [amountMajor, setAmountMajor] = useState(doc.totalAmountMinor != null ? doc.totalAmountMinor / 100 : NaN); + const [currency, setCurrency] = useState(doc.currency || 'CHF'); + const [invoiceDate, setInvoiceDate] = useState(doc.invoiceDate || ''); + const [disposition, setDisposition] = useState('eigener_aufwand'); + const [categoryId, setCategoryId] = useState(undefined); + const [declineReason, setDeclineReason] = useState(''); + 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; + + 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, { + disposition, + supplierName: supplier || null, + chfAmountMinor: totalMinor, + grossAmountMinor: totalMinor, + 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, + }); + // 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(); + }, + onError: (e: any) => toast.error(e?.response?.data?.error || e.message || 'Failed'), + }); + + const rebillNeedsCustomer = disposition === 'rebill' && !customer[0]; + + return ( +
+
+
+

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

+ +
+ +
+
+
+ + setSupplier(e.target.value)} /> +
+
+ + +
+
+ + setCurrency(e.target.value.toUpperCase())} maxLength={3} /> +
+
+ + +
+
+ +
+ + +
+ + {disposition === 'eigener_aufwand' && ( +
+ + +
+ )} + + {disposition === 'abgelehnt' && ( +
+ + setDeclineReason(e.target.value)} /> +
+ )} + + {disposition === 'rebill' && ( +
+
+ + setCustomer(next.slice(-1))} /> +
+
+
+ + setEventId(e.target.value.replace(/[^0-9]/g, ''))} inputMode="numeric" /> +
+
+ + +
+
+ {markupType !== 'none' && ( + + )} +

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

+
+ )} +
+ +
+ + +
+
+
+ ); +}; + +export const AccountingInboxPage: React.FC = () => { + const { t } = useTranslation(); + const qc = useQueryClient(); + const { format } = useLocalizedDate(); + const cameraRef = useRef(null); + const uploadRef = useRef(null); + const [triageDoc, setTriageDoc] = 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 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 + }, + onError: (e: any) => toast.error(e?.response?.data?.error || e.message || 'Upload 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 items = data?.items ?? []; + + return ( +
+ + + + + +
+

{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 ? ( +
+ +

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

+
+ ) : ( +
+ {items.map((doc) => ( +
+
+
+ + {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)} +
+
+ {doc.status === 'unsorted' && ( + + )} +
+ ))} +
+ )} + + {triageDoc && ( + setTriageDoc(null)} + onDone={() => { + setTriageDoc(null); + qc.invalidateQueries({ queryKey: ['accounting-inbound'] }); + }} + /> + )} +
+ ); +}; + +export default AccountingInboxPage; diff --git a/frontend/src/services/accounting.service.ts b/frontend/src/services/accounting.service.ts new file mode 100644 index 00000000..c60cb27c --- /dev/null +++ b/frontend/src/services/accounting.service.ts @@ -0,0 +1,148 @@ +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 interface InboundDocument { + id: number; + source: 'upload' | 'camera' | 'email' | 'manual'; + originalFilename: string | null; + mimeType: string | null; + status: InboundStatus; + parseStatus: 'pending' | 'parsed' | 'failed' | 'manual'; + parseMethod: string | null; + supplierName: string | null; + invoiceNumber: string | null; + invoiceDate: string | null; + dueDate: string | null; + currency: string | null; + netAmountMinor: number | null; + vatAmountMinor: number | null; + totalAmountMinor: number | null; + qrAmountMinor: number | null; + iban: string | null; + paymentReference: string | null; + duplicateOfId: number | null; + createdAt: string; + updatedAt: string; +} + +export interface Expense { + id: number; + inboundDocumentId: number | null; + disposition: Disposition; + taxTreatment: TaxTreatment; + eventId: number | null; + customerAccountId: number | null; + 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'; + createdAt: string; + updatedAt: string; +} + +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 CategorizePayload { + disposition: Disposition; + supplierName?: string | null; + chfAmountMinor?: number | null; + netAmountMinor?: number | null; + vatAmountMinor?: number | null; + grossAmountMinor?: number | null; + taxTreatment?: TaxTreatment; + 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; + contractId?: number | null; + markupType?: MarkupType; + markupPercent?: number | null; + markupFlatMinor?: number | null; +} + +export const accountingService = { + 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' }, + }); + return data.document; + }, + + async listInbound(params: { status?: InboundStatus; page?: number; pageSize?: number } = {}): Promise> { + 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.expense; + }, + + async rebill(expenseId: number, payload: RebillPayload): Promise<{ expense: Expense; invoiceId: number }> { + const { data } = await api.post(`/admin/expenses/${expenseId}/rebill`, payload); + return data; + }, + + 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; + }, +};