@asteby/metacore-runtime-react 46.4.0 → 47.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 +25 -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/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 +5 -0
- 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-kanban.d.ts.map +1 -1
- package/dist/dynamic-kanban.js +3 -1
- package/dist/dynamic-table.d.ts.map +1 -1
- package/dist/dynamic-table.js +6 -2
- package/dist/index.d.ts +5 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +5 -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/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 +46 -0
- package/dist/types.d.ts.map +1 -1
- 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__/business-pickers.test.tsx +157 -0
- package/src/__tests__/document-form-dialog.test.tsx +190 -0
- package/src/__tests__/dynamic-table-mutation-refresh.test.tsx +116 -0
- package/src/__tests__/nav-permissions.test.tsx +76 -0
- package/src/__tests__/permissions-manager.test.tsx +21 -4
- package/src/__tests__/use-print-document-feedback.test.tsx +58 -0
- package/src/action-modal-dispatcher.tsx +124 -6
- 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 +5 -0
- package/src/document-form-dialog.tsx +357 -0
- package/src/dynamic-crud-page.tsx +21 -2
- package/src/dynamic-kanban.tsx +3 -1
- package/src/dynamic-table.tsx +6 -2
- package/src/index.ts +28 -0
- package/src/nav-permissions.ts +58 -0
- package/src/navigation-builder.tsx +27 -1
- 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 +49 -0
- package/src/use-print-document.ts +66 -3
- package/src/use-record-mutation-tick.ts +12 -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
|
+
}
|
|
@@ -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-kanban.tsx
CHANGED
|
@@ -152,6 +152,7 @@ import { useFlipAnimation } from './use-flip-animation'
|
|
|
152
152
|
import { PortalDragOverlay } from './portal-drag-overlay'
|
|
153
153
|
import { objectLabel } from './dynamic-relation-helpers'
|
|
154
154
|
import { useMetadataCache } from './metadata-cache'
|
|
155
|
+
import { useRecordMutationTick } from './use-record-mutation-tick'
|
|
155
156
|
import { useOptionsResolver } from './use-options-resolver'
|
|
156
157
|
import { ActivityValueRenderer } from './activity-value-renderer'
|
|
157
158
|
import { DynamicIcon } from './dynamic-icon'
|
|
@@ -923,6 +924,7 @@ function DynamicKanbanBoard({
|
|
|
923
924
|
// Realtime refetch (opt-in) — debounced counter bumped by DATA_EVENTs for
|
|
924
925
|
// this model; folded into the board refetch effect next to refreshTrigger.
|
|
925
926
|
const realtimeDefault = useRealtimeDefault()
|
|
927
|
+
const mutationTick = useRecordMutationTick(model)
|
|
926
928
|
const realtimeTick = useRealtimeTick({
|
|
927
929
|
models: [model],
|
|
928
930
|
enabled: realtimeProp ?? realtimeDefault,
|
|
@@ -1339,7 +1341,7 @@ function DynamicKanbanBoard({
|
|
|
1339
1341
|
return () => clearTimeout(handle)
|
|
1340
1342
|
// realtimeTick: data events for this model (see the `realtime` prop).
|
|
1341
1343
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
1342
|
-
}, [fetchData, metadata, refreshTrigger, realtimeTick])
|
|
1344
|
+
}, [fetchData, metadata, refreshTrigger, realtimeTick, mutationTick])
|
|
1343
1345
|
|
|
1344
1346
|
// Filterable fields for the toolbar, in metadata order (explicit filters
|
|
1345
1347
|
// first, then filterable columns), each labeled from its metadata source.
|
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,
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
// nav-permissions — the sidebar follows the role's EFFECTIVE permissions
|
|
2
|
+
// (PIT-044). Before, every installed addon's entries were listed for every
|
|
3
|
+
// role and only failed with a 403 after the click.
|
|
4
|
+
//
|
|
5
|
+
// Rules (same capability vocabulary as useCan/gateTableMetadata):
|
|
6
|
+
// - no <PermissionsProvider> mounted, or isAdmin / "*" -> nothing hidden
|
|
7
|
+
// (useCan() is always-true then, so the predicate is a no-op);
|
|
8
|
+
// - an entry with an explicit `requires` capability -> can(requires);
|
|
9
|
+
// - an entry pointing at a model screen (`/m/<model>`) -> can('<model>.index');
|
|
10
|
+
// - any other entry (custom pages, activity, settings…) -> kept. Those are
|
|
11
|
+
// gated by the backend / the host's own `requires`, never guessed here.
|
|
12
|
+
import { useCallback } from 'react'
|
|
13
|
+
import { modelCapability, useCan, usePermissionsActive, type CanFn } from './permissions-context'
|
|
14
|
+
|
|
15
|
+
/** Minimal structural shape of a sidebar leaf (matches `@asteby/metacore-ui` NavLinkItem). */
|
|
16
|
+
export interface NavLeafLike {
|
|
17
|
+
url: string
|
|
18
|
+
requires?: string
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const MODEL_URL_RE = /^\/(?:app\/)?m\/([^/?#]+)/i
|
|
22
|
+
|
|
23
|
+
/** Model key of a `/m/<model>` url, or undefined for any other route. */
|
|
24
|
+
export function modelFromNavUrl(url: string | undefined): string | undefined {
|
|
25
|
+
if (!url) return undefined
|
|
26
|
+
const m = MODEL_URL_RE.exec(url)
|
|
27
|
+
if (!m) return undefined
|
|
28
|
+
try {
|
|
29
|
+
return decodeURIComponent(m[1])
|
|
30
|
+
} catch {
|
|
31
|
+
return m[1]
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Capability a leaf needs, or undefined when it is not gated by the SDK. */
|
|
36
|
+
export function capabilityForNavItem(item: NavLeafLike): string | undefined {
|
|
37
|
+
if (item.requires) return item.requires
|
|
38
|
+
const model = modelFromNavUrl(item.url)
|
|
39
|
+
return model ? modelCapability(model, 'index') : undefined
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Pure visibility predicate for a leaf given a capability checker. */
|
|
43
|
+
export function isNavItemAllowed(item: NavLeafLike, can: CanFn): boolean {
|
|
44
|
+
const cap = capabilityForNavItem(item)
|
|
45
|
+
return cap ? can(cap) : true
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Hook for the host's sidebar: returns a predicate for `<AppSidebar
|
|
50
|
+
* isItemVisible>` / `filterNavGroups`, or `undefined` when permission gating
|
|
51
|
+
* is not active (nothing to filter).
|
|
52
|
+
*/
|
|
53
|
+
export function useNavItemVisible(): ((item: NavLeafLike) => boolean) | undefined {
|
|
54
|
+
const can = useCan()
|
|
55
|
+
const active = usePermissionsActive()
|
|
56
|
+
const predicate = useCallback((item: NavLeafLike) => isNavItemAllowed(item, can), [can])
|
|
57
|
+
return active ? predicate : undefined
|
|
58
|
+
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// NavigationBuilder — merges a host's base sidebar with `manifest.navigation`
|
|
2
2
|
// contributions from every loaded addon. Pure function + a React hook.
|
|
3
3
|
import { useMemo } from 'react'
|
|
4
|
+
import { useCan, type CanFn } from './permissions-context'
|
|
4
5
|
|
|
5
6
|
export interface NavItem {
|
|
6
7
|
key: string
|
|
@@ -59,8 +60,33 @@ export function mergeNavigation(base: NavItem[], contributions: AddonNavigationC
|
|
|
59
60
|
.sort((a, b) => (b.priority ?? 0) - (a.priority ?? 0))
|
|
60
61
|
}
|
|
61
62
|
|
|
63
|
+
/**
|
|
64
|
+
* Drops items whose `requires` capability the user lacks (PIT-044). A parent
|
|
65
|
+
* left without children is dropped too, unless it links somewhere itself.
|
|
66
|
+
*/
|
|
67
|
+
export function filterNavigationByCapability(items: NavItem[], can: CanFn): NavItem[] {
|
|
68
|
+
const out: NavItem[] = []
|
|
69
|
+
for (const item of items) {
|
|
70
|
+
if (item.requires && !can(item.requires)) continue
|
|
71
|
+
if (item.children) {
|
|
72
|
+
const children = filterNavigationByCapability(item.children, can)
|
|
73
|
+
if (children.length === 0 && item.children.length > 0 && !item.to) continue
|
|
74
|
+
out.push({ ...item, children })
|
|
75
|
+
} else {
|
|
76
|
+
out.push(item)
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
return out
|
|
80
|
+
}
|
|
81
|
+
|
|
62
82
|
export function useNavigation(base: NavItem[], contributions: AddonNavigationContribution[]): NavItem[] {
|
|
63
|
-
|
|
83
|
+
// useCan() allows everything without a <PermissionsProvider>, so hosts that
|
|
84
|
+
// never opt in keep the full tree.
|
|
85
|
+
const can = useCan()
|
|
86
|
+
return useMemo(
|
|
87
|
+
() => filterNavigationByCapability(mergeNavigation(base, contributions), can),
|
|
88
|
+
[base, contributions, can],
|
|
89
|
+
)
|
|
64
90
|
}
|
|
65
91
|
|
|
66
92
|
export interface NavigationBuilderProps {
|