@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.
Files changed (39) hide show
  1. package/CHANGELOG.md +37 -0
  2. package/dist/action-modal-dispatcher.d.ts +1 -0
  3. package/dist/action-modal-dispatcher.d.ts.map +1 -1
  4. package/dist/action-modal-dispatcher.js +34 -5
  5. package/dist/business/document-editor-model.d.ts +27 -1
  6. package/dist/business/document-editor-model.d.ts.map +1 -1
  7. package/dist/business/document-editor-model.js +104 -7
  8. package/dist/business/document-editor-panels.d.ts +18 -0
  9. package/dist/business/document-editor-panels.d.ts.map +1 -1
  10. package/dist/business/document-editor-panels.js +9 -0
  11. package/dist/business/document-editor.d.ts +7 -1
  12. package/dist/business/document-editor.d.ts.map +1 -1
  13. package/dist/business/document-editor.js +106 -41
  14. package/dist/document-form-dialog.d.ts.map +1 -1
  15. package/dist/document-form-dialog.js +8 -3
  16. package/dist/dynamic-row-actions.d.ts +6 -0
  17. package/dist/dynamic-row-actions.d.ts.map +1 -1
  18. package/dist/dynamic-row-actions.js +9 -4
  19. package/dist/index.d.ts +1 -1
  20. package/dist/index.d.ts.map +1 -1
  21. package/dist/index.js +1 -1
  22. package/dist/record-mutation-events.d.ts +12 -0
  23. package/dist/record-mutation-events.d.ts.map +1 -1
  24. package/dist/record-mutation-events.js +18 -0
  25. package/package.json +1 -1
  26. package/src/__tests__/document-editor-model.test.ts +27 -0
  27. package/src/__tests__/document-editor.test.tsx +60 -0
  28. package/src/__tests__/invoice-document-editor.test.tsx +267 -0
  29. package/src/__tests__/r5-self-label-settled.test.ts +57 -0
  30. package/src/__tests__/r6-editor-source-seeds.test.tsx +137 -0
  31. package/src/__tests__/row-action-link-navigation.test.ts +10 -6
  32. package/src/action-modal-dispatcher.tsx +34 -5
  33. package/src/business/document-editor-model.ts +118 -7
  34. package/src/business/document-editor-panels.tsx +49 -0
  35. package/src/business/document-editor.tsx +288 -177
  36. package/src/document-form-dialog.tsx +13 -4
  37. package/src/dynamic-row-actions.tsx +12 -4
  38. package/src/index.ts +2 -0
  39. 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 (err: any) {
337
- if (err?.response?.status !== 404) throw err
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, undefined, fieldErrors)}
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
- party,
533
- type.party?.summary,
534
- Object.fromEntries(allFields.map((f) => [f.key, tl(f.label)])),
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?.find((o) => String(o.value) === String(v))
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
- return (
574
- <div className="flex min-h-0 flex-1 flex-col" data-slot="document-editor" data-kind={kind}>
575
- <div className="-mx-1 min-h-0 flex-1 space-y-8 overflow-y-auto px-1 py-2">
576
- <FormErrorBanner message={formError} />
577
-
578
- <EditorSection slot="editor-essentials">
579
- <FieldGrid>
580
- {groups.party && fieldCell(groups.party)}
581
- {groups.essential.map((f) => fieldCell(f))}
582
- </FieldGrid>
583
- {party && (
584
- <PartyCard name={party.name ?? party.legal_name} rows={summaryRows} credit={credit} fmt={fmt}>
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
- {isAllocation ? (
652
- allocPartyId ? (
653
- <PaymentAllocator
654
- documents={openDocs}
655
- result={allocation}
656
- value={alloc}
657
- onChange={setAlloc}
658
- currency={currency}
659
- loading={docsLoading}
660
- today={today}
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
- columns={(lineCfg.columns as LineItemsColumn[] | undefined) ?? ['discount', 'tax']}
675
- priceSource={lineCfg.price_source ?? (kind === 'purchase' ? 'cost' : 'sale')}
676
- discountMode={lineCfg.discount_mode}
677
- mode={linesFromSourceDoc ? 'from_source' : 'free'}
678
- policy={kind === 'credit' ? { allowZeroQuantity: linesFromSourceDoc } : undefined}
679
- search={search}
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
- </CollapsibleSection>
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
- {[...bodyContribs, ...sideContribs].map(({ id, component: C }) => (
703
- <section key={id} data-contribution={id}>
704
- <C {...contribProps} />
705
- </section>
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
- {groups.notes.length > 0 && <FieldGrid>{groups.notes.map((f) => fieldCell(f, true))}</FieldGrid>}
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
- {previewEnabled && (
711
- <CollapsibleSection
712
- slot="preview"
713
- title={tl(type.preview!.label ?? 'Vista previa')}
714
- open={previewOpen}
715
- onOpenChange={togglePreview}
716
- >
717
- {busy === 'preview' ? (
718
- <p className="flex items-center gap-2 text-sm text-muted-foreground">
719
- <Loader2 className="size-4 animate-spin" aria-hidden />
720
- {t('documentEditor.preview_loading', { defaultValue: 'Generando vista previa…' })}
721
- </p>
722
- ) : (
723
- <>
724
- <PreviewPanel preview={preview} />
725
- <Button type="button" variant="ghost" size="sm" onClick={() => void runPreview()} disabled={busy !== null}>
726
- {t('documentEditor.preview_refresh', { defaultValue: 'Actualizar vista previa' })}
727
- </Button>
728
- </>
729
- )}
730
- </CollapsibleSection>
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
- <ValidationChecklist issues={shownIssues} title={t('documentEditor.review', { defaultValue: 'Revisa antes de guardar' })} />
734
- </div>
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="flex max-h-[92dvh] flex-col overflow-hidden"
345
- style={{ maxHeight: '92dvh', maxWidth: '960px', width: '95vw' }}
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)