@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.
- package/CHANGELOG.md +45 -0
- package/dist/action-modal-dispatcher.d.ts +18 -1
- package/dist/action-modal-dispatcher.d.ts.map +1 -1
- package/dist/action-modal-dispatcher.js +66 -11
- package/dist/business/document-editor-model.d.ts +6 -1
- package/dist/business/document-editor-model.d.ts.map +1 -1
- package/dist/business/document-editor-model.js +48 -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 +72 -26
- package/dist/calendar-date.d.ts +12 -0
- package/dist/calendar-date.d.ts.map +1 -1
- package/dist/calendar-date.js +36 -0
- package/dist/document-form-dialog.d.ts +23 -2
- package/dist/document-form-dialog.d.ts.map +1 -1
- package/dist/document-form-dialog.js +49 -8
- package/dist/dynamic-crud-page.d.ts +7 -0
- package/dist/dynamic-crud-page.d.ts.map +1 -1
- package/dist/dynamic-crud-page.js +5 -4
- package/dist/index.d.ts +4 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -3
- 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/dist/types.d.ts +7 -0
- package/dist/types.d.ts.map +1 -1
- 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__/document-form-create-model.test.tsx +143 -0
- package/src/__tests__/field-default-today.test.tsx +116 -0
- package/src/__tests__/invoice-document-editor.test.tsx +267 -0
- package/src/__tests__/r5-self-label-settled.test.ts +57 -0
- package/src/action-modal-dispatcher.tsx +78 -11
- package/src/business/document-editor-model.ts +50 -7
- package/src/business/document-editor-panels.tsx +49 -0
- package/src/business/document-editor.tsx +249 -166
- package/src/calendar-date.ts +36 -0
- package/src/document-form-dialog.tsx +72 -8
- package/src/dynamic-crud-page.tsx +20 -2
- package/src/index.ts +8 -0
- package/src/record-mutation-events.ts +23 -0
- 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
|
|
336
|
-
|
|
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
|
-
|
|
532
|
-
|
|
533
|
-
|
|
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
|
-
|
|
573
|
-
<
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
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
|
-
{
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
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
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
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
|
-
</
|
|
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
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
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
|
-
|
|
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
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
)}
|
|
729
|
-
</
|
|
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
|
-
|
|
733
|
-
|
|
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 }) => (
|
package/src/calendar-date.ts
CHANGED
|
@@ -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
|
+
}
|