@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.
Files changed (137) hide show
  1. package/CHANGELOG.md +17 -0
  2. package/dist/approval-events.d.ts +16 -0
  3. package/dist/approval-events.d.ts.map +1 -0
  4. package/dist/approval-events.js +27 -0
  5. package/dist/approval-gate.d.ts +39 -0
  6. package/dist/approval-gate.d.ts.map +1 -0
  7. package/dist/approval-gate.js +110 -0
  8. package/dist/approval-pin-dialog.d.ts +25 -0
  9. package/dist/approval-pin-dialog.d.ts.map +1 -0
  10. package/dist/approval-pin-dialog.js +66 -0
  11. package/dist/business/catalog.d.ts +16 -0
  12. package/dist/business/catalog.d.ts.map +1 -0
  13. package/dist/business/catalog.js +42 -0
  14. package/dist/business/customer-picker.d.ts +39 -0
  15. package/dist/business/customer-picker.d.ts.map +1 -0
  16. package/dist/business/customer-picker.js +51 -0
  17. package/dist/business/feedback.d.ts +72 -0
  18. package/dist/business/feedback.d.ts.map +1 -0
  19. package/dist/business/feedback.js +52 -0
  20. package/dist/business/field-errors.d.ts +26 -0
  21. package/dist/business/field-errors.d.ts.map +1 -0
  22. package/dist/business/field-errors.js +59 -0
  23. package/dist/business/format.d.ts +49 -0
  24. package/dist/business/format.d.ts.map +1 -0
  25. package/dist/business/format.js +128 -0
  26. package/dist/business/index.d.ts +14 -0
  27. package/dist/business/index.d.ts.map +1 -0
  28. package/dist/business/index.js +13 -0
  29. package/dist/business/line-items-editor.d.ts +25 -0
  30. package/dist/business/line-items-editor.d.ts.map +1 -0
  31. package/dist/business/line-items-editor.js +36 -0
  32. package/dist/business/line-items.d.ts +69 -0
  33. package/dist/business/line-items.d.ts.map +1 -0
  34. package/dist/business/line-items.js +133 -0
  35. package/dist/business/payment-capture.d.ts +21 -0
  36. package/dist/business/payment-capture.d.ts.map +1 -0
  37. package/dist/business/payment-capture.js +24 -0
  38. package/dist/business/payment.d.ts +61 -0
  39. package/dist/business/payment.d.ts.map +1 -0
  40. package/dist/business/payment.js +74 -0
  41. package/dist/business/product-picker.d.ts +24 -0
  42. package/dist/business/product-picker.d.ts.map +1 -0
  43. package/dist/business/product-picker.js +48 -0
  44. package/dist/business/product-search.d.ts +70 -0
  45. package/dist/business/product-search.d.ts.map +1 -0
  46. package/dist/business/product-search.js +51 -0
  47. package/dist/business/refund-destination.d.ts +19 -0
  48. package/dist/business/refund-destination.d.ts.map +1 -0
  49. package/dist/business/refund-destination.js +31 -0
  50. package/dist/business/refund.d.ts +29 -0
  51. package/dist/business/refund.d.ts.map +1 -0
  52. package/dist/business/refund.js +43 -0
  53. package/dist/business/use-async-search.d.ts +10 -0
  54. package/dist/business/use-async-search.d.ts.map +1 -0
  55. package/dist/business/use-async-search.js +38 -0
  56. package/dist/document/action-bar.d.ts +12 -0
  57. package/dist/document/action-bar.d.ts.map +1 -0
  58. package/dist/document/action-bar.js +39 -0
  59. package/dist/document/cancel-with-reason.d.ts +47 -0
  60. package/dist/document/cancel-with-reason.d.ts.map +1 -0
  61. package/dist/document/cancel-with-reason.js +52 -0
  62. package/dist/document/document-header.d.ts +41 -0
  63. package/dist/document/document-header.d.ts.map +1 -0
  64. package/dist/document/document-header.js +30 -0
  65. package/dist/document/document-page.d.ts +37 -0
  66. package/dist/document/document-page.d.ts.map +1 -0
  67. package/dist/document/document-page.js +137 -0
  68. package/dist/document/document-timeline.d.ts +20 -0
  69. package/dist/document/document-timeline.d.ts.map +1 -0
  70. package/dist/document/document-timeline.js +26 -0
  71. package/dist/document/evaluate.d.ts +31 -0
  72. package/dist/document/evaluate.d.ts.map +1 -0
  73. package/dist/document/evaluate.js +170 -0
  74. package/dist/document/format.d.ts +9 -0
  75. package/dist/document/format.d.ts.map +1 -0
  76. package/dist/document/format.js +52 -0
  77. package/dist/document/index.d.ts +13 -0
  78. package/dist/document/index.d.ts.map +1 -0
  79. package/dist/document/index.js +12 -0
  80. package/dist/document/registry.d.ts +20 -0
  81. package/dist/document/registry.d.ts.map +1 -0
  82. package/dist/document/registry.js +20 -0
  83. package/dist/document/smart-buttons.d.ts +16 -0
  84. package/dist/document/smart-buttons.d.ts.map +1 -0
  85. package/dist/document/smart-buttons.js +16 -0
  86. package/dist/document/status-badge.d.ts +16 -0
  87. package/dist/document/status-badge.d.ts.map +1 -0
  88. package/dist/document/status-badge.js +23 -0
  89. package/dist/document/types.d.ts +196 -0
  90. package/dist/document/types.d.ts.map +1 -0
  91. package/dist/document/types.js +1 -0
  92. package/dist/document/use-document-sources.d.ts +14 -0
  93. package/dist/document/use-document-sources.d.ts.map +1 -0
  94. package/dist/document/use-document-sources.js +72 -0
  95. package/dist/index.d.ts +4 -0
  96. package/dist/index.d.ts.map +1 -1
  97. package/dist/index.js +5 -0
  98. package/dist/server-error.d.ts.map +1 -1
  99. package/dist/server-error.js +19 -0
  100. package/package.json +1 -1
  101. package/src/__tests__/approval-gate.test.tsx +134 -0
  102. package/src/approval-events.ts +39 -0
  103. package/src/approval-gate.tsx +180 -0
  104. package/src/approval-pin-dialog.tsx +162 -0
  105. package/src/business/business.test.ts +164 -0
  106. package/src/business/catalog.ts +60 -0
  107. package/src/business/customer-picker.tsx +201 -0
  108. package/src/business/feedback.tsx +141 -0
  109. package/src/business/field-errors.ts +92 -0
  110. package/src/business/format.ts +151 -0
  111. package/src/business/index.ts +13 -0
  112. package/src/business/line-items-editor.tsx +259 -0
  113. package/src/business/line-items.ts +190 -0
  114. package/src/business/payment-capture.tsx +158 -0
  115. package/src/business/payment.ts +125 -0
  116. package/src/business/product-picker.tsx +171 -0
  117. package/src/business/product-search.ts +108 -0
  118. package/src/business/refund-destination.tsx +121 -0
  119. package/src/business/refund.ts +71 -0
  120. package/src/business/use-async-search.ts +43 -0
  121. package/src/document/__tests__/document-page.test.tsx +107 -0
  122. package/src/document/__tests__/evaluate.test.ts +140 -0
  123. package/src/document/action-bar.tsx +140 -0
  124. package/src/document/cancel-with-reason.tsx +214 -0
  125. package/src/document/document-header.tsx +140 -0
  126. package/src/document/document-page.tsx +268 -0
  127. package/src/document/document-timeline.tsx +66 -0
  128. package/src/document/evaluate.ts +180 -0
  129. package/src/document/format.ts +59 -0
  130. package/src/document/index.ts +28 -0
  131. package/src/document/registry.ts +39 -0
  132. package/src/document/smart-buttons.tsx +54 -0
  133. package/src/document/status-badge.tsx +44 -0
  134. package/src/document/types.ts +203 -0
  135. package/src/document/use-document-sources.ts +91 -0
  136. package/src/index.ts +23 -0
  137. package/src/server-error.ts +20 -0
