@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,76 @@
1
+ // AgingTable — antigüedad de saldos (CxC y CxP) como función pura.
2
+ //
3
+ // customers ya guarda aging_1_30 … aging_90_plus por cliente (refresh_credit_file).
4
+ // Esta función es la MISMA regla para la UI (drill-down por documento, CxP que
5
+ // hoy no la tiene) y para reportes: un solo lugar decide qué es «corriente».
6
+ import { roundMoney } from '../business/format'
7
+
8
+ export interface AgingBucket {
9
+ key: string
10
+ label: string
11
+ /** Días vencidos desde (inclusive). `current` usa -Infinity..0. */
12
+ from: number
13
+ /** Días vencidos hasta (inclusive). `null` = sin tope. */
14
+ to: number | null
15
+ }
16
+
17
+ export const DEFAULT_AGING_BUCKETS: AgingBucket[] = [
18
+ { key: 'current', label: 'Corriente', from: Number.NEGATIVE_INFINITY, to: 0 },
19
+ { key: 'd1_30', label: '1–30', from: 1, to: 30 },
20
+ { key: 'd31_60', label: '31–60', from: 31, to: 60 },
21
+ { key: 'd61_90', label: '61–90', from: 61, to: 90 },
22
+ { key: 'd90_plus', label: '90+', from: 91, to: null },
23
+ ]
24
+
25
+ export interface AgingDoc {
26
+ party_id: string
27
+ party_name?: string
28
+ due_at: string | null // ISO date; null = vence al emitir
29
+ issued_at: string
30
+ balance: number
31
+ }
32
+
33
+ export interface AgingRow {
34
+ party_id: string
35
+ party_name?: string
36
+ buckets: Record<string, number>
37
+ total: number
38
+ overdue: number
39
+ oldest_overdue_days: number
40
+ }
41
+
42
+ const DAY = 86_400_000
43
+
44
+ /** Días vencidos al corte (negativo = falta para vencer). Fechas en ISO (día). */
45
+ export function daysOverdue(dueISO: string, asOfISO: string): number {
46
+ const due = Date.parse(dueISO.slice(0, 10) + 'T00:00:00Z')
47
+ const asOf = Date.parse(asOfISO.slice(0, 10) + 'T00:00:00Z')
48
+ return Math.round((asOf - due) / DAY)
49
+ }
50
+
51
+ export function bucketFor(days: number, buckets: readonly AgingBucket[] = DEFAULT_AGING_BUCKETS): string {
52
+ for (const b of buckets) if (days >= b.from && (b.to === null || days <= b.to)) return b.key
53
+ return buckets[buckets.length - 1].key
54
+ }
55
+
56
+ /** Agrupa por contraparte y tramo. Saldos ≤ 0 se ignoran (anticipos van aparte). */
57
+ export function computeAging(docs: readonly AgingDoc[], asOfISO: string, buckets: readonly AgingBucket[] = DEFAULT_AGING_BUCKETS): { rows: AgingRow[]; totals: Record<string, number>; total: number } {
58
+ const rows = new Map<string, AgingRow>()
59
+ const totals: Record<string, number> = Object.fromEntries(buckets.map(b => [b.key, 0]))
60
+ for (const d of docs) {
61
+ if (d.balance <= 0) continue
62
+ const days = daysOverdue(d.due_at ?? d.issued_at, asOfISO)
63
+ const key = bucketFor(days, buckets)
64
+ const row = rows.get(d.party_id) ?? { party_id: d.party_id, party_name: d.party_name, buckets: Object.fromEntries(buckets.map(b => [b.key, 0])), total: 0, overdue: 0, oldest_overdue_days: 0 }
65
+ row.buckets[key] = roundMoney(row.buckets[key] + d.balance)
66
+ row.total = roundMoney(row.total + d.balance)
67
+ if (days > 0) {
68
+ row.overdue = roundMoney(row.overdue + d.balance)
69
+ row.oldest_overdue_days = Math.max(row.oldest_overdue_days, days)
70
+ }
71
+ totals[key] = roundMoney(totals[key] + d.balance)
72
+ rows.set(d.party_id, row)
73
+ }
74
+ const list = [...rows.values()].sort((a, b) => b.overdue - a.overdue || b.total - a.total)
75
+ return { rows: list, totals, total: roundMoney(list.reduce((s, r) => s + r.total, 0)) }
76
+ }
@@ -0,0 +1,104 @@
1
+ // PaymentAllocator — reglas puras para aplicar un pago a varios documentos
2
+ // abiertos (CxC: facturas del cliente; CxP: facturas del proveedor).
3
+ //
4
+ // Lo usan: cobro multi-factura + REP (customers / fiscal_mexico), CxC
5
+ // «Registrar abono», CxP «Aplicar pago», abonos de apartado. Sin React y sin
6
+ // host: la UI (PaymentAllocator) y el backend (wasm) pueden validar con lo mismo.
7
+ import { roundMoney } from '../business/format'
8
+
9
+ /** Documento abierto con saldo. `installments_paid` = parcialidades previas vigentes. */
10
+ export interface OpenDocument {
11
+ id: string
12
+ number: string
13
+ issued_at: string // ISO date
14
+ due_at?: string | null
15
+ total: number
16
+ balance: number
17
+ currency: string
18
+ /** `PPD` / `PUE` u otro: el núcleo no lo interpreta; fiscal_mexico sí. */
19
+ payment_method?: string | null
20
+ installments_paid?: number
21
+ /** Extensiones de país (UUID, serie…). Pasan intactas a la línea. */
22
+ extra?: Record<string, unknown>
23
+ }
24
+
25
+ export interface Allocation {
26
+ document_id: string
27
+ amount: number
28
+ /** Saldo anterior / pagado / insoluto: lo que pide el DoctoRelacionado del REP y el estado de cuenta. */
29
+ balance_before: number
30
+ balance_after: number
31
+ /** Número de parcialidad de ESTE pago para el documento (1-based). */
32
+ installment: number
33
+ }
34
+
35
+ export type AllocationStrategy = 'oldest_due_first' | 'oldest_issued_first' | 'manual'
36
+
37
+ export interface AllocationResult {
38
+ allocations: Allocation[]
39
+ applied: number
40
+ unapplied: number
41
+ }
42
+
43
+ function sortKey(d: OpenDocument, s: AllocationStrategy): string {
44
+ return s === 'oldest_issued_first' ? d.issued_at : (d.due_at ?? d.issued_at)
45
+ }
46
+
47
+ /** Reparte `amount` sobre `docs` según la estrategia. `manual` respeta `manual[id]`. */
48
+ export function allocatePayment(
49
+ amount: number,
50
+ docs: readonly OpenDocument[],
51
+ strategy: AllocationStrategy = 'oldest_due_first',
52
+ manual: Readonly<Record<string, number>> = {},
53
+ ): AllocationResult {
54
+ let left = roundMoney(Math.max(0, amount))
55
+ const ordered = strategy === 'manual' ? [...docs] : [...docs].sort((a, b) => sortKey(a, strategy).localeCompare(sortKey(b, strategy)))
56
+ const allocations: Allocation[] = []
57
+ for (const d of ordered) {
58
+ if (left <= 0) break
59
+ const balance = roundMoney(d.balance)
60
+ if (balance <= 0) continue
61
+ const want = strategy === 'manual' ? roundMoney(manual[d.id] ?? 0) : balance
62
+ const pay = roundMoney(Math.min(want, balance, left))
63
+ if (pay <= 0) continue
64
+ allocations.push({
65
+ document_id: d.id,
66
+ amount: pay,
67
+ balance_before: balance,
68
+ balance_after: roundMoney(balance - pay),
69
+ installment: (d.installments_paid ?? 0) + 1,
70
+ })
71
+ left = roundMoney(left - pay)
72
+ }
73
+ const applied = roundMoney(allocations.reduce((s, a) => s + a.amount, 0))
74
+ return { allocations, applied, unapplied: roundMoney(Math.max(0, amount) - applied) }
75
+ }
76
+
77
+ export interface AllocationIssue {
78
+ code: 'over_balance' | 'over_amount' | 'currency_mismatch' | 'unapplied' | 'non_positive'
79
+ document_id?: string
80
+ severity: 'error' | 'warning'
81
+ }
82
+
83
+ /** Revisión previa a guardar (la UI la pinta en ValidationChecklist; el backend la repite). */
84
+ export function validateAllocation(
85
+ amount: number,
86
+ currency: string,
87
+ docs: readonly OpenDocument[],
88
+ allocations: readonly Allocation[],
89
+ opts: { allowUnapplied?: boolean } = {},
90
+ ): AllocationIssue[] {
91
+ const byId = new Map(docs.map(d => [d.id, d]))
92
+ const issues: AllocationIssue[] = []
93
+ if (amount <= 0) issues.push({ code: 'non_positive', severity: 'error' })
94
+ for (const a of allocations) {
95
+ const d = byId.get(a.document_id)
96
+ if (!d) continue
97
+ if (a.amount > roundMoney(d.balance) + 0.005) issues.push({ code: 'over_balance', document_id: d.id, severity: 'error' })
98
+ if (d.currency !== currency) issues.push({ code: 'currency_mismatch', document_id: d.id, severity: 'error' })
99
+ }
100
+ const applied = roundMoney(allocations.reduce((s, a) => s + a.amount, 0))
101
+ if (applied > roundMoney(amount) + 0.005) issues.push({ code: 'over_amount', severity: 'error' })
102
+ else if (applied < roundMoney(amount) - 0.005) issues.push({ code: 'unapplied', severity: opts.allowUnapplied ? 'warning' : 'error' })
103
+ return issues
104
+ }
@@ -0,0 +1,103 @@
1
+ // ApprovalInbox — tipos y cliente de la bandeja de aprobaciones del kernel
2
+ // (dynamic/approvals.go, rutas /approvals*). Una sola bandeja para TODO lo que
3
+ // declare `supervisor_policy`, `approval` o un guard `on_violation:
4
+ // request_approval`, sin importar el addon (descuentos, crédito, OC por monto,
5
+ // cancelación CFDI, ajustes negativos…).
6
+ import type { ApiClient } from '../api-context'
7
+
8
+ /** Espejo de dynamic.ApprovalRequest (JSON). jsonb llega como string. */
9
+ export interface ApprovalRequestDTO {
10
+ id: string
11
+ addon_key: string
12
+ model_key: string
13
+ record_id: string
14
+ action_key: string
15
+ constraint_key: string
16
+ kind: 'constraint' | 'action' | 'explicit' | 'pin'
17
+ label: string
18
+ status: 'pending' | 'approved' | 'applied' | 'failed' | 'rejected' | 'expired'
19
+ requested_by: string
20
+ requested_by_role: string
21
+ requested_at: string
22
+ expires_at: string | null
23
+ roles: string | string[]
24
+ reason_required: boolean
25
+ payload: string | Record<string, unknown>
26
+ snapshot: string | Record<string, unknown>
27
+ violation: string | Record<string, unknown>
28
+ decided_by: string | null
29
+ decided_at: string | null
30
+ reason: string
31
+ error: string
32
+ }
33
+
34
+ export interface ApprovalListQuery {
35
+ status?: ApprovalRequestDTO['status']
36
+ kind?: ApprovalRequestDTO['kind']
37
+ model?: string
38
+ /** Solo las que yo pedí. */
39
+ mine?: boolean
40
+ /** Solo las que mis roles pueden decidir (default de la bandeja). */
41
+ for_me?: boolean
42
+ page?: number
43
+ per_page?: number
44
+ }
45
+
46
+ /** Categoría visible en la bandeja. El mapeo policy → categoría es extensible por addons. */
47
+ export interface ApprovalCategory {
48
+ key: string
49
+ label: string
50
+ icon?: string
51
+ /** Coincide si devuelve true (p. ej. por action_key, constraint_key o model_key). */
52
+ match: (r: ApprovalRequestDTO) => boolean
53
+ }
54
+
55
+ const categories: ApprovalCategory[] = []
56
+
57
+ /** Los addons registran su categoría al cargar (purchases: «Compras», fiscal_mexico: «Cancelación CFDI»…). */
58
+ export function registerApprovalCategory(c: ApprovalCategory): () => void {
59
+ categories.push(c)
60
+ return () => {
61
+ const i = categories.indexOf(c)
62
+ if (i >= 0) categories.splice(i, 1)
63
+ }
64
+ }
65
+
66
+ export function categorize(r: ApprovalRequestDTO): string {
67
+ return categories.find(c => c.match(r))?.key ?? 'other'
68
+ }
69
+
70
+ export function groupApprovals(items: readonly ApprovalRequestDTO[]): Record<string, ApprovalRequestDTO[]> {
71
+ const out: Record<string, ApprovalRequestDTO[]> = {}
72
+ for (const r of items) (out[categorize(r)] ??= []).push(r)
73
+ return out
74
+ }
75
+
76
+ export function parseJSONField<T = Record<string, unknown>>(v: string | T | null | undefined): T | null {
77
+ if (v == null || v === '') return null
78
+ if (typeof v !== 'string') return v
79
+ try {
80
+ return JSON.parse(v) as T
81
+ } catch {
82
+ return null
83
+ }
84
+ }
85
+
86
+ /** Cliente fino sobre las rutas del kernel; la UI lo usa vía useApi(). */
87
+ export function approvalsClient(api: ApiClient) {
88
+ const qs = (q: ApprovalListQuery) =>
89
+ Object.entries(q)
90
+ .filter(([, v]) => v !== undefined && v !== '')
91
+ .map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(String(v))}`)
92
+ .join('&')
93
+ return {
94
+ list: async (q: ApprovalListQuery = { status: 'pending', for_me: true }) => {
95
+ const res = await api.get(`/approvals?${qs(q)}`)
96
+ return { items: (res.data?.data ?? []) as ApprovalRequestDTO[], total: Number(res.data?.meta?.total ?? 0) }
97
+ },
98
+ count: async () => Number((await api.get('/approvals/count')).data?.data?.count ?? 0),
99
+ approve: (id: string, reason?: string) => api.post(`/approvals/${id}/approve`, { reason }),
100
+ reject: (id: string, reason: string) => api.post(`/approvals/${id}/reject`, { reason }),
101
+ approveWithPin: (id: string, pin: string, reason: string) => api.post(`/approvals/${id}/approve-pin`, { pin, reason }),
102
+ }
103
+ }
@@ -0,0 +1,255 @@
1
+ // Registro de contribuciones federadas — cómo un addon mete secciones,
2
+ // columnas, modales, fuentes de «Cargar desde» y vistas previas en la pantalla
3
+ // de OTRO addon sin que ese addon lo conozca.
4
+ //
5
+ // Se monta sobre slotRegistry (slot.tsx) — no es un segundo sistema: cada
6
+ // contribución es un SlotEntry con id canónico `doc:<kind>:<region>` — y añade
7
+ // lo que slot no tiene: `requiresAddon` (se oculta solo si el addon no está
8
+ // instalado o se desinstala en caliente), orden estable, `when` por documento y
9
+ // carga perezosa del componente (import() del remote federado).
10
+ //
11
+ // Contrato estable para remotes federados: los tipos de este archivo. Todo
12
+ // `register*` devuelve un disposer; el AddonLoader lo llama al desmontar o
13
+ // recargar el remote y la contribución desaparece sin recargar la página.
14
+ import { createElement, lazy, Suspense, type ComponentType } from 'react'
15
+ import {
16
+ getModalComponent,
17
+ registerModalComponent,
18
+ subscribeActionComponents,
19
+ type ModalComponentEntry,
20
+ } from '@asteby/metacore-sdk'
21
+ import { slotRegistry } from '../slot'
22
+
23
+ /** Regiones del DocumentEditor/DocumentPage donde se puede contribuir. */
24
+ export type DocumentRegion =
25
+ | 'header.fields' // campos extra del encabezado (Uso CFDI, Método, Forma…)
26
+ | 'party.card' // filas extra en PartyCard (RFC, régimen, CP, CSF)
27
+ | 'lines.columns' // columnas de LinesGrid (clave SAT, DOT, almacén)
28
+ | 'side.panels' // paneles laterales (Revisión, PreviewPanel, crédito)
29
+ | 'body.sections' // secciones bajo los renglones (Reingreso a stock, Relación CFDI)
30
+ | 'footer.actions' // botones del ActionBar (Timbrar, Pedir autorización)
31
+
32
+ export interface DocumentEditorContext {
33
+ kind: string // 'invoice' | 'credit_note' | 'payment' | 'purchase_order' | …
34
+ model: string // 'customers.Invoice'
35
+ values: Record<string, unknown>
36
+ mode: 'create' | 'edit' | 'view'
37
+ }
38
+
39
+ export interface DocumentContribution<P = DocumentContributionProps> {
40
+ /** Id estable `addon.region.name` (dedupe y orden). Re-registrar el mismo id reemplaza. */
41
+ id: string
42
+ kinds: string[] | '*'
43
+ region: DocumentRegion
44
+ /** Addon dueño. Si no está instalado, no se renderiza (y el server revalida). */
45
+ requiresAddon?: string
46
+ /** Mayor = primero. Default 0. */
47
+ priority?: number
48
+ when?: (ctx: DocumentEditorContext) => boolean
49
+ /** Componente o loader perezoso del remote (`() => import('fiscal_mexico/CfdiSection')`). */
50
+ component?: ComponentType<P>
51
+ load?: () => Promise<{ default: ComponentType<P> }>
52
+ }
53
+
54
+ /**
55
+ * Contribución resuelta: `component` siempre presente (si se registró con
56
+ * `load`, es un envoltorio lazy + Suspense que no pinta nada mientras carga).
57
+ */
58
+ export type ResolvedDocumentContribution<P = DocumentContributionProps> = DocumentContribution<P> & {
59
+ component: ComponentType<P>
60
+ }
61
+
62
+ export interface DocumentContributionProps {
63
+ ctx: DocumentEditorContext
64
+ setValue: (key: string, value: unknown) => void
65
+ issues: { report: (key: string, issue: { severity: 'error' | 'warning'; message: string }) => void }
66
+ }
67
+
68
+ /** Predicado «¿está instalado?»; `useInstalledAddons` lo arma en React. */
69
+ export type IsAddonInstalled = (addonKey: string) => boolean
70
+
71
+ interface Entry {
72
+ c: ResolvedDocumentContribution<any>
73
+ dispose: () => void
74
+ }
75
+
76
+ const contribs = new Map<string, Entry>()
77
+ const listeners = new Set<() => void>()
78
+ let version = 0
79
+
80
+ function notify() {
81
+ version += 1
82
+ for (const l of [...listeners]) l()
83
+ }
84
+
85
+ // Los modales viven en el store canónico del SDK (action-registry): cualquier
86
+ // cambio ahí (Registry.registerModal, registerModalComponent, unbind) también
87
+ // re-pinta a quien escucha contribuciones.
88
+ subscribeActionComponents(notify)
89
+
90
+ /** Suscripción para useSyncExternalStore (contribuciones y modales). */
91
+ export function subscribeContributions(listener: () => void): () => void {
92
+ listeners.add(listener)
93
+ return () => {
94
+ listeners.delete(listener)
95
+ }
96
+ }
97
+
98
+ /** Versión monotónica: cambia en cada register/dispose. */
99
+ export function contributionsVersion(): number {
100
+ return version
101
+ }
102
+
103
+ export function slotIdFor(kind: string, region: DocumentRegion): string {
104
+ return `doc:${kind}:${region}`
105
+ }
106
+
107
+ /** `load` → componente lazy envuelto en Suspense (fallback vacío: sin saltos de layout). */
108
+ function lazyComponent<P>(load: () => Promise<{ default: ComponentType<P> }>): ComponentType<P> {
109
+ const Lazy = lazy(load) as unknown as ComponentType<any>
110
+ return (props: P) => createElement(Suspense, { fallback: null }, createElement(Lazy, props as any))
111
+ }
112
+
113
+ /**
114
+ * Registra la contribución y la publica en slotRegistry bajo cada `doc:<kind>:<region>`
115
+ * (kinds '*' → `doc:*:<region>`). Devuelve el disposer: AddonLoader lo llama al
116
+ * desmontar o recargar el remote (fiber), así la sección desaparece al desinstalar.
117
+ * Re-registrar el mismo `id` (recarga en caliente) reemplaza la anterior.
118
+ */
119
+ export function registerDocumentContribution<P = DocumentContributionProps>(c: DocumentContribution<P>, source?: string): () => void {
120
+ if (!c.component && !c.load) throw new Error(`contribution ${c.id}: component o load es obligatorio`)
121
+ contribs.get(c.id)?.dispose()
122
+ const resolved = { ...c, component: c.component ?? lazyComponent(c.load!) } as ResolvedDocumentContribution<any>
123
+ const kinds = c.kinds === '*' ? ['*'] : c.kinds
124
+ const slotDisposers = kinds.map(k =>
125
+ slotRegistry.register(slotIdFor(k, c.region), resolved.component as ComponentType, {
126
+ priority: c.priority ?? 0,
127
+ source: source ?? c.requiresAddon ?? c.id,
128
+ owner: c.requiresAddon,
129
+ }),
130
+ )
131
+ let disposed = false
132
+ const entry: Entry = {
133
+ c: resolved,
134
+ dispose: () => {
135
+ if (disposed) return
136
+ disposed = true
137
+ slotDisposers.forEach(d => d())
138
+ if (contribs.get(c.id) === entry) contribs.delete(c.id)
139
+ notify()
140
+ },
141
+ }
142
+ contribs.set(c.id, entry)
143
+ notify()
144
+ return entry.dispose
145
+ }
146
+
147
+ /**
148
+ * Contribuciones aplicables a `region` para el documento `ctx`, ordenadas por
149
+ * prioridad (desc) y luego id. Pura: no lee React; `useDocumentContributions`
150
+ * la envuelve con useInstalledAddons.
151
+ */
152
+ export function resolveContributions(
153
+ ctx: DocumentEditorContext,
154
+ region: DocumentRegion,
155
+ isInstalled: IsAddonInstalled,
156
+ ): ResolvedDocumentContribution<any>[] {
157
+ return [...contribs.values()]
158
+ .map(e => e.c)
159
+ .filter(c => c.region === region)
160
+ .filter(c => c.kinds === '*' || c.kinds.includes(ctx.kind))
161
+ .filter(c => !c.requiresAddon || isInstalled(c.requiresAddon))
162
+ .filter(c => !c.when || c.when(ctx))
163
+ .sort((a, b) => (b.priority ?? 0) - (a.priority ?? 0) || a.id.localeCompare(b.id))
164
+ }
165
+
166
+ // ---- Modales federados ------------------------------------------------------
167
+ // ActionModalDispatcher resuelve `modal: "returns.settle_method"` con
168
+ // requestFederatedAction (espera hasta 20 s). Este registro es su contraparte
169
+ // declarativa: el addon registra la clave al cargar. Si el addon dueño no está
170
+ // instalado, resolveFederatedModal devuelve null de inmediato y el dispatcher
171
+ // usa el formulario genérico de la acción en vez de colgarse esperando.
172
+ //
173
+ // No hay mapa propio: es un alias del store canónico de modales por slug de
174
+ // @asteby/metacore-sdk (registerModalComponent), el mismo donde escribe
175
+ // `api.registry.registerModal` de AddonAPI.
176
+
177
+ export interface FederatedModal<P = any> {
178
+ /** Clave `<addon>.<nombre>`, la misma que `modal` en el manifest. */
179
+ key: string // 'fiscal_mexico.import_cfdi'
180
+ addon: string
181
+ /** El dispatcher le pasa ActionModalProps (open, onOpenChange, action, model, record…). */
182
+ load: () => Promise<{ default: ComponentType<P> }>
183
+ /** Presente cuando el modal se registró ya cargado (`api.registry.registerModal`). */
184
+ component?: ComponentType<P>
185
+ }
186
+
187
+ /** Vista FederatedModal de cada entrada del store (identidad estable por entrada). */
188
+ const views = new WeakMap<ModalComponentEntry, FederatedModal>()
189
+ const modalComponents = new WeakMap<FederatedModal, ComponentType<any>>()
190
+ /** Disposers de lo registrado por este alias (sólo __resetContributions los usa). */
191
+ const aliasDisposers = new Set<() => void>()
192
+
193
+ /**
194
+ * @deprecated Usa `registerModalComponent({ slug, load, owner })` de
195
+ * `@asteby/metacore-sdk` o `api.registry.registerModal` desde el plugin. Se
196
+ * mantiene como alias compatible: escribe en el mismo store.
197
+ */
198
+ export function registerFederatedModal(m: FederatedModal): () => void {
199
+ const dispose = registerModalComponent({ slug: m.key, owner: m.addon, load: m.load, component: m.component })
200
+ const stored = getModalComponent(m.key)
201
+ if (stored) views.set(stored, m)
202
+ aliasDisposers.add(dispose)
203
+ return () => {
204
+ aliasDisposers.delete(dispose)
205
+ dispose()
206
+ }
207
+ }
208
+
209
+ function viewOf(e: ModalComponentEntry): FederatedModal {
210
+ let v = views.get(e)
211
+ if (!v) {
212
+ const component = e.component
213
+ v = {
214
+ key: e.slug,
215
+ addon: e.owner ?? '',
216
+ load: e.load ?? (async () => ({ default: component! })),
217
+ component,
218
+ }
219
+ views.set(e, v)
220
+ }
221
+ return v
222
+ }
223
+
224
+ /**
225
+ * Modal del slug en el store canónico, o null si no está registrado o su addon
226
+ * dueño no está instalado. Nunca espera.
227
+ */
228
+ export function resolveFederatedModal(key: string, isInstalled: IsAddonInstalled): FederatedModal | null {
229
+ const m = getModalComponent(key)
230
+ return m && (!m.owner || isInstalled(m.owner)) ? viewOf(m) : null
231
+ }
232
+
233
+ /**
234
+ * Componente del modal resuelto: el ya cargado si lo hay; si no, un
235
+ * `React.lazy` memoizado por registro (no se re-crea en cada render; quien lo
236
+ * pinta lo envuelve en `<Suspense>`).
237
+ */
238
+ export function federatedModalComponent<P = any>(m: FederatedModal<P>): ComponentType<P> {
239
+ if (m.component) return m.component
240
+ let C = modalComponents.get(m)
241
+ if (!C) {
242
+ C = lazy(m.load) as unknown as ComponentType<any>
243
+ modalComponents.set(m, C)
244
+ }
245
+ return C as ComponentType<P>
246
+ }
247
+
248
+ /** Solo para tests. */
249
+ export function __resetContributions(): void {
250
+ for (const e of [...contribs.values()]) e.dispose()
251
+ contribs.clear()
252
+ for (const d of [...aliasDisposers]) d()
253
+ aliasDisposers.clear()
254
+ notify()
255
+ }
@@ -0,0 +1,62 @@
1
+ // DocumentKind — un registro de tipos de documento comercial para que
2
+ // factura, cotización, pedido, OC, NC, cobro/REP, factura de proveedor y OT usen
3
+ // el MISMO DocumentEditor / DocumentPage. Cada addon declara su tipo (desde el
4
+ // manifest `document_forms` o con registerDocumentKind) y el editor arma:
5
+ // contraparte, renglones, fuentes de «Cargar desde», totales y vista previa.
6
+ import type { DocumentFormPreview, DocumentFormSource } from '../types'
7
+
8
+ export type PartyRole = 'customer' | 'supplier' | 'none'
9
+ export type LinesMode =
10
+ | 'sale' // precio de venta, descuento, impuesto (factura, cotización, pedido)
11
+ | 'purchase' // costo, recepción pendiente (OC, factura de proveedor)
12
+ | 'credit' // cantidades ≤ facturado − acreditado (NC, devolución)
13
+ | 'allocation' // documentos abiertos a pagar (cobro / REP / pago a proveedor)
14
+ | 'workorder' // servicio + refacción, técnico, horas (OT)
15
+ | 'none'
16
+
17
+ export interface DocumentKind {
18
+ key: string
19
+ label: string
20
+ model: string // ModelKey canónico ('customers.Invoice')
21
+ party: PartyRole
22
+ partyModel?: string // 'customers.Customer' | 'purchases.Supplier'
23
+ lines: LinesMode
24
+ /** Campo de renglones del modelo (one_to_many) o del payload de la acción. */
25
+ linesField?: string
26
+ sources?: DocumentFormSource[]
27
+ preview?: DocumentFormPreview
28
+ /** Estados del documento (StageMachine): los muestra StageStepper. */
29
+ stages?: string[]
30
+ /** Addon dueño del tipo (si no está, el tipo no se ofrece). */
31
+ addon: string
32
+ }
33
+
34
+ const kinds = new Map<string, DocumentKind>()
35
+
36
+ export function registerDocumentKind(k: DocumentKind): () => void {
37
+ kinds.set(k.key, k)
38
+ return () => {
39
+ if (kinds.get(k.key) === k) kinds.delete(k.key)
40
+ }
41
+ }
42
+
43
+ export function getDocumentKind(key: string): DocumentKind | undefined {
44
+ return kinds.get(key)
45
+ }
46
+
47
+ export function listDocumentKinds(isInstalled: (addon: string) => boolean): DocumentKind[] {
48
+ return [...kinds.values()].filter(k => isInstalled(k.addon))
49
+ }
50
+
51
+ /** Tipos base que el core ya tiene (modelos verificados en addons main 9cd69bde). */
52
+ export const CORE_DOCUMENT_KINDS: DocumentKind[] = [
53
+ { key: 'invoice', label: 'Factura', model: 'customers.Invoice', party: 'customer', partyModel: 'customers.Customer', lines: 'sale', linesField: 'items', addon: 'customers' },
54
+ { key: 'quote', label: 'Cotización', model: 'quotes.Quote', party: 'customer', partyModel: 'customers.Customer', lines: 'sale', linesField: 'items', addon: 'quotes' },
55
+ { key: 'sales_order', label: 'Pedido', model: 'customers.SalesOrder', party: 'customer', partyModel: 'customers.Customer', lines: 'sale', linesField: 'items', addon: 'customers' },
56
+ { key: 'payment', label: 'Cobro', model: 'customers.Payment', party: 'customer', partyModel: 'customers.Customer', lines: 'allocation', linesField: 'allocations', addon: 'customers' },
57
+ { key: 'credit_note', label: 'Nota de crédito', model: 'fiscal_mexico.CreditNote', party: 'customer', partyModel: 'customers.Customer', lines: 'credit', linesField: 'lines', addon: 'fiscal_mexico' },
58
+ { key: 'purchase_order', label: 'Orden de compra', model: 'purchases.PurchaseOrder', party: 'supplier', partyModel: 'purchases.Supplier', lines: 'purchase', linesField: 'items', addon: 'purchases' },
59
+ { key: 'supplier_invoice', label: 'Factura de proveedor', model: 'purchases.SupplierInvoice', party: 'supplier', partyModel: 'purchases.Supplier', lines: 'purchase', addon: 'purchases' },
60
+ { key: 'supplier_payment', label: 'Pago a proveedor', model: 'purchases.PurchasePayment', party: 'supplier', partyModel: 'purchases.Supplier', lines: 'allocation', addon: 'purchases' },
61
+ { key: 'work_order', label: 'Orden de trabajo', model: 'workshop.WorkOrder', party: 'customer', partyModel: 'customers.Customer', lines: 'workorder', linesField: 'items', addon: 'workshop' },
62
+ ]
@@ -0,0 +1,23 @@
1
+ // Primitivos de negocio — contrato estable para hosts y remotes federados.
2
+ // Las funciones son puras (sin React ni host) salvo use-contributions; la UI y
3
+ // el backend (wasm) pueden validar con las mismas reglas.
4
+ //
5
+ // allocation PaymentAllocator: allocatePayment / validateAllocation
6
+ // (cobro multi-factura, REP, CxC «Registrar abono», CxP «Aplicar pago»).
7
+ // aging AgingTable: computeAging / daysOverdue / bucketFor (antigüedad de saldos).
8
+ // approvals ApprovalInbox: approvalsClient (rutas /approvals del kernel),
9
+ // registerApprovalCategory / groupApprovals.
10
+ // contributions Secciones y modales federados: registerDocumentContribution,
11
+ // resolveContributions (pura), registerFederatedModal /
12
+ // resolveFederatedModal. Todo register* devuelve su disposer.
13
+ // document-kinds registerDocumentKind / listDocumentKinds / CORE_DOCUMENT_KINDS.
14
+ // panels Tipos de PartyCard / TotalsPanel / PreviewPanel /
15
+ // ValidationChecklist / LoadFromDocument + blockingChecks.
16
+ // use-contributions Hooks: useDocumentContributions, useIsAddonInstalled.
17
+ export * from './allocation'
18
+ export * from './aging'
19
+ export * from './approvals'
20
+ export * from './contributions'
21
+ export * from './document-kinds'
22
+ export * from './panels'
23
+ export * from './use-contributions'