@asteby/metacore-runtime-react 46.3.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 +31 -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 +17 -7
- 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 +6 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +6 -0
- package/dist/list-primary-action.d.ts +31 -0
- package/dist/list-primary-action.d.ts.map +1 -0
- package/dist/list-primary-action.js +35 -0
- package/dist/model-action-toolbar.d.ts +7 -1
- package/dist/model-action-toolbar.d.ts.map +1 -1
- package/dist/model-action-toolbar.js +10 -3
- package/dist/model-extension-registry.d.ts +7 -0
- package/dist/model-extension-registry.d.ts.map +1 -1
- 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 +60 -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__/list-primary-action.test.tsx +157 -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 +36 -7
- package/src/dynamic-kanban.tsx +3 -1
- package/src/dynamic-table.tsx +6 -2
- package/src/index.ts +36 -0
- package/src/list-primary-action.ts +71 -0
- package/src/model-action-toolbar.tsx +18 -3
- package/src/model-extension-registry.tsx +7 -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 +63 -0
- package/src/use-print-document.ts +66 -3
- package/src/use-record-mutation-tick.ts +12 -0
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
// CfdiStampPanel — human-readable result of stamping (timbrar) a CFDI.
|
|
2
|
+
//
|
|
3
|
+
// A stamp action answers `{ document_id, fiscal_uuid, pdf_url, xml_url }`. The
|
|
4
|
+
// dialog used to dump that JSON; this panel shows the fiscal UUID as text and
|
|
5
|
+
// the PDF / XML as download links instead (#1024, FAC-3 / PIT-051). The SDK
|
|
6
|
+
// invents nothing: only the keys the server actually returned are rendered.
|
|
7
|
+
import { useTranslation } from 'react-i18next'
|
|
8
|
+
import {
|
|
9
|
+
Dialog,
|
|
10
|
+
DialogContent,
|
|
11
|
+
DialogDescription,
|
|
12
|
+
DialogFooter,
|
|
13
|
+
DialogHeader,
|
|
14
|
+
DialogTitle,
|
|
15
|
+
Button,
|
|
16
|
+
} from '@asteby/metacore-ui/primitives'
|
|
17
|
+
import { CheckCircle2, FileCode2, FileText } from 'lucide-react'
|
|
18
|
+
|
|
19
|
+
export interface CfdiStampResult {
|
|
20
|
+
fiscalUuid?: string
|
|
21
|
+
pdfUrl?: string
|
|
22
|
+
xmlUrl?: string
|
|
23
|
+
/** Optional human folio (`serie-folio`) when the server sends it. */
|
|
24
|
+
folio?: string
|
|
25
|
+
documentId?: string
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const str = (v: unknown): string | undefined =>
|
|
29
|
+
typeof v === 'string' && v.trim() ? v.trim() : undefined
|
|
30
|
+
|
|
31
|
+
const isSafeUrl = (u: string) => /^(https?:)?\/\//i.test(u) || u.startsWith('/') || u.startsWith('blob:')
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Reads a stamp result out of a successful action response body. Looks at
|
|
35
|
+
* `body.data`, `body.result` and the body itself. Returns `undefined` unless
|
|
36
|
+
* the payload carries `fiscal_uuid` and/or `pdf_url` / `xml_url` — a response
|
|
37
|
+
* without those keys is never turned into a fiscal result.
|
|
38
|
+
*/
|
|
39
|
+
export function extractStampResult(body: unknown): CfdiStampResult | undefined {
|
|
40
|
+
if (!body || typeof body !== 'object') return undefined
|
|
41
|
+
const b = body as Record<string, unknown>
|
|
42
|
+
const candidates = [b.data, b.result, b]
|
|
43
|
+
for (const c of candidates) {
|
|
44
|
+
if (!c || typeof c !== 'object' || Array.isArray(c)) continue
|
|
45
|
+
const o = c as Record<string, unknown>
|
|
46
|
+
const fiscalUuid = str(o.fiscal_uuid) ?? str(o.fiscalUuid) ?? str(o.uuid)
|
|
47
|
+
const pdf = str(o.pdf_url) ?? str(o.pdfUrl)
|
|
48
|
+
const xml = str(o.xml_url) ?? str(o.xmlUrl)
|
|
49
|
+
// A bare `uuid` is too generic to call a fiscal result on its own.
|
|
50
|
+
const hasFiscal = !!str(o.fiscal_uuid) || !!str(o.fiscalUuid) || !!pdf || !!xml
|
|
51
|
+
if (!hasFiscal) continue
|
|
52
|
+
return {
|
|
53
|
+
fiscalUuid,
|
|
54
|
+
pdfUrl: pdf && isSafeUrl(pdf) ? pdf : undefined,
|
|
55
|
+
xmlUrl: xml && isSafeUrl(xml) ? xml : undefined,
|
|
56
|
+
folio: str(o.folio) ?? str(o.number),
|
|
57
|
+
documentId: str(o.document_id) ?? str(o.documentId),
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
return undefined
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export interface CfdiStampPanelProps {
|
|
64
|
+
result: CfdiStampResult
|
|
65
|
+
className?: string
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function CfdiStampPanel({ result, className }: CfdiStampPanelProps) {
|
|
69
|
+
const { t } = useTranslation()
|
|
70
|
+
return (
|
|
71
|
+
<div data-slot="cfdi-stamp-panel" className={`space-y-4 ${className ?? ''}`}>
|
|
72
|
+
<div className="flex items-center gap-2 text-sm font-medium text-emerald-600 dark:text-emerald-400">
|
|
73
|
+
<CheckCircle2 className="size-5" aria-hidden />
|
|
74
|
+
{t('cfdi.stamp.success', { defaultValue: 'CFDI timbrado correctamente' })}
|
|
75
|
+
</div>
|
|
76
|
+
<dl className="space-y-2 rounded-md border bg-muted/40 p-3 text-sm">
|
|
77
|
+
{result.folio && (
|
|
78
|
+
<div className="flex flex-col gap-0.5">
|
|
79
|
+
<dt className="text-xs uppercase tracking-wide text-muted-foreground">
|
|
80
|
+
{t('cfdi.stamp.folio', { defaultValue: 'Folio' })}
|
|
81
|
+
</dt>
|
|
82
|
+
<dd>{result.folio}</dd>
|
|
83
|
+
</div>
|
|
84
|
+
)}
|
|
85
|
+
{result.fiscalUuid && (
|
|
86
|
+
<div className="flex flex-col gap-0.5">
|
|
87
|
+
<dt className="text-xs uppercase tracking-wide text-muted-foreground">
|
|
88
|
+
{t('cfdi.stamp.uuid', { defaultValue: 'UUID fiscal' })}
|
|
89
|
+
</dt>
|
|
90
|
+
<dd className="break-all font-mono" data-slot="cfdi-stamp-uuid">
|
|
91
|
+
{result.fiscalUuid}
|
|
92
|
+
</dd>
|
|
93
|
+
</div>
|
|
94
|
+
)}
|
|
95
|
+
</dl>
|
|
96
|
+
{(result.pdfUrl || result.xmlUrl) && (
|
|
97
|
+
<div className="flex flex-wrap gap-2">
|
|
98
|
+
{result.pdfUrl && (
|
|
99
|
+
<Button asChild variant="outline" size="sm">
|
|
100
|
+
<a href={result.pdfUrl} target="_blank" rel="noopener noreferrer" data-slot="cfdi-stamp-pdf">
|
|
101
|
+
<FileText className="mr-2 size-4" aria-hidden />
|
|
102
|
+
{t('cfdi.stamp.pdf', { defaultValue: 'Descargar PDF' })}
|
|
103
|
+
</a>
|
|
104
|
+
</Button>
|
|
105
|
+
)}
|
|
106
|
+
{result.xmlUrl && (
|
|
107
|
+
<Button asChild variant="outline" size="sm">
|
|
108
|
+
<a href={result.xmlUrl} target="_blank" rel="noopener noreferrer" data-slot="cfdi-stamp-xml">
|
|
109
|
+
<FileCode2 className="mr-2 size-4" aria-hidden />
|
|
110
|
+
{t('cfdi.stamp.xml', { defaultValue: 'Descargar XML' })}
|
|
111
|
+
</a>
|
|
112
|
+
</Button>
|
|
113
|
+
)}
|
|
114
|
+
</div>
|
|
115
|
+
)}
|
|
116
|
+
</div>
|
|
117
|
+
)
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export interface CfdiStampResultDialogProps {
|
|
121
|
+
open: boolean
|
|
122
|
+
onOpenChange: (open: boolean) => void
|
|
123
|
+
result: CfdiStampResult
|
|
124
|
+
title?: string
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** Dialog shell used by the action modals after a successful stamp. */
|
|
128
|
+
export function CfdiStampResultDialog({ open, onOpenChange, result, title }: CfdiStampResultDialogProps) {
|
|
129
|
+
const { t } = useTranslation()
|
|
130
|
+
return (
|
|
131
|
+
<Dialog open={open} onOpenChange={onOpenChange}>
|
|
132
|
+
<DialogContent className="sm:max-w-md">
|
|
133
|
+
<DialogHeader>
|
|
134
|
+
<DialogTitle>{title ?? t('cfdi.stamp.title', { defaultValue: 'Resultado del timbrado' })}</DialogTitle>
|
|
135
|
+
<DialogDescription className="sr-only">
|
|
136
|
+
{t('cfdi.stamp.desc', { defaultValue: 'UUID fiscal y archivos del CFDI timbrado' })}
|
|
137
|
+
</DialogDescription>
|
|
138
|
+
</DialogHeader>
|
|
139
|
+
<CfdiStampPanel result={result} />
|
|
140
|
+
<DialogFooter>
|
|
141
|
+
<Button onClick={() => onOpenChange(false)}>{t('common.close', { defaultValue: 'Cerrar' })}</Button>
|
|
142
|
+
</DialogFooter>
|
|
143
|
+
</DialogContent>
|
|
144
|
+
</Dialog>
|
|
145
|
+
)
|
|
146
|
+
}
|
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
// flows through <ApiProvider> from runtime-react. Host-specific runtime values —
|
|
10
10
|
// the image-url resolver and the org IANA timezone — are passed as props so the
|
|
11
11
|
// SDK stays transport- and host-agnostic.
|
|
12
|
+
import { emitRecordMutation } from '../record-mutation-events'
|
|
12
13
|
import { createContext, useCallback, useContext, useEffect, useId, useRef, useState } from 'react'
|
|
13
14
|
import { useTranslation } from 'react-i18next'
|
|
14
15
|
import type { ModelSchema } from './types'
|
|
@@ -945,6 +946,7 @@ export function DynamicRecordDialog({
|
|
|
945
946
|
if (isCreate && onCreate) {
|
|
946
947
|
const created = await onCreate(payload)
|
|
947
948
|
toast.success(modalMeta?.messages?.created || t('dynamic.create_success', { defaultValue: 'Registro creado correctamente' }))
|
|
949
|
+
emitRecordMutation(model, 'create')
|
|
948
950
|
onSaved?.(created ?? undefined)
|
|
949
951
|
onOpenChange(false)
|
|
950
952
|
return
|
|
@@ -953,6 +955,7 @@ export function DynamicRecordDialog({
|
|
|
953
955
|
if (!isCreate && recordId && onUpdate) {
|
|
954
956
|
const updated = await onUpdate(String(recordId), payload)
|
|
955
957
|
toast.success(modalMeta?.messages?.updated || t('dynamic.update_success', { defaultValue: 'Guardado correctamente' }))
|
|
958
|
+
emitRecordMutation(model, 'update')
|
|
956
959
|
onSaved?.(updated ?? undefined)
|
|
957
960
|
onOpenChange(false)
|
|
958
961
|
return
|
|
@@ -980,6 +983,7 @@ export function DynamicRecordDialog({
|
|
|
980
983
|
: t('dynamic.update_success', { defaultValue: 'Guardado correctamente' })),
|
|
981
984
|
)
|
|
982
985
|
// Hand the persisted record back so callers can auto-select it.
|
|
986
|
+
emitRecordMutation(model, isCreate ? 'create' : 'update')
|
|
983
987
|
onSaved?.(res.data?.data ?? res.data ?? undefined)
|
|
984
988
|
onOpenChange(false)
|
|
985
989
|
} else {
|
|
@@ -1000,6 +1004,7 @@ export function DynamicRecordDialog({
|
|
|
1000
1004
|
setDeleting(true)
|
|
1001
1005
|
try {
|
|
1002
1006
|
await onDelete()
|
|
1007
|
+
emitRecordMutation(model, 'delete')
|
|
1003
1008
|
onOpenChange(false)
|
|
1004
1009
|
} catch (err: any) {
|
|
1005
1010
|
console.error('[DynamicRecordDialog] delete error:', err)
|
|
@@ -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,12 +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'
|
|
41
|
+
import { resolveListPrimaryAction } from './list-primary-action'
|
|
40
42
|
import { useCan, modelCapability } from './permissions-context'
|
|
41
|
-
import type { TableMetadata } from './types'
|
|
43
|
+
import type { DocumentFormsManifest, TableMetadata } from './types'
|
|
42
44
|
|
|
43
45
|
export interface DynamicCRUDPageStrings {
|
|
44
46
|
refresh?: string
|
|
@@ -89,6 +91,11 @@ export interface DynamicCRUDPageProps {
|
|
|
89
91
|
classes?: DynamicCRUDPageClasses
|
|
90
92
|
/** Fired after a create/import/refresh successfully reloads the table. */
|
|
91
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
|
|
92
99
|
}
|
|
93
100
|
|
|
94
101
|
/**
|
|
@@ -111,6 +118,7 @@ export function DynamicCRUDPage(props: DynamicCRUDPageProps) {
|
|
|
111
118
|
toolbarExtras,
|
|
112
119
|
classes,
|
|
113
120
|
onChange,
|
|
121
|
+
documentForms,
|
|
114
122
|
} = props
|
|
115
123
|
|
|
116
124
|
const strings = { ...defaultStrings, ...(i18n ?? {}) }
|
|
@@ -159,10 +167,17 @@ export function DynamicCRUDPage(props: DynamicCRUDPageProps) {
|
|
|
159
167
|
}, [title])
|
|
160
168
|
|
|
161
169
|
const enableCRUD = metadata?.enableCRUDActions ?? false
|
|
162
|
-
//
|
|
163
|
-
//
|
|
164
|
-
|
|
165
|
-
const
|
|
170
|
+
// Una sola primaria: placement create, replaces_create, create_mode o la
|
|
171
|
+
// extensión del modelo ocultan el Crear genérico. El shell de ops debe
|
|
172
|
+
// usar el mismo resolveListPrimaryAction.
|
|
173
|
+
const primary = resolveListPrimaryAction({
|
|
174
|
+
enableCRUD,
|
|
175
|
+
canCreate: metadata?.canCreate,
|
|
176
|
+
hideCreate: hideCreate || ext?.hideCreate,
|
|
177
|
+
createMode: ext?.createMode ?? metadata?.create_mode,
|
|
178
|
+
primaryActionKey: ext?.primaryActionKey,
|
|
179
|
+
actions: metadata?.actions,
|
|
180
|
+
})
|
|
166
181
|
const effectiveHideExport = hideExport || ext?.hideExport
|
|
167
182
|
const effectiveHideImport = hideImport || ext?.hideImport
|
|
168
183
|
// Refresh defaults to hidden in the page header — <DynamicTable> ships
|
|
@@ -174,10 +189,11 @@ export function DynamicCRUDPage(props: DynamicCRUDPageProps) {
|
|
|
174
189
|
// (useCan defaults to always-true), in which case create/export/import
|
|
175
190
|
// require `lowercase(model).create|export|import`.
|
|
176
191
|
const can = useCan()
|
|
177
|
-
const showCreate =
|
|
192
|
+
const showCreate = primary.showGenericCreate && can(modelCapability(model, 'create'))
|
|
178
193
|
const showImport = enableCRUD && !effectiveHideImport && can(modelCapability(model, 'import'))
|
|
179
194
|
const showExport = !effectiveHideExport && can(modelCapability(model, 'export'))
|
|
180
195
|
const showRefresh = !effectiveHideRefresh
|
|
196
|
+
const guidedForms = resolveDocumentForms(metadata, documentForms)
|
|
181
197
|
|
|
182
198
|
const handleRefresh = useCallback(() => {
|
|
183
199
|
setRefreshKey((k) => k + 1)
|
|
@@ -239,11 +255,13 @@ export function DynamicCRUDPage(props: DynamicCRUDPageProps) {
|
|
|
239
255
|
model={model}
|
|
240
256
|
endpoint={dataEndpoint}
|
|
241
257
|
actions={metadata?.actions}
|
|
258
|
+
primaryActionKey={ext?.primaryActionKey}
|
|
242
259
|
onChange={handleRefresh}
|
|
243
260
|
/>
|
|
244
261
|
{showCreate && (
|
|
245
262
|
<button
|
|
246
263
|
type='button'
|
|
264
|
+
data-primary='true'
|
|
247
265
|
onClick={() => setOpenCreate(true)}
|
|
248
266
|
className='inline-flex items-center gap-2 h-9 px-3 rounded-md bg-primary text-primary-foreground hover:opacity-90 text-sm font-medium'
|
|
249
267
|
>
|
|
@@ -264,7 +282,18 @@ export function DynamicCRUDPage(props: DynamicCRUDPageProps) {
|
|
|
264
282
|
</div>
|
|
265
283
|
</div>
|
|
266
284
|
|
|
267
|
-
{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 && (
|
|
268
297
|
<DynamicRecordDialog
|
|
269
298
|
open={openCreate}
|
|
270
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.
|