@asteby/metacore-runtime-react 13.2.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 +42 -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 +80 -0
- package/dist/dynamic-form.d.ts +1 -0
- package/dist/dynamic-form.d.ts.map +1 -1
- package/dist/dynamic-form.js +33 -2
- package/dist/dynamic-line-items.d.ts.map +1 -1
- package/dist/dynamic-line-items.js +57 -3
- package/dist/dynamic-select-field.d.ts +9 -0
- package/dist/dynamic-select-field.d.ts.map +1 -0
- package/dist/dynamic-select-field.js +74 -0
- package/dist/types.d.ts +34 -1
- 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 +94 -0
- package/src/dynamic-form.tsx +61 -18
- package/src/dynamic-line-items.tsx +127 -5
- package/src/dynamic-select-field.tsx +164 -0
- package/src/types.ts +35 -0
|
@@ -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.
|
|
@@ -119,6 +210,9 @@ export function resolveWidget(field: ActionFieldDef): string {
|
|
|
119
210
|
switch (field.type) {
|
|
120
211
|
case 'textarea': return 'textarea'
|
|
121
212
|
case 'select': return 'select'
|
|
213
|
+
// Async searchable single-select against /api/options/<ref>. The
|
|
214
|
+
// declarative replacement for typing a raw FK UUID.
|
|
215
|
+
case 'dynamic_select': return 'dynamic_select'
|
|
122
216
|
case 'boolean': return 'switch'
|
|
123
217
|
case 'number': return 'number'
|
|
124
218
|
case 'date': return 'date'
|
package/src/dynamic-form.tsx
CHANGED
|
@@ -15,12 +15,19 @@ 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'
|
|
26
|
+
import { DynamicSelectField } from './dynamic-select-field'
|
|
21
27
|
|
|
22
28
|
export { buildZodSchema, resolveWidget }
|
|
23
29
|
export { DynamicLineItems } from './dynamic-line-items'
|
|
30
|
+
export { DynamicSelectField } from './dynamic-select-field'
|
|
24
31
|
|
|
25
32
|
export interface DynamicFormProps {
|
|
26
33
|
fields: ActionFieldDef[]
|
|
@@ -47,6 +54,18 @@ export function DynamicForm({
|
|
|
47
54
|
|
|
48
55
|
const schema = useMemo(() => buildZodSchema(fields), [fields])
|
|
49
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
|
+
|
|
50
69
|
useEffect(() => {
|
|
51
70
|
const defaults: Record<string, any> = {}
|
|
52
71
|
for (const f of fields) {
|
|
@@ -65,6 +84,7 @@ export function DynamicForm({
|
|
|
65
84
|
|
|
66
85
|
const handleSubmit = async (e: React.FormEvent) => {
|
|
67
86
|
e.preventDefault()
|
|
87
|
+
if (balanceBlocked) return
|
|
68
88
|
const result = schema.safeParse(values)
|
|
69
89
|
if (!result.success) {
|
|
70
90
|
const next: Record<string, string> = {}
|
|
@@ -80,31 +100,48 @@ export function DynamicForm({
|
|
|
80
100
|
try { await onSubmit(result.data as Record<string, any>) } finally { setSubmitting(false) }
|
|
81
101
|
}
|
|
82
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.
|
|
83
107
|
return (
|
|
84
108
|
<form onSubmit={handleSubmit} className="grid gap-4">
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
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>
|
|
101
136
|
<div className="flex justify-end gap-2 pt-2">
|
|
102
137
|
{onCancel && (
|
|
103
138
|
<Button type="button" variant="outline" onClick={onCancel} disabled={submitting || disabled}>
|
|
104
139
|
{cancelLabel}
|
|
105
140
|
</Button>
|
|
106
141
|
)}
|
|
107
|
-
<Button type="submit" disabled={submitting || disabled}>
|
|
142
|
+
<Button type="submit" disabled={submitting || disabled || balanceBlocked}>
|
|
143
|
+
{submitLabel}
|
|
144
|
+
</Button>
|
|
108
145
|
</div>
|
|
109
146
|
</form>
|
|
110
147
|
)
|
|
@@ -123,6 +160,12 @@ function FieldRenderer({ field, value, onChange }: FieldRendererProps) {
|
|
|
123
160
|
return <DynamicLineItems field={field} value={value} onChange={onChange} />
|
|
124
161
|
}
|
|
125
162
|
const widget = resolveWidget(field)
|
|
163
|
+
// Async searchable picker (typeahead against /api/options/<ref>?q=…).
|
|
164
|
+
// Preferred for FK fields with large option sets — no UUID typing, no
|
|
165
|
+
// dumping every row into a plain <select>.
|
|
166
|
+
if (widget === 'dynamic_select') {
|
|
167
|
+
return <DynamicSelectField field={field} value={value} onChange={onChange} />
|
|
168
|
+
}
|
|
126
169
|
// Ref-driven select: hook into useOptionsResolver so the canonical
|
|
127
170
|
// /api/options/<ref>?field=id endpoint feeds the dropdown. This is
|
|
128
171
|
// the path the kernel auto-derives for FK columns; legacy callers
|
|
@@ -18,9 +18,16 @@ 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'
|
|
30
|
+
import { DynamicSelectField } from './dynamic-select-field'
|
|
24
31
|
import { useOptionsResolver, type ResolvedOption } from './use-options-resolver'
|
|
25
32
|
|
|
26
33
|
export interface DynamicLineItemsProps {
|
|
@@ -30,6 +37,14 @@ export interface DynamicLineItemsProps {
|
|
|
30
37
|
disabled?: boolean
|
|
31
38
|
}
|
|
32
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
|
+
|
|
33
48
|
function emptyRow(itemFields: ActionFieldDef[]): Record<string, any> {
|
|
34
49
|
const row: Record<string, any> = {}
|
|
35
50
|
for (const f of itemFields) {
|
|
@@ -42,11 +57,45 @@ export function DynamicLineItems({ field, value, onChange, disabled = false }: D
|
|
|
42
57
|
const itemFields = getItemFields(field)
|
|
43
58
|
const rows: any[] = Array.isArray(value) ? value : []
|
|
44
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
|
+
|
|
45
67
|
const addRow = () => onChange([...rows, emptyRow(itemFields)])
|
|
46
68
|
const removeRow = (idx: number) => onChange(rows.filter((_, i) => i !== idx))
|
|
47
69
|
const updateCell = (idx: number, key: string, cellValue: any) =>
|
|
48
70
|
onChange(rows.map((r, i) => (i === idx ? { ...r, [key]: cellValue } : r)))
|
|
49
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
|
+
|
|
50
99
|
return (
|
|
51
100
|
<div className="grid gap-2" data-widget="line_items">
|
|
52
101
|
<div className="overflow-x-auto rounded-md border">
|
|
@@ -54,7 +103,13 @@ export function DynamicLineItems({ field, value, onChange, disabled = false }: D
|
|
|
54
103
|
<thead className="bg-muted/50">
|
|
55
104
|
<tr>
|
|
56
105
|
{itemFields.map((col) => (
|
|
57
|
-
<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
|
+
>
|
|
58
113
|
{col.label}
|
|
59
114
|
{col.required && <span className="text-red-500 ml-1">*</span>}
|
|
60
115
|
</th>
|
|
@@ -80,7 +135,7 @@ export function DynamicLineItems({ field, value, onChange, disabled = false }: D
|
|
|
80
135
|
<CellRenderer
|
|
81
136
|
field={col}
|
|
82
137
|
value={row?.[col.key]}
|
|
83
|
-
onChange={(v: any) =>
|
|
138
|
+
onChange={(v: any) => handleCell(idx, col.key, v)}
|
|
84
139
|
disabled={disabled}
|
|
85
140
|
/>
|
|
86
141
|
</td>
|
|
@@ -100,18 +155,80 @@ export function DynamicLineItems({ field, value, onChange, disabled = false }: D
|
|
|
100
155
|
</tr>
|
|
101
156
|
))}
|
|
102
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
|
+
)}
|
|
103
190
|
</table>
|
|
104
191
|
</div>
|
|
105
|
-
<div>
|
|
192
|
+
<div className="flex items-center justify-between gap-2">
|
|
106
193
|
<Button type="button" variant="outline" size="sm" onClick={addRow} disabled={disabled}>
|
|
107
194
|
<Plus className="mr-1 h-4 w-4" />
|
|
108
195
|
Agregar renglón
|
|
109
196
|
</Button>
|
|
197
|
+
{balance && <BalanceBadge state={balance} />}
|
|
110
198
|
</div>
|
|
111
199
|
</div>
|
|
112
200
|
)
|
|
113
201
|
}
|
|
114
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
|
+
|
|
115
232
|
interface CellRendererProps {
|
|
116
233
|
field: ActionFieldDef
|
|
117
234
|
value: any
|
|
@@ -125,6 +242,11 @@ interface CellRendererProps {
|
|
|
125
242
|
// a scalar widget).
|
|
126
243
|
function CellRenderer({ field, value, onChange, disabled }: CellRendererProps) {
|
|
127
244
|
const widget = resolveWidget(field)
|
|
245
|
+
// Async searchable picker per row cell — e.g. the account_id column of a
|
|
246
|
+
// journal entry's debit/credit lines. Same widget as the flat form.
|
|
247
|
+
if (widget === 'dynamic_select') {
|
|
248
|
+
return <DynamicSelectField field={field} value={value} onChange={onChange} />
|
|
249
|
+
}
|
|
128
250
|
if (widget === 'select' && field.ref) {
|
|
129
251
|
return <RefCell field={field} value={value} onChange={onChange} disabled={disabled} />
|
|
130
252
|
}
|