@asteby/metacore-runtime-react 44.0.0 → 45.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +28 -0
- package/dist/approval-events.d.ts +16 -0
- package/dist/approval-events.d.ts.map +1 -0
- package/dist/approval-events.js +27 -0
- package/dist/approval-gate.d.ts +39 -0
- package/dist/approval-gate.d.ts.map +1 -0
- package/dist/approval-gate.js +110 -0
- package/dist/approval-pin-dialog.d.ts +25 -0
- package/dist/approval-pin-dialog.d.ts.map +1 -0
- package/dist/approval-pin-dialog.js +66 -0
- package/dist/business/catalog.d.ts +16 -0
- package/dist/business/catalog.d.ts.map +1 -0
- package/dist/business/catalog.js +42 -0
- package/dist/business/customer-picker.d.ts +39 -0
- package/dist/business/customer-picker.d.ts.map +1 -0
- package/dist/business/customer-picker.js +51 -0
- package/dist/business/feedback.d.ts +72 -0
- package/dist/business/feedback.d.ts.map +1 -0
- package/dist/business/feedback.js +52 -0
- package/dist/business/field-errors.d.ts +26 -0
- package/dist/business/field-errors.d.ts.map +1 -0
- package/dist/business/field-errors.js +59 -0
- package/dist/business/format.d.ts +49 -0
- package/dist/business/format.d.ts.map +1 -0
- package/dist/business/format.js +128 -0
- package/dist/business/index.d.ts +14 -0
- package/dist/business/index.d.ts.map +1 -0
- package/dist/business/index.js +13 -0
- package/dist/business/line-items-editor.d.ts +25 -0
- package/dist/business/line-items-editor.d.ts.map +1 -0
- package/dist/business/line-items-editor.js +36 -0
- package/dist/business/line-items.d.ts +69 -0
- package/dist/business/line-items.d.ts.map +1 -0
- package/dist/business/line-items.js +133 -0
- package/dist/business/payment-capture.d.ts +21 -0
- package/dist/business/payment-capture.d.ts.map +1 -0
- package/dist/business/payment-capture.js +24 -0
- package/dist/business/payment.d.ts +61 -0
- package/dist/business/payment.d.ts.map +1 -0
- package/dist/business/payment.js +74 -0
- package/dist/business/product-picker.d.ts +24 -0
- package/dist/business/product-picker.d.ts.map +1 -0
- package/dist/business/product-picker.js +48 -0
- package/dist/business/product-search.d.ts +70 -0
- package/dist/business/product-search.d.ts.map +1 -0
- package/dist/business/product-search.js +51 -0
- package/dist/business/refund-destination.d.ts +19 -0
- package/dist/business/refund-destination.d.ts.map +1 -0
- package/dist/business/refund-destination.js +31 -0
- package/dist/business/refund.d.ts +29 -0
- package/dist/business/refund.d.ts.map +1 -0
- package/dist/business/refund.js +43 -0
- package/dist/business/use-async-search.d.ts +10 -0
- package/dist/business/use-async-search.d.ts.map +1 -0
- package/dist/business/use-async-search.js +38 -0
- package/dist/document/action-bar.d.ts +12 -0
- package/dist/document/action-bar.d.ts.map +1 -0
- package/dist/document/action-bar.js +39 -0
- package/dist/document/cancel-with-reason.d.ts +47 -0
- package/dist/document/cancel-with-reason.d.ts.map +1 -0
- package/dist/document/cancel-with-reason.js +52 -0
- package/dist/document/document-header.d.ts +41 -0
- package/dist/document/document-header.d.ts.map +1 -0
- package/dist/document/document-header.js +30 -0
- package/dist/document/document-page.d.ts +37 -0
- package/dist/document/document-page.d.ts.map +1 -0
- package/dist/document/document-page.js +137 -0
- package/dist/document/document-timeline.d.ts +20 -0
- package/dist/document/document-timeline.d.ts.map +1 -0
- package/dist/document/document-timeline.js +26 -0
- package/dist/document/evaluate.d.ts +31 -0
- package/dist/document/evaluate.d.ts.map +1 -0
- package/dist/document/evaluate.js +170 -0
- package/dist/document/format.d.ts +9 -0
- package/dist/document/format.d.ts.map +1 -0
- package/dist/document/format.js +52 -0
- package/dist/document/index.d.ts +13 -0
- package/dist/document/index.d.ts.map +1 -0
- package/dist/document/index.js +12 -0
- package/dist/document/registry.d.ts +20 -0
- package/dist/document/registry.d.ts.map +1 -0
- package/dist/document/registry.js +20 -0
- package/dist/document/smart-buttons.d.ts +16 -0
- package/dist/document/smart-buttons.d.ts.map +1 -0
- package/dist/document/smart-buttons.js +16 -0
- package/dist/document/status-badge.d.ts +16 -0
- package/dist/document/status-badge.d.ts.map +1 -0
- package/dist/document/status-badge.js +23 -0
- package/dist/document/types.d.ts +196 -0
- package/dist/document/types.d.ts.map +1 -0
- package/dist/document/types.js +1 -0
- package/dist/document/use-document-sources.d.ts +14 -0
- package/dist/document/use-document-sources.d.ts.map +1 -0
- package/dist/document/use-document-sources.js +72 -0
- package/dist/dynamic-columns.d.ts +11 -0
- package/dist/dynamic-columns.d.ts.map +1 -1
- package/dist/dynamic-columns.js +13 -1
- package/dist/dynamic-kanban.d.ts.map +1 -1
- package/dist/dynamic-kanban.js +4 -5
- package/dist/dynamic-row-actions.d.ts.map +1 -1
- package/dist/dynamic-row-actions.js +12 -1
- package/dist/index.d.ts +5 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +6 -0
- package/dist/model-action-toolbar.d.ts.map +1 -1
- package/dist/model-action-toolbar.js +16 -3
- package/dist/requires-addon.d.ts +37 -0
- package/dist/requires-addon.d.ts.map +1 -0
- package/dist/requires-addon.js +104 -0
- package/dist/server-error.d.ts.map +1 -1
- package/dist/server-error.js +19 -0
- package/dist/types.d.ts +23 -0
- package/dist/types.d.ts.map +1 -1
- package/package.json +3 -3
- package/src/__tests__/approval-gate.test.tsx +134 -0
- package/src/__tests__/requires-addon.test.tsx +201 -0
- package/src/approval-events.ts +39 -0
- package/src/approval-gate.tsx +180 -0
- package/src/approval-pin-dialog.tsx +162 -0
- package/src/business/business.test.ts +164 -0
- package/src/business/catalog.ts +60 -0
- package/src/business/customer-picker.tsx +201 -0
- package/src/business/feedback.tsx +141 -0
- package/src/business/field-errors.ts +92 -0
- package/src/business/format.ts +151 -0
- package/src/business/index.ts +13 -0
- package/src/business/line-items-editor.tsx +259 -0
- package/src/business/line-items.ts +190 -0
- package/src/business/payment-capture.tsx +158 -0
- package/src/business/payment.ts +125 -0
- package/src/business/product-picker.tsx +171 -0
- package/src/business/product-search.ts +108 -0
- package/src/business/refund-destination.tsx +121 -0
- package/src/business/refund.ts +71 -0
- package/src/business/use-async-search.ts +43 -0
- package/src/document/__tests__/document-page.test.tsx +107 -0
- package/src/document/__tests__/evaluate.test.ts +140 -0
- package/src/document/action-bar.tsx +140 -0
- package/src/document/cancel-with-reason.tsx +214 -0
- package/src/document/document-header.tsx +140 -0
- package/src/document/document-page.tsx +268 -0
- package/src/document/document-timeline.tsx +66 -0
- package/src/document/evaluate.ts +180 -0
- package/src/document/format.ts +59 -0
- package/src/document/index.ts +28 -0
- package/src/document/registry.ts +39 -0
- package/src/document/smart-buttons.tsx +54 -0
- package/src/document/status-badge.tsx +44 -0
- package/src/document/types.ts +203 -0
- package/src/document/use-document-sources.ts +91 -0
- package/src/dynamic-columns.tsx +36 -6
- package/src/dynamic-kanban.tsx +6 -7
- package/src/dynamic-row-actions.tsx +15 -1
- package/src/index.ts +34 -0
- package/src/model-action-toolbar.tsx +28 -3
- package/src/requires-addon.tsx +160 -0
- package/src/server-error.ts +20 -0
- package/src/types.ts +24 -0
|
@@ -0,0 +1,133 @@
|
|
|
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
|
+
let seq = 0;
|
|
7
|
+
/** Clave de UI única en el proceso. */
|
|
8
|
+
export function newLineKey() {
|
|
9
|
+
seq += 1;
|
|
10
|
+
return `li_${Date.now().toString(36)}_${seq}`;
|
|
11
|
+
}
|
|
12
|
+
export function makeLine(partial = {}) {
|
|
13
|
+
return {
|
|
14
|
+
key: partial.key ?? newLineKey(),
|
|
15
|
+
kind: 'item',
|
|
16
|
+
description: '',
|
|
17
|
+
quantity: 1,
|
|
18
|
+
unit_price: 0,
|
|
19
|
+
discount: 0,
|
|
20
|
+
tax_rate: 0,
|
|
21
|
+
...partial,
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
export function computeLine(l) {
|
|
25
|
+
if (l.kind !== 'item')
|
|
26
|
+
return { gross: 0, discount: 0, net: 0, tax: 0, total: 0 };
|
|
27
|
+
const gross = roundMoney(toAmount(l.quantity) * toAmount(l.unit_price));
|
|
28
|
+
const discount = roundMoney(gross * (toAmount(l.discount) / 100));
|
|
29
|
+
const net = roundMoney(gross - discount);
|
|
30
|
+
const tax = roundMoney(net * toAmount(l.tax_rate));
|
|
31
|
+
return { gross, discount, net, tax, total: roundMoney(net + tax) };
|
|
32
|
+
}
|
|
33
|
+
export function computeTotals(lines) {
|
|
34
|
+
const t = { subtotal: 0, discount: 0, tax: 0, total: 0, units: 0 };
|
|
35
|
+
for (const l of lines) {
|
|
36
|
+
if (l.kind !== 'item')
|
|
37
|
+
continue;
|
|
38
|
+
const a = computeLine(l);
|
|
39
|
+
t.subtotal += a.gross;
|
|
40
|
+
t.discount += a.discount;
|
|
41
|
+
t.tax += a.tax;
|
|
42
|
+
t.units += toAmount(l.quantity);
|
|
43
|
+
}
|
|
44
|
+
t.subtotal = roundMoney(t.subtotal);
|
|
45
|
+
t.discount = roundMoney(t.discount);
|
|
46
|
+
t.tax = roundMoney(t.tax);
|
|
47
|
+
t.total = roundMoney(t.subtotal - t.discount + t.tax);
|
|
48
|
+
return t;
|
|
49
|
+
}
|
|
50
|
+
export function validateLineItems(lines, policy = {}) {
|
|
51
|
+
const { stockPolicy = 'warn', allowNegative = false, requireItems = true } = policy;
|
|
52
|
+
const errors = {};
|
|
53
|
+
const warnings = {};
|
|
54
|
+
let items = 0;
|
|
55
|
+
lines.forEach((l, i) => {
|
|
56
|
+
if (l.kind === 'section' || l.kind === 'note') {
|
|
57
|
+
if (!l.description.trim())
|
|
58
|
+
errors[`${i}.description`] = 'Escribe el texto';
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
items += 1;
|
|
62
|
+
const qty = toAmount(l.quantity);
|
|
63
|
+
const price = toAmount(l.unit_price);
|
|
64
|
+
const disc = toAmount(l.discount);
|
|
65
|
+
if (!l.description.trim() && !l.product_id)
|
|
66
|
+
errors[`${i}.description`] = 'Elige un producto o escribe una descripción';
|
|
67
|
+
if (qty === 0)
|
|
68
|
+
errors[`${i}.quantity`] = 'La cantidad debe ser distinta de cero';
|
|
69
|
+
else if (qty < 0 && !allowNegative)
|
|
70
|
+
errors[`${i}.quantity`] = 'La cantidad no puede ser negativa';
|
|
71
|
+
if (price < 0 && !allowNegative)
|
|
72
|
+
errors[`${i}.unit_price`] = 'El precio no puede ser negativo';
|
|
73
|
+
if (disc < 0 || disc > 100)
|
|
74
|
+
errors[`${i}.discount`] = 'El descuento debe estar entre 0 y 100%';
|
|
75
|
+
if (l.available != null && qty > l.available && stockPolicy !== 'allow') {
|
|
76
|
+
const msg = `Solo hay ${l.available} disponibles`;
|
|
77
|
+
if (stockPolicy === 'block')
|
|
78
|
+
errors[`${i}.quantity`] ??= msg;
|
|
79
|
+
else
|
|
80
|
+
warnings[`${i}.quantity`] = msg;
|
|
81
|
+
}
|
|
82
|
+
});
|
|
83
|
+
const form = requireItems && items === 0 ? 'Agrega al menos un renglón' : undefined;
|
|
84
|
+
return { errors, warnings, form, valid: !form && Object.keys(errors).length === 0 };
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* Forma que se manda al backend: números reales (nunca `""`), sin claves de UI,
|
|
88
|
+
* opcionales vacíos omitidos y `position` 1-based conservando el orden.
|
|
89
|
+
*/
|
|
90
|
+
export function serializeLineItems(lines) {
|
|
91
|
+
return lines.map((l, i) => {
|
|
92
|
+
const out = {
|
|
93
|
+
position: i + 1,
|
|
94
|
+
kind: l.kind,
|
|
95
|
+
description: l.description.trim(),
|
|
96
|
+
};
|
|
97
|
+
if (l.kind === 'item') {
|
|
98
|
+
out.quantity = toAmount(l.quantity);
|
|
99
|
+
out.unit_price = toAmount(l.unit_price);
|
|
100
|
+
out.discount = toAmount(l.discount);
|
|
101
|
+
out.tax_rate = toAmount(l.tax_rate);
|
|
102
|
+
for (const k of ['product_id', 'sku', 'technician_id', 'lot', 'dot']) {
|
|
103
|
+
const v = l[k];
|
|
104
|
+
if (typeof v === 'string' && v.trim())
|
|
105
|
+
out[k] = v.trim();
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
return out;
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
/** Inversa de `serializeLineItems`: filas del API → líneas editables (defaults seguros). */
|
|
112
|
+
export function parseLineItems(rows) {
|
|
113
|
+
if (!Array.isArray(rows))
|
|
114
|
+
return [];
|
|
115
|
+
return rows.map((r) => {
|
|
116
|
+
const o = (r ?? {});
|
|
117
|
+
const kind = o.kind === 'section' || o.kind === 'note' ? o.kind : 'item';
|
|
118
|
+
const str = (v) => (typeof v === 'string' && v ? v : undefined);
|
|
119
|
+
return makeLine({
|
|
120
|
+
kind,
|
|
121
|
+
product_id: str(o.product_id),
|
|
122
|
+
sku: str(o.sku),
|
|
123
|
+
description: typeof o.description === 'string' ? o.description : '',
|
|
124
|
+
quantity: o.quantity == null ? 1 : toAmount(o.quantity),
|
|
125
|
+
unit_price: toAmount(o.unit_price),
|
|
126
|
+
discount: toAmount(o.discount),
|
|
127
|
+
tax_rate: toAmount(o.tax_rate),
|
|
128
|
+
technician_id: str(o.technician_id),
|
|
129
|
+
lot: str(o.lot),
|
|
130
|
+
dot: str(o.dot),
|
|
131
|
+
});
|
|
132
|
+
});
|
|
133
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { serializePayment, type PaymentMethodOption, type PaymentPolicy, type PaymentTender } from './payment';
|
|
2
|
+
export interface PaymentCaptureProps {
|
|
3
|
+
/** Total a cobrar. */
|
|
4
|
+
total: number;
|
|
5
|
+
methods: PaymentMethodOption[];
|
|
6
|
+
value: PaymentTender[];
|
|
7
|
+
onChange: (tenders: PaymentTender[]) => void;
|
|
8
|
+
/** Confirmar cobro: recibe el payload ya serializado. Solo se llama si es válido. */
|
|
9
|
+
onSubmit?: (payload: ReturnType<typeof serializePayment>) => void;
|
|
10
|
+
policy?: PaymentPolicy;
|
|
11
|
+
/** Errores del servidor `"<índice>.<campo>"`. */
|
|
12
|
+
serverErrors?: Record<string, string>;
|
|
13
|
+
submitLabel?: string;
|
|
14
|
+
submitting?: boolean;
|
|
15
|
+
/** Capability requerida para cobrar (p. ej. `pos_orders.pagar`). Sin ella, botón oculto. */
|
|
16
|
+
submitPermission?: string;
|
|
17
|
+
currency?: string;
|
|
18
|
+
disabled?: boolean;
|
|
19
|
+
}
|
|
20
|
+
export declare function PaymentCapture({ total, methods, value, onChange, onSubmit, policy, serverErrors, submitLabel, submitting, submitPermission, currency, disabled, }: PaymentCaptureProps): import("react").JSX.Element;
|
|
21
|
+
//# sourceMappingURL=payment-capture.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"payment-capture.d.ts","sourceRoot":"","sources":["../../src/business/payment-capture.tsx"],"names":[],"mappings":"AAOA,OAAO,EAEH,gBAAgB,EAGhB,KAAK,mBAAmB,EACxB,KAAK,aAAa,EAClB,KAAK,aAAa,EACrB,MAAM,WAAW,CAAA;AAElB,MAAM,WAAW,mBAAmB;IAChC,sBAAsB;IACtB,KAAK,EAAE,MAAM,CAAA;IACb,OAAO,EAAE,mBAAmB,EAAE,CAAA;IAC9B,KAAK,EAAE,aAAa,EAAE,CAAA;IACtB,QAAQ,EAAE,CAAC,OAAO,EAAE,aAAa,EAAE,KAAK,IAAI,CAAA;IAC5C,qFAAqF;IACrF,QAAQ,CAAC,EAAE,CAAC,OAAO,EAAE,UAAU,CAAC,OAAO,gBAAgB,CAAC,KAAK,IAAI,CAAA;IACjE,MAAM,CAAC,EAAE,aAAa,CAAA;IACtB,iDAAiD;IACjD,YAAY,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAA;IACrC,WAAW,CAAC,EAAE,MAAM,CAAA;IACpB,UAAU,CAAC,EAAE,OAAO,CAAA;IACpB,4FAA4F;IAC5F,gBAAgB,CAAC,EAAE,MAAM,CAAA;IACzB,QAAQ,CAAC,EAAE,MAAM,CAAA;IACjB,QAAQ,CAAC,EAAE,OAAO,CAAA;CACrB;AAED,wBAAgB,cAAc,CAAC,EAC3B,KAAK,EACL,OAAO,EACP,KAAK,EACL,QAAQ,EACR,QAAQ,EACR,MAAM,EACN,YAAY,EACZ,WAAW,EACX,UAAU,EACV,gBAAgB,EAChB,QAAQ,EACR,QAAQ,GACX,EAAE,mBAAmB,+BA4GrB"}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
// PaymentCapture — cobro mixto con cambio, referencia y cuenta (benchmark §7).
|
|
3
|
+
import { useMemo } from 'react';
|
|
4
|
+
import { useTranslation } from 'react-i18next';
|
|
5
|
+
import { Trash2 } from 'lucide-react';
|
|
6
|
+
import { Button, Input } from '@asteby/metacore-ui';
|
|
7
|
+
import { useCan } from '../permissions-context';
|
|
8
|
+
import { useFormatter } from './format';
|
|
9
|
+
import { newTender, serializePayment, summarizePayment, validatePayment, } from './payment';
|
|
10
|
+
export function PaymentCapture({ total, methods, value, onChange, onSubmit, policy, serverErrors, submitLabel, submitting, submitPermission, currency, disabled, }) {
|
|
11
|
+
const { t } = useTranslation();
|
|
12
|
+
const can = useCan();
|
|
13
|
+
const fmt = useFormatter({ currency });
|
|
14
|
+
const summary = useMemo(() => summarizePayment(total, value, methods), [total, value, methods]);
|
|
15
|
+
const validation = useMemo(() => validatePayment(total, value, methods, policy), [total, value, methods, policy]);
|
|
16
|
+
const errors = { ...validation.errors, ...serverErrors };
|
|
17
|
+
const byId = new Map(methods.map((m) => [m.id, m]));
|
|
18
|
+
const mayPay = submitPermission ? can(submitPermission) : true;
|
|
19
|
+
const patch = (idx, p) => onChange(value.map((x, i) => (i === idx ? { ...x, ...p } : x)));
|
|
20
|
+
return (_jsxs("div", { "data-slot": "payment-capture", className: "space-y-3", children: [_jsx("div", { className: "flex flex-wrap gap-2", children: methods.map((m) => (_jsx(Button, { type: "button", size: "sm", variant: "outline", disabled: disabled, onClick: () => onChange([...value, newTender(m.id, summary.remaining)]), children: m.label }, m.id))) }), value.map((tender, i) => {
|
|
21
|
+
const m = byId.get(tender.method);
|
|
22
|
+
return (_jsxs("div", { className: "grid grid-cols-[1fr_8rem_auto] items-start gap-2 rounded-md border p-2", children: [_jsxs("div", { className: "space-y-1", children: [_jsx("p", { className: "text-sm font-medium", children: m?.label ?? tender.method }), m?.requiresReference && (_jsx(Input, { placeholder: t('payment.reference', { defaultValue: 'Referencia' }), value: tender.reference ?? '', disabled: disabled, "aria-invalid": !!errors[`${i}.reference`], onChange: (e) => patch(i, { reference: e.target.value }) })), m?.requiresAccount && (_jsxs("select", { className: "h-9 w-full rounded-md border bg-background px-2 text-sm", value: tender.account_id ?? '', disabled: disabled, onChange: (e) => patch(i, { account_id: e.target.value || undefined }), children: [_jsx("option", { value: "", children: t('payment.account', { defaultValue: 'Cuenta…' }) }), (m.accounts ?? []).map((a) => (_jsx("option", { value: a.id, children: a.label }, a.id)))] })), ['reference', 'account_id', 'method'].map((f) => errors[`${i}.${f}`] && _jsx("p", { className: "text-xs text-destructive", children: errors[`${i}.${f}`] }, f))] }), _jsxs("div", { children: [_jsx(Input, { inputMode: "decimal", className: "text-right", value: String(tender.amount), disabled: disabled, "aria-invalid": !!errors[`${i}.amount`], onChange: (e) => patch(i, { amount: Number(e.target.value.replace(',', '.')) || 0 }) }), errors[`${i}.amount`] && _jsx("p", { className: "mt-0.5 text-xs text-destructive", children: errors[`${i}.amount`] })] }), _jsx(Button, { type: "button", size: "icon", variant: "ghost", disabled: disabled, "aria-label": t('common.delete', { defaultValue: 'Eliminar' }), onClick: () => onChange(value.filter((_, j) => j !== i)), children: _jsx(Trash2, { className: "size-4" }) })] }, tender.key));
|
|
23
|
+
}), _jsxs("dl", { className: "space-y-0.5 text-sm tabular-nums", children: [_jsxs("div", { className: "flex justify-between", children: [_jsx("dt", { className: "text-muted-foreground", children: t('payment.total', { defaultValue: 'Total' }) }), _jsx("dd", { children: fmt.money(summary.total) })] }), _jsxs("div", { className: "flex justify-between", children: [_jsx("dt", { className: "text-muted-foreground", children: t('payment.paid', { defaultValue: 'Pagado' }) }), _jsx("dd", { children: fmt.money(summary.paid) })] }), summary.remaining > 0 && _jsxs("div", { className: "flex justify-between font-medium", children: [_jsx("dt", { children: t('payment.remaining', { defaultValue: 'Pendiente' }) }), _jsx("dd", { children: fmt.money(summary.remaining) })] }), summary.change > 0 && _jsxs("div", { className: "flex justify-between font-semibold", children: [_jsx("dt", { children: t('payment.change', { defaultValue: 'Cambio' }) }), _jsx("dd", { children: fmt.money(summary.change) })] })] }), validation.form && value.length > 0 && _jsx("p", { role: "alert", className: "text-sm text-destructive", children: validation.form }), onSubmit && mayPay && (_jsx(Button, { type: "button", className: "w-full", disabled: disabled || submitting || !validation.valid, onClick: () => onSubmit(serializePayment(value, methods, total)), children: submitLabel ?? t('payment.submit', { defaultValue: 'Cobrar' }) }))] }));
|
|
24
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/** Método de pago configurable por org (el catálogo NO se hardcodea en el SDK). */
|
|
2
|
+
export interface PaymentMethodOption {
|
|
3
|
+
id: string;
|
|
4
|
+
label: string;
|
|
5
|
+
/** Efectivo: admite recibir de más y devolver cambio. */
|
|
6
|
+
cash?: boolean;
|
|
7
|
+
requiresReference?: boolean;
|
|
8
|
+
requiresAccount?: boolean;
|
|
9
|
+
/** Tope por método (saldo de cuenta de cliente, monedero). */
|
|
10
|
+
maxAmount?: number;
|
|
11
|
+
/** Cuentas/cajas destino elegibles (requiresAccount). */
|
|
12
|
+
accounts?: Array<{
|
|
13
|
+
id: string;
|
|
14
|
+
label: string;
|
|
15
|
+
}>;
|
|
16
|
+
}
|
|
17
|
+
export interface PaymentTender {
|
|
18
|
+
key: string;
|
|
19
|
+
method: string;
|
|
20
|
+
amount: number;
|
|
21
|
+
reference?: string;
|
|
22
|
+
account_id?: string;
|
|
23
|
+
}
|
|
24
|
+
export interface PaymentSummary {
|
|
25
|
+
total: number;
|
|
26
|
+
paid: number;
|
|
27
|
+
/** Lo que falta por cobrar (>= 0). */
|
|
28
|
+
remaining: number;
|
|
29
|
+
/** Cambio a entregar: exceso cubierto por efectivo (>= 0). */
|
|
30
|
+
change: number;
|
|
31
|
+
/** Exceso en métodos no-efectivo (no se puede devolver): error. */
|
|
32
|
+
overpaid: number;
|
|
33
|
+
settled: boolean;
|
|
34
|
+
}
|
|
35
|
+
export declare function newTender(method: string, amount?: number): PaymentTender;
|
|
36
|
+
export declare function summarizePayment(total: number, tenders: PaymentTender[], methods: PaymentMethodOption[]): PaymentSummary;
|
|
37
|
+
export interface PaymentValidation {
|
|
38
|
+
/** Errores por renglón de pago `"<índice>.<campo>"`. */
|
|
39
|
+
errors: Record<string, string>;
|
|
40
|
+
/** Error general. */
|
|
41
|
+
form?: string;
|
|
42
|
+
valid: boolean;
|
|
43
|
+
}
|
|
44
|
+
export interface PaymentPolicy {
|
|
45
|
+
/** Permite cobro parcial (anticipo/abono). Default false. */
|
|
46
|
+
allowPartial?: boolean;
|
|
47
|
+
}
|
|
48
|
+
export declare function validatePayment(total: number, tenders: PaymentTender[], methods: PaymentMethodOption[], policy?: PaymentPolicy): PaymentValidation;
|
|
49
|
+
/** Payload hacia el backend: montos reales; el cambio no es un pago. */
|
|
50
|
+
export declare function serializePayment(tenders: PaymentTender[], methods: PaymentMethodOption[], total: number): {
|
|
51
|
+
payments: {
|
|
52
|
+
account_id?: string | undefined;
|
|
53
|
+
reference?: string | undefined;
|
|
54
|
+
method: string;
|
|
55
|
+
amount: number;
|
|
56
|
+
}[];
|
|
57
|
+
paid: number;
|
|
58
|
+
change: number;
|
|
59
|
+
remaining: number;
|
|
60
|
+
};
|
|
61
|
+
//# sourceMappingURL=payment.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"payment.d.ts","sourceRoot":"","sources":["../../src/business/payment.ts"],"names":[],"mappings":"AAIA,mFAAmF;AACnF,MAAM,WAAW,mBAAmB;IAChC,EAAE,EAAE,MAAM,CAAA;IACV,KAAK,EAAE,MAAM,CAAA;IACb,yDAAyD;IACzD,IAAI,CAAC,EAAE,OAAO,CAAA;IACd,iBAAiB,CAAC,EAAE,OAAO,CAAA;IAC3B,eAAe,CAAC,EAAE,OAAO,CAAA;IACzB,8DAA8D;IAC9D,SAAS,CAAC,EAAE,MAAM,CAAA;IAClB,yDAAyD;IACzD,QAAQ,CAAC,EAAE,KAAK,CAAC;QAAE,EAAE,EAAE,MAAM,CAAC;QAAC,KAAK,EAAE,MAAM,CAAA;KAAE,CAAC,CAAA;CAClD;AAED,MAAM,WAAW,aAAa;IAC1B,GAAG,EAAE,MAAM,CAAA;IACX,MAAM,EAAE,MAAM,CAAA;IACd,MAAM,EAAE,MAAM,CAAA;IACd,SAAS,CAAC,EAAE,MAAM,CAAA;IAClB,UAAU,CAAC,EAAE,MAAM,CAAA;CACtB;AAED,MAAM,WAAW,cAAc;IAC3B,KAAK,EAAE,MAAM,CAAA;IACb,IAAI,EAAE,MAAM,CAAA;IACZ,sCAAsC;IACtC,SAAS,EAAE,MAAM,CAAA;IACjB,8DAA8D;IAC9D,MAAM,EAAE,MAAM,CAAA;IACd,mEAAmE;IACnE,QAAQ,EAAE,MAAM,CAAA;IAChB,OAAO,EAAE,OAAO,CAAA;CACnB;AAGD,wBAAgB,SAAS,CAAC,MAAM,EAAE,MAAM,EAAE,MAAM,SAAI,GAAG,aAAa,CAGnE;AAED,wBAAgB,gBAAgB,CAC5B,KAAK,EAAE,MAAM,EACb,OAAO,EAAE,aAAa,EAAE,EACxB,OAAO,EAAE,mBAAmB,EAAE,GAC/B,cAAc,CAqBhB;AAED,MAAM,WAAW,iBAAiB;IAC9B,wDAAwD;IACxD,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAA;IAC9B,qBAAqB;IACrB,IAAI,CAAC,EAAE,MAAM,CAAA;IACb,KAAK,EAAE,OAAO,CAAA;CACjB;AAED,MAAM,WAAW,aAAa;IAC1B,6DAA6D;IAC7D,YAAY,CAAC,EAAE,OAAO,CAAA;CACzB;AAED,wBAAgB,eAAe,CAC3B,KAAK,EAAE,MAAM,EACb,OAAO,EAAE,aAAa,EAAE,EACxB,OAAO,EAAE,mBAAmB,EAAE,EAC9B,MAAM,GAAE,aAAkB,GAC3B,iBAAiB,CAkBnB;AAED,wEAAwE;AACxE,wBAAgB,gBAAgB,CAAC,OAAO,EAAE,aAAa,EAAE,EAAE,OAAO,EAAE,mBAAmB,EAAE,EAAE,KAAK,EAAE,MAAM;;;;;;;;;;EAcvG"}
|
|
@@ -0,0 +1,74 @@
|
|
|
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
|
+
let seq = 0;
|
|
5
|
+
export function newTender(method, amount = 0) {
|
|
6
|
+
seq += 1;
|
|
7
|
+
return { key: `pt_${Date.now().toString(36)}_${seq}`, method, amount };
|
|
8
|
+
}
|
|
9
|
+
export function summarizePayment(total, tenders, methods) {
|
|
10
|
+
const byId = new Map(methods.map((m) => [m.id, m]));
|
|
11
|
+
let paid = 0;
|
|
12
|
+
let cash = 0;
|
|
13
|
+
for (const t of tenders) {
|
|
14
|
+
const a = Math.max(0, toAmount(t.amount));
|
|
15
|
+
paid += a;
|
|
16
|
+
if (byId.get(t.method)?.cash)
|
|
17
|
+
cash += a;
|
|
18
|
+
}
|
|
19
|
+
paid = roundMoney(paid);
|
|
20
|
+
const tot = roundMoney(total);
|
|
21
|
+
const excess = roundMoney(Math.max(0, paid - tot));
|
|
22
|
+
const change = roundMoney(Math.min(excess, cash));
|
|
23
|
+
return {
|
|
24
|
+
total: tot,
|
|
25
|
+
paid,
|
|
26
|
+
remaining: roundMoney(Math.max(0, tot - paid)),
|
|
27
|
+
change,
|
|
28
|
+
overpaid: roundMoney(excess - change),
|
|
29
|
+
settled: paid >= tot,
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
export function validatePayment(total, tenders, methods, policy = {}) {
|
|
33
|
+
const byId = new Map(methods.map((m) => [m.id, m]));
|
|
34
|
+
const errors = {};
|
|
35
|
+
tenders.forEach((t, i) => {
|
|
36
|
+
const m = byId.get(t.method);
|
|
37
|
+
const a = toAmount(t.amount);
|
|
38
|
+
if (!m)
|
|
39
|
+
errors[`${i}.method`] = 'Método de pago no disponible';
|
|
40
|
+
if (a <= 0)
|
|
41
|
+
errors[`${i}.amount`] = 'Captura un monto mayor a cero';
|
|
42
|
+
if (m?.maxAmount != null && a > m.maxAmount)
|
|
43
|
+
errors[`${i}.amount`] = `El máximo para ${m.label} es ${m.maxAmount}`;
|
|
44
|
+
if (m?.requiresReference && !t.reference?.trim())
|
|
45
|
+
errors[`${i}.reference`] = 'La referencia es obligatoria';
|
|
46
|
+
if (m?.requiresAccount && !t.account_id)
|
|
47
|
+
errors[`${i}.account_id`] = 'Elige la cuenta';
|
|
48
|
+
});
|
|
49
|
+
const s = summarizePayment(total, tenders, methods);
|
|
50
|
+
let form;
|
|
51
|
+
if (tenders.length === 0)
|
|
52
|
+
form = 'Agrega al menos un pago';
|
|
53
|
+
else if (s.overpaid > 0)
|
|
54
|
+
form = 'Solo el efectivo puede exceder el total (se devuelve como cambio)';
|
|
55
|
+
else if (!policy.allowPartial && !s.settled)
|
|
56
|
+
form = 'El pago no cubre el total';
|
|
57
|
+
return { errors, form, valid: !form && Object.keys(errors).length === 0 };
|
|
58
|
+
}
|
|
59
|
+
/** Payload hacia el backend: montos reales; el cambio no es un pago. */
|
|
60
|
+
export function serializePayment(tenders, methods, total) {
|
|
61
|
+
const s = summarizePayment(total, tenders, methods);
|
|
62
|
+
return {
|
|
63
|
+
payments: tenders.map((t) => ({
|
|
64
|
+
method: t.method,
|
|
65
|
+
// Monto entregado por el cliente; el cambio va aparte en `change`.
|
|
66
|
+
amount: roundMoney(toAmount(t.amount)),
|
|
67
|
+
...(t.reference?.trim() ? { reference: t.reference.trim() } : {}),
|
|
68
|
+
...(t.account_id ? { account_id: t.account_id } : {}),
|
|
69
|
+
})),
|
|
70
|
+
paid: s.paid,
|
|
71
|
+
change: s.change,
|
|
72
|
+
remaining: s.remaining,
|
|
73
|
+
};
|
|
74
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { type ProductQuery, type ProductResult, type ProductVariant } from './product-search';
|
|
2
|
+
export interface ProductPickerProps {
|
|
3
|
+
/**
|
|
4
|
+
* Búsqueda: recibe la consulta ya clasificada (`barcode` | `tire_size` |
|
|
5
|
+
* `text`) para que el backend elija el índice. Debe ser estable (useCallback).
|
|
6
|
+
*/
|
|
7
|
+
search: (query: Exclude<ProductQuery, {
|
|
8
|
+
kind: 'empty';
|
|
9
|
+
}>, signal: AbortSignal) => Promise<ProductResult[]>;
|
|
10
|
+
/** Se emite al elegir un producto o una variante concreta. */
|
|
11
|
+
onSelect: (product: ProductResult, variant?: ProductVariant) => void;
|
|
12
|
+
/** Almacén para mostrar/validar existencias; sin él, se suma el total. */
|
|
13
|
+
warehouseId?: string;
|
|
14
|
+
/** Un código de barras con un único resultado se selecciona solo al pulsar Enter. Default true. */
|
|
15
|
+
autoSelectBarcode?: boolean;
|
|
16
|
+
/** Permite elegir productos sin existencia (se marcan). Default true. */
|
|
17
|
+
allowOutOfStock?: boolean;
|
|
18
|
+
placeholder?: string;
|
|
19
|
+
autoFocus?: boolean;
|
|
20
|
+
currency?: string;
|
|
21
|
+
disabled?: boolean;
|
|
22
|
+
}
|
|
23
|
+
export declare function ProductPicker({ search, onSelect, warehouseId, autoSelectBarcode, allowOutOfStock, placeholder, autoFocus, currency, disabled, }: ProductPickerProps): import("react").JSX.Element;
|
|
24
|
+
//# sourceMappingURL=product-picker.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"product-picker.d.ts","sourceRoot":"","sources":["../../src/business/product-picker.tsx"],"names":[],"mappings":"AAQA,OAAO,EAGH,KAAK,YAAY,EACjB,KAAK,aAAa,EAClB,KAAK,cAAc,EACtB,MAAM,kBAAkB,CAAA;AAGzB,MAAM,WAAW,kBAAkB;IAC/B;;;OAGG;IACH,MAAM,EAAE,CAAC,KAAK,EAAE,OAAO,CAAC,YAAY,EAAE;QAAE,IAAI,EAAE,OAAO,CAAA;KAAE,CAAC,EAAE,MAAM,EAAE,WAAW,KAAK,OAAO,CAAC,aAAa,EAAE,CAAC,CAAA;IAC1G,8DAA8D;IAC9D,QAAQ,EAAE,CAAC,OAAO,EAAE,aAAa,EAAE,OAAO,CAAC,EAAE,cAAc,KAAK,IAAI,CAAA;IACpE,0EAA0E;IAC1E,WAAW,CAAC,EAAE,MAAM,CAAA;IACpB,mGAAmG;IACnG,iBAAiB,CAAC,EAAE,OAAO,CAAA;IAC3B,yEAAyE;IACzE,eAAe,CAAC,EAAE,OAAO,CAAA;IACzB,WAAW,CAAC,EAAE,MAAM,CAAA;IACpB,SAAS,CAAC,EAAE,OAAO,CAAA;IACnB,QAAQ,CAAC,EAAE,MAAM,CAAA;IACjB,QAAQ,CAAC,EAAE,OAAO,CAAA;CACrB;AAED,wBAAgB,aAAa,CAAC,EAC1B,MAAM,EACN,QAAQ,EACR,WAAW,EACX,iBAAwB,EACxB,eAAsB,EACtB,WAAW,EACX,SAAS,EACT,QAAQ,EACR,QAAQ,GACX,EAAE,kBAAkB,+BA2HpB"}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
// ProductPicker — una sola búsqueda para productos, llantas y variantes
|
|
3
|
+
// (medida «205/55R16», SKU, código de barras, clave de proveedor). Benchmark §7.
|
|
4
|
+
import { useCallback, useState } from 'react';
|
|
5
|
+
import { useTranslation } from 'react-i18next';
|
|
6
|
+
import { Search } from 'lucide-react';
|
|
7
|
+
import { Badge, Input } from '@asteby/metacore-ui';
|
|
8
|
+
import { useFormatter } from './format';
|
|
9
|
+
import { useAsyncSearch } from './use-async-search';
|
|
10
|
+
import { availableStock, parseProductQuery, } from './product-search';
|
|
11
|
+
import { EmptyState } from './feedback';
|
|
12
|
+
export function ProductPicker({ search, onSelect, warehouseId, autoSelectBarcode = true, allowOutOfStock = true, placeholder, autoFocus, currency, disabled, }) {
|
|
13
|
+
const { t } = useTranslation();
|
|
14
|
+
const fmt = useFormatter({ currency });
|
|
15
|
+
const [text, setText] = useState('');
|
|
16
|
+
const parsed = parseProductQuery(text);
|
|
17
|
+
// Un código de barras es exacto: no se espera a 2 caracteres ni se debouncea mucho.
|
|
18
|
+
const run = useCallback((q, signal) => {
|
|
19
|
+
const p = parseProductQuery(q);
|
|
20
|
+
return p.kind === 'empty' ? Promise.resolve([]) : search(p, signal);
|
|
21
|
+
}, [search]);
|
|
22
|
+
const { results, loading, error } = useAsyncSearch(text, run, {
|
|
23
|
+
minChars: parsed.kind === 'barcode' ? 8 : 2,
|
|
24
|
+
delay: parsed.kind === 'barcode' ? 0 : 250,
|
|
25
|
+
});
|
|
26
|
+
const pick = (p, v) => {
|
|
27
|
+
const stock = availableStock(v ?? p, warehouseId);
|
|
28
|
+
if (!allowOutOfStock && stock != null && stock <= 0)
|
|
29
|
+
return;
|
|
30
|
+
onSelect(p, v);
|
|
31
|
+
setText('');
|
|
32
|
+
};
|
|
33
|
+
return (_jsxs("div", { "data-slot": "product-picker", className: "space-y-2", children: [_jsxs("div", { className: "relative", children: [_jsx(Search, { className: "pointer-events-none absolute left-2.5 top-2.5 size-4 text-muted-foreground", "aria-hidden": true }), _jsx(Input, { autoFocus: autoFocus, disabled: disabled, className: "pl-8", value: text, placeholder: placeholder ?? t('productPicker.placeholder', { defaultValue: 'Producto, medida (205/55R16), SKU o código de barras' }), onChange: (e) => setText(e.target.value), onKeyDown: (e) => {
|
|
34
|
+
if (e.key === 'Enter' && autoSelectBarcode && parsed.kind === 'barcode' && results.length === 1) {
|
|
35
|
+
e.preventDefault();
|
|
36
|
+
const only = results[0];
|
|
37
|
+
const exact = only.variants?.find((v) => v.barcode === parsed.barcode);
|
|
38
|
+
pick(only, exact);
|
|
39
|
+
}
|
|
40
|
+
} })] }), parsed.kind === 'tire_size' && (_jsxs("p", { className: "text-xs text-muted-foreground", children: [t('productPicker.tireSize', { defaultValue: 'Medida de llanta' }), ": ", parsed.tire.normalized] })), loading && _jsx("p", { className: "text-sm text-muted-foreground", children: t('common.searching', { defaultValue: 'Buscando…' }) }), error && !loading && (_jsx("p", { role: "alert", className: "text-sm text-destructive", children: t('productPicker.error', { defaultValue: 'No se pudo buscar. Revisa tu conexión e inténtalo de nuevo.' }) })), !loading && !error && parsed.kind !== 'empty' && text.trim().length >= 2 && results.length === 0 && (_jsx(EmptyState, { title: t('productPicker.empty', { defaultValue: 'No encontramos ese producto' }), description: t('productPicker.emptyHint', { defaultValue: 'Prueba con otra medida, SKU o código de barras.' }) })), results.length > 0 && (_jsx("ul", { role: "listbox", className: "max-h-72 divide-y overflow-auto rounded-md border", children: results.map((p) => {
|
|
41
|
+
const stock = availableStock(p, warehouseId);
|
|
42
|
+
const hasVariants = (p.variants?.length ?? 0) > 0;
|
|
43
|
+
return (_jsxs("li", { role: "option", "aria-selected": false, className: "px-2 py-1.5", children: [_jsxs("button", { type: "button", className: "flex w-full items-center justify-between gap-2 text-left disabled:opacity-50", disabled: hasVariants || (!allowOutOfStock && stock != null && stock <= 0), onClick: () => pick(p), children: [_jsxs("span", { children: [_jsx("span", { className: "block text-sm font-medium", children: p.name }), _jsx("span", { className: "block text-xs text-muted-foreground", children: [p.sku, p.tire?.normalized, p.supplier_sku && `Prov. ${p.supplier_sku}`].filter(Boolean).join(' · ') })] }), _jsxs("span", { className: "flex items-center gap-2", children: [stock != null && (_jsx(Badge, { variant: stock > 0 ? 'success' : 'danger', children: stock > 0 ? t('productPicker.inStock', { defaultValue: '{{n}} disp.', n: stock }) : t('productPicker.outOfStock', { defaultValue: 'Sin existencia' }) })), p.price != null && !hasVariants && _jsx("span", { className: "text-sm tabular-nums", children: fmt.money(p.price) })] })] }), hasVariants && (_jsx("ul", { className: "mt-1 space-y-0.5 pl-3", children: p.variants.map((v) => {
|
|
44
|
+
const vs = availableStock(v, warehouseId);
|
|
45
|
+
return (_jsx("li", { children: _jsxs("button", { type: "button", 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", disabled: !allowOutOfStock && vs != null && vs <= 0, onClick: () => pick(p, v), children: [_jsx("span", { children: v.label }), _jsxs("span", { className: "flex items-center gap-2", children: [vs != null && _jsx(Badge, { variant: vs > 0 ? 'success' : 'danger', children: vs }), (v.price ?? p.price) != null && _jsx("span", { className: "tabular-nums", children: fmt.money(v.price ?? p.price) })] })] }) }, v.id));
|
|
46
|
+
}) }))] }, p.id));
|
|
47
|
+
}) }))] }));
|
|
48
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import type { LineItem } from './line-items';
|
|
2
|
+
export interface TireSize {
|
|
3
|
+
width: number;
|
|
4
|
+
ratio: number;
|
|
5
|
+
rim: number;
|
|
6
|
+
/** Forma canónica `205/55R16`. */
|
|
7
|
+
normalized: string;
|
|
8
|
+
}
|
|
9
|
+
export type ProductQuery = {
|
|
10
|
+
kind: 'empty';
|
|
11
|
+
raw: string;
|
|
12
|
+
} | {
|
|
13
|
+
kind: 'barcode';
|
|
14
|
+
raw: string;
|
|
15
|
+
barcode: string;
|
|
16
|
+
} | {
|
|
17
|
+
kind: 'tire_size';
|
|
18
|
+
raw: string;
|
|
19
|
+
tire: TireSize;
|
|
20
|
+
} | {
|
|
21
|
+
kind: 'text';
|
|
22
|
+
raw: string;
|
|
23
|
+
text: string;
|
|
24
|
+
};
|
|
25
|
+
export declare function parseTireSize(input: string): TireSize | null;
|
|
26
|
+
export declare function parseProductQuery(raw: string): ProductQuery;
|
|
27
|
+
export interface StockByWarehouse {
|
|
28
|
+
warehouse_id: string;
|
|
29
|
+
warehouse_name?: string;
|
|
30
|
+
available: number;
|
|
31
|
+
}
|
|
32
|
+
export interface ProductVariant {
|
|
33
|
+
id: string;
|
|
34
|
+
/** «Rin 16 · 205/55R16 · 91V». */
|
|
35
|
+
label: string;
|
|
36
|
+
sku?: string;
|
|
37
|
+
barcode?: string;
|
|
38
|
+
price?: number;
|
|
39
|
+
stock?: StockByWarehouse[];
|
|
40
|
+
}
|
|
41
|
+
export interface ProductResult {
|
|
42
|
+
id: string;
|
|
43
|
+
name: string;
|
|
44
|
+
sku?: string;
|
|
45
|
+
barcode?: string;
|
|
46
|
+
/** Clave del proveedor (búsqueda por SKU del proveedor). */
|
|
47
|
+
supplier_sku?: string;
|
|
48
|
+
kind?: 'product' | 'tire' | 'service' | 'bundle';
|
|
49
|
+
price?: number;
|
|
50
|
+
tax_rate?: number;
|
|
51
|
+
tire?: TireSize;
|
|
52
|
+
variants?: ProductVariant[];
|
|
53
|
+
stock?: StockByWarehouse[];
|
|
54
|
+
/** Paquetes automáticos: ids/nombres que se sugieren junto al producto. */
|
|
55
|
+
bundle_items?: Array<{
|
|
56
|
+
product_id: string;
|
|
57
|
+
name: string;
|
|
58
|
+
quantity: number;
|
|
59
|
+
}>;
|
|
60
|
+
}
|
|
61
|
+
/** Existencia disponible: de la variante o del producto, en un almacén o en total. */
|
|
62
|
+
export declare function availableStock(source: {
|
|
63
|
+
stock?: StockByWarehouse[];
|
|
64
|
+
}, warehouseId?: string): number | undefined;
|
|
65
|
+
/** Producto (+ variante) → renglón listo para <LineItemsEditor>. */
|
|
66
|
+
export declare function productToLine(p: ProductResult, variant?: ProductVariant, opts?: {
|
|
67
|
+
warehouseId?: string;
|
|
68
|
+
quantity?: number;
|
|
69
|
+
}): LineItem;
|
|
70
|
+
//# sourceMappingURL=product-search.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"product-search.d.ts","sourceRoot":"","sources":["../../src/business/product-search.ts"],"names":[],"mappings":"AAIA,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,cAAc,CAAA;AAG5C,MAAM,WAAW,QAAQ;IACrB,KAAK,EAAE,MAAM,CAAA;IACb,KAAK,EAAE,MAAM,CAAA;IACb,GAAG,EAAE,MAAM,CAAA;IACX,kCAAkC;IAClC,UAAU,EAAE,MAAM,CAAA;CACrB;AAED,MAAM,MAAM,YAAY,GAClB;IAAE,IAAI,EAAE,OAAO,CAAC;IAAC,GAAG,EAAE,MAAM,CAAA;CAAE,GAC9B;IAAE,IAAI,EAAE,SAAS,CAAC;IAAC,GAAG,EAAE,MAAM,CAAC;IAAC,OAAO,EAAE,MAAM,CAAA;CAAE,GACjD;IAAE,IAAI,EAAE,WAAW,CAAC;IAAC,GAAG,EAAE,MAAM,CAAC;IAAC,IAAI,EAAE,QAAQ,CAAA;CAAE,GAClD;IAAE,IAAI,EAAE,MAAM,CAAC;IAAC,GAAG,EAAE,MAAM,CAAC;IAAC,IAAI,EAAE,MAAM,CAAA;CAAE,CAAA;AAMjD,wBAAgB,aAAa,CAAC,KAAK,EAAE,MAAM,GAAG,QAAQ,GAAG,IAAI,CAW5D;AAED,wBAAgB,iBAAiB,CAAC,GAAG,EAAE,MAAM,GAAG,YAAY,CAO3D;AAED,MAAM,WAAW,gBAAgB;IAC7B,YAAY,EAAE,MAAM,CAAA;IACpB,cAAc,CAAC,EAAE,MAAM,CAAA;IACvB,SAAS,EAAE,MAAM,CAAA;CACpB;AAED,MAAM,WAAW,cAAc;IAC3B,EAAE,EAAE,MAAM,CAAA;IACV,kCAAkC;IAClC,KAAK,EAAE,MAAM,CAAA;IACb,GAAG,CAAC,EAAE,MAAM,CAAA;IACZ,OAAO,CAAC,EAAE,MAAM,CAAA;IAChB,KAAK,CAAC,EAAE,MAAM,CAAA;IACd,KAAK,CAAC,EAAE,gBAAgB,EAAE,CAAA;CAC7B;AAED,MAAM,WAAW,aAAa;IAC1B,EAAE,EAAE,MAAM,CAAA;IACV,IAAI,EAAE,MAAM,CAAA;IACZ,GAAG,CAAC,EAAE,MAAM,CAAA;IACZ,OAAO,CAAC,EAAE,MAAM,CAAA;IAChB,4DAA4D;IAC5D,YAAY,CAAC,EAAE,MAAM,CAAA;IACrB,IAAI,CAAC,EAAE,SAAS,GAAG,MAAM,GAAG,SAAS,GAAG,QAAQ,CAAA;IAChD,KAAK,CAAC,EAAE,MAAM,CAAA;IACd,QAAQ,CAAC,EAAE,MAAM,CAAA;IACjB,IAAI,CAAC,EAAE,QAAQ,CAAA;IACf,QAAQ,CAAC,EAAE,cAAc,EAAE,CAAA;IAC3B,KAAK,CAAC,EAAE,gBAAgB,EAAE,CAAA;IAC1B,2EAA2E;IAC3E,YAAY,CAAC,EAAE,KAAK,CAAC;QAAE,UAAU,EAAE,MAAM,CAAC;QAAC,IAAI,EAAE,MAAM,CAAC;QAAC,QAAQ,EAAE,MAAM,CAAA;KAAE,CAAC,CAAA;CAC/E;AAED,sFAAsF;AACtF,wBAAgB,cAAc,CAC1B,MAAM,EAAE;IAAE,KAAK,CAAC,EAAE,gBAAgB,EAAE,CAAA;CAAE,EACtC,WAAW,CAAC,EAAE,MAAM,GACrB,MAAM,GAAG,SAAS,CAKpB;AAED,oEAAoE;AACpE,wBAAgB,aAAa,CACzB,CAAC,EAAE,aAAa,EAChB,OAAO,CAAC,EAAE,cAAc,EACxB,IAAI,GAAE;IAAE,WAAW,CAAC,EAAE,MAAM,CAAC;IAAC,QAAQ,CAAC,EAAE,MAAM,CAAA;CAAO,GACvD,QAAQ,CAWV"}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { makeLine } from './line-items';
|
|
2
|
+
// 205/55R16, 205 55 16, 205-55-16, 205/55ZR16, 2055516 (sin separadores).
|
|
3
|
+
const TIRE_RE = /^(\d{3})\s*[\/\-x\s]?\s*(\d{2})\s*[\/\-x\s]?\s*(?:z?r|-)?\s*(\d{2})$/i;
|
|
4
|
+
const BARCODE_RE = /^\d{8,14}$/;
|
|
5
|
+
export function parseTireSize(input) {
|
|
6
|
+
const s = input.trim();
|
|
7
|
+
// 7 dígitos pegados también son medida, pero 8+ dígitos es código de barras.
|
|
8
|
+
if (BARCODE_RE.test(s))
|
|
9
|
+
return null;
|
|
10
|
+
const m = TIRE_RE.exec(s);
|
|
11
|
+
if (!m)
|
|
12
|
+
return null;
|
|
13
|
+
const width = Number(m[1]);
|
|
14
|
+
const ratio = Number(m[2]);
|
|
15
|
+
const rim = Number(m[3]);
|
|
16
|
+
if (width < 125 || width > 395 || ratio < 25 || ratio > 95 || rim < 10 || rim > 26)
|
|
17
|
+
return null;
|
|
18
|
+
return { width, ratio, rim, normalized: `${width}/${ratio}R${rim}` };
|
|
19
|
+
}
|
|
20
|
+
export function parseProductQuery(raw) {
|
|
21
|
+
const s = raw.trim();
|
|
22
|
+
if (!s)
|
|
23
|
+
return { kind: 'empty', raw };
|
|
24
|
+
if (BARCODE_RE.test(s))
|
|
25
|
+
return { kind: 'barcode', raw, barcode: s };
|
|
26
|
+
const tire = parseTireSize(s);
|
|
27
|
+
if (tire)
|
|
28
|
+
return { kind: 'tire_size', raw, tire };
|
|
29
|
+
return { kind: 'text', raw, text: s };
|
|
30
|
+
}
|
|
31
|
+
/** Existencia disponible: de la variante o del producto, en un almacén o en total. */
|
|
32
|
+
export function availableStock(source, warehouseId) {
|
|
33
|
+
if (!source.stock)
|
|
34
|
+
return undefined;
|
|
35
|
+
return source.stock
|
|
36
|
+
.filter((s) => !warehouseId || s.warehouse_id === warehouseId)
|
|
37
|
+
.reduce((n, s) => n + s.available, 0);
|
|
38
|
+
}
|
|
39
|
+
/** Producto (+ variante) → renglón listo para <LineItemsEditor>. */
|
|
40
|
+
export function productToLine(p, variant, opts = {}) {
|
|
41
|
+
const src = variant ?? p;
|
|
42
|
+
return makeLine({
|
|
43
|
+
product_id: variant?.id ?? p.id,
|
|
44
|
+
sku: variant?.sku ?? p.sku,
|
|
45
|
+
description: variant ? `${p.name} · ${variant.label}` : p.name,
|
|
46
|
+
quantity: opts.quantity ?? 1,
|
|
47
|
+
unit_price: variant?.price ?? p.price ?? 0,
|
|
48
|
+
tax_rate: p.tax_rate ?? 0,
|
|
49
|
+
available: availableStock(src, opts.warehouseId) ?? availableStock(p, opts.warehouseId),
|
|
50
|
+
});
|
|
51
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { type RefundAllocation, type RefundDestinationOption, type RefundValidation } from './refund';
|
|
2
|
+
export interface RefundDestinationProps {
|
|
3
|
+
/** Monto total a reembolsar. */
|
|
4
|
+
total: number;
|
|
5
|
+
value: RefundAllocation[];
|
|
6
|
+
onChange: (allocations: RefundAllocation[]) => void;
|
|
7
|
+
/** Destinos ofrecidos (default: los cinco). Filtra/limita según el documento. */
|
|
8
|
+
options?: RefundDestinationOption[];
|
|
9
|
+
/** Emite la validación tras cada cambio (para bloquear «Confirmar»). */
|
|
10
|
+
onValidate?: (v: RefundValidation) => void;
|
|
11
|
+
/** Errores del servidor `"<índice>.<campo>"`. */
|
|
12
|
+
serverErrors?: Record<string, string>;
|
|
13
|
+
/** Permite repartir entre varios destinos. Default false (un solo destino). */
|
|
14
|
+
allowSplit?: boolean;
|
|
15
|
+
currency?: string;
|
|
16
|
+
disabled?: boolean;
|
|
17
|
+
}
|
|
18
|
+
export declare function RefundDestination({ total, value, onChange, options, onValidate, serverErrors, allowSplit, currency, disabled, }: RefundDestinationProps): import("react").JSX.Element;
|
|
19
|
+
//# sourceMappingURL=refund-destination.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"refund-destination.d.ts","sourceRoot":"","sources":["../../src/business/refund-destination.tsx"],"names":[],"mappings":"AAMA,OAAO,EAIH,KAAK,gBAAgB,EAErB,KAAK,uBAAuB,EAC5B,KAAK,gBAAgB,EACxB,MAAM,UAAU,CAAA;AAEjB,MAAM,WAAW,sBAAsB;IACnC,gCAAgC;IAChC,KAAK,EAAE,MAAM,CAAA;IACb,KAAK,EAAE,gBAAgB,EAAE,CAAA;IACzB,QAAQ,EAAE,CAAC,WAAW,EAAE,gBAAgB,EAAE,KAAK,IAAI,CAAA;IACnD,iFAAiF;IACjF,OAAO,CAAC,EAAE,uBAAuB,EAAE,CAAA;IACnC,wEAAwE;IACxE,UAAU,CAAC,EAAE,CAAC,CAAC,EAAE,gBAAgB,KAAK,IAAI,CAAA;IAC1C,iDAAiD;IACjD,YAAY,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAA;IACrC,+EAA+E;IAC/E,UAAU,CAAC,EAAE,OAAO,CAAA;IACpB,QAAQ,CAAC,EAAE,MAAM,CAAA;IACjB,QAAQ,CAAC,EAAE,OAAO,CAAA;CACrB;AAED,wBAAgB,iBAAiB,CAAC,EAC9B,KAAK,EACL,KAAK,EACL,QAAQ,EACR,OAAqC,EACrC,UAAU,EACV,YAAY,EACZ,UAAkB,EAClB,QAAQ,EACR,QAAQ,GACX,EAAE,sBAAsB,+BA6ExB"}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
// RefundDestination — a dónde regresa el dinero: efectivo, tarjeta, monedero,
|
|
3
|
+
// saldo a favor o NC CFDI (benchmark §7, POS-3/DEV-*).
|
|
4
|
+
import { useMemo } from 'react';
|
|
5
|
+
import { useTranslation } from 'react-i18next';
|
|
6
|
+
import { Button, Input } from '@asteby/metacore-ui';
|
|
7
|
+
import { useFormatter, roundMoney } from './format';
|
|
8
|
+
import { DEFAULT_REFUND_DESTINATIONS, singleDestination, validateRefund, } from './refund';
|
|
9
|
+
export function RefundDestination({ total, value, onChange, options = DEFAULT_REFUND_DESTINATIONS, onValidate, serverErrors, allowSplit = false, currency, disabled, }) {
|
|
10
|
+
const { t } = useTranslation();
|
|
11
|
+
const fmt = useFormatter({ currency });
|
|
12
|
+
const validation = useMemo(() => validateRefund(total, value, options), [total, value, options]);
|
|
13
|
+
const errors = { ...validation.errors, ...serverErrors };
|
|
14
|
+
const chosen = (k) => value.some((a) => a.kind === k);
|
|
15
|
+
const emit = (next) => {
|
|
16
|
+
onChange(next);
|
|
17
|
+
onValidate?.(validateRefund(total, next, options));
|
|
18
|
+
};
|
|
19
|
+
const toggle = (o) => {
|
|
20
|
+
if (!allowSplit)
|
|
21
|
+
return emit(singleDestination(o.kind, total));
|
|
22
|
+
if (chosen(o.kind))
|
|
23
|
+
return emit(value.filter((a) => a.kind !== o.kind));
|
|
24
|
+
emit([...value, { kind: o.kind, amount: Math.max(0, roundMoney(total - validation.allocated)) }]);
|
|
25
|
+
};
|
|
26
|
+
return (_jsxs("div", { "data-slot": "refund-destination", className: "space-y-2", children: [_jsxs("p", { className: "text-sm text-muted-foreground", children: [t('refund.amount', { defaultValue: 'Monto a reembolsar' }), ": ", _jsx("span", { className: "font-semibold text-foreground", children: fmt.money(total) })] }), _jsx("div", { role: allowSplit ? 'group' : 'radiogroup', className: "grid gap-2 sm:grid-cols-2", children: options.map((o) => {
|
|
27
|
+
const off = o.available === false;
|
|
28
|
+
const on = chosen(o.kind);
|
|
29
|
+
return (_jsx(Button, { type: "button", variant: on ? 'default' : 'outline', role: allowSplit ? 'checkbox' : 'radio', "aria-checked": on, disabled: disabled || off, title: off ? o.disabledReason : undefined, className: "h-auto justify-start whitespace-normal py-2 text-left", onClick: () => toggle(o), children: _jsxs("span", { children: [_jsx("span", { className: "block", children: o.label }), off && o.disabledReason && _jsx("span", { className: "block text-xs opacity-70", children: o.disabledReason }), !off && o.maxAmount != null && _jsxs("span", { className: "block text-xs opacity-70", children: [t('refund.max', { defaultValue: 'Máx.' }), " ", fmt.money(o.maxAmount)] })] }) }, o.kind));
|
|
30
|
+
}) }), value.map((a, i) => (_jsxs("div", { className: "grid grid-cols-[1fr_8rem_10rem] items-start gap-2", children: [_jsx("span", { className: "pt-2 text-sm", children: options.find((o) => o.kind === a.kind)?.label ?? a.kind }), _jsxs("div", { children: [_jsx(Input, { inputMode: "decimal", className: "text-right", value: String(a.amount), disabled: disabled || !allowSplit, "aria-invalid": !!errors[`${i}.amount`], onChange: (e) => emit(value.map((x, j) => (j === i ? { ...x, amount: Number(e.target.value.replace(',', '.')) || 0 } : x))) }), errors[`${i}.amount`] && _jsx("p", { className: "mt-0.5 text-xs text-destructive", children: errors[`${i}.amount`] })] }), (a.kind === 'card' || a.kind === 'credit_note') && (_jsx(Input, { placeholder: a.kind === 'card' ? t('refund.cardRef', { defaultValue: 'Referencia' }) : t('refund.ncRef', { defaultValue: 'Folio / UUID relacionado' }), value: a.reference ?? '', disabled: disabled, onChange: (e) => emit(value.map((x, j) => (j === i ? { ...x, reference: e.target.value } : x))) })), errors[`${i}.kind`] && _jsx("p", { className: "col-span-3 text-xs text-destructive", children: errors[`${i}.kind`] })] }, a.kind))), validation.form && value.length > 0 && _jsx("p", { role: "alert", className: "text-sm text-destructive", children: validation.form })] }));
|
|
31
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
export type RefundDestinationKind = 'cash' | 'card' | 'wallet' | 'store_credit' | 'credit_note';
|
|
2
|
+
export interface RefundDestinationOption {
|
|
3
|
+
kind: RefundDestinationKind;
|
|
4
|
+
label: string;
|
|
5
|
+
/** Tope reembolsable por este destino (p. ej. lo pagado con tarjeta). */
|
|
6
|
+
maxAmount?: number;
|
|
7
|
+
/** false → la opción se muestra deshabilitada con `disabledReason`. */
|
|
8
|
+
available?: boolean;
|
|
9
|
+
disabledReason?: string;
|
|
10
|
+
}
|
|
11
|
+
export interface RefundAllocation {
|
|
12
|
+
kind: RefundDestinationKind;
|
|
13
|
+
amount: number;
|
|
14
|
+
/** Referencia del reembolso a tarjeta / folio de NC relacionada. */
|
|
15
|
+
reference?: string;
|
|
16
|
+
}
|
|
17
|
+
/** Catálogo por defecto: el llamador filtra/limita según el documento. */
|
|
18
|
+
export declare const DEFAULT_REFUND_DESTINATIONS: RefundDestinationOption[];
|
|
19
|
+
export interface RefundValidation {
|
|
20
|
+
errors: Record<string, string>;
|
|
21
|
+
form?: string;
|
|
22
|
+
allocated: number;
|
|
23
|
+
remaining: number;
|
|
24
|
+
valid: boolean;
|
|
25
|
+
}
|
|
26
|
+
export declare function validateRefund(total: number, allocations: RefundAllocation[], options?: RefundDestinationOption[]): RefundValidation;
|
|
27
|
+
/** Un solo destino cubriendo todo el monto (caso común). */
|
|
28
|
+
export declare function singleDestination(kind: RefundDestinationKind, total: number): RefundAllocation[];
|
|
29
|
+
//# sourceMappingURL=refund.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"refund.d.ts","sourceRoot":"","sources":["../../src/business/refund.ts"],"names":[],"mappings":"AAIA,MAAM,MAAM,qBAAqB,GAAG,MAAM,GAAG,MAAM,GAAG,QAAQ,GAAG,cAAc,GAAG,aAAa,CAAA;AAE/F,MAAM,WAAW,uBAAuB;IACpC,IAAI,EAAE,qBAAqB,CAAA;IAC3B,KAAK,EAAE,MAAM,CAAA;IACb,yEAAyE;IACzE,SAAS,CAAC,EAAE,MAAM,CAAA;IAClB,uEAAuE;IACvE,SAAS,CAAC,EAAE,OAAO,CAAA;IACnB,cAAc,CAAC,EAAE,MAAM,CAAA;CAC1B;AAED,MAAM,WAAW,gBAAgB;IAC7B,IAAI,EAAE,qBAAqB,CAAA;IAC3B,MAAM,EAAE,MAAM,CAAA;IACd,oEAAoE;IACpE,SAAS,CAAC,EAAE,MAAM,CAAA;CACrB;AAED,0EAA0E;AAC1E,eAAO,MAAM,2BAA2B,EAAE,uBAAuB,EAMhE,CAAA;AAED,MAAM,WAAW,gBAAgB;IAC7B,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAA;IAC9B,IAAI,CAAC,EAAE,MAAM,CAAA;IACb,SAAS,EAAE,MAAM,CAAA;IACjB,SAAS,EAAE,MAAM,CAAA;IACjB,KAAK,EAAE,OAAO,CAAA;CACjB;AAED,wBAAgB,cAAc,CAC1B,KAAK,EAAE,MAAM,EACb,WAAW,EAAE,gBAAgB,EAAE,EAC/B,OAAO,GAAE,uBAAuB,EAAgC,GACjE,gBAAgB,CAqBlB;AAED,4DAA4D;AAC5D,wBAAgB,iBAAiB,CAAC,IAAI,EAAE,qBAAqB,EAAE,KAAK,EAAE,MAAM,GAAG,gBAAgB,EAAE,CAEhG"}
|