@asteby/metacore-runtime-react 45.0.0 → 45.3.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 (177) hide show
  1. package/CHANGELOG.md +29 -0
  2. package/dist/action-modal-dispatcher.d.ts +29 -0
  3. package/dist/action-modal-dispatcher.d.ts.map +1 -1
  4. package/dist/action-modal-dispatcher.js +115 -6
  5. package/dist/activity-diff.d.ts +2 -0
  6. package/dist/activity-diff.d.ts.map +1 -1
  7. package/dist/activity-diff.js +2 -2
  8. package/dist/approval-events.d.ts +16 -0
  9. package/dist/approval-events.d.ts.map +1 -0
  10. package/dist/approval-events.js +27 -0
  11. package/dist/approval-gate.d.ts +39 -0
  12. package/dist/approval-gate.d.ts.map +1 -0
  13. package/dist/approval-gate.js +110 -0
  14. package/dist/approval-pin-dialog.d.ts +25 -0
  15. package/dist/approval-pin-dialog.d.ts.map +1 -0
  16. package/dist/approval-pin-dialog.js +66 -0
  17. package/dist/business/catalog.d.ts +16 -0
  18. package/dist/business/catalog.d.ts.map +1 -0
  19. package/dist/business/catalog.js +50 -0
  20. package/dist/business/customer-picker.d.ts +39 -0
  21. package/dist/business/customer-picker.d.ts.map +1 -0
  22. package/dist/business/customer-picker.js +51 -0
  23. package/dist/business/feedback.d.ts +72 -0
  24. package/dist/business/feedback.d.ts.map +1 -0
  25. package/dist/business/feedback.js +52 -0
  26. package/dist/business/field-errors.d.ts +26 -0
  27. package/dist/business/field-errors.d.ts.map +1 -0
  28. package/dist/business/field-errors.js +59 -0
  29. package/dist/business/format.d.ts +49 -0
  30. package/dist/business/format.d.ts.map +1 -0
  31. package/dist/business/format.js +128 -0
  32. package/dist/business/index.d.ts +16 -0
  33. package/dist/business/index.d.ts.map +1 -0
  34. package/dist/business/index.js +15 -0
  35. package/dist/business/line-items-editor.d.ts +25 -0
  36. package/dist/business/line-items-editor.d.ts.map +1 -0
  37. package/dist/business/line-items-editor.js +36 -0
  38. package/dist/business/line-items.d.ts +69 -0
  39. package/dist/business/line-items.d.ts.map +1 -0
  40. package/dist/business/line-items.js +133 -0
  41. package/dist/business/payment-capture.d.ts +21 -0
  42. package/dist/business/payment-capture.d.ts.map +1 -0
  43. package/dist/business/payment-capture.js +24 -0
  44. package/dist/business/payment.d.ts +61 -0
  45. package/dist/business/payment.d.ts.map +1 -0
  46. package/dist/business/payment.js +74 -0
  47. package/dist/business/product-picker.d.ts +24 -0
  48. package/dist/business/product-picker.d.ts.map +1 -0
  49. package/dist/business/product-picker.js +48 -0
  50. package/dist/business/product-search.d.ts +70 -0
  51. package/dist/business/product-search.d.ts.map +1 -0
  52. package/dist/business/product-search.js +51 -0
  53. package/dist/business/refund-destination.d.ts +19 -0
  54. package/dist/business/refund-destination.d.ts.map +1 -0
  55. package/dist/business/refund-destination.js +31 -0
  56. package/dist/business/refund.d.ts +29 -0
  57. package/dist/business/refund.d.ts.map +1 -0
  58. package/dist/business/refund.js +44 -0
  59. package/dist/business/return-wizard.d.ts +23 -0
  60. package/dist/business/return-wizard.d.ts.map +1 -0
  61. package/dist/business/return-wizard.js +59 -0
  62. package/dist/business/return.d.ts +90 -0
  63. package/dist/business/return.d.ts.map +1 -0
  64. package/dist/business/return.js +112 -0
  65. package/dist/business/use-async-search.d.ts +10 -0
  66. package/dist/business/use-async-search.d.ts.map +1 -0
  67. package/dist/business/use-async-search.js +38 -0
  68. package/dist/document/action-bar.d.ts +12 -0
  69. package/dist/document/action-bar.d.ts.map +1 -0
  70. package/dist/document/action-bar.js +39 -0
  71. package/dist/document/cancel-with-reason.d.ts +47 -0
  72. package/dist/document/cancel-with-reason.d.ts.map +1 -0
  73. package/dist/document/cancel-with-reason.js +52 -0
  74. package/dist/document/document-header.d.ts +41 -0
  75. package/dist/document/document-header.d.ts.map +1 -0
  76. package/dist/document/document-header.js +30 -0
  77. package/dist/document/document-page.d.ts +37 -0
  78. package/dist/document/document-page.d.ts.map +1 -0
  79. package/dist/document/document-page.js +137 -0
  80. package/dist/document/document-timeline.d.ts +20 -0
  81. package/dist/document/document-timeline.d.ts.map +1 -0
  82. package/dist/document/document-timeline.js +26 -0
  83. package/dist/document/evaluate.d.ts +31 -0
  84. package/dist/document/evaluate.d.ts.map +1 -0
  85. package/dist/document/evaluate.js +170 -0
  86. package/dist/document/format.d.ts +9 -0
  87. package/dist/document/format.d.ts.map +1 -0
  88. package/dist/document/format.js +52 -0
  89. package/dist/document/index.d.ts +13 -0
  90. package/dist/document/index.d.ts.map +1 -0
  91. package/dist/document/index.js +12 -0
  92. package/dist/document/registry.d.ts +20 -0
  93. package/dist/document/registry.d.ts.map +1 -0
  94. package/dist/document/registry.js +20 -0
  95. package/dist/document/smart-buttons.d.ts +16 -0
  96. package/dist/document/smart-buttons.d.ts.map +1 -0
  97. package/dist/document/smart-buttons.js +16 -0
  98. package/dist/document/status-badge.d.ts +16 -0
  99. package/dist/document/status-badge.d.ts.map +1 -0
  100. package/dist/document/status-badge.js +23 -0
  101. package/dist/document/types.d.ts +196 -0
  102. package/dist/document/types.d.ts.map +1 -0
  103. package/dist/document/types.js +1 -0
  104. package/dist/document/use-document-sources.d.ts +14 -0
  105. package/dist/document/use-document-sources.d.ts.map +1 -0
  106. package/dist/document/use-document-sources.js +72 -0
  107. package/dist/dynamic-columns.d.ts.map +1 -1
  108. package/dist/dynamic-columns.js +3 -1
  109. package/dist/dynamic-relation.d.ts +4 -0
  110. package/dist/dynamic-relation.d.ts.map +1 -1
  111. package/dist/dynamic-relation.js +10 -1
  112. package/dist/dynamic-row-actions.d.ts.map +1 -1
  113. package/dist/dynamic-row-actions.js +14 -2
  114. package/dist/dynamic-table.d.ts.map +1 -1
  115. package/dist/dynamic-table.js +28 -3
  116. package/dist/index.d.ts +5 -0
  117. package/dist/index.d.ts.map +1 -1
  118. package/dist/index.js +6 -0
  119. package/dist/reason-prompt.d.ts +59 -0
  120. package/dist/reason-prompt.d.ts.map +1 -0
  121. package/dist/reason-prompt.js +111 -0
  122. package/dist/server-error.d.ts.map +1 -1
  123. package/dist/server-error.js +19 -0
  124. package/dist/use-addon-settings.d.ts +17 -3
  125. package/dist/use-addon-settings.d.ts.map +1 -1
  126. package/dist/use-addon-settings.js +20 -8
  127. package/package.json +1 -1
  128. package/src/__tests__/approval-gate.test.tsx +134 -0
  129. package/src/__tests__/prefill-from-record.test.ts +69 -0
  130. package/src/__tests__/reason-prompt.test.tsx +121 -0
  131. package/src/__tests__/use-addon-settings.test.ts +16 -1
  132. package/src/action-modal-dispatcher.tsx +145 -3
  133. package/src/activity-diff.tsx +10 -1
  134. package/src/approval-events.ts +39 -0
  135. package/src/approval-gate.tsx +180 -0
  136. package/src/approval-pin-dialog.tsx +162 -0
  137. package/src/business/business.test.ts +210 -0
  138. package/src/business/catalog.ts +68 -0
  139. package/src/business/customer-picker.tsx +201 -0
  140. package/src/business/feedback.tsx +141 -0
  141. package/src/business/field-errors.ts +92 -0
  142. package/src/business/format.ts +151 -0
  143. package/src/business/index.ts +15 -0
  144. package/src/business/line-items-editor.tsx +259 -0
  145. package/src/business/line-items.ts +190 -0
  146. package/src/business/payment-capture.tsx +158 -0
  147. package/src/business/payment.ts +125 -0
  148. package/src/business/product-picker.tsx +171 -0
  149. package/src/business/product-search.ts +108 -0
  150. package/src/business/refund-destination.tsx +121 -0
  151. package/src/business/refund.ts +72 -0
  152. package/src/business/return-wizard.tsx +223 -0
  153. package/src/business/return.ts +181 -0
  154. package/src/business/use-async-search.ts +43 -0
  155. package/src/document/__tests__/document-page.test.tsx +107 -0
  156. package/src/document/__tests__/evaluate.test.ts +140 -0
  157. package/src/document/action-bar.tsx +140 -0
  158. package/src/document/cancel-with-reason.tsx +214 -0
  159. package/src/document/document-header.tsx +140 -0
  160. package/src/document/document-page.tsx +268 -0
  161. package/src/document/document-timeline.tsx +66 -0
  162. package/src/document/evaluate.ts +180 -0
  163. package/src/document/format.ts +59 -0
  164. package/src/document/index.ts +28 -0
  165. package/src/document/registry.ts +39 -0
  166. package/src/document/smart-buttons.tsx +54 -0
  167. package/src/document/status-badge.tsx +44 -0
  168. package/src/document/types.ts +203 -0
  169. package/src/document/use-document-sources.ts +91 -0
  170. package/src/dynamic-columns.tsx +5 -1
  171. package/src/dynamic-relation.tsx +14 -1
  172. package/src/dynamic-row-actions.tsx +13 -1
  173. package/src/dynamic-table.tsx +22 -2
  174. package/src/index.ts +33 -0
  175. package/src/reason-prompt.tsx +247 -0
  176. package/src/server-error.ts +20 -0
  177. package/src/use-addon-settings.ts +26 -7
