@asteby/metacore-runtime-react 47.1.0 → 48.0.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 (145) hide show
  1. package/CHANGELOG.md +29 -0
  2. package/dist/action-modal-dispatcher.d.ts.map +1 -1
  3. package/dist/action-modal-dispatcher.js +24 -4
  4. package/dist/business/catalog-product-search.d.ts +25 -0
  5. package/dist/business/catalog-product-search.d.ts.map +1 -0
  6. package/dist/business/catalog-product-search.js +57 -0
  7. package/dist/business/catalog.d.ts.map +1 -1
  8. package/dist/business/catalog.js +2 -0
  9. package/dist/business/document-editor-model.d.ts +107 -0
  10. package/dist/business/document-editor-model.d.ts.map +1 -0
  11. package/dist/business/document-editor-model.js +279 -0
  12. package/dist/business/document-editor-panels.d.ts +64 -0
  13. package/dist/business/document-editor-panels.d.ts.map +1 -0
  14. package/dist/business/document-editor-panels.js +58 -0
  15. package/dist/business/document-editor.d.ts +27 -0
  16. package/dist/business/document-editor.d.ts.map +1 -0
  17. package/dist/business/document-editor.js +445 -0
  18. package/dist/business/document-lines.d.ts.map +1 -1
  19. package/dist/business/document-lines.js +5 -1
  20. package/dist/business/index.d.ts +5 -0
  21. package/dist/business/index.d.ts.map +1 -1
  22. package/dist/business/index.js +5 -0
  23. package/dist/business/line-items.d.ts +7 -0
  24. package/dist/business/line-items.d.ts.map +1 -1
  25. package/dist/business/line-items.js +6 -1
  26. package/dist/business/payment-allocator.d.ts +19 -0
  27. package/dist/business/payment-allocator.d.ts.map +1 -0
  28. package/dist/business/payment-allocator.js +31 -0
  29. package/dist/business/product-search.d.ts +4 -0
  30. package/dist/business/product-search.d.ts.map +1 -1
  31. package/dist/dialogs/dynamic-record.d.ts.map +1 -1
  32. package/dist/dialogs/dynamic-record.js +14 -7
  33. package/dist/document-form-dialog.d.ts +31 -2
  34. package/dist/document-form-dialog.d.ts.map +1 -1
  35. package/dist/document-form-dialog.js +43 -11
  36. package/dist/dynamic-columns-helpers.d.ts +9 -0
  37. package/dist/dynamic-columns-helpers.d.ts.map +1 -1
  38. package/dist/dynamic-columns-helpers.js +19 -0
  39. package/dist/dynamic-columns.d.ts +8 -2
  40. package/dist/dynamic-columns.d.ts.map +1 -1
  41. package/dist/dynamic-columns.js +21 -5
  42. package/dist/dynamic-crud-page.d.ts +6 -0
  43. package/dist/dynamic-crud-page.d.ts.map +1 -1
  44. package/dist/dynamic-crud-page.js +14 -5
  45. package/dist/dynamic-form-schema.d.ts.map +1 -1
  46. package/dist/dynamic-form-schema.js +15 -3
  47. package/dist/dynamic-form.d.ts +16 -1
  48. package/dist/dynamic-form.d.ts.map +1 -1
  49. package/dist/dynamic-form.js +78 -20
  50. package/dist/dynamic-kanban.d.ts +9 -3
  51. package/dist/dynamic-kanban.d.ts.map +1 -1
  52. package/dist/dynamic-kanban.js +31 -12
  53. package/dist/dynamic-relation-helpers.d.ts.map +1 -1
  54. package/dist/dynamic-relation-helpers.js +8 -0
  55. package/dist/dynamic-relation.js +1 -1
  56. package/dist/index.d.ts +5 -2
  57. package/dist/index.d.ts.map +1 -1
  58. package/dist/index.js +13 -2
  59. package/dist/installed-addons-context.d.ts +15 -0
  60. package/dist/installed-addons-context.d.ts.map +1 -0
  61. package/dist/installed-addons-context.js +39 -0
  62. package/dist/org-runtime-context.d.ts +8 -0
  63. package/dist/org-runtime-context.d.ts.map +1 -1
  64. package/dist/org-runtime-context.js +8 -0
  65. package/dist/org-runtime-provider.d.ts +3 -1
  66. package/dist/org-runtime-provider.d.ts.map +1 -1
  67. package/dist/org-runtime-provider.js +3 -3
  68. package/dist/primitives/aging.d.ts +34 -0
  69. package/dist/primitives/aging.d.ts.map +1 -0
  70. package/dist/primitives/aging.js +48 -0
  71. package/dist/primitives/allocation.d.ts +42 -0
  72. package/dist/primitives/allocation.d.ts.map +1 -0
  73. package/dist/primitives/allocation.js +59 -0
  74. package/dist/primitives/approvals.d.ts +71 -0
  75. package/dist/primitives/approvals.d.ts.map +1 -0
  76. package/dist/primitives/approvals.js +48 -0
  77. package/dist/primitives/contributions.d.ts +82 -0
  78. package/dist/primitives/contributions.d.ts.map +1 -0
  79. package/dist/primitives/contributions.js +126 -0
  80. package/dist/primitives/document-kinds.d.ts +25 -0
  81. package/dist/primitives/document-kinds.d.ts.map +1 -0
  82. package/dist/primitives/document-kinds.js +26 -0
  83. package/dist/primitives/index.d.ts +8 -0
  84. package/dist/primitives/index.d.ts.map +1 -0
  85. package/dist/primitives/index.js +23 -0
  86. package/dist/primitives/panels.d.ts +68 -0
  87. package/dist/primitives/panels.d.ts.map +1 -0
  88. package/dist/primitives/panels.js +3 -0
  89. package/dist/primitives/use-contributions.d.ts +15 -0
  90. package/dist/primitives/use-contributions.d.ts.map +1 -0
  91. package/dist/primitives/use-contributions.js +30 -0
  92. package/dist/record-prefill-bar.d.ts +11 -0
  93. package/dist/record-prefill-bar.d.ts.map +1 -0
  94. package/dist/record-prefill-bar.js +33 -0
  95. package/dist/types.d.ts +102 -0
  96. package/dist/types.d.ts.map +1 -1
  97. package/package.json +3 -3
  98. package/src/__tests__/catalog-product-search.test.ts +61 -0
  99. package/src/__tests__/document-editor-model.test.ts +167 -0
  100. package/src/__tests__/document-editor.test.tsx +261 -0
  101. package/src/__tests__/document-form-dialog.test.tsx +118 -3
  102. package/src/__tests__/dynamic-form-select-edit.test.tsx +155 -0
  103. package/src/__tests__/dynamic-kanban.test.tsx +24 -3
  104. package/src/__tests__/dynamic-record-extension-label.test.tsx +88 -0
  105. package/src/__tests__/installed-addons-context.test.tsx +50 -0
  106. package/src/__tests__/line-item-totals.test.ts +28 -0
  107. package/src/__tests__/primitives-federated.test.tsx +156 -0
  108. package/src/__tests__/primitives.test.ts +224 -0
  109. package/src/__tests__/record-prefill-bar.test.tsx +50 -0
  110. package/src/action-modal-dispatcher.tsx +47 -4
  111. package/src/business/catalog-product-search.ts +87 -0
  112. package/src/business/catalog.ts +2 -0
  113. package/src/business/document-editor-model.ts +325 -0
  114. package/src/business/document-editor-panels.tsx +164 -0
  115. package/src/business/document-editor.tsx +727 -0
  116. package/src/business/document-lines.ts +5 -1
  117. package/src/business/index.ts +5 -0
  118. package/src/business/line-items.ts +15 -1
  119. package/src/business/payment-allocator.tsx +110 -0
  120. package/src/business/product-search.ts +4 -0
  121. package/src/dialogs/dynamic-record.tsx +29 -6
  122. package/src/document-form-dialog.tsx +92 -12
  123. package/src/dynamic-columns-helpers.ts +22 -0
  124. package/src/dynamic-columns.tsx +23 -6
  125. package/src/dynamic-crud-page.tsx +22 -3
  126. package/src/dynamic-form-schema.ts +15 -2
  127. package/src/dynamic-form.tsx +117 -39
  128. package/src/dynamic-kanban.tsx +34 -7
  129. package/src/dynamic-relation-helpers.ts +9 -0
  130. package/src/dynamic-relation.tsx +7 -2
  131. package/src/index.ts +21 -1
  132. package/src/installed-addons-context.tsx +58 -0
  133. package/src/org-runtime-context.ts +10 -0
  134. package/src/org-runtime-provider.tsx +7 -2
  135. package/src/primitives/aging.ts +76 -0
  136. package/src/primitives/allocation.ts +104 -0
  137. package/src/primitives/approvals.ts +103 -0
  138. package/src/primitives/contributions.ts +208 -0
  139. package/src/primitives/document-kinds.ts +62 -0
  140. package/src/primitives/index.ts +23 -0
  141. package/src/primitives/panels.ts +59 -0
  142. package/src/primitives/use-contributions.ts +42 -0
  143. package/src/record-prefill-bar.tsx +62 -0
  144. package/src/row-action-stage-gate.test.ts +33 -0
  145. package/src/types.ts +101 -0
