@asteby/metacore-runtime-react 47.1.0 → 48.0.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 +29 -0
- package/dist/action-modal-dispatcher.d.ts.map +1 -1
- package/dist/action-modal-dispatcher.js +24 -4
- package/dist/business/catalog-product-search.d.ts +25 -0
- package/dist/business/catalog-product-search.d.ts.map +1 -0
- package/dist/business/catalog-product-search.js +57 -0
- package/dist/business/catalog.d.ts.map +1 -1
- package/dist/business/catalog.js +2 -0
- package/dist/business/document-editor-model.d.ts +107 -0
- package/dist/business/document-editor-model.d.ts.map +1 -0
- package/dist/business/document-editor-model.js +279 -0
- package/dist/business/document-editor-panels.d.ts +64 -0
- package/dist/business/document-editor-panels.d.ts.map +1 -0
- package/dist/business/document-editor-panels.js +58 -0
- package/dist/business/document-editor.d.ts +27 -0
- package/dist/business/document-editor.d.ts.map +1 -0
- package/dist/business/document-editor.js +445 -0
- package/dist/business/document-lines.d.ts.map +1 -1
- package/dist/business/document-lines.js +5 -1
- package/dist/business/index.d.ts +5 -0
- package/dist/business/index.d.ts.map +1 -1
- package/dist/business/index.js +5 -0
- package/dist/business/line-items.d.ts +7 -0
- package/dist/business/line-items.d.ts.map +1 -1
- package/dist/business/line-items.js +6 -1
- package/dist/business/payment-allocator.d.ts +19 -0
- package/dist/business/payment-allocator.d.ts.map +1 -0
- package/dist/business/payment-allocator.js +31 -0
- package/dist/business/product-search.d.ts +4 -0
- package/dist/business/product-search.d.ts.map +1 -1
- package/dist/dialogs/dynamic-record.d.ts.map +1 -1
- package/dist/dialogs/dynamic-record.js +14 -7
- package/dist/document-form-dialog.d.ts +31 -2
- package/dist/document-form-dialog.d.ts.map +1 -1
- package/dist/document-form-dialog.js +43 -11
- package/dist/dynamic-columns-helpers.d.ts +9 -0
- package/dist/dynamic-columns-helpers.d.ts.map +1 -1
- package/dist/dynamic-columns-helpers.js +19 -0
- package/dist/dynamic-columns.d.ts +8 -2
- package/dist/dynamic-columns.d.ts.map +1 -1
- package/dist/dynamic-columns.js +21 -5
- package/dist/dynamic-crud-page.d.ts +6 -0
- package/dist/dynamic-crud-page.d.ts.map +1 -1
- package/dist/dynamic-crud-page.js +14 -5
- package/dist/dynamic-form-schema.d.ts.map +1 -1
- package/dist/dynamic-form-schema.js +15 -3
- package/dist/dynamic-form.d.ts +16 -1
- package/dist/dynamic-form.d.ts.map +1 -1
- package/dist/dynamic-form.js +78 -20
- package/dist/dynamic-kanban.d.ts +9 -3
- package/dist/dynamic-kanban.d.ts.map +1 -1
- package/dist/dynamic-kanban.js +31 -12
- package/dist/dynamic-relation-helpers.d.ts.map +1 -1
- package/dist/dynamic-relation-helpers.js +8 -0
- package/dist/dynamic-relation.js +1 -1
- package/dist/index.d.ts +5 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +13 -2
- package/dist/installed-addons-context.d.ts +15 -0
- package/dist/installed-addons-context.d.ts.map +1 -0
- package/dist/installed-addons-context.js +39 -0
- package/dist/org-runtime-context.d.ts +8 -0
- package/dist/org-runtime-context.d.ts.map +1 -1
- package/dist/org-runtime-context.js +8 -0
- package/dist/org-runtime-provider.d.ts +3 -1
- package/dist/org-runtime-provider.d.ts.map +1 -1
- package/dist/org-runtime-provider.js +3 -3
- package/dist/primitives/aging.d.ts +34 -0
- package/dist/primitives/aging.d.ts.map +1 -0
- package/dist/primitives/aging.js +48 -0
- package/dist/primitives/allocation.d.ts +42 -0
- package/dist/primitives/allocation.d.ts.map +1 -0
- package/dist/primitives/allocation.js +59 -0
- package/dist/primitives/approvals.d.ts +71 -0
- package/dist/primitives/approvals.d.ts.map +1 -0
- package/dist/primitives/approvals.js +48 -0
- package/dist/primitives/contributions.d.ts +82 -0
- package/dist/primitives/contributions.d.ts.map +1 -0
- package/dist/primitives/contributions.js +126 -0
- package/dist/primitives/document-kinds.d.ts +25 -0
- package/dist/primitives/document-kinds.d.ts.map +1 -0
- package/dist/primitives/document-kinds.js +26 -0
- package/dist/primitives/index.d.ts +8 -0
- package/dist/primitives/index.d.ts.map +1 -0
- package/dist/primitives/index.js +23 -0
- package/dist/primitives/panels.d.ts +68 -0
- package/dist/primitives/panels.d.ts.map +1 -0
- package/dist/primitives/panels.js +3 -0
- package/dist/primitives/use-contributions.d.ts +15 -0
- package/dist/primitives/use-contributions.d.ts.map +1 -0
- package/dist/primitives/use-contributions.js +30 -0
- package/dist/record-prefill-bar.d.ts +11 -0
- package/dist/record-prefill-bar.d.ts.map +1 -0
- package/dist/record-prefill-bar.js +33 -0
- package/dist/types.d.ts +102 -0
- package/dist/types.d.ts.map +1 -1
- package/package.json +3 -3
- package/src/__tests__/catalog-product-search.test.ts +61 -0
- package/src/__tests__/document-editor-model.test.ts +167 -0
- package/src/__tests__/document-editor.test.tsx +261 -0
- package/src/__tests__/document-form-dialog.test.tsx +118 -3
- package/src/__tests__/dynamic-form-select-edit.test.tsx +155 -0
- package/src/__tests__/dynamic-kanban.test.tsx +24 -3
- package/src/__tests__/dynamic-record-extension-label.test.tsx +88 -0
- package/src/__tests__/installed-addons-context.test.tsx +50 -0
- package/src/__tests__/line-item-totals.test.ts +28 -0
- package/src/__tests__/primitives-federated.test.tsx +156 -0
- package/src/__tests__/primitives.test.ts +224 -0
- package/src/__tests__/record-prefill-bar.test.tsx +50 -0
- package/src/action-modal-dispatcher.tsx +47 -4
- package/src/business/catalog-product-search.ts +87 -0
- package/src/business/catalog.ts +2 -0
- package/src/business/document-editor-model.ts +325 -0
- package/src/business/document-editor-panels.tsx +164 -0
- package/src/business/document-editor.tsx +727 -0
- package/src/business/document-lines.ts +5 -1
- package/src/business/index.ts +5 -0
- package/src/business/line-items.ts +15 -1
- package/src/business/payment-allocator.tsx +110 -0
- package/src/business/product-search.ts +4 -0
- package/src/dialogs/dynamic-record.tsx +29 -6
- package/src/document-form-dialog.tsx +92 -12
- package/src/dynamic-columns-helpers.ts +22 -0
- package/src/dynamic-columns.tsx +23 -6
- package/src/dynamic-crud-page.tsx +22 -3
- package/src/dynamic-form-schema.ts +15 -2
- package/src/dynamic-form.tsx +117 -39
- package/src/dynamic-kanban.tsx +34 -7
- package/src/dynamic-relation-helpers.ts +9 -0
- package/src/dynamic-relation.tsx +7 -2
- package/src/index.ts +21 -1
- package/src/installed-addons-context.tsx +58 -0
- package/src/org-runtime-context.ts +10 -0
- package/src/org-runtime-provider.tsx +7 -2
- package/src/primitives/aging.ts +76 -0
- package/src/primitives/allocation.ts +104 -0
- package/src/primitives/approvals.ts +103 -0
- package/src/primitives/contributions.ts +208 -0
- package/src/primitives/document-kinds.ts +62 -0
- package/src/primitives/index.ts +23 -0
- package/src/primitives/panels.ts +59 -0
- package/src/primitives/use-contributions.ts +42 -0
- package/src/record-prefill-bar.tsx +62 -0
- package/src/row-action-stage-gate.test.ts +33 -0
- package/src/types.ts +101 -0
package/src/dynamic-form.tsx
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// Minimal standalone DynamicForm. Factored from the dynamic-record-dialog
|
|
2
2
|
// pattern + ActionFieldDef renderer so callers can reuse the form layout
|
|
3
3
|
// outside the full record-edit modal.
|
|
4
|
-
import { useEffect, useMemo, useState } from 'react'
|
|
4
|
+
import { useEffect, useMemo, useRef, useState } from 'react'
|
|
5
5
|
import { groupFieldsBySection, type FormLayout } from './form-layout'
|
|
6
6
|
import { FieldSection, WizardProgress } from './form-layout-ui'
|
|
7
7
|
import { AssistInterview } from './assist-interview'
|
|
@@ -65,8 +65,35 @@ export interface DynamicFormProps {
|
|
|
65
65
|
* legacy flat list (unchanged).
|
|
66
66
|
*/
|
|
67
67
|
formLayout?: FormLayout
|
|
68
|
+
/**
|
|
69
|
+
* Para formularios dentro de un modal con alto acotado: el cuerpo de campos
|
|
70
|
+
* hace scroll propio y la barra de botones (Guardar/Cancelar) queda fija
|
|
71
|
+
* abajo, así nunca queda fuera de pantalla con muchos campos. El contenedor
|
|
72
|
+
* debe ser flex-col con alto máximo (p.ej. `max-h-[90dvh]`).
|
|
73
|
+
*/
|
|
74
|
+
scrollableBody?: boolean
|
|
68
75
|
}
|
|
69
76
|
|
|
77
|
+
// Valores iniciales del formulario: el registro (edición) gana sobre el default
|
|
78
|
+
// del campo. Se calcula de forma síncrona en el primer render para que ningún
|
|
79
|
+
// widget (p.ej. un Select de Radix) monte con '' y emita un cambio espurio que
|
|
80
|
+
// pise el valor real del registro.
|
|
81
|
+
function seedValues(fields: ActionFieldDef[], initialValues?: Record<string, any>): Record<string, any> {
|
|
82
|
+
const defaults: Record<string, any> = {}
|
|
83
|
+
for (const f of fields) {
|
|
84
|
+
if (isLineItemsField(f)) {
|
|
85
|
+
defaults[f.key] = initialValues?.[f.key] ?? f.defaultValue ?? []
|
|
86
|
+
continue
|
|
87
|
+
}
|
|
88
|
+
defaults[f.key] = initialValues?.[f.key] ?? f.defaultValue ?? (f.type === 'boolean' ? false : '')
|
|
89
|
+
}
|
|
90
|
+
return defaults
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const isReadonlyField = (f: ActionFieldDef) =>
|
|
94
|
+
!!(f as { readonly?: boolean; readOnly?: boolean }).readonly ||
|
|
95
|
+
!!(f as { readonly?: boolean; readOnly?: boolean }).readOnly
|
|
96
|
+
|
|
70
97
|
export function DynamicForm({
|
|
71
98
|
fields,
|
|
72
99
|
initialValues,
|
|
@@ -76,8 +103,8 @@ export function DynamicForm({
|
|
|
76
103
|
cancelLabel = 'Cancelar',
|
|
77
104
|
disabled = false,
|
|
78
105
|
formLayout,
|
|
106
|
+
scrollableBody = false,
|
|
79
107
|
}: DynamicFormProps) {
|
|
80
|
-
const [values, setValues] = useState<Record<string, any>>({})
|
|
81
108
|
const [errors, setErrors] = useState<Record<string, string>>({})
|
|
82
109
|
const [submitting, setSubmitting] = useState(false)
|
|
83
110
|
|
|
@@ -87,10 +114,15 @@ export function DynamicForm({
|
|
|
87
114
|
// (ColumnDef.Readonly). They are also excluded from defaults/submit so a
|
|
88
115
|
// create never posts e.g. `status: ""` over the column default.
|
|
89
116
|
const editableFields = useMemo(
|
|
90
|
-
() => fields.filter((f) => !(f
|
|
117
|
+
() => fields.filter((f) => !isReadonlyField(f)),
|
|
91
118
|
[fields],
|
|
92
119
|
)
|
|
93
120
|
|
|
121
|
+
const [values, setValues] = useState<Record<string, any>>(() => seedValues(editableFields, initialValues))
|
|
122
|
+
// Campos que el usuario (o un reset dependiente) cambió desde la siembra.
|
|
123
|
+
// En edición, un select que sigue vacío y nadie tocó no viaja en el PATCH.
|
|
124
|
+
const touched = useRef<Set<string>>(new Set())
|
|
125
|
+
|
|
94
126
|
// Conditional visibility: a field carrying `visible_when` is rendered — and
|
|
95
127
|
// validated — only while the referenced sibling field's current value
|
|
96
128
|
// matches the predicate. Driving BOTH the schema and the render off the same
|
|
@@ -133,21 +165,22 @@ export function DynamicForm({
|
|
|
133
165
|
setStepIndex((i) => Math.min(i, Math.max(groups.length - 1, 0)))
|
|
134
166
|
}, [groups.length])
|
|
135
167
|
|
|
168
|
+
// Re-siembra cuando cambia el registro o los campos DESPUÉS del montaje (el
|
|
169
|
+
// primer render ya quedó sembrado por el inicializador de useState).
|
|
170
|
+
const seededFrom = useRef({ editableFields, initialValues })
|
|
136
171
|
useEffect(() => {
|
|
137
|
-
const
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
}
|
|
143
|
-
defaults[f.key] = initialValues?.[f.key] ?? f.defaultValue ?? (f.type === 'boolean' ? false : '')
|
|
144
|
-
}
|
|
145
|
-
setValues(defaults)
|
|
172
|
+
const prev = seededFrom.current
|
|
173
|
+
if (prev.editableFields === editableFields && prev.initialValues === initialValues) return
|
|
174
|
+
seededFrom.current = { editableFields, initialValues }
|
|
175
|
+
touched.current = new Set()
|
|
176
|
+
setValues(seedValues(editableFields, initialValues))
|
|
146
177
|
setErrors({})
|
|
147
178
|
}, [editableFields, initialValues])
|
|
148
179
|
|
|
149
|
-
const update = (k: string, v: any) =>
|
|
180
|
+
const update = (k: string, v: any) => {
|
|
181
|
+
touched.current.add(k)
|
|
150
182
|
setValues((prev: Record<string, any>) => ({ ...prev, [k]: v }))
|
|
183
|
+
}
|
|
151
184
|
|
|
152
185
|
const handleSubmit = async (e: React.FormEvent) => {
|
|
153
186
|
e.preventDefault()
|
|
@@ -163,8 +196,19 @@ export function DynamicForm({
|
|
|
163
196
|
return
|
|
164
197
|
}
|
|
165
198
|
setErrors({})
|
|
199
|
+
const data = { ...(result.data as Record<string, any>) }
|
|
200
|
+
// Edición: un select vacío que el usuario no tocó no se manda — el update
|
|
201
|
+
// es parcial, así que omitirlo conserva lo que tenga el registro en vez de
|
|
202
|
+
// pisarlo con ''.
|
|
203
|
+
if (initialValues) {
|
|
204
|
+
for (const f of visibleFields) {
|
|
205
|
+
if (resolveWidget(f) !== 'select') continue
|
|
206
|
+
if (touched.current.has(f.key)) continue
|
|
207
|
+
if (data[f.key] === '' || data[f.key] == null) delete data[f.key]
|
|
208
|
+
}
|
|
209
|
+
}
|
|
166
210
|
setSubmitting(true)
|
|
167
|
-
try { await onSubmit(
|
|
211
|
+
try { await onSubmit(data) } finally { setSubmitting(false) }
|
|
168
212
|
}
|
|
169
213
|
|
|
170
214
|
// Renders one group's fields into the responsive 2-column grid: scalar
|
|
@@ -186,6 +230,11 @@ export function DynamicForm({
|
|
|
186
230
|
</div>
|
|
187
231
|
)
|
|
188
232
|
|
|
233
|
+
// scrollableBody: el cuerpo hace scroll y el footer de botones queda fijo.
|
|
234
|
+
const formClass = scrollableBody ? 'flex min-h-0 flex-1 flex-col gap-4' : 'grid gap-4'
|
|
235
|
+
const bodyClass = scrollableBody ? '-mx-1 grid min-h-0 flex-1 gap-4 overflow-y-auto px-1' : 'grid gap-4'
|
|
236
|
+
const footerExtra = scrollableBody ? ' shrink-0 border-t' : ''
|
|
237
|
+
|
|
189
238
|
// ── Steps (wizard) mode ────────────────────────────────────────────────
|
|
190
239
|
// One step per section; Anterior/Siguiente navigate, submit only on the
|
|
191
240
|
// last step. Advancing validates just the current step's fields so a later
|
|
@@ -212,20 +261,22 @@ export function DynamicForm({
|
|
|
212
261
|
const goBack = () => setStepIndex((i) => Math.max(i - 1, 0))
|
|
213
262
|
|
|
214
263
|
return (
|
|
215
|
-
<form onSubmit={handleSubmit} className=
|
|
216
|
-
<
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
264
|
+
<form onSubmit={handleSubmit} className={formClass}>
|
|
265
|
+
<div className={bodyClass}>
|
|
266
|
+
<WizardProgress groups={groups} stepIndex={stepIndex} onStepClick={i => setStepIndex(i)} />
|
|
267
|
+
{step.assist ? (
|
|
268
|
+
<AssistInterview
|
|
269
|
+
assist={step.assist}
|
|
270
|
+
values={values}
|
|
271
|
+
eyebrow={step.title}
|
|
272
|
+
autoStart={step.assist.trigger !== 'button'}
|
|
273
|
+
onApply={fields => setValues(prev => ({ ...prev, ...fields }))}
|
|
274
|
+
/>
|
|
275
|
+
) : (
|
|
276
|
+
renderGrid(step.fields)
|
|
277
|
+
)}
|
|
278
|
+
</div>
|
|
279
|
+
<div className={'flex justify-between gap-2 pt-2' + footerExtra}>
|
|
229
280
|
{stepIndex > 0 ? (
|
|
230
281
|
<Button type="button" variant="outline" onClick={goBack} disabled={submitting || disabled}>
|
|
231
282
|
Anterior
|
|
@@ -258,13 +309,15 @@ export function DynamicForm({
|
|
|
258
309
|
// stays fully declarative — driven only by field shape. With no layout this
|
|
259
310
|
// is a single default group rendered without section chrome (unchanged).
|
|
260
311
|
return (
|
|
261
|
-
<form onSubmit={handleSubmit} className=
|
|
262
|
-
{
|
|
263
|
-
|
|
264
|
-
{
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
312
|
+
<form onSubmit={handleSubmit} className={formClass}>
|
|
313
|
+
<div className={bodyClass}>
|
|
314
|
+
{groups.map((group) => (
|
|
315
|
+
<FieldSection key={group.key} group={group}>
|
|
316
|
+
{renderGrid(group.fields)}
|
|
317
|
+
</FieldSection>
|
|
318
|
+
))}
|
|
319
|
+
</div>
|
|
320
|
+
<div className={'flex justify-end gap-2 pt-2' + footerExtra}>
|
|
268
321
|
{onCancel && (
|
|
269
322
|
<Button type="button" variant="outline" onClick={onCancel} disabled={submitting || disabled}>
|
|
270
323
|
{cancelLabel}
|
|
@@ -428,15 +481,17 @@ function FieldRenderer({
|
|
|
428
481
|
onChange={onChange}
|
|
429
482
|
/>
|
|
430
483
|
)
|
|
431
|
-
case 'select':
|
|
484
|
+
case 'select': {
|
|
485
|
+
const opts = effectiveOptions ?? field.options
|
|
432
486
|
return (
|
|
433
|
-
<Select value={value || ''} onValueChange={onChange}>
|
|
487
|
+
<Select value={value || ''} onValueChange={guardEmptySelect(value, onChange, (opts ?? []).map((o) => o.value))}>
|
|
434
488
|
<SelectTrigger className="w-full"><SelectValue placeholder={field.placeholder || 'Seleccionar...'} /></SelectTrigger>
|
|
435
489
|
<SelectContent>
|
|
436
|
-
{
|
|
490
|
+
{opts?.map((opt) => <SelectItem key={opt.value} value={opt.value}>{opt.label}</SelectItem>)}
|
|
437
491
|
</SelectContent>
|
|
438
492
|
</Select>
|
|
439
493
|
)
|
|
494
|
+
}
|
|
440
495
|
case 'switch':
|
|
441
496
|
return <Switch id={field.key} checked={!!value} onCheckedChange={onChange} />
|
|
442
497
|
case 'number':
|
|
@@ -526,6 +581,25 @@ function ScannableInput({
|
|
|
526
581
|
)
|
|
527
582
|
}
|
|
528
583
|
|
|
584
|
+
/**
|
|
585
|
+
* Radix Select puede emitir `onValueChange('')` sin interacción del usuario (su
|
|
586
|
+
* <select> nativo oculto colapsa a '' si recibe el valor antes que sus
|
|
587
|
+
* <option>s, p.ej. dentro de un <form> en el primer render). Ese '' pisaría el
|
|
588
|
+
* valor real del registro y viajaría en el PATCH. Se ignora cuando ya hay un
|
|
589
|
+
* valor y '' no es una opción válida — el usuario nunca puede elegir '' así.
|
|
590
|
+
*/
|
|
591
|
+
export function guardEmptySelect(
|
|
592
|
+
current: unknown,
|
|
593
|
+
onChange: (v: any) => void,
|
|
594
|
+
optionValues: readonly unknown[],
|
|
595
|
+
): (v: string) => void {
|
|
596
|
+
return (v: string) => {
|
|
597
|
+
const hasValue = current !== undefined && current !== null && current !== ''
|
|
598
|
+
if (v === '' && hasValue && !optionValues.some((o) => String(o) === '')) return
|
|
599
|
+
onChange(v)
|
|
600
|
+
}
|
|
601
|
+
}
|
|
602
|
+
|
|
529
603
|
function RefSelect({ field, value, onChange }: FieldRendererProps) {
|
|
530
604
|
const { options, loading } = useOptionsResolver({
|
|
531
605
|
modelKey: '', // unused — `ref` drives the URL
|
|
@@ -535,7 +609,11 @@ function RefSelect({ field, value, onChange }: FieldRendererProps) {
|
|
|
535
609
|
keepValue: value,
|
|
536
610
|
})
|
|
537
611
|
return (
|
|
538
|
-
<Select
|
|
612
|
+
<Select
|
|
613
|
+
value={value || ''}
|
|
614
|
+
onValueChange={guardEmptySelect(value, onChange, options.map((o: ResolvedOption) => String(o.id)))}
|
|
615
|
+
disabled={loading}
|
|
616
|
+
>
|
|
539
617
|
<SelectTrigger className="w-full">
|
|
540
618
|
<SelectValue placeholder={loading ? 'Cargando…' : (field.placeholder || 'Seleccionar...')} />
|
|
541
619
|
</SelectTrigger>
|
package/src/dynamic-kanban.tsx
CHANGED
|
@@ -165,6 +165,7 @@ import {
|
|
|
165
165
|
aggregateOf,
|
|
166
166
|
formatAggregateTotal,
|
|
167
167
|
isRowActionVisible,
|
|
168
|
+
lifecycleStageField,
|
|
168
169
|
relationKeyFor,
|
|
169
170
|
RowActionMenuItem,
|
|
170
171
|
} from './dynamic-columns'
|
|
@@ -424,18 +425,39 @@ export function isCardOverdue(card: any, col: ColumnDefinition, now: number = Da
|
|
|
424
425
|
* The stage machine (stages, transitions, smart lanes) belongs to the model's
|
|
425
426
|
* stage column (`stage_field`; older hosts that do not serve it: the served
|
|
426
427
|
* group_by): grouping by it keeps the machine, grouping by anything else drops
|
|
427
|
-
* it and the lanes come from the new column.
|
|
428
|
-
*
|
|
429
|
-
*
|
|
428
|
+
* it and the lanes come from the new column.
|
|
429
|
+
*
|
|
430
|
+
* The served group_by is the LAST kanban nav entry's, which need not be the
|
|
431
|
+
* stage column: a board opened without `?group_by=` (the list's view toggle)
|
|
432
|
+
* groups by the stage column, and a board grouped by the served column keeps
|
|
433
|
+
* the machine only when that column IS the stage column. Otherwise the stage
|
|
434
|
+
* lanes were painted over another column (by bay: every card under «Sin etapa»
|
|
435
|
+
* and a drop wrote `service_bay_id = 'diagnosis'`, 422). Returns the SAME
|
|
436
|
+
* object when there is nothing to change. Pure — exported for unit tests.
|
|
430
437
|
*/
|
|
431
438
|
export function withGroupBy(
|
|
432
439
|
metadata: TableMetadata | null,
|
|
433
440
|
groupBy: string | undefined,
|
|
434
441
|
): TableMetadata | null {
|
|
435
|
-
if (!metadata
|
|
442
|
+
if (!metadata) return metadata
|
|
443
|
+
const hasMachine = (metadata.stages?.length ?? 0) > 0
|
|
444
|
+
const stageColumn = metadata.stage_field ?? (hasMachine ? metadata.group_by : undefined)
|
|
445
|
+
if (!groupBy) {
|
|
446
|
+
if (!hasMachine || !stageColumn || stageColumn === metadata.group_by) return metadata
|
|
447
|
+
return { ...metadata, group_by: stageColumn }
|
|
448
|
+
}
|
|
449
|
+
if (groupBy === stageColumn) {
|
|
450
|
+
return groupBy === metadata.group_by ? metadata : { ...metadata, group_by: groupBy }
|
|
451
|
+
}
|
|
436
452
|
if (!metadata.columns.some((c) => c.key === groupBy)) return metadata
|
|
437
|
-
|
|
438
|
-
|
|
453
|
+
if (
|
|
454
|
+
groupBy === metadata.group_by &&
|
|
455
|
+
!hasMachine &&
|
|
456
|
+
!metadata.transitions &&
|
|
457
|
+
!metadata.smart_lanes
|
|
458
|
+
) {
|
|
459
|
+
return metadata
|
|
460
|
+
}
|
|
439
461
|
return {
|
|
440
462
|
...metadata,
|
|
441
463
|
group_by: groupBy,
|
|
@@ -2119,6 +2141,7 @@ function DynamicKanbanBoard({
|
|
|
2119
2141
|
fieldCols={fieldCols}
|
|
2120
2142
|
showAllFields={explicitCard}
|
|
2121
2143
|
actions={rowActions}
|
|
2144
|
+
stageField={lifecycleStageField(metadata)}
|
|
2122
2145
|
locale={i18n.language}
|
|
2123
2146
|
timeZone={timeZone}
|
|
2124
2147
|
currency={currency}
|
|
@@ -2151,6 +2174,7 @@ function DynamicKanbanBoard({
|
|
|
2151
2174
|
fieldCols={fieldCols}
|
|
2152
2175
|
showAllFields={explicitCard}
|
|
2153
2176
|
actions={rowActions}
|
|
2177
|
+
stageField={lifecycleStageField(metadata)}
|
|
2154
2178
|
locale={i18n.language}
|
|
2155
2179
|
timeZone={timeZone}
|
|
2156
2180
|
currency={currency}
|
|
@@ -3183,6 +3207,8 @@ interface KanbanCardProps {
|
|
|
3183
3207
|
/** The model laid its card out explicitly: show every field with a value. */
|
|
3184
3208
|
showAllFields?: boolean
|
|
3185
3209
|
actions: ActionDefinition[]
|
|
3210
|
+
/** Column the actions' requiresState reads (the model's stage_field). */
|
|
3211
|
+
stageField?: string
|
|
3186
3212
|
locale: string
|
|
3187
3213
|
timeZone?: string
|
|
3188
3214
|
currency?: string
|
|
@@ -3260,6 +3286,7 @@ function KanbanCard({
|
|
|
3260
3286
|
fieldCols,
|
|
3261
3287
|
showAllFields,
|
|
3262
3288
|
actions,
|
|
3289
|
+
stageField,
|
|
3263
3290
|
locale,
|
|
3264
3291
|
timeZone,
|
|
3265
3292
|
currency,
|
|
@@ -3274,7 +3301,7 @@ function KanbanCard({
|
|
|
3274
3301
|
disabled: !draggable,
|
|
3275
3302
|
})
|
|
3276
3303
|
|
|
3277
|
-
const visibleActions = actions.filter((a) => isRowActionVisible(a, card))
|
|
3304
|
+
const visibleActions = actions.filter((a) => isRowActionVisible(a, card, stageField))
|
|
3278
3305
|
|
|
3279
3306
|
const menu =
|
|
3280
3307
|
visibleActions.length > 0 ? (
|
|
@@ -175,11 +175,20 @@ export function deriveRelationFormFields(
|
|
|
175
175
|
// than a raw uuid text input. Without this the column lost its `ref`
|
|
176
176
|
// crossing the column→field boundary and degraded to plain text.
|
|
177
177
|
ref: col.ref,
|
|
178
|
+
// Columnas de solo lectura (escritas por el servidor/sistema): el
|
|
179
|
+
// DynamicForm las oculta y no las manda en el submit. Sin copiarla,
|
|
180
|
+
// el renglón las exponía editables y el PATCH las pisaba.
|
|
181
|
+
...(isReadonlyColumn(col) ? { readonly: true } : {}),
|
|
178
182
|
})
|
|
179
183
|
}
|
|
180
184
|
return out
|
|
181
185
|
}
|
|
182
186
|
|
|
187
|
+
function isReadonlyColumn(col: ColumnDefinition): boolean {
|
|
188
|
+
const c = col as ColumnDefinition & { readonly?: boolean; readOnly?: boolean; read_only?: boolean }
|
|
189
|
+
return c.readonly === true || c.readOnly === true || c.read_only === true
|
|
190
|
+
}
|
|
191
|
+
|
|
183
192
|
function columnTypeToFieldType(col: ColumnDefinition): string {
|
|
184
193
|
switch (col.type) {
|
|
185
194
|
case 'number': return 'number'
|
package/src/dynamic-relation.tsx
CHANGED
|
@@ -636,11 +636,16 @@ function OneToManyRelation({
|
|
|
636
636
|
)}
|
|
637
637
|
|
|
638
638
|
<Dialog open={formOpen} onOpenChange={(open: boolean) => { setFormOpen(open); if (!open) setEditingRow(null) }}>
|
|
639
|
-
|
|
640
|
-
|
|
639
|
+
{/* Alto acotado al viewport: el cuerpo del form hace scroll y el
|
|
640
|
+
footer (Guardar) queda fijo, para que un renglón con muchos
|
|
641
|
+
campos no deje el botón fuera de pantalla. */}
|
|
642
|
+
<DialogContent className="flex max-h-[90dvh] flex-col overflow-hidden">
|
|
643
|
+
<DialogHeader className="shrink-0">
|
|
641
644
|
<DialogTitle>{editingRow ? labels.editLabel : labels.addLabel}</DialogTitle>
|
|
642
645
|
</DialogHeader>
|
|
643
646
|
<DynamicForm
|
|
647
|
+
key={editingRow ? `edit-${editingRow.id ?? ''}` : 'new'}
|
|
648
|
+
scrollableBody
|
|
644
649
|
fields={formFields}
|
|
645
650
|
initialValues={editingRow || undefined}
|
|
646
651
|
onSubmit={handleSubmit}
|
package/src/index.ts
CHANGED
|
@@ -206,6 +206,7 @@ export {
|
|
|
206
206
|
export {
|
|
207
207
|
DocumentFormDialog,
|
|
208
208
|
resolveDocumentForms,
|
|
209
|
+
scopeDocumentFormsToFilter,
|
|
209
210
|
type DocumentFormDialogProps,
|
|
210
211
|
} from './document-form-dialog'
|
|
211
212
|
export {
|
|
@@ -359,6 +360,10 @@ export {
|
|
|
359
360
|
} from './sidebar-layout-manager'
|
|
360
361
|
export * from './org-runtime-context'
|
|
361
362
|
export * from './business'
|
|
363
|
+
// Primitivos de negocio (PaymentAllocator, AgingTable, ApprovalInbox,
|
|
364
|
+
// contribuciones/modales federados, tipos de documento y contratos de paneles).
|
|
365
|
+
// Ver primitives/index.ts para el mapa completo.
|
|
366
|
+
export * from './primitives'
|
|
362
367
|
export * from './org-runtime-provider'
|
|
363
368
|
export * from './navigation-builder'
|
|
364
369
|
export * from './i18n-provider'
|
|
@@ -422,7 +427,7 @@ export {
|
|
|
422
427
|
resolveRelationLabel,
|
|
423
428
|
type DynamicColumnsHelpers,
|
|
424
429
|
} from './dynamic-columns'
|
|
425
|
-
export { humanizeToken, translateMetadataLabel } from './dynamic-columns-helpers'
|
|
430
|
+
export { humanizeToken, localizeFieldLabel, translateMetadataLabel } from './dynamic-columns-helpers'
|
|
426
431
|
export type { MetadataTranslator } from './dynamic-columns-helpers'
|
|
427
432
|
export {
|
|
428
433
|
UrlChip,
|
|
@@ -674,3 +679,18 @@ export * from './document'
|
|
|
674
679
|
// (`option_filter` / `optionFilter` on the field).
|
|
675
680
|
export { getOptionFilter, applyOptionFilter, optionPassesRule } from './option-filter'
|
|
676
681
|
export type { OptionFilter, OptionFilterRule } from './option-filter'
|
|
682
|
+
|
|
683
|
+
// Addons instalados / capacidades provistas — primitivo único de «¿qué hay
|
|
684
|
+
// instalado?» para hosts y remotes federados. Sin provider los hooks devuelven
|
|
685
|
+
// `undefined` (desconocido): el consumidor NUNCA debe asumir ausente.
|
|
686
|
+
export {
|
|
687
|
+
InstalledAddonsProvider,
|
|
688
|
+
useAddonInstalled,
|
|
689
|
+
useCapabilityProvided,
|
|
690
|
+
useInstalledAddons,
|
|
691
|
+
type InstalledAddonsValue,
|
|
692
|
+
} from './installed-addons-context'
|
|
693
|
+
|
|
694
|
+
// Ayudantes de captura (registerRecordPrefill de @asteby/metacore-sdk) arriba
|
|
695
|
+
// del formulario genérico de un modelo; DynamicRecordDialog ya lo monta.
|
|
696
|
+
export { RecordPrefillBar, type RecordPrefillBarProps } from './record-prefill-bar'
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
// InstalledAddonsProvider / useAddonInstalled / useCapabilityProvided —
|
|
2
|
+
// primitivo ÚNICO de "¿qué hay instalado?" para hosts y addons.
|
|
3
|
+
//
|
|
4
|
+
// Hoy cada addon lo resuelve a su manera: POS lee `installedAddons` del host
|
|
5
|
+
// (lib/host.ts addonKnownMissing), sondea `/options/<Model>` (use-model-available:
|
|
6
|
+
// 403/404 = ausente) o hardcodea claves (caja-policy useAnyFiscalAddonInstalled);
|
|
7
|
+
// `useInstalledAddons` vive sólo en @asteby/metacore-marketplace. Esto lo sube a
|
|
8
|
+
// runtime-react con dos niveles:
|
|
9
|
+
// - addon key (useAddonInstalled('fiscal_mexico'))
|
|
10
|
+
// - capability (useCapabilityProvided('fiscal.invoice_issuer')) — contrato
|
|
11
|
+
// independiente del país/vertical: fiscal_mexico, fiscal_colombia… lo proveen
|
|
12
|
+
// vía manifest `provides_capabilities`; caja pregunta por la capacidad,
|
|
13
|
+
// no por la clave del addon.
|
|
14
|
+
// Fuente: el host monta el provider con lo que ya trae su bootstrap
|
|
15
|
+
// (installed addons + provides_capabilities del manifest). Sin provider:
|
|
16
|
+
// `undefined` = desconocido (el consumidor decide; NUNCA asume ausente).
|
|
17
|
+
import { createContext, useContext, useMemo, type ReactNode } from 'react'
|
|
18
|
+
|
|
19
|
+
export interface InstalledAddonsValue {
|
|
20
|
+
addons: ReadonlySet<string>
|
|
21
|
+
capabilities: ReadonlySet<string>
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const Ctx = createContext<InstalledAddonsValue | null>(null)
|
|
25
|
+
|
|
26
|
+
export function InstalledAddonsProvider({
|
|
27
|
+
addons,
|
|
28
|
+
capabilities = [],
|
|
29
|
+
children,
|
|
30
|
+
}: {
|
|
31
|
+
addons: readonly string[]
|
|
32
|
+
capabilities?: readonly string[]
|
|
33
|
+
children: ReactNode
|
|
34
|
+
}) {
|
|
35
|
+
const aKey = [...addons].sort().join(',')
|
|
36
|
+
const cKey = [...capabilities].sort().join(',')
|
|
37
|
+
const value = useMemo<InstalledAddonsValue>(
|
|
38
|
+
() => ({ addons: new Set(addons), capabilities: new Set(capabilities) }),
|
|
39
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
40
|
+
[aKey, cKey],
|
|
41
|
+
)
|
|
42
|
+
return <Ctx.Provider value={value}>{children}</Ctx.Provider>
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** true/false si el host lo sabe; undefined si no hay provider. */
|
|
46
|
+
export function useAddonInstalled(key: string): boolean | undefined {
|
|
47
|
+
const v = useContext(Ctx)
|
|
48
|
+
return v ? v.addons.has(key) : undefined
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function useCapabilityProvided(capability: string): boolean | undefined {
|
|
52
|
+
const v = useContext(Ctx)
|
|
53
|
+
return v ? v.capabilities.has(capability) : undefined
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function useInstalledAddons(): InstalledAddonsValue | null {
|
|
57
|
+
return useContext(Ctx)
|
|
58
|
+
}
|
|
@@ -26,3 +26,13 @@ export const CurrencyContext = createContext<string | undefined>(undefined)
|
|
|
26
26
|
|
|
27
27
|
/** Reads the nearest org currency (undefined outside a provider). */
|
|
28
28
|
export const useCurrency = () => useContext(CurrencyContext)
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Org default tax rate as a fraction (e.g. `0.16`), from the org's fiscal
|
|
32
|
+
* config. Document line editors use it when the picked product carries no rate
|
|
33
|
+
* of its own. `undefined` outside a provider → lines start at 0.
|
|
34
|
+
*/
|
|
35
|
+
export const TaxRateContext = createContext<number | undefined>(undefined)
|
|
36
|
+
|
|
37
|
+
/** Reads the nearest org default tax rate (undefined outside a provider). */
|
|
38
|
+
export const useOrgTaxRate = () => useContext(TaxRateContext)
|
|
@@ -12,13 +12,15 @@ import {
|
|
|
12
12
|
identityImageUrl,
|
|
13
13
|
type GetImageUrl,
|
|
14
14
|
} from './image-url-context'
|
|
15
|
-
import { CurrencyContext, TimeZoneContext } from './org-runtime-context'
|
|
15
|
+
import { CurrencyContext, TaxRateContext, TimeZoneContext } from './org-runtime-context'
|
|
16
16
|
|
|
17
17
|
export interface OrgRuntimeProviderProps {
|
|
18
18
|
/** Org IANA timezone (e.g. `America/Mexico_City`). */
|
|
19
19
|
timeZone?: string
|
|
20
20
|
/** Org ISO-4217 currency (e.g. `MXN`). */
|
|
21
21
|
currency?: string
|
|
22
|
+
/** Org default tax rate as a fraction (e.g. `0.16`) for document lines. */
|
|
23
|
+
taxRate?: number
|
|
22
24
|
/** Resolver turning a stored path into a fetchable URL. Defaults to identity. */
|
|
23
25
|
getImageUrl?: GetImageUrl
|
|
24
26
|
children: ReactNode
|
|
@@ -27,6 +29,7 @@ export interface OrgRuntimeProviderProps {
|
|
|
27
29
|
export function OrgRuntimeProvider({
|
|
28
30
|
timeZone,
|
|
29
31
|
currency,
|
|
32
|
+
taxRate,
|
|
30
33
|
getImageUrl = identityImageUrl,
|
|
31
34
|
children,
|
|
32
35
|
}: OrgRuntimeProviderProps) {
|
|
@@ -34,7 +37,9 @@ export function OrgRuntimeProvider({
|
|
|
34
37
|
<ImageUrlContext.Provider value={getImageUrl}>
|
|
35
38
|
<TimeZoneContext.Provider value={timeZone}>
|
|
36
39
|
<CurrencyContext.Provider value={currency}>
|
|
37
|
-
{
|
|
40
|
+
<TaxRateContext.Provider value={taxRate}>
|
|
41
|
+
{children}
|
|
42
|
+
</TaxRateContext.Provider>
|
|
38
43
|
</CurrencyContext.Provider>
|
|
39
44
|
</TimeZoneContext.Provider>
|
|
40
45
|
</ImageUrlContext.Provider>
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
// AgingTable — antigüedad de saldos (CxC y CxP) como función pura.
|
|
2
|
+
//
|
|
3
|
+
// customers ya guarda aging_1_30 … aging_90_plus por cliente (refresh_credit_file).
|
|
4
|
+
// Esta función es la MISMA regla para la UI (drill-down por documento, CxP que
|
|
5
|
+
// hoy no la tiene) y para reportes: un solo lugar decide qué es «corriente».
|
|
6
|
+
import { roundMoney } from '../business/format'
|
|
7
|
+
|
|
8
|
+
export interface AgingBucket {
|
|
9
|
+
key: string
|
|
10
|
+
label: string
|
|
11
|
+
/** Días vencidos desde (inclusive). `current` usa -Infinity..0. */
|
|
12
|
+
from: number
|
|
13
|
+
/** Días vencidos hasta (inclusive). `null` = sin tope. */
|
|
14
|
+
to: number | null
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export const DEFAULT_AGING_BUCKETS: AgingBucket[] = [
|
|
18
|
+
{ key: 'current', label: 'Corriente', from: Number.NEGATIVE_INFINITY, to: 0 },
|
|
19
|
+
{ key: 'd1_30', label: '1–30', from: 1, to: 30 },
|
|
20
|
+
{ key: 'd31_60', label: '31–60', from: 31, to: 60 },
|
|
21
|
+
{ key: 'd61_90', label: '61–90', from: 61, to: 90 },
|
|
22
|
+
{ key: 'd90_plus', label: '90+', from: 91, to: null },
|
|
23
|
+
]
|
|
24
|
+
|
|
25
|
+
export interface AgingDoc {
|
|
26
|
+
party_id: string
|
|
27
|
+
party_name?: string
|
|
28
|
+
due_at: string | null // ISO date; null = vence al emitir
|
|
29
|
+
issued_at: string
|
|
30
|
+
balance: number
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface AgingRow {
|
|
34
|
+
party_id: string
|
|
35
|
+
party_name?: string
|
|
36
|
+
buckets: Record<string, number>
|
|
37
|
+
total: number
|
|
38
|
+
overdue: number
|
|
39
|
+
oldest_overdue_days: number
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const DAY = 86_400_000
|
|
43
|
+
|
|
44
|
+
/** Días vencidos al corte (negativo = falta para vencer). Fechas en ISO (día). */
|
|
45
|
+
export function daysOverdue(dueISO: string, asOfISO: string): number {
|
|
46
|
+
const due = Date.parse(dueISO.slice(0, 10) + 'T00:00:00Z')
|
|
47
|
+
const asOf = Date.parse(asOfISO.slice(0, 10) + 'T00:00:00Z')
|
|
48
|
+
return Math.round((asOf - due) / DAY)
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function bucketFor(days: number, buckets: readonly AgingBucket[] = DEFAULT_AGING_BUCKETS): string {
|
|
52
|
+
for (const b of buckets) if (days >= b.from && (b.to === null || days <= b.to)) return b.key
|
|
53
|
+
return buckets[buckets.length - 1].key
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Agrupa por contraparte y tramo. Saldos ≤ 0 se ignoran (anticipos van aparte). */
|
|
57
|
+
export function computeAging(docs: readonly AgingDoc[], asOfISO: string, buckets: readonly AgingBucket[] = DEFAULT_AGING_BUCKETS): { rows: AgingRow[]; totals: Record<string, number>; total: number } {
|
|
58
|
+
const rows = new Map<string, AgingRow>()
|
|
59
|
+
const totals: Record<string, number> = Object.fromEntries(buckets.map(b => [b.key, 0]))
|
|
60
|
+
for (const d of docs) {
|
|
61
|
+
if (d.balance <= 0) continue
|
|
62
|
+
const days = daysOverdue(d.due_at ?? d.issued_at, asOfISO)
|
|
63
|
+
const key = bucketFor(days, buckets)
|
|
64
|
+
const row = rows.get(d.party_id) ?? { party_id: d.party_id, party_name: d.party_name, buckets: Object.fromEntries(buckets.map(b => [b.key, 0])), total: 0, overdue: 0, oldest_overdue_days: 0 }
|
|
65
|
+
row.buckets[key] = roundMoney(row.buckets[key] + d.balance)
|
|
66
|
+
row.total = roundMoney(row.total + d.balance)
|
|
67
|
+
if (days > 0) {
|
|
68
|
+
row.overdue = roundMoney(row.overdue + d.balance)
|
|
69
|
+
row.oldest_overdue_days = Math.max(row.oldest_overdue_days, days)
|
|
70
|
+
}
|
|
71
|
+
totals[key] = roundMoney(totals[key] + d.balance)
|
|
72
|
+
rows.set(d.party_id, row)
|
|
73
|
+
}
|
|
74
|
+
const list = [...rows.values()].sort((a, b) => b.overdue - a.overdue || b.total - a.total)
|
|
75
|
+
return { rows: list, totals, total: roundMoney(list.reduce((s, r) => s + r.total, 0)) }
|
|
76
|
+
}
|