feat(accounting): incoming-invoices inbox with camera capture + triage/re-bill

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
  (<input accept="image/*" capture="environment">) 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.
This commit is contained in:
Luca
2026-06-11 00:31:05 +02:00
parent 2c351bf0c9
commit 2b5efebaff
6 changed files with 591 additions and 8 deletions
+6 -2
View File
@@ -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. */}
<Route element={<RequireFeature flag="accounting" />}>
<Route path="accounting" element={<AccountingLayout />}>
<Route element={<RequireFeature flag="incomingInvoices" />}>
<Route path="inbox" element={<AccountingInboxPage />} />
</Route>
<Route element={<RequireFeature flag="taxReport" />}>
<Route path="tax-report" element={<TaxReportPage />} />
</Route>
<Route index element={<Navigate to="/admin/accounting/tax-report" replace />} />
<Route index element={<AccountingIndex />} />
</Route>
</Route>
@@ -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 = () => {
</div>
);
};
/**
* 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 <Navigate to="/admin/accounting/inbox" replace />;
if (flags.taxReport) return <Navigate to="/admin/accounting/tax-report" replace />;
return null;
};
+48
View File
@@ -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": {
+48
View File
@@ -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": {
@@ -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<string, string> = {
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<number>(doc.totalAmountMinor != null ? doc.totalAmountMinor / 100 : NaN);
const [currency, setCurrency] = useState(doc.currency || 'CHF');
const [invoiceDate, setInvoiceDate] = useState(doc.invoiceDate || '');
const [disposition, setDisposition] = useState<Disposition>('eigener_aufwand');
const [categoryId, setCategoryId] = useState<number | undefined>(undefined);
const [declineReason, setDeclineReason] = useState('');
const [customer, setCustomer] = useState<SelectedCustomer[]>([]);
const [eventId, setEventId] = useState('');
const [markupType, setMarkupType] = useState<MarkupType>('none');
const [markupValue, setMarkupValue] = useState<number>(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 (
<div className="fixed inset-0 z-50 flex items-start justify-center overflow-y-auto bg-black/50 p-4">
<div className="mt-10 w-full max-w-lg rounded-xl bg-white dark:bg-neutral-900 shadow-xl">
<div className="flex items-center justify-between border-b border-neutral-200 dark:border-neutral-700 px-5 py-3">
<h2 className="text-lg font-semibold text-neutral-900 dark:text-neutral-100">
{t('accounting.inbox.triageTitle', 'Categorize document')}
</h2>
<button onClick={onClose} aria-label={t('common.close', 'Close')} className="text-neutral-400 hover:text-neutral-600">
<X className="w-5 h-5" />
</button>
</div>
<div className="px-5 py-4 space-y-4">
<div className="grid grid-cols-2 gap-3">
<div className="col-span-2">
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">{t('accounting.inbox.field.supplier', 'Supplier')}</label>
<Input value={supplier} onChange={(e) => setSupplier(e.target.value)} />
</div>
<div>
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">{t('accounting.inbox.field.total', 'Total')}</label>
<DecimalInput value={amountMajor} onChange={setAmountMajor} fractionDigits={2} className="w-full rounded-md border border-neutral-300 dark:border-neutral-600 bg-white dark:bg-neutral-800 px-3 py-2 text-sm" />
</div>
<div>
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">{t('accounting.inbox.field.currency', 'Currency')}</label>
<Input value={currency} onChange={(e) => setCurrency(e.target.value.toUpperCase())} maxLength={3} />
</div>
<div className="col-span-2">
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">{t('accounting.inbox.field.invoiceDate', 'Invoice date')}</label>
<LocalizedDateInput value={invoiceDate} onChange={setInvoiceDate} />
</div>
</div>
<div>
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">{t('accounting.inbox.field.disposition', 'Disposition')}</label>
<select
value={disposition}
onChange={(e) => setDisposition(e.target.value as Disposition)}
className="w-full rounded-md border border-neutral-300 dark:border-neutral-600 bg-white dark:bg-neutral-800 px-3 py-2 text-sm"
>
{DISPOSITIONS.map((d) => (
<option key={d} value={d}>{t(`accounting.disposition.${d}`, d)}</option>
))}
</select>
</div>
{disposition === 'eigener_aufwand' && (
<div>
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">{t('accounting.inbox.field.category', 'Category')}</label>
<select
value={categoryId ?? ''}
onChange={(e) => setCategoryId(e.target.value ? Number(e.target.value) : undefined)}
className="w-full rounded-md border border-neutral-300 dark:border-neutral-600 bg-white dark:bg-neutral-800 px-3 py-2 text-sm"
>
<option value="">{t('accounting.inbox.field.categoryNone', '— none —')}</option>
{categories.map((c) => <option key={c.id} value={c.id}>{c.name}</option>)}
</select>
</div>
)}
{disposition === 'abgelehnt' && (
<div>
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">{t('accounting.inbox.field.declineReason', 'Reason')}</label>
<Input value={declineReason} onChange={(e) => setDeclineReason(e.target.value)} />
</div>
)}
{disposition === 'rebill' && (
<div className="space-y-3 rounded-lg border border-neutral-200 dark:border-neutral-700 p-3">
<div>
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">{t('accounting.inbox.field.customer', 'Client')} *</label>
<CustomerAccountPicker value={customer.slice(0, 1)} onChange={(next) => setCustomer(next.slice(-1))} />
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">{t('accounting.inbox.field.eventId', 'Event ID (optional)')}</label>
<Input value={eventId} onChange={(e) => setEventId(e.target.value.replace(/[^0-9]/g, ''))} inputMode="numeric" />
</div>
<div>
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">{t('accounting.inbox.field.markup', 'Markup')}</label>
<select
value={markupType}
onChange={(e) => setMarkupType(e.target.value as MarkupType)}
className="w-full rounded-md border border-neutral-300 dark:border-neutral-600 bg-white dark:bg-neutral-800 px-3 py-2 text-sm"
>
<option value="none">{t('accounting.markup.none', 'None / from contract')}</option>
<option value="percent">{t('accounting.markup.percent', 'Percent')}</option>
<option value="flat">{t('accounting.markup.flat', 'Flat')}</option>
</select>
</div>
</div>
{markupType !== 'none' && (
<DecimalInput value={markupValue} onChange={setMarkupValue} fractionDigits={2}
className="w-full rounded-md border border-neutral-300 dark:border-neutral-600 bg-white dark:bg-neutral-800 px-3 py-2 text-sm"
placeholder={markupType === 'percent' ? '%' : currency} />
)}
<p className="text-xs text-neutral-500 dark:text-neutral-400">{t('accounting.inbox.rebillHint', 'Creates an editable scheduled invoice on the client. VAT/tax handling is v1 — verify with your Treuhänder.')}</p>
</div>
)}
</div>
<div className="flex justify-end gap-2 border-t border-neutral-200 dark:border-neutral-700 px-5 py-3">
<Button variant="outline" onClick={onClose}>{t('common.cancel', 'Cancel')}</Button>
<Button onClick={() => save.mutate()} disabled={save.isPending || rebillNeedsCustomer}>
{save.isPending ? t('common.saving', 'Saving…') : t('accounting.inbox.saveCategorize', 'Save')}
</Button>
</div>
</div>
</div>
);
};
export const AccountingInboxPage: React.FC = () => {
const { t } = useTranslation();
const qc = useQueryClient();
const { format } = useLocalizedDate();
const cameraRef = useRef<HTMLInputElement>(null);
const uploadRef = useRef<HTMLInputElement>(null);
const [triageDoc, setTriageDoc] = useState<InboundDocument | null>(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<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (file) upload.mutate({ file, source });
e.target.value = '';
};
const items = data?.items ?? [];
return (
<div>
<input ref={cameraRef} type="file" accept="image/*" capture="environment" className="hidden" onChange={onFile('camera')} />
<input ref={uploadRef} type="file" accept="image/*,application/pdf" className="hidden" onChange={onFile('upload')} />
<Card className="mb-6">
<CardContent className="flex flex-col sm:flex-row items-center gap-3 p-5">
<div className="flex-1">
<h2 className="text-base font-semibold text-neutral-900 dark:text-neutral-100">{t('accounting.inbox.captureTitle', 'Capture a supplier invoice')}</h2>
<p className="text-sm text-neutral-600 dark:text-neutral-400">{t('accounting.inbox.captureBody', 'Photograph a paper invoice with your device camera, or upload a PDF / image.')}</p>
</div>
<Button onClick={() => cameraRef.current?.click()} disabled={upload.isPending}>
<Camera className="w-4 h-4 mr-2" /> {t('accounting.inbox.scanCamera', 'Scan with camera')}
</Button>
<Button variant="outline" onClick={() => uploadRef.current?.click()} disabled={upload.isPending}>
<Upload className="w-4 h-4 mr-2" /> {t('accounting.inbox.uploadFile', 'Upload file')}
</Button>
</CardContent>
</Card>
{isLoading ? (
<Loading />
) : items.length === 0 ? (
<div className="rounded-xl border border-dashed border-neutral-300 dark:border-neutral-700 bg-neutral-50 dark:bg-neutral-900 p-8 text-center">
<Inbox className="w-10 h-10 mx-auto mb-3 text-neutral-400" />
<p className="text-sm text-neutral-600 dark:text-neutral-400">{t('accounting.inbox.empty', 'No documents yet — capture one above.')}</p>
</div>
) : (
<div className="space-y-2">
{items.map((doc) => (
<div key={doc.id} className="flex items-center gap-3 rounded-lg border border-neutral-200 dark:border-neutral-700 bg-white dark:bg-neutral-900 px-4 py-3">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className={`inline-block rounded px-2 py-0.5 text-xs font-medium ${statusClasses[doc.status] || ''}`}>
{t(`accounting.inbox.status.${doc.status}`, doc.status)}
</span>
<span className="text-sm font-medium text-neutral-900 dark:text-neutral-100 truncate">
{doc.supplierName || doc.originalFilename || t('accounting.inbox.untitled', 'Untitled document')}
</span>
{doc.source === 'camera' && <Camera className="w-3.5 h-3.5 text-neutral-400" />}
</div>
<div className="text-xs text-neutral-500 dark:text-neutral-400">
{doc.totalAmountMinor != null ? formatMoneyMinor(doc.totalAmountMinor, doc.currency || 'CHF') : t('accounting.inbox.noAmount', 'amount not entered')}
{' · '}
{format(doc.createdAt)}
</div>
</div>
{doc.status === 'unsorted' && (
<Button size="sm" onClick={() => setTriageDoc(doc)}>{t('accounting.inbox.categorize', 'Categorize')}</Button>
)}
</div>
))}
</div>
)}
{triageDoc && (
<TriageModal
doc={triageDoc}
categories={categories ?? []}
onClose={() => setTriageDoc(null)}
onDone={() => {
setTriageDoc(null);
qc.invalidateQueries({ queryKey: ['accounting-inbound'] });
}}
/>
)}
</div>
);
};
export default AccountingInboxPage;
+148
View File
@@ -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<T> {
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<InboundDocument> {
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<Paginated<InboundDocument>> {
const { data } = await api.get('/admin/expenses/inbound', { params });
return data;
},
async getInbound(id: number): Promise<InboundDocument> {
const { data } = await api.get(`/admin/expenses/inbound/${id}`);
return data.document;
},
async updateInbound(id: number, fields: Partial<Pick<InboundDocument,
'supplierName' | 'invoiceNumber' | 'invoiceDate' | 'dueDate' | 'currency' |
'netAmountMinor' | 'vatAmountMinor' | 'totalAmountMinor' | 'iban' | 'paymentReference'>>): Promise<InboundDocument> {
const { data } = await api.patch(`/admin/expenses/inbound/${id}`, fields);
return data.document;
},
async categorizeInbound(id: number, payload: CategorizePayload): Promise<Expense> {
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<Expense> {
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<Paginated<Expense>> {
const { data } = await api.get('/admin/expenses', { params });
return data;
},
async listCategories(): Promise<ExpenseCategory[]> {
const { data } = await api.get('/admin/expenses/categories');
return data.items;
},
};