@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,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
|
+
}
|