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
143 lines
5.1 KiB
TypeScript
143 lines
5.1 KiB
TypeScript
import { IconButton, MenuItem, Table, TableBody, TableCell, TableHead, TableRow, TextField } from '@mui/material';
|
|
import DeleteIcon from '@mui/icons-material/Delete';
|
|
import { MoneyCell } from '../../../components/common/MoneyCell';
|
|
import type { GLAccount, TaxCode } from '../../../types/api';
|
|
|
|
export interface APDocumentLineDraft {
|
|
key: number;
|
|
description: string;
|
|
quantity: string;
|
|
unitPrice: string;
|
|
expenseGLAccountId: number | '';
|
|
taxCodeId: number | '';
|
|
}
|
|
|
|
let keySeed = 0;
|
|
export const blankAPDocumentLine = (): APDocumentLineDraft => ({
|
|
key: keySeed++,
|
|
description: '',
|
|
quantity: '1',
|
|
unitPrice: '',
|
|
expenseGLAccountId: '',
|
|
taxCodeId: '',
|
|
});
|
|
|
|
export function apLineTotals(line: APDocumentLineDraft, taxCodes: TaxCode[]) {
|
|
const quantity = Number(line.quantity) || 0;
|
|
const unitPrice = Number(line.unitPrice) || 0;
|
|
const subtotal = quantity * unitPrice;
|
|
const rate = line.taxCodeId ? Number(taxCodes.find((t) => t.id === line.taxCodeId)?.ratePercent ?? 0) : 0;
|
|
const tax = subtotal * (rate / 100);
|
|
return { subtotal, tax, total: subtotal + tax };
|
|
}
|
|
|
|
interface APDocumentLineEditorProps {
|
|
lines: APDocumentLineDraft[];
|
|
onChange: (lines: APDocumentLineDraft[]) => void;
|
|
expenseAccounts: GLAccount[];
|
|
taxCodes: TaxCode[];
|
|
}
|
|
|
|
export function APDocumentLineEditor({ lines, onChange, expenseAccounts, taxCodes }: APDocumentLineEditorProps) {
|
|
const updateLine = (key: number, patch: Partial<APDocumentLineDraft>) =>
|
|
onChange(lines.map((line) => (line.key === key ? { ...line, ...patch } : line)));
|
|
|
|
const removeLine = (key: number) => onChange(lines.length > 1 ? lines.filter((line) => line.key !== key) : lines);
|
|
|
|
return (
|
|
<Table size="small">
|
|
<TableHead>
|
|
<TableRow>
|
|
<TableCell width="30%">Description</TableCell>
|
|
<TableCell width="10%" align="right">
|
|
Qty
|
|
</TableCell>
|
|
<TableCell width="14%" align="right">
|
|
Unit Price
|
|
</TableCell>
|
|
<TableCell width="20%">Expense Account</TableCell>
|
|
<TableCell width="14%">Tax</TableCell>
|
|
<TableCell width="10%" align="right">
|
|
Amount
|
|
</TableCell>
|
|
<TableCell width="2%" />
|
|
</TableRow>
|
|
</TableHead>
|
|
<TableBody>
|
|
{lines.map((line) => {
|
|
const { total } = apLineTotals(line, taxCodes);
|
|
return (
|
|
<TableRow key={line.key}>
|
|
<TableCell>
|
|
<TextField
|
|
fullWidth
|
|
size="small"
|
|
value={line.description}
|
|
onChange={(e) => updateLine(line.key, { description: e.target.value })}
|
|
/>
|
|
</TableCell>
|
|
<TableCell align="right">
|
|
<TextField
|
|
type="number"
|
|
size="small"
|
|
value={line.quantity}
|
|
onChange={(e) => updateLine(line.key, { quantity: e.target.value })}
|
|
slotProps={{ htmlInput: { min: 0, step: '0.01', style: { textAlign: 'right' } } }}
|
|
/>
|
|
</TableCell>
|
|
<TableCell align="right">
|
|
<TextField
|
|
type="number"
|
|
size="small"
|
|
value={line.unitPrice}
|
|
onChange={(e) => updateLine(line.key, { unitPrice: e.target.value })}
|
|
slotProps={{ htmlInput: { min: 0, step: '0.01', style: { textAlign: 'right' } } }}
|
|
/>
|
|
</TableCell>
|
|
<TableCell>
|
|
<TextField
|
|
select
|
|
fullWidth
|
|
size="small"
|
|
value={line.expenseGLAccountId}
|
|
onChange={(e) => updateLine(line.key, { expenseGLAccountId: Number(e.target.value) })}
|
|
>
|
|
{expenseAccounts.map((account) => (
|
|
<MenuItem key={account.id} value={account.id}>
|
|
{account.accountCode} — {account.accountName}
|
|
</MenuItem>
|
|
))}
|
|
</TextField>
|
|
</TableCell>
|
|
<TableCell>
|
|
<TextField
|
|
select
|
|
fullWidth
|
|
size="small"
|
|
value={line.taxCodeId}
|
|
onChange={(e) => updateLine(line.key, { taxCodeId: e.target.value ? Number(e.target.value) : '' })}
|
|
>
|
|
<MenuItem value="">None</MenuItem>
|
|
{taxCodes.map((taxCode) => (
|
|
<MenuItem key={taxCode.id} value={taxCode.id}>
|
|
{taxCode.code} ({Number(taxCode.ratePercent)}%)
|
|
</MenuItem>
|
|
))}
|
|
</TextField>
|
|
</TableCell>
|
|
<TableCell align="right">
|
|
<MoneyCell value={total} />
|
|
</TableCell>
|
|
<TableCell>
|
|
<IconButton size="small" onClick={() => removeLine(line.key)} disabled={lines.length <= 1}>
|
|
<DeleteIcon fontSize="small" />
|
|
</IconButton>
|
|
</TableCell>
|
|
</TableRow>
|
|
);
|
|
})}
|
|
</TableBody>
|
|
</Table>
|
|
);
|
|
}
|