Files
cross-financial-management/client/src/features/ar/invoices/InvoiceDetailPage.tsx
T
Joshua OosthuizenandClaude Sonnet 5 4acbc03b35 Initial commit: multi-company financial management system
Root company app (src/, client/) — GL foundation, AR, and AP:
- Segmented Chart of Accounts (Sage 300-style), double-entry PostingService with
  three-layer balance enforcement (app validation, CHECK constraints, deferred
  DB trigger), gap-free document numbering, RBAC with branch restrictions.
- Accounts Receivable: Customers, Invoices, Credit Notes, Receipts with
  allocation, all posting through PostingService.
- Accounts Payable: Suppliers, Supplier Invoices, Debit Notes, Payments with
  allocation, mirroring AR's posting pattern.
- Tax Codes shared between AR and AP.
- Company-app login is a token handoff from the Account Manager (RS256,
  single-use, company-scoped) rather than a local password.
- Frontend reskinned to the approved mockup: navy sidebar, dashboard, tree-view
  Chart of Accounts, Customer/Supplier 360 pages.

Account Manager (manager/, manager/client/) — central login gateway:
- Password + TOTP 2FA + Google/Microsoft OAuth, company picker, and the RS256
  handoff token issuance that the company app verifies.

Covered by unit + integration tests against a real Postgres database (GL
balance invariants, AR/AP posting correctness and allocation limits, the full
handoff exchange including replay/cross-company/expiry/forgery rejection).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Tq2DbEvzHzs13jDFhLaX44
2026-09-03 13:27:19 +02:00

225 lines
7.6 KiB
TypeScript

