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 { CustomerAuthProvider } from './contexts/CustomerAuthContext';
|
||||||
import { AdminLayout, AdminAuthWrapper } from './components/admin';
|
import { AdminLayout, AdminAuthWrapper } from './components/admin';
|
||||||
import { ClientsLayout } from './components/admin/ClientsLayout';
|
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 { RequireFeature } from './components/admin/RequireFeature';
|
||||||
import { PageErrorBoundary, OfflineIndicator, SkipLink, DynamicFavicon, RobotsMetaTags, CMSContentBlock, Loading } from './components/common';
|
import { PageErrorBoundary, OfflineIndicator, SkipLink, DynamicFavicon, RobotsMetaTags, CMSContentBlock, Loading } from './components/common';
|
||||||
import { MaintenanceWrapper } from './components/MaintenanceWrapper';
|
import { MaintenanceWrapper } from './components/MaintenanceWrapper';
|
||||||
@@ -268,10 +269,13 @@ function App() {
|
|||||||
pages. Each sub-route is independently flagged. */}
|
pages. Each sub-route is independently flagged. */}
|
||||||
<Route element={<RequireFeature flag="accounting" />}>
|
<Route element={<RequireFeature flag="accounting" />}>
|
||||||
<Route path="accounting" element={<AccountingLayout />}>
|
<Route path="accounting" element={<AccountingLayout />}>
|
||||||
|
<Route element={<RequireFeature flag="incomingInvoices" />}>
|
||||||
|
<Route path="inbox" element={<AccountingInboxPage />} />
|
||||||
|
</Route>
|
||||||
<Route element={<RequireFeature flag="taxReport" />}>
|
<Route element={<RequireFeature flag="taxReport" />}>
|
||||||
<Route path="tax-report" element={<TaxReportPage />} />
|
<Route path="tax-report" element={<TaxReportPage />} />
|
||||||
</Route>
|
</Route>
|
||||||
<Route index element={<Navigate to="/admin/accounting/tax-report" replace />} />
|
<Route index element={<AccountingIndex />} />
|
||||||
</Route>
|
</Route>
|
||||||
</Route>
|
</Route>
|
||||||
|
|
||||||
|
|||||||
@@ -7,9 +7,9 @@
|
|||||||
* expenses pages slot in as additional sub-nav entries when their UIs land.
|
* expenses pages slot in as additional sub-nav entries when their UIs land.
|
||||||
*/
|
*/
|
||||||
import React from 'react';
|
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 { useTranslation } from 'react-i18next';
|
||||||
import { Landmark, Calculator } from 'lucide-react';
|
import { Landmark, Calculator, Inbox } from 'lucide-react';
|
||||||
import type { LucideIcon } from 'lucide-react';
|
import type { LucideIcon } from 'lucide-react';
|
||||||
import { useFeatureFlags, type FeatureKey } from '../../contexts/FeatureFlagsContext';
|
import { useFeatureFlags, type FeatureKey } from '../../contexts/FeatureFlagsContext';
|
||||||
|
|
||||||
@@ -29,6 +29,13 @@ export const AccountingLayout: React.FC = () => {
|
|||||||
const { flags } = useFeatureFlags();
|
const { flags } = useFeatureFlags();
|
||||||
|
|
||||||
const navItems: NavItem[] = [
|
const navItems: NavItem[] = [
|
||||||
|
{
|
||||||
|
key: 'inbox',
|
||||||
|
to: '/admin/accounting/inbox',
|
||||||
|
label: t('accounting.subnav.incomingInvoices', 'Incoming invoices'),
|
||||||
|
icon: Inbox,
|
||||||
|
featureFlag: 'incomingInvoices',
|
||||||
|
},
|
||||||
{
|
{
|
||||||
key: 'tax-report',
|
key: 'tax-report',
|
||||||
to: '/admin/accounting/tax-report',
|
to: '/admin/accounting/tax-report',
|
||||||
@@ -36,10 +43,7 @@ export const AccountingLayout: React.FC = () => {
|
|||||||
icon: Calculator,
|
icon: Calculator,
|
||||||
featureFlag: 'taxReport',
|
featureFlag: 'taxReport',
|
||||||
},
|
},
|
||||||
// Future Accounting sub-features (inbound inbox, expenses) slot in here
|
// Future: expenses ledger, Erfolgsrechnung.
|
||||||
// once their pages land, e.g.:
|
|
||||||
// { key: 'inbox', to: '/admin/accounting/inbox', featureFlag: 'accounting' }
|
|
||||||
// { key: 'expenses', to: '/admin/accounting/expenses', featureFlag: 'accounting' }
|
|
||||||
];
|
];
|
||||||
|
|
||||||
const enabledItems = navItems.filter((item) => flags[item.featureFlag]);
|
const enabledItems = navItems.filter((item) => flags[item.featureFlag]);
|
||||||
@@ -139,3 +143,15 @@ export const AccountingLayout: React.FC = () => {
|
|||||||
</div>
|
</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",
|
"error": "Fehler",
|
||||||
"configureInSettings": "Standards in den Einstellungen anpassen ↗",
|
"configureInSettings": "Standards in den Einstellungen anpassen ↗",
|
||||||
"save": "Speichern",
|
"save": "Speichern",
|
||||||
|
"saving": "Speichern…",
|
||||||
"cancel": "Abbrechen",
|
"cancel": "Abbrechen",
|
||||||
"delete": "Löschen",
|
"delete": "Löschen",
|
||||||
"edit": "Bearbeiten",
|
"edit": "Bearbeiten",
|
||||||
@@ -3398,7 +3399,54 @@
|
|||||||
"body": "Aktiviere die Steuerliste (oder eine andere Buchhaltungs-Unterfunktion) unter Einstellungen → Funktionen, um loszulegen."
|
"body": "Aktiviere die Steuerliste (oder eine andere Buchhaltungs-Unterfunktion) unter Einstellungen → Funktionen, um loszulegen."
|
||||||
},
|
},
|
||||||
"subnav": {
|
"subnav": {
|
||||||
|
"incomingInvoices": "Eingangsrechnungen",
|
||||||
"taxReport": "Steuer"
|
"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": {
|
"calendar": {
|
||||||
|
|||||||
@@ -98,6 +98,7 @@
|
|||||||
"error": "Error",
|
"error": "Error",
|
||||||
"configureInSettings": "Configure defaults in Settings ↗",
|
"configureInSettings": "Configure defaults in Settings ↗",
|
||||||
"save": "Save",
|
"save": "Save",
|
||||||
|
"saving": "Saving…",
|
||||||
"cancel": "Cancel",
|
"cancel": "Cancel",
|
||||||
"delete": "Delete",
|
"delete": "Delete",
|
||||||
"edit": "Edit",
|
"edit": "Edit",
|
||||||
@@ -3398,7 +3399,54 @@
|
|||||||
"body": "Enable the Tax report (or another accounting sub-feature) under Settings → Features to get started."
|
"body": "Enable the Tax report (or another accounting sub-feature) under Settings → Features to get started."
|
||||||
},
|
},
|
||||||
"subnav": {
|
"subnav": {
|
||||||
|
"incomingInvoices": "Incoming invoices",
|
||||||
"taxReport": "Tax"
|
"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": {
|
"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