@asteby/metacore-runtime-react 46.4.0 → 47.1.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 +38 -0
- package/dist/action-modal-dispatcher.d.ts +10 -1
- package/dist/action-modal-dispatcher.d.ts.map +1 -1
- package/dist/action-modal-dispatcher.js +107 -19
- package/dist/audit-info.d.ts +54 -0
- package/dist/audit-info.d.ts.map +1 -0
- package/dist/audit-info.js +142 -0
- package/dist/business/catalog.d.ts.map +1 -1
- package/dist/business/catalog.js +24 -0
- package/dist/business/index.d.ts +3 -0
- package/dist/business/index.d.ts.map +1 -1
- package/dist/business/index.js +3 -0
- package/dist/business/print-send-dialog.d.ts +27 -0
- package/dist/business/print-send-dialog.d.ts.map +1 -0
- package/dist/business/print-send-dialog.js +55 -0
- package/dist/business/relate-documents.d.ts +41 -0
- package/dist/business/relate-documents.d.ts.map +1 -0
- package/dist/business/relate-documents.js +42 -0
- package/dist/business/vehicle-picker.d.ts +36 -0
- package/dist/business/vehicle-picker.d.ts.map +1 -0
- package/dist/business/vehicle-picker.js +61 -0
- package/dist/cfdi-stamp-panel.d.ts +29 -0
- package/dist/cfdi-stamp-panel.d.ts.map +1 -0
- package/dist/cfdi-stamp-panel.js +53 -0
- package/dist/dialogs/dynamic-record.d.ts.map +1 -1
- package/dist/dialogs/dynamic-record.js +10 -1
- package/dist/document-form-dialog.d.ts +23 -0
- package/dist/document-form-dialog.d.ts.map +1 -0
- package/dist/document-form-dialog.js +196 -0
- 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 +4 -2
- package/dist/dynamic-form.d.ts.map +1 -1
- package/dist/dynamic-form.js +3 -0
- package/dist/dynamic-kanban.d.ts.map +1 -1
- package/dist/dynamic-kanban.js +11 -2
- package/dist/dynamic-line-items.d.ts.map +1 -1
- package/dist/dynamic-line-items.js +3 -0
- package/dist/dynamic-multi-select-field.d.ts.map +1 -1
- package/dist/dynamic-multi-select-field.js +2 -0
- package/dist/dynamic-select-field.d.ts.map +1 -1
- package/dist/dynamic-select-field.js +4 -0
- package/dist/dynamic-table.d.ts.map +1 -1
- package/dist/dynamic-table.js +6 -2
- package/dist/index.d.ts +8 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +9 -0
- package/dist/nav-permissions.d.ts +19 -0
- package/dist/nav-permissions.d.ts.map +1 -0
- package/dist/nav-permissions.js +51 -0
- package/dist/navigation-builder.d.ts +6 -0
- package/dist/navigation-builder.d.ts.map +1 -1
- package/dist/navigation-builder.js +26 -1
- package/dist/option-filter.d.ts +31 -0
- package/dist/option-filter.d.ts.map +1 -0
- package/dist/option-filter.js +49 -0
- package/dist/permissions-manager.d.ts +9 -0
- package/dist/permissions-manager.d.ts.map +1 -1
- package/dist/permissions-manager.js +50 -17
- package/dist/print-document-button.d.ts.map +1 -1
- package/dist/print-document-button.js +9 -1
- package/dist/record-mutation-events.d.ts +14 -0
- package/dist/record-mutation-events.d.ts.map +1 -0
- package/dist/record-mutation-events.js +37 -0
- package/dist/types.d.ts +86 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/use-options-resolver.d.ts +8 -0
- package/dist/use-options-resolver.d.ts.map +1 -1
- package/dist/use-options-resolver.js +8 -2
- package/dist/use-print-document.d.ts +15 -1
- package/dist/use-print-document.d.ts.map +1 -1
- package/dist/use-print-document.js +52 -1
- package/dist/use-record-mutation-tick.d.ts +6 -0
- package/dist/use-record-mutation-tick.d.ts.map +1 -0
- package/dist/use-record-mutation-tick.js +11 -0
- package/package.json +3 -3
- package/src/__tests__/action-modal-feedback.test.tsx +116 -0
- package/src/__tests__/action-modal-grid-layout.test.tsx +1 -1
- package/src/__tests__/apply-sidebar-layout.test.ts +1 -1
- package/src/__tests__/audit-info.test.tsx +103 -0
- package/src/__tests__/business-pickers.test.tsx +157 -0
- package/src/__tests__/dependent-options.test.tsx +12 -5
- package/src/__tests__/document-form-dialog.test.tsx +190 -0
- package/src/__tests__/dynamic-table-infinite-scroll.test.tsx +4 -0
- package/src/__tests__/dynamic-table-mutation-refresh.test.tsx +116 -0
- package/src/__tests__/line-items-lock-rows.test.tsx +2 -1
- package/src/__tests__/nav-permissions.test.tsx +76 -0
- package/src/__tests__/option-filter.test.tsx +115 -0
- package/src/__tests__/permissions-manager.test.tsx +21 -4
- package/src/__tests__/structured-view-value.test.tsx +5 -1
- package/src/__tests__/use-print-document-feedback.test.tsx +58 -0
- package/src/__tests__/wizard-action-modal.test.tsx +1 -1
- package/src/action-modal-dispatcher.tsx +124 -6
- package/src/audit-info.tsx +219 -0
- package/src/business/catalog.ts +24 -0
- package/src/business/index.ts +3 -0
- package/src/business/print-send-dialog.tsx +175 -0
- package/src/business/relate-documents.tsx +182 -0
- package/src/business/vehicle-picker.tsx +188 -0
- package/src/cfdi-stamp-panel.tsx +146 -0
- package/src/dialogs/dynamic-record.tsx +18 -0
- package/src/document-form-dialog.tsx +357 -0
- package/src/dropdown-menu-entry.test.ts +23 -0
- package/src/dynamic-crud-page.tsx +21 -2
- package/src/dynamic-form.tsx +3 -0
- package/src/dynamic-kanban.tsx +10 -4
- package/src/dynamic-line-items.tsx +3 -0
- package/src/dynamic-multi-select-field.tsx +2 -0
- package/src/dynamic-select-field.tsx +4 -0
- package/src/dynamic-table.tsx +6 -2
- package/src/index.ts +41 -0
- package/src/nav-permissions.ts +58 -0
- package/src/navigation-builder.tsx +27 -1
- package/src/option-filter.ts +88 -0
- package/src/permissions-manager.tsx +39 -26
- package/src/print-document-button.tsx +9 -1
- package/src/record-mutation-events.ts +50 -0
- package/src/types.ts +90 -0
- package/src/use-options-resolver.ts +19 -1
- package/src/use-print-document.ts +66 -3
- package/src/use-record-mutation-tick.ts +12 -0
- package/vitest.config.ts +1 -0
- package/vitest.setup.ts +7 -0
|
@@ -0,0 +1,357 @@
|
|
|
1
|
+
// DocumentFormDialog — alta guiada por tipo de documento (FAC-12, #1022).
|
|
2
|
+
//
|
|
3
|
+
// "Crear documento fiscal" era el mismo formulario genérico para factura, nota
|
|
4
|
+
// de crédito, REP, global y traslado: los campos los dictaba el modelo, no el
|
|
5
|
+
// tipo. Aquí el metadata (`document_forms`) o una prop declara los tipos; el
|
|
6
|
+
// diálogo muestra tarjetas de tipo y, al elegir uno, SUS campos y (si lo declara)
|
|
7
|
+
// el paso de renglones con el editor de renglones del SDK. Un solo tipo se salta
|
|
8
|
+
// el selector; sin manifest el alta genérica sigue igual (DynamicCRUDPage decide).
|
|
9
|
+
//
|
|
10
|
+
// El SDK no hardcodea catálogos SAT: campos, valores fijos y el valor del tipo
|
|
11
|
+
// vienen del manifest que sirve el kernel o el addon.
|
|
12
|
+
import { useEffect, useMemo, useState } from 'react'
|
|
13
|
+
import { useTranslation } from 'react-i18next'
|
|
14
|
+
import {
|
|
15
|
+
Button,
|
|
16
|
+
Dialog,
|
|
17
|
+
DialogContent,
|
|
18
|
+
DialogDescription,
|
|
19
|
+
DialogFooter,
|
|
20
|
+
DialogHeader,
|
|
21
|
+
DialogTitle,
|
|
22
|
+
} from '@asteby/metacore-ui/primitives'
|
|
23
|
+
import { Loader2 } from 'lucide-react'
|
|
24
|
+
import { toast } from 'sonner'
|
|
25
|
+
import { useApi } from './api-context'
|
|
26
|
+
import { DynamicIcon } from './dynamic-icon'
|
|
27
|
+
import { FieldCell, FieldGrid, FieldLabel } from './field-grid'
|
|
28
|
+
import { FormErrorBanner } from './business/feedback'
|
|
29
|
+
import {
|
|
30
|
+
DocumentLinesGrid,
|
|
31
|
+
type DocumentLinesGridProps,
|
|
32
|
+
type LineItemsColumn,
|
|
33
|
+
} from './business/line-items-editor'
|
|
34
|
+
import { serializeLineItems, validateLineItems, type LineItem } from './business/line-items'
|
|
35
|
+
import {
|
|
36
|
+
actionErrorBannerMessage,
|
|
37
|
+
buildFieldDefaults,
|
|
38
|
+
renderField,
|
|
39
|
+
} from './action-modal-dispatcher'
|
|
40
|
+
import { extractFieldErrors, localizeFieldErrorMap } from './server-error'
|
|
41
|
+
import { validateValues, bagHasErrors } from './validator'
|
|
42
|
+
import { clearFieldErrorTree } from './field-validation-ui'
|
|
43
|
+
import { emitRecordMutation } from './record-mutation-events'
|
|
44
|
+
import { isLineItemsField, resolveWidget } from './dynamic-form-schema'
|
|
45
|
+
import type {
|
|
46
|
+
ActionFieldDef,
|
|
47
|
+
DocumentFormLines,
|
|
48
|
+
DocumentFormsManifest,
|
|
49
|
+
DocumentFormType,
|
|
50
|
+
TableMetadata,
|
|
51
|
+
} from './types'
|
|
52
|
+
|
|
53
|
+
export interface DocumentFormDialogProps {
|
|
54
|
+
open: boolean
|
|
55
|
+
onOpenChange: (open: boolean) => void
|
|
56
|
+
/** Modelo en el que se da de alta (p. ej. `fiscal_documents`). */
|
|
57
|
+
model: string
|
|
58
|
+
/** Endpoint de alta. Default `/dynamic/<model>`. */
|
|
59
|
+
endpoint?: string
|
|
60
|
+
forms: DocumentFormsManifest
|
|
61
|
+
/** Tipo preseleccionado (se salta el selector). */
|
|
62
|
+
initialType?: string
|
|
63
|
+
/** Se llama con el registro creado. */
|
|
64
|
+
onSaved?: (record?: unknown) => void
|
|
65
|
+
/** Buscador de productos para el editor de renglones (mismo contrato que ProductPicker). */
|
|
66
|
+
searchProducts?: DocumentLinesGridProps['search']
|
|
67
|
+
/** Moneda ISO para el editor de renglones (default: la de la org). */
|
|
68
|
+
currency?: string
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Manifest efectivo: la prop gana sobre el metadata; sin tipos devuelve undefined. */
|
|
72
|
+
export function resolveDocumentForms(
|
|
73
|
+
metadata?: Pick<TableMetadata, 'document_forms'> | null,
|
|
74
|
+
override?: DocumentFormsManifest | null,
|
|
75
|
+
): DocumentFormsManifest | undefined {
|
|
76
|
+
const m = override ?? metadata?.document_forms
|
|
77
|
+
return m && Array.isArray(m.types) && m.types.length > 0 ? m : undefined
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function linesConfig(type: DocumentFormType, forms: DocumentFormsManifest): (DocumentFormLines & { field: string }) | undefined {
|
|
81
|
+
if (!type.lines) return undefined
|
|
82
|
+
const cfg: DocumentFormLines = type.lines === true ? {} : type.lines
|
|
83
|
+
return { ...cfg, field: cfg.field ?? forms.lines_field ?? 'lines' }
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
type Step = 'type' | 'fields' | 'lines'
|
|
87
|
+
|
|
88
|
+
export function DocumentFormDialog({
|
|
89
|
+
open,
|
|
90
|
+
onOpenChange,
|
|
91
|
+
model,
|
|
92
|
+
endpoint,
|
|
93
|
+
forms,
|
|
94
|
+
initialType,
|
|
95
|
+
onSaved,
|
|
96
|
+
searchProducts,
|
|
97
|
+
currency,
|
|
98
|
+
}: DocumentFormDialogProps) {
|
|
99
|
+
const { t, i18n } = useTranslation()
|
|
100
|
+
const api = useApi()
|
|
101
|
+
const types = forms.types
|
|
102
|
+
const single = types.length === 1 ? types[0] : undefined
|
|
103
|
+
|
|
104
|
+
const [typeKey, setTypeKey] = useState<string | null>(initialType ?? single?.key ?? null)
|
|
105
|
+
const [step, setStep] = useState<Step>(initialType || single ? 'fields' : 'type')
|
|
106
|
+
const [formData, setFormData] = useState<Record<string, any>>({})
|
|
107
|
+
const [lines, setLines] = useState<LineItem[]>([])
|
|
108
|
+
const [fieldErrors, setFieldErrors] = useState<Record<string, string>>({})
|
|
109
|
+
const [serverLineErrors, setServerLineErrors] = useState<Record<string, string>>({})
|
|
110
|
+
const [formError, setFormError] = useState<string | undefined>()
|
|
111
|
+
const [saving, setSaving] = useState(false)
|
|
112
|
+
|
|
113
|
+
const type = useMemo(() => types.find((x) => x.key === typeKey) ?? null, [types, typeKey])
|
|
114
|
+
const lineCfg = type ? linesConfig(type, forms) : undefined
|
|
115
|
+
|
|
116
|
+
// Reset every time the dialog (re)opens.
|
|
117
|
+
useEffect(() => {
|
|
118
|
+
if (!open) return
|
|
119
|
+
const start = initialType ?? single?.key ?? null
|
|
120
|
+
setTypeKey(start)
|
|
121
|
+
setStep(start ? 'fields' : 'type')
|
|
122
|
+
setLines([])
|
|
123
|
+
setFieldErrors({})
|
|
124
|
+
setServerLineErrors({})
|
|
125
|
+
setFormError(undefined)
|
|
126
|
+
}, [open, initialType, single?.key])
|
|
127
|
+
|
|
128
|
+
// Seed field defaults for the chosen type.
|
|
129
|
+
useEffect(() => {
|
|
130
|
+
if (!type) return
|
|
131
|
+
setFormData({ ...buildFieldDefaults(type.fields, undefined), ...(type.defaults ?? {}) })
|
|
132
|
+
setFieldErrors({})
|
|
133
|
+
setFormError(undefined)
|
|
134
|
+
}, [type])
|
|
135
|
+
|
|
136
|
+
const updateField = (key: string, value: any) => {
|
|
137
|
+
setFormData((prev) => ({ ...prev, [key]: value }))
|
|
138
|
+
setFieldErrors((prev) => clearFieldErrorTree(prev, key))
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const tl = (s: string) => t(s, { defaultValue: s })
|
|
142
|
+
const lang = i18n?.language
|
|
143
|
+
|
|
144
|
+
const chooseType = (k: string) => {
|
|
145
|
+
setTypeKey(k)
|
|
146
|
+
setStep('fields')
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** Validate the header fields; returns true when they pass. */
|
|
150
|
+
const validateHeader = (): boolean => {
|
|
151
|
+
if (!type) return false
|
|
152
|
+
const bag = validateValues(type.fields, formData)
|
|
153
|
+
if (!bagHasErrors(bag)) {
|
|
154
|
+
setFieldErrors({})
|
|
155
|
+
return true
|
|
156
|
+
}
|
|
157
|
+
const labels: Record<string, string> = {}
|
|
158
|
+
for (const f of type.fields) labels[f.key] = tl(f.label)
|
|
159
|
+
setFieldErrors(localizeFieldErrorMap(bag, t, { labels, language: lang }))
|
|
160
|
+
return false
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const goNext = () => {
|
|
164
|
+
if (validateHeader()) setStep('lines')
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const submit = async () => {
|
|
168
|
+
if (!type) return
|
|
169
|
+
if (!validateHeader()) {
|
|
170
|
+
setStep('fields')
|
|
171
|
+
return
|
|
172
|
+
}
|
|
173
|
+
if (lineCfg && (lineCfg.required ?? true)) {
|
|
174
|
+
const v = validateLineItems(lines, { requireItems: true })
|
|
175
|
+
if (!v.form && Object.keys(v.errors).length === 0) {
|
|
176
|
+
/* ok */
|
|
177
|
+
} else {
|
|
178
|
+
setServerLineErrors(v.errors)
|
|
179
|
+
setFormError(v.form ?? t('documentForm.lines_invalid', { defaultValue: 'Revisa los renglones del documento.' }))
|
|
180
|
+
setStep('lines')
|
|
181
|
+
return
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
setSaving(true)
|
|
185
|
+
setFormError(undefined)
|
|
186
|
+
try {
|
|
187
|
+
const payload: Record<string, unknown> = { ...(type.defaults ?? {}), ...formData }
|
|
188
|
+
if (forms.type_field) payload[forms.type_field] = type.value ?? type.key
|
|
189
|
+
if (lineCfg) payload[lineCfg.field] = serializeLineItems(lines)
|
|
190
|
+
const url = type.endpoint ?? endpoint ?? `/dynamic/${model}`
|
|
191
|
+
const res = await api.post(url, payload)
|
|
192
|
+
if (res.data?.success === false) {
|
|
193
|
+
handleError({ response: { data: res.data } })
|
|
194
|
+
return
|
|
195
|
+
}
|
|
196
|
+
toast.success(t('dynamic.create_success', { defaultValue: 'Registro creado correctamente' }))
|
|
197
|
+
emitRecordMutation(model, 'create')
|
|
198
|
+
onSaved?.(res.data?.data ?? res.data ?? undefined)
|
|
199
|
+
onOpenChange(false)
|
|
200
|
+
} catch (err) {
|
|
201
|
+
handleError(err)
|
|
202
|
+
} finally {
|
|
203
|
+
setSaving(false)
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/** 422 → marks the fields and shows the banner INSIDE the dialog; stays open. */
|
|
208
|
+
const handleError = (err: unknown) => {
|
|
209
|
+
setFormError(actionErrorBannerMessage(err, type?.fields, t, lang))
|
|
210
|
+
const map = extractFieldErrors(err)
|
|
211
|
+
if (map) {
|
|
212
|
+
const labels: Record<string, string> = {}
|
|
213
|
+
for (const f of type?.fields ?? []) labels[f.key] = tl(f.label)
|
|
214
|
+
const localized = localizeFieldErrorMap(map, t, { labels, language: lang })
|
|
215
|
+
const header: Record<string, string> = {}
|
|
216
|
+
const lineErrs: Record<string, string> = {}
|
|
217
|
+
const prefix = lineCfg ? `${lineCfg.field}.` : null
|
|
218
|
+
for (const [k, v] of Object.entries(localized)) {
|
|
219
|
+
if (prefix && k.startsWith(prefix)) lineErrs[k.slice(prefix.length)] = v
|
|
220
|
+
else header[k] = v
|
|
221
|
+
}
|
|
222
|
+
setFieldErrors(header)
|
|
223
|
+
setServerLineErrors(lineErrs)
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
const canGoBackToTypes = types.length > 1 && !initialType
|
|
228
|
+
const stepLabel = (s: Step) =>
|
|
229
|
+
s === 'type'
|
|
230
|
+
? t('documentForm.step_type', { defaultValue: 'Tipo de documento' })
|
|
231
|
+
: s === 'fields'
|
|
232
|
+
? t('documentForm.step_data', { defaultValue: 'Datos' })
|
|
233
|
+
: tl(lineCfg?.title ?? 'Renglones')
|
|
234
|
+
|
|
235
|
+
return (
|
|
236
|
+
<Dialog open={open} onOpenChange={onOpenChange}>
|
|
237
|
+
<DialogContent
|
|
238
|
+
className="flex max-h-[90dvh] flex-col overflow-hidden sm:max-w-2xl"
|
|
239
|
+
style={{ maxHeight: '90dvh', ...(step === 'lines' ? { maxWidth: '980px', width: '95vw' } : {}) }}
|
|
240
|
+
data-slot="document-form-dialog"
|
|
241
|
+
>
|
|
242
|
+
<DialogHeader className="shrink-0">
|
|
243
|
+
<DialogTitle>
|
|
244
|
+
{type ? tl(type.label) : t('documentForm.title', { defaultValue: 'Nuevo documento' })}
|
|
245
|
+
</DialogTitle>
|
|
246
|
+
<DialogDescription>{stepLabel(step)}</DialogDescription>
|
|
247
|
+
</DialogHeader>
|
|
248
|
+
|
|
249
|
+
<div className="-mx-1 min-h-0 flex-1 space-y-3 overflow-y-auto px-1 py-3">
|
|
250
|
+
<FormErrorBanner message={formError} />
|
|
251
|
+
|
|
252
|
+
{step === 'type' && (
|
|
253
|
+
<div role="radiogroup" className="grid gap-3 sm:grid-cols-2">
|
|
254
|
+
{types.map((x) => (
|
|
255
|
+
<button
|
|
256
|
+
key={x.key}
|
|
257
|
+
type="button"
|
|
258
|
+
role="radio"
|
|
259
|
+
aria-checked={x.key === typeKey}
|
|
260
|
+
data-type-card={x.key}
|
|
261
|
+
onClick={() => chooseType(x.key)}
|
|
262
|
+
className="flex items-start gap-3 rounded-lg border p-4 text-left transition-colors hover:bg-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
|
263
|
+
>
|
|
264
|
+
{x.icon && <DynamicIcon name={x.icon} className="mt-0.5 size-5 shrink-0 text-primary" />}
|
|
265
|
+
<span>
|
|
266
|
+
<span className="block text-sm font-medium">{tl(x.label)}</span>
|
|
267
|
+
{x.description && (
|
|
268
|
+
<span className="block text-xs text-muted-foreground">{tl(x.description)}</span>
|
|
269
|
+
)}
|
|
270
|
+
</span>
|
|
271
|
+
</button>
|
|
272
|
+
))}
|
|
273
|
+
</div>
|
|
274
|
+
)}
|
|
275
|
+
|
|
276
|
+
{step === 'fields' && type && (
|
|
277
|
+
<FieldGrid>
|
|
278
|
+
{type.fields.map((field: ActionFieldDef) => {
|
|
279
|
+
const fullWidth =
|
|
280
|
+
isLineItemsField(field) ||
|
|
281
|
+
resolveWidget(field) === 'textarea' ||
|
|
282
|
+
resolveWidget(field) === 'richtext'
|
|
283
|
+
return (
|
|
284
|
+
<FieldCell key={field.key} fullWidth={fullWidth}>
|
|
285
|
+
<FieldLabel htmlFor={field.key} required={field.required}>
|
|
286
|
+
{tl(field.label)}
|
|
287
|
+
</FieldLabel>
|
|
288
|
+
{renderField(
|
|
289
|
+
field,
|
|
290
|
+
formData[field.key],
|
|
291
|
+
(v: any) => updateField(field.key, v),
|
|
292
|
+
formData,
|
|
293
|
+
undefined,
|
|
294
|
+
fieldErrors,
|
|
295
|
+
)}
|
|
296
|
+
{fieldErrors[field.key] && (
|
|
297
|
+
<p className="mt-1 text-xs text-destructive">{fieldErrors[field.key]}</p>
|
|
298
|
+
)}
|
|
299
|
+
</FieldCell>
|
|
300
|
+
)
|
|
301
|
+
})}
|
|
302
|
+
</FieldGrid>
|
|
303
|
+
)}
|
|
304
|
+
|
|
305
|
+
{step === 'lines' && type && lineCfg && (
|
|
306
|
+
<DocumentLinesGrid
|
|
307
|
+
value={lines}
|
|
308
|
+
onChange={(next) => {
|
|
309
|
+
setLines(next)
|
|
310
|
+
setServerLineErrors({})
|
|
311
|
+
}}
|
|
312
|
+
columns={(lineCfg.columns as LineItemsColumn[] | undefined) ?? ['discount', 'tax']}
|
|
313
|
+
priceSource={lineCfg.price_source ?? 'sale'}
|
|
314
|
+
serverErrors={serverLineErrors}
|
|
315
|
+
search={searchProducts}
|
|
316
|
+
currency={currency}
|
|
317
|
+
/>
|
|
318
|
+
)}
|
|
319
|
+
</div>
|
|
320
|
+
|
|
321
|
+
<DialogFooter className="shrink-0">
|
|
322
|
+
{step === 'type' ? (
|
|
323
|
+
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
|
|
324
|
+
{t('common.cancel', { defaultValue: 'Cancelar' })}
|
|
325
|
+
</Button>
|
|
326
|
+
) : (
|
|
327
|
+
<Button
|
|
328
|
+
type="button"
|
|
329
|
+
variant="outline"
|
|
330
|
+
disabled={saving}
|
|
331
|
+
onClick={() => {
|
|
332
|
+
if (step === 'lines') setStep('fields')
|
|
333
|
+
else if (canGoBackToTypes) setStep('type')
|
|
334
|
+
else onOpenChange(false)
|
|
335
|
+
}}
|
|
336
|
+
>
|
|
337
|
+
{step === 'fields' && !canGoBackToTypes
|
|
338
|
+
? t('common.cancel', { defaultValue: 'Cancelar' })
|
|
339
|
+
: t('common.back', { defaultValue: 'Atrás' })}
|
|
340
|
+
</Button>
|
|
341
|
+
)}
|
|
342
|
+
{step === 'fields' && type && lineCfg && (
|
|
343
|
+
<Button type="button" onClick={goNext}>
|
|
344
|
+
{t('common.next', { defaultValue: 'Siguiente' })}
|
|
345
|
+
</Button>
|
|
346
|
+
)}
|
|
347
|
+
{((step === 'fields' && type && !lineCfg) || step === 'lines') && (
|
|
348
|
+
<Button type="button" onClick={submit} disabled={saving}>
|
|
349
|
+
{saving && <Loader2 className="mr-2 size-4 animate-spin" aria-hidden />}
|
|
350
|
+
{type?.submit_label ? tl(type.submit_label) : t('common.create', { defaultValue: 'Crear' })}
|
|
351
|
+
</Button>
|
|
352
|
+
)}
|
|
353
|
+
</DialogFooter>
|
|
354
|
+
</DialogContent>
|
|
355
|
+
</Dialog>
|
|
356
|
+
)
|
|
357
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import { readFileSync, readdirSync } from 'node:fs'
|
|
3
|
+
import { join } from 'node:path'
|
|
4
|
+
|
|
5
|
+
// Radix context guard: a component that renders RowActionMenuItem (whose
|
|
6
|
+
// DropdownMenuItem is imported from the ROOT '@asteby/metacore-ui' entry) must
|
|
7
|
+
// import its DropdownMenu shell from the root entry too. Hosts share the root
|
|
8
|
+
// entry as a Module Federation singleton but bundle '/primitives' locally, so
|
|
9
|
+
// mixing entries creates two Menu contexts → "`MenuItem` must be used within
|
|
10
|
+
// `Menu`" at runtime (Pitsline kanban card menu crash, 2026-10-05).
|
|
11
|
+
describe('dropdown menu entry consistency', () => {
|
|
12
|
+
const dir = join(__dirname)
|
|
13
|
+
const files = readdirSync(dir).filter((f) => f.endsWith('.tsx'))
|
|
14
|
+
for (const f of files) {
|
|
15
|
+
const src = readFileSync(join(dir, f), 'utf8')
|
|
16
|
+
if (!src.includes('<RowActionMenuItem')) continue
|
|
17
|
+
it(`${f} imports DropdownMenu from the root ui entry`, () => {
|
|
18
|
+
const primitivesImport = src.match(/import\s*{([^}]*)}\s*from\s*'@asteby\/metacore-ui\/primitives'/)
|
|
19
|
+
const fromPrimitives = primitivesImport ? primitivesImport[1] : ''
|
|
20
|
+
expect(fromPrimitives).not.toMatch(/\bDropdownMenu(Content|Trigger)?\b/)
|
|
21
|
+
})
|
|
22
|
+
}
|
|
23
|
+
})
|
|
@@ -33,13 +33,14 @@ import { useApi } from './api-context'
|
|
|
33
33
|
import { useMetadataCache } from './metadata-cache'
|
|
34
34
|
import { DynamicTable } from './dynamic-table'
|
|
35
35
|
import { DynamicRecordDialog } from './dialogs/dynamic-record'
|
|
36
|
+
import { DocumentFormDialog, resolveDocumentForms } from './document-form-dialog'
|
|
36
37
|
import { ExportDialog } from './dialogs/export'
|
|
37
38
|
import { ImportDialog } from './dialogs/import'
|
|
38
39
|
import { getModelExtension } from './model-extension-registry'
|
|
39
40
|
import { ModelActionToolbar } from './model-action-toolbar'
|
|
40
41
|
import { resolveListPrimaryAction } from './list-primary-action'
|
|
41
42
|
import { useCan, modelCapability } from './permissions-context'
|
|
42
|
-
import type { TableMetadata } from './types'
|
|
43
|
+
import type { DocumentFormsManifest, TableMetadata } from './types'
|
|
43
44
|
|
|
44
45
|
export interface DynamicCRUDPageStrings {
|
|
45
46
|
refresh?: string
|
|
@@ -90,6 +91,11 @@ export interface DynamicCRUDPageProps {
|
|
|
90
91
|
classes?: DynamicCRUDPageClasses
|
|
91
92
|
/** Fired after a create/import/refresh successfully reloads the table. */
|
|
92
93
|
onChange?: () => void
|
|
94
|
+
/**
|
|
95
|
+
* Guided create by document type (FAC-12). Wins over `metadata.document_forms`;
|
|
96
|
+
* with neither, the generic record dialog is used as before.
|
|
97
|
+
*/
|
|
98
|
+
documentForms?: DocumentFormsManifest
|
|
93
99
|
}
|
|
94
100
|
|
|
95
101
|
/**
|
|
@@ -112,6 +118,7 @@ export function DynamicCRUDPage(props: DynamicCRUDPageProps) {
|
|
|
112
118
|
toolbarExtras,
|
|
113
119
|
classes,
|
|
114
120
|
onChange,
|
|
121
|
+
documentForms,
|
|
115
122
|
} = props
|
|
116
123
|
|
|
117
124
|
const strings = { ...defaultStrings, ...(i18n ?? {}) }
|
|
@@ -186,6 +193,7 @@ export function DynamicCRUDPage(props: DynamicCRUDPageProps) {
|
|
|
186
193
|
const showImport = enableCRUD && !effectiveHideImport && can(modelCapability(model, 'import'))
|
|
187
194
|
const showExport = !effectiveHideExport && can(modelCapability(model, 'export'))
|
|
188
195
|
const showRefresh = !effectiveHideRefresh
|
|
196
|
+
const guidedForms = resolveDocumentForms(metadata, documentForms)
|
|
189
197
|
|
|
190
198
|
const handleRefresh = useCallback(() => {
|
|
191
199
|
setRefreshKey((k) => k + 1)
|
|
@@ -274,7 +282,18 @@ export function DynamicCRUDPage(props: DynamicCRUDPageProps) {
|
|
|
274
282
|
</div>
|
|
275
283
|
</div>
|
|
276
284
|
|
|
277
|
-
{showCreate && (
|
|
285
|
+
{showCreate && guidedForms && (
|
|
286
|
+
<DocumentFormDialog
|
|
287
|
+
open={openCreate}
|
|
288
|
+
onOpenChange={setOpenCreate}
|
|
289
|
+
model={model}
|
|
290
|
+
endpoint={dataEndpoint}
|
|
291
|
+
forms={guidedForms}
|
|
292
|
+
onSaved={handleRefresh}
|
|
293
|
+
/>
|
|
294
|
+
)}
|
|
295
|
+
|
|
296
|
+
{showCreate && !guidedForms && (
|
|
278
297
|
<DynamicRecordDialog
|
|
279
298
|
open={openCreate}
|
|
280
299
|
onOpenChange={setOpenCreate}
|
package/src/dynamic-form.tsx
CHANGED
|
@@ -30,6 +30,7 @@ import {
|
|
|
30
30
|
} from './dynamic-form-schema'
|
|
31
31
|
import { ScanLine } from 'lucide-react'
|
|
32
32
|
import { BarcodeScanner } from './barcode-scanner'
|
|
33
|
+
import { getOptionFilter } from './option-filter'
|
|
33
34
|
import { useOptionsResolver, type ResolvedOption } from './use-options-resolver'
|
|
34
35
|
import { DynamicLineItems } from './dynamic-line-items'
|
|
35
36
|
import { DynamicSelectField } from './dynamic-select-field'
|
|
@@ -530,6 +531,8 @@ function RefSelect({ field, value, onChange }: FieldRendererProps) {
|
|
|
530
531
|
modelKey: '', // unused — `ref` drives the URL
|
|
531
532
|
fieldKey: 'id',
|
|
532
533
|
ref: field.ref,
|
|
534
|
+
optionFilter: getOptionFilter(field),
|
|
535
|
+
keepValue: value,
|
|
533
536
|
})
|
|
534
537
|
return (
|
|
535
538
|
<Select value={value || ''} onValueChange={onChange} disabled={loading}>
|
package/src/dynamic-kanban.tsx
CHANGED
|
@@ -94,9 +94,6 @@ import {
|
|
|
94
94
|
Button,
|
|
95
95
|
Card,
|
|
96
96
|
CardContent,
|
|
97
|
-
DropdownMenu,
|
|
98
|
-
DropdownMenuContent,
|
|
99
|
-
DropdownMenuTrigger,
|
|
100
97
|
Input,
|
|
101
98
|
Popover,
|
|
102
99
|
PopoverContent,
|
|
@@ -113,6 +110,13 @@ import {
|
|
|
113
110
|
SheetTrigger,
|
|
114
111
|
Skeleton,
|
|
115
112
|
} from '@asteby/metacore-ui/primitives'
|
|
113
|
+
// The card "…" menu renders RowActionMenuItem (dynamic-columns), whose
|
|
114
|
+
// DropdownMenuItem comes from the ROOT '@asteby/metacore-ui' entry. The menu
|
|
115
|
+
// shell MUST come from that same entry: hosts share the root entry as a Module
|
|
116
|
+
// Federation singleton while '/primitives' is bundled locally, so mixing them
|
|
117
|
+
// yields two Radix Menu contexts and the click crashes the app with
|
|
118
|
+
// "`MenuItem` must be used within `Menu`" (Pitsline taller kanban, 2026-10-05).
|
|
119
|
+
import { DropdownMenu, DropdownMenuContent, DropdownMenuTrigger } from '@asteby/metacore-ui'
|
|
116
120
|
import { ColumnFilterControl, FilterValueCombobox, type ColumnFilterType } from '@asteby/metacore-ui/data-table'
|
|
117
121
|
import { generateBadgeStyles, optionColor } from '@asteby/metacore-ui/lib'
|
|
118
122
|
import { useApi } from './api-context'
|
|
@@ -152,6 +156,7 @@ import { useFlipAnimation } from './use-flip-animation'
|
|
|
152
156
|
import { PortalDragOverlay } from './portal-drag-overlay'
|
|
153
157
|
import { objectLabel } from './dynamic-relation-helpers'
|
|
154
158
|
import { useMetadataCache } from './metadata-cache'
|
|
159
|
+
import { useRecordMutationTick } from './use-record-mutation-tick'
|
|
155
160
|
import { useOptionsResolver } from './use-options-resolver'
|
|
156
161
|
import { ActivityValueRenderer } from './activity-value-renderer'
|
|
157
162
|
import { DynamicIcon } from './dynamic-icon'
|
|
@@ -923,6 +928,7 @@ function DynamicKanbanBoard({
|
|
|
923
928
|
// Realtime refetch (opt-in) — debounced counter bumped by DATA_EVENTs for
|
|
924
929
|
// this model; folded into the board refetch effect next to refreshTrigger.
|
|
925
930
|
const realtimeDefault = useRealtimeDefault()
|
|
931
|
+
const mutationTick = useRecordMutationTick(model)
|
|
926
932
|
const realtimeTick = useRealtimeTick({
|
|
927
933
|
models: [model],
|
|
928
934
|
enabled: realtimeProp ?? realtimeDefault,
|
|
@@ -1339,7 +1345,7 @@ function DynamicKanbanBoard({
|
|
|
1339
1345
|
return () => clearTimeout(handle)
|
|
1340
1346
|
// realtimeTick: data events for this model (see the `realtime` prop).
|
|
1341
1347
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
1342
|
-
}, [fetchData, metadata, refreshTrigger, realtimeTick])
|
|
1348
|
+
}, [fetchData, metadata, refreshTrigger, realtimeTick, mutationTick])
|
|
1343
1349
|
|
|
1344
1350
|
// Filterable fields for the toolbar, in metadata order (explicit filters
|
|
1345
1351
|
// first, then filterable columns), each labeled from its metadata source.
|
|
@@ -36,6 +36,7 @@ import {
|
|
|
36
36
|
applyOptionWhen,
|
|
37
37
|
} from './dynamic-form-schema'
|
|
38
38
|
import { DynamicSelectField, DEFAULT_DEPENDS_HINT } from './dynamic-select-field'
|
|
39
|
+
import { getOptionFilter } from './option-filter'
|
|
39
40
|
import { useOptionsResolver, type ResolvedOption } from './use-options-resolver'
|
|
40
41
|
|
|
41
42
|
export interface DynamicLineItemsProps {
|
|
@@ -575,6 +576,8 @@ function RefCell({ field, value, onChange, onPick, disabled, formValues, rowValu
|
|
|
575
576
|
ref: optSource.ref,
|
|
576
577
|
endpoint: optSource.endpoint,
|
|
577
578
|
filterValue: dependsOn ? scope : undefined,
|
|
579
|
+
optionFilter: getOptionFilter(field),
|
|
580
|
+
keepValue: value,
|
|
578
581
|
enabled: !blockedByDependency,
|
|
579
582
|
})
|
|
580
583
|
|
|
@@ -19,6 +19,7 @@
|
|
|
19
19
|
// value instead.
|
|
20
20
|
import { useMemo } from 'react'
|
|
21
21
|
import { MultiSelect } from '@asteby/metacore-ui/primitives'
|
|
22
|
+
import { getOptionFilter } from './option-filter'
|
|
22
23
|
import { useOptionsResolver } from './use-options-resolver'
|
|
23
24
|
import { getFieldRef } from './dynamic-form-schema'
|
|
24
25
|
import type { ActionFieldDef } from './types'
|
|
@@ -38,6 +39,7 @@ export function DynamicMultiSelectField({ field, value, onChange }: DynamicMulti
|
|
|
38
39
|
ref,
|
|
39
40
|
endpoint: !ref && field.searchEndpoint ? field.searchEndpoint : undefined,
|
|
40
41
|
limit: 200,
|
|
42
|
+
optionFilter: getOptionFilter(field),
|
|
41
43
|
})
|
|
42
44
|
|
|
43
45
|
const selected = useMemo(() => (Array.isArray(value) ? value.map(String) : []), [value])
|
|
@@ -22,6 +22,7 @@
|
|
|
22
22
|
// value). A dedicated `?ids=` lookup is a follow-up; create flows — the common
|
|
23
23
|
// case — start empty and never hit this.
|
|
24
24
|
import { useEffect, useRef, useState } from 'react'
|
|
25
|
+
import { getOptionFilter } from './option-filter'
|
|
25
26
|
import { useTranslation } from 'react-i18next'
|
|
26
27
|
import {
|
|
27
28
|
Badge,
|
|
@@ -266,6 +267,7 @@ export function DynamicSelectField({
|
|
|
266
267
|
const blockedByDependency = !!dependsOn && scope === ''
|
|
267
268
|
|
|
268
269
|
const useStatic = Array.isArray(staticOptions)
|
|
270
|
+
const optionFilter = getOptionFilter(field)
|
|
269
271
|
|
|
270
272
|
const { options: fetchedOptions, loading: fetchLoading } = useOptionsResolver({
|
|
271
273
|
modelKey: '',
|
|
@@ -280,6 +282,8 @@ export function DynamicSelectField({
|
|
|
280
282
|
// Cascade scope forwarded as filter_value (only when this field
|
|
281
283
|
// declares a dependency). Re-fetches when the parent value changes.
|
|
282
284
|
filterValue: dependsOn ? scope : undefined,
|
|
285
|
+
optionFilter,
|
|
286
|
+
keepValue: value,
|
|
283
287
|
// Don't fetch until the popover opens (and keep fetching as the query
|
|
284
288
|
// changes while open). A picker blocked by an unset dependency never
|
|
285
289
|
// fetches. A readonly cell fetches eagerly so its value's label resolves
|
package/src/dynamic-table.tsx
CHANGED
|
@@ -66,6 +66,7 @@ import { Inbox, Download, Upload, Trash2 } from 'lucide-react'
|
|
|
66
66
|
import { toast } from 'sonner'
|
|
67
67
|
import { Progress } from './dialogs/_primitives'
|
|
68
68
|
import { useMetadataCache } from './metadata-cache'
|
|
69
|
+
import { useRecordMutationTick } from './use-record-mutation-tick'
|
|
69
70
|
import { useApi, useCurrentBranch } from './api-context'
|
|
70
71
|
import { useReasonPrompt } from './reason-prompt'
|
|
71
72
|
import type { ColumnFilterConfig, GetDynamicColumns } from './dynamic-columns-shim'
|
|
@@ -295,6 +296,9 @@ export function DynamicTable({
|
|
|
295
296
|
// OrgRuntimeProvider (without it, money fell back to USD).
|
|
296
297
|
const orgTimeZone = useTimeZone()
|
|
297
298
|
const orgCurrency = useCurrency()
|
|
299
|
+
// Bumps when a dialog/modal anywhere creates, edits or deletes a record of
|
|
300
|
+
// this model, so the list reloads without the caller wiring refreshTrigger.
|
|
301
|
+
const mutationTick = useRecordMutationTick(model)
|
|
298
302
|
const timeZone = timeZoneProp ?? orgTimeZone
|
|
299
303
|
const currency = currencyProp ?? orgCurrency
|
|
300
304
|
// The explicit `pagination` prop wins; the legacy `infiniteScroll` boolean
|
|
@@ -917,7 +921,7 @@ export function DynamicTable({
|
|
|
917
921
|
} finally {
|
|
918
922
|
setLoadingData(false)
|
|
919
923
|
}
|
|
920
|
-
}, [model, metadata, pagination, buildFilterParams, refreshTrigger, endpoint, currentBranch?.id, api, enableUrlSync, relationInclude])
|
|
924
|
+
}, [model, metadata, pagination, buildFilterParams, refreshTrigger, mutationTick, endpoint, currentBranch?.id, api, enableUrlSync, relationInclude])
|
|
921
925
|
|
|
922
926
|
// Columns whose metadata opts into a footer total (display_config.aggregate
|
|
923
927
|
// → styleConfig.aggregate). When empty, no footer row is rendered and no
|
|
@@ -1098,7 +1102,7 @@ export function DynamicTable({
|
|
|
1098
1102
|
// deps). Without it the comment above lied: infinite lists silently
|
|
1099
1103
|
// failed to reload after a create ("a veces no recarga la tabla").
|
|
1100
1104
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
1101
|
-
}, [infiniteScroll, metadata, filterSignature, refreshTrigger])
|
|
1105
|
+
}, [infiniteScroll, metadata, filterSignature, refreshTrigger, mutationTick])
|
|
1102
1106
|
|
|
1103
1107
|
const handleRefresh = useCallback(() => {
|
|
1104
1108
|
// Infinite mode owns its own list: refresh reloads page 1 and drops the
|
package/src/index.ts
CHANGED
|
@@ -196,6 +196,34 @@ export {
|
|
|
196
196
|
type ListActionRef,
|
|
197
197
|
type CreateMode,
|
|
198
198
|
} from './list-primary-action'
|
|
199
|
+
export {
|
|
200
|
+
emitRecordMutation,
|
|
201
|
+
subscribeRecordMutations,
|
|
202
|
+
RECORD_MUTATION_EVENT,
|
|
203
|
+
type RecordMutationKind,
|
|
204
|
+
type RecordMutationDetail,
|
|
205
|
+
} from './record-mutation-events'
|
|
206
|
+
export {
|
|
207
|
+
DocumentFormDialog,
|
|
208
|
+
resolveDocumentForms,
|
|
209
|
+
type DocumentFormDialogProps,
|
|
210
|
+
} from './document-form-dialog'
|
|
211
|
+
export {
|
|
212
|
+
modelFromNavUrl,
|
|
213
|
+
capabilityForNavItem,
|
|
214
|
+
isNavItemAllowed,
|
|
215
|
+
useNavItemVisible,
|
|
216
|
+
type NavLeafLike,
|
|
217
|
+
} from './nav-permissions'
|
|
218
|
+
export {
|
|
219
|
+
CfdiStampPanel,
|
|
220
|
+
CfdiStampResultDialog,
|
|
221
|
+
extractStampResult,
|
|
222
|
+
type CfdiStampResult,
|
|
223
|
+
type CfdiStampPanelProps,
|
|
224
|
+
type CfdiStampResultDialogProps,
|
|
225
|
+
} from './cfdi-stamp-panel'
|
|
226
|
+
export { useRecordMutationTick } from './use-record-mutation-tick'
|
|
199
227
|
export * from './addon-loader'
|
|
200
228
|
export {
|
|
201
229
|
PURGE_ADDON_MESSAGE,
|
|
@@ -556,6 +584,14 @@ export {
|
|
|
556
584
|
type ActivityEvent,
|
|
557
585
|
type ActivityDiffProps,
|
|
558
586
|
} from './activity-diff'
|
|
587
|
+
export {
|
|
588
|
+
AuditInfo,
|
|
589
|
+
readAuditMeta,
|
|
590
|
+
resolveAuditActor,
|
|
591
|
+
SYSTEM_ACTOR_ID,
|
|
592
|
+
type AuditInfoProps,
|
|
593
|
+
type AuditActor,
|
|
594
|
+
} from './audit-info'
|
|
559
595
|
export {
|
|
560
596
|
RecordHistory,
|
|
561
597
|
type RecordHistoryProps,
|
|
@@ -633,3 +669,8 @@ export {
|
|
|
633
669
|
|
|
634
670
|
// Página de documento (benchmark §5/§6): cabecera, badges, acciones por estado, botones inteligentes.
|
|
635
671
|
export * from './document'
|
|
672
|
+
|
|
673
|
+
// Option filter — hide options of a relation / dynamic picker from the manifest
|
|
674
|
+
// (`option_filter` / `optionFilter` on the field).
|
|
675
|
+
export { getOptionFilter, applyOptionFilter, optionPassesRule } from './option-filter'
|
|
676
|
+
export type { OptionFilter, OptionFilterRule } from './option-filter'
|