@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.
- package/CHANGELOG.md +29 -0
- package/dist/action-modal-dispatcher.d.ts +29 -0
- package/dist/action-modal-dispatcher.d.ts.map +1 -1
- package/dist/action-modal-dispatcher.js +115 -6
- package/dist/activity-diff.d.ts +2 -0
- package/dist/activity-diff.d.ts.map +1 -1
- package/dist/activity-diff.js +2 -2
- package/dist/approval-events.d.ts +16 -0
- package/dist/approval-events.d.ts.map +1 -0
- package/dist/approval-events.js +27 -0
- package/dist/approval-gate.d.ts +39 -0
- package/dist/approval-gate.d.ts.map +1 -0
- package/dist/approval-gate.js +110 -0
- package/dist/approval-pin-dialog.d.ts +25 -0
- package/dist/approval-pin-dialog.d.ts.map +1 -0
- package/dist/approval-pin-dialog.js +66 -0
- package/dist/business/catalog.d.ts +16 -0
- package/dist/business/catalog.d.ts.map +1 -0
- package/dist/business/catalog.js +50 -0
- package/dist/business/customer-picker.d.ts +39 -0
- package/dist/business/customer-picker.d.ts.map +1 -0
- package/dist/business/customer-picker.js +51 -0
- package/dist/business/feedback.d.ts +72 -0
- package/dist/business/feedback.d.ts.map +1 -0
- package/dist/business/feedback.js +52 -0
- package/dist/business/field-errors.d.ts +26 -0
- package/dist/business/field-errors.d.ts.map +1 -0
- package/dist/business/field-errors.js +59 -0
- package/dist/business/format.d.ts +49 -0
- package/dist/business/format.d.ts.map +1 -0
- package/dist/business/format.js +128 -0
- package/dist/business/index.d.ts +16 -0
- package/dist/business/index.d.ts.map +1 -0
- package/dist/business/index.js +15 -0
- package/dist/business/line-items-editor.d.ts +25 -0
- package/dist/business/line-items-editor.d.ts.map +1 -0
- package/dist/business/line-items-editor.js +36 -0
- package/dist/business/line-items.d.ts +69 -0
- package/dist/business/line-items.d.ts.map +1 -0
- package/dist/business/line-items.js +133 -0
- package/dist/business/payment-capture.d.ts +21 -0
- package/dist/business/payment-capture.d.ts.map +1 -0
- package/dist/business/payment-capture.js +24 -0
- package/dist/business/payment.d.ts +61 -0
- package/dist/business/payment.d.ts.map +1 -0
- package/dist/business/payment.js +74 -0
- package/dist/business/product-picker.d.ts +24 -0
- package/dist/business/product-picker.d.ts.map +1 -0
- package/dist/business/product-picker.js +48 -0
- package/dist/business/product-search.d.ts +70 -0
- package/dist/business/product-search.d.ts.map +1 -0
- package/dist/business/product-search.js +51 -0
- package/dist/business/refund-destination.d.ts +19 -0
- package/dist/business/refund-destination.d.ts.map +1 -0
- package/dist/business/refund-destination.js +31 -0
- package/dist/business/refund.d.ts +29 -0
- package/dist/business/refund.d.ts.map +1 -0
- package/dist/business/refund.js +44 -0
- package/dist/business/return-wizard.d.ts +23 -0
- package/dist/business/return-wizard.d.ts.map +1 -0
- package/dist/business/return-wizard.js +59 -0
- package/dist/business/return.d.ts +90 -0
- package/dist/business/return.d.ts.map +1 -0
- package/dist/business/return.js +112 -0
- package/dist/business/use-async-search.d.ts +10 -0
- package/dist/business/use-async-search.d.ts.map +1 -0
- package/dist/business/use-async-search.js +38 -0
- package/dist/document/action-bar.d.ts +12 -0
- package/dist/document/action-bar.d.ts.map +1 -0
- package/dist/document/action-bar.js +39 -0
- package/dist/document/cancel-with-reason.d.ts +47 -0
- package/dist/document/cancel-with-reason.d.ts.map +1 -0
- package/dist/document/cancel-with-reason.js +52 -0
- package/dist/document/document-header.d.ts +41 -0
- package/dist/document/document-header.d.ts.map +1 -0
- package/dist/document/document-header.js +30 -0
- package/dist/document/document-page.d.ts +37 -0
- package/dist/document/document-page.d.ts.map +1 -0
- package/dist/document/document-page.js +137 -0
- package/dist/document/document-timeline.d.ts +20 -0
- package/dist/document/document-timeline.d.ts.map +1 -0
- package/dist/document/document-timeline.js +26 -0
- package/dist/document/evaluate.d.ts +31 -0
- package/dist/document/evaluate.d.ts.map +1 -0
- package/dist/document/evaluate.js +170 -0
- package/dist/document/format.d.ts +9 -0
- package/dist/document/format.d.ts.map +1 -0
- package/dist/document/format.js +52 -0
- package/dist/document/index.d.ts +13 -0
- package/dist/document/index.d.ts.map +1 -0
- package/dist/document/index.js +12 -0
- package/dist/document/registry.d.ts +20 -0
- package/dist/document/registry.d.ts.map +1 -0
- package/dist/document/registry.js +20 -0
- package/dist/document/smart-buttons.d.ts +16 -0
- package/dist/document/smart-buttons.d.ts.map +1 -0
- package/dist/document/smart-buttons.js +16 -0
- package/dist/document/status-badge.d.ts +16 -0
- package/dist/document/status-badge.d.ts.map +1 -0
- package/dist/document/status-badge.js +23 -0
- package/dist/document/types.d.ts +196 -0
- package/dist/document/types.d.ts.map +1 -0
- package/dist/document/types.js +1 -0
- package/dist/document/use-document-sources.d.ts +14 -0
- package/dist/document/use-document-sources.d.ts.map +1 -0
- package/dist/document/use-document-sources.js +72 -0
- package/dist/dynamic-columns.d.ts.map +1 -1
- package/dist/dynamic-columns.js +3 -1
- package/dist/dynamic-relation.d.ts +4 -0
- package/dist/dynamic-relation.d.ts.map +1 -1
- package/dist/dynamic-relation.js +10 -1
- package/dist/dynamic-row-actions.d.ts.map +1 -1
- package/dist/dynamic-row-actions.js +14 -2
- package/dist/dynamic-table.d.ts.map +1 -1
- package/dist/dynamic-table.js +28 -3
- package/dist/index.d.ts +5 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +6 -0
- package/dist/reason-prompt.d.ts +59 -0
- package/dist/reason-prompt.d.ts.map +1 -0
- package/dist/reason-prompt.js +111 -0
- package/dist/server-error.d.ts.map +1 -1
- package/dist/server-error.js +19 -0
- package/dist/use-addon-settings.d.ts +17 -3
- package/dist/use-addon-settings.d.ts.map +1 -1
- package/dist/use-addon-settings.js +20 -8
- package/package.json +1 -1
- package/src/__tests__/approval-gate.test.tsx +134 -0
- package/src/__tests__/prefill-from-record.test.ts +69 -0
- package/src/__tests__/reason-prompt.test.tsx +121 -0
- package/src/__tests__/use-addon-settings.test.ts +16 -1
- package/src/action-modal-dispatcher.tsx +145 -3
- package/src/activity-diff.tsx +10 -1
- package/src/approval-events.ts +39 -0
- package/src/approval-gate.tsx +180 -0
- package/src/approval-pin-dialog.tsx +162 -0
- package/src/business/business.test.ts +210 -0
- package/src/business/catalog.ts +68 -0
- package/src/business/customer-picker.tsx +201 -0
- package/src/business/feedback.tsx +141 -0
- package/src/business/field-errors.ts +92 -0
- package/src/business/format.ts +151 -0
- package/src/business/index.ts +15 -0
- package/src/business/line-items-editor.tsx +259 -0
- package/src/business/line-items.ts +190 -0
- package/src/business/payment-capture.tsx +158 -0
- package/src/business/payment.ts +125 -0
- package/src/business/product-picker.tsx +171 -0
- package/src/business/product-search.ts +108 -0
- package/src/business/refund-destination.tsx +121 -0
- package/src/business/refund.ts +72 -0
- package/src/business/return-wizard.tsx +223 -0
- package/src/business/return.ts +181 -0
- package/src/business/use-async-search.ts +43 -0
- package/src/document/__tests__/document-page.test.tsx +107 -0
- package/src/document/__tests__/evaluate.test.ts +140 -0
- package/src/document/action-bar.tsx +140 -0
- package/src/document/cancel-with-reason.tsx +214 -0
- package/src/document/document-header.tsx +140 -0
- package/src/document/document-page.tsx +268 -0
- package/src/document/document-timeline.tsx +66 -0
- package/src/document/evaluate.ts +180 -0
- package/src/document/format.ts +59 -0
- package/src/document/index.ts +28 -0
- package/src/document/registry.ts +39 -0
- package/src/document/smart-buttons.tsx +54 -0
- package/src/document/status-badge.tsx +44 -0
- package/src/document/types.ts +203 -0
- package/src/document/use-document-sources.ts +91 -0
- package/src/dynamic-columns.tsx +5 -1
- package/src/dynamic-relation.tsx +14 -1
- package/src/dynamic-row-actions.tsx +13 -1
- package/src/dynamic-table.tsx +22 -2
- package/src/index.ts +33 -0
- package/src/reason-prompt.tsx +247 -0
- package/src/server-error.ts +20 -0
- 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
|
+
}
|