diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index aee3c7c9..51330423 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -66,6 +66,7 @@ import { AdminLayout, AdminAuthWrapper } from './components/admin'; import { ClientsLayout } from './components/admin/ClientsLayout'; import { AccountingLayout, AccountingIndex } from './components/admin/AccountingLayout'; import { AccountingInboxPage } from './pages/admin/accounting/AccountingInboxPage'; +import { ExpensesLedgerPage } from './pages/admin/accounting/ExpensesLedgerPage'; import { RequireFeature } from './components/admin/RequireFeature'; import { PageErrorBoundary, OfflineIndicator, SkipLink, DynamicFavicon, RobotsMetaTags, CMSContentBlock, Loading } from './components/common'; import { MaintenanceWrapper } from './components/MaintenanceWrapper'; @@ -271,6 +272,7 @@ function App() { }> }> } /> + } /> }> } /> diff --git a/frontend/src/components/admin/AccountingLayout.tsx b/frontend/src/components/admin/AccountingLayout.tsx index 81f85214..d919ddd6 100644 --- a/frontend/src/components/admin/AccountingLayout.tsx +++ b/frontend/src/components/admin/AccountingLayout.tsx @@ -9,7 +9,7 @@ import React from 'react'; import { NavLink, Outlet, Navigate, useLocation, useNavigate } from 'react-router-dom'; import { useTranslation } from 'react-i18next'; -import { Landmark, Calculator, Inbox } from 'lucide-react'; +import { Landmark, Calculator, Inbox, Wallet } from 'lucide-react'; import type { LucideIcon } from 'lucide-react'; import { useFeatureFlags, type FeatureKey } from '../../contexts/FeatureFlagsContext'; @@ -36,6 +36,13 @@ export const AccountingLayout: React.FC = () => { icon: Inbox, featureFlag: 'incomingInvoices', }, + { + key: 'expenses', + to: '/admin/accounting/expenses', + label: t('accounting.subnav.expenses', 'Expenses'), + icon: Wallet, + featureFlag: 'incomingInvoices', + }, { key: 'tax-report', to: '/admin/accounting/tax-report', diff --git a/frontend/src/i18n/locales/de.json b/frontend/src/i18n/locales/de.json index dbf5d8be..86d5b614 100644 --- a/frontend/src/i18n/locales/de.json +++ b/frontend/src/i18n/locales/de.json @@ -3400,6 +3400,7 @@ }, "subnav": { "incomingInvoices": "Eingangsrechnungen", + "expenses": "Aufwände", "taxReport": "Steuer" }, "disposition": { @@ -3449,6 +3450,36 @@ "eventId": "Event-ID (optional)", "markup": "Zuschlag" } + }, + "expenseStatus": { + "open": "Offen", + "parked": "Geparkt", + "billed": "Verrechnet", + "declined": "Abgelehnt" + }, + "paymentMethod": { + "bank_transfer": "Überweisung", + "cash": "Bargeld", + "twint": "TWINT", + "paypal": "PayPal", + "card": "Karte", + "other": "Sonstiges" + }, + "ledger": { + "allStatuses": "Alle Status", + "allDispositions": "Alle Zuordnungen", + "empty": "Noch keine Aufwände — Dokumente im Eingang kategorisieren.", + "untitled": "Aufwand", + "invoiceLink": "Rechnung", + "paid": "Bezahlt", + "markPaid": "Als bezahlt markieren", + "markPaidTitle": "Lieferant als bezahlt markieren", + "paidDate": "Zahldatum", + "method": "Methode", + "reference": "Referenz (optional)", + "confirmPaid": "Als bezahlt markieren", + "paidToast": "Als bezahlt markiert.", + "unpaidToast": "Als nicht bezahlt markiert." } }, "calendar": { diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json index 829fbe83..a5a03b2c 100644 --- a/frontend/src/i18n/locales/en.json +++ b/frontend/src/i18n/locales/en.json @@ -3400,6 +3400,7 @@ }, "subnav": { "incomingInvoices": "Incoming invoices", + "expenses": "Expenses", "taxReport": "Tax" }, "disposition": { @@ -3449,6 +3450,36 @@ "eventId": "Event ID (optional)", "markup": "Markup" } + }, + "expenseStatus": { + "open": "Open", + "parked": "Parked", + "billed": "Billed", + "declined": "Declined" + }, + "paymentMethod": { + "bank_transfer": "Bank transfer", + "cash": "Cash", + "twint": "TWINT", + "paypal": "PayPal", + "card": "Card", + "other": "Other" + }, + "ledger": { + "allStatuses": "All statuses", + "allDispositions": "All dispositions", + "empty": "No expenses yet — categorize documents in the inbox.", + "untitled": "Expense", + "invoiceLink": "Invoice", + "paid": "Paid", + "markPaid": "Mark paid", + "markPaidTitle": "Mark supplier paid", + "paidDate": "Payment date", + "method": "Method", + "reference": "Reference (optional)", + "confirmPaid": "Mark paid", + "paidToast": "Marked as paid.", + "unpaidToast": "Marked as not paid." } }, "calendar": { diff --git a/frontend/src/pages/admin/accounting/ExpensesLedgerPage.tsx b/frontend/src/pages/admin/accounting/ExpensesLedgerPage.tsx new file mode 100644 index 00000000..9b091532 --- /dev/null +++ b/frontend/src/pages/admin/accounting/ExpensesLedgerPage.tsx @@ -0,0 +1,174 @@ +/** + * Accounting → Expenses ledger. + * + * Lists the expenses booked from triaged inbound documents (and manual ones). + * Shows disposition + status, the linked client invoice for re-billed items, + * and a supplier-payment toggle ("Zu zahlen / Bezahlt") with method capture — + * decoupled from categorisation, per the locked design. + */ +import React, { useState } 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 { CheckCircle2, Circle, X, ExternalLink } from 'lucide-react'; +import { Button, Card, CardContent, Input, LocalizedDateInput, Loading } from '../../../components/common'; +import { formatMoneyMinor } from '../../../utils/money'; +import { useLocalizedDate } from '../../../hooks/useLocalizedDate'; +import { + accountingService, + type Expense, + type Disposition, + type PaymentMethod, +} from '../../../services/accounting.service'; + +const DISPOSITIONS: Disposition[] = ['rebill', 'durchlaufend', 'eigener_aufwand', 'duplikat', 'abgelehnt']; +const STATUSES = ['open', 'parked', 'billed', 'declined']; +const PAYMENT_METHODS: PaymentMethod[] = ['bank_transfer', 'cash', 'twint', 'paypal', 'card', 'other']; + +const statusClasses: Record = { + 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 { t } = useTranslation(); + const [paidAt, setPaidAt] = useState(''); + const [method, setMethod] = useState('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 ( +
+
+
+

{t('accounting.ledger.markPaidTitle', 'Mark supplier paid')}

+ +
+
+
+ + +
+
+ + +
+
+ + setReference(e.target.value)} /> +
+
+
+ + +
+
+
+ ); +}; + +export const ExpensesLedgerPage: React.FC = () => { + const { t } = useTranslation(); + const qc = useQueryClient(); + const { format } = useLocalizedDate(); + const [status, setStatus] = useState(''); + const [disposition, setDisposition] = useState(''); + const [payExpense, setPayExpense] = useState(null); + + const { data, isLoading } = useQuery({ + queryKey: ['accounting-expenses', status, disposition], + queryFn: () => accountingService.listExpenses({ + status: status || undefined, + disposition: (disposition || undefined) as Disposition | undefined, + pageSize: 100, + }), + }); + + const unpay = useMutation({ + mutationFn: (id: number) => accountingService.setSupplierPayment(id, { paid: false }), + onSuccess: () => { toast.success(t('accounting.ledger.unpaidToast', 'Marked as not paid.')); qc.invalidateQueries({ queryKey: ['accounting-expenses'] }); }, + 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 ?? []; + + return ( +
+
+ + +
+ + {isLoading ? ( + + ) : items.length === 0 ? ( + {t('accounting.ledger.empty', 'No expenses yet — categorize documents in the inbox.')} + ) : ( +
+ {items.map((ex) => ( +
+
+
+ {t(`accounting.expenseStatus.${ex.status}`, ex.status)} + {ex.supplierName || ex.description || t('accounting.ledger.untitled', 'Expense')} +
+
+ {t(`accounting.disposition.${ex.disposition}`, ex.disposition)} + {' · '} + {format(ex.createdAt)} + {ex.billedInvoiceId ? ( + <>{' · '}{t('accounting.ledger.invoiceLink', 'Invoice')} + ) : null} +
+
+ +
+ {ex.chfAmountMinor != null ? formatMoneyMinor(ex.chfAmountMinor, 'CHF') : '—'} +
+ + {/* Supplier-payment toggle (skip for declined/duplicate). */} + {ex.disposition !== 'abgelehnt' && ex.disposition !== 'duplikat' && ( + ex.supplierPaid ? ( + + ) : ( + + ) + )} +
+ ))} +
+ )} + + {payExpense && ( + setPayExpense(null)} onDone={() => { setPayExpense(null); qc.invalidateQueries({ queryKey: ['accounting-expenses'] }); }} /> + )} +
+ ); +}; + +export default ExpensesLedgerPage;