feat(accounting): frontend rework - separate Incoming invoices vs Expenses (stage 2)

Matches the backend split. Incoming invoices and Expenses are now distinct
surfaces with no shared rows.

Incoming invoices (AccountingInboxPage): triage sets the disposition + booking
(event or company) ON the document; "Mark paid" / "Paid" toggle records
supplier payment HERE with the outstanding total shown; re-bill via the
customer picker + markup. PDF preview still rasterised (last page = QR-bill).

Expenses (ExpensesLedgerPage): internal own-costs only. Add form has a Type
dropdown (amount / mileage(km) / per-diem); km/per-diem switch the input to a
quantity + rate (default from accounting settings, per-entry override) with a
live computed amount; optional proof upload (required when the setting says so);
localized category; booked to an event or the company. Proof viewable per row.

Service: reworked to the new endpoints/shapes; categoryLabel() localizes seed
categories (custom stay free-text). i18n: accounting.booking / incoming /
expense / expenseKind / category (EN + DE, DE native).

Verified: tsc --noEmit clean (0 errors); en/de JSON valid; npm run build green.
This commit is contained in:
Luca
2026-06-11 12:47:55 +02:00
parent 5e78fb6475
commit f305541f90
5 changed files with 475 additions and 559 deletions
+48 -1
View File
@@ -3475,10 +3475,57 @@
"card": "Karte", "card": "Karte",
"other": "Sonstiges" "other": "Sonstiges"
}, },
"booking": {
"label": "Buchen auf",
"company": "Firma",
"event": "Event",
"eventId": "Event-ID"
},
"incoming": {
"triageTitle": "Eingangsrechnung kategorisieren",
"payTitle": "Lieferant als bezahlt markieren",
"outstanding": "Offen",
"markPaid": "Als bezahlt markieren",
"confirmPaid": "Als bezahlt markieren",
"paid": "Bezahlt",
"paidToast": "Als bezahlt markiert.",
"categorizedToast": "Kategorisiert."
},
"expense": {
"kind": "Art",
"km": "Kilometer",
"days": "Tage",
"rate": "Satz",
"rateDefault": "Standard {{rate}} — leer lassen, um ihn zu verwenden",
"computed": "Betrag",
"who": "Bezahlt von / Lieferant",
"whoHint": "z. B. Mitarbeitername oder Geschäft",
"proof": "Beleg",
"proofRequired": "Ein Beleg ist erforderlich.",
"viewProof": "Beleg"
},
"expenseKind": {
"amount": "Betrag",
"mileage": "Kilometer (km)",
"per_diem": "Spesenpauschale"
},
"category": {
"infrastructure": "Infrastruktur & Miete",
"equipment": "Equipment & Hardware",
"software": "Software & Lizenzen",
"material": "Material & Verbrauch",
"travel": "Reise & Spesen",
"marketing": "Werbung & Marketing",
"services": "Dienstleistungen/Fremdleistungen",
"insurance": "Versicherungen & Gebühren",
"training": "Weiterbildung",
"other": "Sonstiges"
},
"ledger": { "ledger": {
"allStatuses": "Alle Status", "allStatuses": "Alle Status",
"allDispositions": "Alle Zuordnungen", "allDispositions": "Alle Zuordnungen",
"empty": "Noch keine Aufwände — Dokumente im Eingang kategorisieren.", "allKinds": "Alle Arten",
"empty": "Noch keine Aufwände — oben einen hinzufügen.",
"untitled": "Aufwand", "untitled": "Aufwand",
"invoiceLink": "Rechnung", "invoiceLink": "Rechnung",
"paid": "Bezahlt", "paid": "Bezahlt",
+48 -1
View File
@@ -3475,10 +3475,57 @@
"card": "Card", "card": "Card",
"other": "Other" "other": "Other"
}, },
"booking": {
"label": "Book to",
"company": "Company",
"event": "Event",
"eventId": "Event ID"
},
"incoming": {
"triageTitle": "Categorize incoming invoice",
"payTitle": "Mark supplier paid",
"outstanding": "Outstanding",
"markPaid": "Mark paid",
"confirmPaid": "Mark paid",
"paid": "Paid",
"paidToast": "Marked as paid.",
"categorizedToast": "Categorized."
},
"expense": {
"kind": "Type",
"km": "Kilometres",
"days": "Days",
"rate": "Rate",
"rateDefault": "Default {{rate}} — leave blank to use it",
"computed": "Amount",
"who": "Paid by / vendor",
"whoHint": "e.g. coworker name or shop",
"proof": "Proof",
"proofRequired": "A proof file is required.",
"viewProof": "Proof"
},
"expenseKind": {
"amount": "Amount",
"mileage": "Mileage (km)",
"per_diem": "Per-diem"
},
"category": {
"infrastructure": "Infrastructure & rent",
"equipment": "Equipment & hardware",
"software": "Software & licenses",
"material": "Materials & supplies",
"travel": "Travel & expenses",
"marketing": "Advertising & marketing",
"services": "Services / subcontracting",
"insurance": "Insurance & fees",
"training": "Training",
"other": "Other"
},
"ledger": { "ledger": {
"allStatuses": "All statuses", "allStatuses": "All statuses",
"allDispositions": "All dispositions", "allDispositions": "All dispositions",
"empty": "No expenses yet — categorize documents in the inbox.", "allKinds": "All types",
"empty": "No expenses yet — add one above.",
"untitled": "Expense", "untitled": "Expense",
"invoiceLink": "Invoice", "invoiceLink": "Invoice",
"paid": "Paid", "paid": "Paid",
@@ -1,33 +1,28 @@
/** /**
* Accounting → Incoming invoices inbox ("Neu / Unsortiert"). * Accounting → Incoming invoices (external supplier invoices).
* *
* Capture a received supplier invoice via the phone/tablet CAMERA or a file * Capture (camera/upload) → triage (confirm fields + disposition + booking) →
* upload, then triage it: confirm the best-effort parsed fields and give it a * the supplier invoice is the payable: mark it PAID here, or re-bill it to a
* disposition (re-bill to a client, pass-through, company expense, duplicate, * client. PDFs are previewed as server-rasterised page images (never raw).
* 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, useEffect } from 'react'; import React, { useRef, useState, useEffect } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { toast } from 'react-toastify'; import { toast } from 'react-toastify';
import { Camera, Upload, Inbox, X } from 'lucide-react'; import { Camera, Upload, Inbox, X, CheckCircle2, Circle } from 'lucide-react';
import { Button, Card, CardContent, Input, LocalizedDateInput, Loading } from '../../../components/common'; import { Button, Card, CardContent, Input, LocalizedDateInput, Loading } from '../../../components/common';
import { DecimalInput } from '../../../components/common/DecimalInput'; import { DecimalInput } from '../../../components/common/DecimalInput';
import { CustomerAccountPicker, type SelectedCustomer } from '../../../components/admin/CustomerAccountPicker'; import { CustomerAccountPicker, type SelectedCustomer } from '../../../components/admin/CustomerAccountPicker';
import { formatMoneyMinor } from '../../../utils/money'; import { formatMoneyMinor } from '../../../utils/money';
import { useLocalizedDate } from '../../../hooks/useLocalizedDate'; import { useLocalizedDate } from '../../../hooks/useLocalizedDate';
import { import {
accountingService, accountingService, categoryLabel,
type InboundDocument, type InboundDocument, type Disposition, type MarkupType, type PaymentMethod, type ExpenseCategory,
type Disposition,
type MarkupType,
type ExpenseCategory,
} from '../../../services/accounting.service'; } from '../../../services/accounting.service';
const DISPOSITIONS: Disposition[] = ['rebill', 'durchlaufend', 'eigener_aufwand', 'duplikat', 'abgelehnt']; const DISPOSITIONS: Disposition[] = ['rebill', 'durchlaufend', 'eigener_aufwand', 'duplikat', 'abgelehnt'];
const PAYMENT_METHODS: PaymentMethod[] = ['bank_transfer', 'cash', 'twint', 'paypal', 'card', 'other'];
const BOOKING_DISPOSITIONS: Disposition[] = ['rebill', 'durchlaufend', 'eigener_aufwand'];
const statusClasses: Record<string, string> = { const statusClasses: Record<string, string> = {
unsorted: 'bg-amber-100 text-amber-800 dark:bg-amber-900/40 dark:text-amber-300', unsorted: 'bg-amber-100 text-amber-800 dark:bg-amber-900/40 dark:text-amber-300',
@@ -36,12 +31,69 @@ const statusClasses: Record<string, string> = {
duplicate: '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<{ const labelCls = 'block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1';
doc: InboundDocument; const selectCls = 'w-full rounded-md border border-neutral-300 dark:border-neutral-600 bg-white dark:bg-neutral-800 px-3 py-2 text-sm';
categories: ExpenseCategory[];
onClose: () => void; const BookingField: React.FC<{ eventId: number | null; onChange: (id: number | null) => void }> = ({ eventId, onChange }) => {
onDone: () => void; const { t } = useTranslation();
}> = ({ doc, categories, onClose, onDone }) => { const isEvent = eventId != null;
return (
<div>
<label className={labelCls}>{t('accounting.booking.label', 'Book to')}</label>
<div className="flex gap-2">
<select className={selectCls} style={{ maxWidth: 160 }} value={isEvent ? 'event' : 'company'}
onChange={(e) => onChange(e.target.value === 'event' ? (eventId ?? 0) : null)}>
<option value="company">{t('accounting.booking.company', 'Company')}</option>
<option value="event">{t('accounting.booking.event', 'Event')}</option>
</select>
{isEvent && (
<Input placeholder={t('accounting.booking.eventId', 'Event ID')} inputMode="numeric"
value={eventId ? String(eventId) : ''} onChange={(e) => onChange(Number(e.target.value.replace(/[^0-9]/g, '')) || 0)} />
)}
</div>
</div>
);
};
const PayModal: React.FC<{ doc: InboundDocument; onClose: () => void; onDone: () => void }> = ({ doc, onClose, onDone }) => {
const { t } = useTranslation();
const [paidAt, setPaidAt] = useState('');
const [method, setMethod] = useState<PaymentMethod>('bank_transfer');
const [reference, setReference] = useState(doc.paymentReference || '');
const save = useMutation({
mutationFn: () => accountingService.markInboundPaid(doc.id, { paid: true, paidAt: paidAt || undefined, paymentMethod: method, paymentReference: reference || undefined }),
onSuccess: () => { toast.success(t('accounting.incoming.paidToast', 'Marked as paid.')); onDone(); },
onError: (e: any) => toast.error(e?.response?.data?.error || e.message || 'Failed'),
});
return (
<div className="fixed inset-0 z-50 flex items-start justify-center bg-black/50 p-4">
<div className="mt-16 w-full max-w-sm 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-base font-semibold text-neutral-900 dark:text-neutral-100">{t('accounting.incoming.payTitle', 'Mark supplier paid')}</h2>
<button onClick={onClose} className="text-neutral-400 hover:text-neutral-600"><X className="w-5 h-5" /></button>
</div>
<div className="px-5 py-4 space-y-3">
<p className="text-sm text-neutral-600 dark:text-neutral-400">
{t('accounting.incoming.outstanding', 'Outstanding')}: <span className="font-semibold text-neutral-900 dark:text-neutral-100">{doc.totalAmountMinor != null ? formatMoneyMinor(doc.totalAmountMinor, doc.currency || 'CHF') : '—'}</span>
</p>
<div><label className={labelCls}>{t('accounting.ledger.paidDate', 'Payment date')}</label><LocalizedDateInput value={paidAt} onChange={setPaidAt} /></div>
<div><label className={labelCls}>{t('accounting.ledger.method', 'Method')}</label>
<select value={method} onChange={(e) => setMethod(e.target.value as PaymentMethod)} className={selectCls}>
{PAYMENT_METHODS.map((m) => <option key={m} value={m}>{t(`accounting.paymentMethod.${m}`, m)}</option>)}
</select>
</div>
<div><label className={labelCls}>{t('accounting.ledger.reference', 'Reference (optional)')}</label><Input value={reference} onChange={(e) => setReference(e.target.value)} /></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}>{save.isPending ? t('common.saving', 'Saving…') : t('accounting.incoming.confirmPaid', 'Mark paid')}</Button>
</div>
</div>
</div>
);
};
const TriageModal: React.FC<{ doc: InboundDocument; categories: ExpenseCategory[]; onClose: () => void; onDone: () => void }> = ({ doc, categories, onClose, onDone }) => {
const { t } = useTranslation(); const { t } = useTranslation();
const [supplier, setSupplier] = useState(doc.supplierName || ''); const [supplier, setSupplier] = useState(doc.supplierName || '');
const [amountMajor, setAmountMajor] = useState<number>(doc.totalAmountMinor != null ? doc.totalAmountMinor / 100 : NaN); const [amountMajor, setAmountMajor] = useState<number>(doc.totalAmountMinor != null ? doc.totalAmountMinor / 100 : NaN);
@@ -49,75 +101,46 @@ const TriageModal: React.FC<{
const [invoiceDate, setInvoiceDate] = useState(doc.invoiceDate || ''); const [invoiceDate, setInvoiceDate] = useState(doc.invoiceDate || '');
const [disposition, setDisposition] = useState<Disposition>('eigener_aufwand'); const [disposition, setDisposition] = useState<Disposition>('eigener_aufwand');
const [categoryId, setCategoryId] = useState<number | undefined>(undefined); const [categoryId, setCategoryId] = useState<number | undefined>(undefined);
const [declineReason, setDeclineReason] = useState(''); const [eventId, setEventId] = useState<number | null>(null);
const [customer, setCustomer] = useState<SelectedCustomer[]>([]); const [customer, setCustomer] = useState<SelectedCustomer[]>([]);
const [eventId, setEventId] = useState('');
const [markupType, setMarkupType] = useState<MarkupType>('none'); const [markupType, setMarkupType] = useState<MarkupType>('none');
const [markupValue, setMarkupValue] = useState<number>(NaN); const [markupValue, setMarkupValue] = useState<number>(NaN);
const totalMinor = Number.isFinite(amountMajor) ? Math.round(amountMajor * 100) : null; const totalMinor = Number.isFinite(amountMajor) ? Math.round(amountMajor * 100) : null;
// Authenticated preview. PDFs are shown as SERVER-RASTERISED page images // Rasterised preview (last page = QR-bill).
// (the raw PDF never reaches the browser); images stream directly. Start on
// the LAST page — the Swiss QR-bill payment part sits at its bottom (no OCR;
// the admin reads the slip and types the fields).
const isPdf = (doc.mimeType || '').includes('pdf'); const isPdf = (doc.mimeType || '').includes('pdf');
const pageCount = doc.pageCount || 1; const pageCount = doc.pageCount || 1;
const [page, setPage] = useState(pageCount); const [page, setPage] = useState(pageCount);
const [imgUrl, setImgUrl] = useState<string | null>(null); const [imgUrl, setImgUrl] = useState<string | null>(null);
const [previewError, setPreviewError] = useState(false); const [previewError, setPreviewError] = useState(false);
useEffect(() => { useEffect(() => {
let url: string | null = null; let url: string | null = null; let cancelled = false;
let cancelled = false; setImgUrl(null); setPreviewError(false);
setImgUrl(null); (isPdf ? accountingService.getInboundPageBlob(doc.id, page) : accountingService.getInboundFileBlob(doc.id))
setPreviewError(false); .then((b) => { if (!cancelled) { url = URL.createObjectURL(b); setImgUrl(url); } })
const fetcher = isPdf
? accountingService.getInboundPageBlob(doc.id, page)
: accountingService.getInboundFileBlob(doc.id);
fetcher
.then((blob) => { if (!cancelled) { url = URL.createObjectURL(blob); setImgUrl(url); } })
.catch(() => { if (!cancelled) setPreviewError(true); }); .catch(() => { if (!cancelled) setPreviewError(true); });
return () => { cancelled = true; if (url) URL.revokeObjectURL(url); }; return () => { cancelled = true; if (url) URL.revokeObjectURL(url); };
}, [doc.id, isPdf, page]); }, [doc.id, isPdf, page]);
const markupPayload = () => ({
markupType,
markupPercent: markupType === 'percent' && Number.isFinite(markupValue) ? markupValue : null,
markupFlatMinor: markupType === 'flat' && Number.isFinite(markupValue) ? Math.round(markupValue * 100) : null,
});
const save = useMutation({ const save = useMutation({
mutationFn: async () => { 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 });
await accountingService.updateInbound(doc.id, { await accountingService.categorizeInbound(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, disposition,
supplierName: supplier || null, eventId: BOOKING_DISPOSITIONS.includes(disposition) ? eventId : null,
chfAmountMinor: totalMinor,
grossAmountMinor: totalMinor,
categoryId: disposition === 'eigener_aufwand' ? (categoryId ?? null) : null, categoryId: disposition === 'eigener_aufwand' ? (categoryId ?? null) : null,
declineReason: disposition === 'abgelehnt' ? (declineReason || null) : null,
eventId: eventId ? Number(eventId) : null,
customerAccountId: disposition === 'rebill' && customer[0] ? customer[0].id : null, customerAccountId: disposition === 'rebill' && customer[0] ? customer[0].id : null,
markupType, ...markupPayload(),
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();
}, },
onSuccess: () => { toast.success(t('accounting.incoming.categorizedToast', 'Categorized.')); onDone(); },
onError: (e: any) => toast.error(e?.response?.data?.error || e.message || 'Failed'), onError: (e: any) => toast.error(e?.response?.data?.error || e.message || 'Failed'),
}); });
@@ -127,29 +150,15 @@ const TriageModal: React.FC<{
<div className="fixed inset-0 z-50 flex items-start justify-center overflow-y-auto bg-black/50 p-4"> <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-4xl rounded-xl bg-white dark:bg-neutral-900 shadow-xl"> <div className="mt-10 w-full max-w-4xl 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"> <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"> <h2 className="text-lg font-semibold text-neutral-900 dark:text-neutral-100">{t('accounting.incoming.triageTitle', 'Categorize incoming invoice')}</h2>
{t('accounting.inbox.triageTitle', 'Categorize document')} <button onClick={onClose} className="text-neutral-400 hover:text-neutral-600"><X className="w-5 h-5" /></button>
</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>
<div className="px-5 py-4 grid grid-cols-1 lg:grid-cols-2 gap-5"> <div className="px-5 py-4 grid grid-cols-1 lg:grid-cols-2 gap-5">
{/* Document preview — PDFs open at the last page (QR-bill area). */}
<div className="order-2 lg:order-1"> <div className="order-2 lg:order-1">
<div className="overflow-auto rounded-md border border-neutral-200 dark:border-neutral-700 bg-neutral-50 dark:bg-neutral-800" style={{ maxHeight: '60vh' }}> <div className="overflow-auto rounded-md border border-neutral-200 dark:border-neutral-700 bg-neutral-50 dark:bg-neutral-800" style={{ maxHeight: '60vh' }}>
{previewError ? ( {previewError ? <div className="flex h-[60vh] items-center justify-center px-3 text-center text-sm text-neutral-500">{t('accounting.inbox.previewError', 'Preview unavailable — enter the fields manually.')}</div>
<div className="flex h-[60vh] items-center justify-center px-3 text-center text-sm text-neutral-500"> : imgUrl ? <img src={imgUrl} alt="document page" className="w-full h-auto" />
{t('accounting.inbox.previewError', 'Preview unavailable — enter the fields manually.')} : <div className="flex h-[60vh] items-center justify-center text-sm text-neutral-500">{t('accounting.inbox.previewLoading', 'Loading preview…')}</div>}
</div>
) : imgUrl ? (
<img src={imgUrl} alt="document page" className="w-full h-auto" />
) : (
<div className="flex h-[60vh] items-center justify-center text-sm text-neutral-500">
{t('accounting.inbox.previewLoading', 'Loading preview…')}
</div>
)}
</div> </div>
{isPdf && pageCount > 1 && ( {isPdf && pageCount > 1 && (
<div className="mt-2 flex items-center justify-center gap-3 text-sm"> <div className="mt-2 flex items-center justify-center gap-3 text-sm">
@@ -158,108 +167,53 @@ const TriageModal: React.FC<{
<Button size="sm" variant="outline" onClick={() => setPage((p) => Math.min(pageCount, p + 1))} disabled={page >= pageCount}>{t('accounting.inbox.nextPage', 'Next')}</Button> <Button size="sm" variant="outline" onClick={() => setPage((p) => Math.min(pageCount, p + 1))} disabled={page >= pageCount}>{t('accounting.inbox.nextPage', 'Next')}</Button>
</div> </div>
)} )}
{isPdf && ( {isPdf && <p className="mt-1 text-center text-xs text-neutral-500 dark:text-neutral-400">{t('accounting.inbox.qrHint', 'Showing the last page — the Swiss QR-bill usually sits at the bottom.')}</p>}
<p className="mt-1 text-center text-xs text-neutral-500 dark:text-neutral-400">
{t('accounting.inbox.qrHint', 'Showing the last page — the Swiss QR-bill usually sits at the bottom.')}
</p>
)}
</div> </div>
{/* Triage form */}
<div className="order-1 lg:order-2 space-y-4"> <div className="order-1 lg:order-2 space-y-4">
<div className="grid grid-cols-2 gap-3"> <div className="grid grid-cols-2 gap-3">
<div className="col-span-2"> <div className="col-span-2"><label className={labelCls}>{t('accounting.inbox.field.supplier', 'Supplier')}</label><Input value={supplier} onChange={(e) => setSupplier(e.target.value)} /></div>
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">{t('accounting.inbox.field.supplier', 'Supplier')}</label> <div><label className={labelCls}>{t('accounting.inbox.field.total', 'Total')}</label><DecimalInput value={amountMajor} onChange={setAmountMajor} fractionDigits={2} className={selectCls} /></div>
<Input value={supplier} onChange={(e) => setSupplier(e.target.value)} /> <div><label className={labelCls}>{t('accounting.inbox.field.currency', 'Currency')}</label><Input value={currency} maxLength={3} onChange={(e) => setCurrency(e.target.value.toUpperCase())} /></div>
</div> <div className="col-span-2"><label className={labelCls}>{t('accounting.inbox.field.invoiceDate', 'Invoice date')}</label><LocalizedDateInput value={invoiceDate} onChange={setInvoiceDate} /></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>
<div> <div><label className={labelCls}>{t('accounting.inbox.field.disposition', 'Disposition')}</label>
<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={selectCls}>
<select {DISPOSITIONS.map((d) => <option key={d} value={d}>{t(`accounting.disposition.${d}`, d)}</option>)}
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> </select>
</div> </div>
{BOOKING_DISPOSITIONS.includes(disposition) && <BookingField eventId={eventId} onChange={setEventId} />}
{disposition === 'eigener_aufwand' && ( {disposition === 'eigener_aufwand' && (
<div> <div><label className={labelCls}>{t('accounting.inbox.field.category', 'Category')}</label>
<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={selectCls}>
<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> <option value="">{t('accounting.inbox.field.categoryNone', '— none —')}</option>
{categories.map((c) => <option key={c.id} value={c.id}>{c.name}</option>)} {categories.map((c) => <option key={c.id} value={c.id}>{categoryLabel(c, t)}</option>)}
</select> </select>
</div> </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' && ( {disposition === 'rebill' && (
<div className="space-y-3 rounded-lg border border-neutral-200 dark:border-neutral-700 p-3"> <div className="space-y-3 rounded-lg border border-neutral-200 dark:border-neutral-700 p-3">
<div> <div><label className={labelCls}>{t('accounting.inbox.field.customer', 'Client')} *</label>
<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>
<CustomerAccountPicker value={customer.slice(0, 1)} onChange={(next) => setCustomer(next.slice(-1))} /> <div><label className={labelCls}>{t('accounting.inbox.field.markup', 'Markup')}</label>
</div> <select value={markupType} onChange={(e) => setMarkupType(e.target.value as MarkupType)} className={selectCls}>
<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="none">{t('accounting.markup.none', 'None / from contract')}</option>
<option value="percent">{t('accounting.markup.percent', 'Percent')}</option> <option value="percent">{t('accounting.markup.percent', 'Percent')}</option>
<option value="flat">{t('accounting.markup.flat', 'Flat')}</option> <option value="flat">{t('accounting.markup.flat', 'Flat')}</option>
</select> </select>
</div> </div>
</div> {markupType !== 'none' && <DecimalInput value={markupValue} onChange={setMarkupValue} fractionDigits={2} className={selectCls} placeholder={markupType === 'percent' ? '%' : currency} />}
{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> </div>
</div> </div>
<div className="flex justify-end gap-2 border-t border-neutral-200 dark:border-neutral-700 px-5 py-3"> <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 variant="outline" onClick={onClose}>{t('common.cancel', 'Cancel')}</Button>
<Button onClick={() => save.mutate()} disabled={save.isPending || rebillNeedsCustomer}> <Button onClick={() => save.mutate()} disabled={save.isPending || rebillNeedsCustomer}>{save.isPending ? t('common.saving', 'Saving…') : t('accounting.inbox.saveCategorize', 'Save')}</Button>
{save.isPending ? t('common.saving', 'Saving…') : t('accounting.inbox.saveCategorize', 'Save')}
</Button>
</div> </div>
</div> </div>
</div> </div>
@@ -273,32 +227,26 @@ export const AccountingInboxPage: React.FC = () => {
const cameraRef = useRef<HTMLInputElement>(null); const cameraRef = useRef<HTMLInputElement>(null);
const uploadRef = useRef<HTMLInputElement>(null); const uploadRef = useRef<HTMLInputElement>(null);
const [triageDoc, setTriageDoc] = useState<InboundDocument | null>(null); const [triageDoc, setTriageDoc] = useState<InboundDocument | null>(null);
const [payDoc, setPayDoc] = useState<InboundDocument | null>(null);
const { data, isLoading } = useQuery({ const { data, isLoading } = useQuery({ queryKey: ['accounting-inbound'], queryFn: () => accountingService.listInbound({ pageSize: 100 }) });
queryKey: ['accounting-inbound'], const { data: categories } = useQuery({ queryKey: ['expense-categories'], queryFn: () => accountingService.listCategories() });
queryFn: () => accountingService.listInbound({ pageSize: 100 }),
});
const { data: categories } = useQuery({
queryKey: ['expense-categories'],
queryFn: () => accountingService.listCategories(),
});
const upload = useMutation({ const upload = useMutation({
mutationFn: ({ file, source }: { file: File; source: 'upload' | 'camera' }) => accountingService.uploadInbound(file, source), mutationFn: ({ file, source }: { file: File; source: 'upload' | 'camera' }) => accountingService.uploadInbound(file, source),
onSuccess: (doc) => { onSuccess: (doc) => { toast.success(t('accounting.inbox.capturedToast', 'Document captured.')); qc.invalidateQueries({ queryKey: ['accounting-inbound'] }); if (doc.status === 'unsorted') setTriageDoc(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'), onError: (e: any) => toast.error(e?.response?.data?.error || e.message || 'Upload failed'),
}); });
const unpay = useMutation({
mutationFn: (id: number) => accountingService.markInboundPaid(id, { paid: false }),
onSuccess: () => { qc.invalidateQueries({ queryKey: ['accounting-inbound'] }); },
onError: (e: any) => toast.error(e?.response?.data?.error || e.message || 'Failed'),
});
const onFile = (source: 'upload' | 'camera') => (e: React.ChangeEvent<HTMLInputElement>) => { const onFile = (source: 'upload' | 'camera') => (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]; const file = e.target.files?.[0]; if (file) upload.mutate({ file, source }); e.target.value = '';
if (file) upload.mutate({ file, source });
e.target.value = '';
}; };
const refresh = () => qc.invalidateQueries({ queryKey: ['accounting-inbound'] });
const items = data?.items ?? []; const items = data?.items ?? [];
return ( return (
@@ -306,24 +254,16 @@ export const AccountingInboxPage: React.FC = () => {
<input ref={cameraRef} type="file" accept="image/*" capture="environment" className="hidden" onChange={onFile('camera')} /> <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')} /> <input ref={uploadRef} type="file" accept="image/*,application/pdf" className="hidden" onChange={onFile('upload')} />
<Card className="mb-6"> <Card className="mb-6"><CardContent className="flex flex-col sm:flex-row items-center gap-3 p-5">
<CardContent className="flex flex-col sm:flex-row items-center gap-3 p-5">
<div className="flex-1"> <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> <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> <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> </div>
<Button onClick={() => cameraRef.current?.click()} disabled={upload.isPending}> <Button onClick={() => cameraRef.current?.click()} disabled={upload.isPending}><Camera className="w-4 h-4 mr-2" /> {t('accounting.inbox.scanCamera', 'Scan with camera')}</Button>
<Camera className="w-4 h-4 mr-2" /> {t('accounting.inbox.scanCamera', 'Scan with camera')} <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>
</Button> </CardContent></Card>
<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 ? ( {isLoading ? <Loading /> : items.length === 0 ? (
<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"> <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" /> <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> <p className="text-sm text-neutral-600 dark:text-neutral-400">{t('accounting.inbox.empty', 'No documents yet — capture one above.')}</p>
@@ -331,42 +271,32 @@ export const AccountingInboxPage: React.FC = () => {
) : ( ) : (
<div className="space-y-2"> <div className="space-y-2">
{items.map((doc) => ( {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 key={doc.id} className="flex flex-wrap 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-1 min-w-[12rem]">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className={`inline-block rounded px-2 py-0.5 text-xs font-medium ${statusClasses[doc.status] || ''}`}> <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>
{t(`accounting.inbox.status.${doc.status}`, doc.status)} <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>
</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" />} {doc.source === 'camera' && <Camera className="w-3.5 h-3.5 text-neutral-400" />}
</div> </div>
<div className="text-xs text-neutral-500 dark:text-neutral-400"> <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')} {doc.totalAmountMinor != null ? formatMoneyMinor(doc.totalAmountMinor, doc.currency || 'CHF') : t('accounting.inbox.noAmount', 'amount not entered')}
{' · '} {' · '}{format(doc.createdAt)}
{format(doc.createdAt)} {doc.disposition && <>{' · '}{t(`accounting.disposition.${doc.disposition}`, doc.disposition)}</>}
</div> </div>
</div> </div>
{doc.status === 'unsorted' && ( {doc.status !== 'declined' && doc.status !== 'duplicate' && (
<Button size="sm" onClick={() => setTriageDoc(doc)}>{t('accounting.inbox.categorize', 'Categorize')}</Button> doc.supplierPaid
? <button onClick={() => unpay.mutate(doc.id)} disabled={unpay.isPending} className="inline-flex items-center gap-1 rounded-md px-2 py-1 text-xs font-medium text-green-700 dark:text-green-300 hover:bg-green-50 dark:hover:bg-green-900/30"><CheckCircle2 className="w-4 h-4" /> {t('accounting.incoming.paid', 'Paid')}</button>
: <Button size="sm" variant="outline" onClick={() => setPayDoc(doc)}><Circle className="w-3.5 h-3.5 mr-1" /> {t('accounting.incoming.markPaid', 'Mark paid')}</Button>
)} )}
{doc.status === 'unsorted' && <Button size="sm" onClick={() => setTriageDoc(doc)}>{t('accounting.inbox.categorize', 'Categorize')}</Button>}
</div> </div>
))} ))}
</div> </div>
)} )}
{triageDoc && ( {triageDoc && <TriageModal doc={triageDoc} categories={categories ?? []} onClose={() => setTriageDoc(null)} onDone={() => { setTriageDoc(null); refresh(); }} />}
<TriageModal {payDoc && <PayModal doc={payDoc} onClose={() => setPayDoc(null)} onDone={() => { setPayDoc(null); refresh(); }} />}
doc={triageDoc}
categories={categories ?? []}
onClose={() => setTriageDoc(null)}
onDone={() => {
setTriageDoc(null);
qc.invalidateQueries({ queryKey: ['accounting-inbound'] });
}}
/>
)}
</div> </div>
); );
}; };
@@ -1,210 +1,129 @@
/** /**
* Accounting Expenses ledger. * Accounting Expenses (internal own costs).
* *
* Lists the expenses booked from triaged inbound documents (and manual ones). * Mileage (km) / per-diem (days) / plain amount, booked to an event or the
* Shows disposition + status, the linked client invoice for re-billed items, * company, with an optional proof file (required when the accounting setting
* and a supplier-payment toggle ("Zu zahlen / Bezahlt") with method capture * says so). Rates default from the Accounting settings tab, overridable per
* decoupled from categorisation, per the locked design. * entry. No supplier payment here that lives on incoming invoices.
*/ */
import React, { useState } from 'react'; import React, { useState, useMemo } from 'react';
import { Link } from 'react-router-dom';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { toast } from 'react-toastify'; import { toast } from 'react-toastify';
import { CheckCircle2, Circle, X, ExternalLink, Plus } from 'lucide-react'; import { X, Plus, Paperclip, Car, CalendarDays, Coins } from 'lucide-react';
import { Button, Card, CardContent, Input, LocalizedDateInput, Loading } from '../../../components/common'; import { Button, Card, CardContent, Input, Loading } from '../../../components/common';
import { DecimalInput } from '../../../components/common/DecimalInput'; import { DecimalInput } from '../../../components/common/DecimalInput';
import { CustomerAccountPicker, type SelectedCustomer } from '../../../components/admin/CustomerAccountPicker';
import { formatMoneyMinor } from '../../../utils/money'; import { formatMoneyMinor } from '../../../utils/money';
import { useLocalizedDate } from '../../../hooks/useLocalizedDate'; import { useLocalizedDate } from '../../../hooks/useLocalizedDate';
import { import {
accountingService, accountingService, categoryLabel,
type Expense, type Expense, type ExpenseKind, type ExpenseCategory,
type Disposition,
type MarkupType,
type PaymentMethod,
} from '../../../services/accounting.service'; } from '../../../services/accounting.service';
const DISPOSITIONS: Disposition[] = ['rebill', 'durchlaufend', 'eigener_aufwand', 'duplikat', 'abgelehnt']; const KINDS: ExpenseKind[] = ['amount', 'mileage', 'per_diem'];
const STATUSES = ['open', 'parked', 'billed', 'declined']; const labelCls = 'block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1';
const PAYMENT_METHODS: PaymentMethod[] = ['bank_transfer', 'cash', 'twint', 'paypal', 'card', 'other']; const selectCls = 'w-full rounded-md border border-neutral-300 dark:border-neutral-600 bg-white dark:bg-neutral-800 px-3 py-2 text-sm';
const kindIcon: Record<ExpenseKind, React.ReactNode> = {
const statusClasses: Record<string, string> = { amount: <Coins className="w-3.5 h-3.5" />, mileage: <Car className="w-3.5 h-3.5" />, per_diem: <CalendarDays className="w-3.5 h-3.5" />,
open: 'bg-amber-100 text-amber-800 dark:bg-amber-900/40 dark:text-amber-300',
parked: 'bg-blue-100 text-blue-800 dark:bg-blue-900/40 dark:text-blue-300',
billed: 'bg-green-100 text-green-800 dark:bg-green-900/40 dark:text-green-300',
declined: 'bg-neutral-200 text-neutral-700 dark:bg-neutral-700 dark:text-neutral-300',
}; };
const PayModal: React.FC<{ expense: Expense; onClose: () => void; onDone: () => void }> = ({ expense, onClose, onDone }) => { const AddExpenseModal: React.FC<{ categories: ExpenseCategory[]; onClose: () => void; onDone: () => void }> = ({ categories, onClose, onDone }) => {
const { t } = useTranslation();
const [paidAt, setPaidAt] = useState('');
const [method, setMethod] = useState<PaymentMethod>('bank_transfer');
const [reference, setReference] = useState('');
const save = useMutation({
mutationFn: () => accountingService.setSupplierPayment(expense.id, {
paid: true, paidAt: paidAt || undefined, paymentMethod: method, paymentReference: reference || undefined,
}),
onSuccess: () => { toast.success(t('accounting.ledger.paidToast', 'Marked as paid.')); onDone(); },
onError: (e: any) => toast.error(e?.response?.data?.error || e.message || 'Failed'),
});
return (
<div className="fixed inset-0 z-50 flex items-start justify-center bg-black/50 p-4">
<div className="mt-16 w-full max-w-sm 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-base font-semibold text-neutral-900 dark:text-neutral-100">{t('accounting.ledger.markPaidTitle', 'Mark supplier paid')}</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-3">
<div>
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">{t('accounting.ledger.paidDate', 'Payment date')}</label>
<LocalizedDateInput value={paidAt} onChange={setPaidAt} />
</div>
<div>
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">{t('accounting.ledger.method', 'Method')}</label>
<select value={method} onChange={(e) => setMethod(e.target.value as PaymentMethod)}
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">
{PAYMENT_METHODS.map((m) => <option key={m} value={m}>{t(`accounting.paymentMethod.${m}`, m)}</option>)}
</select>
</div>
<div>
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">{t('accounting.ledger.reference', 'Reference (optional)')}</label>
<Input value={reference} onChange={(e) => setReference(e.target.value)} />
</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}>{save.isPending ? t('common.saving', 'Saving…') : t('accounting.ledger.confirmPaid', 'Mark paid')}</Button>
</div>
</div>
</div>
);
};
const MANUAL_DISPOSITIONS: Disposition[] = ['eigener_aufwand', 'durchlaufend', 'rebill'];
const AddExpenseModal: React.FC<{ onClose: () => void; onDone: () => void }> = ({ onClose, onDone }) => {
const { t } = useTranslation(); const { t } = useTranslation();
const { data: settings } = useQuery({ queryKey: ['accounting-settings'], queryFn: () => accountingService.getSettings() });
const [kind, setKind] = useState<ExpenseKind>('amount');
const [supplier, setSupplier] = useState(''); const [supplier, setSupplier] = useState('');
const [description, setDescription] = useState(''); const [description, setDescription] = useState('');
const [amountMajor, setAmountMajor] = useState<number>(NaN); const [amountMajor, setAmountMajor] = useState<number>(NaN);
const [currency, setCurrency] = useState('CHF'); const [quantity, setQuantity] = useState<number>(NaN);
const [disposition, setDisposition] = useState<Disposition>('eigener_aufwand'); const [rateMajor, setRateMajor] = useState<number>(NaN); // per-entry override (major units)
const [categoryId, setCategoryId] = useState<number | undefined>(undefined); const [categoryId, setCategoryId] = useState<number | undefined>(undefined);
const [customer, setCustomer] = useState<SelectedCustomer[]>([]); const [eventId, setEventId] = useState<number | null>(null);
const [eventId, setEventId] = useState(''); const [file, setFile] = useState<File | null>(null);
const [markupType, setMarkupType] = useState<MarkupType>('none');
const [markupValue, setMarkupValue] = useState<number>(NaN);
const { data: categories } = useQuery({ queryKey: ['expense-categories'], queryFn: () => accountingService.listCategories() }); const defaultRateMinor = kind === 'mileage' ? (settings?.accounting_km_rate_minor ?? 0)
const totalMinor = Number.isFinite(amountMajor) ? Math.round(amountMajor * 100) : null; : kind === 'per_diem' ? (settings?.accounting_per_diem_rate_minor ?? 0) : 0;
const selectClass = 'w-full rounded-md border border-neutral-300 dark:border-neutral-600 bg-white dark:bg-neutral-800 px-3 py-2 text-sm'; const effRateMinor = Number.isFinite(rateMajor) ? Math.round(rateMajor * 100) : defaultRateMinor;
const computedMinor = useMemo(() => {
if (kind === 'amount') return Number.isFinite(amountMajor) ? Math.round(amountMajor * 100) : null;
return Number.isFinite(quantity) ? Math.round(quantity * effRateMinor) : null;
}, [kind, amountMajor, quantity, effRateMinor]);
const requireProof = !!settings?.accounting_require_proof;
const save = useMutation({ const save = useMutation({
mutationFn: async () => { mutationFn: () => accountingService.createExpense({
const expense = await accountingService.createExpense({ kind,
disposition, quantity: kind === 'amount' ? undefined : (Number.isFinite(quantity) ? quantity : undefined),
rateMinor: kind === 'amount' ? undefined : (Number.isFinite(rateMajor) ? Math.round(rateMajor * 100) : undefined),
chfAmountMinor: kind === 'amount' && Number.isFinite(amountMajor) ? Math.round(amountMajor * 100) : undefined,
eventId,
categoryId: categoryId ?? null,
supplierName: supplier || null, supplierName: supplier || null,
description: description || null, description: description || null,
chfAmountMinor: totalMinor, }, file),
grossAmountMinor: totalMinor,
categoryId: disposition === 'eigener_aufwand' ? (categoryId ?? null) : null,
eventId: eventId ? Number(eventId) : null,
customerAccountId: disposition === 'rebill' && customer[0] ? customer[0].id : null,
markupType,
markupPercent: markupType === 'percent' && Number.isFinite(markupValue) ? markupValue : null,
markupFlatMinor: markupType === 'flat' && Number.isFinite(markupValue) ? Math.round(markupValue * 100) : null,
});
if (disposition === 'rebill') {
await accountingService.rebill(expense.id, {
customerAccountId: customer[0].id,
eventId: eventId ? Number(eventId) : null,
markupType,
markupPercent: markupType === 'percent' && Number.isFinite(markupValue) ? markupValue : null,
markupFlatMinor: markupType === 'flat' && Number.isFinite(markupValue) ? Math.round(markupValue * 100) : null,
});
}
},
onSuccess: () => { toast.success(t('accounting.ledger.createdToast', 'Expense added.')); onDone(); }, onSuccess: () => { toast.success(t('accounting.ledger.createdToast', 'Expense added.')); onDone(); },
onError: (e: any) => toast.error(e?.response?.data?.error || e.message || 'Failed'), onError: (e: any) => toast.error(e?.response?.data?.error || e.message || 'Failed'),
}); });
const rebillNeedsCustomer = disposition === 'rebill' && !customer[0]; const qtyLabel = kind === 'mileage' ? t('accounting.expense.km', 'Kilometres') : t('accounting.expense.days', 'Days');
const incomplete = (kind === 'amount' && !Number.isFinite(amountMajor)) || (kind !== 'amount' && !Number.isFinite(quantity)) || (requireProof && !file);
return ( return (
<div className="fixed inset-0 z-50 flex items-start justify-center overflow-y-auto bg-black/50 p-4"> <div className="fixed inset-0 z-50 flex items-start justify-center overflow-y-auto bg-black/50 p-4">
<div className="mt-12 w-full max-w-md rounded-xl bg-white dark:bg-neutral-900 shadow-xl"> <div className="mt-12 w-full max-w-md 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"> <div className="flex items-center justify-between border-b border-neutral-200 dark:border-neutral-700 px-5 py-3">
<h2 className="text-base font-semibold text-neutral-900 dark:text-neutral-100">{t('accounting.ledger.addTitle', 'Add expense')}</h2> <h2 className="text-base font-semibold text-neutral-900 dark:text-neutral-100">{t('accounting.ledger.addTitle', 'Add expense')}</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> <button onClick={onClose} className="text-neutral-400 hover:text-neutral-600"><X className="w-5 h-5" /></button>
</div> </div>
<div className="px-5 py-4 space-y-3"> <div className="px-5 py-4 space-y-3">
<div> <div><label className={labelCls}>{t('accounting.expense.kind', 'Type')}</label>
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">{t('accounting.inbox.field.supplier', 'Supplier')}</label> <select value={kind} onChange={(e) => setKind(e.target.value as ExpenseKind)} className={selectCls}>
<Input value={supplier} onChange={(e) => setSupplier(e.target.value)} /> {KINDS.map((k) => <option key={k} value={k}>{t(`accounting.expenseKind.${k}`, k)}</option>)}
</div>
<div>
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">{t('accounting.ledger.description', 'Description')}</label>
<Input value={description} onChange={(e) => setDescription(e.target.value)} placeholder={t('accounting.ledger.descriptionHint', 'e.g. mileage, per-diem, cash receipt')} />
</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.total', 'Total')}</label>
<DecimalInput value={amountMajor} onChange={setAmountMajor} fractionDigits={2} className={selectClass} />
</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>
<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={selectClass}>
{MANUAL_DISPOSITIONS.map((d) => <option key={d} value={d}>{t(`accounting.disposition.${d}`, d)}</option>)}
</select> </select>
</div> </div>
{disposition === 'eigener_aufwand' && (
<div> {kind === 'amount' ? (
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">{t('accounting.inbox.field.category', 'Category')}</label> <div><label className={labelCls}>{t('accounting.inbox.field.total', 'Total')}</label><DecimalInput value={amountMajor} onChange={setAmountMajor} fractionDigits={2} className={selectCls} /></div>
<select value={categoryId ?? ''} onChange={(e) => setCategoryId(e.target.value ? Number(e.target.value) : undefined)} className={selectClass}> ) : (
<div className="grid grid-cols-2 gap-3">
<div><label className={labelCls}>{qtyLabel}</label><DecimalInput value={quantity} onChange={setQuantity} fractionDigits={2} className={selectCls} /></div>
<div><label className={labelCls}>{t('accounting.expense.rate', 'Rate')}</label>
<DecimalInput value={rateMajor} onChange={setRateMajor} fractionDigits={2} className={selectCls} placeholder={(defaultRateMinor / 100).toFixed(2)} />
<p className="mt-1 text-xs text-neutral-500">{t('accounting.expense.rateDefault', 'Default {{rate}} — leave blank to use it', { rate: (defaultRateMinor / 100).toFixed(2) })}</p>
</div>
</div>
)}
{computedMinor != null && <p className="text-sm text-neutral-700 dark:text-neutral-300">{t('accounting.expense.computed', 'Amount')}: <span className="font-semibold">{formatMoneyMinor(computedMinor, 'CHF')}</span></p>}
<div><label className={labelCls}>{t('accounting.expense.who', 'Paid by / vendor')}</label><Input value={supplier} onChange={(e) => setSupplier(e.target.value)} placeholder={t('accounting.expense.whoHint', 'e.g. coworker name or shop')} /></div>
<div><label className={labelCls}>{t('accounting.ledger.description', 'Description')}</label><Input value={description} onChange={(e) => setDescription(e.target.value)} /></div>
<div><label className={labelCls}>{t('accounting.inbox.field.category', 'Category')}</label>
<select value={categoryId ?? ''} onChange={(e) => setCategoryId(e.target.value ? Number(e.target.value) : undefined)} className={selectCls}>
<option value="">{t('accounting.inbox.field.categoryNone', '— none —')}</option> <option value="">{t('accounting.inbox.field.categoryNone', '— none —')}</option>
{(categories ?? []).map((c) => <option key={c.id} value={c.id}>{c.name}</option>)} {categories.map((c) => <option key={c.id} value={c.id}>{categoryLabel(c, t)}</option>)}
</select> </select>
</div> </div>
)}
{disposition === 'rebill' && ( <div><label className={labelCls}>{t('accounting.booking.label', 'Book to')}</label>
<div className="space-y-3 rounded-lg border border-neutral-200 dark:border-neutral-700 p-3"> <div className="flex gap-2">
<div> <select className={selectCls} style={{ maxWidth: 160 }} value={eventId != null ? 'event' : 'company'} onChange={(e) => setEventId(e.target.value === 'event' ? (eventId ?? 0) : null)}>
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">{t('accounting.inbox.field.customer', 'Client')} *</label> <option value="company">{t('accounting.booking.company', 'Company')}</option>
<CustomerAccountPicker value={customer.slice(0, 1)} onChange={(next) => setCustomer(next.slice(-1))} /> <option value="event">{t('accounting.booking.event', 'Event')}</option>
</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={selectClass}>
<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> </select>
{eventId != null && <Input placeholder={t('accounting.booking.eventId', 'Event ID')} inputMode="numeric" value={eventId ? String(eventId) : ''} onChange={(e) => setEventId(Number(e.target.value.replace(/[^0-9]/g, '')) || 0)} />}
</div> </div>
</div> </div>
{markupType !== 'none' && (
<DecimalInput value={markupValue} onChange={setMarkupValue} fractionDigits={2} className={selectClass} placeholder={markupType === 'percent' ? '%' : currency} /> <div>
)} <label className={labelCls}>{t('accounting.expense.proof', 'Proof')}{requireProof ? ' *' : ''}</label>
<input type="file" accept="image/*,application/pdf" onChange={(e) => setFile(e.target.files?.[0] || null)} className="text-sm" />
{requireProof && !file && <p className="mt-1 text-xs text-amber-600 dark:text-amber-400">{t('accounting.expense.proofRequired', 'A proof file is required.')}</p>}
</div> </div>
)}
</div> </div>
<div className="flex justify-end gap-2 border-t border-neutral-200 dark:border-neutral-700 px-5 py-3"> <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 variant="outline" onClick={onClose}>{t('common.cancel', 'Cancel')}</Button>
<Button onClick={() => save.mutate()} disabled={save.isPending || rebillNeedsCustomer || totalMinor == null}> <Button onClick={() => save.mutate()} disabled={save.isPending || incomplete}>{save.isPending ? t('common.saving', 'Saving…') : t('accounting.ledger.addExpense', 'Add expense')}</Button>
{save.isPending ? t('common.saving', 'Saving…') : t('accounting.ledger.addExpense', 'Add expense')}
</Button>
</div> </div>
</div> </div>
</div> </div>
@@ -215,97 +134,59 @@ export const ExpensesLedgerPage: React.FC = () => {
const { t } = useTranslation(); const { t } = useTranslation();
const qc = useQueryClient(); const qc = useQueryClient();
const { format } = useLocalizedDate(); const { format } = useLocalizedDate();
const [status, setStatus] = useState(''); const [kind, setKind] = useState('');
const [disposition, setDisposition] = useState('');
const [payExpense, setPayExpense] = useState<Expense | null>(null);
const [showAdd, setShowAdd] = useState(false); const [showAdd, setShowAdd] = useState(false);
const { data, isLoading } = useQuery({ const { data, isLoading } = useQuery({
queryKey: ['accounting-expenses', status, disposition], queryKey: ['accounting-expenses', kind],
queryFn: () => accountingService.listExpenses({ queryFn: () => accountingService.listExpenses({ kind: (kind || undefined) as ExpenseKind | undefined, pageSize: 100 }),
status: status || undefined,
disposition: (disposition || undefined) as Disposition | undefined,
pageSize: 100,
}),
}); });
const { data: categories } = useQuery({ queryKey: ['expense-categories'], queryFn: () => accountingService.listCategories() });
const catById = useMemo(() => new Map((categories ?? []).map((c) => [c.id, c])), [categories]);
const unpay = useMutation({ const openProof = async (id: number) => {
mutationFn: (id: number) => accountingService.setSupplierPayment(id, { paid: false }), try { const blob = await accountingService.getExpenseProofBlob(id); window.open(URL.createObjectURL(blob), '_blank'); }
onSuccess: () => { toast.success(t('accounting.ledger.unpaidToast', 'Marked as not paid.')); qc.invalidateQueries({ queryKey: ['accounting-expenses'] }); }, catch (e: any) { toast.error(e?.response?.data?.error || e.message || 'Failed'); }
onError: (e: any) => toast.error(e?.response?.data?.error || e.message || 'Failed'), };
});
const selectClass = 'rounded-md border border-neutral-300 dark:border-neutral-600 bg-white dark:bg-neutral-800 px-3 py-2 text-sm';
const items = data?.items ?? []; const items = data?.items ?? [];
return ( return (
<div> <div>
<div className="mb-4 flex flex-wrap gap-3"> <div className="mb-4 flex flex-wrap gap-3">
<select value={status} onChange={(e) => setStatus(e.target.value)} className={selectClass}> <select value={kind} onChange={(e) => setKind(e.target.value)} className={selectCls} style={{ maxWidth: 200 }}>
<option value="">{t('accounting.ledger.allStatuses', 'All statuses')}</option> <option value="">{t('accounting.ledger.allKinds', 'All types')}</option>
{STATUSES.map((s) => <option key={s} value={s}>{t(`accounting.expenseStatus.${s}`, s)}</option>)} {KINDS.map((k) => <option key={k} value={k}>{t(`accounting.expenseKind.${k}`, k)}</option>)}
</select> </select>
<select value={disposition} onChange={(e) => setDisposition(e.target.value)} className={selectClass}> <Button className="ml-auto" onClick={() => setShowAdd(true)}><Plus className="w-4 h-4 mr-1" /> {t('accounting.ledger.addExpense', 'Add expense')}</Button>
<option value="">{t('accounting.ledger.allDispositions', 'All dispositions')}</option>
{DISPOSITIONS.map((d) => <option key={d} value={d}>{t(`accounting.disposition.${d}`, d)}</option>)}
</select>
<Button className="ml-auto" onClick={() => setShowAdd(true)}>
<Plus className="w-4 h-4 mr-1" /> {t('accounting.ledger.addExpense', 'Add expense')}
</Button>
</div> </div>
{isLoading ? ( {isLoading ? <Loading /> : items.length === 0 ? (
<Loading /> <Card><CardContent className="p-8 text-center text-sm text-neutral-600 dark:text-neutral-400">{t('accounting.ledger.empty', 'No expenses yet — add one above.')}</CardContent></Card>
) : items.length === 0 ? (
<Card><CardContent className="p-8 text-center text-sm text-neutral-600 dark:text-neutral-400">{t('accounting.ledger.empty', 'No expenses yet — categorize documents in the inbox.')}</CardContent></Card>
) : ( ) : (
<div className="space-y-2"> <div className="space-y-2">
{items.map((ex) => ( {items.map((ex: Expense) => {
const cat = ex.categoryId ? catById.get(ex.categoryId) : null;
return (
<div key={ex.id} className="flex flex-wrap 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 key={ex.id} className="flex flex-wrap 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-[12rem]"> <span className="inline-flex items-center gap-1 rounded bg-neutral-100 dark:bg-neutral-800 px-2 py-0.5 text-xs font-medium text-neutral-700 dark:text-neutral-300">{kindIcon[ex.kind]} {t(`accounting.expenseKind.${ex.kind}`, ex.kind)}</span>
<div className="flex items-center gap-2"> <div className="flex-1 min-w-[10rem]">
<span className={`inline-block rounded px-2 py-0.5 text-xs font-medium ${statusClasses[ex.status] || ''}`}>{t(`accounting.expenseStatus.${ex.status}`, ex.status)}</span> <div className="text-sm font-medium text-neutral-900 dark:text-neutral-100 truncate">{ex.description || ex.supplierName || t('accounting.ledger.untitled', 'Expense')}</div>
<span className="text-sm font-medium text-neutral-900 dark:text-neutral-100 truncate">{ex.supplierName || ex.description || t('accounting.ledger.untitled', 'Expense')}</span>
</div>
<div className="text-xs text-neutral-500 dark:text-neutral-400"> <div className="text-xs text-neutral-500 dark:text-neutral-400">
{t(`accounting.disposition.${ex.disposition}`, ex.disposition)} {ex.eventId != null ? `${t('accounting.booking.event', 'Event')} #${ex.eventId}` : t('accounting.booking.company', 'Company')}
{' · '} {cat && <>{' · '}{categoryLabel(cat, t)}</>}
{format(ex.createdAt)} {' · '}{format(ex.createdAt)}
{ex.billedInvoiceId ? (
<>{' · '}<Link to={`/admin/clients/bills/${ex.billedInvoiceId}`} className="inline-flex items-center gap-0.5 text-primary-600 hover:underline">{t('accounting.ledger.invoiceLink', 'Invoice')}<ExternalLink className="w-3 h-3" /></Link></>
) : null}
</div> </div>
</div> </div>
{ex.hasProof && <button onClick={() => openProof(ex.id)} className="inline-flex items-center gap-1 text-xs text-primary-600 hover:underline"><Paperclip className="w-3.5 h-3.5" /> {t('accounting.expense.viewProof', 'Proof')}</button>}
<div className="text-sm font-medium text-neutral-900 dark:text-neutral-100 tabular-nums"> <div className="text-sm font-medium tabular-nums text-neutral-900 dark:text-neutral-100">{ex.chfAmountMinor != null ? formatMoneyMinor(ex.chfAmountMinor, 'CHF') : '—'}</div>
{ex.chfAmountMinor != null ? formatMoneyMinor(ex.chfAmountMinor, 'CHF') : '—'}
</div> </div>
);
{/* Supplier-payment toggle (skip for declined/duplicate). */} })}
{ex.disposition !== 'abgelehnt' && ex.disposition !== 'duplikat' && (
ex.supplierPaid ? (
<button onClick={() => unpay.mutate(ex.id)} disabled={unpay.isPending}
className="inline-flex items-center gap-1 rounded-md px-2 py-1 text-xs font-medium text-green-700 dark:text-green-300 hover:bg-green-50 dark:hover:bg-green-900/30">
<CheckCircle2 className="w-4 h-4" /> {t('accounting.ledger.paid', 'Paid')}
</button>
) : (
<Button size="sm" variant="outline" onClick={() => setPayExpense(ex)}>
<Circle className="w-3.5 h-3.5 mr-1" /> {t('accounting.ledger.markPaid', 'Mark paid')}
</Button>
)
)}
</div>
))}
</div> </div>
)} )}
{payExpense && ( {showAdd && <AddExpenseModal categories={categories ?? []} onClose={() => setShowAdd(false)} onDone={() => { setShowAdd(false); qc.invalidateQueries({ queryKey: ['accounting-expenses'] }); }} />}
<PayModal expense={payExpense} onClose={() => setPayExpense(null)} onDone={() => { setPayExpense(null); qc.invalidateQueries({ queryKey: ['accounting-expenses'] }); }} />
)}
{showAdd && (
<AddExpenseModal onClose={() => setShowAdd(false)} onDone={() => { setShowAdd(false); qc.invalidateQueries({ queryKey: ['accounting-expenses'] }); }} />
)}
</div> </div>
); );
}; };
+108 -97
View File
@@ -1,12 +1,13 @@
import { api } from '../config/api'; import { api } from '../config/api';
// Mirrors backend transformInbound / transformExpense (camelCase).
export type InboundStatus = 'unsorted' | 'categorized' | 'declined' | 'duplicate'; export type InboundStatus = 'unsorted' | 'categorized' | 'declined' | 'duplicate';
export type Disposition = 'rebill' | 'durchlaufend' | 'eigener_aufwand' | 'duplikat' | 'abgelehnt'; export type Disposition = 'rebill' | 'durchlaufend' | 'eigener_aufwand' | 'duplikat' | 'abgelehnt';
export type TaxTreatment = 'domestic' | 'reverse_charge_service' | 'foreign_vat_non_reclaimable' | 'import_goods'; export type TaxTreatment = 'domestic' | 'reverse_charge_service' | 'foreign_vat_non_reclaimable' | 'import_goods';
export type MarkupType = 'none' | 'percent' | 'flat'; export type MarkupType = 'none' | 'percent' | 'flat';
export type PaymentMethod = 'bank_transfer' | 'cash' | 'twint' | 'paypal' | 'card' | 'other'; export type PaymentMethod = 'bank_transfer' | 'cash' | 'twint' | 'paypal' | 'card' | 'other';
export type ExpenseKind = 'amount' | 'mileage' | 'per_diem';
/** Incoming invoice (external supplier document). The payable lives here. */
export interface InboundDocument { export interface InboundDocument {
id: number; id: number;
source: 'upload' | 'camera' | 'email' | 'manual'; source: 'upload' | 'camera' | 'email' | 'manual';
@@ -14,7 +15,6 @@ export interface InboundDocument {
mimeType: string | null; mimeType: string | null;
status: InboundStatus; status: InboundStatus;
parseStatus: 'pending' | 'parsed' | 'failed' | 'manual'; parseStatus: 'pending' | 'parsed' | 'failed' | 'manual';
parseMethod: string | null;
pageCount: number | null; pageCount: number | null;
supplierName: string | null; supplierName: string | null;
invoiceNumber: string | null; invoiceNumber: string | null;
@@ -27,141 +27,152 @@ export interface InboundDocument {
qrAmountMinor: number | null; qrAmountMinor: number | null;
iban: string | null; iban: string | null;
paymentReference: string | null; paymentReference: string | null;
duplicateOfId: number | null; disposition: Disposition | null;
taxTreatment: TaxTreatment | null;
eventId: number | null;
categoryId: number | null;
markupType: MarkupType | null;
markupPercent: number | null;
markupFlatMinor: number | null;
billedInvoiceId: number | null;
supplierPaid: boolean;
supplierPaidAt: string | null;
supplierPaymentMethod: PaymentMethod | null;
supplierPaymentRef: string | null;
createdAt: string; createdAt: string;
updatedAt: string; updatedAt: string;
} }
/** Internal expense (own cost). */
export interface Expense { export interface Expense {
id: number; id: number;
inboundDocumentId: number | null; kind: ExpenseKind;
disposition: Disposition; quantity: number | null;
taxTreatment: TaxTreatment; rateMinor: number | null;
eventId: number | null; eventId: number | null; // null = company
customerAccountId: number | null;
supplierName: string | null; supplierName: string | null;
description: string | null; description: string | null;
chfAmountMinor: number | null; chfAmountMinor: number | null;
grossAmountMinor: number | null;
markupType: MarkupType;
markupPercent: number | null;
markupFlatMinor: number | null;
categoryId: number | null; categoryId: number | null;
billedInvoiceId: number | null; hasProof: boolean;
supplierPaid: boolean; taxTreatment: TaxTreatment | null;
status: 'open' | 'parked' | 'billed' | 'declined'; status: string;
createdAt: string; createdAt: string;
updatedAt: string; updatedAt: string;
} }
export interface ExpenseCategory { export interface ExpenseCategory { id: number; name: string; color: string | null; is_seed: boolean; display_order: number; }
id: number; export interface Paginated<T> { items: T[]; pagination: { page: number; pageSize: number; total: number; totalPages: number }; }
name: string;
color: string | null;
is_seed: boolean;
display_order: number;
}
export interface Paginated<T> { export interface AccountingSettings {
items: T[]; accounting_km_rate_minor: number;
pagination: { page: number; pageSize: number; total: number; totalPages: number }; accounting_per_diem_rate_minor: number;
accounting_require_proof: boolean;
} }
export interface CategorizePayload { export interface CategorizePayload {
disposition: Disposition; disposition: Disposition;
supplierName?: string | null;
description?: string | null;
chfAmountMinor?: number | null;
netAmountMinor?: number | null;
vatAmountMinor?: number | null;
grossAmountMinor?: number | null;
taxTreatment?: TaxTreatment; taxTreatment?: TaxTreatment;
eventId?: number | null;
categoryId?: number | null; categoryId?: number | null;
eventId?: number | null; customerAccountId?: number | null; // re-bill
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; contractId?: number | null;
markupType?: MarkupType; markupType?: MarkupType;
markupPercent?: number | null; markupPercent?: number | null;
markupFlatMinor?: number | null; markupFlatMinor?: number | null;
duplicateOfId?: number | null;
}
export interface ExpensePayload {
kind: ExpenseKind;
quantity?: number;
rateMinor?: number; // per-entry override
chfAmountMinor?: number; // kind 'amount'
eventId?: number | null; // null = company
categoryId?: number | null;
supplierName?: string | null;
description?: string | null;
taxTreatment?: TaxTreatment;
}
function expenseFormData(payload: ExpensePayload, file?: File | null): FormData {
const fd = new FormData();
fd.append('kind', payload.kind);
if (payload.quantity != null && Number.isFinite(payload.quantity)) fd.append('quantity', String(payload.quantity));
if (payload.rateMinor != null && Number.isFinite(payload.rateMinor)) fd.append('rateMinor', String(payload.rateMinor));
if (payload.chfAmountMinor != null && Number.isFinite(payload.chfAmountMinor)) fd.append('chfAmountMinor', String(payload.chfAmountMinor));
if (payload.eventId != null) fd.append('eventId', String(payload.eventId));
if (payload.categoryId != null) fd.append('categoryId', String(payload.categoryId));
if (payload.supplierName) fd.append('supplierName', payload.supplierName);
if (payload.description) fd.append('description', payload.description);
if (payload.taxTreatment) fd.append('taxTreatment', payload.taxTreatment);
if (file) fd.append('proof', file);
return fd;
} }
export const accountingService = { export const accountingService = {
// ── incoming invoices ──
async uploadInbound(file: File, source: 'upload' | 'camera' = 'upload'): Promise<InboundDocument> { async uploadInbound(file: File, source: 'upload' | 'camera' = 'upload'): Promise<InboundDocument> {
const form = new FormData(); const fd = new FormData(); fd.append('file', file); fd.append('source', source);
form.append('file', file); const { data } = await api.post('/admin/expenses/inbound', fd, { headers: { 'Content-Type': 'multipart/form-data' } });
form.append('source', source);
const { data } = await api.post('/admin/expenses/inbound', form, {
headers: { 'Content-Type': 'multipart/form-data' },
});
return data.document; return data.document;
}, },
async listInbound(params: { status?: InboundStatus; page?: number; pageSize?: number } = {}): Promise<Paginated<InboundDocument>> { async listInbound(params: { status?: InboundStatus; page?: number; pageSize?: number } = {}): Promise<Paginated<InboundDocument>> {
const { data } = await api.get('/admin/expenses/inbound', { params }); const { data } = await api.get('/admin/expenses/inbound', { params }); return data;
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<InboundDocument>): Promise<InboundDocument> { const { data } = await api.patch(`/admin/expenses/inbound/${id}`, fields); return data.document; },
async categorizeInbound(id: number, payload: CategorizePayload): Promise<InboundDocument> { const { data } = await api.post(`/admin/expenses/inbound/${id}/categorize`, payload); return data.document; },
async rebillInbound(id: number, payload: CategorizePayload): Promise<{ document: InboundDocument; invoiceId: number }> { const { data } = await api.post(`/admin/expenses/inbound/${id}/rebill`, payload); return data; },
async markInboundPaid(id: number, payload: { paid: boolean; paidAt?: string; paymentMethod?: PaymentMethod; paymentReference?: string }): Promise<InboundDocument> { const { data } = await api.post(`/admin/expenses/inbound/${id}/supplier-payment`, payload); return data.document; },
async getInboundFileBlob(id: number): Promise<Blob> { const { data } = await api.get(`/admin/expenses/inbound/${id}/file`, { responseType: 'blob' }); return data; },
async getInboundPageBlob(id: number, page: number): Promise<Blob> { const { data } = await api.get(`/admin/expenses/inbound/${id}/page/${page}`, { responseType: 'blob' }); return data; },
async getInbound(id: number): Promise<InboundDocument> { // ── expenses (internal) ──
const { data } = await api.get(`/admin/expenses/inbound/${id}`); async listExpenses(params: { kind?: ExpenseKind; categoryId?: number; eventId?: number | 'company'; page?: number; pageSize?: number } = {}): Promise<Paginated<Expense>> {
return data.document; const { data } = await api.get('/admin/expenses', { params }); return data;
}, },
async getExpense(id: number): Promise<Expense> { const { data } = await api.get(`/admin/expenses/${id}`); return data.expense; },
async getInboundFileBlob(id: number): Promise<Blob> { async createExpense(payload: ExpensePayload, file?: File | null): Promise<Expense> {
const { data } = await api.get(`/admin/expenses/inbound/${id}/file`, { responseType: 'blob' }); const { data } = await api.post('/admin/expenses', expenseFormData(payload, file), { headers: { 'Content-Type': 'multipart/form-data' } });
return data;
},
// Rasterised PDF page (PNG) — the raw PDF is never sent to the browser.
async getInboundPageBlob(id: number, page: number): Promise<Blob> {
const { data } = await api.get(`/admin/expenses/inbound/${id}/page/${page}`, { responseType: 'blob' });
return data;
},
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; return data.expense;
}, },
async updateExpense(id: number, payload: ExpensePayload, file?: File | null): Promise<Expense> {
// Create a manual expense (no inbound document). const { data } = await api.patch(`/admin/expenses/${id}`, expenseFormData(payload, file), { headers: { 'Content-Type': 'multipart/form-data' } });
async createExpense(payload: CategorizePayload): Promise<Expense> {
const { data } = await api.post('/admin/expenses', payload);
return data.expense; return data.expense;
}, },
async getExpenseProofBlob(id: number): Promise<Blob> { const { data } = await api.get(`/admin/expenses/${id}/proof`, { responseType: 'blob' }); return data; },
async rebill(expenseId: number, payload: RebillPayload): Promise<{ expense: Expense; invoiceId: number }> { // ── categories + settings ──
const { data } = await api.post(`/admin/expenses/${expenseId}/rebill`, payload); async listCategories(): Promise<ExpenseCategory[]> { const { data } = await api.get('/admin/expenses/categories'); return data.items; },
return data; async getSettings(): Promise<AccountingSettings> {
const { data } = await api.get('/admin/settings/accounting');
return {
accounting_km_rate_minor: Number(data.accounting_km_rate_minor) || 0,
accounting_per_diem_rate_minor: Number(data.accounting_per_diem_rate_minor) || 0,
accounting_require_proof: data.accounting_require_proof === true,
};
}, },
async updateSettings(payload: Partial<AccountingSettings>): Promise<{ updated: string[] }> {
async setSupplierPayment(expenseId: number, payload: { paid: boolean; paidAt?: string; paymentMethod?: PaymentMethod; paymentReference?: string }): Promise<Expense> { const { data } = await api.put('/admin/settings/accounting', payload); return data;
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;
}, },
}; };
// Seed categories are stored with literal German names; localize them by a
// stable key. Admin-created categories show their own free-text name.
const SEED_CATEGORY_KEYS: Record<string, string> = {
'Infrastruktur & Miete': 'infrastructure',
'Equipment & Hardware': 'equipment',
'Software & Lizenzen': 'software',
'Material & Verbrauch': 'material',
'Reise & Spesen': 'travel',
'Werbung & Marketing': 'marketing',
'Dienstleistungen/Fremdleistungen': 'services',
'Versicherungen & Gebühren': 'insurance',
'Weiterbildung': 'training',
'Sonstiges': 'other',
};
export function categoryLabel(cat: ExpenseCategory, t: (k: string, d?: string) => string): string {
if (cat?.is_seed && SEED_CATEGORY_KEYS[cat.name]) return t(`accounting.category.${SEED_CATEGORY_KEYS[cat.name]}`, cat.name);
return cat?.name ?? '';
}