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:
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
Reference in New Issue
Block a user