@asteby/metacore-runtime-react 47.1.0 → 49.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 (190) hide show
  1. package/CHANGELOG.md +51 -0
  2. package/dist/action-modal-dispatcher.d.ts +17 -3
  3. package/dist/action-modal-dispatcher.d.ts.map +1 -1
  4. package/dist/action-modal-dispatcher.js +80 -13
  5. package/dist/action-priority.d.ts +34 -0
  6. package/dist/action-priority.d.ts.map +1 -0
  7. package/dist/action-priority.js +60 -0
  8. package/dist/business/catalog-product-search.d.ts +32 -0
  9. package/dist/business/catalog-product-search.d.ts.map +1 -0
  10. package/dist/business/catalog-product-search.js +71 -0
  11. package/dist/business/catalog.d.ts.map +1 -1
  12. package/dist/business/catalog.js +2 -0
  13. package/dist/business/document-editor-model.d.ts +119 -0
  14. package/dist/business/document-editor-model.d.ts.map +1 -0
  15. package/dist/business/document-editor-model.js +317 -0
  16. package/dist/business/document-editor-panels.d.ts +64 -0
  17. package/dist/business/document-editor-panels.d.ts.map +1 -0
  18. package/dist/business/document-editor-panels.js +58 -0
  19. package/dist/business/document-editor.d.ts +27 -0
  20. package/dist/business/document-editor.d.ts.map +1 -0
  21. package/dist/business/document-editor.js +493 -0
  22. package/dist/business/document-lines.d.ts.map +1 -1
  23. package/dist/business/document-lines.js +5 -1
  24. package/dist/business/index.d.ts +5 -0
  25. package/dist/business/index.d.ts.map +1 -1
  26. package/dist/business/index.js +5 -0
  27. package/dist/business/line-items.d.ts +16 -1
  28. package/dist/business/line-items.d.ts.map +1 -1
  29. package/dist/business/line-items.js +14 -2
  30. package/dist/business/payment-allocator.d.ts +19 -0
  31. package/dist/business/payment-allocator.d.ts.map +1 -0
  32. package/dist/business/payment-allocator.js +31 -0
  33. package/dist/business/product-search.d.ts +4 -0
  34. package/dist/business/product-search.d.ts.map +1 -1
  35. package/dist/dialogs/dynamic-record.d.ts.map +1 -1
  36. package/dist/dialogs/dynamic-record.js +14 -7
  37. package/dist/document/document-page.d.ts.map +1 -1
  38. package/dist/document/document-page.js +8 -1
  39. package/dist/document/evaluate.d.ts.map +1 -1
  40. package/dist/document/evaluate.js +17 -1
  41. package/dist/document/index.d.ts +1 -0
  42. package/dist/document/index.d.ts.map +1 -1
  43. package/dist/document/index.js +1 -0
  44. package/dist/document/secondary-bar.d.ts +20 -0
  45. package/dist/document/secondary-bar.d.ts.map +1 -0
  46. package/dist/document/secondary-bar.js +44 -0
  47. package/dist/document/types.d.ts +8 -0
  48. package/dist/document/types.d.ts.map +1 -1
  49. package/dist/document-form-dialog.d.ts +31 -2
  50. package/dist/document-form-dialog.d.ts.map +1 -1
  51. package/dist/document-form-dialog.js +46 -11
  52. package/dist/dynamic-columns-helpers.d.ts +9 -0
  53. package/dist/dynamic-columns-helpers.d.ts.map +1 -1
  54. package/dist/dynamic-columns-helpers.js +19 -0
  55. package/dist/dynamic-columns.d.ts +9 -13
  56. package/dist/dynamic-columns.d.ts.map +1 -1
  57. package/dist/dynamic-columns.js +27 -21
  58. package/dist/dynamic-crud-page.d.ts +18 -0
  59. package/dist/dynamic-crud-page.d.ts.map +1 -1
  60. package/dist/dynamic-crud-page.js +15 -6
  61. package/dist/dynamic-form-schema.d.ts.map +1 -1
  62. package/dist/dynamic-form-schema.js +15 -3
  63. package/dist/dynamic-form.d.ts +16 -1
  64. package/dist/dynamic-form.d.ts.map +1 -1
  65. package/dist/dynamic-form.js +78 -20
  66. package/dist/dynamic-kanban.d.ts +9 -3
  67. package/dist/dynamic-kanban.d.ts.map +1 -1
  68. package/dist/dynamic-kanban.js +31 -12
  69. package/dist/dynamic-relation-helpers.d.ts.map +1 -1
  70. package/dist/dynamic-relation-helpers.js +8 -0
  71. package/dist/dynamic-relation.js +1 -1
  72. package/dist/dynamic-row-actions.d.ts +8 -0
  73. package/dist/dynamic-row-actions.d.ts.map +1 -1
  74. package/dist/dynamic-row-actions.js +12 -2
  75. package/dist/dynamic-table.d.ts.map +1 -1
  76. package/dist/dynamic-table.js +64 -63
  77. package/dist/index.d.ts +7 -2
  78. package/dist/index.d.ts.map +1 -1
  79. package/dist/index.js +19 -2
  80. package/dist/installed-addons-context.d.ts +15 -0
  81. package/dist/installed-addons-context.d.ts.map +1 -0
  82. package/dist/installed-addons-context.js +39 -0
  83. package/dist/org-runtime-context.d.ts +8 -0
  84. package/dist/org-runtime-context.d.ts.map +1 -1
  85. package/dist/org-runtime-context.js +8 -0
  86. package/dist/org-runtime-provider.d.ts +3 -1
  87. package/dist/org-runtime-provider.d.ts.map +1 -1
  88. package/dist/org-runtime-provider.js +3 -3
  89. package/dist/primitives/aging.d.ts +34 -0
  90. package/dist/primitives/aging.d.ts.map +1 -0
  91. package/dist/primitives/aging.js +48 -0
  92. package/dist/primitives/allocation.d.ts +42 -0
  93. package/dist/primitives/allocation.d.ts.map +1 -0
  94. package/dist/primitives/allocation.js +59 -0
  95. package/dist/primitives/approvals.d.ts +71 -0
  96. package/dist/primitives/approvals.d.ts.map +1 -0
  97. package/dist/primitives/approvals.js +48 -0
  98. package/dist/primitives/contributions.d.ts +93 -0
  99. package/dist/primitives/contributions.d.ts.map +1 -0
  100. package/dist/primitives/contributions.js +163 -0
  101. package/dist/primitives/document-kinds.d.ts +25 -0
  102. package/dist/primitives/document-kinds.d.ts.map +1 -0
  103. package/dist/primitives/document-kinds.js +26 -0
  104. package/dist/primitives/index.d.ts +8 -0
  105. package/dist/primitives/index.d.ts.map +1 -0
  106. package/dist/primitives/index.js +23 -0
  107. package/dist/primitives/panels.d.ts +68 -0
  108. package/dist/primitives/panels.d.ts.map +1 -0
  109. package/dist/primitives/panels.js +3 -0
  110. package/dist/primitives/use-contributions.d.ts +15 -0
  111. package/dist/primitives/use-contributions.d.ts.map +1 -0
  112. package/dist/primitives/use-contributions.js +30 -0
  113. package/dist/record-actions.d.ts +21 -0
  114. package/dist/record-actions.d.ts.map +1 -0
  115. package/dist/record-actions.js +76 -0
  116. package/dist/record-prefill-bar.d.ts +11 -0
  117. package/dist/record-prefill-bar.d.ts.map +1 -0
  118. package/dist/record-prefill-bar.js +33 -0
  119. package/dist/row-actions-menu.d.ts +32 -0
  120. package/dist/row-actions-menu.d.ts.map +1 -0
  121. package/dist/row-actions-menu.js +59 -0
  122. package/dist/slot.d.ts +4 -21
  123. package/dist/slot.d.ts.map +1 -1
  124. package/dist/slot.js +14 -36
  125. package/dist/types.d.ts +134 -0
  126. package/dist/types.d.ts.map +1 -1
  127. package/package.json +3 -3
  128. package/src/__tests__/catalog-product-search.test.ts +61 -0
  129. package/src/__tests__/document-editor-model.test.ts +221 -0
  130. package/src/__tests__/document-editor.test.tsx +340 -0
  131. package/src/__tests__/document-form-dialog.test.tsx +158 -3
  132. package/src/__tests__/dynamic-form-select-edit.test.tsx +155 -0
  133. package/src/__tests__/dynamic-kanban.test.tsx +24 -3
  134. package/src/__tests__/dynamic-record-extension-label.test.tsx +88 -0
  135. package/src/__tests__/installed-addons-context.test.tsx +50 -0
  136. package/src/__tests__/line-item-totals.test.ts +28 -0
  137. package/src/__tests__/prefill-scalar-from-record.test.ts +52 -1
  138. package/src/__tests__/primitives-federated.test.tsx +156 -0
  139. package/src/__tests__/primitives.test.ts +224 -0
  140. package/src/__tests__/record-prefill-bar.test.tsx +50 -0
  141. package/src/__tests__/row-action-link-navigation.test.ts +17 -0
  142. package/src/__tests__/secondary-actions.test.tsx +261 -0
  143. package/src/action-modal-dispatcher.tsx +97 -12
  144. package/src/action-priority.ts +82 -0
  145. package/src/business/business.test.ts +1 -1
  146. package/src/business/catalog-product-search.ts +105 -0
  147. package/src/business/catalog.ts +2 -0
  148. package/src/business/document-editor-model.ts +367 -0
  149. package/src/business/document-editor-panels.tsx +164 -0
  150. package/src/business/document-editor.tsx +781 -0
  151. package/src/business/document-lines.ts +5 -1
  152. package/src/business/index.ts +5 -0
  153. package/src/business/line-items.ts +31 -2
  154. package/src/business/payment-allocator.tsx +110 -0
  155. package/src/business/product-search.ts +4 -0
  156. package/src/dialogs/dynamic-record.tsx +29 -6
  157. package/src/document/document-page.tsx +15 -0
  158. package/src/document/evaluate.ts +17 -1
  159. package/src/document/index.ts +1 -0
  160. package/src/document/secondary-bar.tsx +92 -0
  161. package/src/document/types.ts +8 -0
  162. package/src/document-form-dialog.tsx +96 -12
  163. package/src/dynamic-columns-helpers.ts +22 -0
  164. package/src/dynamic-columns.tsx +35 -62
  165. package/src/dynamic-crud-page.tsx +33 -5
  166. package/src/dynamic-form-schema.ts +15 -2
  167. package/src/dynamic-form.tsx +117 -39
  168. package/src/dynamic-kanban.tsx +34 -7
  169. package/src/dynamic-relation-helpers.ts +9 -0
  170. package/src/dynamic-relation.tsx +7 -2
  171. package/src/dynamic-row-actions.tsx +12 -2
  172. package/src/dynamic-table.tsx +3 -0
  173. package/src/index.ts +46 -1
  174. package/src/installed-addons-context.tsx +58 -0
  175. package/src/org-runtime-context.ts +10 -0
  176. package/src/org-runtime-provider.tsx +7 -2
  177. package/src/primitives/aging.ts +76 -0
  178. package/src/primitives/allocation.ts +104 -0
  179. package/src/primitives/approvals.ts +103 -0
  180. package/src/primitives/contributions.ts +255 -0
  181. package/src/primitives/document-kinds.ts +62 -0
  182. package/src/primitives/index.ts +23 -0
  183. package/src/primitives/panels.ts +59 -0
  184. package/src/primitives/use-contributions.ts +42 -0
  185. package/src/record-actions.ts +101 -0
  186. package/src/record-prefill-bar.tsx +62 -0
  187. package/src/row-action-stage-gate.test.ts +33 -0
  188. package/src/row-actions-menu.tsx +159 -0
  189. package/src/slot.tsx +18 -53
  190. package/src/types.ts +129 -0
