@asteby/metacore-runtime-react 49.1.0 → 49.2.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.
Files changed (33) hide show
  1. package/CHANGELOG.md +26 -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 +6 -1
  6. package/dist/business/document-editor-model.d.ts.map +1 -1
  7. package/dist/business/document-editor-model.js +48 -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 +69 -24
  14. package/dist/document-form-dialog.d.ts.map +1 -1
  15. package/dist/document-form-dialog.js +8 -3
  16. package/dist/index.d.ts +1 -1
  17. package/dist/index.d.ts.map +1 -1
  18. package/dist/index.js +1 -1
  19. package/dist/record-mutation-events.d.ts +12 -0
  20. package/dist/record-mutation-events.d.ts.map +1 -1
  21. package/dist/record-mutation-events.js +18 -0
  22. package/package.json +1 -1
  23. package/src/__tests__/document-editor-model.test.ts +27 -0
  24. package/src/__tests__/document-editor.test.tsx +60 -0
  25. package/src/__tests__/invoice-document-editor.test.tsx +267 -0
  26. package/src/__tests__/r5-self-label-settled.test.ts +57 -0
  27. package/src/action-modal-dispatcher.tsx +34 -5
  28. package/src/business/document-editor-model.ts +50 -7
  29. package/src/business/document-editor-panels.tsx +49 -0
  30. package/src/business/document-editor.tsx +246 -164
  31. package/src/document-form-dialog.tsx +13 -4
  32. package/src/index.ts +2 -0
  33. package/src/record-mutation-events.ts +23 -0
@@ -44,11 +44,13 @@ import {
44
44
  partySummaryRows,
45
45
  splitEditorFields,
46
46
  toOpenDocuments,
47
+ friendlyOptionLabel,
47
48
  withFriendlyOptions,
48
49
  type EditorIssue,
49
50
  } from './document-editor-model'
