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