diff --git a/frontend/src/i18n/locales/de.json b/frontend/src/i18n/locales/de.json index 3c7a7570..f2eeed34 100644 --- a/frontend/src/i18n/locales/de.json +++ b/frontend/src/i18n/locales/de.json @@ -3474,6 +3474,7 @@ "triageTitle": "Dokument kategorisieren", "saveCategorize": "Speichern", "categorize": "Kategorisieren", + "view": "Ansehen", "empty": "Noch keine Dokumente — oben eines erfassen.", "untitled": "Unbenanntes Dokument", "noAmount": "Betrag nicht erfasst", @@ -3485,6 +3486,7 @@ "nextPage": "Weiter", "pageOf": "Seite {{n}} / {{total}}", "status": { + "label": "Status", "unsorted": "Neu", "categorized": "Kategorisiert", "declined": "Abgelehnt", @@ -3545,6 +3547,7 @@ "whoHint": "z. B. Mitarbeitername oder Geschäft", "proof": "Beleg", "proofRequired": "Ein Beleg ist erforderlich.", + "proofExisting": "Es ist bereits ein Beleg angehängt — neuen hochladen, um ihn zu ersetzen.", "viewProof": "Beleg" }, "expenseKind": { @@ -3582,6 +3585,16 @@ "unpaidToast": "Als nicht bezahlt markiert.", "addExpense": "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", "descriptionHint": "z. B. Kilometer, Spesenpauschale, Barbeleg", "createdToast": "Aufwand hinzugefügt." diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json index 2493ffff..feeb1ad5 100644 --- a/frontend/src/i18n/locales/en.json +++ b/frontend/src/i18n/locales/en.json @@ -3474,6 +3474,7 @@ "triageTitle": "Categorize document", "saveCategorize": "Save", "categorize": "Categorize", + "view": "View", "empty": "No documents yet — capture one above.", "untitled": "Untitled document", "noAmount": "amount not entered", @@ -3485,6 +3486,7 @@ "nextPage": "Next", "pageOf": "Page {{n}} / {{total}}", "status": { + "label": "Status", "unsorted": "New", "categorized": "Categorized", "declined": "Declined", @@ -3545,6 +3547,7 @@ "whoHint": "e.g. coworker name or shop", "proof": "Proof", "proofRequired": "A proof file is required.", + "proofExisting": "A proof file is already attached — upload a new one to replace it.", "viewProof": "Proof" }, "expenseKind": { @@ -3582,6 +3585,16 @@ "unpaidToast": "Marked as not paid.", "addExpense": "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 client’s 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", "descriptionHint": "e.g. mileage, per-diem, cash receipt", "createdToast": "Expense added." diff --git a/frontend/src/pages/admin/accounting/AccountingInboxPage.tsx b/frontend/src/pages/admin/accounting/AccountingInboxPage.tsx index 71678787..0e10aa4d 100644 --- a/frontend/src/pages/admin/accounting/AccountingInboxPage.tsx +++ b/frontend/src/pages/admin/accounting/AccountingInboxPage.tsx @@ -9,7 +9,7 @@ import React, { useRef, useState, useEffect } from 'react'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useTranslation } from 'react-i18next'; import { toast } from 'react-toastify'; -import { Camera, Upload, Inbox, X, 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 { DecimalInput } from '../../../components/common/DecimalInput'; import { CustomerAccountPicker, type SelectedCustomer } from '../../../components/admin/CustomerAccountPicker'; @@ -35,6 +35,47 @@ const statusClasses: Record = { 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'; +/** + * 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(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 ( +
+
+ {previewError ?
{t('accounting.inbox.previewError', 'Preview unavailable — enter the fields manually.')}
+ : imgUrl ? document page + :
{t('accounting.inbox.previewLoading', 'Loading preview…')}
} +
+ {isPdf && pageCount > 1 && ( +
+ + {t('accounting.inbox.pageOf', 'Page {{n}} / {{total}}', { n: page, total: pageCount })} + +
+ )} + {isPdf &&

{t('accounting.inbox.qrHint', 'Showing the last page — the Swiss QR-bill usually sits at the bottom.')}

} +
+ ); +}; + const PayModal: React.FC<{ doc: InboundDocument; onClose: () => void; onDone: () => void }> = ({ doc, onClose, onDone }) => { const { t } = useTranslation(); 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'), }); return ( -
-
+
+ {/* Wider, two-column: preview on the left so the admin can read the + QR-bill while confirming payment — #1. */} +

{t('accounting.incoming.payTitle', 'Mark supplier paid')}

-
-

- {t('accounting.incoming.outstanding', 'Outstanding')}: {doc.totalAmountMinor != null ? formatMoneyMinor(doc.totalAmountMinor, doc.currency || 'CHF') : '—'} -

-
-
- +
+
+
+

+ {t('accounting.incoming.outstanding', 'Outstanding')}: {doc.totalAmountMinor != null ? formatMoneyMinor(doc.totalAmountMinor, doc.currency || 'CHF') : '—'} +

+
+
+ +
+
setReference(e.target.value)} />
-
setReference(e.target.value)} />
@@ -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) => ( +
+ {label} + {value ?? '—'} +
+ ); + return ( +
+
+
+

{doc.supplierName || doc.originalFilename || t('accounting.inbox.untitled', 'Untitled document')}

+ +
+
+
+
+ {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'))} +
+
+
+ +
+
+
+ ); +}; + const TriageModal: React.FC<{ doc: InboundDocument; categories: ExpenseCategory[]; onClose: () => void; onDone: () => void }> = ({ doc, categories, onClose, onDone }) => { const { t } = useTranslation(); const [supplier, setSupplier] = useState(doc.supplierName || ''); @@ -88,21 +173,6 @@ const TriageModal: React.FC<{ doc: InboundDocument; categories: ExpenseCategory[ 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(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 = () => ({ markupType, markupPercent: markupType === 'percent' && Number.isFinite(markupValue) ? markupValue : null, @@ -134,21 +204,7 @@ const TriageModal: React.FC<{ doc: InboundDocument; categories: ExpenseCategory[
-
-
- {previewError ?
{t('accounting.inbox.previewError', 'Preview unavailable — enter the fields manually.')}
- : imgUrl ? document page - :
{t('accounting.inbox.previewLoading', 'Loading preview…')}
} -
- {isPdf && pageCount > 1 && ( -
- - {t('accounting.inbox.pageOf', 'Page {{n}} / {{total}}', { n: page, total: pageCount })} - -
- )} - {isPdf &&

{t('accounting.inbox.qrHint', 'Showing the last page — the Swiss QR-bill usually sits at the bottom.')}

} -
+
@@ -213,6 +269,7 @@ export const AccountingInboxPage: React.FC = () => { const uploadRef = useRef(null); const [triageDoc, setTriageDoc] = useState(null); const [payDoc, setPayDoc] = useState(null); + const [viewDoc, setViewDoc] = useState(null); const { data, isLoading } = useQuery({ queryKey: ['accounting-inbound'], queryFn: () => accountingService.listInbound({ pageSize: 100 }) }); const { data: categories } = useQuery({ queryKey: ['expense-categories'], queryFn: () => accountingService.listCategories() }); @@ -257,10 +314,11 @@ export const AccountingInboxPage: React.FC = () => {
{items.map((doc) => (
-
+ {/* Click anywhere on the summary to re-view the document — #1. */} +
+ + {doc.status !== 'declined' && doc.status !== 'duplicate' && ( doc.supplierPaid ? @@ -282,6 +341,7 @@ export const AccountingInboxPage: React.FC = () => { {triageDoc && setTriageDoc(null)} onDone={() => { setTriageDoc(null); refresh(); }} />} {payDoc && setPayDoc(null)} onDone={() => { setPayDoc(null); refresh(); }} />} + {viewDoc && setViewDoc(null)} />}
); }; diff --git a/frontend/src/pages/admin/accounting/ExpensesLedgerPage.tsx b/frontend/src/pages/admin/accounting/ExpensesLedgerPage.tsx index 8e3a9db0..4bc8ab83 100644 --- a/frontend/src/pages/admin/accounting/ExpensesLedgerPage.tsx +++ b/frontend/src/pages/admin/accounting/ExpensesLedgerPage.tsx @@ -7,20 +7,24 @@ * entry. No supplier payment here — that lives on incoming invoices. */ import React, { useState, useMemo } from 'react'; +import { Link } from 'react-router-dom'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useTranslation } from 'react-i18next'; import { toast } from 'react-toastify'; -import { X, Plus, Paperclip, Car, CalendarDays, Coins } from 'lucide-react'; -import { Button, Card, CardContent, Input, Loading } from '../../../components/common'; +import { X, Plus, Paperclip, Car, CalendarDays, Coins, Pencil, FileText, CheckCircle2, Circle, Lock } from 'lucide-react'; +import { Button, Card, CardContent, Input, LocalizedDateInput, Loading } from '../../../components/common'; import { DecimalInput } from '../../../components/common/DecimalInput'; import { EventBookingSelect } from '../../../components/admin/EventBookingSelect'; +import { CustomerAccountPicker, type SelectedCustomer } from '../../../components/admin/CustomerAccountPicker'; import { formatMoneyMinor } from '../../../utils/money'; import { useLocalizedDate } from '../../../hooks/useLocalizedDate'; import { accountingService, categoryLabel, - type Expense, type ExpenseKind, type ExpenseCategory, + type Expense, type ExpenseKind, type ExpenseCategory, type MarkupType, type PaymentMethod, } from '../../../services/accounting.service'; +const PAYMENT_METHODS: PaymentMethod[] = ['bank_transfer', 'cash', 'twint', 'paypal', 'card', 'other']; + const KINDS: ExpenseKind[] = ['amount', 'mileage', 'per_diem']; 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'; @@ -28,17 +32,18 @@ const kindIcon: Record = { amount: , mileage: , per_diem: , }; -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 isEdit = !!expense; const { data: settings } = useQuery({ queryKey: ['accounting-settings'], queryFn: () => accountingService.getSettings() }); - const [kind, setKind] = useState('amount'); - const [supplier, setSupplier] = useState(''); - const [description, setDescription] = useState(''); - const [amountMajor, setAmountMajor] = useState(NaN); - const [quantity, setQuantity] = useState(NaN); - const [rateMajor, setRateMajor] = useState(NaN); // per-entry override (major units) - const [categoryId, setCategoryId] = useState(undefined); - const [eventId, setEventId] = useState(null); + const [kind, setKind] = useState(expense?.kind ?? 'amount'); + const [supplier, setSupplier] = useState(expense?.supplierName ?? ''); + const [description, setDescription] = useState(expense?.description ?? ''); + const [amountMajor, setAmountMajor] = useState(expense && expense.kind === 'amount' && expense.chfAmountMinor != null ? expense.chfAmountMinor / 100 : NaN); + const [quantity, setQuantity] = useState(expense?.quantity != null ? expense.quantity : NaN); + const [rateMajor, setRateMajor] = useState(expense?.rateMinor != null ? expense.rateMinor / 100 : NaN); // per-entry override (major units) + const [categoryId, setCategoryId] = useState(expense?.categoryId ?? undefined); + const [eventId, setEventId] = useState(expense?.eventId ?? null); const [file, setFile] = useState(null); 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; return Number.isFinite(quantity) ? Math.round(quantity * effRateMinor) : null; }, [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({ - mutationFn: () => accountingService.createExpense({ - 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, - }, file), - onSuccess: () => { toast.success(t('accounting.ledger.createdToast', 'Expense added.')); onDone(); }, + mutationFn: () => isEdit + ? accountingService.updateExpense(expense!.id, payload(), file) + : accountingService.createExpense(payload(), file), + onSuccess: () => { toast.success(isEdit ? t('accounting.ledger.updatedToast', 'Expense updated.') : t('accounting.ledger.createdToast', 'Expense added.')); onDone(); }, onError: (e: any) => toast.error(e?.response?.data?.error || e.message || 'Failed'), }); @@ -72,7 +83,7 @@ const AddExpenseModal: React.FC<{ categories: ExpenseCategory[]; onClose: () =>
-

{t('accounting.ledger.addTitle', 'Add expense')}

+

{isEdit ? t('accounting.ledger.editTitle', 'Edit expense') : t('accounting.ledger.addTitle', 'Add expense')}

@@ -112,13 +123,102 @@ const AddExpenseModal: React.FC<{ categories: ExpenseCategory[]; onClose: () =>
+ {isEdit && expense!.hasProof && !file &&

{t('accounting.expense.proofExisting', 'A proof file is already attached — upload a new one to replace it.')}

} setFile(e.target.files?.[0] || null)} className="text-sm" /> {requireProof && !file &&

{t('accounting.expense.proofRequired', 'A proof file is required.')}

}
- + +
+
+
+ ); +}; + +/** 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('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 ( +
+
+
+

{t('accounting.ledger.payTitle', 'Mark expense paid')}

+ +
+
+

+ {t('accounting.expense.computed', 'Amount')}: {expense.chfAmountMinor != null ? formatMoneyMinor(expense.chfAmountMinor, 'CHF') : '—'} +

+
+
+ +
+
setReference(e.target.value)} />
+
+
+ + +
+
+
+ ); +}; + +/** 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([]); + const [markupType, setMarkupType] = useState('none'); + const [markupValue, setMarkupValue] = useState(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 ( +
+
+
+

{t('accounting.ledger.invoiceTitle', 'Add to client invoice')}

+ +
+
+

+ {t('accounting.expense.computed', 'Amount')}: {expense.chfAmountMinor != null ? formatMoneyMinor(expense.chfAmountMinor, 'CHF') : '—'} +

+

{t('accounting.ledger.invoiceHint', 'This creates a billable line on the client’s next scheduled invoice and locks the expense from further edits.')}

+
+ setCustomer(next.slice(-1))} /> +
+
+ +
+ {markupType !== 'none' && } +
+
+ +
@@ -131,6 +231,15 @@ export const ExpensesLedgerPage: React.FC = () => { const { format } = useLocalizedDate(); const [kind, setKind] = useState(''); const [showAdd, setShowAdd] = useState(false); + const [editExpense, setEditExpense] = useState(null); + const [paidExpense, setPaidExpense] = useState(null); + const [invoiceExpense, setInvoiceExpense] = useState(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({ queryKey: ['accounting-expenses', kind], @@ -166,7 +275,19 @@ export const ExpensesLedgerPage: React.FC = () => {
{kindIcon[ex.kind]} {t(`accounting.expenseKind.${ex.kind}`, ex.kind)}
-
{ex.description || ex.supplierName || t('accounting.ledger.untitled', 'Expense')}
+
+ {ex.description || ex.supplierName || t('accounting.ledger.untitled', 'Expense')} + {/* invoiced = on a real client invoice → locked (#2/#3). */} + {ex.invoiced && ( + ex.billedInvoiceId ? ( + + {t('accounting.ledger.invoiced', 'Invoiced')} + + ) : ( + {t('accounting.ledger.invoiced', 'Invoiced')} + ) + )} +
{ex.eventId != null ? `${t('accounting.booking.event', 'Event')} #${ex.eventId}` : t('accounting.booking.company', 'Company')} {cat && <>{' · '}{categoryLabel(cat, t)}} @@ -174,6 +295,22 @@ export const ExpensesLedgerPage: React.FC = () => {
{ex.hasProof && } + + {/* Paid toggle (#2): manual, independent of invoiced. */} + {ex.paid + ? + : } + + {/* Edit + add-to-invoice only until invoiced (#3). */} + {ex.invoiced ? ( + {t('accounting.ledger.locked', 'Locked')} + ) : ( + <> + + + + )} +
{ex.chfAmountMinor != null ? formatMoneyMinor(ex.chfAmountMinor, 'CHF') : '—'}
); @@ -181,7 +318,10 @@ export const ExpensesLedgerPage: React.FC = () => {
)} - {showAdd && setShowAdd(false)} onDone={() => { setShowAdd(false); qc.invalidateQueries({ queryKey: ['accounting-expenses'] }); }} />} + {showAdd && setShowAdd(false)} onDone={() => { setShowAdd(false); qc.invalidateQueries({ queryKey: ['accounting-expenses'] }); }} />} + {editExpense && setEditExpense(null)} onDone={() => { setEditExpense(null); qc.invalidateQueries({ queryKey: ['accounting-expenses'] }); }} />} + {paidExpense && setPaidExpense(null)} onDone={() => { setPaidExpense(null); qc.invalidateQueries({ queryKey: ['accounting-expenses'] }); }} />} + {invoiceExpense && setInvoiceExpense(null)} onDone={() => { setInvoiceExpense(null); qc.invalidateQueries({ queryKey: ['accounting-expenses'] }); }} />}
); }; diff --git a/frontend/src/services/accounting.service.ts b/frontend/src/services/accounting.service.ts index 492bfb8e..1db12437 100644 --- a/frontend/src/services/accounting.service.ts +++ b/frontend/src/services/accounting.service.ts @@ -56,11 +56,29 @@ export interface Expense { categoryId: number | null; hasProof: boolean; 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; createdAt: 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 Paginated { items: T[]; pagination: { page: number; pageSize: number; total: number; totalPages: number }; } @@ -142,6 +160,14 @@ export const accountingService = { return data.expense; }, async getExpenseProofBlob(id: number): Promise { 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 { + const { data } = await api.post(`/admin/expenses/${id}/paid`, payload); return data.expense; + }, // ── categories + settings ── async listCategories(): Promise { const { data } = await api.get('/admin/expenses/categories'); return data.items; },