50
51
  import {
51
52
  CollapsibleSection,
53
+ DraftPreview,
52
54
  EditorSection,
53
55
  PartyCard,
54
56
  PreviewPanel,
@@ -89,6 +91,12 @@ export interface DocumentEditorProps {
89
91
  currency?: string
90
92
  /** Hoy (días de atraso del reparto; inyectable en tests). */
91
93
  today?: Date
94
+ /**
95
+ * Pantalla completa (DocumentFormDialog la pide para documentos con
96
+ * renglones): a dos columnas en pantallas anchas, con totales, revisión y
97
+ * vista previa a la derecha; en una sola columna en móvil.
98
+ */
99
+ fullscreen?: boolean
92
100
  }
93
101
 
94
102
  type Busy = 'save' | 'preview' | 'source' | null
@@ -107,6 +115,7 @@ export function DocumentEditor({
107
115
  defaultTaxRate,
108
116
  currency,
109
117
  today,
118
+ fullscreen = false,
110
119
  }: DocumentEditorProps) {
111
120
  const { t, i18n } = useTranslation()
112
121
  const lang = i18n?.language
@@ -130,6 +139,7 @@ export function DocumentEditor({
130
139
  const [linesFromSourceDoc, setLinesFromSourceDoc] = useState(false)
131
140
  const [extFields, setExtFields] = useState<ActionFieldDef[]>([])
132
141
  const [party, setParty] = useState<Record<string, any> | null>(null)
142
+ const [partyLabels, setPartyLabels] = useState<Record<string, string>>({})
133
143
  const [openDocs, setOpenDocs] = useState<OpenDocument[]>([])
134
144
  const [docsLoading, setDocsLoading] = useState(false)
135
145
  const [alloc, setAlloc] = useState<PaymentAllocatorValue>({ strategy: 'oldest_due_first', manual: {} })
@@ -220,6 +230,25 @@ export function DocumentEditor({
220
230
  }
221
231
  }, [api, type.party, partyId])
222
232
 
233
+ // Etiquetas de la tarjeta (RFC, régimen…): las del formulario del modelo de
234
+ // la contraparte, incluidas sus extensiones; sin ellas se ve la columna cruda.
235
+ const partyModel = type.party?.model
236
+ useEffect(() => {
237
+ if (!partyModel) return
238
+ let cancelled = false
239
+ api.get(`/metadata/modal/${partyModel}`)
240
+ .then((res: any) => {
241
+ const fields: any[] = res?.data?.data?.fields ?? res?.data?.fields ?? []
242
+ const out: Record<string, string> = {}
243
+ for (const f of fields) if (typeof f?.key === 'string' && typeof f?.label === 'string' && f.label) out[f.key] = f.label
244
+ if (!cancelled) setPartyLabels(out)
245
+ })
246
+ .catch(() => {})
247
+ return () => {
248
+ cancelled = true
249
+ }
250
+ }, [api, partyModel])
251
+
223
252
  // Cobro: documentos abiertos de la contraparte.
224
253
  const openCfg = lineCfg?.open_documents
225
254
  const allocPartyId = openCfg ? header[openCfg.party_field] ?? partyId : undefined
@@ -333,8 +362,11 @@ export function DocumentEditor({
333
362
  params: { source: src.key, id, ...(draftId ? { exclude: draftId } : {}) },
334
363
  })
335
364
  return res?.data?.data ?? []
336
- } catch (err: any) {
337
- if (err?.response?.status !== 404) throw err
365
+ } catch {
366
+ // Un host sin `source-lines` (404, o la ruta del registro
367
+ // contestando 400/500 a «source-lines» como id) o un origen que
368
+ // el host aún no sabe calcular: se precarga de la relación y el
369
+ // guardado del servidor sigue validando lo pendiente.
338
370
  }
339
371
  }
340
372
  let rels = relCache.current.get(src.model)
@@ -362,7 +394,7 @@ export function DocumentEditor({
362
394
  try {
363
395
  if (!isAllocation) {
364
396
  const rows = await fetchSourceRows(src, id)
365
- const loaded = linesFromSource(rows, src.map, { kind, discountMode: lineCfg.discount_mode })
397
+ const loaded = linesFromSource(rows, src.map, { kind, discountMode: lineCfg.discount_mode, defaultTaxRate: taxRate })
366
398
  const { covered } = sourceLoadSummary(rows)
367
399
  setLines(loaded)
368
400
  setLoadedSource(src)
@@ -400,7 +432,7 @@ export function DocumentEditor({
400
432
  setBusy(null)
401
433
  }
402
434
  },
403
- [api, lineCfg, isAllocation, kind, t, fetchSourceRows],
435
+ [api, lineCfg, isAllocation, kind, t, fetchSourceRows, taxRate],
404
436
  )
405
437
 
406
438
  // Prefill al abrir desde un documento origen (acción de fila «Crear NC», «Facturar venta»…).
@@ -528,11 +560,10 @@ export function DocumentEditor({
528
560
  </FieldCell>
529
561
  ) : null
530
562
 
531
- const summaryRows = partySummaryRows(
532
- party,
533
- type.party?.summary,
534
- Object.fromEntries(allFields.map((f) => [f.key, tl(f.label)])),
535
- )
563
+ const summaryRows = partySummaryRows(party, type.party?.summary, {
564
+ ...Object.fromEntries(Object.entries(partyLabels).map(([k, v]) => [k, tl(v)])),
565
+ ...Object.fromEntries(allFields.map((f) => [f.key, tl(f.label)])),
566
+ })
536
567
  const advancedSummary = groups.advanced
537
568
  .filter(visible)
538
569
  .map((f) => {
@@ -563,175 +594,226 @@ export function DocumentEditor({
563
594
  { label: t('documentEditor.total', { defaultValue: 'Total' }), value: fmt.money(totals.total), emphasis: true },
564
595
  ]
565
596
 
566
- const activeSource = sources.find((s) => s.key === sourceKey)
597
+ const activeSource = sources.find((s) => s.key === sourceKey) ?? (sources.length === 1 ? sources[0] : undefined)
567
598
  const linesTitle = lineCfg?.title
568
599
  ? tl(lineCfg.title)
569
600
  : isAllocation
570
601
  ? t('documentEditor.allocation_title', { defaultValue: 'Documentos a pagar' })
571
602
  : t('documentEditor.lines_title', { defaultValue: 'Conceptos' })
603
+ // Sin acción de vista previa del servidor, un documento con renglones ofrece
604
+ // la suya local (encabezado, renglones y totales): sin borrador ni red.
605
+ const localPreview = !previewEnabled && !type.preview && !!lineCfg && !isAllocation && kind !== 'credit'
606
+
607
+ const essentials = (
608
+ <EditorSection slot="editor-essentials">
609
+ <FieldGrid>
610
+ {groups.party && fieldCell(groups.party)}
611
+ {groups.essential.map((f) => fieldCell(f))}
612
+ </FieldGrid>
613
+ {party && (
614
+ <PartyCard name={party.name ?? party.legal_name} rows={summaryRows} credit={credit} fmt={fmt}>
615
+ {partyContribs.map(({ id, component: C }) => (
616
+ <C key={id} {...contribProps} />
617
+ ))}
618
+ </PartyCard>
619
+ )}
620
+ </EditorSection>
621
+ )
572
622
 
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
- }
623
+ const loadFrom = sources.length > 0 && !isAllocation && (
624
+ <EditorSection
625
+ slot="load-from-source"
626
+ title={t('documentEditor.load_from', { defaultValue: 'Cargar desde…' })}
627
+ hint={busy === 'source' ? t('documentEditor.loading_source', { defaultValue: 'Cargando…' }) : sourceNote}
628
+ >
629
+ <div className="flex flex-wrap items-center gap-2">
630
+ {sources.length > 1 && (
631
+ <div
632
+ role="radiogroup"
633
+ aria-label={t('documentEditor.source_kind', { defaultValue: 'Tipo de documento origen' })}
634
+ className="inline-flex flex-wrap gap-1 rounded-md border p-1"
650
635
  >
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([])
636
+ {sources.map((s) => (
637
+ <button
638
+ key={s.key}
639
+ type="button"
640
+ role="radio"
641
+ aria-checked={s.key === sourceKey}
642
+ data-source-kind={s.key}
643
+ onClick={() => {
644
+ setSourceKey(s.key)
645
+ setSourceId('')
673
646
  }}
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} />
647
+ className={`rounded px-3 py-1.5 text-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring ${
648
+ s.key === sourceKey ? 'bg-primary/10 font-medium text-primary' : 'text-muted-foreground hover:bg-accent'
649
+ }`}
650
+ >
651
+ {tl(s.label)}
652
+ </button>
698
653
  ))}
699
- </CollapsibleSection>
654
+ </div>
700
655
  )}
656
+ {activeSource && (
657
+ <SourcePicker
658
+ key={activeSource.key}
659
+ source={activeSource}
660
+ value={sourceId}
661
+ header={header}
662
+ label={tl(activeSource.label)}
663
+ onPick={(id) => {
664
+ setSourceKey(activeSource.key)
665
+ setSourceId(id)
666
+ if (id) void loadFromSource(activeSource, id)
667
+ }}
668
+ />
669
+ )}
670
+ </div>
671
+ </EditorSection>
672
+ )
701
673
 
702
- {[...bodyContribs, ...sideContribs].map(({ id, component: C }) => (
703
- <section key={id} data-contribution={id}>
704
- <C {...contribProps} />
705
- </section>
706
- ))}
674
+ const totalsPanel = <TotalsPanel rows={totalsRows} />
675
+
676
+ const linesSection = lineCfg && (
677
+ <EditorSection
678
+ slot={isAllocation ? 'editor-allocation' : 'editor-lines'}
679
+ title={linesTitle}
680
+ hint={
681
+ isAllocation && openBalance > 0 && amountKey ? (
682
+ <button type="button" className="underline-offset-2 hover:underline" onClick={() => updateField(amountKey, openBalance)}>
683
+ {t('documentEditor.pay_all', { defaultValue: 'Cobrar todo ({{amount}})', amount: fmt.money(openBalance) })}
684
+ </button>
685
+ ) : undefined
686
+ }
687
+ >
688
+ {isAllocation ? (
689
+ allocPartyId ? (
690
+ <PaymentAllocator
691
+ documents={openDocs}
692
+ result={allocation}
693
+ value={alloc}
694
+ onChange={setAlloc}
695
+ currency={currency}
696
+ loading={docsLoading}
697
+ today={today}
698
+ />
699
+ ) : (
700
+ <p className="text-sm text-muted-foreground">
701
+ {t('documentEditor.pick_party_first', { defaultValue: 'Elige el cliente para ver sus documentos con saldo.' })}
702
+ </p>
703
+ )
704
+ ) : (
705
+ <DocumentLinesGrid
706
+ value={lines}
707
+ onChange={(next) => {
708
+ setLines(next)
709
+ setServerIssues([])
710
+ }}
711
+ columns={(lineCfg.columns as LineItemsColumn[] | undefined) ?? ['discount', 'tax']}
712
+ priceSource={lineCfg.price_source ?? (kind === 'purchase' ? 'cost' : 'sale')}
713
+ discountMode={lineCfg.discount_mode}
714
+ mode={linesFromSourceDoc ? 'from_source' : 'free'}
715
+ policy={kind === 'credit' ? { allowZeroQuantity: linesFromSourceDoc } : undefined}
716
+ search={search}
717
+ currency={currency}
718
+ />
719
+ )}
720
+ {!fullscreen && totalsPanel}
721
+ </EditorSection>
722
+ )
707
723
 
708
- {groups.notes.length > 0 && <FieldGrid>{groups.notes.map((f) => fieldCell(f, true))}</FieldGrid>}
724
+ const advanced = (groups.advanced.length > 0 || headerContribs.length > 0) && (
725
+ <CollapsibleSection
726
+ slot="advanced-options"
727
+ title={t('documentEditor.advanced', { defaultValue: 'Opciones fiscales' })}
728
+ summary={advancedSummary || t('documentEditor.advanced_defaults', { defaultValue: 'Valores sugeridos' })}
729
+ open={advancedOpen}
730
+ onOpenChange={setAdvancedOpen}
731
+ >
732
+ {groups.advanced.length > 0 && <FieldGrid>{groups.advanced.map((f) => fieldCell(f))}</FieldGrid>}
733
+ {headerContribs.map(({ id, component: C }) => (
734
+ <C key={id} {...contribProps} />
735
+ ))}
736
+ </CollapsibleSection>
737
+ )
709
738
 
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
- )}
739
+ const contributedSections = [...bodyContribs, ...sideContribs].map(({ id, component: C }) => (
740
+ <section key={id} data-contribution={id}>
741
+ <C {...contribProps} />
742
+ </section>
743
+ ))
744
+
745
+ const notes = groups.notes.length > 0 && <FieldGrid>{groups.notes.map((f) => fieldCell(f, true))}</FieldGrid>
746
+
747
+ const previewSection = previewEnabled ? (
748
+ <CollapsibleSection slot="preview" title={tl(type.preview!.label ?? 'Vista previa')} open={previewOpen} onOpenChange={togglePreview}>
749
+ {busy === 'preview' ? (
750
+ <p className="flex items-center gap-2 text-sm text-muted-foreground">
751
+ <Loader2 className="size-4 animate-spin" aria-hidden />
752
+ {t('documentEditor.preview_loading', { defaultValue: 'Generando vista previa…' })}
753
+ </p>
754
+ ) : (
755
+ <>
756
+ <PreviewPanel preview={preview} />
757
+ <Button type="button" variant="ghost" size="sm" onClick={() => void runPreview()} disabled={busy !== null}>
758
+ {t('documentEditor.preview_refresh', { defaultValue: 'Actualizar vista previa' })}
759
+ </Button>
760
+ </>
761
+ )}
762
+ </CollapsibleSection>
763
+ ) : localPreview ? (
764
+ <CollapsibleSection slot="preview" title={t('documentEditor.preview', { defaultValue: 'Vista previa' })} open={previewOpen} onOpenChange={setPreviewOpen}>
765
+ <DraftPreview
766
+ title={tl(type.label)}
767
+ party={party ? String(party.name ?? party.legal_name ?? '') : undefined}
768
+ header={[...groups.essential, ...groups.advanced]
769
+ .filter(visible)
770
+ .map((f) => {
771
+ const v = header[f.key]
772
+ const opt = f.options?.find((o) => String(o.value) === String(v))
773
+ return { label: tl(f.label), value: v == null || v === '' ? '' : opt ? friendlyOptionLabel(tl(String(opt.label ?? v))) : String(v) }
774
+ })
775
+ .filter((r) => r.value)}
776
+ lines={lines}
777
+ totals={totalsRows}
778
+ fmt={fmt}
779
+ />
780
+ </CollapsibleSection>
781
+ ) : null
732
782
 
733
- <ValidationChecklist issues={shownIssues} title={t('documentEditor.review', { defaultValue: 'Revisa antes de guardar' })} />
734
- </div>
783
+ const checklist = <ValidationChecklist issues={shownIssues} title={t('documentEditor.review', { defaultValue: 'Revisa antes de guardar' })} />
784
+
785
+ return (
786
+ <div className="flex min-h-0 flex-1 flex-col" data-slot="document-editor" data-kind={kind} data-layout={fullscreen ? 'fullscreen' : 'dialog'}>
787
+ {fullscreen ? (
788
+ <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">
789
+ <div className="min-w-0 space-y-8" data-slot="editor-main">
790
+ <FormErrorBanner message={formError} />
791
+ {essentials}
792
+ {loadFrom}
793
+ {linesSection}
794
+ {advanced}
795
+ {contributedSections}
796
+ {notes}
797
+ </div>
798
+ <aside className="mt-8 space-y-6 lg:sticky lg:top-0 lg:mt-0 lg:self-start" data-slot="editor-aside">
799
+ <div className="rounded-lg border p-4">{totalsPanel}</div>
800
+ {checklist}
801
+ {previewSection}
802
+ </aside>
803
+ </div>
804
+ ) : (
805
+ <div className="-mx-1 min-h-0 flex-1 space-y-8 overflow-y-auto px-1 py-2">
806
+ <FormErrorBanner message={formError} />
807
+ {essentials}
808
+ {loadFrom}
809
+ {linesSection}
810
+ {advanced}
811
+ {contributedSections}
812
+ {notes}
813
+ {previewSection}
814
+ {checklist}
815
+ </div>
816
+ )}
735
817
 
736
818
  <footer className="flex shrink-0 flex-wrap items-center justify-end gap-2 border-t pt-4" data-slot="editor-actions">
737
819
  {footerContribs.map(({ id, component: C }) => (
@@ -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)
package/src/index.ts CHANGED
@@ -201,8 +201,10 @@ export {
201
201
  } from './list-primary-action'
202
202
  export {
203
203
  emitRecordMutation,
204
+ emitRecordMutationSettled,
204
205
  subscribeRecordMutations,
205
206
  RECORD_MUTATION_EVENT,
207
+ RECORD_MUTATION_SETTLE_DELAYS,
206
208
  type RecordMutationKind,
207
209
  type RecordMutationDetail,
208
210
  } from './record-mutation-events'
@@ -31,6 +31,29 @@ export function emitRecordMutation(model: string | undefined | null, kind: Recor
31
31
  }
32
32
  }
33
33
 
34
+ /**
35
+ * Delays (ms) at which a mutation is announced again. The backend's event
36
+ * subscribers write AFTER the action answered (stamping → the invoice's state,
37
+ * a payment → its auto-stamped REP), so a list or tab that reloaded at once kept
38
+ * showing the previous state until a manual reload (retest Pitsline r5).
39
+ */
40
+ export const RECORD_MUTATION_SETTLE_DELAYS: readonly number[] = [1500, 5000]
41
+
42
+ /**
43
+ * Announce a mutation now and again after `delays`, so what the server derives
44
+ * asynchronously from it reaches mounted lists too. Returns a cancel function.
45
+ */
46
+ export function emitRecordMutationSettled(
47
+ model: string | undefined | null,
48
+ kind: RecordMutationKind,
49
+ delays: readonly number[] = RECORD_MUTATION_SETTLE_DELAYS,
50
+ ): () => void {
51
+ emitRecordMutation(model, kind)
52
+ if (!model || typeof window === 'undefined') return () => {}
53
+ const timers = delays.map((ms) => window.setTimeout(() => emitRecordMutation(model, kind), ms))
54
+ return () => timers.forEach((t) => window.clearTimeout(t))
55
+ }
56
+
34
57
  /**
35
58
  * Listen for mutations of `model` (case-insensitive). Returns an unsubscribe.
36
59
  */