@asteby/metacore-runtime-react 45.0.0 → 45.2.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 +17 -0
- 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 +42 -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 +14 -0
- package/dist/business/index.d.ts.map +1 -0
- package/dist/business/index.js +13 -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 +43 -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/index.d.ts +4 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +5 -0
- package/dist/server-error.d.ts.map +1 -1
- package/dist/server-error.js +19 -0
- package/package.json +1 -1
- package/src/__tests__/approval-gate.test.tsx +134 -0
- 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 +164 -0
- package/src/business/catalog.ts +60 -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 +13 -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 +71 -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/index.ts +23 -0
- package/src/server-error.ts +20 -0
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
// Búsqueda unificada de producto (productos, llantas y variantes).
|
|
2
|
+
// Un solo cuadro acepta: medida de llanta «205/55R16», código de barras (EAN/UPC
|
|
3
|
+
// numérico), SKU/clave de proveedor o texto libre. `parseProductQuery` clasifica
|
|
4
|
+
// la entrada para que el backend/addon elija el índice correcto.
|
|
5
|
+
import type { LineItem } from './line-items'
|
|
6
|
+
import { makeLine } from './line-items'
|
|
7
|
+
|
|
8
|
+
export interface TireSize {
|
|
9
|
+
width: number
|
|
10
|
+
ratio: number
|
|
11
|
+
rim: number
|
|
12
|
+
/** Forma canónica `205/55R16`. */
|
|
13
|
+
normalized: string
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export type ProductQuery =
|
|
17
|
+
| { kind: 'empty'; raw: string }
|
|
18
|
+
| { kind: 'barcode'; raw: string; barcode: string }
|
|
19
|
+
| { kind: 'tire_size'; raw: string; tire: TireSize }
|
|
20
|
+
| { kind: 'text'; raw: string; text: string }
|
|
21
|
+
|
|
22
|
+
// 205/55R16, 205 55 16, 205-55-16, 205/55ZR16, 2055516 (sin separadores).
|
|
23
|
+
const TIRE_RE = /^(\d{3})\s*[\/\-x\s]?\s*(\d{2})\s*[\/\-x\s]?\s*(?:z?r|-)?\s*(\d{2})$/i
|
|
24
|
+
const BARCODE_RE = /^\d{8,14}$/
|
|
25
|
+
|
|
26
|
+
export function parseTireSize(input: string): TireSize | null {
|
|
27
|
+
const s = input.trim()
|
|
28
|
+
// 7 dígitos pegados también son medida, pero 8+ dígitos es código de barras.
|
|
29
|
+
if (BARCODE_RE.test(s)) return null
|
|
30
|
+
const m = TIRE_RE.exec(s)
|
|
31
|
+
if (!m) return null
|
|
32
|
+
const width = Number(m[1])
|
|
33
|
+
const ratio = Number(m[2])
|
|
34
|
+
const rim = Number(m[3])
|
|
35
|
+
if (width < 125 || width > 395 || ratio < 25 || ratio > 95 || rim < 10 || rim > 26) return null
|
|
36
|
+
return { width, ratio, rim, normalized: `${width}/${ratio}R${rim}` }
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function parseProductQuery(raw: string): ProductQuery {
|
|
40
|
+
const s = raw.trim()
|
|
41
|
+
if (!s) return { kind: 'empty', raw }
|
|
42
|
+
if (BARCODE_RE.test(s)) return { kind: 'barcode', raw, barcode: s }
|
|
43
|
+
const tire = parseTireSize(s)
|
|
44
|
+
if (tire) return { kind: 'tire_size', raw, tire }
|
|
45
|
+
return { kind: 'text', raw, text: s }
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface StockByWarehouse {
|
|
49
|
+
warehouse_id: string
|
|
50
|
+
warehouse_name?: string
|
|
51
|
+
available: number
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export interface ProductVariant {
|
|
55
|
+
id: string
|
|
56
|
+
/** «Rin 16 · 205/55R16 · 91V». */
|
|
57
|
+
label: string
|
|
58
|
+
sku?: string
|
|
59
|
+
barcode?: string
|
|
60
|
+
price?: number
|
|
61
|
+
stock?: StockByWarehouse[]
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export interface ProductResult {
|
|
65
|
+
id: string
|
|
66
|
+
name: string
|
|
67
|
+
sku?: string
|
|
68
|
+
barcode?: string
|
|
69
|
+
/** Clave del proveedor (búsqueda por SKU del proveedor). */
|
|
70
|
+
supplier_sku?: string
|
|
71
|
+
kind?: 'product' | 'tire' | 'service' | 'bundle'
|
|
72
|
+
price?: number
|
|
73
|
+
tax_rate?: number
|
|
74
|
+
tire?: TireSize
|
|
75
|
+
variants?: ProductVariant[]
|
|
76
|
+
stock?: StockByWarehouse[]
|
|
77
|
+
/** Paquetes automáticos: ids/nombres que se sugieren junto al producto. */
|
|
78
|
+
bundle_items?: Array<{ product_id: string; name: string; quantity: number }>
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Existencia disponible: de la variante o del producto, en un almacén o en total. */
|
|
82
|
+
export function availableStock(
|
|
83
|
+
source: { stock?: StockByWarehouse[] },
|
|
84
|
+
warehouseId?: string,
|
|
85
|
+
): number | undefined {
|
|
86
|
+
if (!source.stock) return undefined
|
|
87
|
+
return source.stock
|
|
88
|
+
.filter((s) => !warehouseId || s.warehouse_id === warehouseId)
|
|
89
|
+
.reduce((n, s) => n + s.available, 0)
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Producto (+ variante) → renglón listo para <LineItemsEditor>. */
|
|
93
|
+
export function productToLine(
|
|
94
|
+
p: ProductResult,
|
|
95
|
+
variant?: ProductVariant,
|
|
96
|
+
opts: { warehouseId?: string; quantity?: number } = {},
|
|
97
|
+
): LineItem {
|
|
98
|
+
const src = variant ?? p
|
|
99
|
+
return makeLine({
|
|
100
|
+
product_id: variant?.id ?? p.id,
|
|
101
|
+
sku: variant?.sku ?? p.sku,
|
|
102
|
+
description: variant ? `${p.name} · ${variant.label}` : p.name,
|
|
103
|
+
quantity: opts.quantity ?? 1,
|
|
104
|
+
unit_price: variant?.price ?? p.price ?? 0,
|
|
105
|
+
tax_rate: p.tax_rate ?? 0,
|
|
106
|
+
available: availableStock(src, opts.warehouseId) ?? availableStock(p, opts.warehouseId),
|
|
107
|
+
})
|
|
108
|
+
}
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
// RefundDestination — a dónde regresa el dinero: efectivo, tarjeta, monedero,
|
|
2
|
+
// saldo a favor o NC CFDI (benchmark §7, POS-3/DEV-*).
|
|
3
|
+
import { useMemo } from 'react'
|
|
4
|
+
import { useTranslation } from 'react-i18next'
|
|
5
|
+
import { Button, Input } from '@asteby/metacore-ui'
|
|
6
|
+
import { useFormatter, roundMoney } from './format'
|
|
7
|
+
import {
|
|
8
|
+
DEFAULT_REFUND_DESTINATIONS,
|
|
9
|
+
singleDestination,
|
|
10
|
+
validateRefund,
|
|
11
|
+
type RefundAllocation,
|
|
12
|
+
type RefundDestinationKind,
|
|
13
|
+
type RefundDestinationOption,
|
|
14
|
+
type RefundValidation,
|
|
15
|
+
} from './refund'
|
|
16
|
+
|
|
17
|
+
export interface RefundDestinationProps {
|
|
18
|
+
/** Monto total a reembolsar. */
|
|
19
|
+
total: number
|
|
20
|
+
value: RefundAllocation[]
|
|
21
|
+
onChange: (allocations: RefundAllocation[]) => void
|
|
22
|
+
/** Destinos ofrecidos (default: los cinco). Filtra/limita según el documento. */
|
|
23
|
+
options?: RefundDestinationOption[]
|
|
24
|
+
/** Emite la validación tras cada cambio (para bloquear «Confirmar»). */
|
|
25
|
+
onValidate?: (v: RefundValidation) => void
|
|
26
|
+
/** Errores del servidor `"<índice>.<campo>"`. */
|
|
27
|
+
serverErrors?: Record<string, string>
|
|
28
|
+
/** Permite repartir entre varios destinos. Default false (un solo destino). */
|
|
29
|
+
allowSplit?: boolean
|
|
30
|
+
currency?: string
|
|
31
|
+
disabled?: boolean
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function RefundDestination({
|
|
35
|
+
total,
|
|
36
|
+
value,
|
|
37
|
+
onChange,
|
|
38
|
+
options = DEFAULT_REFUND_DESTINATIONS,
|
|
39
|
+
onValidate,
|
|
40
|
+
serverErrors,
|
|
41
|
+
allowSplit = false,
|
|
42
|
+
currency,
|
|
43
|
+
disabled,
|
|
44
|
+
}: RefundDestinationProps) {
|
|
45
|
+
const { t } = useTranslation()
|
|
46
|
+
const fmt = useFormatter({ currency })
|
|
47
|
+
const validation = useMemo(() => validateRefund(total, value, options), [total, value, options])
|
|
48
|
+
const errors = { ...validation.errors, ...serverErrors }
|
|
49
|
+
const chosen = (k: RefundDestinationKind) => value.some((a) => a.kind === k)
|
|
50
|
+
|
|
51
|
+
const emit = (next: RefundAllocation[]) => {
|
|
52
|
+
onChange(next)
|
|
53
|
+
onValidate?.(validateRefund(total, next, options))
|
|
54
|
+
}
|
|
55
|
+
const toggle = (o: RefundDestinationOption) => {
|
|
56
|
+
if (!allowSplit) return emit(singleDestination(o.kind, total))
|
|
57
|
+
if (chosen(o.kind)) return emit(value.filter((a) => a.kind !== o.kind))
|
|
58
|
+
emit([...value, { kind: o.kind, amount: Math.max(0, roundMoney(total - validation.allocated)) }])
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
return (
|
|
62
|
+
<div data-slot="refund-destination" className="space-y-2">
|
|
63
|
+
<p className="text-sm text-muted-foreground">
|
|
64
|
+
{t('refund.amount', { defaultValue: 'Monto a reembolsar' })}: <span className="font-semibold text-foreground">{fmt.money(total)}</span>
|
|
65
|
+
</p>
|
|
66
|
+
<div role={allowSplit ? 'group' : 'radiogroup'} className="grid gap-2 sm:grid-cols-2">
|
|
67
|
+
{options.map((o) => {
|
|
68
|
+
const off = o.available === false
|
|
69
|
+
const on = chosen(o.kind)
|
|
70
|
+
return (
|
|
71
|
+
<Button
|
|
72
|
+
key={o.kind}
|
|
73
|
+
type="button"
|
|
74
|
+
variant={on ? 'default' : 'outline'}
|
|
75
|
+
role={allowSplit ? 'checkbox' : 'radio'}
|
|
76
|
+
aria-checked={on}
|
|
77
|
+
disabled={disabled || off}
|
|
78
|
+
title={off ? o.disabledReason : undefined}
|
|
79
|
+
className="h-auto justify-start whitespace-normal py-2 text-left"
|
|
80
|
+
onClick={() => toggle(o)}
|
|
81
|
+
>
|
|
82
|
+
<span>
|
|
83
|
+
<span className="block">{o.label}</span>
|
|
84
|
+
{off && o.disabledReason && <span className="block text-xs opacity-70">{o.disabledReason}</span>}
|
|
85
|
+
{!off && o.maxAmount != null && <span className="block text-xs opacity-70">{t('refund.max', { defaultValue: 'Máx.' })} {fmt.money(o.maxAmount)}</span>}
|
|
86
|
+
</span>
|
|
87
|
+
</Button>
|
|
88
|
+
)
|
|
89
|
+
})}
|
|
90
|
+
</div>
|
|
91
|
+
|
|
92
|
+
{value.map((a, i) => (
|
|
93
|
+
<div key={a.kind} className="grid grid-cols-[1fr_8rem_10rem] items-start gap-2">
|
|
94
|
+
<span className="pt-2 text-sm">{options.find((o) => o.kind === a.kind)?.label ?? a.kind}</span>
|
|
95
|
+
<div>
|
|
96
|
+
<Input
|
|
97
|
+
inputMode="decimal"
|
|
98
|
+
className="text-right"
|
|
99
|
+
value={String(a.amount)}
|
|
100
|
+
disabled={disabled || !allowSplit}
|
|
101
|
+
aria-invalid={!!errors[`${i}.amount`]}
|
|
102
|
+
onChange={(e) => emit(value.map((x, j) => (j === i ? { ...x, amount: Number(e.target.value.replace(',', '.')) || 0 } : x)))}
|
|
103
|
+
/>
|
|
104
|
+
{errors[`${i}.amount`] && <p className="mt-0.5 text-xs text-destructive">{errors[`${i}.amount`]}</p>}
|
|
105
|
+
</div>
|
|
106
|
+
{(a.kind === 'card' || a.kind === 'credit_note') && (
|
|
107
|
+
<Input
|
|
108
|
+
placeholder={a.kind === 'card' ? t('refund.cardRef', { defaultValue: 'Referencia' }) : t('refund.ncRef', { defaultValue: 'Folio / UUID relacionado' })}
|
|
109
|
+
value={a.reference ?? ''}
|
|
110
|
+
disabled={disabled}
|
|
111
|
+
onChange={(e) => emit(value.map((x, j) => (j === i ? { ...x, reference: e.target.value } : x)))}
|
|
112
|
+
/>
|
|
113
|
+
)}
|
|
114
|
+
{errors[`${i}.kind`] && <p className="col-span-3 text-xs text-destructive">{errors[`${i}.kind`]}</p>}
|
|
115
|
+
</div>
|
|
116
|
+
))}
|
|
117
|
+
|
|
118
|
+
{validation.form && value.length > 0 && <p role="alert" className="text-sm text-destructive">{validation.form}</p>}
|
|
119
|
+
</div>
|
|
120
|
+
)
|
|
121
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
// Destino de un reembolso (ReturnWizard, cancelación de apartado, anticipos).
|
|
2
|
+
// Lógica pura de <RefundDestination>.
|
|
3
|
+
import { roundMoney, toAmount } from './format'
|
|
4
|
+
|
|
5
|
+
export type RefundDestinationKind = 'cash' | 'card' | 'wallet' | 'store_credit' | 'credit_note'
|
|
6
|
+
|
|
7
|
+
export interface RefundDestinationOption {
|
|
8
|
+
kind: RefundDestinationKind
|
|
9
|
+
label: string
|
|
10
|
+
/** Tope reembolsable por este destino (p. ej. lo pagado con tarjeta). */
|
|
11
|
+
maxAmount?: number
|
|
12
|
+
/** false → la opción se muestra deshabilitada con `disabledReason`. */
|
|
13
|
+
available?: boolean
|
|
14
|
+
disabledReason?: string
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface RefundAllocation {
|
|
18
|
+
kind: RefundDestinationKind
|
|
19
|
+
amount: number
|
|
20
|
+
/** Referencia del reembolso a tarjeta / folio de NC relacionada. */
|
|
21
|
+
reference?: string
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Catálogo por defecto: el llamador filtra/limita según el documento. */
|
|
25
|
+
export const DEFAULT_REFUND_DESTINATIONS: RefundDestinationOption[] = [
|
|
26
|
+
{ kind: 'cash', label: 'Efectivo' },
|
|
27
|
+
{ kind: 'card', label: 'Tarjeta' },
|
|
28
|
+
{ kind: 'wallet', label: 'Monedero' },
|
|
29
|
+
{ kind: 'store_credit', label: 'Saldo a favor' },
|
|
30
|
+
{ kind: 'credit_note', label: 'Nota de crédito (CFDI)' },
|
|
31
|
+
]
|
|
32
|
+
|
|
33
|
+
export interface RefundValidation {
|
|
34
|
+
errors: Record<string, string>
|
|
35
|
+
form?: string
|
|
36
|
+
allocated: number
|
|
37
|
+
remaining: number
|
|
38
|
+
valid: boolean
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function validateRefund(
|
|
42
|
+
total: number,
|
|
43
|
+
allocations: RefundAllocation[],
|
|
44
|
+
options: RefundDestinationOption[] = DEFAULT_REFUND_DESTINATIONS,
|
|
45
|
+
): RefundValidation {
|
|
46
|
+
const byKind = new Map(options.map((o) => [o.kind, o]))
|
|
47
|
+
const errors: Record<string, string> = {}
|
|
48
|
+
let allocated = 0
|
|
49
|
+
const seen = new Set<string>()
|
|
50
|
+
allocations.forEach((a, i) => {
|
|
51
|
+
const o = byKind.get(a.kind)
|
|
52
|
+
const amt = toAmount(a.amount)
|
|
53
|
+
allocated += amt
|
|
54
|
+
if (!o || o.available === false) errors[`${i}.kind`] = o?.disabledReason ?? 'Destino no disponible'
|
|
55
|
+
if (seen.has(a.kind)) errors[`${i}.kind`] = 'Ese destino ya está en la lista'
|
|
56
|
+
seen.add(a.kind)
|
|
57
|
+
if (amt <= 0) errors[`${i}.amount`] = 'Captura un monto mayor a cero'
|
|
58
|
+
else if (o?.maxAmount != null && amt > o.maxAmount) errors[`${i}.amount`] = `El máximo para ${o.label} es ${o.maxAmount}`
|
|
59
|
+
})
|
|
60
|
+
allocated = roundMoney(allocated)
|
|
61
|
+
const remaining = roundMoney(total - allocated)
|
|
62
|
+
let form: string | undefined
|
|
63
|
+
if (allocations.length === 0) form = 'Elige a dónde se devuelve el dinero'
|
|
64
|
+
else if (remaining !== 0) form = remaining > 0 ? 'Falta asignar parte del reembolso' : 'La suma excede el monto a reembolsar'
|
|
65
|
+
return { errors, form, allocated, remaining, valid: !form && Object.keys(errors).length === 0 }
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Un solo destino cubriendo todo el monto (caso común). */
|
|
69
|
+
export function singleDestination(kind: RefundDestinationKind, total: number): RefundAllocation[] {
|
|
70
|
+
return [{ kind, amount: roundMoney(total) }]
|
|
71
|
+
}
|
|
@@ -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
|
+
})
|