import { useEffect, useRef, useState } from 'react';
import { useNavigate, useParams, useSearchParams } from 'react-router-dom';
import {
Alert,
Box,
Button,
Card,
CircularProgress,
Stack,
Table,
TableBody,
TableCell,
TableHead,
TableRow,
Typography,
} from '@mui/material';
import { useInvoice, usePostInvoice, useVoidInvoice } from '../../../hooks/useARInvoices';
import { useAuth } from '../../../hooks/useAuth';
import { StatusChip } from '../../../components/common/StatusChip';
import { MoneyCell } from '../../../components/common/MoneyCell';
export function InvoiceDetailPage() {
const { id } = useParams();
const navigate = useNavigate();
const [searchParams, setSearchParams] = useSearchParams();
const invoiceId = id ? Number(id) : undefined;
const { data: invoice, isLoading } = useInvoice(invoiceId);
const postInvoice = usePostInvoice();
const voidInvoice = useVoidInvoice();
const { hasPermission } = useAuth();
const [error, setError] = useState<string | null>(null);
const autoPosted = useRef(false);
useEffect(() => {
if (searchParams.get('post') === '1' && invoice?.status === 'DRAFT' && !autoPosted.current) {
autoPosted.current = true;
postInvoice.mutateAsync(invoice.id).catch((err) => {
const message = (err as { response?: { data?: { error?: string } } })?.response?.data?.error;
setError(message ?? 'Failed to post invoice');
});
setSearchParams({}, { replace: true });
}
}, [invoice, postInvoice, searchParams, setSearchParams]);
if (isLoading || !invoice) return <CircularProgress />;
const handlePost = async () => {
setError(null);
try {
await postInvoice.mutateAsync(invoice.id);
} catch (err) {
const message = (err as { response?: { data?: { error?: string } } })?.response?.data?.error;
setError(message ?? 'Failed to post invoice');
}
};
const handleVoid = async () => {
setError(null);
try {
await voidInvoice.mutateAsync(invoice.id);
} catch (err) {
const message = (err as { response?: { data?: { error?: string } } })?.response?.data?.error;
setError(message ?? 'Failed to void invoice');
}
};
return (
<Box>
<Stack direction="row" sx={{ mb: 2, justifyContent: 'space-between', alignItems: 'center' }}>
<Stack direction="row" spacing={1.5} sx={{ alignItems: 'center' }}>
<Typography variant="h5">{invoice.invoiceNumber}</Typography>
<StatusChip status={invoice.status} />
</Stack>
<Stack direction="row" spacing={1.5}>
{invoice.status === 'DRAFT' && hasPermission('ar.invoice.manage') && (
<>
<Button variant="outlined" color="error" onClick={handleVoid} disabled={voidInvoice.isPending}>
Void
</Button>
<Button variant="contained" onClick={handlePost} disabled={postInvoice.isPending}>
Post Invoice
</Button>
</>
)}
</Stack>
</Stack>
{error && (
<Alert severity="error" sx={{ mb: 2 }}>
{error}
</Alert>
)}
<Card sx={{ p: 2, mb: 2 }}>
<Stack direction="row" spacing={4} sx={{ flexWrap: 'wrap' }}>
<Box>
<Typography variant="caption" color="text.secondary">
Customer
</Typography>
<Typography
sx={{ cursor: 'pointer', color: 'primary.main' }}
onClick={() => navigate(`/ar/customers/${invoice.customerId}`)}
>
{invoice.customer?.name}
</Typography>
</Box>
<Box>
<Typography variant="caption" color="text.secondary">
Invoice Date
</Typography>
<Typography>{invoice.invoiceDate.slice(0, 10)}</Typography>
</Box>
<Box>
<Typography variant="caption" color="text.secondary">
Due Date
</Typography>
<Typography>{invoice.dueDate.slice(0, 10)}</Typography>
</Box>
<Box>
<Typography variant="caption" color="text.secondary">
Reference
</Typography>
<Typography>{invoice.reference || '—'}</Typography>
</Box>
<Box>
<Typography variant="caption" color="text.secondary">
Balance
</Typography>
<Typography>
<MoneyCell value={invoice.balance} />
</Typography>
</Box>
</Stack>
</Card>
<Card>
<Table size="small">
<TableHead>
<TableRow>
<TableCell>Description</TableCell>
<TableCell align="right">Qty</TableCell>
<TableCell align="right">Unit Price</TableCell>
<TableCell>Revenue Account</TableCell>
<TableCell>Tax</TableCell>
<TableCell align="right">Amount</TableCell>
</TableRow>
</TableHead>
<TableBody>
{invoice.lines.map((line) => (
<TableRow key={line.id}>
<TableCell>{line.description}</TableCell>
<TableCell align="right">{line.quantity}</TableCell>
<TableCell align="right">
<MoneyCell value={line.unitPrice} />
</TableCell>
<TableCell>{line.revenueGLAccount?.accountName}</TableCell>
<TableCell>{line.taxCode?.code ?? '—'}</TableCell>
<TableCell align="right">
<MoneyCell value={line.lineTotal} />
</TableCell>
</TableRow>
))}
<TableRow>
<TableCell colSpan={5} align="right">
Subtotal
</TableCell>
<TableCell align="right">
<MoneyCell value={invoice.subtotal} />
</TableCell>
</TableRow>
<TableRow>
<TableCell colSpan={5} align="right">
Tax
</TableCell>
<TableCell align="right">
<MoneyCell value={invoice.taxTotal} />
</TableCell>
</TableRow>
<TableRow>
<TableCell colSpan={5} align="right">
<strong>Total</strong>
</TableCell>
<TableCell align="right">
<strong>
<MoneyCell value={invoice.total} />
</strong>
</TableCell>
</TableRow>
</TableBody>
</Table>
</Card>
{invoice.allocations && invoice.allocations.length > 0 && (
<Card sx={{ p: 2, mt: 2 }}>
<Typography variant="subtitle1" sx={{ mb: 1.5 }}>
Allocations
</Typography>
<Table size="small">
<TableHead>
<TableRow>
<TableCell>Source</TableCell>
<TableCell>Date</TableCell>
<TableCell align="right">Amount</TableCell>
</TableRow>
</TableHead>
<TableBody>
{invoice.allocations.map((allocation) => (
<TableRow key={allocation.id}>
<TableCell sx={{ fontFamily: 'monospace' }}>
{allocation.receipt?.receiptNumber ?? allocation.creditNote?.creditNoteNumber}
</TableCell>
<TableCell>{allocation.allocatedAt.slice(0, 10)}</TableCell>
<TableCell align="right">
<MoneyCell value={allocation.amount} />
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</Card>
)}
</Box>
);
}