@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,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 === 'transfer' || a.kind === 'credit_note') && (
|
|
107
|
+
<Input
|
|
108
|
+
placeholder={a.kind === 'card' || a.kind === 'transfer' ? 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,72 @@
|
|
|
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' | 'transfer' | '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: 'transfer', label: 'Transferencia' },
|
|
29
|
+
{ kind: 'wallet', label: 'Monedero' },
|
|
30
|
+
{ kind: 'store_credit', label: 'Saldo a favor' },
|
|
31
|
+
{ kind: 'credit_note', label: 'Nota de crédito (CFDI)' },
|
|
32
|
+
]
|
|
33
|
+
|
|
34
|
+
export interface RefundValidation {
|
|
35
|
+
errors: Record<string, string>
|
|
36
|
+
form?: string
|
|
37
|
+
allocated: number
|
|
38
|
+
remaining: number
|
|
39
|
+
valid: boolean
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function validateRefund(
|
|
43
|
+
total: number,
|
|
44
|
+
allocations: RefundAllocation[],
|
|
45
|
+
options: RefundDestinationOption[] = DEFAULT_REFUND_DESTINATIONS,
|
|
46
|
+
): RefundValidation {
|
|
47
|
+
const byKind = new Map(options.map((o) => [o.kind, o]))
|
|
48
|
+
const errors: Record<string, string> = {}
|
|
49
|
+
let allocated = 0
|
|
50
|
+
const seen = new Set<string>()
|
|
51
|
+
allocations.forEach((a, i) => {
|
|
52
|
+
const o = byKind.get(a.kind)
|
|
53
|
+
const amt = toAmount(a.amount)
|
|
54
|
+
allocated += amt
|
|
55
|
+
if (!o || o.available === false) errors[`${i}.kind`] = o?.disabledReason ?? 'Destino no disponible'
|
|
56
|
+
if (seen.has(a.kind)) errors[`${i}.kind`] = 'Ese destino ya está en la lista'
|
|
57
|
+
seen.add(a.kind)
|
|
58
|
+
if (amt <= 0) errors[`${i}.amount`] = 'Captura un monto mayor a cero'
|
|
59
|
+
else if (o?.maxAmount != null && amt > o.maxAmount) errors[`${i}.amount`] = `El máximo para ${o.label} es ${o.maxAmount}`
|
|
60
|
+
})
|
|
61
|
+
allocated = roundMoney(allocated)
|
|
62
|
+
const remaining = roundMoney(total - allocated)
|
|
63
|
+
let form: string | undefined
|
|
64
|
+
if (allocations.length === 0) form = 'Elige a dónde se devuelve el dinero'
|
|
65
|
+
else if (remaining !== 0) form = remaining > 0 ? 'Falta asignar parte del reembolso' : 'La suma excede el monto a reembolsar'
|
|
66
|
+
return { errors, form, allocated, remaining, valid: !form && Object.keys(errors).length === 0 }
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Un solo destino cubriendo todo el monto (caso común). */
|
|
70
|
+
export function singleDestination(kind: RefundDestinationKind, total: number): RefundAllocation[] {
|
|
71
|
+
return [{ kind, amount: roundMoney(total) }]
|
|
72
|
+
}
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
// ReturnWizard («Devolver mercancía») — un solo componente para POS, Pedido,
|
|
2
|
+
// Factura, RMA y OT (benchmark §7, DEV-2). No conoce endpoints: recibe los
|
|
3
|
+
// renglones devolvibles y entrega un payload por `onSubmit`.
|
|
4
|
+
import { useMemo, useState } from 'react'
|
|
5
|
+
import { useTranslation } from 'react-i18next'
|
|
6
|
+
import { Button, Input } from '@asteby/metacore-ui'
|
|
7
|
+
import { useFormatter } from './format'
|
|
8
|
+
import { RefundDestination } from './refund-destination'
|
|
9
|
+
import {
|
|
10
|
+
DEFAULT_REFUND_DESTINATIONS,
|
|
11
|
+
type RefundAllocation,
|
|
12
|
+
type RefundDestinationOption,
|
|
13
|
+
} from './refund'
|
|
14
|
+
import {
|
|
15
|
+
RETURN_CONDITION_DESTINATIONS,
|
|
16
|
+
computeReturnTotals,
|
|
17
|
+
creditNoteRelation,
|
|
18
|
+
defaultStockDestination,
|
|
19
|
+
returnSteps,
|
|
20
|
+
returnableQty,
|
|
21
|
+
serializeReturn,
|
|
22
|
+
validateReturnChoices,
|
|
23
|
+
validateReturnRefund,
|
|
24
|
+
type ReturnChoices,
|
|
25
|
+
type ReturnCondition,
|
|
26
|
+
type ReturnStep,
|
|
27
|
+
type ReturnStockDestination,
|
|
28
|
+
type ReturnSubmitPayload,
|
|
29
|
+
type ReturnableLine,
|
|
30
|
+
} from './return'
|
|
31
|
+
|
|
32
|
+
export interface ReturnWizardProps {
|
|
33
|
+
lines: ReturnableLine[]
|
|
34
|
+
/** ¿Hay un addon de almacén que confirma la recepción? Sin él, «Recibir» es un paso. */
|
|
35
|
+
warehouseConnected?: boolean
|
|
36
|
+
/** La venta está facturada: habilita la NC CFDI con relación 01/03 automática. */
|
|
37
|
+
invoiced?: boolean
|
|
38
|
+
/** Destinos de reembolso; `credit_note` se deshabilita solo si no está facturada. */
|
|
39
|
+
refundOptions?: RefundDestinationOption[]
|
|
40
|
+
reasons?: { value: string; label: string }[]
|
|
41
|
+
currency?: string
|
|
42
|
+
submitting?: boolean
|
|
43
|
+
/** Errores del servidor (`"<key>.<campo>"`). */
|
|
44
|
+
serverErrors?: Record<string, string>
|
|
45
|
+
onSubmit: (payload: ReturnSubmitPayload) => void | Promise<void>
|
|
46
|
+
onCancel?: () => void
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const DEFAULT_REASONS = [
|
|
50
|
+
{ value: 'not_wanted', label: 'No lo quiso' },
|
|
51
|
+
{ value: 'defective', label: 'Defectuoso' },
|
|
52
|
+
{ value: 'wrong_item', label: 'Producto equivocado' },
|
|
53
|
+
{ value: 'warranty', label: 'Garantía' },
|
|
54
|
+
]
|
|
55
|
+
|
|
56
|
+
const STOCK_LABEL: Record<ReturnStockDestination, string> = { stock: 'Regresa a stock', scrap: 'Merma', warranty: 'Garantía con proveedor' }
|
|
57
|
+
const STEP_LABEL: Record<ReturnStep, string> = { lines: 'Renglones', condition: 'Condición', receive: 'Recibir', refund: 'Reembolso' }
|
|
58
|
+
|
|
59
|
+
export function ReturnWizard({
|
|
60
|
+
lines,
|
|
61
|
+
warehouseConnected = false,
|
|
62
|
+
invoiced = false,
|
|
63
|
+
refundOptions = DEFAULT_REFUND_DESTINATIONS,
|
|
64
|
+
reasons = DEFAULT_REASONS,
|
|
65
|
+
currency,
|
|
66
|
+
submitting,
|
|
67
|
+
serverErrors,
|
|
68
|
+
onSubmit,
|
|
69
|
+
onCancel,
|
|
70
|
+
}: ReturnWizardProps) {
|
|
71
|
+
const { t } = useTranslation()
|
|
72
|
+
const fmt = useFormatter({ currency })
|
|
73
|
+
const steps = useMemo(() => returnSteps({ warehouseConnected }), [warehouseConnected])
|
|
74
|
+
const [stepIdx, setStepIdx] = useState(0)
|
|
75
|
+
const [choices, setChoices] = useState<ReturnChoices>({})
|
|
76
|
+
const [reason, setReason] = useState('')
|
|
77
|
+
const [notes, setNotes] = useState('')
|
|
78
|
+
const [received, setReceived] = useState(false)
|
|
79
|
+
const [refund, setRefund] = useState<RefundAllocation[]>([])
|
|
80
|
+
|
|
81
|
+
const step = steps[stepIdx]
|
|
82
|
+
const totals = useMemo(() => computeReturnTotals(lines, choices), [lines, choices])
|
|
83
|
+
const validation = useMemo(() => validateReturnChoices(lines, choices, reason), [lines, choices, reason])
|
|
84
|
+
const relation = useMemo(() => creditNoteRelation(lines, choices), [lines, choices])
|
|
85
|
+
const options = useMemo(
|
|
86
|
+
() =>
|
|
87
|
+
refundOptions.map((o) =>
|
|
88
|
+
o.kind === 'credit_note' && !invoiced
|
|
89
|
+
? { ...o, available: false, disabledReason: 'La venta no está facturada' }
|
|
90
|
+
: o.kind === 'credit_note'
|
|
91
|
+
? { ...o, label: `${o.label} · relación ${relation}` }
|
|
92
|
+
: o,
|
|
93
|
+
),
|
|
94
|
+
[refundOptions, invoiced, relation],
|
|
95
|
+
)
|
|
96
|
+
const refundValidation = useMemo(() => validateReturnRefund(totals.total, refund, options), [totals.total, refund, options])
|
|
97
|
+
const errors = { ...validation.errors, ...serverErrors }
|
|
98
|
+
|
|
99
|
+
const setChoice = (key: string, patch: Partial<ReturnChoices[string]>) =>
|
|
100
|
+
setChoices((prev) => {
|
|
101
|
+
const cur = prev[key] ?? { quantity: 0, condition: 'sellable' as ReturnCondition, destination: 'stock' as ReturnStockDestination }
|
|
102
|
+
const next = { ...cur, ...patch }
|
|
103
|
+
if (patch.condition && !patch.destination) next.destination = defaultStockDestination(patch.condition)
|
|
104
|
+
return { ...prev, [key]: next }
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
const canNext =
|
|
108
|
+
step === 'lines' ? validation.valid
|
|
109
|
+
: step === 'condition' ? validation.valid
|
|
110
|
+
: step === 'receive' ? received
|
|
111
|
+
: refundValidation.valid
|
|
112
|
+
|
|
113
|
+
const submit = () =>
|
|
114
|
+
onSubmit(serializeReturn({ lines, choices, reason, notes, received: warehouseConnected ? false : received, refund }))
|
|
115
|
+
|
|
116
|
+
return (
|
|
117
|
+
<div data-slot="return-wizard" className="space-y-4">
|
|
118
|
+
<ol className="flex flex-wrap gap-2 text-xs">
|
|
119
|
+
{steps.map((s, i) => (
|
|
120
|
+
<li key={s} aria-current={i === stepIdx ? 'step' : undefined} className={i === stepIdx ? 'font-semibold text-foreground' : 'text-muted-foreground'}>
|
|
121
|
+
{i + 1}. {t(`returns.step.${s}`, { defaultValue: STEP_LABEL[s] })}
|
|
122
|
+
</li>
|
|
123
|
+
))}
|
|
124
|
+
{warehouseConnected && (
|
|
125
|
+
<li className="text-muted-foreground">{t('returns.step.warehouse', { defaultValue: 'Almacén confirma la recepción' })}</li>
|
|
126
|
+
)}
|
|
127
|
+
</ol>
|
|
128
|
+
|
|
129
|
+
{(step === 'lines' || step === 'condition') && (
|
|
130
|
+
<div className="space-y-2">
|
|
131
|
+
{lines.map((l) => {
|
|
132
|
+
const c = choices[l.key]
|
|
133
|
+
const max = returnableQty(l)
|
|
134
|
+
return (
|
|
135
|
+
<div key={l.key} className="grid gap-2 rounded-md border p-2 sm:grid-cols-[1fr_6rem_auto]">
|
|
136
|
+
<div>
|
|
137
|
+
<p className="text-sm font-medium">{l.description}</p>
|
|
138
|
+
<p className="text-xs text-muted-foreground">
|
|
139
|
+
{t('returns.sold', { defaultValue: 'Vendido' })}: {l.sold} · {t('returns.returnable', { defaultValue: 'Por devolver' })}: {max} · {fmt.money(l.unit_price)}
|
|
140
|
+
</p>
|
|
141
|
+
</div>
|
|
142
|
+
{step === 'lines' ? (
|
|
143
|
+
<div>
|
|
144
|
+
<Input
|
|
145
|
+
inputMode="decimal"
|
|
146
|
+
className="text-right"
|
|
147
|
+
aria-label={l.description}
|
|
148
|
+
disabled={max === 0}
|
|
149
|
+
value={c ? String(c.quantity) : ''}
|
|
150
|
+
placeholder="0"
|
|
151
|
+
onChange={(e) => setChoice(l.key, { quantity: Number(e.target.value.replace(',', '.')) || 0 })}
|
|
152
|
+
/>
|
|
153
|
+
{errors[`${l.key}.quantity`] && <p className="mt-0.5 text-xs text-destructive">{errors[`${l.key}.quantity`]}</p>}
|
|
154
|
+
</div>
|
|
155
|
+
) : (
|
|
156
|
+
(c?.quantity ?? 0) > 0 && (
|
|
157
|
+
<div className="sm:col-span-2 flex flex-wrap items-center gap-2">
|
|
158
|
+
{(['sellable', 'defective'] as ReturnCondition[]).map((cond) => (
|
|
159
|
+
<Button key={cond} type="button" size="sm" variant={c!.condition === cond ? 'default' : 'outline'} onClick={() => setChoice(l.key, { condition: cond })}>
|
|
160
|
+
{cond === 'sellable' ? t('returns.sellable', { defaultValue: 'Vendible' }) : t('returns.defective', { defaultValue: 'Defectuoso' })}
|
|
161
|
+
</Button>
|
|
162
|
+
))}
|
|
163
|
+
{RETURN_CONDITION_DESTINATIONS[c!.condition].length > 1 &&
|
|
164
|
+
RETURN_CONDITION_DESTINATIONS[c!.condition].map((d) => (
|
|
165
|
+
<Button key={d} type="button" size="sm" variant={c!.destination === d ? 'secondary' : 'ghost'} onClick={() => setChoice(l.key, { destination: d })}>
|
|
166
|
+
{STOCK_LABEL[d]}
|
|
167
|
+
</Button>
|
|
168
|
+
))}
|
|
169
|
+
{RETURN_CONDITION_DESTINATIONS[c!.condition].length === 1 && (
|
|
170
|
+
<span className="text-xs text-muted-foreground">{STOCK_LABEL[c!.destination]}</span>
|
|
171
|
+
)}
|
|
172
|
+
{errors[`${l.key}.destination`] && <p className="w-full text-xs text-destructive">{errors[`${l.key}.destination`]}</p>}
|
|
173
|
+
</div>
|
|
174
|
+
)
|
|
175
|
+
)}
|
|
176
|
+
</div>
|
|
177
|
+
)
|
|
178
|
+
})}
|
|
179
|
+
{step === 'lines' && (
|
|
180
|
+
<div className="grid gap-2 sm:grid-cols-2">
|
|
181
|
+
<select className="h-9 rounded-md border bg-background px-2 text-sm" value={reason} onChange={(e) => setReason(e.target.value)} aria-label={t('returns.reason', { defaultValue: 'Motivo' })}>
|
|
182
|
+
<option value="">{t('returns.reason', { defaultValue: 'Motivo' })}…</option>
|
|
183
|
+
{reasons.map((r) => (
|
|
184
|
+
<option key={r.value} value={r.value}>{r.label}</option>
|
|
185
|
+
))}
|
|
186
|
+
</select>
|
|
187
|
+
<Input value={notes} onChange={(e) => setNotes(e.target.value)} placeholder={t('returns.notes', { defaultValue: 'Notas (opcional)' })} />
|
|
188
|
+
</div>
|
|
189
|
+
)}
|
|
190
|
+
{validation.form && stepIdx === 0 && Object.keys(choices).length > 0 && <p role="alert" className="text-sm text-destructive">{validation.form}</p>}
|
|
191
|
+
</div>
|
|
192
|
+
)}
|
|
193
|
+
|
|
194
|
+
{step === 'receive' && (
|
|
195
|
+
<label className="flex items-start gap-2 rounded-md border p-3 text-sm">
|
|
196
|
+
<input type="checkbox" className="mt-1" checked={received} onChange={(e) => setReceived(e.target.checked)} />
|
|
197
|
+
<span>{t('returns.receiveHere', { defaultValue: 'Confirmo que la mercancía ya está en tienda. No hay almacén que la reciba, así que la recepción se registra aquí.' })}</span>
|
|
198
|
+
</label>
|
|
199
|
+
)}
|
|
200
|
+
|
|
201
|
+
{step === 'refund' && (
|
|
202
|
+
<RefundDestination total={totals.total} value={refund} onChange={setRefund} options={options} currency={currency} allowSplit serverErrors={serverErrors} />
|
|
203
|
+
)}
|
|
204
|
+
|
|
205
|
+
<div className="flex items-center justify-between gap-2 border-t pt-3">
|
|
206
|
+
<span className="text-sm">
|
|
207
|
+
{t('returns.total', { defaultValue: 'Total a devolver' })}: <strong>{fmt.money(totals.total)}</strong>
|
|
208
|
+
</span>
|
|
209
|
+
<div className="flex gap-2">
|
|
210
|
+
{onCancel && stepIdx === 0 && <Button type="button" variant="outline" onClick={onCancel}>{t('common.cancel', { defaultValue: 'Cancelar' })}</Button>}
|
|
211
|
+
{stepIdx > 0 && <Button type="button" variant="outline" onClick={() => setStepIdx(stepIdx - 1)}>{t('common.back', { defaultValue: 'Atrás' })}</Button>}
|
|
212
|
+
{stepIdx < steps.length - 1 ? (
|
|
213
|
+
<Button type="button" disabled={!canNext} onClick={() => setStepIdx(stepIdx + 1)}>{t('common.next', { defaultValue: 'Siguiente' })}</Button>
|
|
214
|
+
) : (
|
|
215
|
+
<Button type="button" disabled={!canNext || submitting} onClick={submit}>
|
|
216
|
+
{t('returns.confirm', { defaultValue: 'Confirmar devolución' })} {fmt.money(totals.total)}
|
|
217
|
+
</Button>
|
|
218
|
+
)}
|
|
219
|
+
</div>
|
|
220
|
+
</div>
|
|
221
|
+
</div>
|
|
222
|
+
)
|
|
223
|
+
}
|