@@ -0,0 +1,190 @@
1
+ // Renglones de documento (cotización, pedido, factura, OC, recepción, OT, RMA, NC).
2
+ // Lógica pura del <LineItemsEditor>: cálculo, validación y serialización. El
3
+ // guardado de renglones fallaba (PIT-018) por mandar al API cadenas vacías y
4
+ // claves de UI; `serializeLineItems` es la única puerta de salida hacia el backend.
5
+ import { roundMoney, toAmount } from './format'
6
+
7
+ export type LineItemKind = 'item' | 'section' | 'note'
8
+
9
+ export interface LineItem {
10
+ /** Clave estable de UI (no se envía al backend). */
11
+ key: string
12
+ kind: LineItemKind
13
+ /** Id del producto/variante en el catálogo (kind=item). */
14
+ product_id?: string
15
+ sku?: string
16
+ description: string
17
+ quantity: number
18
+ unit_price: number
19
+ /** Descuento en porcentaje 0-100. */
20
+ discount: number
21
+ /** Tasa de impuesto como fracción (0.16). Viene de la config fiscal de la org. */
22
+ tax_rate: number
23
+ technician_id?: string
24
+ lot?: string
25
+ dot?: string
26
+ /** Existencia disponible conocida (para la política de sobreventa). */
27
+ available?: number
28
+ }
29
+
30
+ /** Qué hacer si `quantity > available`. */
31
+ export type StockPolicy = 'allow' | 'warn' | 'block'
32
+
33
+ export interface LineItemsPolicy {
34
+ /** Default `warn`. */
35
+ stockPolicy?: StockPolicy
36
+ /** Permite cantidades/precios negativos (notas de crédito). Default false. */
37
+ allowNegative?: boolean
38
+ /** Exige al menos un renglón `item`. Default true. */
39
+ requireItems?: boolean
40
+ }
41
+
42
+ let seq = 0
43
+ /** Clave de UI única en el proceso. */
44
+ export function newLineKey(): string {
45
+ seq += 1
46
+ return `li_${Date.now().toString(36)}_${seq}`
47
+ }
48
+
49
+ export function makeLine(partial: Partial<LineItem> = {}): LineItem {
50
+ return {
51
+ key: partial.key ?? newLineKey(),
52
+ kind: 'item',
53
+ description: '',
54
+ quantity: 1,
55
+ unit_price: 0,
56
+ discount: 0,
57
+ tax_rate: 0,
58
+ ...partial,
59
+ }
60
+ }
61
+
62
+ export interface LineAmounts {
63
+ gross: number
64
+ discount: number
65
+ net: number
66
+ tax: number
67
+ total: number
68
+ }
69
+
70
+ export function computeLine(l: Pick<LineItem, 'kind' | 'quantity' | 'unit_price' | 'discount' | 'tax_rate'>): LineAmounts {
71
+ if (l.kind !== 'item') return { gross: 0, discount: 0, net: 0, tax: 0, total: 0 }
72
+ const gross = roundMoney(toAmount(l.quantity) * toAmount(l.unit_price))
73
+ const discount = roundMoney(gross * (toAmount(l.discount) / 100))
74
+ const net = roundMoney(gross - discount)
75
+ const tax = roundMoney(net * toAmount(l.tax_rate))
76
+ return { gross, discount, net, tax, total: roundMoney(net + tax) }
77
+ }
78
+
79
+ export interface LineItemsTotals {
80
+ subtotal: number
81
+ discount: number
82
+ tax: number
83
+ total: number
84
+ /** Suma de cantidades de los renglones `item`. */
85
+ units: number
86
+ }
87
+
88
+ export function computeTotals(lines: LineItem[]): LineItemsTotals {
89
+ const t: LineItemsTotals = { subtotal: 0, discount: 0, tax: 0, total: 0, units: 0 }
90
+ for (const l of lines) {
91
+ if (l.kind !== 'item') continue
92
+ const a = computeLine(l)
93
+ t.subtotal += a.gross
94
+ t.discount += a.discount
95
+ t.tax += a.tax
96
+ t.units += toAmount(l.quantity)
97
+ }
98
+ t.subtotal = roundMoney(t.subtotal)
99
+ t.discount = roundMoney(t.discount)
100
+ t.tax = roundMoney(t.tax)
101
+ t.total = roundMoney(t.subtotal - t.discount + t.tax)
102
+ return t
103
+ }
104
+
105
+ export interface LineItemsValidation {
106
+ /** Errores por celda `"<índice>.<campo>"` (mismo formato que DynamicLineItems). */
107
+ errors: Record<string, string>
108
+ /** Avisos no bloqueantes (sobreventa con política `warn`). */
109
+ warnings: Record<string, string>
110
+ /** Error de documento (sin renglones). */
111
+ form?: string
112
+ valid: boolean
113
+ }
114
+
115
+ export function validateLineItems(lines: LineItem[], policy: LineItemsPolicy = {}): LineItemsValidation {
116
+ const { stockPolicy = 'warn', allowNegative = false, requireItems = true } = policy
117
+ const errors: Record<string, string> = {}
118
+ const warnings: Record<string, string> = {}
119
+ let items = 0
120
+ lines.forEach((l, i) => {
121
+ if (l.kind === 'section' || l.kind === 'note') {
122
+ if (!l.description.trim()) errors[`${i}.description`] = 'Escribe el texto'
123
+ return
124
+ }
125
+ items += 1
126
+ const qty = toAmount(l.quantity)
127
+ const price = toAmount(l.unit_price)
128
+ const disc = toAmount(l.discount)
129
+ if (!l.description.trim() && !l.product_id) errors[`${i}.description`] = 'Elige un producto o escribe una descripción'
130
+ if (qty === 0) errors[`${i}.quantity`] = 'La cantidad debe ser distinta de cero'
131
+ else if (qty < 0 && !allowNegative) errors[`${i}.quantity`] = 'La cantidad no puede ser negativa'
132
+ if (price < 0 && !allowNegative) errors[`${i}.unit_price`] = 'El precio no puede ser negativo'
133
+ if (disc < 0 || disc > 100) errors[`${i}.discount`] = 'El descuento debe estar entre 0 y 100%'
134
+ if (l.available != null && qty > l.available && stockPolicy !== 'allow') {
135
+ const msg = `Solo hay ${l.available} disponibles`
136
+ if (stockPolicy === 'block') errors[`${i}.quantity`] ??= msg
137
+ else warnings[`${i}.quantity`] = msg
138
+ }
139
+ })
140
+ const form = requireItems && items === 0 ? 'Agrega al menos un renglón' : undefined
141
+ return { errors, warnings, form, valid: !form && Object.keys(errors).length === 0 }
142
+ }
143
+
144
+ /**
145
+ * Forma que se manda al backend: números reales (nunca `""`), sin claves de UI,
146
+ * opcionales vacíos omitidos y `position` 1-based conservando el orden.
147
+ */
148
+ export function serializeLineItems(lines: LineItem[]): Array<Record<string, unknown>> {
149
+ return lines.map((l, i) => {
150
+ const out: Record<string, unknown> = {
151
+ position: i + 1,
152
+ kind: l.kind,
153
+ description: l.description.trim(),
154
+ }
155
+ if (l.kind === 'item') {
156
+ out.quantity = toAmount(l.quantity)
157
+ out.unit_price = toAmount(l.unit_price)
158
+ out.discount = toAmount(l.discount)
159
+ out.tax_rate = toAmount(l.tax_rate)
160
+ for (const k of ['product_id', 'sku', 'technician_id', 'lot', 'dot'] as const) {
161
+ const v = l[k]
162
+ if (typeof v === 'string' && v.trim()) out[k] = v.trim()
163
+ }
164
+ }
165
+ return out
166
+ })
167
+ }
168
+
169
+ /** Inversa de `serializeLineItems`: filas del API → líneas editables (defaults seguros). */
170
+ export function parseLineItems(rows: unknown): LineItem[] {
171
+ if (!Array.isArray(rows)) return []
172
+ return rows.map((r) => {
173
+ const o = (r ?? {}) as Record<string, unknown>
174
+ const kind: LineItemKind = o.kind === 'section' || o.kind === 'note' ? o.kind : 'item'
175
+ const str = (v: unknown) => (typeof v === 'string' && v ? v : undefined)
176
+ return makeLine({
177
+ kind,
178
+ product_id: str(o.product_id),
179
+ sku: str(o.sku),
180
+ description: typeof o.description === 'string' ? o.description : '',
181
+ quantity: o.quantity == null ? 1 : toAmount(o.quantity),
182
+ unit_price: toAmount(o.unit_price),
183
+ discount: toAmount(o.discount),
184
+ tax_rate: toAmount(o.tax_rate),
185
+ technician_id: str(o.technician_id),
186
+ lot: str(o.lot),
187
+ dot: str(o.dot),
188
+ })
189
+ })
190
+ }
@@ -0,0 +1,158 @@
1
+ // PaymentCapture — cobro mixto con cambio, referencia y cuenta (benchmark §7).
2
+ import { useMemo } from 'react'
3
+ import { useTranslation } from 'react-i18next'
4
+ import { Trash2 } from 'lucide-react'
5
+ import { Button, Input } from '@asteby/metacore-ui'
6
+ import { useCan } from '../permissions-context'
7
+ import { useFormatter } from './format'
8
+ import {
9
+ newTender,
10
+ serializePayment,
11
+ summarizePayment,
12
+ validatePayment,
13
+ type PaymentMethodOption,
14
+ type PaymentPolicy,
15
+ type PaymentTender,
16
+ } from './payment'
17
+
18
+ export interface PaymentCaptureProps {
19
+ /** Total a cobrar. */
20
+ total: number
21
+ methods: PaymentMethodOption[]
22
+ value: PaymentTender[]
23
+ onChange: (tenders: PaymentTender[]) => void
24
+ /** Confirmar cobro: recibe el payload ya serializado. Solo se llama si es válido. */
25
+ onSubmit?: (payload: ReturnType<typeof serializePayment>) => void
26
+ policy?: PaymentPolicy
27
+ /** Errores del servidor `"<índice>.<campo>"`. */
28
+ serverErrors?: Record<string, string>
29
+ submitLabel?: string
30
+ submitting?: boolean
31
+ /** Capability requerida para cobrar (p. ej. `pos_orders.pagar`). Sin ella, botón oculto. */
32
+ submitPermission?: string
33
+ currency?: string
34
+ disabled?: boolean
35
+ }
36
+
37
+ export function PaymentCapture({
38
+ total,
39
+ methods,
40
+ value,
41
+ onChange,
42
+ onSubmit,
43
+ policy,
44
+ serverErrors,
45
+ submitLabel,
46
+ submitting,
47
+ submitPermission,
48
+ currency,
49
+ disabled,
50
+ }: PaymentCaptureProps) {
51
+ const { t } = useTranslation()
52
+ const can = useCan()
53
+ const fmt = useFormatter({ currency })
54
+ const summary = useMemo(() => summarizePayment(total, value, methods), [total, value, methods])
55
+ const validation = useMemo(() => validatePayment(total, value, methods, policy), [total, value, methods, policy])
56
+ const errors = { ...validation.errors, ...serverErrors }
57
+ const byId = new Map(methods.map((m) => [m.id, m]))
58
+ const mayPay = submitPermission ? can(submitPermission) : true
59
+
60
+ const patch = (idx: number, p: Partial<PaymentTender>) =>
61
+ onChange(value.map((x, i) => (i === idx ? { ...x, ...p } : x)))
62
+
63
+ return (
64
+ <div data-slot="payment-capture" className="space-y-3">
65
+ <div className="flex flex-wrap gap-2">
66
+ {methods.map((m) => (
67
+ <Button
68
+ key={m.id}
69
+ type="button"
70
+ size="sm"
71
+ variant="outline"
72
+ disabled={disabled}
73
+ onClick={() => onChange([...value, newTender(m.id, summary.remaining)])}
74
+ >
75
+ {m.label}
76
+ </Button>
77
+ ))}
78
+ </div>
79
+
80
+ {value.map((tender, i) => {
81
+ const m = byId.get(tender.method)
82
+ return (
83
+ <div key={tender.key} className="grid grid-cols-[1fr_8rem_auto] items-start gap-2 rounded-md border p-2">
84
+ <div className="space-y-1">
85
+ <p className="text-sm font-medium">{m?.label ?? tender.method}</p>
86
+ {m?.requiresReference && (
87
+ <Input
88
+ placeholder={t('payment.reference', { defaultValue: 'Referencia' })}
89
+ value={tender.reference ?? ''}
90
+ disabled={disabled}
91
+ aria-invalid={!!errors[`${i}.reference`]}
92
+ onChange={(e) => patch(i, { reference: e.target.value })}
93
+ />
94
+ )}
95
+ {m?.requiresAccount && (
96
+ <select
97
+ className="h-9 w-full rounded-md border bg-background px-2 text-sm"
98
+ value={tender.account_id ?? ''}
99
+ disabled={disabled}
100
+ onChange={(e) => patch(i, { account_id: e.target.value || undefined })}
101
+ >
102
+ <option value="">{t('payment.account', { defaultValue: 'Cuenta…' })}</option>
103
+ {(m.accounts ?? []).map((a) => (
104
+ <option key={a.id} value={a.id}>{a.label}</option>
105
+ ))}
106
+ </select>
107
+ )}
108
+ {(['reference', 'account_id', 'method'] as const).map(
109
+ (f) => errors[`${i}.${f}`] && <p key={f} className="text-xs text-destructive">{errors[`${i}.${f}`]}</p>,
110
+ )}
111
+ </div>
112
+ <div>
113
+ <Input
114
+ inputMode="decimal"
115
+ className="text-right"
116
+ value={String(tender.amount)}
117
+ disabled={disabled}
118
+ aria-invalid={!!errors[`${i}.amount`]}
119
+ onChange={(e) => patch(i, { amount: Number(e.target.value.replace(',', '.')) || 0 })}
120
+ />
121
+ {errors[`${i}.amount`] && <p className="mt-0.5 text-xs text-destructive">{errors[`${i}.amount`]}</p>}
122
+ </div>
123
+ <Button
124
+ type="button"
125
+ size="icon"
126
+ variant="ghost"
127
+ disabled={disabled}
128
+ aria-label={t('common.delete', { defaultValue: 'Eliminar' })}
129
+ onClick={() => onChange(value.filter((_, j) => j !== i))}
130
+ >
131
+ <Trash2 className="size-4" />
132
+ </Button>
133
+ </div>
134
+ )
135
+ })}
136
+
137
+ <dl className="space-y-0.5 text-sm tabular-nums">
138
+ <div className="flex justify-between"><dt className="text-muted-foreground">{t('payment.total', { defaultValue: 'Total' })}</dt><dd>{fmt.money(summary.total)}</dd></div>
139
+ <div className="flex justify-between"><dt className="text-muted-foreground">{t('payment.paid', { defaultValue: 'Pagado' })}</dt><dd>{fmt.money(summary.paid)}</dd></div>
140
+ {summary.remaining > 0 && <div className="flex justify-between font-medium"><dt>{t('payment.remaining', { defaultValue: 'Pendiente' })}</dt><dd>{fmt.money(summary.remaining)}</dd></div>}
141
+ {summary.change > 0 && <div className="flex justify-between font-semibold"><dt>{t('payment.change', { defaultValue: 'Cambio' })}</dt><dd>{fmt.money(summary.change)}</dd></div>}
142
+ </dl>
143
+
144
+ {validation.form && value.length > 0 && <p role="alert" className="text-sm text-destructive">{validation.form}</p>}
145
+
146
+ {onSubmit && mayPay && (
147
+ <Button
148
+ type="button"
149
+ className="w-full"
150
+ disabled={disabled || submitting || !validation.valid}
151
+ onClick={() => onSubmit(serializePayment(value, methods, total))}
152
+ >
153
+ {submitLabel ?? t('payment.submit', { defaultValue: 'Cobrar' })}
154
+ </Button>
155
+ )}
156
+ </div>
157
+ )
158
+ }
@@ -0,0 +1,125 @@
1
+ // Cobro mixto (POS/Caja, Apartado, Cobranza, Factura «Registrar pago», OT).
2
+ // Lógica pura de <PaymentCapture>: resumen (pagado/pendiente/cambio) y validación.
3
+ import { roundMoney, toAmount } from './format'
4
+
5
+ /** Método de pago configurable por org (el catálogo NO se hardcodea en el SDK). */
6
+ export interface PaymentMethodOption {
7
+ id: string
8
+ label: string
9
+ /** Efectivo: admite recibir de más y devolver cambio. */
10
+ cash?: boolean
11
+ requiresReference?: boolean
12
+ requiresAccount?: boolean
13
+ /** Tope por método (saldo de cuenta de cliente, monedero). */
14
+ maxAmount?: number
15
+ /** Cuentas/cajas destino elegibles (requiresAccount). */
16
+ accounts?: Array<{ id: string; label: string }>
17
+ }
18
+
19
+ export interface PaymentTender {
20
+ key: string
21
+ method: string
22
+ amount: number
23
+ reference?: string
24
+ account_id?: string
25
+ }
26
+
27
+ export interface PaymentSummary {
28
+ total: number
29
+ paid: number
30
+ /** Lo que falta por cobrar (>= 0). */
31
+ remaining: number
32
+ /** Cambio a entregar: exceso cubierto por efectivo (>= 0). */
33
+ change: number
34
+ /** Exceso en métodos no-efectivo (no se puede devolver): error. */
35
+ overpaid: number
36
+ settled: boolean
37
+ }
38
+
39
+ let seq = 0
40
+ export function newTender(method: string, amount = 0): PaymentTender {
41
+ seq += 1
42
+ return { key: `pt_${Date.now().toString(36)}_${seq}`, method, amount }
43
+ }
44
+
45
+ export function summarizePayment(
46
+ total: number,
47
+ tenders: PaymentTender[],
48
+ methods: PaymentMethodOption[],
49
+ ): PaymentSummary {
50
+ const byId = new Map(methods.map((m) => [m.id, m]))
51
+ let paid = 0
52
+ let cash = 0
53
+ for (const t of tenders) {
54
+ const a = Math.max(0, toAmount(t.amount))
55
+ paid += a
56
+ if (byId.get(t.method)?.cash) cash += a
57
+ }
58
+ paid = roundMoney(paid)
59
+ const tot = roundMoney(total)
60
+ const excess = roundMoney(Math.max(0, paid - tot))
61
+ const change = roundMoney(Math.min(excess, cash))
62
+ return {
63
+ total: tot,
64
+ paid,
65
+ remaining: roundMoney(Math.max(0, tot - paid)),
66
+ change,
67
+ overpaid: roundMoney(excess - change),
68
+ settled: paid >= tot,
69
+ }
70
+ }
71
+
72
+ export interface PaymentValidation {
73
+ /** Errores por renglón de pago `"<índice>.<campo>"`. */
74
+ errors: Record<string, string>
75
+ /** Error general. */
76
+ form?: string
77
+ valid: boolean
78
+ }
79
+
80
+ export interface PaymentPolicy {
81
+ /** Permite cobro parcial (anticipo/abono). Default false. */
82
+ allowPartial?: boolean
83
+ }
84
+
85
+ export function validatePayment(
86
+ total: number,
87
+ tenders: PaymentTender[],
88
+ methods: PaymentMethodOption[],
89
+ policy: PaymentPolicy = {},
90
+ ): PaymentValidation {
91
+ const byId = new Map(methods.map((m) => [m.id, m]))
92
+ const errors: Record<string, string> = {}
93
+ tenders.forEach((t, i) => {
94
+ const m = byId.get(t.method)
95
+ const a = toAmount(t.amount)
96
+ if (!m) errors[`${i}.method`] = 'Método de pago no disponible'
97
+ if (a <= 0) errors[`${i}.amount`] = 'Captura un monto mayor a cero'
98
+ if (m?.maxAmount != null && a > m.maxAmount) errors[`${i}.amount`] = `El máximo para ${m.label} es ${m.maxAmount}`
99
+ if (m?.requiresReference && !t.reference?.trim()) errors[`${i}.reference`] = 'La referencia es obligatoria'
100
+ if (m?.requiresAccount && !t.account_id) errors[`${i}.account_id`] = 'Elige la cuenta'
101
+ })
102
+ const s = summarizePayment(total, tenders, methods)
103
+ let form: string | undefined
104
+ if (tenders.length === 0) form = 'Agrega al menos un pago'
105
+ else if (s.overpaid > 0) form = 'Solo el efectivo puede exceder el total (se devuelve como cambio)'
106
+ else if (!policy.allowPartial && !s.settled) form = 'El pago no cubre el total'
107
+ return { errors, form, valid: !form && Object.keys(errors).length === 0 }
108
+ }
109
+
110
+ /** Payload hacia el backend: montos reales; el cambio no es un pago. */
111
+ export function serializePayment(tenders: PaymentTender[], methods: PaymentMethodOption[], total: number) {
112
+ const s = summarizePayment(total, tenders, methods)
113
+ return {
114
+ payments: tenders.map((t) => ({
115
+ method: t.method,
116
+ // Monto entregado por el cliente; el cambio va aparte en `change`.
117
+ amount: roundMoney(toAmount(t.amount)),
118
+ ...(t.reference?.trim() ? { reference: t.reference.trim() } : {}),
119
+ ...(t.account_id ? { account_id: t.account_id } : {}),
120
+ })),
121
+ paid: s.paid,
122
+ change: s.change,
123
+ remaining: s.remaining,
124
+ }
125
+ }
@@ -0,0 +1,171 @@
1
+ // ProductPicker — una sola búsqueda para productos, llantas y variantes
2
+ // (medida «205/55R16», SKU, código de barras, clave de proveedor). Benchmark §7.
3
+ import { useCallback, useState } from 'react'
4
+ import { useTranslation } from 'react-i18next'
5
+ import { Search } from 'lucide-react'
6
+ import { Badge, Input } from '@asteby/metacore-ui'
7
+ import { useFormatter } from './format'
8
+ import { useAsyncSearch } from './use-async-search'
9
+ import {
10
+ availableStock,
11
+ parseProductQuery,
12
+ type ProductQuery,
13
+ type ProductResult,
14
+ type ProductVariant,
15
+ } from './product-search'
16
+ import { EmptyState } from './feedback'
17
+
18
+ export interface ProductPickerProps {
19
+ /**
20
+ * Búsqueda: recibe la consulta ya clasificada (`barcode` | `tire_size` |
21
+ * `text`) para que el backend elija el índice. Debe ser estable (useCallback).
22
+ */
23
+ search: (query: Exclude<ProductQuery, { kind: 'empty' }>, signal: AbortSignal) => Promise<ProductResult[]>
24
+ /** Se emite al elegir un producto o una variante concreta. */
25
+ onSelect: (product: ProductResult, variant?: ProductVariant) => void
26
+ /** Almacén para mostrar/validar existencias; sin él, se suma el total. */
27
+ warehouseId?: string
28
+ /** Un código de barras con un único resultado se selecciona solo al pulsar Enter. Default true. */
29
+ autoSelectBarcode?: boolean
30
+ /** Permite elegir productos sin existencia (se marcan). Default true. */
31
+ allowOutOfStock?: boolean
32
+ placeholder?: string
33
+ autoFocus?: boolean
34
+ currency?: string
35
+ disabled?: boolean
36
+ }
37
+
38
+ export function ProductPicker({
39
+ search,
40
+ onSelect,
41
+ warehouseId,
42
+ autoSelectBarcode = true,
43
+ allowOutOfStock = true,
44
+ placeholder,
45
+ autoFocus,
46
+ currency,
47
+ disabled,
48
+ }: ProductPickerProps) {
49
+ const { t } = useTranslation()
50
+ const fmt = useFormatter({ currency })
51
+ const [text, setText] = useState('')
52
+ const parsed = parseProductQuery(text)
53
+ // Un código de barras es exacto: no se espera a 2 caracteres ni se debouncea mucho.
54
+ const run = useCallback(
55
+ (q: string, signal: AbortSignal) => {
56
+ const p = parseProductQuery(q)
57
+ return p.kind === 'empty' ? Promise.resolve([]) : search(p, signal)
58
+ },
59
+ [search],
60
+ )
61
+ const { results, loading, error } = useAsyncSearch(text, run, {
62
+ minChars: parsed.kind === 'barcode' ? 8 : 2,
63
+ delay: parsed.kind === 'barcode' ? 0 : 250,
64
+ })
65
+
66
+ const pick = (p: ProductResult, v?: ProductVariant) => {
67
+ const stock = availableStock(v ?? p, warehouseId)
68
+ if (!allowOutOfStock && stock != null && stock <= 0) return
69
+ onSelect(p, v)
70
+ setText('')
71
+ }
72
+
73
+ return (
74
+ <div data-slot="product-picker" className="space-y-2">
75
+ <div className="relative">
76
+ <Search className="pointer-events-none absolute left-2.5 top-2.5 size-4 text-muted-foreground" aria-hidden />
77
+ <Input
78
+ autoFocus={autoFocus}
79
+ disabled={disabled}
80
+ className="pl-8"
81
+ value={text}
82
+ placeholder={placeholder ?? t('productPicker.placeholder', { defaultValue: 'Producto, medida (205/55R16), SKU o código de barras' })}
83
+ onChange={(e) => setText(e.target.value)}
84
+ onKeyDown={(e) => {
85
+ if (e.key === 'Enter' && autoSelectBarcode && parsed.kind === 'barcode' && results.length === 1) {
86
+ e.preventDefault()
87
+ const only = results[0]!
88
+ const exact = only.variants?.find((v) => v.barcode === parsed.barcode)
89
+ pick(only, exact)
90
+ }
91
+ }}
92
+ />
93
+ </div>
94
+
95
+ {parsed.kind === 'tire_size' && (
96
+ <p className="text-xs text-muted-foreground">
97
+ {t('productPicker.tireSize', { defaultValue: 'Medida de llanta' })}: {parsed.tire.normalized}
98
+ </p>
99
+ )}
100
+ {loading && <p className="text-sm text-muted-foreground">{t('common.searching', { defaultValue: 'Buscando…' })}</p>}
101
+ {error && !loading && (
102
+ <p role="alert" className="text-sm text-destructive">
103
+ {t('productPicker.error', { defaultValue: 'No se pudo buscar. Revisa tu conexión e inténtalo de nuevo.' })}
104
+ </p>
105
+ )}
106
+ {!loading && !error && parsed.kind !== 'empty' && text.trim().length >= 2 && results.length === 0 && (
107
+ <EmptyState
108
+ title={t('productPicker.empty', { defaultValue: 'No encontramos ese producto' })}
109
+ description={t('productPicker.emptyHint', { defaultValue: 'Prueba con otra medida, SKU o código de barras.' })}
110
+ />
111
+ )}
112
+
113
+ {results.length > 0 && (
114
+ <ul role="listbox" className="max-h-72 divide-y overflow-auto rounded-md border">
115
+ {results.map((p) => {
116
+ const stock = availableStock(p, warehouseId)
117
+ const hasVariants = (p.variants?.length ?? 0) > 0
118
+ return (
119
+ <li key={p.id} role="option" aria-selected={false} className="px-2 py-1.5">
120
+ <button
121
+ type="button"
122
+ className="flex w-full items-center justify-between gap-2 text-left disabled:opacity-50"
123
+ disabled={hasVariants || (!allowOutOfStock && stock != null && stock <= 0)}
124
+ onClick={() => pick(p)}
125
+ >
126
+ <span>
127
+ <span className="block text-sm font-medium">{p.name}</span>
128
+ <span className="block text-xs text-muted-foreground">
129
+ {[p.sku, p.tire?.normalized, p.supplier_sku && `Prov. ${p.supplier_sku}`].filter(Boolean).join(' · ')}
130
+ </span>
131
+ </span>
132
+ <span className="flex items-center gap-2">
133
+ {stock != null && (
134
+ <Badge variant={stock > 0 ? 'success' : 'danger'}>
135
+ {stock > 0 ? t('productPicker.inStock', { defaultValue: '{{n}} disp.', n: stock }) : t('productPicker.outOfStock', { defaultValue: 'Sin existencia' })}
136
+ </Badge>
137
+ )}
138
+ {p.price != null && !hasVariants && <span className="text-sm tabular-nums">{fmt.money(p.price)}</span>}
139
+ </span>
140
+ </button>
141
+ {hasVariants && (
142
+ <ul className="mt-1 space-y-0.5 pl-3">
143
+ {p.variants!.map((v) => {
144
+ const vs = availableStock(v, warehouseId)
145
+ return (
146
+ <li key={v.id}>
147
+ <button
148
+ type="button"
149
+ className="flex w-full items-center justify-between gap-2 rounded px-1 py-0.5 text-left text-sm hover:bg-accent disabled:opacity-50"
150
+ disabled={!allowOutOfStock && vs != null && vs <= 0}
151
+ onClick={() => pick(p, v)}
152
+ >
153
+ <span>{v.label}</span>
154
+ <span className="flex items-center gap-2">
155
+ {vs != null && <Badge variant={vs > 0 ? 'success' : 'danger'}>{vs}</Badge>}
156
+ {(v.price ?? p.price) != null && <span className="tabular-nums">{fmt.money(v.price ?? p.price)}</span>}
157
+ </span>
158
+ </button>
159
+ </li>
160
+ )
161
+ })}
162
+ </ul>
163
+ )}
164
+ </li>
165
+ )
166
+ })}
167
+ </ul>
168
+ )}
169
+ </div>
170
+ )
171
+ }