@asteby/metacore-runtime-react 47.1.0 → 48.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +29 -0
- package/dist/action-modal-dispatcher.d.ts.map +1 -1
- package/dist/action-modal-dispatcher.js +24 -4
- package/dist/business/catalog-product-search.d.ts +25 -0
- package/dist/business/catalog-product-search.d.ts.map +1 -0
- package/dist/business/catalog-product-search.js +57 -0
- package/dist/business/catalog.d.ts.map +1 -1
- package/dist/business/catalog.js +2 -0
- package/dist/business/document-editor-model.d.ts +107 -0
- package/dist/business/document-editor-model.d.ts.map +1 -0
- package/dist/business/document-editor-model.js +279 -0
- package/dist/business/document-editor-panels.d.ts +64 -0
- package/dist/business/document-editor-panels.d.ts.map +1 -0
- package/dist/business/document-editor-panels.js +58 -0
- package/dist/business/document-editor.d.ts +27 -0
- package/dist/business/document-editor.d.ts.map +1 -0
- package/dist/business/document-editor.js +445 -0
- package/dist/business/document-lines.d.ts.map +1 -1
- package/dist/business/document-lines.js +5 -1
- package/dist/business/index.d.ts +5 -0
- package/dist/business/index.d.ts.map +1 -1
- package/dist/business/index.js +5 -0
- package/dist/business/line-items.d.ts +7 -0
- package/dist/business/line-items.d.ts.map +1 -1
- package/dist/business/line-items.js +6 -1
- package/dist/business/payment-allocator.d.ts +19 -0
- package/dist/business/payment-allocator.d.ts.map +1 -0
- package/dist/business/payment-allocator.js +31 -0
- package/dist/business/product-search.d.ts +4 -0
- package/dist/business/product-search.d.ts.map +1 -1
- package/dist/dialogs/dynamic-record.d.ts.map +1 -1
- package/dist/dialogs/dynamic-record.js +14 -7
- package/dist/document-form-dialog.d.ts +31 -2
- package/dist/document-form-dialog.d.ts.map +1 -1
- package/dist/document-form-dialog.js +43 -11
- package/dist/dynamic-columns-helpers.d.ts +9 -0
- package/dist/dynamic-columns-helpers.d.ts.map +1 -1
- package/dist/dynamic-columns-helpers.js +19 -0
- package/dist/dynamic-columns.d.ts +8 -2
- package/dist/dynamic-columns.d.ts.map +1 -1
- package/dist/dynamic-columns.js +21 -5
- package/dist/dynamic-crud-page.d.ts +6 -0
- package/dist/dynamic-crud-page.d.ts.map +1 -1
- package/dist/dynamic-crud-page.js +14 -5
- package/dist/dynamic-form-schema.d.ts.map +1 -1
- package/dist/dynamic-form-schema.js +15 -3
- package/dist/dynamic-form.d.ts +16 -1
- package/dist/dynamic-form.d.ts.map +1 -1
- package/dist/dynamic-form.js +78 -20
- package/dist/dynamic-kanban.d.ts +9 -3
- package/dist/dynamic-kanban.d.ts.map +1 -1
- package/dist/dynamic-kanban.js +31 -12
- package/dist/dynamic-relation-helpers.d.ts.map +1 -1
- package/dist/dynamic-relation-helpers.js +8 -0
- package/dist/dynamic-relation.js +1 -1
- package/dist/index.d.ts +5 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +13 -2
- package/dist/installed-addons-context.d.ts +15 -0
- package/dist/installed-addons-context.d.ts.map +1 -0
- package/dist/installed-addons-context.js +39 -0
- package/dist/org-runtime-context.d.ts +8 -0
- package/dist/org-runtime-context.d.ts.map +1 -1
- package/dist/org-runtime-context.js +8 -0
- package/dist/org-runtime-provider.d.ts +3 -1
- package/dist/org-runtime-provider.d.ts.map +1 -1
- package/dist/org-runtime-provider.js +3 -3
- package/dist/primitives/aging.d.ts +34 -0
- package/dist/primitives/aging.d.ts.map +1 -0
- package/dist/primitives/aging.js +48 -0
- package/dist/primitives/allocation.d.ts +42 -0
- package/dist/primitives/allocation.d.ts.map +1 -0
- package/dist/primitives/allocation.js +59 -0
- package/dist/primitives/approvals.d.ts +71 -0
- package/dist/primitives/approvals.d.ts.map +1 -0
- package/dist/primitives/approvals.js +48 -0
- package/dist/primitives/contributions.d.ts +82 -0
- package/dist/primitives/contributions.d.ts.map +1 -0
- package/dist/primitives/contributions.js +126 -0
- package/dist/primitives/document-kinds.d.ts +25 -0
- package/dist/primitives/document-kinds.d.ts.map +1 -0
- package/dist/primitives/document-kinds.js +26 -0
- package/dist/primitives/index.d.ts +8 -0
- package/dist/primitives/index.d.ts.map +1 -0
- package/dist/primitives/index.js +23 -0
- package/dist/primitives/panels.d.ts +68 -0
- package/dist/primitives/panels.d.ts.map +1 -0
- package/dist/primitives/panels.js +3 -0
- package/dist/primitives/use-contributions.d.ts +15 -0
- package/dist/primitives/use-contributions.d.ts.map +1 -0
- package/dist/primitives/use-contributions.js +30 -0
- package/dist/record-prefill-bar.d.ts +11 -0
- package/dist/record-prefill-bar.d.ts.map +1 -0
- package/dist/record-prefill-bar.js +33 -0
- package/dist/types.d.ts +102 -0
- package/dist/types.d.ts.map +1 -1
- package/package.json +3 -3
- package/src/__tests__/catalog-product-search.test.ts +61 -0
- package/src/__tests__/document-editor-model.test.ts +167 -0
- package/src/__tests__/document-editor.test.tsx +261 -0
- package/src/__tests__/document-form-dialog.test.tsx +118 -3
- package/src/__tests__/dynamic-form-select-edit.test.tsx +155 -0
- package/src/__tests__/dynamic-kanban.test.tsx +24 -3
- package/src/__tests__/dynamic-record-extension-label.test.tsx +88 -0
- package/src/__tests__/installed-addons-context.test.tsx +50 -0
- package/src/__tests__/line-item-totals.test.ts +28 -0
- package/src/__tests__/primitives-federated.test.tsx +156 -0
- package/src/__tests__/primitives.test.ts +224 -0
- package/src/__tests__/record-prefill-bar.test.tsx +50 -0
- package/src/action-modal-dispatcher.tsx +47 -4
- package/src/business/catalog-product-search.ts +87 -0
- package/src/business/catalog.ts +2 -0
- package/src/business/document-editor-model.ts +325 -0
- package/src/business/document-editor-panels.tsx +164 -0
- package/src/business/document-editor.tsx +727 -0
- package/src/business/document-lines.ts +5 -1
- package/src/business/index.ts +5 -0
- package/src/business/line-items.ts +15 -1
- package/src/business/payment-allocator.tsx +110 -0
- package/src/business/product-search.ts +4 -0
- package/src/dialogs/dynamic-record.tsx +29 -6
- package/src/document-form-dialog.tsx +92 -12
- package/src/dynamic-columns-helpers.ts +22 -0
- package/src/dynamic-columns.tsx +23 -6
- package/src/dynamic-crud-page.tsx +22 -3
- package/src/dynamic-form-schema.ts +15 -2
- package/src/dynamic-form.tsx +117 -39
- package/src/dynamic-kanban.tsx +34 -7
- package/src/dynamic-relation-helpers.ts +9 -0
- package/src/dynamic-relation.tsx +7 -2
- package/src/index.ts +21 -1
- package/src/installed-addons-context.tsx +58 -0
- package/src/org-runtime-context.ts +10 -0
- package/src/org-runtime-provider.tsx +7 -2
- package/src/primitives/aging.ts +76 -0
- package/src/primitives/allocation.ts +104 -0
- package/src/primitives/approvals.ts +103 -0
- package/src/primitives/contributions.ts +208 -0
- package/src/primitives/document-kinds.ts +62 -0
- package/src/primitives/index.ts +23 -0
- package/src/primitives/panels.ts +59 -0
- package/src/primitives/use-contributions.ts +42 -0
- package/src/record-prefill-bar.tsx +62 -0
- package/src/row-action-stage-gate.test.ts +33 -0
- package/src/types.ts +101 -0
|
@@ -0,0 +1,727 @@
|
|
|
1
|
+
// DocumentEditor — alta de un documento comercial en UNA pantalla por
|
|
2
|
+
// secciones: factura, NC, cobro/REP, cotización, OC… Sustituye al wizard
|
|
3
|
+
// «Datos → Siguiente → Renglones» de DocumentFormDialog cuando el tipo declara
|
|
4
|
+
// `layout: "editor"` en `document_forms` (lo sirve el kernel ≥ v0.190.0).
|
|
5
|
+
//
|
|
6
|
+
// Contraparte + lo esencial (PartyCard: datos clave y crédito)
|
|
7
|
+
// Cargar desde… (sources: el renglón/encabezado se precarga)
|
|
8
|
+
// Renglones | Reparto del pago (DocumentLinesGrid con catálogo | PaymentAllocator)
|
|
9
|
+
// Totales
|
|
10
|
+
// ▸ Opciones fiscales (plegada: extensiones fiscal_data.*, catálogos
|
|
11
|
+
// con default y contribuciones header.fields)
|
|
12
|
+
// Secciones de otros addons (body.sections / side.panels)
|
|
13
|
+
// ▸ Vista previa (plegada; acción `preview` con analyze: true)
|
|
14
|
+
// [Cancelar] [Acción primaria única]
|
|
15
|
+
//
|
|
16
|
+
// Pensado para quien hace varios roles: a la vista solo lo que hay que decidir;
|
|
17
|
+
// lo fiscal trae defaults (de la contraparte y del manifest) y se revisa solo si
|
|
18
|
+
// hace falta. Genérico y sin país: fiscal_mexico aporta sus campos como
|
|
19
|
+
// extensiones o contribuciones; otra localización hace lo mismo.
|
|
20
|
+
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
|
21
|
+
import { useTranslation } from 'react-i18next'
|
|
22
|
+
import { Button } from '@asteby/metacore-ui/primitives'
|
|
23
|
+
import { Loader2 } from 'lucide-react'
|
|
24
|
+
import { toast } from 'sonner'
|
|
25
|
+
import { useApi } from '../api-context'
|
|
26
|
+
import { useOrgTaxRate } from '../org-runtime-context'
|
|
27
|
+
import { FieldCell, FieldGrid, FieldLabel } from '../field-grid'
|
|
28
|
+
import { FormErrorBanner } from './feedback'
|
|
29
|
+
import { DocumentLinesGrid, type DocumentLinesGridProps, type LineItemsColumn } from './line-items-editor'
|
|
30
|
+
import { computeTotals, serializeLineItems, taxBreakdown, type LineItem } from './line-items'
|
|
31
|
+
import { roundMoney, toAmount, useFormatter } from './format'
|
|
32
|
+
import { createCatalogProductSearch } from './catalog-product-search'
|
|
33
|
+
import {
|
|
34
|
+
allocationAmountField,
|
|
35
|
+
allocationIssueMessages,
|
|
36
|
+
allocationPayload,
|
|
37
|
+
creditStatus,
|
|
38
|
+
editorLinesConfig,
|
|
39
|
+
linesFromSource,
|
|
40
|
+
localIssues,
|
|
41
|
+
partyDefaults,
|
|
42
|
+
partySummaryRows,
|
|
43
|
+
splitEditorFields,
|
|
44
|
+
toOpenDocuments,
|
|
45
|
+
withFriendlyOptions,
|
|
46
|
+
type EditorIssue,
|
|
47
|
+
} from './document-editor-model'
|
|
48
|
+
import {
|
|
49
|
+
CollapsibleSection,
|
|
50
|
+
EditorSection,
|
|
51
|
+
PartyCard,
|
|
52
|
+
PreviewPanel,
|
|
53
|
+
TotalsPanel,
|
|
54
|
+
ValidationChecklist,
|
|
55
|
+
type PreviewContent,
|
|
56
|
+
type TotalsRow,
|
|
57
|
+
} from './document-editor-panels'
|
|
58
|
+
import { PaymentAllocator, type PaymentAllocatorValue } from './payment-allocator'
|
|
59
|
+
import { actionErrorBannerMessage, buildFieldDefaults, prefillFromFieldRelationRequest, renderField } from '../action-modal-dispatcher'
|
|
60
|
+
import { extractFieldErrors, localizeFieldErrorMap } from '../server-error'
|
|
61
|
+
import { validateValues, bagHasErrors } from '../validator'
|
|
62
|
+
import { clearFieldErrorTree } from '../field-validation-ui'
|
|
63
|
+
import { emitRecordMutation } from '../record-mutation-events'
|
|
64
|
+
import { evaluateVisibleWhen, getVisibleWhen } from '../dynamic-form-schema'
|
|
65
|
+
import { useInstalledAddons } from '../installed-addons-context'
|
|
66
|
+
import { allocatePayment, validateAllocation, type OpenDocument } from '../primitives/allocation'
|
|
67
|
+
import { useDocumentContributions } from '../primitives/use-contributions'
|
|
68
|
+
import type { DocumentContributionProps, DocumentEditorContext } from '../primitives/contributions'
|
|
69
|
+
import type { ActionFieldDef, DocumentFormSource, DocumentFormType, DocumentFormsManifest } from '../types'
|
|
70
|
+
|
|
71
|
+
export interface DocumentEditorProps {
|
|
72
|
+
model: string
|
|
73
|
+
/** Endpoint de alta del modelo. Default `/dynamic/<model>`. */
|
|
74
|
+
endpoint?: string
|
|
75
|
+
forms: DocumentFormsManifest
|
|
76
|
+
type: DocumentFormType
|
|
77
|
+
/** Registro desde el que se abrió (acción de fila): siembra `default_from_record`. */
|
|
78
|
+
record?: Record<string, any>
|
|
79
|
+
/** Documento origen a precargar al abrir (`sources[].key` + id). */
|
|
80
|
+
initialSource?: { key: string; id: string }
|
|
81
|
+
onSaved?: (record?: unknown) => void
|
|
82
|
+
onCancel: () => void
|
|
83
|
+
searchProducts?: DocumentLinesGridProps['search']
|
|
84
|
+
/** Modelo de catálogo del buscador por defecto. Default `products.Product`. */
|
|
85
|
+
productModel?: string
|
|
86
|
+
defaultTaxRate?: number
|
|
87
|
+
currency?: string
|
|
88
|
+
/** Hoy (días de atraso del reparto; inyectable en tests). */
|
|
89
|
+
today?: Date
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
type Busy = 'save' | 'preview' | 'source' | null
|
|
93
|
+
|
|
94
|
+
export function DocumentEditor({
|
|
95
|
+
model,
|
|
96
|
+
endpoint,
|
|
97
|
+
forms,
|
|
98
|
+
type,
|
|
99
|
+
record,
|
|
100
|
+
initialSource,
|
|
101
|
+
onSaved,
|
|
102
|
+
onCancel,
|
|
103
|
+
searchProducts,
|
|
104
|
+
productModel,
|
|
105
|
+
defaultTaxRate,
|
|
106
|
+
currency,
|
|
107
|
+
today,
|
|
108
|
+
}: DocumentEditorProps) {
|
|
109
|
+
const { t, i18n } = useTranslation()
|
|
110
|
+
const lang = i18n?.language
|
|
111
|
+
const api = useApi()
|
|
112
|
+
const fmt = useFormatter({ currency })
|
|
113
|
+
const installed = useInstalledAddons()
|
|
114
|
+
const orgTaxRate = useOrgTaxRate()
|
|
115
|
+
const tl = useCallback((s: string) => t(s, { defaultValue: s }), [t])
|
|
116
|
+
|
|
117
|
+
const lineCfg = useMemo(() => editorLinesConfig(type, forms), [type, forms])
|
|
118
|
+
const kind = lineCfg?.kind ?? 'sale'
|
|
119
|
+
const isAllocation = kind === 'allocation' && !!lineCfg?.open_documents
|
|
120
|
+
const amountKey = isAllocation ? allocationAmountField(type.fields) : undefined
|
|
121
|
+
|
|
122
|
+
const [header, setHeader] = useState<Record<string, any>>(() => ({
|
|
123
|
+
...buildFieldDefaults(type.fields, record),
|
|
124
|
+
...(type.defaults ?? {}),
|
|
125
|
+
}))
|
|
126
|
+
const [lines, setLines] = useState<LineItem[]>([])
|
|
127
|
+
const [linesFromSourceDoc, setLinesFromSourceDoc] = useState(false)
|
|
128
|
+
const [extFields, setExtFields] = useState<ActionFieldDef[]>([])
|
|
129
|
+
const [party, setParty] = useState<Record<string, any> | null>(null)
|
|
130
|
+
const [openDocs, setOpenDocs] = useState<OpenDocument[]>([])
|
|
131
|
+
const [docsLoading, setDocsLoading] = useState(false)
|
|
132
|
+
const [alloc, setAlloc] = useState<PaymentAllocatorValue>({ strategy: 'oldest_due_first', manual: {} })
|
|
133
|
+
const [draftId, setDraftId] = useState<string | null>(null)
|
|
134
|
+
const [serverIssues, setServerIssues] = useState<EditorIssue[]>([])
|
|
135
|
+
const [contributed, setContributed] = useState<Record<string, EditorIssue>>({})
|
|
136
|
+
const [preview, setPreview] = useState<PreviewContent | null>(null)
|
|
137
|
+
const [previewOpen, setPreviewOpen] = useState(false)
|
|
138
|
+
const [advancedOpen, setAdvancedOpen] = useState(false)
|
|
139
|
+
const [busy, setBusy] = useState<Busy>(null)
|
|
140
|
+
const [formError, setFormError] = useState<string | undefined>()
|
|
141
|
+
const [fieldErrors, setFieldErrors] = useState<Record<string, string>>({})
|
|
142
|
+
const [attempted, setAttempted] = useState(false)
|
|
143
|
+
const [sourceKey, setSourceKey] = useState<string>(initialSource?.key ?? '')
|
|
144
|
+
const [sourceId, setSourceId] = useState<string>(initialSource?.id ?? '')
|
|
145
|
+
const relCache = useRef(new Map<string, Promise<any[]>>())
|
|
146
|
+
|
|
147
|
+
const taxRate = defaultTaxRate ?? orgTaxRate
|
|
148
|
+
const search = useMemo(
|
|
149
|
+
() => searchProducts ?? createCatalogProductSearch(api, { model: productModel, defaultTaxRate: taxRate }),
|
|
150
|
+
[searchProducts, api, productModel, taxRate],
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
const isInstalled = useCallback((k?: string) => !k || !installed || installed.addons.has(k), [installed])
|
|
154
|
+
const sources = useMemo(() => (type.sources ?? []).filter((s) => isInstalled(s.requires_addon)), [type.sources, isInstalled])
|
|
155
|
+
const previewEnabled = !!type.preview && !type.submit_action && isInstalled(type.preview.requires_addon)
|
|
156
|
+
|
|
157
|
+
// Campos de extensión del modelo (fiscal_data.* que declaran otros addons): van plegados.
|
|
158
|
+
useEffect(() => {
|
|
159
|
+
let cancelled = false
|
|
160
|
+
api.get(`/metadata/modal/${model}`)
|
|
161
|
+
.then((res: any) => {
|
|
162
|
+
const fields: any[] = res?.data?.data?.fields ?? res?.data?.fields ?? []
|
|
163
|
+
const declared = new Set(type.fields.map((f) => f.key))
|
|
164
|
+
const ext = fields.filter(
|
|
165
|
+
(f) => typeof f?.key === 'string' && f.key.startsWith('fiscal_data.') && !declared.has(f.key) && !f.readonly && !f.readOnly,
|
|
166
|
+
)
|
|
167
|
+
if (!cancelled) setExtFields(ext as ActionFieldDef[])
|
|
168
|
+
})
|
|
169
|
+
.catch(() => {})
|
|
170
|
+
return () => {
|
|
171
|
+
cancelled = true
|
|
172
|
+
}
|
|
173
|
+
}, [api, model, type.fields])
|
|
174
|
+
|
|
175
|
+
const groups = useMemo(
|
|
176
|
+
() =>
|
|
177
|
+
splitEditorFields(type.fields, {
|
|
178
|
+
partyField: type.party?.field,
|
|
179
|
+
essentialKeys: [amountKey, ...sources.map((s) => s.link_field)].filter((k): k is string => !!k),
|
|
180
|
+
extensionFields: extFields,
|
|
181
|
+
}),
|
|
182
|
+
[type.fields, type.party?.field, amountKey, sources, extFields],
|
|
183
|
+
)
|
|
184
|
+
const visible = useCallback((f: ActionFieldDef) => evaluateVisibleWhen(getVisibleWhen(f), header), [header])
|
|
185
|
+
const allFields = useMemo(
|
|
186
|
+
() => [...(groups.party ? [groups.party] : []), ...groups.essential, ...groups.advanced, ...groups.notes],
|
|
187
|
+
[groups],
|
|
188
|
+
)
|
|
189
|
+
|
|
190
|
+
const fieldKeys = useRef<string[]>([])
|
|
191
|
+
fieldKeys.current = allFields.map((f) => f.key)
|
|
192
|
+
|
|
193
|
+
// Contraparte: tarjeta + defaults de las extensiones con la misma clave.
|
|
194
|
+
const partyId = type.party ? header[type.party.field] : undefined
|
|
195
|
+
useEffect(() => {
|
|
196
|
+
if (!type.party || !partyId) {
|
|
197
|
+
setParty(null)
|
|
198
|
+
return
|
|
199
|
+
}
|
|
200
|
+
let cancelled = false
|
|
201
|
+
const ep = type.party.endpoint ?? `/data/${type.party.model}`
|
|
202
|
+
api.get(ep, { params: { f_id: `eq:${partyId}`, per_page: 1 } })
|
|
203
|
+
.then((res: any) => {
|
|
204
|
+
const rows = res?.data?.data ?? []
|
|
205
|
+
const rec = Array.isArray(rows) ? rows[0] ?? null : rows ?? null
|
|
206
|
+
if (cancelled) return
|
|
207
|
+
setParty(rec)
|
|
208
|
+
setHeader((h) => ({ ...h, ...partyDefaults(rec, fieldKeys.current, h) }))
|
|
209
|
+
})
|
|
210
|
+
.catch(() => !cancelled && setParty(null))
|
|
211
|
+
return () => {
|
|
212
|
+
cancelled = true
|
|
213
|
+
}
|
|
214
|
+
}, [api, type.party, partyId])
|
|
215
|
+
|
|
216
|
+
// Cobro: documentos abiertos de la contraparte.
|
|
217
|
+
const openCfg = lineCfg?.open_documents
|
|
218
|
+
const allocPartyId = openCfg ? header[openCfg.party_field] ?? partyId : undefined
|
|
219
|
+
useEffect(() => {
|
|
220
|
+
if (!isAllocation || !openCfg || !allocPartyId) {
|
|
221
|
+
setOpenDocs([])
|
|
222
|
+
return
|
|
223
|
+
}
|
|
224
|
+
let cancelled = false
|
|
225
|
+
setDocsLoading(true)
|
|
226
|
+
api.get(`/data/${openCfg.model}`, { params: { [`f_${openCfg.party_field}`]: `eq:${allocPartyId}`, per_page: 200 } })
|
|
227
|
+
.then((res: any) => {
|
|
228
|
+
if (!cancelled) setOpenDocs(toOpenDocuments(res?.data?.data, openCfg, fmt.currency))
|
|
229
|
+
})
|
|
230
|
+
.catch(() => !cancelled && setOpenDocs([]))
|
|
231
|
+
.finally(() => !cancelled && setDocsLoading(false))
|
|
232
|
+
return () => {
|
|
233
|
+
cancelled = true
|
|
234
|
+
}
|
|
235
|
+
}, [api, isAllocation, openCfg, allocPartyId, fmt.currency])
|
|
236
|
+
|
|
237
|
+
const amount = amountKey ? toAmount(header[amountKey]) : 0
|
|
238
|
+
const allocation = useMemo(() => allocatePayment(amount, openDocs, alloc.strategy, alloc.manual), [amount, openDocs, alloc])
|
|
239
|
+
const openBalance = useMemo(() => roundMoney(openDocs.reduce((s, d) => s + d.balance, 0)), [openDocs])
|
|
240
|
+
|
|
241
|
+
const totals = useMemo(() => computeTotals(lines), [lines])
|
|
242
|
+
const taxes = useMemo(() => taxBreakdown(lines), [lines])
|
|
243
|
+
const documentTotal = isAllocation ? amount : totals.total
|
|
244
|
+
const credit = useMemo(
|
|
245
|
+
() => (isAllocation ? null : creditStatus(party, type.party?.credit, documentTotal)),
|
|
246
|
+
[isAllocation, party, type.party?.credit, documentTotal],
|
|
247
|
+
)
|
|
248
|
+
|
|
249
|
+
const issues = useMemo<EditorIssue[]>(() => {
|
|
250
|
+
const out: EditorIssue[] = []
|
|
251
|
+
if (isAllocation && openCfg) {
|
|
252
|
+
const found = validateAllocation(amount, fmt.currency, openDocs, allocation.allocations, { allowUnapplied: true })
|
|
253
|
+
out.push(...allocationIssueMessages(found, openDocs))
|
|
254
|
+
} else if (lineCfg) {
|
|
255
|
+
out.push(...localIssues(lines, { requireLines: lineCfg.required ?? true }))
|
|
256
|
+
}
|
|
257
|
+
if (credit?.level === 'block') {
|
|
258
|
+
out.push({
|
|
259
|
+
field: type.party?.field,
|
|
260
|
+
severity: 'error',
|
|
261
|
+
message: credit.holdReason
|
|
262
|
+
? `${t('documentEditor.credit_hold', { defaultValue: 'Crédito retenido' })}: ${credit.holdReason}`
|
|
263
|
+
: t('documentEditor.credit_exceeded', { defaultValue: 'El total supera el crédito disponible.' }),
|
|
264
|
+
})
|
|
265
|
+
}
|
|
266
|
+
out.push(...Object.values(contributed), ...serverIssues)
|
|
267
|
+
return out
|
|
268
|
+
}, [isAllocation, openCfg, amount, fmt.currency, openDocs, allocation, lineCfg, lines, credit, type.party?.field, contributed, serverIssues, t])
|
|
269
|
+
const blocking = issues.some((i) => i.severity === 'error')
|
|
270
|
+
const shownIssues = attempted ? issues : issues.filter((i) => i.severity === 'warning')
|
|
271
|
+
|
|
272
|
+
const payload = useCallback(() => {
|
|
273
|
+
const p: Record<string, unknown> = { ...(type.defaults ?? {}), ...header }
|
|
274
|
+
if (forms.type_field && !type.submit_action) p[forms.type_field] = type.value ?? type.key
|
|
275
|
+
if (lineCfg) {
|
|
276
|
+
p[lineCfg.field] = isAllocation && openCfg ? allocationPayload(allocation.allocations, openCfg) : serializeLineItems(lines)
|
|
277
|
+
}
|
|
278
|
+
return p
|
|
279
|
+
}, [type, header, forms.type_field, lineCfg, isAllocation, openCfg, allocation, lines])
|
|
280
|
+
|
|
281
|
+
const baseUrl = type.endpoint ?? endpoint ?? `/dynamic/${model}`
|
|
282
|
+
|
|
283
|
+
/** Crea o actualiza el BORRADOR (idempotente por draftId). La vista previa lo necesita. */
|
|
284
|
+
const saveDraft = useCallback(async (): Promise<Record<string, any>> => {
|
|
285
|
+
const res = draftId ? await api.put(`${baseUrl}/${draftId}`, payload()) : await api.post(baseUrl, payload())
|
|
286
|
+
if (res.data?.success === false) throw { response: { data: res.data } }
|
|
287
|
+
const rec = (res.data?.data ?? res.data ?? {}) as Record<string, any>
|
|
288
|
+
const id = draftId ?? (rec.id != null ? String(rec.id) : null)
|
|
289
|
+
if (id) setDraftId(id)
|
|
290
|
+
emitRecordMutation(model, draftId ? 'update' : 'create')
|
|
291
|
+
return { ...rec, id: id ?? rec.id }
|
|
292
|
+
}, [api, baseUrl, draftId, model, payload])
|
|
293
|
+
|
|
294
|
+
const handleError = (err: unknown) => {
|
|
295
|
+
setFormError(actionErrorBannerMessage(err, allFields, t, lang))
|
|
296
|
+
const map = extractFieldErrors(err)
|
|
297
|
+
if (map) {
|
|
298
|
+
const labels: Record<string, string> = {}
|
|
299
|
+
for (const f of allFields) labels[f.key] = tl(f.label)
|
|
300
|
+
const localized = localizeFieldErrorMap(map, t, { labels, language: lang })
|
|
301
|
+
setFieldErrors(localized)
|
|
302
|
+
if (groups.advanced.some((f) => localized[f.key])) setAdvancedOpen(true)
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
// ---- Cargar desde… --------------------------------------------------------
|
|
307
|
+
const loadFromSource = useCallback(
|
|
308
|
+
async (src: DocumentFormSource, id: string) => {
|
|
309
|
+
if (!lineCfg || !id) return
|
|
310
|
+
setBusy('source')
|
|
311
|
+
try {
|
|
312
|
+
if (!isAllocation) {
|
|
313
|
+
let rels = relCache.current.get(src.model)
|
|
314
|
+
if (!rels) {
|
|
315
|
+
rels = api
|
|
316
|
+
.get(`/metadata/table/${src.model}`)
|
|
317
|
+
.then((r: any) => r?.data?.data?.relations ?? r?.data?.relations ?? [])
|
|
318
|
+
relCache.current.set(src.model, rels)
|
|
319
|
+
}
|
|
320
|
+
const call = prefillFromFieldRelationRequest(
|
|
321
|
+
{ fieldKey: lineCfg.field, refModel: src.model, refId: id, spec: { $prefillFromRecord: src.lines } },
|
|
322
|
+
await rels,
|
|
323
|
+
)
|
|
324
|
+
if (!call) throw new Error(`${src.model}: falta la relación «${src.lines}»`)
|
|
325
|
+
const res = await api.get(call.endpoint, { params: call.params })
|
|
326
|
+
setLines(linesFromSource(res?.data?.data ?? [], src.map, { kind }))
|
|
327
|
+
setLinesFromSourceDoc(kind === 'credit')
|
|
328
|
+
}
|
|
329
|
+
if (src.header || src.link_field) {
|
|
330
|
+
const h = await api.get(`/data/${src.model}`, { params: { f_id: `eq:${id}`, per_page: 1 } })
|
|
331
|
+
const rec = ((h?.data?.data ?? [])[0] ?? {}) as Record<string, any>
|
|
332
|
+
setHeader((prev) => {
|
|
333
|
+
const next = { ...prev }
|
|
334
|
+
for (const [to, from] of Object.entries(src.header ?? {})) if (rec[from] != null) next[to] = rec[from]
|
|
335
|
+
if (src.link_field) next[src.link_field] = id
|
|
336
|
+
return next
|
|
337
|
+
})
|
|
338
|
+
}
|
|
339
|
+
setServerIssues([])
|
|
340
|
+
} catch (err) {
|
|
341
|
+
toast.error(t('documentEditor.source_failed', { defaultValue: 'No se pudo cargar el documento' }), {
|
|
342
|
+
description: err instanceof Error ? err.message : undefined,
|
|
343
|
+
})
|
|
344
|
+
} finally {
|
|
345
|
+
setBusy(null)
|
|
346
|
+
}
|
|
347
|
+
},
|
|
348
|
+
[api, lineCfg, isAllocation, kind, t],
|
|
349
|
+
)
|
|
350
|
+
|
|
351
|
+
// Prefill al abrir desde un documento origen (acción de fila «Crear NC», «Facturar venta»…).
|
|
352
|
+
const autoLoaded = useRef(false)
|
|
353
|
+
useEffect(() => {
|
|
354
|
+
if (autoLoaded.current || !initialSource) return
|
|
355
|
+
const src = sources.find((s) => s.key === initialSource.key)
|
|
356
|
+
if (!src) return
|
|
357
|
+
autoLoaded.current = true
|
|
358
|
+
void loadFromSource(src, initialSource.id)
|
|
359
|
+
}, [initialSource, sources, loadFromSource])
|
|
360
|
+
|
|
361
|
+
// ---- Vista previa -----------------------------------------------------------
|
|
362
|
+
const runPreview = async () => {
|
|
363
|
+
if (!type.preview) return
|
|
364
|
+
setBusy('preview')
|
|
365
|
+
setFormError(undefined)
|
|
366
|
+
try {
|
|
367
|
+
const rec = await saveDraft()
|
|
368
|
+
if (!rec.id) return
|
|
369
|
+
const res = await api.post(`${baseUrl}/${rec.id}/action/${type.preview.action}`, { analyze: true })
|
|
370
|
+
const d = res.data?.data ?? res.data ?? {}
|
|
371
|
+
const checks: any[] = Array.isArray(d.checks) ? d.checks : Array.isArray(d.issues) ? d.issues : []
|
|
372
|
+
setServerIssues(
|
|
373
|
+
checks
|
|
374
|
+
.filter((c) => c && c.severity !== 'ok' && c.message)
|
|
375
|
+
.map((c) => ({ field: c.field, severity: c.severity === 'error' ? 'error' : 'warning', message: String(c.message) })),
|
|
376
|
+
)
|
|
377
|
+
setPreview({ xml: d.xml, html: d.html, pdf_url: d.pdf_url, pdf_base64: d.pdf_base64 })
|
|
378
|
+
} catch (err) {
|
|
379
|
+
handleError(err)
|
|
380
|
+
} finally {
|
|
381
|
+
setBusy(null)
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
const togglePreview = (open: boolean) => {
|
|
386
|
+
setPreviewOpen(open)
|
|
387
|
+
if (open && !preview && busy === null) void runPreview()
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
// ---- Guardar (acción primaria única) ----------------------------------------
|
|
391
|
+
const validateHeader = (): boolean => {
|
|
392
|
+
const fields = allFields.filter(visible)
|
|
393
|
+
const bag = validateValues(fields, header)
|
|
394
|
+
if (!bagHasErrors(bag)) {
|
|
395
|
+
setFieldErrors({})
|
|
396
|
+
return true
|
|
397
|
+
}
|
|
398
|
+
const labels: Record<string, string> = {}
|
|
399
|
+
for (const f of fields) labels[f.key] = tl(f.label)
|
|
400
|
+
const localized = localizeFieldErrorMap(bag, t, { labels, language: lang })
|
|
401
|
+
setFieldErrors(localized)
|
|
402
|
+
if (groups.advanced.some((f) => localized[f.key])) setAdvancedOpen(true)
|
|
403
|
+
return false
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
const submit = async () => {
|
|
407
|
+
setAttempted(true)
|
|
408
|
+
const headerOk = validateHeader()
|
|
409
|
+
if (!headerOk || blocking) {
|
|
410
|
+
setFormError(t('documentEditor.fix_issues', { defaultValue: 'Corrige los puntos marcados antes de guardar.' }))
|
|
411
|
+
return
|
|
412
|
+
}
|
|
413
|
+
setBusy('save')
|
|
414
|
+
setFormError(undefined)
|
|
415
|
+
try {
|
|
416
|
+
let saved: Record<string, any> | undefined
|
|
417
|
+
if (type.submit_action) {
|
|
418
|
+
const res = await api.post(`${baseUrl}/action/${type.submit_action}`, payload())
|
|
419
|
+
if (res.data?.success === false) throw { response: { data: res.data } }
|
|
420
|
+
saved = res.data?.data ?? res.data ?? undefined
|
|
421
|
+
emitRecordMutation(model, 'create')
|
|
422
|
+
} else {
|
|
423
|
+
saved = await saveDraft()
|
|
424
|
+
}
|
|
425
|
+
toast.success(t('dynamic.create_success', { defaultValue: 'Registro creado correctamente' }))
|
|
426
|
+
onSaved?.(saved)
|
|
427
|
+
} catch (err) {
|
|
428
|
+
handleError(err)
|
|
429
|
+
} finally {
|
|
430
|
+
setBusy(null)
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
// ---- Contribuciones de otros addons -----------------------------------------
|
|
435
|
+
const ctx = useMemo<DocumentEditorContext>(() => ({ kind: type.key, model, values: header, mode: 'create' }), [type.key, model, header])
|
|
436
|
+
const setValue = useCallback((key: string, value: unknown) => setHeader((h) => (h[key] === value ? h : { ...h, [key]: value })), [])
|
|
437
|
+
const report = useCallback((key: string, issue: { severity: 'error' | 'warning'; message: string } | null) => {
|
|
438
|
+
setContributed((prev) => {
|
|
439
|
+
const cur = prev[key]
|
|
440
|
+
if (!issue) {
|
|
441
|
+
if (!cur) return prev
|
|
442
|
+
const { [key]: _drop, ...rest } = prev
|
|
443
|
+
return rest
|
|
444
|
+
}
|
|
445
|
+
if (cur && cur.severity === issue.severity && cur.message === issue.message) return prev
|
|
446
|
+
return { ...prev, [key]: { ...issue, field: key } }
|
|
447
|
+
})
|
|
448
|
+
}, [])
|
|
449
|
+
const contribProps = useMemo<DocumentContributionProps>(
|
|
450
|
+
() => ({ ctx, setValue, issues: { report: report as DocumentContributionProps['issues']['report'] } }),
|
|
451
|
+
[ctx, setValue, report],
|
|
452
|
+
)
|
|
453
|
+
const headerContribs = useDocumentContributions(ctx, 'header.fields')
|
|
454
|
+
const partyContribs = useDocumentContributions(ctx, 'party.card')
|
|
455
|
+
const bodyContribs = useDocumentContributions(ctx, 'body.sections')
|
|
456
|
+
const sideContribs = useDocumentContributions(ctx, 'side.panels')
|
|
457
|
+
const footerContribs = useDocumentContributions(ctx, 'footer.actions')
|
|
458
|
+
|
|
459
|
+
// ---- Render -------------------------------------------------------------------
|
|
460
|
+
const updateField = (key: string, value: any) => {
|
|
461
|
+
setHeader((h) => ({ ...h, [key]: value }))
|
|
462
|
+
setFieldErrors((prev) => clearFieldErrorTree(prev, key))
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
const fieldCell = (field: ActionFieldDef, fullWidth = false) =>
|
|
466
|
+
visible(field) ? (
|
|
467
|
+
<FieldCell key={field.key} fullWidth={fullWidth}>
|
|
468
|
+
<FieldLabel htmlFor={field.key} required={field.required}>
|
|
469
|
+
{tl(field.label)}
|
|
470
|
+
</FieldLabel>
|
|
471
|
+
{renderField(withFriendlyOptions(field), header[field.key], (v: any) => updateField(field.key, v), header, undefined, fieldErrors)}
|
|
472
|
+
{fieldErrors[field.key] && <p className="mt-1 text-xs text-destructive">{fieldErrors[field.key]}</p>}
|
|
473
|
+
</FieldCell>
|
|
474
|
+
) : null
|
|
475
|
+
|
|
476
|
+
const summaryRows = partySummaryRows(
|
|
477
|
+
party,
|
|
478
|
+
type.party?.summary,
|
|
479
|
+
Object.fromEntries(allFields.map((f) => [f.key, tl(f.label)])),
|
|
480
|
+
)
|
|
481
|
+
const advancedSummary = groups.advanced
|
|
482
|
+
.filter(visible)
|
|
483
|
+
.map((f) => {
|
|
484
|
+
const v = header[f.key]
|
|
485
|
+
if (v == null || v === '') return null
|
|
486
|
+
const opt = f.options?.find((o) => String(o.value) === String(v))
|
|
487
|
+
return opt ? String(opt.value) : String(v)
|
|
488
|
+
})
|
|
489
|
+
.filter(Boolean)
|
|
490
|
+
.slice(0, 4)
|
|
491
|
+
.join(' · ')
|
|
492
|
+
|
|
493
|
+
const totalsRows: TotalsRow[] = isAllocation
|
|
494
|
+
? [
|
|
495
|
+
{ label: t('documentEditor.received', { defaultValue: 'Recibido' }), value: fmt.money(amount) },
|
|
496
|
+
{ label: t('documentEditor.applied', { defaultValue: 'Aplicado' }), value: fmt.money(allocation.applied) },
|
|
497
|
+
...(allocation.unapplied > 0
|
|
498
|
+
? [{ label: t('documentEditor.unapplied', { defaultValue: 'Sin aplicar (saldo a favor)' }), value: fmt.money(allocation.unapplied) }]
|
|
499
|
+
: []),
|
|
500
|
+
]
|
|
501
|
+
: [
|
|
502
|
+
{ label: t('documentEditor.subtotal', { defaultValue: 'Subtotal' }), value: fmt.money(totals.subtotal) },
|
|
503
|
+
...(totals.discount > 0 ? [{ label: t('documentEditor.discount', { defaultValue: 'Descuento' }), value: `− ${fmt.money(totals.discount)}` }] : []),
|
|
504
|
+
...taxes.map((x) => ({
|
|
505
|
+
label: `${t('documentEditor.tax', { defaultValue: 'Impuesto' })} ${roundMoney(x.rate * 100)} %`,
|
|
506
|
+
value: fmt.money(x.tax),
|
|
507
|
+
})),
|
|
508
|
+
{ label: t('documentEditor.total', { defaultValue: 'Total' }), value: fmt.money(totals.total), emphasis: true },
|
|
509
|
+
]
|
|
510
|
+
|
|
511
|
+
const activeSource = sources.find((s) => s.key === sourceKey)
|
|
512
|
+
const linesTitle = lineCfg?.title
|
|
513
|
+
? tl(lineCfg.title)
|
|
514
|
+
: isAllocation
|
|
515
|
+
? t('documentEditor.allocation_title', { defaultValue: 'Documentos a pagar' })
|
|
516
|
+
: t('documentEditor.lines_title', { defaultValue: 'Conceptos' })
|
|
517
|
+
|
|
518
|
+
return (
|
|
519
|
+
<div className="flex min-h-0 flex-1 flex-col" data-slot="document-editor" data-kind={kind}>
|
|
520
|
+
<div className="-mx-1 min-h-0 flex-1 space-y-8 overflow-y-auto px-1 py-2">
|
|
521
|
+
<FormErrorBanner message={formError} />
|
|
522
|
+
|
|
523
|
+
<EditorSection slot="editor-essentials">
|
|
524
|
+
<FieldGrid>
|
|
525
|
+
{groups.party && fieldCell(groups.party)}
|
|
526
|
+
{groups.essential.map((f) => fieldCell(f))}
|
|
527
|
+
</FieldGrid>
|
|
528
|
+
{party && (
|
|
529
|
+
<PartyCard name={party.name ?? party.legal_name} rows={summaryRows} credit={credit} fmt={fmt}>
|
|
530
|
+
{partyContribs.map(({ id, component: C }) => (
|
|
531
|
+
<C key={id} {...contribProps} />
|
|
532
|
+
))}
|
|
533
|
+
</PartyCard>
|
|
534
|
+
)}
|
|
535
|
+
</EditorSection>
|
|
536
|
+
|
|
537
|
+
{sources.length > 0 && !isAllocation && (
|
|
538
|
+
<EditorSection
|
|
539
|
+
slot="load-from-source"
|
|
540
|
+
title={t('documentEditor.load_from', { defaultValue: 'Cargar desde…' })}
|
|
541
|
+
hint={busy === 'source' ? t('documentEditor.loading_source', { defaultValue: 'Cargando…' }) : undefined}
|
|
542
|
+
>
|
|
543
|
+
<div className="flex flex-wrap items-center gap-2">
|
|
544
|
+
{sources.length > 1 && (
|
|
545
|
+
<select
|
|
546
|
+
aria-label={t('documentEditor.source_kind', { defaultValue: 'Tipo de documento origen' })}
|
|
547
|
+
className="h-9 rounded-md border bg-background px-2 text-sm"
|
|
548
|
+
value={sourceKey}
|
|
549
|
+
onChange={(e) => {
|
|
550
|
+
setSourceKey(e.target.value)
|
|
551
|
+
setSourceId('')
|
|
552
|
+
}}
|
|
553
|
+
>
|
|
554
|
+
<option value="">{t('documentEditor.choose_source', { defaultValue: 'Elige…' })}</option>
|
|
555
|
+
{sources.map((s) => (
|
|
556
|
+
<option key={s.key} value={s.key}>
|
|
557
|
+
{tl(s.label)}
|
|
558
|
+
</option>
|
|
559
|
+
))}
|
|
560
|
+
</select>
|
|
561
|
+
)}
|
|
562
|
+
{(activeSource ?? (sources.length === 1 ? sources[0] : undefined)) && (
|
|
563
|
+
<SourcePicker
|
|
564
|
+
source={(activeSource ?? sources[0])!}
|
|
565
|
+
value={sourceId}
|
|
566
|
+
header={header}
|
|
567
|
+
label={tl((activeSource ?? sources[0])!.label)}
|
|
568
|
+
onPick={(id) => {
|
|
569
|
+
if (!activeSource) setSourceKey(sources[0].key)
|
|
570
|
+
setSourceId(id)
|
|
571
|
+
const src = activeSource ?? sources[0]
|
|
572
|
+
if (id) void loadFromSource(src, id)
|
|
573
|
+
}}
|
|
574
|
+
/>
|
|
575
|
+
)}
|
|
576
|
+
</div>
|
|
577
|
+
</EditorSection>
|
|
578
|
+
)}
|
|
579
|
+
|
|
580
|
+
{lineCfg && (
|
|
581
|
+
<EditorSection
|
|
582
|
+
slot={isAllocation ? 'editor-allocation' : 'editor-lines'}
|
|
583
|
+
title={linesTitle}
|
|
584
|
+
hint={
|
|
585
|
+
isAllocation && openBalance > 0 && amountKey ? (
|
|
586
|
+
<button
|
|
587
|
+
type="button"
|
|
588
|
+
className="underline-offset-2 hover:underline"
|
|
589
|
+
onClick={() => updateField(amountKey, openBalance)}
|
|
590
|
+
>
|
|
591
|
+
{t('documentEditor.pay_all', { defaultValue: 'Cobrar todo ({{amount}})', amount: fmt.money(openBalance) })}
|
|
592
|
+
</button>
|
|
593
|
+
) : undefined
|
|
594
|
+
}
|
|
595
|
+
>
|
|
596
|
+
{isAllocation ? (
|
|
597
|
+
allocPartyId ? (
|
|
598
|
+
<PaymentAllocator
|
|
599
|
+
documents={openDocs}
|
|
600
|
+
result={allocation}
|
|
601
|
+
value={alloc}
|
|
602
|
+
onChange={setAlloc}
|
|
603
|
+
currency={currency}
|
|
604
|
+
loading={docsLoading}
|
|
605
|
+
today={today}
|
|
606
|
+
/>
|
|
607
|
+
) : (
|
|
608
|
+
<p className="text-sm text-muted-foreground">
|
|
609
|
+
{t('documentEditor.pick_party_first', { defaultValue: 'Elige el cliente para ver sus documentos con saldo.' })}
|
|
610
|
+
</p>
|
|
611
|
+
)
|
|
612
|
+
) : (
|
|
613
|
+
<DocumentLinesGrid
|
|
614
|
+
value={lines}
|
|
615
|
+
onChange={(next) => {
|
|
616
|
+
setLines(next)
|
|
617
|
+
setServerIssues([])
|
|
618
|
+
}}
|
|
619
|
+
columns={(lineCfg.columns as LineItemsColumn[] | undefined) ?? ['discount', 'tax']}
|
|
620
|
+
priceSource={lineCfg.price_source ?? (kind === 'purchase' ? 'cost' : 'sale')}
|
|
621
|
+
discountMode={lineCfg.discount_mode}
|
|
622
|
+
mode={linesFromSourceDoc ? 'from_source' : 'free'}
|
|
623
|
+
policy={kind === 'credit' ? { allowZeroQuantity: linesFromSourceDoc } : undefined}
|
|
624
|
+
search={search}
|
|
625
|
+
currency={currency}
|
|
626
|
+
/>
|
|
627
|
+
)}
|
|
628
|
+
<TotalsPanel rows={totalsRows} />
|
|
629
|
+
</EditorSection>
|
|
630
|
+
)}
|
|
631
|
+
|
|
632
|
+
{(groups.advanced.length > 0 || headerContribs.length > 0) && (
|
|
633
|
+
<CollapsibleSection
|
|
634
|
+
slot="advanced-options"
|
|
635
|
+
title={t('documentEditor.advanced', { defaultValue: 'Opciones fiscales' })}
|
|
636
|
+
summary={advancedSummary || t('documentEditor.advanced_defaults', { defaultValue: 'Valores sugeridos' })}
|
|
637
|
+
open={advancedOpen}
|
|
638
|
+
onOpenChange={setAdvancedOpen}
|
|
639
|
+
>
|
|
640
|
+
{groups.advanced.length > 0 && <FieldGrid>{groups.advanced.map((f) => fieldCell(f))}</FieldGrid>}
|
|
641
|
+
{headerContribs.map(({ id, component: C }) => (
|
|
642
|
+
<C key={id} {...contribProps} />
|
|
643
|
+
))}
|
|
644
|
+
</CollapsibleSection>
|
|
645
|
+
)}
|
|
646
|
+
|
|
647
|
+
{[...bodyContribs, ...sideContribs].map(({ id, component: C }) => (
|
|
648
|
+
<section key={id} data-contribution={id}>
|
|
649
|
+
<C {...contribProps} />
|
|
650
|
+
</section>
|
|
651
|
+
))}
|
|
652
|
+
|
|
653
|
+
{groups.notes.length > 0 && <FieldGrid>{groups.notes.map((f) => fieldCell(f, true))}</FieldGrid>}
|
|
654
|
+
|
|
655
|
+
{previewEnabled && (
|
|
656
|
+
<CollapsibleSection
|
|
657
|
+
slot="preview"
|
|
658
|
+
title={tl(type.preview!.label ?? 'Vista previa')}
|
|
659
|
+
open={previewOpen}
|
|
660
|
+
onOpenChange={togglePreview}
|
|
661
|
+
>
|
|
662
|
+
{busy === 'preview' ? (
|
|
663
|
+
<p className="flex items-center gap-2 text-sm text-muted-foreground">
|
|
664
|
+
<Loader2 className="size-4 animate-spin" aria-hidden />
|
|
665
|
+
{t('documentEditor.preview_loading', { defaultValue: 'Generando vista previa…' })}
|
|
666
|
+
</p>
|
|
667
|
+
) : (
|
|
668
|
+
<>
|
|
669
|
+
<PreviewPanel preview={preview} />
|
|
670
|
+
<Button type="button" variant="ghost" size="sm" onClick={() => void runPreview()} disabled={busy !== null}>
|
|
671
|
+
{t('documentEditor.preview_refresh', { defaultValue: 'Actualizar vista previa' })}
|
|
672
|
+
</Button>
|
|
673
|
+
</>
|
|
674
|
+
)}
|
|
675
|
+
</CollapsibleSection>
|
|
676
|
+
)}
|
|
677
|
+
|
|
678
|
+
<ValidationChecklist issues={shownIssues} title={t('documentEditor.review', { defaultValue: 'Revisa antes de guardar' })} />
|
|
679
|
+
</div>
|
|
680
|
+
|
|
681
|
+
<footer className="flex shrink-0 flex-wrap items-center justify-end gap-2 border-t pt-4" data-slot="editor-actions">
|
|
682
|
+
{footerContribs.map(({ id, component: C }) => (
|
|
683
|
+
<C key={id} {...contribProps} />
|
|
684
|
+
))}
|
|
685
|
+
<Button type="button" variant="ghost" onClick={onCancel} disabled={busy === 'save'}>
|
|
686
|
+
{t('common.cancel', { defaultValue: 'Cancelar' })}
|
|
687
|
+
</Button>
|
|
688
|
+
<Button type="button" data-primary="true" onClick={() => void submit()} disabled={busy !== null}>
|
|
689
|
+
{busy === 'save' && <Loader2 className="mr-2 size-4 animate-spin" aria-hidden />}
|
|
690
|
+
{type.submit_label ? tl(type.submit_label) : t('common.save', { defaultValue: 'Guardar' })}
|
|
691
|
+
</Button>
|
|
692
|
+
</footer>
|
|
693
|
+
</div>
|
|
694
|
+
)
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
/** Selector del documento origen (mismo widget de relación que el resto de formularios). */
|
|
698
|
+
function SourcePicker({
|
|
699
|
+
source,
|
|
700
|
+
value,
|
|
701
|
+
header,
|
|
702
|
+
label,
|
|
703
|
+
onPick,
|
|
704
|
+
}: {
|
|
705
|
+
source: DocumentFormSource
|
|
706
|
+
value: string
|
|
707
|
+
header: Record<string, any>
|
|
708
|
+
label: string
|
|
709
|
+
onPick: (id: string) => void
|
|
710
|
+
}) {
|
|
711
|
+
const field = useMemo(
|
|
712
|
+
() =>
|
|
713
|
+
({
|
|
714
|
+
key: `__source_${source.key}`,
|
|
715
|
+
label,
|
|
716
|
+
type: 'dynamic_select',
|
|
717
|
+
ref: source.model,
|
|
718
|
+
option_filter: source.option_filter,
|
|
719
|
+
}) as ActionFieldDef,
|
|
720
|
+
[source, label],
|
|
721
|
+
)
|
|
722
|
+
return (
|
|
723
|
+
<div className="min-w-[240px] flex-1" data-source={source.key}>
|
|
724
|
+
{renderField(field, value, (v: any) => onPick(v == null || v === '' ? '' : String(v)), header)}
|
|
725
|
+
</div>
|
|
726
|
+
)
|
|
727
|
+
}
|