@asteby/metacore-runtime-react 13.3.0 → 13.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +26 -0
- package/dist/dynamic-form-schema.d.ts +36 -0
- package/dist/dynamic-form-schema.d.ts.map +1 -1
- package/dist/dynamic-form-schema.js +77 -0
- package/dist/dynamic-form.d.ts.map +1 -1
- package/dist/dynamic-form.js +25 -2
- package/dist/dynamic-line-items.d.ts.map +1 -1
- package/dist/dynamic-line-items.js +51 -3
- package/dist/types.d.ts +33 -0
- package/dist/types.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/__tests__/line-item-totals.test.ts +116 -0
- package/src/dynamic-form-schema.ts +91 -0
- package/src/dynamic-form.tsx +53 -18
- package/src/dynamic-line-items.tsx +121 -5
- package/src/types.ts +34 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,31 @@
|
|
|
1
1
|
# @asteby/metacore-runtime-react
|
|
2
2
|
|
|
3
|
+
## 13.4.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- 23d737f: feat(runtime-react): rich declarative line-items — column totals, balance rule, pro layout
|
|
8
|
+
|
|
9
|
+
Makes the declarative form/modal renderer rich enough to replace a custom
|
|
10
|
+
federated modal for line-items entry (e.g. a journal entry's debit/credit grid),
|
|
11
|
+
driven entirely from the manifest:
|
|
12
|
+
- **Totals footer** — any `item_fields` column flagged `total: true` is summed
|
|
13
|
+
across rows and shown in a footer row (`computeLineItemTotals`). Numeric
|
|
14
|
+
columns render right-aligned with `tabular-nums`.
|
|
15
|
+
- **Balance rule** — a `type: "array"` field can declare
|
|
16
|
+
`balance: { debit_column, credit_column, message?, require_nonzero? }`. The
|
|
17
|
+
grid shows a live "Cuadrado" / "Descuadre: N" badge and the form blocks submit
|
|
18
|
+
until `Σ(debit_column) === Σ(credit_column)` (and, by default, > 0). Fully
|
|
19
|
+
generic — debit/credit are just the two column keys to reconcile. Typing a
|
|
20
|
+
value into one reconciled column clears its sibling on the same row.
|
|
21
|
+
- **Pro layout** — `DynamicForm` flows scalar header fields through a responsive
|
|
22
|
+
2-column grid while line-items grids and textareas span full width, matching
|
|
23
|
+
the look of the hand-written federated journal modal without any custom React.
|
|
24
|
+
|
|
25
|
+
New pure helpers (`computeLineItemTotals`, `evaluateBalance`, `getBalanceRule`,
|
|
26
|
+
`toNumber`) are exported and unit-tested so hosts can reuse the math. Mirrors the
|
|
27
|
+
kernel v3 `ActionField.total` / `ActionField.balance` contract additions.
|
|
28
|
+
|
|
3
29
|
## 13.3.0
|
|
4
30
|
|
|
5
31
|
### Minor Changes
|
|
@@ -17,5 +17,41 @@ export declare function buildZodSchema(fields: ActionFieldDef[]): z.ZodObject<{
|
|
|
17
17
|
export declare function getItemFields(field: ActionFieldDef): ActionFieldDef[];
|
|
18
18
|
/** A field is a repeatable line-items group when it declares item columns. */
|
|
19
19
|
export declare function isLineItemsField(field: ActionFieldDef): boolean;
|
|
20
|
+
/**
|
|
21
|
+
* Resolves the balance rule of a line-items field, tolerating both the
|
|
22
|
+
* camelCase authored shape and the snake_case the kernel serves. Returns
|
|
23
|
+
* normalized `{ debitColumn, creditColumn, message, requireNonzero }` or
|
|
24
|
+
* `undefined` when the field declares no balance constraint.
|
|
25
|
+
*/
|
|
26
|
+
export declare function getBalanceRule(field: ActionFieldDef): {
|
|
27
|
+
debitColumn: string;
|
|
28
|
+
creditColumn: string;
|
|
29
|
+
message?: string;
|
|
30
|
+
requireNonzero: boolean;
|
|
31
|
+
} | undefined;
|
|
32
|
+
/** Coerces a cell value to a finite number, treating blanks/garbage as 0. */
|
|
33
|
+
export declare function toNumber(v: unknown): number;
|
|
34
|
+
/**
|
|
35
|
+
* Sums each `total`-flagged column of a line-items field across its rows.
|
|
36
|
+
* Pure — no React — so the renderer and unit tests share one implementation.
|
|
37
|
+
* Returns a map of column key → summed value. Rounds to cents to avoid float
|
|
38
|
+
* drift (0.1 + 0.2 noise) that would make a genuinely balanced entry look off.
|
|
39
|
+
*/
|
|
40
|
+
export declare function computeLineItemTotals(field: ActionFieldDef, rows: any[] | undefined): Record<string, number>;
|
|
41
|
+
export interface BalanceState {
|
|
42
|
+
debit: number;
|
|
43
|
+
credit: number;
|
|
44
|
+
/** credit − debit, rounded to cents. Zero when balanced. */
|
|
45
|
+
diff: number;
|
|
46
|
+
balanced: boolean;
|
|
47
|
+
message?: string;
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Evaluates a line-items field's balance rule against its rows. Returns
|
|
51
|
+
* `undefined` when the field declares no balance rule. `balanced` is true when
|
|
52
|
+
* the two summed columns are equal (and, unless `requireNonzero` is false,
|
|
53
|
+
* strictly positive). Pure — drives both the indicator and the submit gate.
|
|
54
|
+
*/
|
|
55
|
+
export declare function evaluateBalance(field: ActionFieldDef, rows: any[] | undefined): BalanceState | undefined;
|
|
20
56
|
export declare function resolveWidget(field: ActionFieldDef): string;
|
|
21
57
|
//# sourceMappingURL=dynamic-form-schema.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"dynamic-form-schema.d.ts","sourceRoot":"","sources":["../src/dynamic-form-schema.ts"],"names":[],"mappings":"AAGA,OAAO,EAAE,CAAC,EAAmB,MAAM,KAAK,CAAA;AACxC,OAAO,KAAK,EAAE,cAAc,EAAmB,MAAM,SAAS,CAAA;AAiB9D;;;GAGG;AACH,wBAAgB,iBAAiB,CAAC,IAAI,EAAE,MAAM,EAAE,EAAE,EAAE,CAAC,CAAC,EAAE,CAAC,CAAC,SAAS,KAAK,CAAC,CAAC,SAAS,GAAG,IAAI,CAEzF;AAcD,wBAAgB,cAAc,CAAC,MAAM,EAAE,cAAc,EAAE;;kBAMtD;AAED;;;;;GAKG;AACH,wBAAgB,aAAa,CAAC,KAAK,EAAE,cAAc,GAAG,cAAc,EAAE,CAGrE;AAED,8EAA8E;AAC9E,wBAAgB,gBAAgB,CAAC,KAAK,EAAE,cAAc,GAAG,OAAO,CAE/D;AAqDD,wBAAgB,aAAa,CAAC,KAAK,EAAE,cAAc,GAAG,MAAM,CAa3D"}
|
|
1
|
+
{"version":3,"file":"dynamic-form-schema.d.ts","sourceRoot":"","sources":["../src/dynamic-form-schema.ts"],"names":[],"mappings":"AAGA,OAAO,EAAE,CAAC,EAAmB,MAAM,KAAK,CAAA;AACxC,OAAO,KAAK,EAAE,cAAc,EAAmB,MAAM,SAAS,CAAA;AAiB9D;;;GAGG;AACH,wBAAgB,iBAAiB,CAAC,IAAI,EAAE,MAAM,EAAE,EAAE,EAAE,CAAC,CAAC,EAAE,CAAC,CAAC,SAAS,KAAK,CAAC,CAAC,SAAS,GAAG,IAAI,CAEzF;AAcD,wBAAgB,cAAc,CAAC,MAAM,EAAE,cAAc,EAAE;;kBAMtD;AAED;;;;;GAKG;AACH,wBAAgB,aAAa,CAAC,KAAK,EAAE,cAAc,GAAG,cAAc,EAAE,CAGrE;AAED,8EAA8E;AAC9E,wBAAgB,gBAAgB,CAAC,KAAK,EAAE,cAAc,GAAG,OAAO,CAE/D;AAED;;;;;GAKG;AACH,wBAAgB,cAAc,CAC1B,KAAK,EAAE,cAAc,GACtB;IAAE,WAAW,EAAE,MAAM,CAAC;IAAC,YAAY,EAAE,MAAM,CAAC;IAAC,OAAO,CAAC,EAAE,MAAM,CAAC;IAAC,cAAc,EAAE,OAAO,CAAA;CAAE,GAAG,SAAS,CAatG;AAED,6EAA6E;AAC7E,wBAAgB,QAAQ,CAAC,CAAC,EAAE,OAAO,GAAG,MAAM,CAO3C;AAED;;;;;GAKG;AACH,wBAAgB,qBAAqB,CACjC,KAAK,EAAE,cAAc,EACrB,IAAI,EAAE,GAAG,EAAE,GAAG,SAAS,GACxB,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAWxB;AAED,MAAM,WAAW,YAAY;IACzB,KAAK,EAAE,MAAM,CAAA;IACb,MAAM,EAAE,MAAM,CAAA;IACd,4DAA4D;IAC5D,IAAI,EAAE,MAAM,CAAA;IACZ,QAAQ,EAAE,OAAO,CAAA;IACjB,OAAO,CAAC,EAAE,MAAM,CAAA;CACnB;AAED;;;;;GAKG;AACH,wBAAgB,eAAe,CAC3B,KAAK,EAAE,cAAc,EACrB,IAAI,EAAE,GAAG,EAAE,GAAG,SAAS,GACxB,YAAY,GAAG,SAAS,CAgB1B;AAqDD,wBAAgB,aAAa,CAAC,KAAK,EAAE,cAAc,GAAG,MAAM,CAa3D"}
|
|
@@ -57,6 +57,83 @@ export function getItemFields(field) {
|
|
|
57
57
|
export function isLineItemsField(field) {
|
|
58
58
|
return getItemFields(field).length > 0;
|
|
59
59
|
}
|
|
60
|
+
/**
|
|
61
|
+
* Resolves the balance rule of a line-items field, tolerating both the
|
|
62
|
+
* camelCase authored shape and the snake_case the kernel serves. Returns
|
|
63
|
+
* normalized `{ debitColumn, creditColumn, message, requireNonzero }` or
|
|
64
|
+
* `undefined` when the field declares no balance constraint.
|
|
65
|
+
*/
|
|
66
|
+
export function getBalanceRule(field) {
|
|
67
|
+
const b = field.balance;
|
|
68
|
+
if (!b)
|
|
69
|
+
return undefined;
|
|
70
|
+
const debitColumn = b.debitColumn ?? b.debit_column ?? '';
|
|
71
|
+
const creditColumn = b.creditColumn ?? b.credit_column ?? '';
|
|
72
|
+
if (!debitColumn || !creditColumn)
|
|
73
|
+
return undefined;
|
|
74
|
+
const reqRaw = b.requireNonzero ?? b.require_nonzero;
|
|
75
|
+
return {
|
|
76
|
+
debitColumn,
|
|
77
|
+
creditColumn,
|
|
78
|
+
message: b.message,
|
|
79
|
+
requireNonzero: reqRaw === undefined ? true : !!reqRaw,
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
/** Coerces a cell value to a finite number, treating blanks/garbage as 0. */
|
|
83
|
+
export function toNumber(v) {
|
|
84
|
+
if (typeof v === 'number')
|
|
85
|
+
return Number.isFinite(v) ? v : 0;
|
|
86
|
+
if (typeof v === 'string') {
|
|
87
|
+
const n = parseFloat(v);
|
|
88
|
+
return Number.isFinite(n) ? n : 0;
|
|
89
|
+
}
|
|
90
|
+
return 0;
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* Sums each `total`-flagged column of a line-items field across its rows.
|
|
94
|
+
* Pure — no React — so the renderer and unit tests share one implementation.
|
|
95
|
+
* Returns a map of column key → summed value. Rounds to cents to avoid float
|
|
96
|
+
* drift (0.1 + 0.2 noise) that would make a genuinely balanced entry look off.
|
|
97
|
+
*/
|
|
98
|
+
export function computeLineItemTotals(field, rows) {
|
|
99
|
+
const cols = getItemFields(field).filter((c) => c.total);
|
|
100
|
+
const totals = {};
|
|
101
|
+
for (const c of cols)
|
|
102
|
+
totals[c.key] = 0;
|
|
103
|
+
if (Array.isArray(rows)) {
|
|
104
|
+
for (const row of rows) {
|
|
105
|
+
for (const c of cols)
|
|
106
|
+
totals[c.key] += toNumber(row?.[c.key]);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
for (const k of Object.keys(totals))
|
|
110
|
+
totals[k] = Math.round(totals[k] * 100) / 100;
|
|
111
|
+
return totals;
|
|
112
|
+
}
|
|
113
|
+
/**
|
|
114
|
+
* Evaluates a line-items field's balance rule against its rows. Returns
|
|
115
|
+
* `undefined` when the field declares no balance rule. `balanced` is true when
|
|
116
|
+
* the two summed columns are equal (and, unless `requireNonzero` is false,
|
|
117
|
+
* strictly positive). Pure — drives both the indicator and the submit gate.
|
|
118
|
+
*/
|
|
119
|
+
export function evaluateBalance(field, rows) {
|
|
120
|
+
const rule = getBalanceRule(field);
|
|
121
|
+
if (!rule)
|
|
122
|
+
return undefined;
|
|
123
|
+
let debit = 0;
|
|
124
|
+
let credit = 0;
|
|
125
|
+
if (Array.isArray(rows)) {
|
|
126
|
+
for (const row of rows) {
|
|
127
|
+
debit += toNumber(row?.[rule.debitColumn]);
|
|
128
|
+
credit += toNumber(row?.[rule.creditColumn]);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
debit = Math.round(debit * 100) / 100;
|
|
132
|
+
credit = Math.round(credit * 100) / 100;
|
|
133
|
+
const diff = Math.round((credit - debit) * 100) / 100;
|
|
134
|
+
const balanced = diff === 0 && (!rule.requireNonzero || debit > 0);
|
|
135
|
+
return { debit, credit, diff, balanced, message: rule.message };
|
|
136
|
+
}
|
|
60
137
|
function fieldToZod(field) {
|
|
61
138
|
// Repeatable line-items group → array of row objects, each row built from
|
|
62
139
|
// the item field columns. Required keeps at least one row.
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"dynamic-form.d.ts","sourceRoot":"","sources":["../src/dynamic-form.tsx"],"names":[],"mappings":"AAgBA,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,SAAS,CAAA;AAC7C,OAAO,
|
|
1
|
+
{"version":3,"file":"dynamic-form.d.ts","sourceRoot":"","sources":["../src/dynamic-form.tsx"],"names":[],"mappings":"AAgBA,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,SAAS,CAAA;AAC7C,OAAO,EACH,cAAc,EACd,aAAa,EAGhB,MAAM,uBAAuB,CAAA;AAK9B,OAAO,EAAE,cAAc,EAAE,aAAa,EAAE,CAAA;AACxC,OAAO,EAAE,gBAAgB,EAAE,MAAM,sBAAsB,CAAA;AACvD,OAAO,EAAE,kBAAkB,EAAE,MAAM,wBAAwB,CAAA;AAE3D,MAAM,WAAW,gBAAgB;IAC7B,MAAM,EAAE,cAAc,EAAE,CAAA;IACxB,aAAa,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,CAAA;IACnC,QAAQ,EAAE,CAAC,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,KAAK,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC,CAAA;IAC/D,QAAQ,CAAC,EAAE,MAAM,IAAI,CAAA;IACrB,WAAW,CAAC,EAAE,MAAM,CAAA;IACpB,WAAW,CAAC,EAAE,MAAM,CAAA;IACpB,QAAQ,CAAC,EAAE,OAAO,CAAA;CACrB;AAED,wBAAgB,WAAW,CAAC,EACxB,MAAM,EACN,aAAa,EACb,QAAQ,EACR,QAAQ,EACR,WAAuB,EACvB,WAAwB,EACxB,QAAgB,GACnB,EAAE,gBAAgB,2CAkGlB"}
|
package/dist/dynamic-form.js
CHANGED
|
@@ -4,7 +4,7 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
|
4
4
|
// outside the full record-edit modal.
|
|
5
5
|
import { useEffect, useMemo, useState } from 'react';
|
|
6
6
|
import { Input, Textarea, Label, Switch, Button, Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from '@asteby/metacore-ui/primitives';
|
|
7
|
-
import { buildZodSchema, resolveWidget, isLineItemsField } from './dynamic-form-schema';
|
|
7
|
+
import { buildZodSchema, resolveWidget, isLineItemsField, evaluateBalance, } from './dynamic-form-schema';
|
|
8
8
|
import { useOptionsResolver } from './use-options-resolver';
|
|
9
9
|
import { DynamicLineItems } from './dynamic-line-items';
|
|
10
10
|
import { DynamicSelectField } from './dynamic-select-field';
|
|
@@ -16,6 +16,18 @@ export function DynamicForm({ fields, initialValues, onSubmit, onCancel, submitL
|
|
|
16
16
|
const [errors, setErrors] = useState({});
|
|
17
17
|
const [submitting, setSubmitting] = useState(false);
|
|
18
18
|
const schema = useMemo(() => buildZodSchema(fields), [fields]);
|
|
19
|
+
// Line-items fields carrying a balance rule gate submit: an unbalanced entry
|
|
20
|
+
// (Σdebit ≠ Σcredit, or all-zero when require_nonzero) can't be saved. This
|
|
21
|
+
// is fully declarative — `evaluateBalance` returns undefined for fields with
|
|
22
|
+
// no rule, so non-balanced forms are unaffected.
|
|
23
|
+
const balanceBlocked = useMemo(() => {
|
|
24
|
+
for (const f of fields) {
|
|
25
|
+
const state = evaluateBalance(f, values[f.key]);
|
|
26
|
+
if (state && !state.balanced)
|
|
27
|
+
return true;
|
|
28
|
+
}
|
|
29
|
+
return false;
|
|
30
|
+
}, [fields, values]);
|
|
19
31
|
useEffect(() => {
|
|
20
32
|
const defaults = {};
|
|
21
33
|
for (const f of fields) {
|
|
@@ -31,6 +43,8 @@ export function DynamicForm({ fields, initialValues, onSubmit, onCancel, submitL
|
|
|
31
43
|
const update = (k, v) => setValues((prev) => ({ ...prev, [k]: v }));
|
|
32
44
|
const handleSubmit = async (e) => {
|
|
33
45
|
e.preventDefault();
|
|
46
|
+
if (balanceBlocked)
|
|
47
|
+
return;
|
|
34
48
|
const result = schema.safeParse(values);
|
|
35
49
|
if (!result.success) {
|
|
36
50
|
const next = {};
|
|
@@ -51,7 +65,16 @@ export function DynamicForm({ fields, initialValues, onSubmit, onCancel, submitL
|
|
|
51
65
|
setSubmitting(false);
|
|
52
66
|
}
|
|
53
67
|
};
|
|
54
|
-
|
|
68
|
+
// Layout: scalar header fields flow through a responsive 2-column grid;
|
|
69
|
+
// line-items grids (and textareas) span the full width so the row table /
|
|
70
|
+
// memo gets room. Mirrors the pro look of the federated journal modal but
|
|
71
|
+
// stays fully declarative — driven only by field shape.
|
|
72
|
+
return (_jsxs("form", { onSubmit: handleSubmit, className: "grid gap-4", children: [_jsx("div", { className: "grid gap-4 sm:grid-cols-2", children: fields.map((field) => {
|
|
73
|
+
const fullWidth = isLineItemsField(field) ||
|
|
74
|
+
resolveWidget(field) === 'textarea' ||
|
|
75
|
+
resolveWidget(field) === 'richtext';
|
|
76
|
+
return (_jsxs("div", { className: 'grid gap-2 ' + (fullWidth ? 'sm:col-span-2' : ''), children: [_jsxs(Label, { htmlFor: field.key, children: [field.label, field.required && _jsx("span", { className: "text-red-500 ml-1", children: "*" })] }), _jsx(FieldRenderer, { field: field, value: values[field.key], onChange: (v) => update(field.key, v) }), errors[field.key] && (_jsx("span", { className: "text-red-500 text-sm", role: "alert", children: errors[field.key] }))] }, field.key));
|
|
77
|
+
}) }), _jsxs("div", { className: "flex justify-end gap-2 pt-2", children: [onCancel && (_jsx(Button, { type: "button", variant: "outline", onClick: onCancel, disabled: submitting || disabled, children: cancelLabel })), _jsx(Button, { type: "submit", disabled: submitting || disabled || balanceBlocked, children: submitLabel })] })] }));
|
|
55
78
|
}
|
|
56
79
|
function FieldRenderer({ field, value, onChange }) {
|
|
57
80
|
// Repeatable line-items group → render the row grid. Its value is an array
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"dynamic-line-items.d.ts","sourceRoot":"","sources":["../src/dynamic-line-items.tsx"],"names":[],"mappings":"AAqBA,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,SAAS,CAAA;
|
|
1
|
+
{"version":3,"file":"dynamic-line-items.d.ts","sourceRoot":"","sources":["../src/dynamic-line-items.tsx"],"names":[],"mappings":"AAqBA,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,SAAS,CAAA;AAW7C,MAAM,WAAW,qBAAqB;IAClC,KAAK,EAAE,cAAc,CAAA;IACrB,KAAK,EAAE,GAAG,EAAE,GAAG,SAAS,CAAA;IACxB,QAAQ,EAAE,CAAC,IAAI,EAAE,GAAG,EAAE,KAAK,IAAI,CAAA;IAC/B,QAAQ,CAAC,EAAE,OAAO,CAAA;CACrB;AAkBD,wBAAgB,gBAAgB,CAAC,EAAE,KAAK,EAAE,KAAK,EAAE,QAAQ,EAAE,QAAgB,EAAE,EAAE,qBAAqB,2CAiJnG"}
|
|
@@ -9,10 +9,15 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
|
9
9
|
// row controls mutate the array; each cell is a widget resolved via
|
|
10
10
|
// `resolveWidget`, matching the flat-field renderer in dynamic-form.tsx.
|
|
11
11
|
import { Input, Textarea, Switch, Button, Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from '@asteby/metacore-ui/primitives';
|
|
12
|
-
import { Plus, Trash2 } from 'lucide-react';
|
|
13
|
-
import { resolveWidget, getItemFields } from './dynamic-form-schema';
|
|
12
|
+
import { Plus, Trash2, Check } from 'lucide-react';
|
|
13
|
+
import { resolveWidget, getItemFields, computeLineItemTotals, evaluateBalance, toNumber, } from './dynamic-form-schema';
|
|
14
14
|
import { DynamicSelectField } from './dynamic-select-field';
|
|
15
15
|
import { useOptionsResolver } from './use-options-resolver';
|
|
16
|
+
const fmtNumber = (n) => n.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
|
17
|
+
/** Numeric columns render right-aligned (debit/credit/amount feel). */
|
|
18
|
+
function isNumericCol(col) {
|
|
19
|
+
return resolveWidget(col) === 'number';
|
|
20
|
+
}
|
|
16
21
|
function emptyRow(itemFields) {
|
|
17
22
|
const row = {};
|
|
18
23
|
for (const f of itemFields) {
|
|
@@ -23,10 +28,53 @@ function emptyRow(itemFields) {
|
|
|
23
28
|
export function DynamicLineItems({ field, value, onChange, disabled = false }) {
|
|
24
29
|
const itemFields = getItemFields(field);
|
|
25
30
|
const rows = Array.isArray(value) ? value : [];
|
|
31
|
+
// Columns flagged `total` get a per-column sum in the footer; the balance
|
|
32
|
+
// rule (if any) reconciles two of them. Both are declarative & generic.
|
|
33
|
+
const totals = computeLineItemTotals(field, rows);
|
|
34
|
+
const totalKeys = itemFields.filter((c) => c.total).map((c) => c.key);
|
|
35
|
+
const hasTotals = totalKeys.length > 0;
|
|
36
|
+
const balance = evaluateBalance(field, rows);
|
|
26
37
|
const addRow = () => onChange([...rows, emptyRow(itemFields)]);
|
|
27
38
|
const removeRow = (idx) => onChange(rows.filter((_, i) => i !== idx));
|
|
28
39
|
const updateCell = (idx, key, cellValue) => onChange(rows.map((r, i) => (i === idx ? { ...r, [key]: cellValue } : r)));
|
|
29
|
-
|
|
40
|
+
// When a balance rule reconciles two columns (e.g. debit ↔ credit), typing
|
|
41
|
+
// into one clears the sibling on the same row — mirrors the federated modal
|
|
42
|
+
// UX so a line is never both a debit and a credit.
|
|
43
|
+
const balancePair = balance
|
|
44
|
+
? (() => {
|
|
45
|
+
const f = getItemFields(field);
|
|
46
|
+
void f;
|
|
47
|
+
const d = field.balance?.debitColumn ?? field.balance?.debit_column;
|
|
48
|
+
const c = field.balance?.creditColumn ?? field.balance?.credit_column;
|
|
49
|
+
return d && c ? [d, c] : null;
|
|
50
|
+
})()
|
|
51
|
+
: null;
|
|
52
|
+
const handleCell = (idx, key, cellValue) => {
|
|
53
|
+
if (balancePair && (key === balancePair[0] || key === balancePair[1])) {
|
|
54
|
+
const sibling = key === balancePair[0] ? balancePair[1] : balancePair[0];
|
|
55
|
+
const hasValue = toNumber(cellValue) > 0;
|
|
56
|
+
onChange(rows.map((r, i) => i === idx ? { ...r, [key]: cellValue, ...(hasValue ? { [sibling]: '' } : {}) } : r));
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
updateCell(idx, key, cellValue);
|
|
60
|
+
};
|
|
61
|
+
return (_jsxs("div", { className: "grid gap-2", "data-widget": "line_items", children: [_jsx("div", { className: "overflow-x-auto rounded-md border", children: _jsxs("table", { className: "w-full text-sm", children: [_jsx("thead", { className: "bg-muted/50", children: _jsxs("tr", { children: [itemFields.map((col) => (_jsxs("th", { className: 'px-3 py-2 font-medium ' +
|
|
62
|
+
(isNumericCol(col) ? 'text-right' : 'text-left'), children: [col.label, col.required && _jsx("span", { className: "text-red-500 ml-1", children: "*" })] }, col.key))), _jsx("th", { className: "w-12 px-3 py-2", "aria-label": "acciones" })] }) }), _jsxs("tbody", { children: [rows.length === 0 && (_jsx("tr", { children: _jsx("td", { colSpan: itemFields.length + 1, className: "px-3 py-4 text-center text-muted-foreground", children: "Sin renglones" }) })), rows.map((row, idx) => (_jsxs("tr", { className: "border-t align-top", children: [itemFields.map((col) => (_jsx("td", { className: "px-2 py-1.5", children: _jsx(CellRenderer, { field: col, value: row?.[col.key], onChange: (v) => handleCell(idx, col.key, v), disabled: disabled }) }, col.key))), _jsx("td", { className: "px-2 py-1.5 text-center", children: _jsx(Button, { type: "button", variant: "ghost", size: "icon", onClick: () => removeRow(idx), disabled: disabled, "aria-label": "Eliminar rengl\u00F3n", children: _jsx(Trash2, { className: "h-4 w-4 text-red-500" }) }) })] }, idx)))] }), hasTotals && rows.length > 0 && (_jsx("tfoot", { className: "border-t bg-muted/30", children: _jsxs("tr", { children: [itemFields.map((col, ci) => {
|
|
63
|
+
if (ci === 0) {
|
|
64
|
+
return (_jsx("td", { className: "px-3 py-2 text-left font-medium text-muted-foreground", children: "Totales" }, col.key));
|
|
65
|
+
}
|
|
66
|
+
return (_jsx("td", { className: 'px-3 py-2 ' +
|
|
67
|
+
(col.total
|
|
68
|
+
? 'text-right font-semibold tabular-nums'
|
|
69
|
+
: ''), children: col.total ? fmtNumber(totals[col.key] ?? 0) : null }, col.key));
|
|
70
|
+
}), _jsx("td", {})] }) }))] }) }), _jsxs("div", { className: "flex items-center justify-between gap-2", children: [_jsxs(Button, { type: "button", variant: "outline", size: "sm", onClick: addRow, disabled: disabled, children: [_jsx(Plus, { className: "mr-1 h-4 w-4" }), "Agregar rengl\u00F3n"] }), balance && _jsx(BalanceBadge, { state: balance })] })] }));
|
|
71
|
+
}
|
|
72
|
+
function BalanceBadge({ state, }) {
|
|
73
|
+
if (state.balanced) {
|
|
74
|
+
return (_jsxs("span", { className: "inline-flex items-center gap-1.5 rounded-md bg-primary/10 px-2.5 py-1 text-sm font-medium text-primary", "data-balance": "balanced", role: "status", children: [_jsx(Check, { className: "h-4 w-4" }), "Cuadrado"] }));
|
|
75
|
+
}
|
|
76
|
+
const diff = Math.abs(state.diff);
|
|
77
|
+
return (_jsx("span", { className: "inline-flex items-center gap-1.5 rounded-md bg-destructive/10 px-2.5 py-1 text-sm font-medium text-destructive", "data-balance": "unbalanced", role: "status", children: state.message ?? `Descuadre: ${fmtNumber(diff)}` }));
|
|
30
78
|
}
|
|
31
79
|
// Per-cell widget. Mirrors the flat FieldRenderer in dynamic-form.tsx but
|
|
32
80
|
// without the per-field Label (the column header is the label) and sized for a
|
package/dist/types.d.ts
CHANGED
|
@@ -131,6 +131,39 @@ export interface ActionFieldDef {
|
|
|
131
131
|
* keyed by these item field keys. Rendered by `DynamicLineItems`.
|
|
132
132
|
*/
|
|
133
133
|
itemFields?: ActionFieldDef[];
|
|
134
|
+
/**
|
|
135
|
+
* On an `itemFields` column: flags the column for summation in the
|
|
136
|
+
* line-items footer. The SDK renders a totals row summing every numeric
|
|
137
|
+
* column marked `total` (e.g. the debit and credit columns of a journal
|
|
138
|
+
* entry). Ignored on flat fields. Mirrors kernel v3 `ActionField.total`.
|
|
139
|
+
*/
|
|
140
|
+
total?: boolean;
|
|
141
|
+
/**
|
|
142
|
+
* On a line-items (`type: "array"`) field: declares an optional, generic
|
|
143
|
+
* balance constraint between two summed columns. The SDK shows a balanced /
|
|
144
|
+
* out-of-balance indicator and blocks submit until the two sides match.
|
|
145
|
+
* Domain-agnostic — "debit"/"credit" are just the two column keys to
|
|
146
|
+
* reconcile. Mirrors kernel v3 `ActionField.balance`.
|
|
147
|
+
*/
|
|
148
|
+
balance?: FieldBalanceRule;
|
|
149
|
+
}
|
|
150
|
+
/**
|
|
151
|
+
* Declarative reconciliation constraint on a line-items field: the summed value
|
|
152
|
+
* of `debitColumn` across all rows must equal the summed value of
|
|
153
|
+
* `creditColumn`. Tolerates the snake_case shape the kernel serves
|
|
154
|
+
* (`debit_column` / `credit_column` / `require_nonzero`). Generic by design.
|
|
155
|
+
*/
|
|
156
|
+
export interface FieldBalanceRule {
|
|
157
|
+
debitColumn?: string;
|
|
158
|
+
creditColumn?: string;
|
|
159
|
+
/** snake_case alias served by the kernel manifest. */
|
|
160
|
+
debit_column?: string;
|
|
161
|
+
/** snake_case alias served by the kernel manifest. */
|
|
162
|
+
credit_column?: string;
|
|
163
|
+
message?: string;
|
|
164
|
+
/** When true (default) an all-zero entry is treated as out of balance. */
|
|
165
|
+
requireNonzero?: boolean;
|
|
166
|
+
require_nonzero?: boolean;
|
|
134
167
|
}
|
|
135
168
|
export interface ActionDefinition {
|
|
136
169
|
key: string;
|
package/dist/types.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"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
|
|
1
|
+
{"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"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"}
|
package/package.json
CHANGED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import {
|
|
3
|
+
computeLineItemTotals,
|
|
4
|
+
evaluateBalance,
|
|
5
|
+
getBalanceRule,
|
|
6
|
+
} from '../dynamic-form-schema'
|
|
7
|
+
import type { ActionFieldDef } from '../types'
|
|
8
|
+
|
|
9
|
+
// A journal-entry-style line-items field: debit/credit columns flagged for
|
|
10
|
+
// summation, reconciled by a balance rule. The functions are domain-agnostic;
|
|
11
|
+
// this just exercises the canonical use case.
|
|
12
|
+
const journalField = (overrides: Partial<ActionFieldDef> = {}): ActionFieldDef => ({
|
|
13
|
+
key: 'journal_entry_lines',
|
|
14
|
+
label: 'Renglones',
|
|
15
|
+
type: 'array',
|
|
16
|
+
required: true,
|
|
17
|
+
itemFields: [
|
|
18
|
+
{ key: 'account_id', label: 'Cuenta', type: 'dynamic_select', ref: 'Account', required: true },
|
|
19
|
+
{ key: 'description', label: 'Descripción', type: 'string' },
|
|
20
|
+
{ key: 'debit', label: 'Débito', type: 'number', total: true },
|
|
21
|
+
{ key: 'credit', label: 'Crédito', type: 'number', total: true },
|
|
22
|
+
],
|
|
23
|
+
balance: { debit_column: 'debit', credit_column: 'credit' },
|
|
24
|
+
...overrides,
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
describe('computeLineItemTotals', () => {
|
|
28
|
+
it('suma solo las columnas marcadas con total', () => {
|
|
29
|
+
const rows = [
|
|
30
|
+
{ account_id: 'a', debit: '100', credit: '' },
|
|
31
|
+
{ account_id: 'b', debit: '50.50', credit: '' },
|
|
32
|
+
{ account_id: 'c', debit: '', credit: '150.50' },
|
|
33
|
+
]
|
|
34
|
+
const totals = computeLineItemTotals(journalField(), rows)
|
|
35
|
+
expect(totals).toEqual({ debit: 150.5, credit: 150.5 })
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
it('trata blancos y basura como 0 y redondea a centavos', () => {
|
|
39
|
+
const rows = [
|
|
40
|
+
{ debit: '0.1', credit: '' },
|
|
41
|
+
{ debit: '0.2', credit: 'abc' },
|
|
42
|
+
]
|
|
43
|
+
const totals = computeLineItemTotals(journalField(), rows)
|
|
44
|
+
expect(totals.debit).toBe(0.3) // sin float drift (0.30000000000000004)
|
|
45
|
+
expect(totals.credit).toBe(0)
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
it('devuelve {} cuando ninguna columna está marcada', () => {
|
|
49
|
+
const field = journalField({
|
|
50
|
+
itemFields: [{ key: 'x', label: 'X', type: 'number' }],
|
|
51
|
+
})
|
|
52
|
+
expect(computeLineItemTotals(field, [{ x: '5' }])).toEqual({})
|
|
53
|
+
})
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
describe('getBalanceRule', () => {
|
|
57
|
+
it('normaliza snake_case del kernel a camelCase', () => {
|
|
58
|
+
const rule = getBalanceRule(journalField())
|
|
59
|
+
expect(rule).toEqual({
|
|
60
|
+
debitColumn: 'debit',
|
|
61
|
+
creditColumn: 'credit',
|
|
62
|
+
message: undefined,
|
|
63
|
+
requireNonzero: true,
|
|
64
|
+
})
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
it('respeta camelCase explícito y require_nonzero=false', () => {
|
|
68
|
+
const rule = getBalanceRule(
|
|
69
|
+
journalField({ balance: { debitColumn: 'd', creditColumn: 'c', require_nonzero: false } }),
|
|
70
|
+
)
|
|
71
|
+
expect(rule?.debitColumn).toBe('d')
|
|
72
|
+
expect(rule?.requireNonzero).toBe(false)
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
it('devuelve undefined sin regla', () => {
|
|
76
|
+
expect(getBalanceRule(journalField({ balance: undefined }))).toBeUndefined()
|
|
77
|
+
})
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
describe('evaluateBalance', () => {
|
|
81
|
+
it('marca balanced cuando Σdébito == Σcrédito y > 0', () => {
|
|
82
|
+
const rows = [
|
|
83
|
+
{ debit: '100', credit: '' },
|
|
84
|
+
{ debit: '', credit: '100' },
|
|
85
|
+
]
|
|
86
|
+
const state = evaluateBalance(journalField(), rows)
|
|
87
|
+
expect(state).toMatchObject({ debit: 100, credit: 100, diff: 0, balanced: true })
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
it('marca unbalanced con descuadre y reporta el diff', () => {
|
|
91
|
+
const rows = [
|
|
92
|
+
{ debit: '100', credit: '' },
|
|
93
|
+
{ debit: '', credit: '70' },
|
|
94
|
+
]
|
|
95
|
+
const state = evaluateBalance(journalField(), rows)
|
|
96
|
+
expect(state?.balanced).toBe(false)
|
|
97
|
+
expect(state?.diff).toBe(-30) // credit - debit
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
it('un asiento todo en cero NO está cuadrado (require_nonzero por defecto)', () => {
|
|
101
|
+
const state = evaluateBalance(journalField(), [{ debit: '', credit: '' }])
|
|
102
|
+
expect(state?.balanced).toBe(false)
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
it('con require_nonzero=false, cero == cero está cuadrado', () => {
|
|
106
|
+
const field = journalField({
|
|
107
|
+
balance: { debit_column: 'debit', credit_column: 'credit', require_nonzero: false },
|
|
108
|
+
})
|
|
109
|
+
const state = evaluateBalance(field, [{ debit: '', credit: '' }])
|
|
110
|
+
expect(state?.balanced).toBe(true)
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
it('devuelve undefined cuando el campo no declara balance', () => {
|
|
114
|
+
expect(evaluateBalance(journalField({ balance: undefined }), [])).toBeUndefined()
|
|
115
|
+
})
|
|
116
|
+
})
|
|
@@ -63,6 +63,97 @@ export function isLineItemsField(field: ActionFieldDef): boolean {
|
|
|
63
63
|
return getItemFields(field).length > 0
|
|
64
64
|
}
|
|
65
65
|
|
|
66
|
+
/**
|
|
67
|
+
* Resolves the balance rule of a line-items field, tolerating both the
|
|
68
|
+
* camelCase authored shape and the snake_case the kernel serves. Returns
|
|
69
|
+
* normalized `{ debitColumn, creditColumn, message, requireNonzero }` or
|
|
70
|
+
* `undefined` when the field declares no balance constraint.
|
|
71
|
+
*/
|
|
72
|
+
export function getBalanceRule(
|
|
73
|
+
field: ActionFieldDef,
|
|
74
|
+
): { debitColumn: string; creditColumn: string; message?: string; requireNonzero: boolean } | undefined {
|
|
75
|
+
const b = field.balance
|
|
76
|
+
if (!b) return undefined
|
|
77
|
+
const debitColumn = b.debitColumn ?? b.debit_column ?? ''
|
|
78
|
+
const creditColumn = b.creditColumn ?? b.credit_column ?? ''
|
|
79
|
+
if (!debitColumn || !creditColumn) return undefined
|
|
80
|
+
const reqRaw = b.requireNonzero ?? b.require_nonzero
|
|
81
|
+
return {
|
|
82
|
+
debitColumn,
|
|
83
|
+
creditColumn,
|
|
84
|
+
message: b.message,
|
|
85
|
+
requireNonzero: reqRaw === undefined ? true : !!reqRaw,
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Coerces a cell value to a finite number, treating blanks/garbage as 0. */
|
|
90
|
+
export function toNumber(v: unknown): number {
|
|
91
|
+
if (typeof v === 'number') return Number.isFinite(v) ? v : 0
|
|
92
|
+
if (typeof v === 'string') {
|
|
93
|
+
const n = parseFloat(v)
|
|
94
|
+
return Number.isFinite(n) ? n : 0
|
|
95
|
+
}
|
|
96
|
+
return 0
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Sums each `total`-flagged column of a line-items field across its rows.
|
|
101
|
+
* Pure — no React — so the renderer and unit tests share one implementation.
|
|
102
|
+
* Returns a map of column key → summed value. Rounds to cents to avoid float
|
|
103
|
+
* drift (0.1 + 0.2 noise) that would make a genuinely balanced entry look off.
|
|
104
|
+
*/
|
|
105
|
+
export function computeLineItemTotals(
|
|
106
|
+
field: ActionFieldDef,
|
|
107
|
+
rows: any[] | undefined,
|
|
108
|
+
): Record<string, number> {
|
|
109
|
+
const cols = getItemFields(field).filter((c) => c.total)
|
|
110
|
+
const totals: Record<string, number> = {}
|
|
111
|
+
for (const c of cols) totals[c.key] = 0
|
|
112
|
+
if (Array.isArray(rows)) {
|
|
113
|
+
for (const row of rows) {
|
|
114
|
+
for (const c of cols) totals[c.key] += toNumber(row?.[c.key])
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
for (const k of Object.keys(totals)) totals[k] = Math.round(totals[k] * 100) / 100
|
|
118
|
+
return totals
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export interface BalanceState {
|
|
122
|
+
debit: number
|
|
123
|
+
credit: number
|
|
124
|
+
/** credit − debit, rounded to cents. Zero when balanced. */
|
|
125
|
+
diff: number
|
|
126
|
+
balanced: boolean
|
|
127
|
+
message?: string
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Evaluates a line-items field's balance rule against its rows. Returns
|
|
132
|
+
* `undefined` when the field declares no balance rule. `balanced` is true when
|
|
133
|
+
* the two summed columns are equal (and, unless `requireNonzero` is false,
|
|
134
|
+
* strictly positive). Pure — drives both the indicator and the submit gate.
|
|
135
|
+
*/
|
|
136
|
+
export function evaluateBalance(
|
|
137
|
+
field: ActionFieldDef,
|
|
138
|
+
rows: any[] | undefined,
|
|
139
|
+
): BalanceState | undefined {
|
|
140
|
+
const rule = getBalanceRule(field)
|
|
141
|
+
if (!rule) return undefined
|
|
142
|
+
let debit = 0
|
|
143
|
+
let credit = 0
|
|
144
|
+
if (Array.isArray(rows)) {
|
|
145
|
+
for (const row of rows) {
|
|
146
|
+
debit += toNumber(row?.[rule.debitColumn])
|
|
147
|
+
credit += toNumber(row?.[rule.creditColumn])
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
debit = Math.round(debit * 100) / 100
|
|
151
|
+
credit = Math.round(credit * 100) / 100
|
|
152
|
+
const diff = Math.round((credit - debit) * 100) / 100
|
|
153
|
+
const balanced = diff === 0 && (!rule.requireNonzero || debit > 0)
|
|
154
|
+
return { debit, credit, diff, balanced, message: rule.message }
|
|
155
|
+
}
|
|
156
|
+
|
|
66
157
|
function fieldToZod(field: ActionFieldDef): ZodTypeAny {
|
|
67
158
|
// Repeatable line-items group → array of row objects, each row built from
|
|
68
159
|
// the item field columns. Required keeps at least one row.
|
package/src/dynamic-form.tsx
CHANGED
|
@@ -15,7 +15,12 @@ import {
|
|
|
15
15
|
SelectValue,
|
|
16
16
|
} from '@asteby/metacore-ui/primitives'
|
|
17
17
|
import type { ActionFieldDef } from './types'
|
|
18
|
-
import {
|
|
18
|
+
import {
|
|
19
|
+
buildZodSchema,
|
|
20
|
+
resolveWidget,
|
|
21
|
+
isLineItemsField,
|
|
22
|
+
evaluateBalance,
|
|
23
|
+
} from './dynamic-form-schema'
|
|
19
24
|
import { useOptionsResolver, type ResolvedOption } from './use-options-resolver'
|
|
20
25
|
import { DynamicLineItems } from './dynamic-line-items'
|
|
21
26
|
import { DynamicSelectField } from './dynamic-select-field'
|
|
@@ -49,6 +54,18 @@ export function DynamicForm({
|
|
|
49
54
|
|
|
50
55
|
const schema = useMemo(() => buildZodSchema(fields), [fields])
|
|
51
56
|
|
|
57
|
+
// Line-items fields carrying a balance rule gate submit: an unbalanced entry
|
|
58
|
+
// (Σdebit ≠ Σcredit, or all-zero when require_nonzero) can't be saved. This
|
|
59
|
+
// is fully declarative — `evaluateBalance` returns undefined for fields with
|
|
60
|
+
// no rule, so non-balanced forms are unaffected.
|
|
61
|
+
const balanceBlocked = useMemo(() => {
|
|
62
|
+
for (const f of fields) {
|
|
63
|
+
const state = evaluateBalance(f, values[f.key])
|
|
64
|
+
if (state && !state.balanced) return true
|
|
65
|
+
}
|
|
66
|
+
return false
|
|
67
|
+
}, [fields, values])
|
|
68
|
+
|
|
52
69
|
useEffect(() => {
|
|
53
70
|
const defaults: Record<string, any> = {}
|
|
54
71
|
for (const f of fields) {
|
|
@@ -67,6 +84,7 @@ export function DynamicForm({
|
|
|
67
84
|
|
|
68
85
|
const handleSubmit = async (e: React.FormEvent) => {
|
|
69
86
|
e.preventDefault()
|
|
87
|
+
if (balanceBlocked) return
|
|
70
88
|
const result = schema.safeParse(values)
|
|
71
89
|
if (!result.success) {
|
|
72
90
|
const next: Record<string, string> = {}
|
|
@@ -82,31 +100,48 @@ export function DynamicForm({
|
|
|
82
100
|
try { await onSubmit(result.data as Record<string, any>) } finally { setSubmitting(false) }
|
|
83
101
|
}
|
|
84
102
|
|
|
103
|
+
// Layout: scalar header fields flow through a responsive 2-column grid;
|
|
104
|
+
// line-items grids (and textareas) span the full width so the row table /
|
|
105
|
+
// memo gets room. Mirrors the pro look of the federated journal modal but
|
|
106
|
+
// stays fully declarative — driven only by field shape.
|
|
85
107
|
return (
|
|
86
108
|
<form onSubmit={handleSubmit} className="grid gap-4">
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
109
|
+
<div className="grid gap-4 sm:grid-cols-2">
|
|
110
|
+
{fields.map((field) => {
|
|
111
|
+
const fullWidth =
|
|
112
|
+
isLineItemsField(field) ||
|
|
113
|
+
resolveWidget(field) === 'textarea' ||
|
|
114
|
+
resolveWidget(field) === 'richtext'
|
|
115
|
+
return (
|
|
116
|
+
<div
|
|
117
|
+
key={field.key}
|
|
118
|
+
className={'grid gap-2 ' + (fullWidth ? 'sm:col-span-2' : '')}
|
|
119
|
+
>
|
|
120
|
+
<Label htmlFor={field.key}>
|
|
121
|
+
{field.label}
|
|
122
|
+
{field.required && <span className="text-red-500 ml-1">*</span>}
|
|
123
|
+
</Label>
|
|
124
|
+
<FieldRenderer
|
|
125
|
+
field={field}
|
|
126
|
+
value={values[field.key]}
|
|
127
|
+
onChange={(v: any) => update(field.key, v)}
|
|
128
|
+
/>
|
|
129
|
+
{errors[field.key] && (
|
|
130
|
+
<span className="text-red-500 text-sm" role="alert">{errors[field.key]}</span>
|
|
131
|
+
)}
|
|
132
|
+
</div>
|
|
133
|
+
)
|
|
134
|
+
})}
|
|
135
|
+
</div>
|
|
103
136
|
<div className="flex justify-end gap-2 pt-2">
|
|
104
137
|
{onCancel && (
|
|
105
138
|
<Button type="button" variant="outline" onClick={onCancel} disabled={submitting || disabled}>
|
|
106
139
|
{cancelLabel}
|
|
107
140
|
</Button>
|
|
108
141
|
)}
|
|
109
|
-
<Button type="submit" disabled={submitting || disabled}>
|
|
142
|
+
<Button type="submit" disabled={submitting || disabled || balanceBlocked}>
|
|
143
|
+
{submitLabel}
|
|
144
|
+
</Button>
|
|
110
145
|
</div>
|
|
111
146
|
</form>
|
|
112
147
|
)
|
|
@@ -18,9 +18,15 @@ import {
|
|
|
18
18
|
SelectTrigger,
|
|
19
19
|
SelectValue,
|
|
20
20
|
} from '@asteby/metacore-ui/primitives'
|
|
21
|
-
import { Plus, Trash2 } from 'lucide-react'
|
|
21
|
+
import { Plus, Trash2, Check } from 'lucide-react'
|
|
22
22
|
import type { ActionFieldDef } from './types'
|
|
23
|
-
import {
|
|
23
|
+
import {
|
|
24
|
+
resolveWidget,
|
|
25
|
+
getItemFields,
|
|
26
|
+
computeLineItemTotals,
|
|
27
|
+
evaluateBalance,
|
|
28
|
+
toNumber,
|
|
29
|
+
} from './dynamic-form-schema'
|
|
24
30
|
import { DynamicSelectField } from './dynamic-select-field'
|
|
25
31
|
import { useOptionsResolver, type ResolvedOption } from './use-options-resolver'
|
|
26
32
|
|
|
@@ -31,6 +37,14 @@ export interface DynamicLineItemsProps {
|
|
|
31
37
|
disabled?: boolean
|
|
32
38
|
}
|
|
33
39
|
|
|
40
|
+
const fmtNumber = (n: number): string =>
|
|
41
|
+
n.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })
|
|
42
|
+
|
|
43
|
+
/** Numeric columns render right-aligned (debit/credit/amount feel). */
|
|
44
|
+
function isNumericCol(col: ActionFieldDef): boolean {
|
|
45
|
+
return resolveWidget(col) === 'number'
|
|
46
|
+
}
|
|
47
|
+
|
|
34
48
|
function emptyRow(itemFields: ActionFieldDef[]): Record<string, any> {
|
|
35
49
|
const row: Record<string, any> = {}
|
|
36
50
|
for (const f of itemFields) {
|
|
@@ -43,11 +57,45 @@ export function DynamicLineItems({ field, value, onChange, disabled = false }: D
|
|
|
43
57
|
const itemFields = getItemFields(field)
|
|
44
58
|
const rows: any[] = Array.isArray(value) ? value : []
|
|
45
59
|
|
|
60
|
+
// Columns flagged `total` get a per-column sum in the footer; the balance
|
|
61
|
+
// rule (if any) reconciles two of them. Both are declarative & generic.
|
|
62
|
+
const totals = computeLineItemTotals(field, rows)
|
|
63
|
+
const totalKeys = itemFields.filter((c) => c.total).map((c) => c.key)
|
|
64
|
+
const hasTotals = totalKeys.length > 0
|
|
65
|
+
const balance = evaluateBalance(field, rows)
|
|
66
|
+
|
|
46
67
|
const addRow = () => onChange([...rows, emptyRow(itemFields)])
|
|
47
68
|
const removeRow = (idx: number) => onChange(rows.filter((_, i) => i !== idx))
|
|
48
69
|
const updateCell = (idx: number, key: string, cellValue: any) =>
|
|
49
70
|
onChange(rows.map((r, i) => (i === idx ? { ...r, [key]: cellValue } : r)))
|
|
50
71
|
|
|
72
|
+
// When a balance rule reconciles two columns (e.g. debit ↔ credit), typing
|
|
73
|
+
// into one clears the sibling on the same row — mirrors the federated modal
|
|
74
|
+
// UX so a line is never both a debit and a credit.
|
|
75
|
+
const balancePair: [string, string] | null = balance
|
|
76
|
+
? (() => {
|
|
77
|
+
const f = getItemFields(field)
|
|
78
|
+
void f
|
|
79
|
+
const d = field.balance?.debitColumn ?? field.balance?.debit_column
|
|
80
|
+
const c = field.balance?.creditColumn ?? field.balance?.credit_column
|
|
81
|
+
return d && c ? [d, c] : null
|
|
82
|
+
})()
|
|
83
|
+
: null
|
|
84
|
+
|
|
85
|
+
const handleCell = (idx: number, key: string, cellValue: any) => {
|
|
86
|
+
if (balancePair && (key === balancePair[0] || key === balancePair[1])) {
|
|
87
|
+
const sibling = key === balancePair[0] ? balancePair[1] : balancePair[0]
|
|
88
|
+
const hasValue = toNumber(cellValue) > 0
|
|
89
|
+
onChange(
|
|
90
|
+
rows.map((r, i) =>
|
|
91
|
+
i === idx ? { ...r, [key]: cellValue, ...(hasValue ? { [sibling]: '' } : {}) } : r,
|
|
92
|
+
),
|
|
93
|
+
)
|
|
94
|
+
return
|
|
95
|
+
}
|
|
96
|
+
updateCell(idx, key, cellValue)
|
|
97
|
+
}
|
|
98
|
+
|
|
51
99
|
return (
|
|
52
100
|
<div className="grid gap-2" data-widget="line_items">
|
|
53
101
|
<div className="overflow-x-auto rounded-md border">
|
|
@@ -55,7 +103,13 @@ export function DynamicLineItems({ field, value, onChange, disabled = false }: D
|
|
|
55
103
|
<thead className="bg-muted/50">
|
|
56
104
|
<tr>
|
|
57
105
|
{itemFields.map((col) => (
|
|
58
|
-
<th
|
|
106
|
+
<th
|
|
107
|
+
key={col.key}
|
|
108
|
+
className={
|
|
109
|
+
'px-3 py-2 font-medium ' +
|
|
110
|
+
(isNumericCol(col) ? 'text-right' : 'text-left')
|
|
111
|
+
}
|
|
112
|
+
>
|
|
59
113
|
{col.label}
|
|
60
114
|
{col.required && <span className="text-red-500 ml-1">*</span>}
|
|
61
115
|
</th>
|
|
@@ -81,7 +135,7 @@ export function DynamicLineItems({ field, value, onChange, disabled = false }: D
|
|
|
81
135
|
<CellRenderer
|
|
82
136
|
field={col}
|
|
83
137
|
value={row?.[col.key]}
|
|
84
|
-
onChange={(v: any) =>
|
|
138
|
+
onChange={(v: any) => handleCell(idx, col.key, v)}
|
|
85
139
|
disabled={disabled}
|
|
86
140
|
/>
|
|
87
141
|
</td>
|
|
@@ -101,18 +155,80 @@ export function DynamicLineItems({ field, value, onChange, disabled = false }: D
|
|
|
101
155
|
</tr>
|
|
102
156
|
))}
|
|
103
157
|
</tbody>
|
|
158
|
+
{hasTotals && rows.length > 0 && (
|
|
159
|
+
<tfoot className="border-t bg-muted/30">
|
|
160
|
+
<tr>
|
|
161
|
+
{itemFields.map((col, ci) => {
|
|
162
|
+
if (ci === 0) {
|
|
163
|
+
return (
|
|
164
|
+
<td
|
|
165
|
+
key={col.key}
|
|
166
|
+
className="px-3 py-2 text-left font-medium text-muted-foreground"
|
|
167
|
+
>
|
|
168
|
+
Totales
|
|
169
|
+
</td>
|
|
170
|
+
)
|
|
171
|
+
}
|
|
172
|
+
return (
|
|
173
|
+
<td
|
|
174
|
+
key={col.key}
|
|
175
|
+
className={
|
|
176
|
+
'px-3 py-2 ' +
|
|
177
|
+
(col.total
|
|
178
|
+
? 'text-right font-semibold tabular-nums'
|
|
179
|
+
: '')
|
|
180
|
+
}
|
|
181
|
+
>
|
|
182
|
+
{col.total ? fmtNumber(totals[col.key] ?? 0) : null}
|
|
183
|
+
</td>
|
|
184
|
+
)
|
|
185
|
+
})}
|
|
186
|
+
<td />
|
|
187
|
+
</tr>
|
|
188
|
+
</tfoot>
|
|
189
|
+
)}
|
|
104
190
|
</table>
|
|
105
191
|
</div>
|
|
106
|
-
<div>
|
|
192
|
+
<div className="flex items-center justify-between gap-2">
|
|
107
193
|
<Button type="button" variant="outline" size="sm" onClick={addRow} disabled={disabled}>
|
|
108
194
|
<Plus className="mr-1 h-4 w-4" />
|
|
109
195
|
Agregar renglón
|
|
110
196
|
</Button>
|
|
197
|
+
{balance && <BalanceBadge state={balance} />}
|
|
111
198
|
</div>
|
|
112
199
|
</div>
|
|
113
200
|
)
|
|
114
201
|
}
|
|
115
202
|
|
|
203
|
+
function BalanceBadge({
|
|
204
|
+
state,
|
|
205
|
+
}: {
|
|
206
|
+
state: NonNullable<ReturnType<typeof evaluateBalance>>
|
|
207
|
+
}) {
|
|
208
|
+
if (state.balanced) {
|
|
209
|
+
return (
|
|
210
|
+
<span
|
|
211
|
+
className="inline-flex items-center gap-1.5 rounded-md bg-primary/10 px-2.5 py-1 text-sm font-medium text-primary"
|
|
212
|
+
data-balance="balanced"
|
|
213
|
+
role="status"
|
|
214
|
+
>
|
|
215
|
+
<Check className="h-4 w-4" />
|
|
216
|
+
Cuadrado
|
|
217
|
+
</span>
|
|
218
|
+
)
|
|
219
|
+
}
|
|
220
|
+
const diff = Math.abs(state.diff)
|
|
221
|
+
return (
|
|
222
|
+
<span
|
|
223
|
+
className="inline-flex items-center gap-1.5 rounded-md bg-destructive/10 px-2.5 py-1 text-sm font-medium text-destructive"
|
|
224
|
+
data-balance="unbalanced"
|
|
225
|
+
role="status"
|
|
226
|
+
>
|
|
227
|
+
{state.message ?? `Descuadre: ${fmtNumber(diff)}`}
|
|
228
|
+
</span>
|
|
229
|
+
)
|
|
230
|
+
}
|
|
231
|
+
|
|
116
232
|
interface CellRendererProps {
|
|
117
233
|
field: ActionFieldDef
|
|
118
234
|
value: any
|
package/src/types.ts
CHANGED
|
@@ -146,6 +146,40 @@ export interface ActionFieldDef {
|
|
|
146
146
|
* keyed by these item field keys. Rendered by `DynamicLineItems`.
|
|
147
147
|
*/
|
|
148
148
|
itemFields?: ActionFieldDef[]
|
|
149
|
+
/**
|
|
150
|
+
* On an `itemFields` column: flags the column for summation in the
|
|
151
|
+
* line-items footer. The SDK renders a totals row summing every numeric
|
|
152
|
+
* column marked `total` (e.g. the debit and credit columns of a journal
|
|
153
|
+
* entry). Ignored on flat fields. Mirrors kernel v3 `ActionField.total`.
|
|
154
|
+
*/
|
|
155
|
+
total?: boolean
|
|
156
|
+
/**
|
|
157
|
+
* On a line-items (`type: "array"`) field: declares an optional, generic
|
|
158
|
+
* balance constraint between two summed columns. The SDK shows a balanced /
|
|
159
|
+
* out-of-balance indicator and blocks submit until the two sides match.
|
|
160
|
+
* Domain-agnostic — "debit"/"credit" are just the two column keys to
|
|
161
|
+
* reconcile. Mirrors kernel v3 `ActionField.balance`.
|
|
162
|
+
*/
|
|
163
|
+
balance?: FieldBalanceRule
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Declarative reconciliation constraint on a line-items field: the summed value
|
|
168
|
+
* of `debitColumn` across all rows must equal the summed value of
|
|
169
|
+
* `creditColumn`. Tolerates the snake_case shape the kernel serves
|
|
170
|
+
* (`debit_column` / `credit_column` / `require_nonzero`). Generic by design.
|
|
171
|
+
*/
|
|
172
|
+
export interface FieldBalanceRule {
|
|
173
|
+
debitColumn?: string
|
|
174
|
+
creditColumn?: string
|
|
175
|
+
/** snake_case alias served by the kernel manifest. */
|
|
176
|
+
debit_column?: string
|
|
177
|
+
/** snake_case alias served by the kernel manifest. */
|
|
178
|
+
credit_column?: string
|
|
179
|
+
message?: string
|
|
180
|
+
/** When true (default) an all-zero entry is treated as out of balance. */
|
|
181
|
+
requireNonzero?: boolean
|
|
182
|
+
require_nonzero?: boolean
|
|
149
183
|
}
|
|
150
184
|
|
|
151
185
|
export interface ActionDefinition {
|