Files
cross-financial-management/client/src/features/ap/shared/APDocumentLineEditor.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

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>
);
}