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:
@@ -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",
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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 ?? '';
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user