@@ -0,0 +1,367 @@
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
+ DocumentFormSource,
15
+ DocumentFormOpenDocuments,
16
+ DocumentFormsManifest,
17
+ DocumentFormType,
18
+ } from '../types'
19
+
20
+ export const EXTENSION_PREFIX = 'fiscal_data.'
21
+
22
+ /** Secciones de campo que el editor pliega en «Opciones fiscales». */
23
+ const ADVANCED_SECTIONS = new Set(['fiscal', 'advanced'])
24
+
25
+ export type EditorLinesConfig = DocumentFormLines & { field: string }
26
+
27
+ /** true si el tipo se abre en el DocumentEditor (`layout: "editor"`). */
28
+ export function isEditorLayout(type: Pick<DocumentFormType, 'layout' | 'sources'> | null | undefined): boolean {
29
+ // «Cargar desde…» vive en el editor: un tipo con `sources` lo usa salvo que
30
+ // pida explícitamente el wizard.
31
+ return type?.layout === 'editor' || (type?.layout !== 'wizard' && (type?.sources?.length ?? 0) > 0)
32
+ }
33
+
34
+ /** Paso de renglones efectivo del tipo (o undefined si no tiene). */
35
+ export function editorLinesConfig(type: DocumentFormType, forms: Pick<DocumentFormsManifest, 'lines_field'>): EditorLinesConfig | undefined {
36
+ if (!type.lines) return undefined
37
+ const cfg: DocumentFormLines = type.lines === true ? {} : type.lines
38
+ const fallback = cfg.kind === 'allocation' ? 'allocations' : 'lines'
39
+ return { ...cfg, field: cfg.field ?? forms.lines_field ?? fallback }
40
+ }
41
+
42
+ /** Lee `a.b.c` de un registro (incluye `fiscal_data.x` dentro del jsonb). */
43
+ function readPath(rec: Record<string, any> | null | undefined, path: string): unknown {
44
+ if (!rec) return undefined
45
+ if (path in rec) return rec[path]
46
+ return path.split('.').reduce<any>((acc, k) => (acc == null ? undefined : acc[k]), rec)
47
+ }
48
+
49
+ const hasValue = (v: unknown) => v !== undefined && v !== null && v !== ''
50
+
51
+ function declaredDefault(f: ActionFieldDef): unknown {
52
+ return (f as { defaultValue?: unknown; default?: unknown }).defaultValue ?? (f as { default?: unknown }).default
53
+ }
54
+
55
+ /**
56
+ * Divulgación progresiva: ¿el campo va plegado en «Opciones fiscales»?
57
+ * - campos de extensión (`fiscal_data.*`) que aportan otros addons;
58
+ * - `section: "fiscal" | "advanced"`;
59
+ * - catálogos opcionales que ya traen un default (método, serie, relación…):
60
+ * el usuario no necesita tocarlos para guardar.
61
+ * Lo obligatorio sin default, la contraparte y el monto siempre están a la vista.
62
+ */
63
+ export function isAdvancedField(f: ActionFieldDef, essentialKeys: ReadonlySet<string> = new Set()): boolean {
64
+ if (essentialKeys.has(f.key)) return false
65
+ if (f.key.startsWith(EXTENSION_PREFIX)) return true
66
+ if (f.section && ADVANCED_SECTIONS.has(f.section)) return true
67
+ const hasOptions = Array.isArray(f.options) && f.options.length > 0
68
+ return !f.required && hasOptions && hasValue(declaredDefault(f))
69
+ }
70
+
71
+ export interface EditorFieldGroups {
72
+ /** Campo que elige la contraparte (cliente/proveedor), si el tipo la declara. */
73
+ party?: ActionFieldDef
74
+ /** A la vista: lo esencial del encabezado. */
75
+ essential: ActionFieldDef[]
76
+ /** Plegados en «Opciones fiscales». */
77
+ advanced: ActionFieldDef[]
78
+ /** Notas largas (textarea): al final. */
79
+ notes: ActionFieldDef[]
80
+ }
81
+
82
+ /** Reparte los campos del tipo (más los de extensión) en grupos de pantalla. */
83
+ export function splitEditorFields(
84
+ fields: readonly ActionFieldDef[],
85
+ opts: { partyField?: string; essentialKeys?: readonly string[]; extensionFields?: readonly ActionFieldDef[] } = {},
86
+ ): EditorFieldGroups {
87
+ const essentialKeys = new Set(opts.essentialKeys ?? [])
88
+ const out: EditorFieldGroups = { essential: [], advanced: [], notes: [] }
89
+ const seen = new Set<string>()
90
+ for (const f of [...fields, ...(opts.extensionFields ?? [])]) {
91
+ if (seen.has(f.key)) continue
92
+ seen.add(f.key)
93
+ if (opts.partyField && f.key === opts.partyField) out.party = f
94
+ else if (f.type === 'textarea' || f.widget === 'textarea') out.notes.push(f)
95
+ else if (isAdvancedField(f, essentialKeys)) out.advanced.push(f)
96
+ else out.essential.push(f)
97
+ }
98
+ return out
99
+ }
100
+
101
+ /**
102
+ * Etiqueta clara con el código como dato secundario:
103
+ * `03 · Transferencia electrónica` → `Transferencia electrónica · 03`.
104
+ * Solo reordena el patrón «CÓDIGO · texto»; cualquier otra etiqueta pasa igual.
105
+ */
106
+ export function friendlyOptionLabel(label: string): string {
107
+ const m = /^\s*([A-Z0-9]{1,5})\s*[·\-–]\s*(.+)$/.exec(label)
108
+ return m && /\d/.test(m[1]) ? `${m[2].trim()} · ${m[1]}` : label
109
+ }
110
+
111
+ /** El mismo campo con sus opciones en formato «texto · código». */
112
+ export function withFriendlyOptions(f: ActionFieldDef): ActionFieldDef {
113
+ if (!Array.isArray(f.options) || f.options.length === 0) return f
114
+ return { ...f, options: f.options.map((o) => ({ ...o, label: friendlyOptionLabel(String(o.label ?? o.value)) })) }
115
+ }
116
+
117
+ /**
118
+ * Defaults del encabezado que salen de la contraparte: los campos de extensión
119
+ * con la MISMA clave en ambos modelos (p. ej. `fiscal_data.uso_cfdi` de Customer
120
+ * → `fiscal_data.uso_cfdi` del documento) y la moneda.
121
+ * Solo llena lo que está vacío: nunca pisa lo que el usuario ya capturó.
122
+ */
123
+ export function partyDefaults(
124
+ party: Record<string, any> | null,
125
+ documentFieldKeys: readonly string[],
126
+ current: Record<string, unknown>,
127
+ ): Record<string, unknown> {
128
+ if (!party) return {}
129
+ const out: Record<string, unknown> = {}
130
+ for (const key of documentFieldKeys) {
131
+ if (!key.startsWith(EXTENSION_PREFIX) || hasValue(current[key])) continue
132
+ const v = readPath(party, key)
133
+ if (hasValue(v)) out[key] = v
134
+ }
135
+ if (!hasValue(current.currency_code) && typeof party.currency_code === 'string' && party.currency_code) {
136
+ out.currency_code = party.currency_code
137
+ }
138
+ return out
139
+ }
140
+
141
+ /** Filas de la tarjeta de contraparte: resumen declarado + extensiones (máximo `limit`). */
142
+ export function partySummaryRows(
143
+ party: Record<string, any> | null,
144
+ summary: readonly string[] = [],
145
+ labels: Record<string, string> = {},
146
+ limit = 6,
147
+ ): Array<{ key: string; label: string; value: string }> {
148
+ if (!party) return []
149
+ const keys = new Set<string>(summary)
150
+ const fd = party.fiscal_data
151
+ if (fd && typeof fd === 'object') for (const k of Object.keys(fd)) keys.add(EXTENSION_PREFIX + k)
152
+ const rows: Array<{ key: string; label: string; value: string }> = []
153
+ for (const key of keys) {
154
+ const v = readPath(party, key)
155
+ if (!hasValue(v) || typeof v === 'object') continue
156
+ rows.push({ key, label: labels[key] ?? key.replace(EXTENSION_PREFIX, ''), value: String(v) })
157
+ }
158
+ return rows.slice(0, limit)
159
+ }
160
+
161
+ export interface CreditStatus {
162
+ limit?: number
163
+ balance?: number
164
+ overdue?: number
165
+ available?: number
166
+ holdReason?: string
167
+ /** `ok` | `warn` (supera 80 % o hay vencido) | `block` (retenido o sin crédito disponible). */
168
+ level: 'ok' | 'warn' | 'block'
169
+ }
170
+
171
+ export function creditStatus(
172
+ party: Record<string, any> | null,
173
+ cols: { limit?: string; balance?: string; overdue?: string; hold_reason?: string } | undefined,
174
+ documentTotal: number,
175
+ ): CreditStatus | null {
176
+ if (!party || !cols) return null
177
+ const n = (k?: string) => (k && hasValue(party[k]) ? toAmount(party[k]) : undefined)
178
+ const limit = n(cols.limit)
179
+ const balance = n(cols.balance)
180
+ const overdue = n(cols.overdue)
181
+ const holdReason = cols.hold_reason ? (party[cols.hold_reason] as string | undefined) || undefined : undefined
182
+ if (limit == null && balance == null && overdue == null && !holdReason) return null
183
+ const available = limit != null ? roundMoney(limit - (balance ?? 0)) : undefined
184
+ let level: CreditStatus['level'] = 'ok'
185
+ if (holdReason || (available != null && limit! > 0 && available < documentTotal)) level = 'block'
186
+ else if ((overdue ?? 0) > 0 || (limit != null && limit > 0 && (balance ?? 0) + documentTotal > 0.8 * limit)) level = 'warn'
187
+ return { limit, balance, overdue, available, holdReason, level }
188
+ }
189
+
190
+ /**
191
+ * Renglones de un documento origen → LineItem. `map` traduce nombres
192
+ * (`description: product_name`, `discount: discount_pct`). Si el origen trae
193
+ * `tax_amount` y `subtotal` pero no la tasa, la deriva. `credit` (NC) topa la
194
+ * cantidad en lo facturado (o en `max_quantity` si el origen la trae).
195
+ *
196
+ * Con `remaining_quantity` (lo sirve el host: cantidad − lo ya facturado o
197
+ * devuelto) la cantidad sugerida y el tope son lo pendiente y los renglones ya
198
+ * cubiertos se omiten. Cada línea guarda `source_line_id` para el vínculo.
199
+ */
200
+ export function linesFromSource(
201
+ rows: unknown,
202
+ map: Record<string, string> = {},
203
+ opts: { kind?: DocumentFormLines['kind']; discountMode?: DocumentFormLines['discount_mode'] } = {},
204
+ ): LineItem[] {
205
+ if (!Array.isArray(rows)) return []
206
+ return rows.filter((raw) => !isFullyConsumed(raw)).map((raw) => {
207
+ const r = (raw ?? {}) as Record<string, any>
208
+ const get = (k: string) => r[map[k] ?? k]
209
+ const pending = hasValue(r.remaining_quantity) ? toAmount(r.remaining_quantity) : undefined
210
+ const qty = pending ?? toAmount(get('quantity') ?? 1)
211
+ let rate = get('tax_rate')
212
+ if (!hasValue(rate)) {
213
+ const sub = toAmount(get('subtotal'))
214
+ const tax = toAmount(get('tax_amount'))
215
+ rate = sub > 0 ? Math.round((tax / sub) * 10000) / 10000 : 0
216
+ }
217
+ const r2 = toAmount(rate)
218
+ const line = makeLine({
219
+ product_id: get('product_id') ?? undefined,
220
+ sku: get('sku') ?? undefined,
221
+ description: String(get('description') ?? get('product_name') ?? ''),
222
+ quantity: qty,
223
+ unit_price: toAmount(get('unit_price')),
224
+ discount: toAmount(get('discount') ?? 0),
225
+ tax_rate: r2 > 1 ? r2 / 100 : r2,
226
+ unit: get('unit') ?? undefined,
227
+ })
228
+ // El descuento del origen se lee en la unidad del documento (importe en
229
+ // modelos cuyo subtotal resta un monto).
230
+ if (opts.discountMode === 'amount') line.discount_kind = 'amount'
231
+ if (pending != null) line.max_quantity = pending
232
+ else if (opts.kind === 'credit') line.max_quantity = hasValue(get('max_quantity')) ? toAmount(get('max_quantity')) : qty
233
+ const sourceLine = r.source_line_id ?? r.id
234
+ if (hasValue(sourceLine)) line.source_line_id = String(sourceLine)
235
+ return line
236
+ })
237
+ }
238
+
239
+ function isFullyConsumed(raw: unknown): boolean {
240
+ const r = (raw ?? {}) as Record<string, any>
241
+ return hasValue(r.remaining_quantity) && toAmount(r.remaining_quantity) <= 0
242
+ }
243
+
244
+ /** Cuántos renglones del origen se cargaron y cuántos se omitieron por estar ya cubiertos. */
245
+ export function sourceLoadSummary(rows: unknown): { loaded: number; covered: number } {
246
+ if (!Array.isArray(rows)) return { loaded: 0, covered: 0 }
247
+ const covered = rows.filter(isFullyConsumed).length
248
+ return { loaded: rows.length - covered, covered }
249
+ }
250
+
251
+ /** Fuente que precarga desde lo pendiente servido por el host (no desde la relación cruda). */
252
+ export function sourceTracksRemaining(src: Pick<DocumentFormSource, 'line_link_field' | 'remaining_qty_field' | 'remaining_endpoint'>): boolean {
253
+ return !!(src.line_link_field || src.remaining_qty_field || src.remaining_endpoint)
254
+ }
255
+
256
+ export interface EditorIssue {
257
+ field?: string
258
+ severity: 'error' | 'warning'
259
+ message: string
260
+ }
261
+
262
+ /** Revisión local (instantánea) de los renglones. Sin ruido: solo lo que hay que mirar. */
263
+ export function localIssues(lines: LineItem[], opts: { requireLines?: boolean; requireTax?: boolean } = {}): EditorIssue[] {
264
+ const issues: EditorIssue[] = []
265
+ const items = lines.filter((l) => l.kind === 'item')
266
+ if ((opts.requireLines ?? true) && items.length === 0) {
267
+ issues.push({ field: 'lines', severity: 'error', message: 'Agrega al menos un renglón.' })
268
+ }
269
+ items.forEach((l, i) => {
270
+ if (!l.description.trim()) issues.push({ field: `lines.${i}`, severity: 'error', message: `Renglón ${i + 1}: falta la descripción.` })
271
+ if (toAmount(l.unit_price) <= 0) issues.push({ field: `lines.${i}`, severity: 'warning', message: `Renglón ${i + 1}: precio en cero.` })
272
+ if (l.max_quantity != null && toAmount(l.quantity) > toAmount(l.max_quantity)) {
273
+ issues.push({
274
+ field: `lines.${i}`,
275
+ severity: 'error',
276
+ message: `Renglón ${i + 1}: la cantidad excede lo pendiente del documento origen (${l.max_quantity}).`,
277
+ })
278
+ }
279
+ if (opts.requireTax && toAmount(l.tax_rate) === 0) {
280
+ issues.push({ field: `lines.${i}`, severity: 'warning', message: `Renglón ${i + 1}: sin impuesto.` })
281
+ }
282
+ })
283
+ if (computeTotals(lines).total < 0) issues.push({ severity: 'error', message: 'El total no puede ser negativo.' })
284
+ return issues
285
+ }
286
+
287
+ // ---- Cobro / REP (lines.kind = allocation) ----------------------------------
288
+
289
+ /** Documento abierto (fila de /data) → OpenDocument para PaymentAllocator. */
290
+ export function toOpenDocuments(rows: unknown, cfg: DocumentFormOpenDocuments, currency: string): OpenDocument[] {
291
+ if (!Array.isArray(rows)) return []
292
+ const rules = getOptionFilter(cfg)
293
+ const out: OpenDocument[] = []
294
+ for (const raw of rows) {
295
+ if (!raw || typeof raw !== 'object') continue
296
+ const r = raw as Record<string, any>
297
+ if (rules.length) {
298
+ const opt = { id: r.id, value: r.id, label: String(r[cfg.number_field] ?? ''), name: '', meta: r } as unknown as ResolvedOption
299
+ if (!rules.every((rule) => optionPassesRule(opt, rule))) continue
300
+ }
301
+ const balance = roundMoney(toAmount(readPath(r, cfg.balance_field)))
302
+ if (balance <= 0) continue
303
+ out.push({
304
+ id: String(r.id),
305
+ number: String(readPath(r, cfg.number_field) ?? r.id),
306
+ issued_at: String(readPath(r, cfg.issued_field ?? 'issued_at') ?? r.created_at ?? ''),
307
+ due_at: (cfg.due_field ? (readPath(r, cfg.due_field) as string | null) : null) ?? null,
308
+ total: roundMoney(toAmount(cfg.total_field ? readPath(r, cfg.total_field) : balance)),
309
+ balance,
310
+ currency: String(r.currency_code ?? r.currency ?? currency),
311
+ payment_method: cfg.method_field ? ((readPath(r, cfg.method_field) as string | null) ?? null) : null,
312
+ installments_paid: hasValue(r.installments_paid) ? toAmount(r.installments_paid) : undefined,
313
+ })
314
+ }
315
+ return out
316
+ }
317
+
318
+ /** Monto actual de cada documento, para pasar a modo manual sin perder lo repartido. */
319
+ export function manualFromAllocations(allocations: readonly Allocation[]): Record<string, number> {
320
+ return Object.fromEntries(allocations.map((a) => [a.document_id, a.amount]))
321
+ }
322
+
323
+ /** Renglones del payload: solo los dos campos que declara la acción (`invoice_id`, `amount`). */
324
+ export function allocationPayload(allocations: readonly Allocation[], cfg: DocumentFormOpenDocuments): Array<Record<string, unknown>> {
325
+ return allocations.filter((a) => a.amount > 0).map((a) => ({ [cfg.line_document_field]: a.document_id, [cfg.line_amount_field]: a.amount }))
326
+ }
327
+
328
+ /**
329
+ * Campo del encabezado con el monto recibido: `amount` si existe; si no, el
330
+ * primer campo numérico/moneda. Convención documentada (el schema no lo nombra).
331
+ */
332
+ export function allocationAmountField(fields: readonly ActionFieldDef[]): string | undefined {
333
+ return (
334
+ fields.find((f) => f.key === 'amount')?.key ??
335
+ fields.find((f) => ['number', 'numeric', 'decimal', 'currency', 'money'].includes(f.type) || f.widget === 'currency')?.key
336
+ )
337
+ }
338
+
339
+ const ALLOCATION_MESSAGES: Record<AllocationIssue['code'], string> = {
340
+ non_positive: 'Captura el monto recibido.',
341
+ over_balance: 'Se aplica más que el saldo del documento.',
342
+ over_amount: 'Lo aplicado supera el monto recibido.',
343
+ currency_mismatch: 'El documento está en otra moneda.',
344
+ unapplied: 'Queda monto sin aplicar.',
345
+ }
346
+
347
+ /** AllocationIssue → mensaje en español claro (con el número del documento). */
348
+ export function allocationIssueMessages(issues: readonly AllocationIssue[], docs: readonly OpenDocument[]): EditorIssue[] {
349
+ const byId = new Map(docs.map((d) => [d.id, d]))
350
+ return issues.map((i) => {
351
+ const doc = i.document_id ? byId.get(i.document_id) : undefined
352
+ return {
353
+ field: i.code === 'non_positive' ? 'amount' : 'allocations',
354
+ severity: i.severity,
355
+ message: doc ? `${doc.number}: ${ALLOCATION_MESSAGES[i.code]}` : ALLOCATION_MESSAGES[i.code],
356
+ }
357
+ })
358
+ }
359
+
360
+ /** Días de atraso de un documento abierto (0 si no está vencido o no tiene vencimiento). */
361
+ export function overdueDays(doc: Pick<OpenDocument, 'due_at'>, today: Date = new Date()): number {
362
+ if (!doc.due_at) return 0
363
+ const due = new Date(doc.due_at)
364
+ if (Number.isNaN(due.getTime())) return 0
365
+ const ms = Date.UTC(today.getFullYear(), today.getMonth(), today.getDate()) - Date.UTC(due.getUTCFullYear(), due.getUTCMonth(), due.getUTCDate())
366
+ return Math.max(0, Math.floor(ms / 86_400_000))
367
+ }
@@ -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
+ }