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
225 lines
7.6 KiB
TypeScript
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>
|
|
);
|
|
}
|