@asteby/metacore-runtime-react 49.0.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 (47) hide show
  1. package/CHANGELOG.md +45 -0
  2. package/dist/action-modal-dispatcher.d.ts +18 -1
  3. package/dist/action-modal-dispatcher.d.ts.map +1 -1
  4. package/dist/action-modal-dispatcher.js +66 -11
  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 +72 -26
  14. package/dist/calendar-date.d.ts +12 -0
  15. package/dist/calendar-date.d.ts.map +1 -1
  16. package/dist/calendar-date.js +36 -0
  17. package/dist/document-form-dialog.d.ts +23 -2
  18. package/dist/document-form-dialog.d.ts.map +1 -1
  19. package/dist/document-form-dialog.js +49 -8
  20. package/dist/dynamic-crud-page.d.ts +7 -0
  21. package/dist/dynamic-crud-page.d.ts.map +1 -1
  22. package/dist/dynamic-crud-page.js +5 -4
  23. package/dist/index.d.ts +4 -3
  24. package/dist/index.d.ts.map +1 -1
  25. package/dist/index.js +4 -3
  26. package/dist/record-mutation-events.d.ts +12 -0
  27. package/dist/record-mutation-events.d.ts.map +1 -1
  28. package/dist/record-mutation-events.js +18 -0
  29. package/dist/types.d.ts +7 -0
  30. package/dist/types.d.ts.map +1 -1
  31. package/package.json +1 -1
  32. package/src/__tests__/document-editor-model.test.ts +27 -0
  33. package/src/__tests__/document-editor.test.tsx +60 -0
  34. package/src/__tests__/document-form-create-model.test.tsx +143 -0
  35. package/src/__tests__/field-default-today.test.tsx +116 -0
  36. package/src/__tests__/invoice-document-editor.test.tsx +267 -0
  37. package/src/__tests__/r5-self-label-settled.test.ts +57 -0
  38. package/src/action-modal-dispatcher.tsx +78 -11
  39. package/src/business/document-editor-model.ts +50 -7
  40. package/src/business/document-editor-panels.tsx +49 -0
  41. package/src/business/document-editor.tsx +249 -166
  42. package/src/calendar-date.ts +36 -0
  43. package/src/document-form-dialog.tsx +72 -8
  44. package/src/dynamic-crud-page.tsx +20 -2
  45. package/src/index.ts +8 -0
  46. package/src/record-mutation-events.ts +23 -0
  47. package/src/types.ts +7 -0
@@ -7,6 +7,7 @@ import { Badge } from '@asteby/metacore-ui/primitives'
7
7
  import { AlertTriangle, ChevronDown, XCircle } from 'lucide-react'
8
8
  import type { CreditStatus, EditorIssue } from './document-editor-model'
9
9
  import type { Formatter } from './format'
10
+ import { computeLine, type LineItem } from './line-items'
10
11
 
11
12
  /** Bloque con título discreto y espaciado generoso (sin bordes pesados). */
