feat(accounting): re-viewable incoming invoices + expense invoiced/paid lifecycle UI

#1 Incoming invoices are re-viewable: extracted a reusable rasterised
DocumentPreview (last page = QR-bill), added a click-to-view ViewModal on
every row, and embedded the preview in the mark-paid dialog.

#2/#3 Expenses ledger:
- invoiced badge (links to the client invoice) + paid toggle (manual,
  independent of invoiced)
- edit until invoiced (ExpenseFormModal now does create + edit; locked
  rows show a Lock chip instead of edit/add-to-invoice)
- 'Add to invoice' action (re-bill via customer picker + markup) and a
  'Mark paid' dialog
- service: Expense gains invoiced/billedInvoiceId/paid/paidAt fields +
  invoiceExpense() and markExpensePaid()

en/de translations added.
This commit is contained in:
Luca
2026-06-11 21:19:06 +02:00
parent 545ef334f4
commit 727bdab6e0
5 changed files with 326 additions and 74 deletions
+13
View File
@@ -3474,6 +3474,7 @@
"triageTitle": "Dokument kategorisieren", "triageTitle": "Dokument kategorisieren",
"saveCategorize": "Speichern", "saveCategorize": "Speichern",
"categorize": "Kategorisieren", "categorize": "Kategorisieren",
"view": "Ansehen",
"empty": "Noch keine Dokumente — oben eines erfassen.", "empty": "Noch keine Dokumente — oben eines erfassen.",
"untitled": "Unbenanntes Dokument", "untitled": "Unbenanntes Dokument",
"noAmount": "Betrag nicht erfasst", "noAmount": "Betrag nicht erfasst",
@@ -3485,6 +3486,7 @@
"nextPage": "Weiter", "nextPage": "Weiter",
"pageOf": "Seite {{n}} / {{total}}", "pageOf": "Seite {{n}} / {{total}}",
"status": { "status": {
"label": "Status",
"unsorted": "Neu", "unsorted": "Neu",
"categorized": "Kategorisiert", "categorized": "Kategorisiert",
"declined": "Abgelehnt", "declined": "Abgelehnt",
@@ -3545,6 +3547,7 @@
"whoHint": "z. B. Mitarbeitername oder Geschäft", "whoHint": "z. B. Mitarbeitername oder Geschäft",
"proof": "Beleg", "proof": "Beleg",
"proofRequired": "Ein Beleg ist erforderlich.", "proofRequired": "Ein Beleg ist erforderlich.",
"proofExisting": "Es ist bereits ein Beleg angehängt — neuen hochladen, um ihn zu ersetzen.",
"viewProof": "Beleg" "viewProof": "Beleg"
}, },
"expenseKind": { "expenseKind": {
@@ -3582,6 +3585,16 @@
"unpaidToast": "Als nicht bezahlt markiert.", "unpaidToast": "Als nicht bezahlt markiert.",
"addExpense": "Aufwand hinzufügen", "addExpense": "Aufwand hinzufügen",
"addTitle": "Aufwand hinzufügen", "addTitle": "Aufwand hinzufügen",
"editTitle": "Aufwand bearbeiten",
"updatedToast": "Aufwand aktualisiert.",
"payTitle": "Aufwand als bezahlt markieren",
"invoiced": "Verrechnet",
"invoicedToast": "Zu einer Kundenrechnung hinzugefügt.",
"invoiceTitle": "Zur Kundenrechnung hinzufügen",
"invoiceHint": "Erstellt eine verrechenbare Position auf der nächsten geplanten Rechnung des Kunden und sperrt den Aufwand für weitere Änderungen.",
"addToInvoice": "Zur Rechnung hinzufügen",
"locked": "Gesperrt",
"lockedHint": "Gesperrt — dieser Aufwand ist auf einer Kundenrechnung.",
"description": "Beschreibung", "description": "Beschreibung",
"descriptionHint": "z. B. Kilometer, Spesenpauschale, Barbeleg", "descriptionHint": "z. B. Kilometer, Spesenpauschale, Barbeleg",
"createdToast": "Aufwand hinzugefügt." "createdToast": "Aufwand hinzugefügt."
+13
View File
@@ -3474,6 +3474,7 @@
"triageTitle": "Categorize document", "triageTitle": "Categorize document",
"saveCategorize": "Save", "saveCategorize": "Save",
"categorize": "Categorize", "categorize": "Categorize",
"view": "View",
"empty": "No documents yet — capture one above.", "empty": "No documents yet — capture one above.",
"untitled": "Untitled document", "untitled": "Untitled document",
"noAmount": "amount not entered", "noAmount": "amount not entered",
@@ -3485,6 +3486,7 @@
"nextPage": "Next", "nextPage": "Next",
"pageOf": "Page {{n}} / {{total}}", "pageOf": "Page {{n}} / {{total}}",
"status": { "status": {
"label": "Status",
"unsorted": "New", "unsorted": "New",
"categorized": "Categorized", "categorized": "Categorized",
"declined": "Declined", "declined": "Declined",
@@ -3545,6 +3547,7 @@
"whoHint": "e.g. coworker name or shop", "whoHint": "e.g. coworker name or shop",
"proof": "Proof", "proof": "Proof",
"proofRequired": "A proof file is required.", "proofRequired": "A proof file is required.",
"proofExisting": "A proof file is already attached — upload a new one to replace it.",
"viewProof": "Proof" "viewProof": "Proof"
}, },
"expenseKind": { "expenseKind": {
@@ -3582,6 +3585,16 @@
"unpaidToast": "Marked as not paid.", "unpaidToast": "Marked as not paid.",
"addExpense": "Add expense", "addExpense": "Add expense",
"addTitle": "Add expense", "addTitle": "Add expense",
"editTitle": "Edit expense",
"updatedToast": "Expense updated.",
"payTitle": "Mark expense paid",
"invoiced": "Invoiced",
"invoicedToast": "Added to a client invoice.",
"invoiceTitle": "Add to client invoice",
"invoiceHint": "This creates a billable line on the clients next scheduled invoice and locks the expense from further edits.",
"addToInvoice": "Add to invoice",
"locked": "Locked",
"lockedHint": "Locked — this expense is on a client invoice.",
"description": "Description", "description": "Description",
"descriptionHint": "e.g. mileage, per-diem, cash receipt", "descriptionHint": "e.g. mileage, per-diem, cash receipt",
"createdToast": "Expense added." "createdToast": "Expense added."
@@ -9,7 +9,7 @@ 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, CheckCircle2, Circle } from 'lucide-react'; import { Camera, Upload, Inbox, X, CheckCircle2, Circle, Eye } 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';
@@ -35,6 +35,47 @@ const statusClasses: Record<string, string> = {
const labelCls = 'block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1'; const labelCls = 'block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1';
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 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';
/**
* Reusable rasterised-document preview. PDFs render as server-side page
* images (never raw); images stream inline. Defaults to the LAST page
* (Swiss QR-bill usually sits at the bottom of the final page). Used by
* the triage, view, and mark-paid modals so the admin can always re-read
* the slip — #1.
*/
const DocumentPreview: React.FC<{ doc: InboundDocument; maxHeight?: string }> = ({ doc, maxHeight = '60vh' }) => {
const { t } = useTranslation();
const isPdf = (doc.mimeType || '').includes('pdf');
const pageCount = doc.pageCount || 1;
const [page, setPage] = useState(pageCount);
const [imgUrl, setImgUrl] = useState<string | null>(null);
const [previewError, setPreviewError] = useState(false);
useEffect(() => {
let url: string | null = null; let cancelled = false;
setImgUrl(null); setPreviewError(false);
(isPdf ? accountingService.getInboundPageBlob(doc.id, page) : accountingService.getInboundFileBlob(doc.id))
.then((b) => { if (!cancelled) { url = URL.createObjectURL(b); setImgUrl(url); } })
.catch(() => { if (!cancelled) setPreviewError(true); });
return () => { cancelled = true; if (url) URL.revokeObjectURL(url); };
}, [doc.id, isPdf, page]);
return (
<div>
<div className="overflow-auto rounded-md border border-neutral-200 dark:border-neutral-700 bg-neutral-50 dark:bg-neutral-800" style={{ maxHeight }}>
{previewError ? <div className="flex items-center justify-center px-3 py-16 text-center text-sm text-neutral-500">{t('accounting.inbox.previewError', 'Preview unavailable — enter the fields manually.')}</div>
: imgUrl ? <img src={imgUrl} alt="document page" className="w-full h-auto" />
: <div className="flex items-center justify-center px-3 py-16 text-sm text-neutral-500">{t('accounting.inbox.previewLoading', 'Loading preview…')}</div>}
</div>
{isPdf && pageCount > 1 && (
<div className="mt-2 flex items-center justify-center gap-3 text-sm">
<Button size="sm" variant="outline" onClick={() => setPage((p) => Math.max(1, p - 1))} disabled={page <= 1}>{t('accounting.inbox.prevPage', 'Prev')}</Button>
<span className="text-neutral-600 dark:text-neutral-400">{t('accounting.inbox.pageOf', 'Page {{n}} / {{total}}', { n: page, total: pageCount })}</span>
<Button size="sm" variant="outline" onClick={() => setPage((p) => Math.min(pageCount, p + 1))} disabled={page >= pageCount}>{t('accounting.inbox.nextPage', 'Next')}</Button>
</div>
)}
{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>}
</div>
);
};
const PayModal: React.FC<{ doc: InboundDocument; onClose: () => void; onDone: () => void }> = ({ doc, onClose, onDone }) => { const PayModal: React.FC<{ doc: InboundDocument; onClose: () => void; onDone: () => void }> = ({ doc, onClose, onDone }) => {
const { t } = useTranslation(); const { t } = useTranslation();
const [paidAt, setPaidAt] = useState(''); const [paidAt, setPaidAt] = useState('');
@@ -46,23 +87,28 @@ const PayModal: React.FC<{ doc: InboundDocument; onClose: () => void; 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'),
}); });
return ( return (
<div className="fixed inset-0 z-50 flex items-start justify-center 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-16 w-full max-w-sm rounded-xl bg-white dark:bg-neutral-900 shadow-xl"> {/* Wider, two-column: preview on the left so the admin can read the
QR-bill while confirming payment — #1. */}
<div className="mt-12 w-full max-w-3xl 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.incoming.payTitle', 'Mark supplier paid')}</h2> <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> <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 grid grid-cols-1 lg:grid-cols-2 gap-5">
<p className="text-sm text-neutral-600 dark:text-neutral-400"> <div className="order-2 lg:order-1"><DocumentPreview doc={doc} maxHeight="50vh" /></div>
{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> <div className="order-1 lg:order-2 space-y-3">
</p> <p className="text-sm text-neutral-600 dark:text-neutral-400">
<div><label className={labelCls}>{t('accounting.ledger.paidDate', 'Payment date')}</label><LocalizedDateInput value={paidAt} onChange={setPaidAt} /></div> {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>
<div><label className={labelCls}>{t('accounting.ledger.method', 'Method')}</label> </p>
<select value={method} onChange={(e) => setMethod(e.target.value as PaymentMethod)} className={selectCls}> <div><label className={labelCls}>{t('accounting.ledger.paidDate', 'Payment date')}</label><LocalizedDateInput value={paidAt} onChange={setPaidAt} /></div>
{PAYMENT_METHODS.map((m) => <option key={m} value={m}>{t(`accounting.paymentMethod.${m}`, m)}</option>)} <div><label className={labelCls}>{t('accounting.ledger.method', 'Method')}</label>
</select> <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>
<div><label className={labelCls}>{t('accounting.ledger.reference', 'Reference (optional)')}</label><Input value={reference} onChange={(e) => setReference(e.target.value)} /></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>
@@ -73,6 +119,45 @@ const PayModal: React.FC<{ doc: InboundDocument; onClose: () => void; onDone: ()
); );
}; };
/** Read-only re-view of a categorized/declined incoming invoice (#1):
* preview + the confirmed fields, without the triage form. */
const ViewModal: React.FC<{ doc: InboundDocument; onClose: () => void }> = ({ doc, onClose }) => {
const { t } = useTranslation();
const { format } = useLocalizedDate();
const field = (label: string, value: React.ReactNode) => (
<div className="flex justify-between gap-3 py-1 text-sm border-b border-neutral-100 dark:border-neutral-800">
<span className="text-neutral-500 dark:text-neutral-400">{label}</span>
<span className="text-neutral-900 dark:text-neutral-100 text-right">{value ?? '—'}</span>
</div>
);
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-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">
<h2 className="text-lg font-semibold text-neutral-900 dark:text-neutral-100">{doc.supplierName || doc.originalFilename || t('accounting.inbox.untitled', 'Untitled document')}</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 grid grid-cols-1 lg:grid-cols-2 gap-5">
<div className="order-2 lg:order-1"><DocumentPreview doc={doc} /></div>
<div className="order-1 lg:order-2">
{field(t('accounting.inbox.field.supplier', 'Supplier'), doc.supplierName)}
{field(t('accounting.inbox.field.total', 'Total'), doc.totalAmountMinor != null ? formatMoneyMinor(doc.totalAmountMinor, doc.currency || 'CHF') : null)}
{field(t('accounting.inbox.field.invoiceDate', 'Invoice date'), doc.invoiceDate ? format(doc.invoiceDate) : null)}
{field(t('accounting.inbox.field.disposition', 'Disposition'), doc.disposition ? t(`accounting.disposition.${doc.disposition}`, doc.disposition) : null)}
{field(t('accounting.inbox.status.label', 'Status'), t(`accounting.inbox.status.${doc.status}`, doc.status))}
{field(t('accounting.incoming.paid', 'Paid'), doc.supplierPaid
? (doc.supplierPaidAt ? format(doc.supplierPaidAt) : t('common.yes', 'Yes'))
: t('common.no', 'No'))}
</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.close', 'Close')}</Button>
</div>
</div>
</div>
);
};
const TriageModal: React.FC<{ doc: InboundDocument; categories: ExpenseCategory[]; onClose: () => void; onDone: () => void }> = ({ doc, categories, onClose, onDone }) => { 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 || '');
@@ -88,21 +173,6 @@ const TriageModal: React.FC<{ doc: InboundDocument; categories: ExpenseCategory[
const totalMinor = Number.isFinite(amountMajor) ? Math.round(amountMajor * 100) : null; const totalMinor = Number.isFinite(amountMajor) ? Math.round(amountMajor * 100) : null;
// Rasterised preview (last page = QR-bill).
const isPdf = (doc.mimeType || '').includes('pdf');
const pageCount = doc.pageCount || 1;
const [page, setPage] = useState(pageCount);
const [imgUrl, setImgUrl] = useState<string | null>(null);
const [previewError, setPreviewError] = useState(false);
useEffect(() => {
let url: string | null = null; let cancelled = false;
setImgUrl(null); setPreviewError(false);
(isPdf ? accountingService.getInboundPageBlob(doc.id, page) : accountingService.getInboundFileBlob(doc.id))
.then((b) => { if (!cancelled) { url = URL.createObjectURL(b); setImgUrl(url); } })
.catch(() => { if (!cancelled) setPreviewError(true); });
return () => { cancelled = true; if (url) URL.revokeObjectURL(url); };
}, [doc.id, isPdf, page]);
const markupPayload = () => ({ const markupPayload = () => ({
markupType, markupType,
markupPercent: markupType === 'percent' && Number.isFinite(markupValue) ? markupValue : null, markupPercent: markupType === 'percent' && Number.isFinite(markupValue) ? markupValue : null,
@@ -134,21 +204,7 @@ const TriageModal: React.FC<{ doc: InboundDocument; categories: ExpenseCategory[
<button onClick={onClose} 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 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">
<div className="order-2 lg:order-1"> <div className="order-2 lg:order-1"><DocumentPreview doc={doc} /></div>
<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 ? <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>
: 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>
{isPdf && pageCount > 1 && (
<div className="mt-2 flex items-center justify-center gap-3 text-sm">
<Button size="sm" variant="outline" onClick={() => setPage((p) => Math.max(1, p - 1))} disabled={page <= 1}>{t('accounting.inbox.prevPage', 'Prev')}</Button>
<span className="text-neutral-600 dark:text-neutral-400">{t('accounting.inbox.pageOf', 'Page {{n}} / {{total}}', { n: page, total: pageCount })}</span>
<Button size="sm" variant="outline" onClick={() => setPage((p) => Math.min(pageCount, p + 1))} disabled={page >= pageCount}>{t('accounting.inbox.nextPage', 'Next')}</Button>
</div>
)}
{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>}
</div>
<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">
@@ -213,6 +269,7 @@ export const AccountingInboxPage: React.FC = () => {
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 [payDoc, setPayDoc] = useState<InboundDocument | null>(null);
const [viewDoc, setViewDoc] = useState<InboundDocument | null>(null);
const { data, isLoading } = useQuery({ queryKey: ['accounting-inbound'], queryFn: () => accountingService.listInbound({ pageSize: 100 }) }); const { data, isLoading } = useQuery({ queryKey: ['accounting-inbound'], queryFn: () => accountingService.listInbound({ pageSize: 100 }) });
const { data: categories } = useQuery({ queryKey: ['expense-categories'], queryFn: () => accountingService.listCategories() }); const { data: categories } = useQuery({ queryKey: ['expense-categories'], queryFn: () => accountingService.listCategories() });
@@ -257,10 +314,11 @@ 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 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={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-[12rem]"> {/* Click anywhere on the summary to re-view the document — #1. */}
<button type="button" onClick={() => setViewDoc(doc)} className="flex-1 min-w-[12rem] text-left">
<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] || ''}`}>{t(`accounting.inbox.status.${doc.status}`, doc.status)}</span> <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> <span className="text-sm font-medium text-neutral-900 dark:text-neutral-100 truncate hover:underline">{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">
@@ -268,7 +326,8 @@ export const AccountingInboxPage: React.FC = () => {
{' · '}{format(doc.createdAt)} {' · '}{format(doc.createdAt)}
{doc.disposition && <>{' · '}{t(`accounting.disposition.${doc.disposition}`, doc.disposition)}</>} {doc.disposition && <>{' · '}{t(`accounting.disposition.${doc.disposition}`, doc.disposition)}</>}
</div> </div>
</div> </button>
<Button size="sm" variant="ghost" onClick={() => setViewDoc(doc)}><Eye className="w-3.5 h-3.5 mr-1" /> {t('accounting.inbox.view', 'View')}</Button>
{doc.status !== 'declined' && doc.status !== 'duplicate' && ( {doc.status !== 'declined' && doc.status !== 'duplicate' && (
doc.supplierPaid 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 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>
@@ -282,6 +341,7 @@ export const AccountingInboxPage: React.FC = () => {
{triageDoc && <TriageModal doc={triageDoc} categories={categories ?? []} onClose={() => setTriageDoc(null)} onDone={() => { setTriageDoc(null); refresh(); }} />} {triageDoc && <TriageModal doc={triageDoc} categories={categories ?? []} onClose={() => setTriageDoc(null)} onDone={() => { setTriageDoc(null); refresh(); }} />}
{payDoc && <PayModal doc={payDoc} onClose={() => setPayDoc(null)} onDone={() => { setPayDoc(null); refresh(); }} />} {payDoc && <PayModal doc={payDoc} onClose={() => setPayDoc(null)} onDone={() => { setPayDoc(null); refresh(); }} />}
{viewDoc && <ViewModal doc={viewDoc} onClose={() => setViewDoc(null)} />}
</div> </div>
); );
}; };
@@ -7,20 +7,24 @@
* entry. No supplier payment here — that lives on incoming invoices. * entry. No supplier payment here — that lives on incoming invoices.
*/ */
import React, { useState, useMemo } 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 { X, Plus, Paperclip, Car, CalendarDays, Coins } from 'lucide-react'; import { X, Plus, Paperclip, Car, CalendarDays, Coins, Pencil, FileText, CheckCircle2, Circle, Lock } from 'lucide-react';
import { Button, Card, CardContent, Input, 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 { EventBookingSelect } from '../../../components/admin/EventBookingSelect'; import { EventBookingSelect } from '../../../components/admin/EventBookingSelect';
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, categoryLabel, accountingService, categoryLabel,
type Expense, type ExpenseKind, type ExpenseCategory, type Expense, type ExpenseKind, type ExpenseCategory, type MarkupType, type PaymentMethod,
} from '../../../services/accounting.service'; } from '../../../services/accounting.service';
const PAYMENT_METHODS: PaymentMethod[] = ['bank_transfer', 'cash', 'twint', 'paypal', 'card', 'other'];
const KINDS: ExpenseKind[] = ['amount', 'mileage', 'per_diem']; const KINDS: ExpenseKind[] = ['amount', 'mileage', 'per_diem'];
const labelCls = 'block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1'; const labelCls = 'block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1';
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 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';
@@ -28,17 +32,18 @@ const kindIcon: Record<ExpenseKind, React.ReactNode> = {
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" />, 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" />,
}; };
const AddExpenseModal: React.FC<{ categories: ExpenseCategory[]; onClose: () => void; onDone: () => void }> = ({ categories, onClose, onDone }) => { const ExpenseFormModal: React.FC<{ categories: ExpenseCategory[]; expense?: Expense; onClose: () => void; onDone: () => void }> = ({ categories, expense, onClose, onDone }) => {
const { t } = useTranslation(); const { t } = useTranslation();
const isEdit = !!expense;
const { data: settings } = useQuery({ queryKey: ['accounting-settings'], queryFn: () => accountingService.getSettings() }); const { data: settings } = useQuery({ queryKey: ['accounting-settings'], queryFn: () => accountingService.getSettings() });
const [kind, setKind] = useState<ExpenseKind>('amount'); const [kind, setKind] = useState<ExpenseKind>(expense?.kind ?? 'amount');
const [supplier, setSupplier] = useState(''); const [supplier, setSupplier] = useState(expense?.supplierName ?? '');
const [description, setDescription] = useState(''); const [description, setDescription] = useState(expense?.description ?? '');
const [amountMajor, setAmountMajor] = useState<number>(NaN); const [amountMajor, setAmountMajor] = useState<number>(expense && expense.kind === 'amount' && expense.chfAmountMinor != null ? expense.chfAmountMinor / 100 : NaN);
const [quantity, setQuantity] = useState<number>(NaN); const [quantity, setQuantity] = useState<number>(expense?.quantity != null ? expense.quantity : NaN);
const [rateMajor, setRateMajor] = useState<number>(NaN); // per-entry override (major units) const [rateMajor, setRateMajor] = useState<number>(expense?.rateMinor != null ? expense.rateMinor / 100 : NaN); // per-entry override (major units)
const [categoryId, setCategoryId] = useState<number | undefined>(undefined); const [categoryId, setCategoryId] = useState<number | undefined>(expense?.categoryId ?? undefined);
const [eventId, setEventId] = useState<number | null>(null); const [eventId, setEventId] = useState<number | null>(expense?.eventId ?? null);
const [file, setFile] = useState<File | null>(null); const [file, setFile] = useState<File | null>(null);
const defaultRateMinor = kind === 'mileage' ? (settings?.accounting_km_rate_minor ?? 0) const defaultRateMinor = kind === 'mileage' ? (settings?.accounting_km_rate_minor ?? 0)
@@ -48,20 +53,26 @@ const AddExpenseModal: React.FC<{ categories: ExpenseCategory[]; onClose: () =>
if (kind === 'amount') return Number.isFinite(amountMajor) ? Math.round(amountMajor * 100) : null; if (kind === 'amount') return Number.isFinite(amountMajor) ? Math.round(amountMajor * 100) : null;
return Number.isFinite(quantity) ? Math.round(quantity * effRateMinor) : null; return Number.isFinite(quantity) ? Math.round(quantity * effRateMinor) : null;
}, [kind, amountMajor, quantity, effRateMinor]); }, [kind, amountMajor, quantity, effRateMinor]);
const requireProof = !!settings?.accounting_require_proof; // Proof is only mandatory on CREATE (or when editing a row that has no
// proof yet) — an edit on a row that already has a proof keeps it.
const requireProof = !!settings?.accounting_require_proof && !(isEdit && expense!.hasProof);
const payload = () => ({
kind,
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,
description: description || null,
});
const save = useMutation({ const save = useMutation({
mutationFn: () => accountingService.createExpense({ mutationFn: () => isEdit
kind, ? accountingService.updateExpense(expense!.id, payload(), file)
quantity: kind === 'amount' ? undefined : (Number.isFinite(quantity) ? quantity : undefined), : accountingService.createExpense(payload(), file),
rateMinor: kind === 'amount' ? undefined : (Number.isFinite(rateMajor) ? Math.round(rateMajor * 100) : undefined), onSuccess: () => { toast.success(isEdit ? t('accounting.ledger.updatedToast', 'Expense updated.') : t('accounting.ledger.createdToast', 'Expense added.')); onDone(); },
chfAmountMinor: kind === 'amount' && Number.isFinite(amountMajor) ? Math.round(amountMajor * 100) : undefined,
eventId,
categoryId: categoryId ?? null,
supplierName: supplier || null,
description: description || null,
}, file),
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'),
}); });
@@ -72,7 +83,7 @@ const AddExpenseModal: React.FC<{ categories: ExpenseCategory[]; onClose: () =>
<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">{isEdit ? t('accounting.ledger.editTitle', 'Edit expense') : t('accounting.ledger.addTitle', 'Add expense')}</h2>
<button onClick={onClose} 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">
@@ -112,13 +123,102 @@ const AddExpenseModal: React.FC<{ categories: ExpenseCategory[]; onClose: () =>
<div> <div>
<label className={labelCls}>{t('accounting.expense.proof', 'Proof')}{requireProof ? ' *' : ''}</label> <label className={labelCls}>{t('accounting.expense.proof', 'Proof')}{requireProof ? ' *' : ''}</label>
{isEdit && expense!.hasProof && !file && <p className="mb-1 text-xs text-neutral-500 dark:text-neutral-400">{t('accounting.expense.proofExisting', 'A proof file is already attached — upload a new one to replace it.')}</p>}
<input type="file" accept="image/*,application/pdf" onChange={(e) => setFile(e.target.files?.[0] || null)} className="text-sm" /> <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>} {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 || incomplete}>{save.isPending ? t('common.saving', 'Saving…') : t('accounting.ledger.addExpense', 'Add expense')}</Button> <Button onClick={() => save.mutate()} disabled={save.isPending || incomplete}>{save.isPending ? t('common.saving', 'Saving…') : (isEdit ? t('common.save', 'Save') : t('accounting.ledger.addExpense', 'Add expense'))}</Button>
</div>
</div>
</div>
);
};
/** Mark an expense supplier-paid / settled (manual). #2. */
const ExpensePaidModal: React.FC<{ expense: Expense; onClose: () => void; onDone: () => void }> = ({ expense, 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.markExpensePaid(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.payTitle', 'Mark expense 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.expense.computed', 'Amount')}: <span className="font-semibold text-neutral-900 dark:text-neutral-100">{expense.chfAmountMinor != null ? formatMoneyMinor(expense.chfAmountMinor, '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.ledger.confirmPaid', 'Mark paid')}</Button>
</div>
</div>
</div>
);
};
/** Add an expense onto a client invoice (re-bill). Locks editing. #3. */
const InvoiceExpenseModal: React.FC<{ expense: Expense; onClose: () => void; onDone: () => void }> = ({ expense, onClose, onDone }) => {
const { t } = useTranslation();
const [customer, setCustomer] = useState<SelectedCustomer[]>([]);
const [markupType, setMarkupType] = useState<MarkupType>('none');
const [markupValue, setMarkupValue] = useState<number>(NaN);
const save = useMutation({
mutationFn: () => accountingService.invoiceExpense(expense.id, {
customerAccountId: customer[0]!.id,
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.invoicedToast', 'Added to a client invoice.')); 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 overflow-y-auto bg-black/50 p-4">
<div className="mt-16 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">
<h2 className="text-base font-semibold text-neutral-900 dark:text-neutral-100">{t('accounting.ledger.invoiceTitle', 'Add to client invoice')}</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.expense.computed', 'Amount')}: <span className="font-semibold text-neutral-900 dark:text-neutral-100">{expense.chfAmountMinor != null ? formatMoneyMinor(expense.chfAmountMinor, 'CHF') : '—'}</span>
</p>
<p className="text-xs text-neutral-500 dark:text-neutral-400">{t('accounting.ledger.invoiceHint', 'This creates a billable line on the clients next scheduled invoice and locks the expense from further edits.')}</p>
<div><label className={labelCls}>{t('accounting.inbox.field.customer', 'Client')} *</label>
<CustomerAccountPicker value={customer.slice(0, 1)} onChange={(next) => setCustomer(next.slice(-1))} />
</div>
<div><label className={labelCls}>{t('accounting.inbox.field.markup', 'Markup')}</label>
<select value={markupType} onChange={(e) => setMarkupType(e.target.value as MarkupType)} className={selectCls}>
<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>
{markupType !== 'none' && <DecimalInput value={markupValue} onChange={setMarkupValue} fractionDigits={2} className={selectCls} placeholder={markupType === 'percent' ? '%' : 'CHF'} />}
</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 || !customer[0]}>{save.isPending ? t('common.saving', 'Saving…') : t('accounting.ledger.addToInvoice', 'Add to invoice')}</Button>
</div> </div>
</div> </div>
</div> </div>
@@ -131,6 +231,15 @@ export const ExpensesLedgerPage: React.FC = () => {
const { format } = useLocalizedDate(); const { format } = useLocalizedDate();
const [kind, setKind] = useState(''); const [kind, setKind] = useState('');
const [showAdd, setShowAdd] = useState(false); const [showAdd, setShowAdd] = useState(false);
const [editExpense, setEditExpense] = useState<Expense | null>(null);
const [paidExpense, setPaidExpense] = useState<Expense | null>(null);
const [invoiceExpense, setInvoiceExpense] = useState<Expense | null>(null);
const unpay = useMutation({
mutationFn: (id: number) => accountingService.markExpensePaid(id, { paid: false }),
onSuccess: () => qc.invalidateQueries({ queryKey: ['accounting-expenses'] }),
onError: (e: any) => toast.error(e?.response?.data?.error || e.message || 'Failed'),
});
const { data, isLoading } = useQuery({ const { data, isLoading } = useQuery({
queryKey: ['accounting-expenses', kind], queryKey: ['accounting-expenses', kind],
@@ -166,7 +275,19 @@ export const ExpensesLedgerPage: React.FC = () => {
<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">
<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> <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-1 min-w-[10rem]"> <div className="flex-1 min-w-[10rem]">
<div className="text-sm font-medium text-neutral-900 dark:text-neutral-100 truncate">{ex.description || ex.supplierName || t('accounting.ledger.untitled', 'Expense')}</div> <div className="flex items-center gap-2">
<span className="text-sm font-medium text-neutral-900 dark:text-neutral-100 truncate">{ex.description || ex.supplierName || t('accounting.ledger.untitled', 'Expense')}</span>
{/* invoiced = on a real client invoice → locked (#2/#3). */}
{ex.invoiced && (
ex.billedInvoiceId ? (
<Link to={`/admin/bills/${ex.billedInvoiceId}`} className="inline-flex items-center gap-1 rounded bg-blue-100 text-blue-800 dark:bg-blue-900/40 dark:text-blue-300 px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-wider hover:underline">
<FileText className="w-3 h-3" /> {t('accounting.ledger.invoiced', 'Invoiced')}
</Link>
) : (
<span className="inline-flex items-center gap-1 rounded bg-blue-100 text-blue-800 dark:bg-blue-900/40 dark:text-blue-300 px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-wider"><FileText className="w-3 h-3" /> {t('accounting.ledger.invoiced', 'Invoiced')}</span>
)
)}
</div>
<div className="text-xs text-neutral-500 dark:text-neutral-400"> <div className="text-xs text-neutral-500 dark:text-neutral-400">
{ex.eventId != null ? `${t('accounting.booking.event', 'Event')} #${ex.eventId}` : t('accounting.booking.company', 'Company')} {ex.eventId != null ? `${t('accounting.booking.event', 'Event')} #${ex.eventId}` : t('accounting.booking.company', 'Company')}
{cat && <>{' · '}{categoryLabel(cat, t)}</>} {cat && <>{' · '}{categoryLabel(cat, t)}</>}
@@ -174,6 +295,22 @@ export const ExpensesLedgerPage: React.FC = () => {
</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>} {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>}
{/* Paid toggle (#2): manual, independent of invoiced. */}
{ex.paid
? <button onClick={() => unpay.mutate(ex.id)} disabled={unpay.isPending} title={ex.paidAt ? format(ex.paidAt) : undefined} 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={() => setPaidExpense(ex)}><Circle className="w-3.5 h-3.5 mr-1" /> {t('accounting.ledger.markPaid', 'Mark paid')}</Button>}
{/* Edit + add-to-invoice only until invoiced (#3). */}
{ex.invoiced ? (
<span className="inline-flex items-center gap-1 text-xs text-neutral-400 dark:text-neutral-500" title={t('accounting.ledger.lockedHint', 'Locked — this expense is on a client invoice.') as string}><Lock className="w-3.5 h-3.5" /> {t('accounting.ledger.locked', 'Locked')}</span>
) : (
<>
<Button size="sm" variant="ghost" onClick={() => setEditExpense(ex)}><Pencil className="w-3.5 h-3.5 mr-1" /> {t('common.edit', 'Edit')}</Button>
<Button size="sm" variant="outline" onClick={() => setInvoiceExpense(ex)}><FileText className="w-3.5 h-3.5 mr-1" /> {t('accounting.ledger.addToInvoice', 'Add to invoice')}</Button>
</>
)}
<div className="text-sm font-medium tabular-nums text-neutral-900 dark:text-neutral-100">{ex.chfAmountMinor != null ? formatMoneyMinor(ex.chfAmountMinor, 'CHF') : '—'}</div> <div className="text-sm font-medium tabular-nums text-neutral-900 dark:text-neutral-100">{ex.chfAmountMinor != null ? formatMoneyMinor(ex.chfAmountMinor, 'CHF') : '—'}</div>
</div> </div>
); );
@@ -181,7 +318,10 @@ export const ExpensesLedgerPage: React.FC = () => {
</div> </div>
)} )}
{showAdd && <AddExpenseModal categories={categories ?? []} onClose={() => setShowAdd(false)} onDone={() => { setShowAdd(false); qc.invalidateQueries({ queryKey: ['accounting-expenses'] }); }} />} {showAdd && <ExpenseFormModal categories={categories ?? []} onClose={() => setShowAdd(false)} onDone={() => { setShowAdd(false); qc.invalidateQueries({ queryKey: ['accounting-expenses'] }); }} />}
{editExpense && <ExpenseFormModal categories={categories ?? []} expense={editExpense} onClose={() => setEditExpense(null)} onDone={() => { setEditExpense(null); qc.invalidateQueries({ queryKey: ['accounting-expenses'] }); }} />}
{paidExpense && <ExpensePaidModal expense={paidExpense} onClose={() => setPaidExpense(null)} onDone={() => { setPaidExpense(null); qc.invalidateQueries({ queryKey: ['accounting-expenses'] }); }} />}
{invoiceExpense && <InvoiceExpenseModal expense={invoiceExpense} onClose={() => setInvoiceExpense(null)} onDone={() => { setInvoiceExpense(null); qc.invalidateQueries({ queryKey: ['accounting-expenses'] }); }} />}
</div> </div>
); );
}; };
@@ -56,11 +56,29 @@ export interface Expense {
categoryId: number | null; categoryId: number | null;
hasProof: boolean; hasProof: boolean;
taxTreatment: TaxTreatment | null; taxTreatment: TaxTreatment | null;
/** invoiced = added to a real client invoice (locks editing). */
invoiced: boolean;
billedInvoiceId: number | null;
billedInvoiceLineItemId: number | null;
customerAccountId: number | null;
/** paid = supplier settled (manual toggle). */
paid: boolean;
paidAt: string | null;
paymentMethod: PaymentMethod | null;
status: string; status: string;
createdAt: string; createdAt: string;
updatedAt: string; updatedAt: string;
} }
/** Payload to add an expense onto a client invoice (re-bill). */
export interface InvoiceExpensePayload {
customerAccountId: number;
contractId?: number | null;
markupType?: MarkupType;
markupPercent?: number | null;
markupFlatMinor?: number | null;
}
export interface ExpenseCategory { id: number; name: string; color: string | null; is_seed: boolean; display_order: number; } 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 Paginated<T> { items: T[]; pagination: { page: number; pageSize: number; total: number; totalPages: number }; }
@@ -142,6 +160,14 @@ export const accountingService = {
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 getExpenseProofBlob(id: number): Promise<Blob> { const { data } = await api.get(`/admin/expenses/${id}/proof`, { responseType: 'blob' }); return data; },
/** Add the expense onto a client invoice (re-bill). Locks editing. */
async invoiceExpense(id: number, payload: InvoiceExpensePayload): Promise<{ expense: Expense; invoiceId: number }> {
const { data } = await api.post(`/admin/expenses/${id}/invoice`, payload); return data;
},
/** Toggle the manual supplier-paid state. */
async markExpensePaid(id: number, payload: { paid: boolean; paidAt?: string; paymentMethod?: PaymentMethod; paymentReference?: string }): Promise<Expense> {
const { data } = await api.post(`/admin/expenses/${id}/paid`, payload); return data.expense;
},
// ── categories + settings ── // ── categories + settings ──
async listCategories(): Promise<ExpenseCategory[]> { const { data } = await api.get('/admin/expenses/categories'); return data.items; }, async listCategories(): Promise<ExpenseCategory[]> { const { data } = await api.get('/admin/expenses/categories'); return data.items; },