@@ -0,0 +1,325 @@
1
+ // Reglas puras del DocumentEditor (sin React ni host): qué se ve a primera
2
+ // vista y qué va en «Opciones fiscales», qué copiar de la contraparte, cómo
3
+ // traer renglones de un documento origen, cómo repartir un cobro entre los
4
+ // documentos abiertos y qué revisar antes de guardar. Genérico: factura,
5
+ // cotización, pedido, OC, nota de crédito y cobro/REP. El SDK no conoce CFDI.
6
+ import { roundMoney, toAmount } from './format'
7
+ import { computeTotals, makeLine, type LineItem } from './line-items'
8
+ import { optionPassesRule, getOptionFilter } from '../option-filter'
9
+ import type { ResolvedOption } from '../use-options-resolver'
10
+ import type { Allocation, AllocationIssue, OpenDocument } from '../primitives/allocation'
11
+ import type {
12
+ ActionFieldDef,
13
+ DocumentFormLines,
14
+ DocumentFormOpenDocuments,
15
+ DocumentFormsManifest,
16
+ DocumentFormType,
17
+ } from '../types'
18
+
19
+ export const EXTENSION_PREFIX = 'fiscal_data.'
20
+
21
+ /** Secciones de campo que el editor pliega en «Opciones fiscales». */
22
+ const ADVANCED_SECTIONS = new Set(['fiscal', 'advanced'])
23
+
24
+ export type EditorLinesConfig = DocumentFormLines & { field: string }
25
+
26
+ /** true si el tipo se abre en el DocumentEditor (`layout: "editor"`). */
27
+ export function isEditorLayout(type: Pick<DocumentFormType, 'layout'> | null | undefined): boolean {
28
+ return type?.layout === 'editor'
29
+ }
30
+
31
+ /** Paso de renglones efectivo del tipo (o undefined si no tiene). */
32
+ export function editorLinesConfig(type: DocumentFormType, forms: Pick<DocumentFormsManifest, 'lines_field'>): EditorLinesConfig | undefined {
33
+ if (!type.lines) return undefined
34
+ const cfg: DocumentFormLines = type.lines === true ? {} : type.lines
35
+ const fallback = cfg.kind === 'allocation' ? 'allocations' : 'lines'
36
+ return { ...cfg, field: cfg.field ?? forms.lines_field ?? fallback }
37
+ }
38
+
39
+ /** Lee `a.b.c` de un registro (incluye `fiscal_data.x` dentro del jsonb). */
40
+ function readPath(rec: Record<string, any> | null | undefined, path: string): unknown {
41
+ if (!rec) return undefined
42
+ if (path in rec) return rec[path]
43
+ return path.split('.').reduce<any>((acc, k) => (acc == null ? undefined : acc[k]), rec)
44
+ }
45
+
46
+ const hasValue = (v: unknown) => v !== undefined && v !== null && v !== ''
47
+
48
+ function declaredDefault(f: ActionFieldDef): unknown {
49
+ return (f as { defaultValue?: unknown; default?: unknown }).defaultValue ?? (f as { default?: unknown }).default
50
+ }
51
+
52
+ /**
53
+ * Divulgación progresiva: ¿el campo va plegado en «Opciones fiscales»?
54
+ * - campos de extensión (`fiscal_data.*`) que aportan otros addons;
55
+ * - `section: "fiscal" | "advanced"`;
56
+ * - catálogos opcionales que ya traen un default (método, serie, relación…):
57
+ * el usuario no necesita tocarlos para guardar.
58
+ * Lo obligatorio sin default, la contraparte y el monto siempre están a la vista.
59
+ */
60
+ export function isAdvancedField(f: ActionFieldDef, essentialKeys: ReadonlySet<string> = new Set()): boolean {
61
+ if (essentialKeys.has(f.key)) return false
62
+ if (f.key.startsWith(EXTENSION_PREFIX)) return true
63
+ if (f.section && ADVANCED_SECTIONS.has(f.section)) return true
64
+ const hasOptions = Array.isArray(f.options) && f.options.length > 0
65
+ return !f.required && hasOptions && hasValue(declaredDefault(f))
66
+ }
67
+
68
+ export interface EditorFieldGroups {
69
+ /** Campo que elige la contraparte (cliente/proveedor), si el tipo la declara. */
70
+ party?: ActionFieldDef
71
+ /** A la vista: lo esencial del encabezado. */
72
+ essential: ActionFieldDef[]
73
+ /** Plegados en «Opciones fiscales». */
74
+ advanced: ActionFieldDef[]
75
+ /** Notas largas (textarea): al final. */
76
+ notes: ActionFieldDef[]
77
+ }
78
+
79
+ /** Reparte los campos del tipo (más los de extensión) en grupos de pantalla. */
80
+ export function splitEditorFields(
81
+ fields: readonly ActionFieldDef[],
82
+ opts: { partyField?: string; essentialKeys?: readonly string[]; extensionFields?: readonly ActionFieldDef[] } = {},
83
+ ): EditorFieldGroups {
84
+ const essentialKeys = new Set(opts.essentialKeys ?? [])
85
+ const out: EditorFieldGroups = { essential: [], advanced: [], notes: [] }
86
+ const seen = new Set<string>()
87
+ for (const f of [...fields, ...(opts.extensionFields ?? [])]) {
88
+ if (seen.has(f.key)) continue
89
+ seen.add(f.key)
90
+ if (opts.partyField && f.key === opts.partyField) out.party = f
91
+ else if (f.type === 'textarea' || f.widget === 'textarea') out.notes.push(f)
92
+ else if (isAdvancedField(f, essentialKeys)) out.advanced.push(f)
93
+ else out.essential.push(f)
94
+ }
95
+ return out
96
+ }
97
+
98
+ /**
99
+ * Etiqueta clara con el código como dato secundario:
100
+ * `03 · Transferencia electrónica` → `Transferencia electrónica · 03`.
101
+ * Solo reordena el patrón «CÓDIGO · texto»; cualquier otra etiqueta pasa igual.
102
+ */
103
+ export function friendlyOptionLabel(label: string): string {
104
+ const m = /^\s*([A-Z0-9]{1,5})\s*[·\-–]\s*(.+)$/.exec(label)
105
+ return m && /\d/.test(m[1]) ? `${m[2].trim()} · ${m[1]}` : label
106
+ }
107
+
108
+ /** El mismo campo con sus opciones en formato «texto · código». */
109
+ export function withFriendlyOptions(f: ActionFieldDef): ActionFieldDef {
110
+ if (!Array.isArray(f.options) || f.options.length === 0) return f
111
+ return { ...f, options: f.options.map((o) => ({ ...o, label: friendlyOptionLabel(String(o.label ?? o.value)) })) }
112
+ }
113
+
114
+ /**
115
+ * Defaults del encabezado que salen de la contraparte: los campos de extensión
116
+ * con la MISMA clave en ambos modelos (p. ej. `fiscal_data.uso_cfdi` de Customer
117
+ * → `fiscal_data.uso_cfdi` del documento) y la moneda.
118
+ * Solo llena lo que está vacío: nunca pisa lo que el usuario ya capturó.
119
+ */
120
+ export function partyDefaults(
121
+ party: Record<string, any> | null,
122
+ documentFieldKeys: readonly string[],
123
+ current: Record<string, unknown>,
124
+ ): Record<string, unknown> {
125
+ if (!party) return {}
126
+ const out: Record<string, unknown> = {}
127
+ for (const key of documentFieldKeys) {
128
+ if (!key.startsWith(EXTENSION_PREFIX) || hasValue(current[key])) continue
129
+ const v = readPath(party, key)
130
+ if (hasValue(v)) out[key] = v
131
+ }
132
+ if (!hasValue(current.currency_code) && typeof party.currency_code === 'string' && party.currency_code) {
133
+ out.currency_code = party.currency_code
134
+ }
135
+ return out
136
+ }
137
+
138
+ /** Filas de la tarjeta de contraparte: resumen declarado + extensiones (máximo `limit`). */
139
+ export function partySummaryRows(
140
+ party: Record<string, any> | null,
141
+ summary: readonly string[] = [],
142
+ labels: Record<string, string> = {},
143
+ limit = 6,
144
+ ): Array<{ key: string; label: string; value: string }> {
145
+ if (!party) return []
146
+ const keys = new Set<string>(summary)
147
+ const fd = party.fiscal_data
148
+ if (fd && typeof fd === 'object') for (const k of Object.keys(fd)) keys.add(EXTENSION_PREFIX + k)
149
+ const rows: Array<{ key: string; label: string; value: string }> = []
150
+ for (const key of keys) {
151
+ const v = readPath(party, key)
152
+ if (!hasValue(v) || typeof v === 'object') continue
153
+ rows.push({ key, label: labels[key] ?? key.replace(EXTENSION_PREFIX, ''), value: String(v) })
154
+ }
155
+ return rows.slice(0, limit)
156
+ }
157
+
158
+ export interface CreditStatus {
159
+ limit?: number
160
+ balance?: number
161
+ overdue?: number
162
+ available?: number
163
+ holdReason?: string
164
+ /** `ok` | `warn` (supera 80 % o hay vencido) | `block` (retenido o sin crédito disponible). */
165
+ level: 'ok' | 'warn' | 'block'
166
+ }
167
+
168
+ export function creditStatus(
169
+ party: Record<string, any> | null,
170
+ cols: { limit?: string; balance?: string; overdue?: string; hold_reason?: string } | undefined,
171
+ documentTotal: number,
172
+ ): CreditStatus | null {
173
+ if (!party || !cols) return null
174
+ const n = (k?: string) => (k && hasValue(party[k]) ? toAmount(party[k]) : undefined)
175
+ const limit = n(cols.limit)
176
+ const balance = n(cols.balance)
177
+ const overdue = n(cols.overdue)
178
+ const holdReason = cols.hold_reason ? (party[cols.hold_reason] as string | undefined) || undefined : undefined
179
+ if (limit == null && balance == null && overdue == null && !holdReason) return null
180
+ const available = limit != null ? roundMoney(limit - (balance ?? 0)) : undefined
181
+ let level: CreditStatus['level'] = 'ok'
182
+ if (holdReason || (available != null && limit! > 0 && available < documentTotal)) level = 'block'
183
+ else if ((overdue ?? 0) > 0 || (limit != null && limit > 0 && (balance ?? 0) + documentTotal > 0.8 * limit)) level = 'warn'
184
+ return { limit, balance, overdue, available, holdReason, level }
185
+ }
186
+
187
+ /**
188
+ * Renglones de un documento origen → LineItem. `map` traduce nombres
189
+ * (`description: product_name`, `discount: discount_pct`). Si el origen trae
190
+ * `tax_amount` y `subtotal` pero no la tasa, la deriva. `credit` (NC) topa la
191
+ * cantidad en lo facturado (o en `max_quantity` si el origen la trae).
192
+ */
193
+ export function linesFromSource(rows: unknown, map: Record<string, string> = {}, opts: { kind?: DocumentFormLines['kind'] } = {}): LineItem[] {
194
+ if (!Array.isArray(rows)) return []
195
+ return rows.map((raw) => {
196
+ const r = (raw ?? {}) as Record<string, any>
197
+ const get = (k: string) => r[map[k] ?? k]
198
+ const qty = toAmount(get('quantity') ?? 1)
199
+ let rate = get('tax_rate')
200
+ if (!hasValue(rate)) {
201
+ const sub = toAmount(get('subtotal'))
202
+ const tax = toAmount(get('tax_amount'))
203
+ rate = sub > 0 ? Math.round((tax / sub) * 10000) / 10000 : 0
204
+ }
205
+ const r2 = toAmount(rate)
206
+ const line = makeLine({
207
+ product_id: get('product_id') ?? undefined,
208
+ sku: get('sku') ?? undefined,
209
+ description: String(get('description') ?? get('product_name') ?? ''),
210
+ quantity: qty,
211
+ unit_price: toAmount(get('unit_price')),
212
+ discount: toAmount(get('discount') ?? 0),
213
+ tax_rate: r2 > 1 ? r2 / 100 : r2,
214
+ unit: get('unit') ?? undefined,
215
+ })
216
+ if (opts.kind === 'credit') line.max_quantity = hasValue(get('max_quantity')) ? toAmount(get('max_quantity')) : qty
217
+ return line
218
+ })
219
+ }
220
+
221
+ export interface EditorIssue {
222
+ field?: string
223
+ severity: 'error' | 'warning'
224
+ message: string
225
+ }
226
+
227
+ /** Revisión local (instantánea) de los renglones. Sin ruido: solo lo que hay que mirar. */
228
+ export function localIssues(lines: LineItem[], opts: { requireLines?: boolean; requireTax?: boolean } = {}): EditorIssue[] {
229
+ const issues: EditorIssue[] = []
230
+ const items = lines.filter((l) => l.kind === 'item')
231
+ if ((opts.requireLines ?? true) && items.length === 0) {
232
+ issues.push({ field: 'lines', severity: 'error', message: 'Agrega al menos un renglón.' })
233
+ }
234
+ items.forEach((l, i) => {
235
+ if (!l.description.trim()) issues.push({ field: `lines.${i}`, severity: 'error', message: `Renglón ${i + 1}: falta la descripción.` })
236
+ if (toAmount(l.unit_price) <= 0) issues.push({ field: `lines.${i}`, severity: 'warning', message: `Renglón ${i + 1}: precio en cero.` })
237
+ if (opts.requireTax && toAmount(l.tax_rate) === 0) {
238
+ issues.push({ field: `lines.${i}`, severity: 'warning', message: `Renglón ${i + 1}: sin impuesto.` })
239
+ }
240
+ })
241
+ if (computeTotals(lines).total < 0) issues.push({ severity: 'error', message: 'El total no puede ser negativo.' })
242
+ return issues
243
+ }
244
+
245
+ // ---- Cobro / REP (lines.kind = allocation) ----------------------------------
246
+
247
+ /** Documento abierto (fila de /data) → OpenDocument para PaymentAllocator. */
248
+ export function toOpenDocuments(rows: unknown, cfg: DocumentFormOpenDocuments, currency: string): OpenDocument[] {
249
+ if (!Array.isArray(rows)) return []
250
+ const rules = getOptionFilter(cfg)
251
+ const out: OpenDocument[] = []
252
+ for (const raw of rows) {
253
+ if (!raw || typeof raw !== 'object') continue
254
+ const r = raw as Record<string, any>
255
+ if (rules.length) {
256
+ const opt = { id: r.id, value: r.id, label: String(r[cfg.number_field] ?? ''), name: '', meta: r } as unknown as ResolvedOption
257
+ if (!rules.every((rule) => optionPassesRule(opt, rule))) continue
258
+ }
259
+ const balance = roundMoney(toAmount(readPath(r, cfg.balance_field)))
260
+ if (balance <= 0) continue
261
+ out.push({
262
+ id: String(r.id),
263
+ number: String(readPath(r, cfg.number_field) ?? r.id),
264
+ issued_at: String(readPath(r, cfg.issued_field ?? 'issued_at') ?? r.created_at ?? ''),
265
+ due_at: (cfg.due_field ? (readPath(r, cfg.due_field) as string | null) : null) ?? null,
266
+ total: roundMoney(toAmount(cfg.total_field ? readPath(r, cfg.total_field) : balance)),
267
+ balance,
268
+ currency: String(r.currency_code ?? r.currency ?? currency),
269
+ payment_method: cfg.method_field ? ((readPath(r, cfg.method_field) as string | null) ?? null) : null,
270
+ installments_paid: hasValue(r.installments_paid) ? toAmount(r.installments_paid) : undefined,
271
+ })
272
+ }
273
+ return out
274
+ }
275
+
276
+ /** Monto actual de cada documento, para pasar a modo manual sin perder lo repartido. */
277
+ export function manualFromAllocations(allocations: readonly Allocation[]): Record<string, number> {
278
+ return Object.fromEntries(allocations.map((a) => [a.document_id, a.amount]))
279
+ }
280
+
281
+ /** Renglones del payload: solo los dos campos que declara la acción (`invoice_id`, `amount`). */
282
+ export function allocationPayload(allocations: readonly Allocation[], cfg: DocumentFormOpenDocuments): Array<Record<string, unknown>> {
283
+ return allocations.filter((a) => a.amount > 0).map((a) => ({ [cfg.line_document_field]: a.document_id, [cfg.line_amount_field]: a.amount }))
284
+ }
285
+
286
+ /**
287
+ * Campo del encabezado con el monto recibido: `amount` si existe; si no, el
288
+ * primer campo numérico/moneda. Convención documentada (el schema no lo nombra).
289
+ */
290
+ export function allocationAmountField(fields: readonly ActionFieldDef[]): string | undefined {
291
+ return (
292
+ fields.find((f) => f.key === 'amount')?.key ??
293
+ fields.find((f) => ['number', 'numeric', 'decimal', 'currency', 'money'].includes(f.type) || f.widget === 'currency')?.key
294
+ )
295
+ }
296
+
297
+ const ALLOCATION_MESSAGES: Record<AllocationIssue['code'], string> = {
298
+ non_positive: 'Captura el monto recibido.',
299
+ over_balance: 'Se aplica más que el saldo del documento.',
300
+ over_amount: 'Lo aplicado supera el monto recibido.',
301
+ currency_mismatch: 'El documento está en otra moneda.',
302
+ unapplied: 'Queda monto sin aplicar.',
303
+ }
304
+
305
+ /** AllocationIssue → mensaje en español claro (con el número del documento). */
306
+ export function allocationIssueMessages(issues: readonly AllocationIssue[], docs: readonly OpenDocument[]): EditorIssue[] {
307
+ const byId = new Map(docs.map((d) => [d.id, d]))
308
+ return issues.map((i) => {
309
+ const doc = i.document_id ? byId.get(i.document_id) : undefined
310
+ return {
311
+ field: i.code === 'non_positive' ? 'amount' : 'allocations',
312
+ severity: i.severity,
313
+ message: doc ? `${doc.number}: ${ALLOCATION_MESSAGES[i.code]}` : ALLOCATION_MESSAGES[i.code],
314
+ }
315
+ })
316
+ }
317
+
318
+ /** Días de atraso de un documento abierto (0 si no está vencido o no tiene vencimiento). */
319
+ export function overdueDays(doc: Pick<OpenDocument, 'due_at'>, today: Date = new Date()): number {
320
+ if (!doc.due_at) return 0
321
+ const due = new Date(doc.due_at)
322
+ if (Number.isNaN(due.getTime())) return 0
323
+ const ms = Date.UTC(today.getFullYear(), today.getMonth(), today.getDate()) - Date.UTC(due.getUTCFullYear(), due.getUTCMonth(), due.getUTCDate())
324
+ return Math.max(0, Math.floor(ms / 86_400_000))
325
+ }
@@ -0,0 +1,164 @@
1
+ // Piezas visuales del DocumentEditor: secciones, sección plegable, tarjeta de
2
+ // contraparte, totales, revisión y vista previa. Sin estado de negocio: el
3
+ // editor (o un remote federado) les pasa los datos ya calculados. Los contratos
4
+ // de datos viven en primitives/panels.ts.
5
+ import { useId, useState, type ReactNode } from 'react'
6
+ import { Badge } from '@asteby/metacore-ui/primitives'
7
+ import { AlertTriangle, ChevronDown, XCircle } from 'lucide-react'
8
+ import type { CreditStatus, EditorIssue } from './document-editor-model'
9
+ import type { Formatter } from './format'
10
+
11
+ /** Bloque con título discreto y espaciado generoso (sin bordes pesados). */
12
+ export function EditorSection({ title, hint, children, slot }: { title?: ReactNode; hint?: ReactNode; children: ReactNode; slot?: string }) {
13
+ return (
14
+ <section className="space-y-3" data-slot={slot}>
15
+ {(title || hint) && (
16
+ <header className="flex items-baseline justify-between gap-3">
17
+ {title && <h3 className="text-sm font-semibold">{title}</h3>}
18
+ {hint && <span className="text-xs text-muted-foreground">{hint}</span>}
19
+ </header>
20
+ )}
21
+ {children}
22
+ </section>
23
+ )
24
+ }
25
+
26
+ export interface CollapsibleSectionProps {
27
+ title: ReactNode
28
+ /** Resumen de una línea cuando está cerrada (p. ej. «G03 · PUE»). */
29
+ summary?: ReactNode
30
+ open?: boolean
31
+ defaultOpen?: boolean
32
+ onOpenChange?: (open: boolean) => void
33
+ children: ReactNode
34
+ slot?: string
35
+ }
36
+
37
+ /**
38
+ * Sección plegable. El contenido queda MONTADO aunque esté cerrada (`hidden`):
39
+ * las contribuciones de otros addons siguen aplicando defaults y reportando
40
+ * revisiones sin que el usuario abra la sección.
41
+ */
42
+ export function CollapsibleSection({ title, summary, open, defaultOpen = false, onOpenChange, children, slot }: CollapsibleSectionProps) {
43
+ const [inner, setInner] = useState(defaultOpen)
44
+ const isOpen = open ?? inner
45
+ const id = useId()
46
+ const toggle = () => {
47
+ setInner(!isOpen)
48
+ onOpenChange?.(!isOpen)
49
+ }
50
+ return (
51
+ <section className="rounded-lg border border-dashed" data-slot={slot} data-state={isOpen ? 'open' : 'closed'}>
52
+ <button
53
+ type="button"
54
+ aria-expanded={isOpen}
55
+ aria-controls={id}
56
+ onClick={toggle}
57
+ className="flex w-full items-center justify-between gap-3 px-4 py-3 text-left text-sm"
58
+ >
59
+ <span className="font-medium">{title}</span>
60
+ <span className="flex min-w-0 items-center gap-2 text-xs text-muted-foreground">
61
+ {!isOpen && summary && <span className="truncate">{summary}</span>}
62
+ <ChevronDown className={`size-4 shrink-0 transition-transform ${isOpen ? 'rotate-180' : ''}`} aria-hidden />
63
+ </span>
64
+ </button>
65
+ <div id={id} hidden={!isOpen} className="space-y-4 px-4 pb-4">
66
+ {children}
67
+ </div>
68
+ </section>
69
+ )
70
+ }
71
+
72
+ export interface PartyCardProps {
73
+ name?: string
74
+ rows: Array<{ key: string; label: string; value: string }>
75
+ credit?: CreditStatus | null
76
+ fmt: Formatter
77
+ children?: ReactNode
78
+ }
79
+
80
+ /** Tarjeta compacta de la contraparte: nombre, 3-6 datos clave y estado de crédito. */
81
+ export function PartyCard({ name, rows, credit, fmt, children }: PartyCardProps) {
82
+ return (
83
+ <div className="rounded-md bg-muted/40 px-4 py-3 text-sm" data-slot="party-card">
84
+ <div className="flex flex-wrap items-center justify-between gap-2">
85
+ {name && <span className="font-medium">{name}</span>}
86
+ {credit && (
87
+ <Badge variant={credit.level === 'block' ? 'destructive' : credit.level === 'warn' ? 'secondary' : 'outline'} data-slot="party-credit">
88
+ {credit.holdReason
89
+ ? credit.holdReason
90
+ : credit.available != null
91
+ ? `Crédito disponible ${fmt.money(credit.available)}`
92
+ : `Saldo ${fmt.money(credit.balance ?? 0)}`}
93
+ </Badge>
94
+ )}
95
+ </div>
96
+ {rows.length > 0 && (
97
+ <dl className="mt-2 grid grid-cols-2 gap-x-6 gap-y-1 sm:grid-cols-3">
98
+ {rows.map((r) => (
99
+ <div key={r.key} className="min-w-0">
100
+ <dt className="text-xs text-muted-foreground">{r.label}</dt>
101
+ <dd className="truncate">{r.value}</dd>
102
+ </div>
103
+ ))}
104
+ </dl>
105
+ )}
106
+ {children}
107
+ </div>
108
+ )
109
+ }
110
+
111
+ export interface TotalsRow {
112
+ label: string
113
+ value: string
114
+ emphasis?: boolean
115
+ }
116
+
117
+ /** Totales alineados a la derecha; la última fila con `emphasis` es el total. */
118
+ export function TotalsPanel({ rows }: { rows: TotalsRow[] }) {
119
+ return (
120
+ <dl className="ml-auto w-full max-w-xs space-y-1 text-sm tabular-nums" data-slot="totals">
121
+ {rows.map((r) => (
122
+ <div key={r.label} className={`flex justify-between gap-6 ${r.emphasis ? 'border-t pt-2 text-base font-semibold' : ''}`}>
123
+ <dt className={r.emphasis ? '' : 'text-muted-foreground'}>{r.label}</dt>
124
+ <dd>{r.value}</dd>
125
+ </div>
126
+ ))}
127
+ </dl>
128
+ )
129
+ }
130
+
131
+ /** Lista de revisión: errores (rojo) y avisos (ámbar). Vacía → no pinta nada. */
132
+ export function ValidationChecklist({ issues, title = 'Revisa antes de guardar' }: { issues: EditorIssue[]; title?: string }) {
133
+ if (issues.length === 0) return null
134
+ return (
135
+ <div className="rounded-md bg-muted/30 px-4 py-3" data-slot="validation-checklist" role="status">
136
+ <p className="mb-1 text-xs font-medium text-muted-foreground">{title}</p>
137
+ <ul className="space-y-1 text-sm">
138
+ {issues.map((i, k) => (
139
+ <li key={`${i.field ?? ''}:${k}`} className={`flex items-start gap-2 ${i.severity === 'error' ? 'text-destructive' : 'text-amber-700'}`}>
140
+ {i.severity === 'error' ? <XCircle className="mt-0.5 size-4 shrink-0" aria-hidden /> : <AlertTriangle className="mt-0.5 size-4 shrink-0" aria-hidden />}
141
+ {i.message}
142
+ </li>
143
+ ))}
144
+ </ul>
145
+ </div>
146
+ )
147
+ }
148
+
149
+ export interface PreviewContent {
150
+ xml?: string
151
+ html?: string
152
+ pdf_url?: string
153
+ pdf_base64?: string
154
+ }
155
+
156
+ /** Contenido de la vista previa (PDF, HTML o XML, en ese orden de preferencia). */
157
+ export function PreviewPanel({ preview }: { preview: PreviewContent | null }) {
158
+ if (!preview) return null
159
+ const pdf = preview.pdf_url ?? (preview.pdf_base64 ? `data:application/pdf;base64,${preview.pdf_base64}` : undefined)
160
+ if (pdf) return <iframe title="Vista previa" src={pdf} className="h-96 w-full rounded border" data-slot="preview-pdf" />
161
+ if (preview.html) return <iframe title="Vista previa" srcDoc={preview.html} className="h-96 w-full rounded border" data-slot="preview-html" />
162
+ 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
+ return null
164
+ }