12
13
  export function EditorSection({ title, hint, children, slot }: { title?: ReactNode; hint?: ReactNode; children: ReactNode; slot?: string }) {
@@ -162,3 +163,51 @@ export function PreviewPanel({ preview }: { preview: PreviewContent | null }) {
162
163
  if (preview.xml) return <pre className="max-h-96 overflow-auto rounded bg-muted p-3 text-[11px] leading-tight" data-slot="preview-xml">{preview.xml}</pre>
163
164
  return null
164
165
  }
166
+
167
+ export interface DraftPreviewProps {
168
+ title: string
169
+ party?: string
170
+ /** Datos del encabezado ya con su etiqueta y valor legibles. */
171
+ header: Array<{ label: string; value: string }>
172
+ lines: LineItem[]
173
+ totals: TotalsRow[]
174
+ fmt: Formatter
175
+ }
176
+
177
+ /**
178
+ * Vista previa local del documento (sin servidor ni borrador): cómo queda con
179
+ * lo capturado. Para tipos sin acción `preview`; la del servidor (PDF/XML) gana.
180
+ */
181
+ export function DraftPreview({ title, party, header, lines, totals, fmt }: DraftPreviewProps) {
182
+ const items = lines.filter((l) => l.kind === 'item')
183
+ return (
184
+ <article className="space-y-3 rounded border bg-background p-4 text-xs" data-slot="preview-draft">
185
+ <header className="flex flex-wrap items-baseline justify-between gap-2 border-b pb-2">
186
+ <span className="text-sm font-semibold">{title}</span>
187
+ {party && <span className="font-medium">{party}</span>}
188
+ </header>
189
+ {header.length > 0 && (
190
+ <dl className="grid grid-cols-2 gap-x-4 gap-y-1">
191
+ {header.map((h) => (
192
+ <div key={h.label} className="min-w-0">
193
+ <dt className="text-muted-foreground">{h.label}</dt>
194
+ <dd className="truncate">{h.value}</dd>
195
+ </div>
196
+ ))}
197
+ </dl>
198
+ )}
199
+ <table className="w-full tabular-nums">
200
+ <tbody>
201
+ {items.map((l) => (
202
+ <tr key={l.key} className="border-t align-top">
203
+ <td className="py-1 pr-2">{l.description}</td>
204
+ <td className="py-1 pr-2 text-right">{l.quantity}</td>
205
+ <td className="py-1 text-right">{fmt.money(computeLine(l).net)}</td>
206
+ </tr>
207
+ ))}
208
+ </tbody>
209
+ </table>
210
+ <TotalsPanel rows={totals} />
211
+ </article>
212
+ )
213
+ }
@@ -23,7 +23,7 @@ import { Button } from '@asteby/metacore-ui/primitives'
23
23
  import { Loader2 } from 'lucide-react'
24
24
  import { toast } from 'sonner'
25
25
  import { useApi } from '../api-context'
26
- import { useOrgTaxRate } from '../org-runtime-context'
26
+ import { useOrgTaxRate, useTimeZone } from '../org-runtime-context'
27
27
  import { FieldCell, FieldGrid, FieldLabel } from '../field-grid'
28
28
  import { FormErrorBanner } from './feedback'
29
29
  import { DocumentLinesGrid, type DocumentLinesGridProps, type LineItemsColumn } from './line-items-editor'
@@ -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
@@ -114,6 +123,7 @@ export function DocumentEditor({
114
123
  const fmt = useFormatter({ currency })
115
124
  const installed = useInstalledAddons()
116
125
  const orgTaxRate = useOrgTaxRate()
126
+ const orgTimeZone = useTimeZone()
117
127
  const tl = useCallback((s: string) => t(s, { defaultValue: s }), [t])
118
128
 
119
129
  const lineCfg = useMemo(() => editorLinesConfig(type, forms), [type, forms])
@@ -122,13 +132,14 @@ export function DocumentEditor({
122
132
  const amountKey = isAllocation ? allocationAmountField(type.fields) : undefined
123
133
 
124
134
  const [header, setHeader] = useState<Record<string, any>>(() => ({
125
- ...buildFieldDefaults(type.fields, record),
135
+ ...buildFieldDefaults(type.fields, record, undefined, { timeZone: orgTimeZone, now: today }),
126
136
  ...(type.defaults ?? {}),
127
137
  }))
128
138
  const [lines, setLines] = useState<LineItem[]>([])
129
139
  const [linesFromSourceDoc, setLinesFromSourceDoc] = useState(false)
130
140
  const [extFields, setExtFields] = useState<ActionFieldDef[]>([])
131
141
  const [party, setParty] = useState<Record<string, any> | null>(null)
142
+ const [partyLabels, setPartyLabels] = useState<Record<string, string>>({})
132
143
  const [openDocs, setOpenDocs] = useState<OpenDocument[]>([])
133
144
  const [docsLoading, setDocsLoading] = useState(false)
134
145
  const [alloc, setAlloc] = useState<PaymentAllocatorValue>({ strategy: 'oldest_due_first', manual: {} })
@@ -219,6 +230,25 @@ export function DocumentEditor({
219
230
  }
220
231
  }, [api, type.party, partyId])
221
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
+
222
252
  // Cobro: documentos abiertos de la contraparte.
223
253
  const openCfg = lineCfg?.open_documents
224
254
  const allocPartyId = openCfg ? header[openCfg.party_field] ?? partyId : undefined
@@ -332,8 +362,11 @@ export function DocumentEditor({
332
362
  params: { source: src.key, id, ...(draftId ? { exclude: draftId } : {}) },
333
363
  })
334
364
  return res?.data?.data ?? []
335
- } catch (err: any) {
336
- 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.
337
370
  }
338
371
  }
339
372
  let rels = relCache.current.get(src.model)
@@ -361,7 +394,7 @@ export function DocumentEditor({
361
394
  try {
362
395
  if (!isAllocation) {
363
396
  const rows = await fetchSourceRows(src, id)
364
- 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 })
365
398
  const { covered } = sourceLoadSummary(rows)
366
399
  setLines(loaded)
367
400
  setLoadedSource(src)
@@ -399,7 +432,7 @@ export function DocumentEditor({
399
432
  setBusy(null)
400
433
  }
401
434
  },
402
- [api, lineCfg, isAllocation, kind, t, fetchSourceRows],
435
+ [api, lineCfg, isAllocation, kind, t, fetchSourceRows, taxRate],
403
436
  )
404
437
 
405
438
  // Prefill al abrir desde un documento origen (acción de fila «Crear NC», «Facturar venta»…).
@@ -527,11 +560,10 @@ export function DocumentEditor({
527
560
  </FieldCell>
528
561
  ) : null
529
562
 
530
- const summaryRows = partySummaryRows(
531
- party,
532
- type.party?.summary,
533
- Object.fromEntries(allFields.map((f) => [f.key, tl(f.label)])),
534
- )
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
+ })
535
567
  const advancedSummary = groups.advanced
536
568
  .filter(visible)
537
569
  .map((f) => {
@@ -562,175 +594,226 @@ export function DocumentEditor({
562
594
  { label: t('documentEditor.total', { defaultValue: 'Total' }), value: fmt.money(totals.total), emphasis: true },
563
595
  ]
564
596
 
565
- const activeSource = sources.find((s) => s.key === sourceKey)
597
+ const activeSource = sources.find((s) => s.key === sourceKey) ?? (sources.length === 1 ? sources[0] : undefined)
566
598
  const linesTitle = lineCfg?.title
567
599
  ? tl(lineCfg.title)
568
600
  : isAllocation
569
601
  ? t('documentEditor.allocation_title', { defaultValue: 'Documentos a pagar' })
570
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
+ )
571
622
 
572
- return (
573
- <div className="flex min-h-0 flex-1 flex-col" data-slot="document-editor" data-kind={kind}>
574
- <div className="-mx-1 min-h-0 flex-1 space-y-8 overflow-y-auto px-1 py-2">
575
- <FormErrorBanner message={formError} />
576
-
577
- <EditorSection slot="editor-essentials">
578
- <FieldGrid>
579
- {groups.party && fieldCell(groups.party)}
580
- {groups.essential.map((f) => fieldCell(f))}
581
- </FieldGrid>
582
- {party && (
583
- <PartyCard name={party.name ?? party.legal_name} rows={summaryRows} credit={credit} fmt={fmt}>
584
- {partyContribs.map(({ id, component: C }) => (
585
- <C key={id} {...contribProps} />
586
- ))}
587
- </PartyCard>
588
- )}
589
- </EditorSection>
590
-
591
- {sources.length > 0 && !isAllocation && (
592
- <EditorSection
593
- slot="load-from-source"
594
- title={t('documentEditor.load_from', { defaultValue: 'Cargar desde…' })}
595
- hint={busy === 'source' ? t('documentEditor.loading_source', { defaultValue: 'Cargando…' }) : sourceNote}
596
- >
597
- <div className="flex flex-wrap items-center gap-2">
598
- {sources.length > 1 && (
599
- <select
600
- aria-label={t('documentEditor.source_kind', { defaultValue: 'Tipo de documento origen' })}
601
- className="h-9 rounded-md border bg-background px-2 text-sm"
602
- value={sourceKey}
603
- onChange={(e) => {
604
- setSourceKey(e.target.value)
605
- setSourceId('')
606
- }}
607
- >
608
- <option value="">{t('documentEditor.choose_source', { defaultValue: 'Elige…' })}</option>
609
- {sources.map((s) => (
610
- <option key={s.key} value={s.key}>
611
- {tl(s.label)}
612
- </option>
613
- ))}
614
- </select>
615
- )}
616
- {(activeSource ?? (sources.length === 1 ? sources[0] : undefined)) && (
617
- <SourcePicker
618
- source={(activeSource ?? sources[0])!}
619
- value={sourceId}
620
- header={header}
621
- label={tl((activeSource ?? sources[0])!.label)}
622
- onPick={(id) => {
623
- if (!activeSource) setSourceKey(sources[0].key)
624
- setSourceId(id)
625
- const src = activeSource ?? sources[0]
626
- if (id) void loadFromSource(src, id)
627
- }}
628
- />
629
- )}
630
- </div>
631
- </EditorSection>
632
- )}
633
-
634
- {lineCfg && (
635
- <EditorSection
636
- slot={isAllocation ? 'editor-allocation' : 'editor-lines'}
637
- title={linesTitle}
638
- hint={
639
- isAllocation && openBalance > 0 && amountKey ? (
640
- <button
641
- type="button"
642
- className="underline-offset-2 hover:underline"
643
- onClick={() => updateField(amountKey, openBalance)}
644
- >
645
- {t('documentEditor.pay_all', { defaultValue: 'Cobrar todo ({{amount}})', amount: fmt.money(openBalance) })}
646
- </button>
647
- ) : undefined
648
- }
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"
649
635
  >
650
- {isAllocation ? (
651
- allocPartyId ? (
652
- <PaymentAllocator
653
- documents={openDocs}
654
- result={allocation}
655
- value={alloc}
656
- onChange={setAlloc}
657
- currency={currency}
658
- loading={docsLoading}
659
- today={today}
660
- />
661
- ) : (
662
- <p className="text-sm text-muted-foreground">
663
- {t('documentEditor.pick_party_first', { defaultValue: 'Elige el cliente para ver sus documentos con saldo.' })}
664
- </p>
665
- )
666
- ) : (
667
- <DocumentLinesGrid
668
- value={lines}
669
- onChange={(next) => {
670
- setLines(next)
671
- 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('')
672
646
  }}
673
- columns={(lineCfg.columns as LineItemsColumn[] | undefined) ?? ['discount', 'tax']}
674
- priceSource={lineCfg.price_source ?? (kind === 'purchase' ? 'cost' : 'sale')}
675
- discountMode={lineCfg.discount_mode}
676
- mode={linesFromSourceDoc ? 'from_source' : 'free'}
677
- policy={kind === 'credit' ? { allowZeroQuantity: linesFromSourceDoc } : undefined}
678
- search={search}
679
- currency={currency}
680
- />
681
- )}
682
- <TotalsPanel rows={totalsRows} />
683
- </EditorSection>
684
- )}
685
-
686
- {(groups.advanced.length > 0 || headerContribs.length > 0) && (
687
- <CollapsibleSection
688
- slot="advanced-options"
689
- title={t('documentEditor.advanced', { defaultValue: 'Opciones fiscales' })}
690
- summary={advancedSummary || t('documentEditor.advanced_defaults', { defaultValue: 'Valores sugeridos' })}
691
- open={advancedOpen}
692
- onOpenChange={setAdvancedOpen}
693
- >
694
- {groups.advanced.length > 0 && <FieldGrid>{groups.advanced.map((f) => fieldCell(f))}</FieldGrid>}
695
- {headerContribs.map(({ id, component: C }) => (
696
- <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>
697
653
  ))}
698
- </CollapsibleSection>
654
+ </div>
699
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
+ )
700
673
 
701
- {[...bodyContribs, ...sideContribs].map(({ id, component: C }) => (
702
- <section key={id} data-contribution={id}>
703
- <C {...contribProps} />
704
- </section>
705
- ))}
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
+ )
706
723
 
707
- {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
+ )
708
738
 
709
- {previewEnabled && (
710
- <CollapsibleSection
711
- slot="preview"
712
- title={tl(type.preview!.label ?? 'Vista previa')}
713
- open={previewOpen}
714
- onOpenChange={togglePreview}
715
- >
716
- {busy === 'preview' ? (
717
- <p className="flex items-center gap-2 text-sm text-muted-foreground">
718
- <Loader2 className="size-4 animate-spin" aria-hidden />
719
- {t('documentEditor.preview_loading', { defaultValue: 'Generando vista previa…' })}
720
- </p>
721
- ) : (
722
- <>
723
- <PreviewPanel preview={preview} />
724
- <Button type="button" variant="ghost" size="sm" onClick={() => void runPreview()} disabled={busy !== null}>
725
- {t('documentEditor.preview_refresh', { defaultValue: 'Actualizar vista previa' })}
726
- </Button>
727
- </>
728
- )}
729
- </CollapsibleSection>
730
- )}
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
731
782
 
732
- <ValidationChecklist issues={shownIssues} title={t('documentEditor.review', { defaultValue: 'Revisa antes de guardar' })} />
733
- </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
+ )}
734
817
 
735
818
  <footer className="flex shrink-0 flex-wrap items-center justify-end gap-2 border-t pt-4" data-slot="editor-actions">
736
819
  {footerContribs.map(({ id, component: C }) => (
@@ -26,3 +26,39 @@ export function isCalendarDayValue(v: unknown): boolean {
26
26
  if (typeof v !== 'string') return false
27
27
  return /^\d{4}-\d{2}-\d{2}(?:[T ]00:00:00(?:\.0+)?(?:Z|\+00:00)?)?$/.test(v.trim())
28
28
  }
29
+
30
+ /**
31
+ * Today's calendar day ("YYYY-MM-DD") in `timeZone` (IANA, e.g. the org's
32
+ * `America/Mexico_City`), or in the browser zone when absent/invalid. A form
33
+ * that seeds «hoy» must use the org's day, not UTC's: at 19:00 in Mexico City
34
+ * UTC is already tomorrow.
35
+ */
36
+ export function todayInZone(timeZone?: string, now: Date = new Date()): string {
37
+ if (timeZone) {
38
+ try {
39
+ // en-CA formats as YYYY-MM-DD.
40
+ return new Intl.DateTimeFormat('en-CA', {
41
+ timeZone,
42
+ year: 'numeric',
43
+ month: '2-digit',
44
+ day: '2-digit',
45
+ }).format(now)
46
+ } catch {
47
+ /* unknown zone → browser day */
48
+ }
49
+ }
50
+ const y = now.getFullYear()
51
+ const m = String(now.getMonth() + 1).padStart(2, '0')
52
+ const d = String(now.getDate()).padStart(2, '0')
53
+ return `${y}-${m}-${d}`
54
+ }
55
+
56
+ /**
57
+ * A field default that names «today» instead of a fixed day: `$today` (the
58
+ * token flows already use) or `today`. Case-insensitive.
59
+ */
60
+ export function isTodayToken(v: unknown): boolean {
61
+ if (typeof v !== 'string') return false
62
+ const s = v.trim().toLowerCase()
63
+ return s === '$today' || s === 'today'
64
+ }