@asteby/metacore-runtime-react 45.0.0 → 45.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (177) hide show
  1. package/CHANGELOG.md +29 -0
  2. package/dist/action-modal-dispatcher.d.ts +29 -0
  3. package/dist/action-modal-dispatcher.d.ts.map +1 -1
  4. package/dist/action-modal-dispatcher.js +115 -6
  5. package/dist/activity-diff.d.ts +2 -0
  6. package/dist/activity-diff.d.ts.map +1 -1
  7. package/dist/activity-diff.js +2 -2
  8. package/dist/approval-events.d.ts +16 -0
  9. package/dist/approval-events.d.ts.map +1 -0
  10. package/dist/approval-events.js +27 -0
  11. package/dist/approval-gate.d.ts +39 -0
  12. package/dist/approval-gate.d.ts.map +1 -0
  13. package/dist/approval-gate.js +110 -0
  14. package/dist/approval-pin-dialog.d.ts +25 -0
  15. package/dist/approval-pin-dialog.d.ts.map +1 -0
  16. package/dist/approval-pin-dialog.js +66 -0
  17. package/dist/business/catalog.d.ts +16 -0
  18. package/dist/business/catalog.d.ts.map +1 -0
  19. package/dist/business/catalog.js +50 -0
  20. package/dist/business/customer-picker.d.ts +39 -0
  21. package/dist/business/customer-picker.d.ts.map +1 -0
  22. package/dist/business/customer-picker.js +51 -0
  23. package/dist/business/feedback.d.ts +72 -0
  24. package/dist/business/feedback.d.ts.map +1 -0
  25. package/dist/business/feedback.js +52 -0
  26. package/dist/business/field-errors.d.ts +26 -0
  27. package/dist/business/field-errors.d.ts.map +1 -0
  28. package/dist/business/field-errors.js +59 -0
  29. package/dist/business/format.d.ts +49 -0
  30. package/dist/business/format.d.ts.map +1 -0
  31. package/dist/business/format.js +128 -0
  32. package/dist/business/index.d.ts +16 -0
  33. package/dist/business/index.d.ts.map +1 -0
  34. package/dist/business/index.js +15 -0
  35. package/dist/business/line-items-editor.d.ts +25 -0
  36. package/dist/business/line-items-editor.d.ts.map +1 -0
  37. package/dist/business/line-items-editor.js +36 -0
  38. package/dist/business/line-items.d.ts +69 -0
  39. package/dist/business/line-items.d.ts.map +1 -0
  40. package/dist/business/line-items.js +133 -0
  41. package/dist/business/payment-capture.d.ts +21 -0
  42. package/dist/business/payment-capture.d.ts.map +1 -0
  43. package/dist/business/payment-capture.js +24 -0
  44. package/dist/business/payment.d.ts +61 -0
  45. package/dist/business/payment.d.ts.map +1 -0
  46. package/dist/business/payment.js +74 -0
  47. package/dist/business/product-picker.d.ts +24 -0
  48. package/dist/business/product-picker.d.ts.map +1 -0
  49. package/dist/business/product-picker.js +48 -0
  50. package/dist/business/product-search.d.ts +70 -0
  51. package/dist/business/product-search.d.ts.map +1 -0
  52. package/dist/business/product-search.js +51 -0
  53. package/dist/business/refund-destination.d.ts +19 -0
  54. package/dist/business/refund-destination.d.ts.map +1 -0
  55. package/dist/business/refund-destination.js +31 -0
  56. package/dist/business/refund.d.ts +29 -0
  57. package/dist/business/refund.d.ts.map +1 -0
  58. package/dist/business/refund.js +44 -0
  59. package/dist/business/return-wizard.d.ts +23 -0
  60. package/dist/business/return-wizard.d.ts.map +1 -0
  61. package/dist/business/return-wizard.js +59 -0
  62. package/dist/business/return.d.ts +90 -0
  63. package/dist/business/return.d.ts.map +1 -0
  64. package/dist/business/return.js +112 -0
  65. package/dist/business/use-async-search.d.ts +10 -0
  66. package/dist/business/use-async-search.d.ts.map +1 -0
  67. package/dist/business/use-async-search.js +38 -0
  68. package/dist/document/action-bar.d.ts +12 -0
  69. package/dist/document/action-bar.d.ts.map +1 -0
  70. package/dist/document/action-bar.js +39 -0
  71. package/dist/document/cancel-with-reason.d.ts +47 -0
  72. package/dist/document/cancel-with-reason.d.ts.map +1 -0
  73. package/dist/document/cancel-with-reason.js +52 -0
  74. package/dist/document/document-header.d.ts +41 -0
  75. package/dist/document/document-header.d.ts.map +1 -0
  76. package/dist/document/document-header.js +30 -0
  77. package/dist/document/document-page.d.ts +37 -0
  78. package/dist/document/document-page.d.ts.map +1 -0
  79. package/dist/document/document-page.js +137 -0
  80. package/dist/document/document-timeline.d.ts +20 -0
  81. package/dist/document/document-timeline.d.ts.map +1 -0
  82. package/dist/document/document-timeline.js +26 -0
  83. package/dist/document/evaluate.d.ts +31 -0
  84. package/dist/document/evaluate.d.ts.map +1 -0
  85. package/dist/document/evaluate.js +170 -0
  86. package/dist/document/format.d.ts +9 -0
  87. package/dist/document/format.d.ts.map +1 -0
  88. package/dist/document/format.js +52 -0
  89. package/dist/document/index.d.ts +13 -0
  90. package/dist/document/index.d.ts.map +1 -0
  91. package/dist/document/index.js +12 -0
  92. package/dist/document/registry.d.ts +20 -0
  93. package/dist/document/registry.d.ts.map +1 -0
  94. package/dist/document/registry.js +20 -0
  95. package/dist/document/smart-buttons.d.ts +16 -0
  96. package/dist/document/smart-buttons.d.ts.map +1 -0
  97. package/dist/document/smart-buttons.js +16 -0
  98. package/dist/document/status-badge.d.ts +16 -0
  99. package/dist/document/status-badge.d.ts.map +1 -0
  100. package/dist/document/status-badge.js +23 -0
  101. package/dist/document/types.d.ts +196 -0
  102. package/dist/document/types.d.ts.map +1 -0
  103. package/dist/document/types.js +1 -0
  104. package/dist/document/use-document-sources.d.ts +14 -0
  105. package/dist/document/use-document-sources.d.ts.map +1 -0
  106. package/dist/document/use-document-sources.js +72 -0
  107. package/dist/dynamic-columns.d.ts.map +1 -1
  108. package/dist/dynamic-columns.js +3 -1
  109. package/dist/dynamic-relation.d.ts +4 -0
  110. package/dist/dynamic-relation.d.ts.map +1 -1
  111. package/dist/dynamic-relation.js +10 -1
  112. package/dist/dynamic-row-actions.d.ts.map +1 -1
  113. package/dist/dynamic-row-actions.js +14 -2
  114. package/dist/dynamic-table.d.ts.map +1 -1
  115. package/dist/dynamic-table.js +28 -3
  116. package/dist/index.d.ts +5 -0
  117. package/dist/index.d.ts.map +1 -1
  118. package/dist/index.js +6 -0
  119. package/dist/reason-prompt.d.ts +59 -0
  120. package/dist/reason-prompt.d.ts.map +1 -0
  121. package/dist/reason-prompt.js +111 -0
  122. package/dist/server-error.d.ts.map +1 -1
  123. package/dist/server-error.js +19 -0
  124. package/dist/use-addon-settings.d.ts +17 -3
  125. package/dist/use-addon-settings.d.ts.map +1 -1
  126. package/dist/use-addon-settings.js +20 -8
  127. package/package.json +1 -1
  128. package/src/__tests__/approval-gate.test.tsx +134 -0
  129. package/src/__tests__/prefill-from-record.test.ts +69 -0
  130. package/src/__tests__/reason-prompt.test.tsx +121 -0
  131. package/src/__tests__/use-addon-settings.test.ts +16 -1
  132. package/src/action-modal-dispatcher.tsx +145 -3
  133. package/src/activity-diff.tsx +10 -1
  134. package/src/approval-events.ts +39 -0
  135. package/src/approval-gate.tsx +180 -0
  136. package/src/approval-pin-dialog.tsx +162 -0
  137. package/src/business/business.test.ts +210 -0
  138. package/src/business/catalog.ts +68 -0
  139. package/src/business/customer-picker.tsx +201 -0
  140. package/src/business/feedback.tsx +141 -0
  141. package/src/business/field-errors.ts +92 -0
  142. package/src/business/format.ts +151 -0
  143. package/src/business/index.ts +15 -0
  144. package/src/business/line-items-editor.tsx +259 -0
  145. package/src/business/line-items.ts +190 -0
  146. package/src/business/payment-capture.tsx +158 -0
  147. package/src/business/payment.ts +125 -0
  148. package/src/business/product-picker.tsx +171 -0
  149. package/src/business/product-search.ts +108 -0
  150. package/src/business/refund-destination.tsx +121 -0
  151. package/src/business/refund.ts +72 -0
  152. package/src/business/return-wizard.tsx +223 -0
  153. package/src/business/return.ts +181 -0
  154. package/src/business/use-async-search.ts +43 -0
  155. package/src/document/__tests__/document-page.test.tsx +107 -0
  156. package/src/document/__tests__/evaluate.test.ts +140 -0
  157. package/src/document/action-bar.tsx +140 -0
  158. package/src/document/cancel-with-reason.tsx +214 -0
  159. package/src/document/document-header.tsx +140 -0
  160. package/src/document/document-page.tsx +268 -0
  161. package/src/document/document-timeline.tsx +66 -0
  162. package/src/document/evaluate.ts +180 -0
  163. package/src/document/format.ts +59 -0
  164. package/src/document/index.ts +28 -0
  165. package/src/document/registry.ts +39 -0
  166. package/src/document/smart-buttons.tsx +54 -0
  167. package/src/document/status-badge.tsx +44 -0
  168. package/src/document/types.ts +203 -0
  169. package/src/document/use-document-sources.ts +91 -0
  170. package/src/dynamic-columns.tsx +5 -1
  171. package/src/dynamic-relation.tsx +14 -1
  172. package/src/dynamic-row-actions.tsx +13 -1
  173. package/src/dynamic-table.tsx +22 -2
  174. package/src/index.ts +33 -0
  175. package/src/reason-prompt.tsx +247 -0
  176. package/src/server-error.ts +20 -0
  177. package/src/use-addon-settings.ts +26 -7
@@ -0,0 +1,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
+ }