@asteby/metacore-runtime-react 49.1.0 → 49.2.1
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 +37 -0
- package/dist/action-modal-dispatcher.d.ts +1 -0
- package/dist/action-modal-dispatcher.d.ts.map +1 -1
- package/dist/action-modal-dispatcher.js +34 -5
- package/dist/business/document-editor-model.d.ts +27 -1
- package/dist/business/document-editor-model.d.ts.map +1 -1
- package/dist/business/document-editor-model.js +104 -7
- package/dist/business/document-editor-panels.d.ts +18 -0
- package/dist/business/document-editor-panels.d.ts.map +1 -1
- package/dist/business/document-editor-panels.js +9 -0
- package/dist/business/document-editor.d.ts +7 -1
- package/dist/business/document-editor.d.ts.map +1 -1
- package/dist/business/document-editor.js +106 -41
- package/dist/document-form-dialog.d.ts.map +1 -1
- package/dist/document-form-dialog.js +8 -3
- package/dist/dynamic-row-actions.d.ts +6 -0
- package/dist/dynamic-row-actions.d.ts.map +1 -1
- package/dist/dynamic-row-actions.js +9 -4
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/record-mutation-events.d.ts +12 -0
- package/dist/record-mutation-events.d.ts.map +1 -1
- package/dist/record-mutation-events.js +18 -0
- package/package.json +1 -1
- package/src/__tests__/document-editor-model.test.ts +27 -0
- package/src/__tests__/document-editor.test.tsx +60 -0
- package/src/__tests__/invoice-document-editor.test.tsx +267 -0
- package/src/__tests__/r5-self-label-settled.test.ts +57 -0
- package/src/__tests__/r6-editor-source-seeds.test.tsx +137 -0
- package/src/__tests__/row-action-link-navigation.test.ts +10 -6
- package/src/action-modal-dispatcher.tsx +34 -5
- package/src/business/document-editor-model.ts +118 -7
- package/src/business/document-editor-panels.tsx +49 -0
- package/src/business/document-editor.tsx +288 -177
- package/src/document-form-dialog.tsx +13 -4
- package/src/dynamic-row-actions.tsx +12 -4
- package/src/index.ts +2 -0
- package/src/record-mutation-events.ts +23 -0
|
@@ -42,13 +42,19 @@ import {
|
|
|
42
42
|
sourceTracksRemaining,
|
|
43
43
|
partyDefaults,
|
|
44
44
|
partySummaryRows,
|
|
45
|
+
recordLabel,
|
|
46
|
+
seedRecord,
|
|
47
|
+
sourceHeaderSeeds,
|
|
45
48
|
splitEditorFields,
|
|
46
49
|
toOpenDocuments,
|
|
50
|
+
friendlyOptionLabel,
|
|
47
51
|
withFriendlyOptions,
|
|
48
52
|
type EditorIssue,
|
|
53
|
+
type SeedLabel,
|
|
49
54
|
} from './document-editor-model'
|
|
50
55
|
import {
|
|
51
56
|
CollapsibleSection,
|
|
57
|
+
DraftPreview,
|
|
52
58
|
EditorSection,
|
|
53
59
|
PartyCard,
|
|
54
60
|
PreviewPanel,
|
|
@@ -89,6 +95,12 @@ export interface DocumentEditorProps {
|
|
|
89
95
|
currency?: string
|
|
90
96
|
/** Hoy (días de atraso del reparto; inyectable en tests). */
|
|
91
97
|
today?: Date
|
|
98
|
+
/**
|
|
99
|
+
* Pantalla completa (DocumentFormDialog la pide para documentos con
|
|
100
|
+
* renglones): a dos columnas en pantallas anchas, con totales, revisión y
|
|
101
|
+
* vista previa a la derecha; en una sola columna en móvil.
|
|
102
|
+
*/
|
|
103
|
+
fullscreen?: boolean
|
|
92
104
|
}
|
|
93
105
|
|
|
94
106
|
type Busy = 'save' | 'preview' | 'source' | null
|
|
@@ -107,6 +119,7 @@ export function DocumentEditor({
|
|
|
107
119
|
defaultTaxRate,
|
|
108
120
|
currency,
|
|
109
121
|
today,
|
|
122
|
+
fullscreen = false,
|
|
110
123
|
}: DocumentEditorProps) {
|
|
111
124
|
const { t, i18n } = useTranslation()
|
|
112
125
|
const lang = i18n?.language
|
|
@@ -130,6 +143,10 @@ export function DocumentEditor({
|
|
|
130
143
|
const [linesFromSourceDoc, setLinesFromSourceDoc] = useState(false)
|
|
131
144
|
const [extFields, setExtFields] = useState<ActionFieldDef[]>([])
|
|
132
145
|
const [party, setParty] = useState<Record<string, any> | null>(null)
|
|
146
|
+
const [partyLabels, setPartyLabels] = useState<Record<string, string>>({})
|
|
147
|
+
// Etiquetas de los selectores que el editor llena por código (contraparte y
|
|
148
|
+
// documento origen de «Cargar desde…»): sin ellas el selector cerrado pinta el UUID.
|
|
149
|
+
const [seeds, setSeeds] = useState<Record<string, SeedLabel>>({})
|
|
133
150
|
const [openDocs, setOpenDocs] = useState<OpenDocument[]>([])
|
|
134
151
|
const [docsLoading, setDocsLoading] = useState(false)
|
|
135
152
|
const [alloc, setAlloc] = useState<PaymentAllocatorValue>({ strategy: 'oldest_due_first', manual: {} })
|
|
@@ -149,6 +166,7 @@ export function DocumentEditor({
|
|
|
149
166
|
// renglón para que el servidor descuente lo ya facturado/devuelto y valide.
|
|
150
167
|
const [loadedSource, setLoadedSource] = useState<DocumentFormSource | null>(null)
|
|
151
168
|
const [sourceNote, setSourceNote] = useState<string | undefined>()
|
|
169
|
+
const [sourceSeed, setSourceSeed] = useState<SeedLabel | null>(null)
|
|
152
170
|
const relCache = useRef(new Map<string, Promise<any[]>>())
|
|
153
171
|
|
|
154
172
|
const taxRate = defaultTaxRate ?? orgTaxRate
|
|
@@ -213,6 +231,11 @@ export function DocumentEditor({
|
|
|
213
231
|
if (cancelled) return
|
|
214
232
|
setParty(rec)
|
|
215
233
|
setHeader((h) => ({ ...h, ...partyDefaults(rec, fieldKeys.current, h) }))
|
|
234
|
+
const label = recordLabel(rec, 'name') ?? recordLabel(rec, 'legal_name')
|
|
235
|
+
if (label && type.party) {
|
|
236
|
+
const key = type.party.field
|
|
237
|
+
setSeeds((s) => ({ ...s, [key]: { value: String(partyId), label } }))
|
|
238
|
+
}
|
|
216
239
|
})
|
|
217
240
|
.catch(() => !cancelled && setParty(null))
|
|
218
241
|
return () => {
|
|
@@ -220,6 +243,25 @@ export function DocumentEditor({
|
|
|
220
243
|
}
|
|
221
244
|
}, [api, type.party, partyId])
|
|
222
245
|
|
|
246
|
+
// Etiquetas de la tarjeta (RFC, régimen…): las del formulario del modelo de
|
|
247
|
+
// la contraparte, incluidas sus extensiones; sin ellas se ve la columna cruda.
|
|
248
|
+
const partyModel = type.party?.model
|
|
249
|
+
useEffect(() => {
|
|
250
|
+
if (!partyModel) return
|
|
251
|
+
let cancelled = false
|
|
252
|
+
api.get(`/metadata/modal/${partyModel}`)
|
|
253
|
+
.then((res: any) => {
|
|
254
|
+
const fields: any[] = res?.data?.data?.fields ?? res?.data?.fields ?? []
|
|
255
|
+
const out: Record<string, string> = {}
|
|
256
|
+
for (const f of fields) if (typeof f?.key === 'string' && typeof f?.label === 'string' && f.label) out[f.key] = f.label
|
|
257
|
+
if (!cancelled) setPartyLabels(out)
|
|
258
|
+
})
|
|
259
|
+
.catch(() => {})
|
|
260
|
+
return () => {
|
|
261
|
+
cancelled = true
|
|
262
|
+
}
|
|
263
|
+
}, [api, partyModel])
|
|
264
|
+
|
|
223
265
|
// Cobro: documentos abiertos de la contraparte.
|
|
224
266
|
const openCfg = lineCfg?.open_documents
|
|
225
267
|
const allocPartyId = openCfg ? header[openCfg.party_field] ?? partyId : undefined
|
|
@@ -333,8 +375,11 @@ export function DocumentEditor({
|
|
|
333
375
|
params: { source: src.key, id, ...(draftId ? { exclude: draftId } : {}) },
|
|
334
376
|
})
|
|
335
377
|
return res?.data?.data ?? []
|
|
336
|
-
} catch
|
|
337
|
-
|
|
378
|
+
} catch {
|
|
379
|
+
// Un host sin `source-lines` (404, o la ruta del registro
|
|
380
|
+
// contestando 400/500 a «source-lines» como id) o un origen que
|
|
381
|
+
// el host aún no sabe calcular: se precarga de la relación y el
|
|
382
|
+
// guardado del servidor sigue validando lo pendiente.
|
|
338
383
|
}
|
|
339
384
|
}
|
|
340
385
|
let rels = relCache.current.get(src.model)
|
|
@@ -360,9 +405,29 @@ export function DocumentEditor({
|
|
|
360
405
|
if (!lineCfg || !id) return
|
|
361
406
|
setBusy('source')
|
|
362
407
|
try {
|
|
408
|
+
// Cabecera primero (cliente, moneda, vínculo): llega aunque los
|
|
409
|
+
// renglones del origen fallen, y la tarjeta del cliente se carga.
|
|
410
|
+
if (src.header || src.link_field) {
|
|
411
|
+
const h = await api.get(`/data/${src.model}`, { params: { f_id: `eq:${id}`, per_page: 1 } })
|
|
412
|
+
const rec = ((h?.data?.data ?? [])[0] ?? {}) as Record<string, any>
|
|
413
|
+
setHeader((prev) => {
|
|
414
|
+
const next = { ...prev }
|
|
415
|
+
for (const [to, from] of Object.entries(src.header ?? {})) {
|
|
416
|
+
const v = rec[from]
|
|
417
|
+
if (v == null || v === '') continue
|
|
418
|
+
next[to] = typeof v === 'object' && !Array.isArray(v) ? v.value ?? v.id ?? v : v
|
|
419
|
+
}
|
|
420
|
+
if (src.link_field) next[src.link_field] = id
|
|
421
|
+
return next
|
|
422
|
+
})
|
|
423
|
+
const seeded = sourceHeaderSeeds(src, rec, id, type.fields)
|
|
424
|
+
setSeeds((s) => ({ ...s, ...seeded }))
|
|
425
|
+
const label = src.link_field ? seeded[src.link_field]?.label : recordLabel(rec)
|
|
426
|
+
setSourceSeed(label ? { value: id, label } : null)
|
|
427
|
+
}
|
|
363
428
|
if (!isAllocation) {
|
|
364
429
|
const rows = await fetchSourceRows(src, id)
|
|
365
|
-
const loaded = linesFromSource(rows, src.map, { kind, discountMode: lineCfg.discount_mode })
|
|
430
|
+
const loaded = linesFromSource(rows, src.map, { kind, discountMode: lineCfg.discount_mode, defaultTaxRate: taxRate })
|
|
366
431
|
const { covered } = sourceLoadSummary(rows)
|
|
367
432
|
setLines(loaded)
|
|
368
433
|
setLoadedSource(src)
|
|
@@ -381,16 +446,6 @@ export function DocumentEditor({
|
|
|
381
446
|
: t('documentEditor.source_loaded', { defaultValue: '{{count}} renglones cargados', count: loaded.length }),
|
|
382
447
|
)
|
|
383
448
|
}
|
|
384
|
-
if (src.header || src.link_field) {
|
|
385
|
-
const h = await api.get(`/data/${src.model}`, { params: { f_id: `eq:${id}`, per_page: 1 } })
|
|
386
|
-
const rec = ((h?.data?.data ?? [])[0] ?? {}) as Record<string, any>
|
|
387
|
-
setHeader((prev) => {
|
|
388
|
-
const next = { ...prev }
|
|
389
|
-
for (const [to, from] of Object.entries(src.header ?? {})) if (rec[from] != null) next[to] = rec[from]
|
|
390
|
-
if (src.link_field) next[src.link_field] = id
|
|
391
|
-
return next
|
|
392
|
-
})
|
|
393
|
-
}
|
|
394
449
|
setServerIssues([])
|
|
395
450
|
} catch (err) {
|
|
396
451
|
toast.error(t('documentEditor.source_failed', { defaultValue: 'No se pudo cargar el documento' }), {
|
|
@@ -400,7 +455,7 @@ export function DocumentEditor({
|
|
|
400
455
|
setBusy(null)
|
|
401
456
|
}
|
|
402
457
|
},
|
|
403
|
-
[api, lineCfg, isAllocation, kind, t, fetchSourceRows],
|
|
458
|
+
[api, lineCfg, isAllocation, kind, t, fetchSourceRows, taxRate, type.fields],
|
|
404
459
|
)
|
|
405
460
|
|
|
406
461
|
// Prefill al abrir desde un documento origen (acción de fila «Crear NC», «Facturar venta»…).
|
|
@@ -517,28 +572,28 @@ export function DocumentEditor({
|
|
|
517
572
|
setFieldErrors((prev) => clearFieldErrorTree(prev, key))
|
|
518
573
|
}
|
|
519
574
|
|
|
575
|
+
const fieldRecord = useMemo(() => seedRecord(seeds, record), [seeds, record])
|
|
520
576
|
const fieldCell = (field: ActionFieldDef, fullWidth = false) =>
|
|
521
577
|
visible(field) ? (
|
|
522
578
|
<FieldCell key={field.key} fullWidth={fullWidth}>
|
|
523
579
|
<FieldLabel htmlFor={field.key} required={field.required}>
|
|
524
580
|
{tl(field.label)}
|
|
525
581
|
</FieldLabel>
|
|
526
|
-
{renderField(withFriendlyOptions(field), header[field.key], (v: any) => updateField(field.key, v), header,
|
|
582
|
+
{renderField(withFriendlyOptions(field), header[field.key], (v: any) => updateField(field.key, v), header, fieldRecord, fieldErrors)}
|
|
527
583
|
{fieldErrors[field.key] && <p className="mt-1 text-xs text-destructive">{fieldErrors[field.key]}</p>}
|
|
528
584
|
</FieldCell>
|
|
529
585
|
) : null
|
|
530
586
|
|
|
531
|
-
const summaryRows = partySummaryRows(
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
)
|
|
587
|
+
const summaryRows = partySummaryRows(party, type.party?.summary, {
|
|
588
|
+
...Object.fromEntries(Object.entries(partyLabels).map(([k, v]) => [k, tl(v)])),
|
|
589
|
+
...Object.fromEntries(allFields.map((f) => [f.key, tl(f.label)])),
|
|
590
|
+
})
|
|
536
591
|
const advancedSummary = groups.advanced
|
|
537
592
|
.filter(visible)
|
|
538
593
|
.map((f) => {
|
|
539
594
|
const v = header[f.key]
|
|
540
595
|
if (v == null || v === '') return null
|
|
541
|
-
const opt = f.options
|
|
596
|
+
const opt = Array.isArray(f.options) ? f.options.find((o) => String(o.value) === String(v)) : undefined
|
|
542
597
|
return opt ? String(opt.value) : String(v)
|
|
543
598
|
})
|
|
544
599
|
.filter(Boolean)
|
|
@@ -563,175 +618,228 @@ export function DocumentEditor({
|
|
|
563
618
|
{ label: t('documentEditor.total', { defaultValue: 'Total' }), value: fmt.money(totals.total), emphasis: true },
|
|
564
619
|
]
|
|
565
620
|
|
|
566
|
-
const activeSource = sources.find((s) => s.key === sourceKey)
|
|
621
|
+
const activeSource = sources.find((s) => s.key === sourceKey) ?? (sources.length === 1 ? sources[0] : undefined)
|
|
567
622
|
const linesTitle = lineCfg?.title
|
|
568
623
|
? tl(lineCfg.title)
|
|
569
624
|
: isAllocation
|
|
570
625
|
? t('documentEditor.allocation_title', { defaultValue: 'Documentos a pagar' })
|
|
571
626
|
: t('documentEditor.lines_title', { defaultValue: 'Conceptos' })
|
|
627
|
+
// Sin acción de vista previa del servidor, un documento con renglones ofrece
|
|
628
|
+
// la suya local (encabezado, renglones y totales): sin borrador ni red.
|
|
629
|
+
const localPreview = !previewEnabled && !type.preview && !!lineCfg && !isAllocation && kind !== 'credit'
|
|
630
|
+
|
|
631
|
+
const essentials = (
|
|
632
|
+
<EditorSection slot="editor-essentials">
|
|
633
|
+
<FieldGrid>
|
|
634
|
+
{groups.party && fieldCell(groups.party)}
|
|
635
|
+
{groups.essential.map((f) => fieldCell(f))}
|
|
636
|
+
</FieldGrid>
|
|
637
|
+
{party && (
|
|
638
|
+
<PartyCard name={party.name ?? party.legal_name} rows={summaryRows} credit={credit} fmt={fmt}>
|
|
639
|
+
{partyContribs.map(({ id, component: C }) => (
|
|
640
|
+
<C key={id} {...contribProps} />
|
|
641
|
+
))}
|
|
642
|
+
</PartyCard>
|
|
643
|
+
)}
|
|
644
|
+
</EditorSection>
|
|
645
|
+
)
|
|
572
646
|
|
|
573
|
-
|
|
574
|
-
<
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
{partyContribs.map(({ id, component: C }) => (
|
|
586
|
-
<C key={id} {...contribProps} />
|
|
587
|
-
))}
|
|
588
|
-
</PartyCard>
|
|
589
|
-
)}
|
|
590
|
-
</EditorSection>
|
|
591
|
-
|
|
592
|
-
{sources.length > 0 && !isAllocation && (
|
|
593
|
-
<EditorSection
|
|
594
|
-
slot="load-from-source"
|
|
595
|
-
title={t('documentEditor.load_from', { defaultValue: 'Cargar desde…' })}
|
|
596
|
-
hint={busy === 'source' ? t('documentEditor.loading_source', { defaultValue: 'Cargando…' }) : sourceNote}
|
|
597
|
-
>
|
|
598
|
-
<div className="flex flex-wrap items-center gap-2">
|
|
599
|
-
{sources.length > 1 && (
|
|
600
|
-
<select
|
|
601
|
-
aria-label={t('documentEditor.source_kind', { defaultValue: 'Tipo de documento origen' })}
|
|
602
|
-
className="h-9 rounded-md border bg-background px-2 text-sm"
|
|
603
|
-
value={sourceKey}
|
|
604
|
-
onChange={(e) => {
|
|
605
|
-
setSourceKey(e.target.value)
|
|
606
|
-
setSourceId('')
|
|
607
|
-
}}
|
|
608
|
-
>
|
|
609
|
-
<option value="">{t('documentEditor.choose_source', { defaultValue: 'Elige…' })}</option>
|
|
610
|
-
{sources.map((s) => (
|
|
611
|
-
<option key={s.key} value={s.key}>
|
|
612
|
-
{tl(s.label)}
|
|
613
|
-
</option>
|
|
614
|
-
))}
|
|
615
|
-
</select>
|
|
616
|
-
)}
|
|
617
|
-
{(activeSource ?? (sources.length === 1 ? sources[0] : undefined)) && (
|
|
618
|
-
<SourcePicker
|
|
619
|
-
source={(activeSource ?? sources[0])!}
|
|
620
|
-
value={sourceId}
|
|
621
|
-
header={header}
|
|
622
|
-
label={tl((activeSource ?? sources[0])!.label)}
|
|
623
|
-
onPick={(id) => {
|
|
624
|
-
if (!activeSource) setSourceKey(sources[0].key)
|
|
625
|
-
setSourceId(id)
|
|
626
|
-
const src = activeSource ?? sources[0]
|
|
627
|
-
if (id) void loadFromSource(src, id)
|
|
628
|
-
}}
|
|
629
|
-
/>
|
|
630
|
-
)}
|
|
631
|
-
</div>
|
|
632
|
-
</EditorSection>
|
|
633
|
-
)}
|
|
634
|
-
|
|
635
|
-
{lineCfg && (
|
|
636
|
-
<EditorSection
|
|
637
|
-
slot={isAllocation ? 'editor-allocation' : 'editor-lines'}
|
|
638
|
-
title={linesTitle}
|
|
639
|
-
hint={
|
|
640
|
-
isAllocation && openBalance > 0 && amountKey ? (
|
|
641
|
-
<button
|
|
642
|
-
type="button"
|
|
643
|
-
className="underline-offset-2 hover:underline"
|
|
644
|
-
onClick={() => updateField(amountKey, openBalance)}
|
|
645
|
-
>
|
|
646
|
-
{t('documentEditor.pay_all', { defaultValue: 'Cobrar todo ({{amount}})', amount: fmt.money(openBalance) })}
|
|
647
|
-
</button>
|
|
648
|
-
) : undefined
|
|
649
|
-
}
|
|
647
|
+
const loadFrom = sources.length > 0 && !isAllocation && (
|
|
648
|
+
<EditorSection
|
|
649
|
+
slot="load-from-source"
|
|
650
|
+
title={t('documentEditor.load_from', { defaultValue: 'Cargar desde…' })}
|
|
651
|
+
hint={busy === 'source' ? t('documentEditor.loading_source', { defaultValue: 'Cargando…' }) : sourceNote}
|
|
652
|
+
>
|
|
653
|
+
<div className="flex flex-wrap items-center gap-2">
|
|
654
|
+
{sources.length > 1 && (
|
|
655
|
+
<div
|
|
656
|
+
role="radiogroup"
|
|
657
|
+
aria-label={t('documentEditor.source_kind', { defaultValue: 'Tipo de documento origen' })}
|
|
658
|
+
className="inline-flex flex-wrap gap-1 rounded-md border p-1"
|
|
650
659
|
>
|
|
651
|
-
{
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
/>
|
|
662
|
-
) : (
|
|
663
|
-
<p className="text-sm text-muted-foreground">
|
|
664
|
-
{t('documentEditor.pick_party_first', { defaultValue: 'Elige el cliente para ver sus documentos con saldo.' })}
|
|
665
|
-
</p>
|
|
666
|
-
)
|
|
667
|
-
) : (
|
|
668
|
-
<DocumentLinesGrid
|
|
669
|
-
value={lines}
|
|
670
|
-
onChange={(next) => {
|
|
671
|
-
setLines(next)
|
|
672
|
-
setServerIssues([])
|
|
660
|
+
{sources.map((s) => (
|
|
661
|
+
<button
|
|
662
|
+
key={s.key}
|
|
663
|
+
type="button"
|
|
664
|
+
role="radio"
|
|
665
|
+
aria-checked={s.key === sourceKey}
|
|
666
|
+
data-source-kind={s.key}
|
|
667
|
+
onClick={() => {
|
|
668
|
+
setSourceKey(s.key)
|
|
669
|
+
setSourceId('')
|
|
673
670
|
}}
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
currency={currency}
|
|
681
|
-
/>
|
|
682
|
-
)}
|
|
683
|
-
<TotalsPanel rows={totalsRows} />
|
|
684
|
-
</EditorSection>
|
|
685
|
-
)}
|
|
686
|
-
|
|
687
|
-
{(groups.advanced.length > 0 || headerContribs.length > 0) && (
|
|
688
|
-
<CollapsibleSection
|
|
689
|
-
slot="advanced-options"
|
|
690
|
-
title={t('documentEditor.advanced', { defaultValue: 'Opciones fiscales' })}
|
|
691
|
-
summary={advancedSummary || t('documentEditor.advanced_defaults', { defaultValue: 'Valores sugeridos' })}
|
|
692
|
-
open={advancedOpen}
|
|
693
|
-
onOpenChange={setAdvancedOpen}
|
|
694
|
-
>
|
|
695
|
-
{groups.advanced.length > 0 && <FieldGrid>{groups.advanced.map((f) => fieldCell(f))}</FieldGrid>}
|
|
696
|
-
{headerContribs.map(({ id, component: C }) => (
|
|
697
|
-
<C key={id} {...contribProps} />
|
|
671
|
+
className={`rounded px-3 py-1.5 text-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring ${
|
|
672
|
+
s.key === sourceKey ? 'bg-primary/10 font-medium text-primary' : 'text-muted-foreground hover:bg-accent'
|
|
673
|
+
}`}
|
|
674
|
+
>
|
|
675
|
+
{tl(s.label)}
|
|
676
|
+
</button>
|
|
698
677
|
))}
|
|
699
|
-
</
|
|
678
|
+
</div>
|
|
700
679
|
)}
|
|
680
|
+
{activeSource && (
|
|
681
|
+
<SourcePicker
|
|
682
|
+
key={activeSource.key}
|
|
683
|
+
source={activeSource}
|
|
684
|
+
value={sourceId}
|
|
685
|
+
seed={sourceSeed}
|
|
686
|
+
header={header}
|
|
687
|
+
label={tl(activeSource.label)}
|
|
688
|
+
onPick={(id) => {
|
|
689
|
+
setSourceKey(activeSource.key)
|
|
690
|
+
setSourceId(id)
|
|
691
|
+
if (id) void loadFromSource(activeSource, id)
|
|
692
|
+
}}
|
|
693
|
+
/>
|
|
694
|
+
)}
|
|
695
|
+
</div>
|
|
696
|
+
</EditorSection>
|
|
697
|
+
)
|
|
701
698
|
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
699
|
+
const totalsPanel = <TotalsPanel rows={totalsRows} />
|
|
700
|
+
|
|
701
|
+
const linesSection = lineCfg && (
|
|
702
|
+
<EditorSection
|
|
703
|
+
slot={isAllocation ? 'editor-allocation' : 'editor-lines'}
|
|
704
|
+
title={linesTitle}
|
|
705
|
+
hint={
|
|
706
|
+
isAllocation && openBalance > 0 && amountKey ? (
|
|
707
|
+
<button type="button" className="underline-offset-2 hover:underline" onClick={() => updateField(amountKey, openBalance)}>
|
|
708
|
+
{t('documentEditor.pay_all', { defaultValue: 'Cobrar todo ({{amount}})', amount: fmt.money(openBalance) })}
|
|
709
|
+
</button>
|
|
710
|
+
) : undefined
|
|
711
|
+
}
|
|
712
|
+
>
|
|
713
|
+
{isAllocation ? (
|
|
714
|
+
allocPartyId ? (
|
|
715
|
+
<PaymentAllocator
|
|
716
|
+
documents={openDocs}
|
|
717
|
+
result={allocation}
|
|
718
|
+
value={alloc}
|
|
719
|
+
onChange={setAlloc}
|
|
720
|
+
currency={currency}
|
|
721
|
+
loading={docsLoading}
|
|
722
|
+
today={today}
|
|
723
|
+
/>
|
|
724
|
+
) : (
|
|
725
|
+
<p className="text-sm text-muted-foreground">
|
|
726
|
+
{t('documentEditor.pick_party_first', { defaultValue: 'Elige el cliente para ver sus documentos con saldo.' })}
|
|
727
|
+
</p>
|
|
728
|
+
)
|
|
729
|
+
) : (
|
|
730
|
+
<DocumentLinesGrid
|
|
731
|
+
value={lines}
|
|
732
|
+
onChange={(next) => {
|
|
733
|
+
setLines(next)
|
|
734
|
+
setServerIssues([])
|
|
735
|
+
}}
|
|
736
|
+
columns={(lineCfg.columns as LineItemsColumn[] | undefined) ?? ['discount', 'tax']}
|
|
737
|
+
priceSource={lineCfg.price_source ?? (kind === 'purchase' ? 'cost' : 'sale')}
|
|
738
|
+
discountMode={lineCfg.discount_mode}
|
|
739
|
+
mode={linesFromSourceDoc ? 'from_source' : 'free'}
|
|
740
|
+
policy={kind === 'credit' ? { allowZeroQuantity: linesFromSourceDoc } : undefined}
|
|
741
|
+
search={search}
|
|
742
|
+
currency={currency}
|
|
743
|
+
/>
|
|
744
|
+
)}
|
|
745
|
+
{!fullscreen && totalsPanel}
|
|
746
|
+
</EditorSection>
|
|
747
|
+
)
|
|
707
748
|
|
|
708
|
-
|
|
749
|
+
const advanced = (groups.advanced.length > 0 || headerContribs.length > 0) && (
|
|
750
|
+
<CollapsibleSection
|
|
751
|
+
slot="advanced-options"
|
|
752
|
+
title={t('documentEditor.advanced', { defaultValue: 'Opciones fiscales' })}
|
|
753
|
+
summary={advancedSummary || t('documentEditor.advanced_defaults', { defaultValue: 'Valores sugeridos' })}
|
|
754
|
+
open={advancedOpen}
|
|
755
|
+
onOpenChange={setAdvancedOpen}
|
|
756
|
+
>
|
|
757
|
+
{groups.advanced.length > 0 && <FieldGrid>{groups.advanced.map((f) => fieldCell(f))}</FieldGrid>}
|
|
758
|
+
{headerContribs.map(({ id, component: C }) => (
|
|
759
|
+
<C key={id} {...contribProps} />
|
|
760
|
+
))}
|
|
761
|
+
</CollapsibleSection>
|
|
762
|
+
)
|
|
709
763
|
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
)}
|
|
730
|
-
</
|
|
731
|
-
|
|
764
|
+
const contributedSections = [...bodyContribs, ...sideContribs].map(({ id, component: C }) => (
|
|
765
|
+
<section key={id} data-contribution={id}>
|
|
766
|
+
<C {...contribProps} />
|
|
767
|
+
</section>
|
|
768
|
+
))
|
|
769
|
+
|
|
770
|
+
const notes = groups.notes.length > 0 && <FieldGrid>{groups.notes.map((f) => fieldCell(f, true))}</FieldGrid>
|
|
771
|
+
|
|
772
|
+
const previewSection = previewEnabled ? (
|
|
773
|
+
<CollapsibleSection slot="preview" title={tl(type.preview!.label ?? 'Vista previa')} open={previewOpen} onOpenChange={togglePreview}>
|
|
774
|
+
{busy === 'preview' ? (
|
|
775
|
+
<p className="flex items-center gap-2 text-sm text-muted-foreground">
|
|
776
|
+
<Loader2 className="size-4 animate-spin" aria-hidden />
|
|
777
|
+
{t('documentEditor.preview_loading', { defaultValue: 'Generando vista previa…' })}
|
|
778
|
+
</p>
|
|
779
|
+
) : (
|
|
780
|
+
<>
|
|
781
|
+
<PreviewPanel preview={preview} />
|
|
782
|
+
<Button type="button" variant="ghost" size="sm" onClick={() => void runPreview()} disabled={busy !== null}>
|
|
783
|
+
{t('documentEditor.preview_refresh', { defaultValue: 'Actualizar vista previa' })}
|
|
784
|
+
</Button>
|
|
785
|
+
</>
|
|
786
|
+
)}
|
|
787
|
+
</CollapsibleSection>
|
|
788
|
+
) : localPreview ? (
|
|
789
|
+
<CollapsibleSection slot="preview" title={t('documentEditor.preview', { defaultValue: 'Vista previa' })} open={previewOpen} onOpenChange={setPreviewOpen}>
|
|
790
|
+
<DraftPreview
|
|
791
|
+
title={tl(type.label)}
|
|
792
|
+
party={party ? String(party.name ?? party.legal_name ?? '') : undefined}
|
|
793
|
+
header={[...groups.essential, ...groups.advanced]
|
|
794
|
+
.filter(visible)
|
|
795
|
+
.map((f) => {
|
|
796
|
+
const v = header[f.key]
|
|
797
|
+
const opt = Array.isArray(f.options) ? f.options.find((o) => String(o.value) === String(v)) : undefined
|
|
798
|
+
const seeded = seeds[f.key] && String(seeds[f.key].value) === String(v) ? seeds[f.key].label : undefined
|
|
799
|
+
return { label: tl(f.label), value: v == null || v === '' ? '' : opt ? friendlyOptionLabel(tl(String(opt.label ?? v))) : seeded ?? String(v) }
|
|
800
|
+
})
|
|
801
|
+
.filter((r) => r.value)}
|
|
802
|
+
lines={lines}
|
|
803
|
+
totals={totalsRows}
|
|
804
|
+
fmt={fmt}
|
|
805
|
+
/>
|
|
806
|
+
</CollapsibleSection>
|
|
807
|
+
) : null
|
|
732
808
|
|
|
733
|
-
|
|
734
|
-
|
|
809
|
+
const checklist = <ValidationChecklist issues={shownIssues} title={t('documentEditor.review', { defaultValue: 'Revisa antes de guardar' })} />
|
|
810
|
+
|
|
811
|
+
return (
|
|
812
|
+
<div className="flex min-h-0 flex-1 flex-col" data-slot="document-editor" data-kind={kind} data-layout={fullscreen ? 'fullscreen' : 'dialog'}>
|
|
813
|
+
{fullscreen ? (
|
|
814
|
+
<div className="-mx-1 min-h-0 flex-1 overflow-y-auto px-1 py-2 lg:grid lg:grid-cols-[minmax(0,1fr)_340px] lg:gap-8">
|
|
815
|
+
<div className="min-w-0 space-y-8" data-slot="editor-main">
|
|
816
|
+
<FormErrorBanner message={formError} />
|
|
817
|
+
{essentials}
|
|
818
|
+
{loadFrom}
|
|
819
|
+
{linesSection}
|
|
820
|
+
{advanced}
|
|
821
|
+
{contributedSections}
|
|
822
|
+
{notes}
|
|
823
|
+
</div>
|
|
824
|
+
<aside className="mt-8 space-y-6 lg:sticky lg:top-0 lg:mt-0 lg:self-start" data-slot="editor-aside">
|
|
825
|
+
<div className="rounded-lg border p-4">{totalsPanel}</div>
|
|
826
|
+
{checklist}
|
|
827
|
+
{previewSection}
|
|
828
|
+
</aside>
|
|
829
|
+
</div>
|
|
830
|
+
) : (
|
|
831
|
+
<div className="-mx-1 min-h-0 flex-1 space-y-8 overflow-y-auto px-1 py-2">
|
|
832
|
+
<FormErrorBanner message={formError} />
|
|
833
|
+
{essentials}
|
|
834
|
+
{loadFrom}
|
|
835
|
+
{linesSection}
|
|
836
|
+
{advanced}
|
|
837
|
+
{contributedSections}
|
|
838
|
+
{notes}
|
|
839
|
+
{previewSection}
|
|
840
|
+
{checklist}
|
|
841
|
+
</div>
|
|
842
|
+
)}
|
|
735
843
|
|
|
736
844
|
<footer className="flex shrink-0 flex-wrap items-center justify-end gap-2 border-t pt-4" data-slot="editor-actions">
|
|
737
845
|
{footerContribs.map(({ id, component: C }) => (
|
|
@@ -753,12 +861,15 @@ export function DocumentEditor({
|
|
|
753
861
|
function SourcePicker({
|
|
754
862
|
source,
|
|
755
863
|
value,
|
|
864
|
+
seed,
|
|
756
865
|
header,
|
|
757
866
|
label,
|
|
758
867
|
onPick,
|
|
759
868
|
}: {
|
|
760
869
|
source: DocumentFormSource
|
|
761
870
|
value: string
|
|
871
|
+
/** Folio del origen ya cargado: el selector cerrado lo muestra en vez del UUID. */
|
|
872
|
+
seed?: SeedLabel | null
|
|
762
873
|
header: Record<string, any>
|
|
763
874
|
label: string
|
|
764
875
|
onPick: (id: string) => void
|
|
@@ -776,7 +887,7 @@ function SourcePicker({
|
|
|
776
887
|
)
|
|
777
888
|
return (
|
|
778
889
|
<div className="min-w-[240px] flex-1" data-source={source.key}>
|
|
779
|
-
{renderField(field, value, (v: any) => onPick(v == null || v === '' ? '' : String(v)), header)}
|
|
890
|
+
{renderField(field, value, (v: any) => onPick(v == null || v === '' ? '' : String(v)), header, seed ? { id: seed.value, label: seed.label } : undefined)}
|
|
780
891
|
</div>
|
|
781
892
|
)
|
|
782
893
|
}
|
|
@@ -45,7 +45,7 @@ import { emitRecordMutation } from './record-mutation-events'
|
|
|
45
45
|
import { isLineItemsField, resolveWidget, scopeValueFromFilterToken } from './dynamic-form-schema'
|
|
46
46
|
import { createCatalogProductSearch, withDefaultTaxRate } from './business/catalog-product-search'
|
|
47
47
|
import { DocumentEditor } from './business/document-editor'
|
|
48
|
-
import { editorLinesConfig, isEditorLayout } from './business/document-editor-model'
|
|
48
|
+
import { editorLinesConfig, isEditorLayout, isFullscreenEditor } from './business/document-editor-model'
|
|
49
49
|
import type {
|
|
50
50
|
ActionFieldDef,
|
|
51
51
|
DocumentFormType,
|
|
@@ -336,14 +336,22 @@ export function DocumentFormDialog({
|
|
|
336
336
|
if (open && type && delegatedModel) return null
|
|
337
337
|
|
|
338
338
|
// layout "editor": una sola pantalla por secciones (DocumentEditor). Sin él
|
|
339
|
-
// sigue el wizard de siempre (retrocompatible).
|
|
339
|
+
// sigue el wizard de siempre (retrocompatible). Un documento con renglones
|
|
340
|
+
// (factura, cotización, pedido, OC) ocupa toda la pantalla; NC y cobro
|
|
341
|
+
// siguen en el diálogo grande.
|
|
340
342
|
if (open && type && step !== 'type' && (isEditorLayout(type) || !!initialSource)) {
|
|
343
|
+
const fullscreen = isFullscreenEditor(type, forms)
|
|
341
344
|
return (
|
|
342
345
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
|
343
346
|
<DialogContent
|
|
344
|
-
className=
|
|
345
|
-
style={
|
|
347
|
+
className={fullscreen ? 'flex flex-col overflow-hidden sm:max-w-none' : 'flex max-h-[92dvh] flex-col overflow-hidden'}
|
|
348
|
+
style={
|
|
349
|
+
fullscreen
|
|
350
|
+
? { width: 'calc(100vw - 2rem)', maxWidth: '1440px', height: 'calc(100dvh - 2rem)', maxHeight: 'calc(100dvh - 2rem)' }
|
|
351
|
+
: { maxHeight: '92dvh', maxWidth: '960px', width: '95vw' }
|
|
352
|
+
}
|
|
346
353
|
data-slot="document-editor-dialog"
|
|
354
|
+
data-fullscreen={fullscreen ? 'true' : undefined}
|
|
347
355
|
>
|
|
348
356
|
<DialogHeader className="shrink-0">
|
|
349
357
|
<DialogTitle>{tl(type.label)}</DialogTitle>
|
|
@@ -359,6 +367,7 @@ export function DocumentFormDialog({
|
|
|
359
367
|
initialSource={initialSource}
|
|
360
368
|
searchProducts={lineSearch}
|
|
361
369
|
currency={currency}
|
|
370
|
+
fullscreen={fullscreen}
|
|
362
371
|
onCancel={() => (canGoBackToTypes ? setStep('type') : onOpenChange(false))}
|
|
363
372
|
onSaved={(rec) => {
|
|
364
373
|
onSaved?.(rec)
|