@@ -0,0 +1,181 @@
1
+ // Devolución de mercancía (ReturnWizard: POS, Pedido, Factura, RMA, OT, devolución
2
+ // a proveedor). Lógica pura, sin endpoints: cada consumidor cablea el envío.
3
+ // Flujo (benchmark §8.2): renglones → condición y destino → recepción → reembolso o NC.
4
+ import { roundMoney, toAmount } from './format'
5
+ import { validateRefund, type RefundAllocation, type RefundDestinationOption } from './refund'
6
+
7
+ /** Estado físico de lo que regresa. */
8
+ export type ReturnCondition = 'sellable' | 'defective'
9
+ /** A dónde va la mercancía: stock vendible, merma o garantía (reclamo al proveedor). */
10
+ export type ReturnStockDestination = 'stock' | 'scrap' | 'warranty'
11
+
12
+ export const RETURN_CONDITION_DESTINATIONS: Record<ReturnCondition, ReturnStockDestination[]> = {
13
+ sellable: ['stock'],
14
+ defective: ['scrap', 'warranty'],
15
+ }
16
+
17
+ /** Destino por defecto de una condición (vendible → stock, defectuoso → merma). */
18
+ export function defaultStockDestination(condition: ReturnCondition): ReturnStockDestination {
19
+ return RETURN_CONDITION_DESTINATIONS[condition][0]
20
+ }
21
+
22
+ /** Renglón devolvible: lo vendido menos lo ya devuelto. */
23
+ export interface ReturnableLine {
24
+ key: string
25
+ product_id?: string
26
+ description: string
27
+ sold: number
28
+ alreadyReturned?: number
29
+ /** Precio unitario neto de descuento tal como se vendió (congelado). */
30
+ unit_price: number
31
+ /** Fracción (0.16). */
32
+ tax_rate?: number
33
+ }
34
+
35
+ export interface ReturnLineChoice {
36
+ quantity: number
37
+ condition: ReturnCondition
38
+ destination: ReturnStockDestination
39
+ }
40
+
41
+ export type ReturnChoices = Record<string, ReturnLineChoice>
42
+
43
+ export function returnableQty(l: ReturnableLine): number {
44
+ return Math.max(0, roundMoney(l.sold - (l.alreadyReturned ?? 0), 4))
45
+ }
46
+
47
+ export interface ReturnTotals {
48
+ base: number
49
+ tax: number
50
+ total: number
51
+ goodsLines: number
52
+ }
53
+
54
+ export function computeReturnTotals(lines: ReturnableLine[], choices: ReturnChoices): ReturnTotals {
55
+ let base = 0
56
+ let tax = 0
57
+ let goodsLines = 0
58
+ for (const l of lines) {
59
+ const c = choices[l.key]
60
+ const q = c ? toAmount(c.quantity) : 0
61
+ if (q <= 0) continue
62
+ goodsLines += 1
63
+ const b = roundMoney(q * l.unit_price)
64
+ base += b
65
+ tax += roundMoney(b * (l.tax_rate ?? 0))
66
+ }
67
+ base = roundMoney(base)
68
+ tax = roundMoney(tax)
69
+ return { base, tax, total: roundMoney(base + tax), goodsLines }
70
+ }
71
+
72
+ /**
73
+ * Relación SAT (c_TipoRelacion) de la NC: 03 «devolución de mercancía» si regresan
74
+ * renglones con producto; 01 «nota de crédito de los documentos relacionados» si es
75
+ * solo ajuste de importe. Misma regla que aplica el backend al proyectar la NC.
76
+ */
77
+ export function creditNoteRelation(lines: ReturnableLine[], choices: ReturnChoices): '01' | '03' {
78
+ return lines.some((l) => !!l.product_id && toAmount(choices[l.key]?.quantity) > 0) ? '03' : '01'
79
+ }
80
+
81
+ export interface ReturnValidation {
82
+ errors: Record<string, string>
83
+ form?: string
84
+ valid: boolean
85
+ }
86
+
87
+ export function validateReturnChoices(lines: ReturnableLine[], choices: ReturnChoices, reason?: string, requireReason = true): ReturnValidation {
88
+ const errors: Record<string, string> = {}
89
+ let any = false
90
+ for (const l of lines) {
91
+ const c = choices[l.key]
92
+ const q = c ? toAmount(c.quantity) : 0
93
+ if (q === 0) continue
94
+ if (q < 0) errors[`${l.key}.quantity`] = 'La cantidad no puede ser negativa'
95
+ else if (q > returnableQty(l)) errors[`${l.key}.quantity`] = `Máximo ${returnableQty(l)} por devolver`
96
+ else any = true
97
+ if (c && !RETURN_CONDITION_DESTINATIONS[c.condition]?.includes(c.destination)) {
98
+ errors[`${l.key}.destination`] = 'Ese destino no aplica a la condición elegida'
99
+ }
100
+ }
101
+ let form: string | undefined
102
+ if (!any && Object.keys(errors).length === 0) form = 'Elige al menos un renglón a devolver'
103
+ else if (requireReason && !(reason ?? '').trim()) form = 'Indica el motivo de la devolución'
104
+ return { errors, form, valid: !form && Object.keys(errors).length === 0 }
105
+ }
106
+
107
+ /** Pasos visibles. Sin almacén, «Recibir» es un paso del mismo asistente (PIT-021). */
108
+ export type ReturnStep = 'lines' | 'condition' | 'receive' | 'refund'
109
+
110
+ export function returnSteps(opts: { warehouseConnected: boolean }): ReturnStep[] {
111
+ // Con almacén, la recepción la confirma almacén fuera del asistente: no es paso.
112
+ return opts.warehouseConnected ? ['lines', 'condition', 'refund'] : ['lines', 'condition', 'receive', 'refund']
113
+ }
114
+
115
+ export interface ReturnSubmitPayload {
116
+ reason: string
117
+ notes?: string
118
+ /** true cuando el asistente confirmó la recepción (sin almacén). */
119
+ received: boolean
120
+ lines: { key: string; product_id?: string; quantity: number; condition: ReturnCondition; destination: ReturnStockDestination }[]
121
+ refund: RefundAllocation[]
122
+ /** Presente solo si el reembolso incluye una NC CFDI. */
123
+ credit_note?: { relation: '01' | '03' }
124
+ totals: ReturnTotals
125
+ }
126
+
127
+ /** Única puerta de salida hacia el backend: solo renglones con cantidad > 0. */
128
+ export function serializeReturn(args: {
129
+ lines: ReturnableLine[]
130
+ choices: ReturnChoices
131
+ reason: string
132
+ notes?: string
133
+ received: boolean
134
+ refund: RefundAllocation[]
135
+ }): ReturnSubmitPayload {
136
+ const lines = args.lines
137
+ .filter((l) => toAmount(args.choices[l.key]?.quantity) > 0)
138
+ .map((l) => {
139
+ const c = args.choices[l.key]
140
+ return { key: l.key, ...(l.product_id ? { product_id: l.product_id } : {}), quantity: toAmount(c.quantity), condition: c.condition, destination: c.destination }
141
+ })
142
+ const notes = (args.notes ?? '').trim()
143
+ return {
144
+ reason: args.reason.trim(),
145
+ ...(notes ? { notes } : {}),
146
+ received: args.received,
147
+ lines,
148
+ refund: args.refund.map((a) => ({ ...a, amount: roundMoney(a.amount) })),
149
+ ...(args.refund.some((a) => a.kind === 'credit_note') ? { credit_note: { relation: creditNoteRelation(args.lines, args.choices) } } : {}),
150
+ totals: computeReturnTotals(args.lines, args.choices),
151
+ }
152
+ }
153
+
154
+ /** Valida el paso de reembolso contra el total de la devolución. */
155
+ export function validateReturnRefund(total: number, refund: RefundAllocation[], options?: RefundDestinationOption[]) {
156
+ return validateRefund(total, refund, options)
157
+ }
158
+
159
+ // ---- Estados de la RMA ---------------------------------------------------------
160
+ // Borrador → Autorizada → Recibida (total o parcial) → Liquidada → Cerrada, y
161
+ // Rechazada o Cancelada con motivo obligatorio (benchmark §8.2).
162
+ export type RmaState = 'draft' | 'authorized' | 'received' | 'settled' | 'closed' | 'rejected' | 'cancelled'
163
+
164
+ export const RMA_TRANSITIONS: Record<RmaState, RmaState[]> = {
165
+ draft: ['authorized', 'rejected', 'cancelled'],
166
+ authorized: ['received', 'rejected', 'cancelled'],
167
+ received: ['settled', 'cancelled'],
168
+ settled: ['closed'],
169
+ closed: [],
170
+ rejected: [],
171
+ cancelled: [],
172
+ }
173
+
174
+ /** Estados a los que se llega con motivo obligatorio. */
175
+ export const RMA_REASON_REQUIRED: RmaState[] = ['rejected', 'cancelled']
176
+
177
+ export function canTransitionRma(from: RmaState, to: RmaState, reason?: string): { ok: boolean; error?: string } {
178
+ if (!RMA_TRANSITIONS[from]?.includes(to)) return { ok: false, error: `No se puede pasar de ${from} a ${to}` }
179
+ if (RMA_REASON_REQUIRED.includes(to) && !(reason ?? '').trim()) return { ok: false, error: 'Indica el motivo' }
180
+ return { ok: true }
181
+ }
@@ -0,0 +1,43 @@
1
+ import { useEffect, useState } from 'react'
2
+ import { useDebouncedValue } from '../use-debounced-value'
3
+
4
+ /** Búsqueda async con debounce y cancelación. `search` debe ser estable (useCallback). */
5
+ export function useAsyncSearch<T>(
6
+ query: string,
7
+ search: (q: string, signal: AbortSignal) => Promise<T[]>,
8
+ opts: { minChars?: number; delay?: number } = {},
9
+ ): { results: T[]; loading: boolean; error: boolean } {
10
+ const { minChars = 2, delay = 250 } = opts
11
+ const q = useDebouncedValue(query.trim(), delay)
12
+ const [results, setResults] = useState<T[]>([])
13
+ const [loading, setLoading] = useState(false)
14
+ const [error, setError] = useState(false)
15
+
16
+ useEffect(() => {
17
+ if (q.length < minChars) {
18
+ setResults([])
19
+ setLoading(false)
20
+ setError(false)
21
+ return
22
+ }
23
+ const ctrl = new AbortController()
24
+ setLoading(true)
25
+ setError(false)
26
+ search(q, ctrl.signal)
27
+ .then((rows) => {
28
+ if (!ctrl.signal.aborted) setResults(rows)
29
+ })
30
+ .catch(() => {
31
+ if (!ctrl.signal.aborted) {
32
+ setResults([])
33
+ setError(true)
34
+ }
35
+ })
36
+ .finally(() => {
37
+ if (!ctrl.signal.aborted) setLoading(false)
38
+ })
39
+ return () => ctrl.abort()
40
+ }, [q, search, minChars])
41
+
42
+ return { results, loading, error }
43
+ }
@@ -0,0 +1,107 @@
1
+ // @vitest-environment happy-dom
2
+ //
3
+ // DocumentPage: dos badges independientes con texto explícito, 1 primaria +
4
+ // secundarias visibles, destructiva separada y bloqueada con motivo, botones
5
+ // inteligentes con contador y pestaña «records» desde una fuente.
6
+ import { afterEach, describe, expect, it, vi } from 'vitest'
7
+ import { cleanup, render, screen, fireEvent, waitFor } from '@testing-library/react'
8
+
9
+ vi.mock('react-i18next', () => ({
10
+ useTranslation: () => ({ t: (k: string, o?: any) => o?.defaultValue ?? k }),
11
+ }))
12
+ vi.mock('../../dynamic-icon', () => ({ DynamicIcon: () => null }))
13
+
14
+ import { DocumentPage } from '../document-page'
15
+ import { ApiProvider, type ApiClient } from '../../api-context'
16
+ import type { DocumentPageRegistration } from '../registry'
17
+
18
+ afterEach(cleanup)
19
+
20
+ const registration: DocumentPageRegistration = {
21
+ spec: {
22
+ model: 'invoices',
23
+ title: '{{number}}',
24
+ statuses: [
25
+ {
26
+ id: 'fiscal',
27
+ label: 'Estado fiscal',
28
+ field: '$.fiscal',
29
+ states: [{ value: 'stamped', label: 'Timbrada', tone: 'success' }],
30
+ },
31
+ {
32
+ id: 'billing',
33
+ label: 'Estado de cobro',
34
+ field: '$.billing',
35
+ states: [{ value: 'partial', label: 'Parcial', tone: 'caution' }],
36
+ },
37
+ ],
38
+ metrics: [{ label: 'Total', path: 'total', format: 'money' }],
39
+ actions: [
40
+ { key: 'pay', label: 'Registrar pago' },
41
+ { key: 'send', label: 'Enviar' },
42
+ { key: 'pdf', label: 'Descargar PDF', openUrl: '{{$.pdf}}' },
43
+ {
44
+ key: 'cancel',
45
+ label: 'Solicitar cancelación',
46
+ destructive: true,
47
+ blockedWhen: { when: { field: 'sources.reps.length', op: 'gt', value: 0 }, reason: 'Cancela primero los REP' },
48
+ },
49
+ ],
50
+ layouts: [{ when: { field: '$.fiscal', op: 'eq', value: 'stamped' }, layout: { primary: 'pay', secondary: ['send', 'pdf'], destructive: ['cancel'] } }],
51
+ smartButtons: [{ key: 'reps', label: 'REP', source: 'reps', tab: 'reps' }],
52
+ sources: [{ key: 'reps', model: 'fiscal_documents', foreignKey: 'source_id' }],
53
+ tabs: [
54
+ { key: 'reps', label: 'Pagos y REP', kind: 'records', source: 'reps', columns: [{ label: 'UUID', path: 'fiscal_uuid' }] },
55
+ ],
56
+ },
57
+ derive: () => ({ fiscal: 'stamped', billing: 'partial', pdf: 'https://x/y.pdf' }),
58
+ }
59
+
60
+ function setup(onAction = vi.fn()) {
61
+ const api = {
62
+ get: vi.fn().mockResolvedValue({ data: { success: true, data: [{ id: 'r1', fiscal_uuid: 'REP-UUID-1' }] } }),
63
+ } as unknown as ApiClient
64
+ render(
65
+ <ApiProvider client={api}>
66
+ <DocumentPage registration={registration} record={{ id: 'i1', number: 'A-42', total: 1500 }} onAction={onAction} currency='MXN' />
67
+ </ApiProvider>,
68
+ )
69
+ return { api, onAction }
70
+ }
71
+
72
+ describe('DocumentPage', () => {
73
+ it('muestra cabecera con dos badges de texto explícito y el total en la moneda de la org', async () => {
74
+ setup()
75
+ expect(screen.getByRole('heading', { name: 'A-42' })).toBeTruthy()
76
+ expect(screen.getByText('Timbrada')).toBeTruthy()
77
+ expect(screen.getByText('Parcial')).toBeTruthy()
78
+ expect(screen.getByText(/1,500\.00/)).toBeTruthy()
79
+ })
80
+
81
+ it('una primaria, secundarias visibles y la destructiva bloqueada con el contador de REP', async () => {
82
+ const { onAction } = setup()
83
+ await waitFor(() => expect(screen.getAllByText('1').length).toBeGreaterThan(0)) // contador del botón inteligente
84
+ fireEvent.click(screen.getByText('Registrar pago'))
85
+ await waitFor(() => expect(onAction).toHaveBeenCalledTimes(1))
86
+ expect(onAction.mock.calls[0][0].def.key).toBe('pay')
87
+ const cancel = screen.getByRole('button', { name: /Solicitar cancelación/ }) as HTMLButtonElement
88
+ expect(cancel.disabled).toBe(true)
89
+ fireEvent.click(cancel)
90
+ expect(onAction).toHaveBeenCalledTimes(1)
91
+ })
92
+
93
+ it('el botón inteligente abre la pestaña con las filas de la fuente', async () => {
94
+ setup()
95
+ await waitFor(() => expect(screen.getAllByText('1').length).toBeGreaterThan(0))
96
+ fireEvent.click(screen.getByRole('button', { name: /REP/ }))
97
+ await waitFor(() => expect(screen.getByText('REP-UUID-1')).toBeTruthy())
98
+ })
99
+
100
+ it('openUrl abre el archivo sin pasar por onAction', async () => {
101
+ const open = vi.spyOn(window, 'open').mockImplementation(() => null)
102
+ const { onAction } = setup()
103
+ fireEvent.click(screen.getByText('Descargar PDF'))
104
+ await waitFor(() => expect(open).toHaveBeenCalledWith('https://x/y.pdf', '_blank', 'noopener,noreferrer'))
105
+ expect(onAction).not.toHaveBeenCalled()
106
+ })
107
+ })
@@ -0,0 +1,140 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { evaluateWhen, interpolate, pruneUnavailableActions, readPath, resolveActions, resolveStatus, resolveTitle } from '../evaluate'
3
+ import type { DocumentContext, DocumentSpec } from '../types'
4
+
5
+ const ctx = (record: Record<string, unknown>, derived: Record<string, unknown> = {}, sources: DocumentContext['sources'] = {}): DocumentContext => ({
6
+ record,
7
+ derived,
8
+ sources,
9
+ })
10
+
11
+ const spec: DocumentSpec = {
12
+ model: 'docs',
13
+ title: '{{series}}{{number}} || {{id}}',
14
+ statuses: [
15
+ {
16
+ id: 'fiscal',
17
+ field: '$.fiscal',
18
+ states: [
19
+ { value: 'draft', label: 'Borrador', tone: 'neutral' },
20
+ { value: 'cancelled', label: 'Cancelada', tone: 'neutral', strike: true },
21
+ ],
22
+ },
23
+ ],
24
+ actions: [
25
+ { key: 'stamp', label: 'Timbrar factura' },
26
+ { key: 'preview', label: 'Vista previa' },
27
+ { key: 'edit', label: 'Editar' },
28
+ { key: 'a', label: 'A' },
29
+ { key: 'b', label: 'B' },
30
+ { key: 'dup', label: 'Duplicar' },
31
+ {
32
+ key: 'cancel',
33
+ label: 'Solicitar cancelación',
34
+ destructive: true,
35
+ blockedWhen: { when: { field: 'sources.reps.length', op: 'gt', value: 0 }, reason: 'Cancela primero los REP' },
36
+ },
37
+ { key: 'secret', label: 'Secreta', hiddenWhen: { field: 'locked', op: 'truthy' } },
38
+ ],
39
+ layouts: [
40
+ { when: { field: '$.fiscal', op: 'eq', value: 'draft' }, layout: { primary: 'stamp', secondary: ['preview', 'edit', 'a', 'b'], more: ['dup', 'secret'] } },
41
+ { when: [{ field: '$.fiscal', op: 'eq', value: 'stamped' }], layout: { primary: 'dup', destructive: ['cancel'] } },
42
+ ],
43
+ tabs: [],
44
+ }
45
+
46
+ describe('readPath', () => {
47
+ it('lee registro, derivados y totales de fuentes', () => {
48
+ const c = ctx({ a: { b: 1 } }, { x: 'y' }, { reps: [{}, {}] })
49
+ expect(readPath(c, 'a.b')).toBe(1)
50
+ expect(readPath(c, 'record.a.b')).toBe(1)
51
+ expect(readPath(c, '$.x')).toBe('y')
52
+ expect(readPath(c, 'sources.reps.length')).toBe(2)
53
+ expect(readPath(c, 'nope.deep')).toBeUndefined()
54
+ })
55
+ })
56
+
57
+ describe('evaluateWhen', () => {
58
+ it('AND de predicados y operadores', () => {
59
+ const c = ctx({ n: 5, s: 'a', e: '' })
60
+ expect(evaluateWhen(undefined, c)).toBe(true)
61
+ expect(evaluateWhen([{ field: 'n', op: 'gt', value: 3 }, { field: 's', op: 'in', value: ['a', 'b'] }], c)).toBe(true)
62
+ expect(evaluateWhen([{ field: 'n', op: 'gt', value: 3 }, { field: 's', op: 'neq', value: 'a' }], c)).toBe(false)
63
+ expect(evaluateWhen({ field: 'e', op: 'falsy' }, c)).toBe(true)
64
+ expect(evaluateWhen({ field: 's', op: 'not_in', value: ['z'] }, c)).toBe(true)
65
+ })
66
+ })
67
+
68
+ describe('resolveStatus', () => {
69
+ it('resuelve del catálogo, cae a neutral con el valor crudo y omite vacío', () => {
70
+ expect(resolveStatus(spec.statuses[0], ctx({}, { fiscal: 'cancelled' }))?.strike).toBe(true)
71
+ expect(resolveStatus(spec.statuses[0], ctx({}, { fiscal: 'raro' }))).toMatchObject({ label: 'raro', tone: 'neutral' })
72
+ expect(resolveStatus(spec.statuses[0], ctx({}, {}))).toBeUndefined()
73
+ })
74
+ })
75
+
76
+ describe('resolveActions', () => {
77
+ it('1 primaria, máx. 3 secundarias y el desborde va a «Más…»', () => {
78
+ const r = resolveActions(spec, ctx({}, { fiscal: 'draft' }))
79
+ expect(r.primary?.def.key).toBe('stamp')
80
+ expect(r.secondary.map((a) => a.def.key)).toEqual(['preview', 'edit', 'a'])
81
+ expect(r.more.map((a) => a.def.key)).toEqual(['b', 'dup', 'secret'])
82
+ })
83
+ it('hiddenWhen omite la acción', () => {
84
+ const r = resolveActions(spec, ctx({ locked: true }, { fiscal: 'draft' }))
85
+ expect(r.more.map((a) => a.def.key)).not.toContain('secret')
86
+ })
87
+ it('blockedWhen deja la destructiva visible pero bloqueada con motivo', () => {
88
+ const blocked = resolveActions(spec, ctx({}, { fiscal: 'stamped' }, { reps: [{ id: 1 }] }))
89
+ expect(blocked.destructive[0].blockedReason).toBe('Cancela primero los REP')
90
+ const free = resolveActions(spec, ctx({}, { fiscal: 'stamped' }, { reps: [] }))
91
+ expect(free.destructive[0].blockedReason).toBeUndefined()
92
+ })
93
+ it('blockedWhen admite varias reglas y muestra el motivo de la primera que aplica', () => {
94
+ const multi: DocumentSpec = {
95
+ ...spec,
96
+ actions: [
97
+ {
98
+ key: 'cancel',
99
+ label: 'Cancelar',
100
+ destructive: true,
101
+ blockedWhen: [
102
+ { when: { field: 'sources.reps.length', op: 'gt', value: 0 }, reason: 'REP' },
103
+ { when: { field: 'sources.notes.length', op: 'gt', value: 0 }, reason: 'NC' },
104
+ ],
105
+ },
106
+ ],
107
+ layouts: [{ when: { field: '$.fiscal', op: 'eq', value: 'stamped' }, layout: { destructive: ['cancel'] } }],
108
+ }
109
+ expect(resolveActions(multi, ctx({}, { fiscal: 'stamped' }, { reps: [], notes: [{}] })).destructive[0].blockedReason).toBe('NC')
110
+ expect(resolveActions(multi, ctx({}, { fiscal: 'stamped' }, { reps: [{}], notes: [{}] })).destructive[0].blockedReason).toBe('REP')
111
+ })
112
+ it('sin regla que coincida no hay acciones', () => {
113
+ const r = resolveActions(spec, ctx({}, { fiscal: 'otro' }))
114
+ expect(r.primary).toBeUndefined()
115
+ expect(r.secondary).toEqual([])
116
+ })
117
+ })
118
+
119
+ describe('interpolate / resolveTitle', () => {
120
+ it('interpola y prueba alternativas', () => {
121
+ expect(interpolate('{{series}}-{{number}}', ctx({ series: 'A', number: 7 }))).toBe('A-7')
122
+ expect(resolveTitle(spec.title, ctx({ series: 'A', number: 7, id: 'x' }))).toBe('A7')
123
+ expect(resolveTitle(spec.title, ctx({ id: 'x' }))).toBe('x')
124
+ })
125
+ })
126
+
127
+ describe('pruneUnavailableActions', () => {
128
+ it('quita acciones sin acción de modelo, conserva href/openUrl/tab', () => {
129
+ const s: DocumentSpec = {
130
+ ...spec,
131
+ actions: [
132
+ { key: 'a', label: 'A' },
133
+ { key: 'b', label: 'B', modelAction: 'real' },
134
+ { key: 'c', label: 'C', tab: 'fiscal' },
135
+ { key: 'd', label: 'D', openUrl: '{{x}}' },
136
+ ],
137
+ }
138
+ expect(pruneUnavailableActions(s, ['real']).actions.map((a) => a.key)).toEqual(['b', 'c', 'd'])
139
+ })
140
+ })
@@ -0,0 +1,140 @@
1
+ // ActionBar — barra de acciones de un documento (benchmark §5.4 / §6.2):
2
+ // una primaria (verbo + sustantivo), 2–3 secundarias visibles, el resto en
3
+ // «Más…» y las destructivas separadas (divisor) y en rojo. Una acción bloqueada se ve
4
+ // deshabilitada con su motivo (no desaparece: el operador entiende por qué).
5
+ import { useState } from 'react'
6
+ import { useTranslation } from 'react-i18next'
7
+ import { Loader2, MoreHorizontal } from 'lucide-react'
8
+ import {
9
+ Button,
10
+ DropdownMenu,
11
+ DropdownMenuContent,
12
+ DropdownMenuItem,
13
+ DropdownMenuTrigger,
14
+ Tooltip,
15
+ TooltipContent,
16
+ TooltipProvider,
17
+ TooltipTrigger,
18
+ } from '@asteby/metacore-ui/primitives'
19
+ import { cn } from '@asteby/metacore-ui/lib'
20
+ import { DynamicIcon } from '../dynamic-icon'
21
+ import type { ResolvedAction, ResolvedActionLayout } from './types'
22
+
23
+ export interface ActionBarProps {
24
+ layout: ResolvedActionLayout
25
+ /**
26
+ * Ejecuta la acción. Si devuelve una promesa, el botón muestra `loading`
27
+ * hasta que se resuelve (acciones asíncronas, §6.2).
28
+ */
29
+ onAction: (action: ResolvedAction) => void | Promise<unknown>
30
+ className?: string
31
+ }
32
+
33
+ export function ActionBar({ layout, onAction, className }: ActionBarProps) {
34
+ const { t } = useTranslation()
35
+ const [busy, setBusy] = useState<string | null>(null)
36
+
37
+ const run = async (a: ResolvedAction) => {
38
+ if (a.blockedReason || busy) return
39
+ setBusy(a.def.key)
40
+ try {
41
+ await onAction(a)
42
+ } finally {
43
+ setBusy(null)
44
+ }
45
+ }
46
+
47
+ const label = (a: ResolvedAction) => t(a.def.label, { defaultValue: a.def.label })
48
+ const { primary, secondary, more, destructive } = layout
49
+ const hasMenu = more.length > 0
50
+
51
+ const button = (a: ResolvedAction, variant: 'default' | 'outline' | 'destructive') => {
52
+ const loading = busy === a.def.key
53
+ const btn = (
54
+ <Button
55
+ key={a.def.key}
56
+ type='button'
57
+ size='sm'
58
+ variant={variant === 'destructive' ? 'outline' : variant}
59
+ disabled={!!a.blockedReason || (!!busy && !loading)}
60
+ aria-disabled={!!a.blockedReason}
61
+ data-action={a.def.key}
62
+ onClick={() => run(a)}
63
+ className={cn(
64
+ variant === 'destructive' &&
65
+ 'border-destructive/40 text-destructive hover:bg-destructive/10 hover:text-destructive',
66
+ )}
67
+ >
68
+ {loading ? (
69
+ <Loader2 className='mr-1.5 h-4 w-4 animate-spin' />
70
+ ) : a.def.icon ? (
71
+ <span className='mr-1.5 inline-flex h-4 w-4 items-center'>
72
+ <DynamicIcon name={a.def.icon} className='h-4 w-4' />
73
+ </span>
74
+ ) : null}
75
+ {label(a)}
76
+ </Button>
77
+ )
78
+ if (!a.blockedReason) return btn
79
+ return (
80
+ <Tooltip key={a.def.key}>
81
+ <TooltipTrigger asChild>
82
+ {/* span: un botón disabled no dispara el tooltip */}
83
+ <span tabIndex={0}>{btn}</span>
84
+ </TooltipTrigger>
85
+ <TooltipContent className='max-w-xs'>{t(a.blockedReason, { defaultValue: a.blockedReason })}</TooltipContent>
86
+ </Tooltip>
87
+ )
88
+ }
89
+
90
+ const item = (a: ResolvedAction) => (
91
+ <DropdownMenuItem
92
+ key={a.def.key}
93
+ disabled={!!a.blockedReason}
94
+ data-action={a.def.key}
95
+ onSelect={() => run(a)}
96
+ >
97
+ {a.def.icon ? (
98
+ <span className='mr-2 inline-flex h-4 w-4 items-center'>
99
+ <DynamicIcon name={a.def.icon} className='h-4 w-4' />
100
+ </span>
101
+ ) : null}
102
+ <span className='flex flex-col'>
103
+ {label(a)}
104
+ {a.blockedReason ? (
105
+ <span className='text-xs font-normal text-muted-foreground'>
106
+ {t(a.blockedReason, { defaultValue: a.blockedReason })}
107
+ </span>
108
+ ) : null}
109
+ </span>
110
+ </DropdownMenuItem>
111
+ )
112
+
113
+ return (
114
+ <TooltipProvider delayDuration={150}>
115
+ <div className={cn('flex flex-wrap items-center gap-2', className)} data-document-actions=''>
116
+ {primary ? button(primary, primary.def.destructive ? 'destructive' : 'default') : null}
117
+ {secondary.map((a) => button(a, 'outline'))}
118
+ {hasMenu ? (
119
+ <DropdownMenu>
120
+ <DropdownMenuTrigger asChild>
121
+ <Button type='button' size='sm' variant='outline' aria-label={t('document.more', { defaultValue: 'Más…' })}>
122
+ <MoreHorizontal className='mr-1.5 h-4 w-4' />
123
+ {t('document.more', { defaultValue: 'Más…' })}
124
+ </Button>
125
+ </DropdownMenuTrigger>
126
+ <DropdownMenuContent align='end' className='min-w-56'>
127
+ {more.map((a) => item(a))}
128
+ </DropdownMenuContent>
129
+ </DropdownMenu>
130
+ ) : null}
131
+ {destructive.length > 0 ? (
132
+ <>
133
+ <span aria-hidden className='mx-1 h-6 w-px bg-border' />
134
+ {destructive.map((a) => button(a, 'destructive'))}
135
+ </>
136
+ ) : null}
137
+ </div>
138
+ </TooltipProvider>
139
+ )
140
+ }