Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/md-totals-stack.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"@object-ui/plugin-form": minor
---

Master-detail form: live Subtotal / Tax / Total stack.

`MasterDetailForm` now renders a right-aligned document totals stack under the line items when the parent form has a tax-rate field (`taxRateField`, default `tax_rate`): **Subtotal** (Σ line amounts) → **Tax** (header rate %) → **Total**, recomputed live as lines and the rate change. The header rate is read via scoped event delegation on the form host (no coupling into `ObjectForm` internals). When the stack is shown, the per-grid footer total is subsumed.
6 changes: 6 additions & 0 deletions e2e/live/form-view-subforms.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,12 @@ test('New <object> modal renders relationship-derived subforms and submits an at
// Computed Amount = 2 × 29.99 (read-only).
await expect(li.locator('[data-computed="amount"]').first()).toContainText('59.98');

// Document totals stack: set the header tax rate → Subtotal / Tax / Total.
await dialog.locator('input[name="tax_rate"]').fill('10');
await expect(dialog.getByTestId('md-subtotal')).toContainText('59.98');
await expect(dialog.getByTestId('md-tax')).toContainText('6.00'); // 10% of 59.98 → 5.998 ≈ 6.00
await expect(dialog.getByTestId('md-grand-total')).toContainText('65.98'); // 59.98 + 5.998

await Promise.all([
page.waitForRequest((r) => r.url().includes('/api/v1/batch'), { timeout: 15_000 }).catch(() => null),
dialog.getByTestId('md-form-submit').click(),
Expand Down
71 changes: 68 additions & 3 deletions packages/plugin-form/src/MasterDetailForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,10 @@ export interface MasterDetailFormSchema {
submitText?: string;
/** One or more child collections. */
details: MasterDetailDetailConfig[];
/** Parent header field holding a tax rate (percent). When the parent form has
* this field, a live Subtotal / Tax / Total stack renders under the lines.
* Defaults to `tax_rate`; the stack only appears if the field is present. */
taxRateField?: string;
onSuccess?: (parent: any) => void | Promise<void>;
onError?: (err: Error) => void;
onCancel?: () => void;
Expand Down Expand Up @@ -198,6 +202,36 @@ export const MasterDetailForm: React.FC<MasterDetailFormProps> = ({
setState((prev) => prev.map((s, i) => (i === detailIdx ? { ...s, rows } : s)));
}, []);

// Live header tax rate, read from the parent form's `tax_rate` input via
// scoped event delegation on the form host (no coupling into ObjectForm's
// internals). Drives the Subtotal / Tax / Total stack under the lines.
const taxRateField = schema.taxRateField || 'tax_rate';
const [taxRate, setTaxRate] = useState<number | null>(null);
useEffect(() => {
const host = formHostRef.current;
if (!host) return;
const read = () => {
const el = host.querySelector(`[name="${taxRateField}"]`) as HTMLInputElement | null;
if (!el) { setTaxRate(null); return; }
const n = Number(el.value);
setTaxRate(Number.isFinite(n) ? n : 0);
};
read();
const onInput = (e: Event) => {
const t = e.target as HTMLInputElement | null;
if (t && t.name === taxRateField) read();
};
host.addEventListener('input', onInput);
host.addEventListener('change', onInput);
const t = setTimeout(read, 300); // re-read once the form has mounted its fields
return () => {
host.removeEventListener('input', onInput);
host.removeEventListener('change', onInput);
clearTimeout(t);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [taxRateField, formKey, details.length]);

// Per-row "expand to full form": opens the child's complete form (all business
// fields, incl. rich types the grid omits) in a drawer, pre-filled with the
// row. Saving writes back into the in-memory row — the atomic batch still
Expand Down Expand Up @@ -424,6 +458,16 @@ export const MasterDetailForm: React.FC<MasterDetailFormProps> = ({

useEffect(() => () => { if (saveGuardTimer.current) clearTimeout(saveGuardTimer.current); }, []);

// Document totals: Subtotal (Σ line amounts) → Tax (header rate %) → Total.
// Shown only when the parent has the tax-rate field AND a detail has an
// amount column; otherwise each grid keeps its own footer total.
const subtotal = details.reduce((acc, d, i) => acc + sumRows(state[i]?.rows ?? [], d.amountField || 'amount'), 0);
const showTaxStack = taxRate !== null && details.some((d) => !!d.amountField);
const taxPct = taxRate ?? 0;
const taxAmount = subtotal * (taxPct / 100);
const grandTotal = subtotal + taxAmount;
const money = (n: number) => `¥${n.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;

return (
<div className={cn('space-y-6', className, schema.className)}>
{/* 1) Header fields on top */}
Expand Down Expand Up @@ -457,9 +501,9 @@ export const MasterDetailForm: React.FC<MasterDetailFormProps> = ({
field={
{
columns: d.columns,
// Show the running total whenever an amount column is set,
// independent of whether it also rolls up onto the parent.
total_field: d.amountField || (d.totalField ? 'amount' : undefined),
// Show the per-grid running total whenever an amount column is
// set — unless the document totals stack below subsumes it.
total_field: showTaxStack ? undefined : (d.amountField || (d.totalField ? 'amount' : undefined)),
min_rows: d.minRows,
max_rows: d.maxRows,
add_label: d.inlineMode === 'form' ? (d.addLabel || 'Add') : d.addLabel,
Expand All @@ -471,6 +515,27 @@ export const MasterDetailForm: React.FC<MasterDetailFormProps> = ({
</Card>
))}

{/* Document totals stack (Subtotal / Tax / Total) — the right-aligned block
every invoicing tool shows. Live as lines and the header tax rate change. */}
{showTaxStack && (
<div className="flex justify-end">
<dl className="w-64 space-y-1.5 text-sm" data-testid="md-totals">
<div className="flex items-center justify-between">
<dt className="text-muted-foreground">Subtotal</dt>
<dd className="tabular-nums" data-testid="md-subtotal">{money(subtotal)}</dd>
</div>
<div className="flex items-center justify-between">
<dt className="text-muted-foreground">Tax ({taxPct}%)</dt>
<dd className="tabular-nums" data-testid="md-tax">{money(taxAmount)}</dd>
</div>
<div className="flex items-center justify-between border-t border-border pt-1.5 text-base font-semibold">
<dt>Total</dt>
<dd className="tabular-nums" data-testid="md-grand-total">{money(grandTotal)}</dd>
</div>
</dl>
</div>
)}

{/* Per-row "expand to full form": an inline editor panel for the selected
row. Rendered INLINE (not a portaled drawer) so it behaves identically
whether this form is itself inside a modal (New-from-list) or a full
Expand Down
Loading