@asteby/metacore-runtime-react 49.3.1 → 49.5.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 +14 -0
- package/dist/api-context.d.ts +9 -1
- package/dist/api-context.d.ts.map +1 -1
- package/dist/api-context.js +6 -4
- package/dist/business/document-editor-panels.d.ts +2 -1
- package/dist/business/document-editor-panels.d.ts.map +1 -1
- package/dist/business/document-editor-panels.js +6 -6
- package/dist/business/document-editor.d.ts.map +1 -1
- package/dist/business/document-editor.js +23 -9
- package/dist/business/index.d.ts +1 -0
- package/dist/business/index.d.ts.map +1 -1
- package/dist/business/index.js +1 -0
- package/dist/business/line-items-editor.d.ts +6 -1
- package/dist/business/line-items-editor.d.ts.map +1 -1
- package/dist/business/line-items-editor.js +109 -83
- package/dist/business/line-product-cell.d.ts +32 -0
- package/dist/business/line-product-cell.d.ts.map +1 -0
- package/dist/business/line-product-cell.js +100 -0
- package/dist/dynamic-select-field.d.ts +2 -1
- package/dist/dynamic-select-field.d.ts.map +1 -1
- package/dist/dynamic-select-field.js +49 -19
- package/dist/dynamic-table.d.ts +31 -1
- package/dist/dynamic-table.d.ts.map +1 -1
- package/dist/dynamic-table.js +37 -4
- package/dist/entity-select.d.ts.map +1 -1
- package/dist/entity-select.js +13 -11
- package/dist/field-grid.d.ts +7 -1
- package/dist/field-grid.d.ts.map +1 -1
- package/dist/field-grid.js +4 -2
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/query-batch.d.ts.map +1 -1
- package/dist/query-batch.js +17 -1
- package/dist/record-picker-actions.d.ts +27 -0
- package/dist/record-picker-actions.d.ts.map +1 -0
- package/dist/record-picker-actions.js +32 -0
- package/package.json +1 -1
- package/src/__tests__/api-batch-opt-out.test.tsx +83 -0
- package/src/__tests__/document-editor-inline-selects.test.tsx +202 -0
- package/src/__tests__/dynamic-table-bulk-actions.test.tsx +140 -0
- package/src/__tests__/dynamic-table-load-error.test.tsx +97 -0
- package/src/api-context.tsx +19 -4
- package/src/business/document-editor-panels.tsx +13 -10
- package/src/business/document-editor.tsx +72 -20
- package/src/business/index.ts +1 -0
- package/src/business/line-items-editor.tsx +229 -174
- package/src/business/line-product-cell.tsx +219 -0
- package/src/dynamic-select-field.tsx +51 -22
- package/src/dynamic-table.tsx +91 -4
- package/src/entity-select.tsx +17 -39
- package/src/field-grid.tsx +15 -1
- package/src/index.ts +2 -0
- package/src/query-batch.ts +18 -1
- package/src/record-picker-actions.tsx +81 -0
|
@@ -2,18 +2,17 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
|
|
|
2
2
|
// DocumentLinesGrid — editor único de renglones (UX-2).
|
|
3
3
|
// `LineItemsEditor` es el mismo componente: las props nuevas son opcionales.
|
|
4
4
|
// Controlado: `value`/`onChange` con `LineItem[]`. Guardar = `serializeLineItems`.
|
|
5
|
-
import { useMemo, useState } from 'react';
|
|
5
|
+
import { useEffect, useMemo, useRef, useState } from 'react';
|
|
6
6
|
import { useTranslation } from 'react-i18next';
|
|
7
|
-
import { Heading, Minus, Plus,
|
|
7
|
+
import { Heading, Minus, Plus, StickyNote, Trash2 } from 'lucide-react';
|
|
8
8
|
import { Button, Input } from '@asteby/metacore-ui';
|
|
9
9
|
import { useCan } from '../permissions-context';
|
|
10
10
|
import { useFormatter } from './format';
|
|
11
|
-
import { addProductLine, lineGridKeyCommand, } from './document-lines';
|
|
11
|
+
import { addProductLine, applyProductToLine, lineGridKeyCommand, } from './document-lines';
|
|
12
12
|
import { computeLine, computeTotals, makeLine, validateLineItems, } from './line-items';
|
|
13
|
-
import {
|
|
14
|
-
import { parseProductQuery, } from './product-search';
|
|
13
|
+
import { LineProductCell } from './line-product-cell';
|
|
15
14
|
const num = (s) => (s.trim() === '' ? 0 : Number(s.replace(',', '.')) || 0);
|
|
16
|
-
export function DocumentLinesGrid({ value, onChange, columns = ['discount'], policy, serverErrors, onValidate, onRequestProduct, search, priceSource = 'sale', mode = 'free', discountMode = 'percent', quantityStepper, mergeSameProduct = true, warehouseId, allowSections = true, readOnly = false, editPermission, currency, }) {
|
|
15
|
+
export function DocumentLinesGrid({ value, onChange, columns = ['discount'], policy, serverErrors, onValidate, onRequestProduct, search, priceSource = 'sale', mode = 'free', discountMode = 'percent', quantityStepper, mergeSameProduct = true, warehouseId, allowSections = true, readOnly = false, editPermission, currency, showTotals = true, }) {
|
|
17
16
|
const { t } = useTranslation();
|
|
18
17
|
const can = useCan();
|
|
19
18
|
const fmt = useFormatter({ currency });
|
|
@@ -46,41 +45,91 @@ export function DocumentLinesGrid({ value, onChange, columns = ['discount'], pol
|
|
|
46
45
|
};
|
|
47
46
|
const cell = (i, field) => errors[`${i}.${field}`];
|
|
48
47
|
const warn = (i, field) => validation.warnings[`${i}.${field}`];
|
|
48
|
+
// Foco tras elegir: Enter en el buscador pasa a «Cant.» del renglón que
|
|
49
|
+
// recibió el producto (nuevo o sumado), como un editor de facturas pro.
|
|
50
|
+
const rootRef = useRef(null);
|
|
51
|
+
const [focusQty, setFocusQty] = useState(null);
|
|
52
|
+
useEffect(() => {
|
|
53
|
+
if (!focusQty)
|
|
54
|
+
return;
|
|
55
|
+
const el = rootRef.current?.querySelector(`[data-line-key="${focusQty}"] [data-cell="quantity"]`);
|
|
56
|
+
if (el) {
|
|
57
|
+
el.focus();
|
|
58
|
+
el.select?.();
|
|
59
|
+
}
|
|
60
|
+
setFocusQty(null);
|
|
61
|
+
}, [focusQty, value]);
|
|
62
|
+
const blankLine = () => makeLine(discountMode === 'amount' ? { discount_kind: 'amount' } : {});
|
|
63
|
+
/** Renglón vacío del final: el producto entra como renglón nuevo (o suma al mismo). */
|
|
49
64
|
const pickProduct = (product, variant) => {
|
|
50
|
-
|
|
65
|
+
const next = addProductLine(value, product, variant, { priceSource, warehouseId, mergeSameProduct });
|
|
66
|
+
emit(next);
|
|
67
|
+
const id = variant?.id ?? product.id;
|
|
68
|
+
const target = next.find((l) => l.kind === 'item' && l.product_id === id && !value.includes(l)) ?? next[next.length - 1];
|
|
69
|
+
if (target)
|
|
70
|
+
setFocusQty(target.key);
|
|
71
|
+
};
|
|
72
|
+
/** Renglón libre existente: el producto elegido en su celda llena ESE renglón. */
|
|
73
|
+
const fillLine = (idx, product, variant) => {
|
|
74
|
+
const cur = value[idx];
|
|
75
|
+
if (!cur)
|
|
76
|
+
return;
|
|
77
|
+
const filled = applyProductToLine(cur, product, variant, { priceSource, warehouseId });
|
|
78
|
+
emit(value.map((l, i) => (i === idx ? (discountMode === 'amount' ? { ...filled, discount_kind: 'amount' } : filled) : l)));
|
|
79
|
+
setFocusQty(cur.key);
|
|
80
|
+
};
|
|
81
|
+
/** Texto escrito en el renglón vacío sin elegir producto → renglón libre con esa descripción. */
|
|
82
|
+
const addFreeFromDraft = (description) => {
|
|
83
|
+
const line = { ...blankLine(), description };
|
|
84
|
+
emit([...value, line]);
|
|
85
|
+
setFocusQty(line.key);
|
|
51
86
|
};
|
|
52
|
-
|
|
87
|
+
// Ancho mínimo = columnas fijas + un mínimo legible para «Descripción».
|
|
88
|
+
const minWidth = 260 +
|
|
89
|
+
(stepper ? 144 : 96) +
|
|
90
|
+
112 +
|
|
91
|
+
112 +
|
|
92
|
+
40 +
|
|
93
|
+
(show('discount') ? (discountMode === 'both' ? 128 : 96) : 0) +
|
|
94
|
+
(show('tax') ? 64 : 0) +
|
|
95
|
+
(show('unit') ? 80 : 0) +
|
|
96
|
+
(show('technician') ? 128 : 0) +
|
|
97
|
+
(show('lot') ? 112 : 0) +
|
|
98
|
+
(show('dot') ? 96 : 0);
|
|
99
|
+
const colCount = 1 + ['unit', 'discount', 'tax', 'technician', 'lot', 'dot'].filter(show).length + 4;
|
|
100
|
+
const descKeys = (e, i, l) => {
|
|
101
|
+
const cmd = lineGridKeyCommand(e.key, {
|
|
102
|
+
cellEmpty: !l.description.trim() && !l.product_id,
|
|
103
|
+
mode,
|
|
104
|
+
});
|
|
105
|
+
if (cmd === 'delete') {
|
|
106
|
+
e.preventDefault();
|
|
107
|
+
remove(i);
|
|
108
|
+
}
|
|
109
|
+
else if (cmd === 'add') {
|
|
110
|
+
e.preventDefault();
|
|
111
|
+
emit([...value, blankLine()]);
|
|
112
|
+
}
|
|
113
|
+
};
|
|
114
|
+
return (_jsxs("div", { ref: rootRef, "data-slot": "line-items-editor", "data-component": "DocumentLinesGrid", "data-mode": mode, className: "space-y-2", children: [_jsx("div", { className: "overflow-x-auto rounded-lg border bg-card/40", children: _jsxs("table", { className: "w-full table-fixed border-collapse text-sm", style: { minWidth }, children: [_jsx("thead", { className: "border-b bg-muted/40 text-left text-xs font-medium text-muted-foreground", children: _jsxs("tr", { children: [_jsx("th", { className: "px-3 py-2 font-medium", children: t('lineItems.description', { defaultValue: 'Descripción' }) }), show('unit') && _jsx("th", { className: "w-20 px-3 py-2 font-medium", children: t('lineItems.unit', { defaultValue: 'Unidad' }) }), _jsx("th", { className: (stepper ? 'w-36' : 'w-24') + ' px-3 py-2 text-right font-medium', children: t('lineItems.quantity', { defaultValue: 'Cant.' }) }), _jsx("th", { className: "w-28 px-3 py-2 text-right font-medium", children: priceSource === 'cost'
|
|
53
115
|
? t('lineItems.unitCost', { defaultValue: 'Costo unitario' })
|
|
54
|
-
: t('lineItems.unitPrice', { defaultValue: 'Precio' }) }), show('discount') && (_jsx("th", { className:
|
|
116
|
+
: t('lineItems.unitPrice', { defaultValue: 'Precio' }) }), show('discount') && (_jsx("th", { className: (discountMode === 'both' ? 'w-32' : 'w-24') + ' px-3 py-2 text-right font-medium', children: discountMode === 'amount'
|
|
55
117
|
? t('lineItems.discountAmount', { defaultValue: 'Desc.' })
|
|
56
|
-
: t('lineItems.discount', { defaultValue: 'Desc. %' }) })), show('tax') && _jsx("th", { className: "w-
|
|
118
|
+
: t('lineItems.discount', { defaultValue: 'Desc. %' }) })), show('tax') && _jsx("th", { className: "w-16 px-3 py-2 text-right font-medium", children: t('lineItems.tax', { defaultValue: 'IVA %' }) }), show('technician') && _jsx("th", { className: "w-32 px-3 py-2 font-medium", children: t('lineItems.technician', { defaultValue: 'Técnico' }) }), show('lot') && _jsx("th", { className: "w-28 px-3 py-2 font-medium", children: t('lineItems.lot', { defaultValue: 'Lote' }) }), show('dot') && _jsx("th", { className: "w-24 px-3 py-2 font-medium", children: "DOT" }), _jsx("th", { className: "w-28 px-3 py-2 text-right font-medium", children: t('lineItems.amount', { defaultValue: 'Importe' }) }), _jsx("th", { className: "w-10", children: _jsx("span", { className: "sr-only", children: t('common.actions', { defaultValue: 'Acciones' }) }) })] }) }), _jsxs("tbody", { children: [value.map((l, i) => {
|
|
57
119
|
if (l.kind !== 'item') {
|
|
58
|
-
return (_jsx("tr", { className: "border-t bg-muted/30", children: _jsxs("td", { colSpan: 99, className:
|
|
120
|
+
return (_jsx("tr", { className: "group border-t bg-muted/30", "data-line-key": l.key, children: _jsxs("td", { colSpan: 99, className: CELL, children: [_jsxs("div", { className: "flex items-center gap-2", children: [_jsx(Input, { value: l.description, disabled: locked, "aria-invalid": !!cell(i, 'description'), placeholder: l.kind === 'section' ? t('lineItems.sectionPlaceholder', { defaultValue: 'Sección' }) : t('lineItems.notePlaceholder', { defaultValue: 'Nota' }), className: 'h-8 ' + (l.kind === 'section' ? 'font-semibold' : 'italic'), onChange: (e) => patch(i, { description: e.target.value }), onKeyDown: (e) => {
|
|
59
121
|
const cmd = lineGridKeyCommand(e.key, { cellEmpty: !l.description.trim(), mode });
|
|
60
122
|
if (cmd === 'delete') {
|
|
61
123
|
e.preventDefault();
|
|
62
124
|
remove(i);
|
|
63
125
|
}
|
|
64
|
-
} }), canAdd && (_jsx(Button, { type: "button", size: "icon", variant: "ghost", onClick: () => remove(i), "aria-label": t('common.delete', { defaultValue: 'Eliminar' }), children: _jsx(Trash2, { className: "size-4" }) }))] }), cell(i, 'description') && _jsx("p", { className: "mt-1 text-xs text-destructive", children: cell(i, 'description') })] }) }, l.key));
|
|
126
|
+
} }), canAdd && (_jsx(Button, { type: "button", size: "icon", variant: "ghost", className: DELETE, onClick: () => remove(i), "aria-label": t('common.delete', { defaultValue: 'Eliminar' }), children: _jsx(Trash2, { className: "size-4" }) }))] }), cell(i, 'description') && _jsx("p", { className: "mt-1 text-xs text-destructive", children: cell(i, 'description') })] }) }, l.key));
|
|
65
127
|
}
|
|
66
128
|
const shown = view[i] ?? l;
|
|
67
129
|
const a = computeLine(shown);
|
|
68
130
|
const kind = shown.discount_kind ?? 'percent';
|
|
69
131
|
const qtyMax = l.max_quantity;
|
|
70
|
-
return (_jsxs("tr", { className: "border-t align-top", "data-line-key": l.key, children: [_jsxs("td", { className:
|
|
71
|
-
const cmd = lineGridKeyCommand(e.key, {
|
|
72
|
-
cellEmpty: !l.description.trim() && !l.product_id,
|
|
73
|
-
mode,
|
|
74
|
-
});
|
|
75
|
-
if (cmd === 'delete') {
|
|
76
|
-
e.preventDefault();
|
|
77
|
-
remove(i);
|
|
78
|
-
}
|
|
79
|
-
else if (cmd === 'add') {
|
|
80
|
-
e.preventDefault();
|
|
81
|
-
emit([...value, makeLine(discountMode === 'amount' ? { discount_kind: 'amount' } : {})]);
|
|
82
|
-
}
|
|
83
|
-
} }), (l.sku || l.unit) && (_jsx("p", { className: "mt-0.5 text-xs text-muted-foreground", children: [l.sku, !show('unit') ? l.unit : ''].filter(Boolean).join(' · ') })), cell(i, 'description') && _jsx("p", { className: "mt-0.5 text-xs text-destructive", children: cell(i, 'description') })] }), show('unit') && (_jsx("td", { className: "px-2 py-1.5", children: _jsx(Input, { value: l.unit ?? '', disabled: locked, onChange: (e) => patch(i, { unit: e.target.value }) }) })), _jsxs("td", { className: "px-2 py-1.5", children: [stepper ? (_jsxs("div", { className: "flex items-center justify-end gap-1", children: [_jsx(Button, { type: "button", size: "icon", variant: "outline", className: "size-7", disabled: locked || toNum(l.quantity) <= (fromSource ? 0 : 1), "aria-label": t('lineItems.decreaseQty', { defaultValue: 'Disminuir cantidad' }), onClick: () => patch(i, { quantity: Math.max(fromSource ? 0 : 1, toNum(l.quantity) - 1) }), children: _jsx(Minus, { className: "size-3" }) }), _jsx(Input, { inputMode: "decimal", className: "h-8 w-14 text-right", value: String(l.quantity), disabled: locked, "aria-label": t('lineItems.quantity', { defaultValue: 'Cant.' }), "aria-invalid": !!cell(i, 'quantity'), onChange: (e) => {
|
|
132
|
+
return (_jsxs("tr", { className: "group border-t align-top transition-colors hover:bg-muted/30 focus-within:bg-muted/20", "data-line-key": l.key, children: [_jsxs("td", { className: CELL, children: [search && canAdd && !l.product_id ? (_jsx(LineProductCell, { search: search, text: l.description, onTextChange: (text) => patch(i, { description: text }), onPick: (p, v) => fillLine(i, p, v), onKeyDownClosed: (e) => descKeys(e, i, l), warehouseId: warehouseId, currency: currency, ariaLabel: t('lineItems.description', { defaultValue: 'Descripción' }), invalid: !!cell(i, 'description'), dataCell: "description" })) : (_jsx(Input, { value: l.description, disabled: locked, className: INPUT, "data-cell": "description", "aria-label": t('lineItems.description', { defaultValue: 'Descripción' }), "aria-invalid": !!cell(i, 'description'), onChange: (e) => patch(i, { description: e.target.value }), onKeyDown: (e) => descKeys(e, i, l) })), (l.sku || l.unit) && (_jsx("p", { className: "mt-0.5 text-xs text-muted-foreground", children: [l.sku, !show('unit') ? l.unit : ''].filter(Boolean).join(' · ') })), cell(i, 'description') && _jsx("p", { className: "mt-0.5 text-xs text-destructive", children: cell(i, 'description') })] }), show('unit') && (_jsx("td", { className: CELL, children: _jsx(Input, { className: INPUT, value: l.unit ?? '', disabled: locked, onChange: (e) => patch(i, { unit: e.target.value }) }) })), _jsxs("td", { className: CELL, children: [stepper ? (_jsxs("div", { className: "flex items-center justify-end gap-1", children: [_jsx(Button, { type: "button", size: "icon", variant: "outline", className: "size-7", disabled: locked || toNum(l.quantity) <= (fromSource ? 0 : 1), "aria-label": t('lineItems.decreaseQty', { defaultValue: 'Disminuir cantidad' }), onClick: () => patch(i, { quantity: Math.max(fromSource ? 0 : 1, toNum(l.quantity) - 1) }), children: _jsx(Minus, { className: "size-3" }) }), _jsx(Input, { inputMode: "decimal", className: "h-8 w-14 text-right tabular-nums", "data-cell": "quantity", value: String(l.quantity), disabled: locked, "aria-label": t('lineItems.quantity', { defaultValue: 'Cant.' }), "aria-invalid": !!cell(i, 'quantity'), onChange: (e) => {
|
|
84
133
|
let q = num(e.target.value);
|
|
85
134
|
if (qtyMax != null && q > qtyMax)
|
|
86
135
|
q = qtyMax;
|
|
@@ -90,72 +139,49 @@ export function DocumentLinesGrid({ value, onChange, columns = ['discount'], pol
|
|
|
90
139
|
} }), _jsx(Button, { type: "button", size: "icon", variant: "outline", className: "size-7", disabled: locked || (qtyMax != null && toNum(l.quantity) >= qtyMax), "aria-label": t('lineItems.increaseQty', { defaultValue: 'Aumentar cantidad' }), onClick: () => {
|
|
91
140
|
const next = toNum(l.quantity) + 1;
|
|
92
141
|
patch(i, { quantity: qtyMax != null ? Math.min(next, qtyMax) : next });
|
|
93
|
-
}, children: _jsx(Plus, { className: "size-3" }) })] })) : (_jsx(Input, { inputMode: "decimal", className: "
|
|
94
|
-
}), value.length === 0 && !search && (_jsx("tr", { children: _jsx("td", { colSpan: 99, className: "px-3 py-6 text-center text-muted-foreground", children: t('lineItems.empty', { defaultValue: 'Aún no hay renglones. Agrega un producto o un renglón libre.' }) }) })), canAdd && search && (_jsx(
|
|
142
|
+
}, children: _jsx(Plus, { className: "size-3" }) })] })) : (_jsx(Input, { inputMode: "decimal", className: NUM, "data-cell": "quantity", value: String(l.quantity), disabled: locked, "aria-label": t('lineItems.quantity', { defaultValue: 'Cant.' }), "aria-invalid": !!cell(i, 'quantity'), onChange: (e) => patch(i, { quantity: num(e.target.value) }) })), cell(i, 'quantity') && _jsx("p", { className: "mt-0.5 text-xs text-destructive", children: cell(i, 'quantity') }), !cell(i, 'quantity') && warn(i, 'quantity') && _jsx("p", { className: "mt-0.5 text-xs text-muted-foreground", children: warn(i, 'quantity') })] }), _jsxs("td", { className: CELL, children: [_jsx(Input, { inputMode: "decimal", className: NUM, value: String(l.unit_price), disabled: locked, "aria-label": priceSource === 'cost' ? t('lineItems.unitCost', { defaultValue: 'Costo unitario' }) : t('lineItems.unitPrice', { defaultValue: 'Precio' }), "aria-invalid": !!cell(i, 'unit_price'), onChange: (e) => patch(i, { unit_price: num(e.target.value) }) }), l.catalog_price != null && (_jsxs("p", { className: "mt-0.5 truncate whitespace-nowrap text-right text-xs text-muted-foreground", children: [t('lineItems.catalogPrice', { defaultValue: 'Catálogo' }), " ", fmt.money(l.catalog_price)] })), cell(i, 'unit_price') && _jsx("p", { className: "mt-0.5 text-xs text-destructive", children: cell(i, 'unit_price') }), !cell(i, 'unit_price') && warn(i, 'unit_price') && _jsx("p", { className: "mt-0.5 text-xs text-muted-foreground", children: warn(i, 'unit_price') })] }), show('discount') && (_jsxs("td", { className: CELL, children: [_jsxs("div", { className: "flex items-center gap-1", children: [discountMode === 'both' && (_jsx(Button, { type: "button", size: "sm", variant: "ghost", className: "h-8 px-1.5", "aria-label": t('lineItems.discountKind', { defaultValue: 'Tipo de descuento' }), onClick: () => patch(i, { discount_kind: kind === 'amount' ? 'percent' : 'amount', discount: 0 }), children: kind === 'amount' ? '$' : '%' })), _jsx(Input, { inputMode: "decimal", className: NUM, value: String(l.discount), disabled: locked, "aria-invalid": !!cell(i, 'discount'), onChange: (e) => patch(i, { discount: num(e.target.value), discount_kind: kind }) })] }), cell(i, 'discount') && _jsx("p", { className: "mt-0.5 text-xs text-destructive", children: cell(i, 'discount') })] })), show('tax') && (_jsx("td", { className: CELL, children: _jsx(Input, { inputMode: "decimal", className: NUM, value: String(Math.round(l.tax_rate * 10000) / 100), disabled: locked, onChange: (e) => patch(i, { tax_rate: num(e.target.value) / 100 }) }) })), show('technician') && (_jsx("td", { className: CELL, children: _jsx(Input, { className: INPUT, value: l.technician_id ?? '', disabled: locked, onChange: (e) => patch(i, { technician_id: e.target.value }) }) })), show('lot') && (_jsx("td", { className: CELL, children: _jsx(Input, { className: INPUT, value: l.lot ?? '', disabled: locked, onChange: (e) => patch(i, { lot: e.target.value }) }) })), show('dot') && (_jsx("td", { className: CELL, children: _jsx(Input, { className: INPUT, value: l.dot ?? '', disabled: locked, onChange: (e) => patch(i, { dot: e.target.value }) }) })), _jsx("td", { className: "px-3 py-1.5 text-right align-top font-medium tabular-nums", "data-slot": "line-amount", children: _jsx("span", { className: "inline-flex h-8 items-center", children: fmt.money(a.net) }) }), _jsx("td", { className: "px-1 py-1.5 align-top", children: canAdd && (_jsx(Button, { type: "button", size: "icon", variant: "ghost", className: DELETE, onClick: () => remove(i), "aria-label": t('common.delete', { defaultValue: 'Eliminar' }), children: _jsx(Trash2, { className: "size-4" }) })) })] }, l.key));
|
|
143
|
+
}), value.length === 0 && !search && (_jsx("tr", { children: _jsx("td", { colSpan: 99, className: "px-3 py-6 text-center text-muted-foreground", children: t('lineItems.empty', { defaultValue: 'Aún no hay renglones. Agrega un producto o un renglón libre.' }) }) })), canAdd && search && (_jsx(DraftRow, { search: search, warehouseId: warehouseId, currency: currency, onPick: pickProduct, onFree: addFreeFromDraft, onAddBlank: () => emit([...value, blankLine()]), trailingCells: colCount - 1 }))] }), showTotals && (_jsxs("tfoot", { className: "border-t bg-muted/20 text-sm tabular-nums", "data-slot": "line-totals", children: [_jsx(TotalRow, { span: colCount - 2, label: t('lineItems.subtotal', { defaultValue: 'Subtotal' }), value: fmt.money(totals.subtotal) }), totals.discount > 0 && _jsx(TotalRow, { span: colCount - 2, label: t('lineItems.discountTotal', { defaultValue: 'Descuento' }), value: `-${fmt.money(totals.discount)}` }), totals.tax > 0 && _jsx(TotalRow, { span: colCount - 2, label: t('lineItems.taxTotal', { defaultValue: 'Impuestos' }), value: fmt.money(totals.tax), slot: "tax-total" }), _jsx(TotalRow, { span: colCount - 2, label: t('lineItems.total', { defaultValue: 'Total' }), value: fmt.money(totals.total), slot: "grand-total", strong: true })] }))] }) }), validation.form && value.length > 0 && _jsx("p", { role: "alert", className: "text-sm text-destructive", children: validation.form }), undo && canAdd && (_jsxs("p", { className: "text-sm text-muted-foreground", children: [t('lineItems.removed', { defaultValue: 'Renglón eliminado.' }), ' ', _jsx("button", { type: "button", className: "underline", onClick: () => {
|
|
95
144
|
emit(undo);
|
|
96
145
|
setUndo(null);
|
|
97
|
-
}, children: t('lineItems.undo', { defaultValue: 'Deshacer' }) })] })),
|
|
146
|
+
}, children: t('lineItems.undo', { defaultValue: 'Deshacer' }) })] })), _jsx("div", { className: "flex flex-wrap items-center gap-3", children: canAdd ? (_jsxs("div", { className: "flex flex-wrap gap-1", children: [onRequestProduct && !search && (_jsxs(Button, { type: "button", size: "sm", variant: "outline", onClick: onRequestProduct, children: [_jsx(Plus, { className: "mr-1 size-4" }), t('lineItems.addProduct', { defaultValue: 'Agregar producto' })] })), _jsxs(Button, { type: "button", size: "sm", variant: "ghost", onClick: () => emit([...value, blankLine()]), children: [_jsx(Plus, { className: "mr-1 size-4" }), t('lineItems.addLine', { defaultValue: 'Renglón libre' })] }), allowSections && (_jsxs(_Fragment, { children: [_jsxs(Button, { type: "button", size: "sm", variant: "ghost", onClick: () => emit([...value, makeLine({ kind: 'section' })]), children: [_jsx(Heading, { className: "mr-1 size-4" }), t('lineItems.addSection', { defaultValue: 'Sección' })] }), _jsxs(Button, { type: "button", size: "sm", variant: "ghost", onClick: () => emit([...value, makeLine({ kind: 'note' })]), children: [_jsx(StickyNote, { className: "mr-1 size-4" }), t('lineItems.addNote', { defaultValue: 'Nota' })] })] }))] })) : null })] }));
|
|
98
147
|
}
|
|
99
148
|
/** Mismo componente. El nombre anterior sigue exportado. */
|
|
100
149
|
export const LineItemsEditor = DocumentLinesGrid;
|
|
101
150
|
function toNum(n) {
|
|
102
151
|
return Number.isFinite(n) ? n : 0;
|
|
103
152
|
}
|
|
104
|
-
|
|
153
|
+
/**
|
|
154
|
+
* Renglón vacío del final, siempre listo para buscar: el buscador vive en la
|
|
155
|
+
* celda «Descripción» (no en una barra a todo lo ancho). Elegir agrega el
|
|
156
|
+
* producto; Enter con texto sin coincidencias lo agrega como renglón libre.
|
|
157
|
+
*/
|
|
158
|
+
function DraftRow({ search, warehouseId, currency, onPick, onFree, onAddBlank, trailingCells, }) {
|
|
105
159
|
const { t } = useTranslation();
|
|
106
|
-
const fmt = useFormatter({ currency });
|
|
107
160
|
const [text, setText] = useState('');
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
const choose = (idx) => {
|
|
123
|
-
const hit = flat[idx];
|
|
124
|
-
if (!hit)
|
|
125
|
-
return;
|
|
126
|
-
onPick(hit.product, hit.variant);
|
|
127
|
-
setText('');
|
|
128
|
-
setActive(0);
|
|
129
|
-
};
|
|
130
|
-
return (_jsx("tr", { className: "border-t", children: _jsx("td", { colSpan: 99, className: "px-2 py-1.5", 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, { className: "pl-8", value: text, role: "combobox", "aria-expanded": open, "aria-label": t('lineItems.searchProduct', { defaultValue: 'Buscar producto' }), placeholder: t('lineItems.searchPlaceholder', { defaultValue: 'Producto, medida (205/55R16), SKU o código de barras' }), onChange: (e) => {
|
|
131
|
-
setText(e.target.value);
|
|
132
|
-
setActive(0);
|
|
133
|
-
}, onKeyDown: (e) => {
|
|
134
|
-
if (e.key === 'ArrowDown' && open) {
|
|
135
|
-
e.preventDefault();
|
|
136
|
-
setActive((n) => Math.min(flat.length - 1, n + 1));
|
|
137
|
-
return;
|
|
138
|
-
}
|
|
139
|
-
if (e.key === 'ArrowUp' && open) {
|
|
140
|
-
e.preventDefault();
|
|
141
|
-
setActive((n) => Math.max(0, n - 1));
|
|
142
|
-
return;
|
|
143
|
-
}
|
|
144
|
-
const cmd = lineGridKeyCommand(e.key, { suggestionsOpen: open, cellEmpty: !text.trim(), mode: 'free' });
|
|
145
|
-
if (e.key === 'Enter' && open) {
|
|
146
|
-
e.preventDefault();
|
|
147
|
-
choose(active);
|
|
148
|
-
return;
|
|
149
|
-
}
|
|
150
|
-
if (cmd === 'add' && !text.trim()) {
|
|
151
|
-
e.preventDefault();
|
|
152
|
-
onAddFree();
|
|
153
|
-
}
|
|
154
|
-
} }), open && (_jsx("ul", { role: "listbox", className: "absolute z-20 mt-1 max-h-60 w-full overflow-auto rounded-md border bg-popover shadow-md", children: flat.map((hit, idx) => {
|
|
155
|
-
const price = priceOf(hit.product, hit.variant, warehouseId);
|
|
156
|
-
return (_jsx("li", { role: "option", "aria-selected": idx === active, children: _jsxs("button", { type: "button", className: 'flex w-full items-center justify-between gap-2 px-2 py-1.5 text-left text-sm hover:bg-accent ' + (idx === active ? 'bg-accent' : ''), onMouseDown: (e) => e.preventDefault(), onClick: () => choose(idx), children: [_jsxs("span", { children: [_jsx("span", { className: "block font-medium", children: hit.variant ? `${hit.product.name} · ${hit.variant.label}` : hit.product.name }), _jsx("span", { className: "block text-xs text-muted-foreground", children: hit.variant?.sku ?? hit.product.sku })] }), price != null && _jsx("span", { className: "tabular-nums", children: fmt.money(price) })] }) }, `${hit.product.id}:${hit.variant?.id ?? ''}`));
|
|
157
|
-
}) })), loading && _jsx("p", { className: "mt-1 text-xs text-muted-foreground", children: t('common.searching', { defaultValue: 'Buscando…' }) })] }) }) }));
|
|
161
|
+
return (_jsxs("tr", { className: "border-t border-dashed", "data-slot": "line-draft-row", children: [_jsx("td", { className: CELL, children: _jsx(LineProductCell, { search: search, text: text, onTextChange: setText, onPick: (p, v) => {
|
|
162
|
+
setText('');
|
|
163
|
+
onPick(p, v);
|
|
164
|
+
}, onKeyDownClosed: (e) => {
|
|
165
|
+
if (e.key !== 'Enter')
|
|
166
|
+
return;
|
|
167
|
+
e.preventDefault();
|
|
168
|
+
const typed = text.trim();
|
|
169
|
+
setText('');
|
|
170
|
+
if (typed)
|
|
171
|
+
onFree(typed);
|
|
172
|
+
else
|
|
173
|
+
onAddBlank();
|
|
174
|
+
}, warehouseId: warehouseId, currency: currency, ariaLabel: t('lineItems.searchProduct', { defaultValue: 'Buscar producto' }), placeholder: t('lineItems.searchPlaceholder', { defaultValue: 'Producto, medida (205/55R16), SKU o código de barras' }), className: "border-dashed bg-transparent shadow-none", dataCell: "search" }) }), Array.from({ length: trailingCells }, (_, i) => (_jsx("td", { "aria-hidden": true }, i)))] }));
|
|
158
175
|
}
|
|
159
|
-
|
|
160
|
-
|
|
176
|
+
/** Fila de totales alineada a la columna «Importe». */
|
|
177
|
+
function TotalRow({ span, label, value, slot, strong }) {
|
|
178
|
+
return (_jsxs("tr", { className: strong ? 'font-semibold' : 'text-muted-foreground', children: [_jsx("td", { colSpan: span, className: "px-3 py-1 text-right", children: label }), _jsx("td", { className: 'px-3 py-1 text-right ' + (strong ? 'text-base text-foreground' : 'text-foreground'), "data-slot": slot, children: value }), _jsx("td", {})] }));
|
|
161
179
|
}
|
|
180
|
+
/** Borrar renglón: aparece al pasar o enfocar la fila (siempre visible en móvil). */
|
|
181
|
+
const DELETE = 'size-8 text-muted-foreground opacity-0 transition-opacity hover:text-destructive focus-visible:opacity-100 group-hover:opacity-100 group-focus-within:opacity-100 max-sm:opacity-100';
|
|
182
|
+
/** Celda densa: misma altura en todos los renglones. */
|
|
183
|
+
const CELL = 'px-2 py-1.5 align-top';
|
|
184
|
+
/** Inputs de celda: un poco más bajos que los del formulario (densidad de editor). */
|
|
185
|
+
const INPUT = 'h-8';
|
|
186
|
+
/** Numéricos: alineados a la derecha y con cifras tabulares. */
|
|
187
|
+
const NUM = 'h-8 text-right tabular-nums';
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { type KeyboardEvent } from 'react';
|
|
2
|
+
import { type ProductQuery, type ProductResult, type ProductVariant } from './product-search';
|
|
3
|
+
export type LineProductSearch = (query: Exclude<ProductQuery, {
|
|
4
|
+
kind: 'empty';
|
|
5
|
+
}>, signal: AbortSignal) => Promise<ProductResult[]>;
|
|
6
|
+
export interface LineProductHit {
|
|
7
|
+
product: ProductResult;
|
|
8
|
+
variant?: ProductVariant;
|
|
9
|
+
}
|
|
10
|
+
export interface LineProductCellProps {
|
|
11
|
+
search: LineProductSearch;
|
|
12
|
+
/** Texto de la celda (la descripción del renglón, o el borrador del renglón vacío). */
|
|
13
|
+
text: string;
|
|
14
|
+
onTextChange: (text: string) => void;
|
|
15
|
+
onPick: (product: ProductResult, variant?: ProductVariant) => void;
|
|
16
|
+
/**
|
|
17
|
+
* Teclas que el buscador no consume (lista cerrada): el grid aplica sus
|
|
18
|
+
* comandos (Enter agrega, Supr en celda vacía borra…).
|
|
19
|
+
*/
|
|
20
|
+
onKeyDownClosed?: (e: KeyboardEvent<HTMLInputElement>) => void;
|
|
21
|
+
warehouseId?: string;
|
|
22
|
+
currency?: string;
|
|
23
|
+
placeholder?: string;
|
|
24
|
+
ariaLabel: string;
|
|
25
|
+
disabled?: boolean;
|
|
26
|
+
invalid?: boolean;
|
|
27
|
+
className?: string;
|
|
28
|
+
/** Atributos de datos para que el grid ubique la celda (foco por teclado). */
|
|
29
|
+
dataCell?: string;
|
|
30
|
+
}
|
|
31
|
+
export declare const LineProductCell: import("react").ForwardRefExoticComponent<LineProductCellProps & import("react").RefAttributes<HTMLInputElement>>;
|
|
32
|
+
//# sourceMappingURL=line-product-cell.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"line-product-cell.d.ts","sourceRoot":"","sources":["../../src/business/line-product-cell.tsx"],"names":[],"mappings":"AASA,OAAO,EAAgD,KAAK,aAAa,EAAE,MAAM,OAAO,CAAA;AAOxF,OAAO,EAAqC,KAAK,YAAY,EAAE,KAAK,aAAa,EAAE,KAAK,cAAc,EAAE,MAAM,kBAAkB,CAAA;AAEhI,MAAM,MAAM,iBAAiB,GAAG,CAAC,KAAK,EAAE,OAAO,CAAC,YAAY,EAAE;IAAE,IAAI,EAAE,OAAO,CAAA;CAAE,CAAC,EAAE,MAAM,EAAE,WAAW,KAAK,OAAO,CAAC,aAAa,EAAE,CAAC,CAAA;AAElI,MAAM,WAAW,cAAc;IAC3B,OAAO,EAAE,aAAa,CAAA;IACtB,OAAO,CAAC,EAAE,cAAc,CAAA;CAC3B;AAED,MAAM,WAAW,oBAAoB;IACjC,MAAM,EAAE,iBAAiB,CAAA;IACzB,uFAAuF;IACvF,IAAI,EAAE,MAAM,CAAA;IACZ,YAAY,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,IAAI,CAAA;IACpC,MAAM,EAAE,CAAC,OAAO,EAAE,aAAa,EAAE,OAAO,CAAC,EAAE,cAAc,KAAK,IAAI,CAAA;IAClE;;;OAGG;IACH,eAAe,CAAC,EAAE,CAAC,CAAC,EAAE,aAAa,CAAC,gBAAgB,CAAC,KAAK,IAAI,CAAA;IAC9D,WAAW,CAAC,EAAE,MAAM,CAAA;IACpB,QAAQ,CAAC,EAAE,MAAM,CAAA;IACjB,WAAW,CAAC,EAAE,MAAM,CAAA;IACpB,SAAS,EAAE,MAAM,CAAA;IACjB,QAAQ,CAAC,EAAE,OAAO,CAAA;IAClB,OAAO,CAAC,EAAE,OAAO,CAAA;IACjB,SAAS,CAAC,EAAE,MAAM,CAAA;IAClB,8EAA8E;IAC9E,QAAQ,CAAC,EAAE,MAAM,CAAA;CACpB;AAED,eAAO,MAAM,eAAe,mHA2K1B,CAAA"}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
// LineProductCell — buscador de producto DENTRO de la celda «Descripción» de un
|
|
3
|
+
// renglón (patrón de editor de facturas pro: Odoo/Holded/Linear). Ocupa solo su
|
|
4
|
+
// celda; la lista de resultados va en un portal (Popover de Radix anclado al
|
|
5
|
+
// input): nunca la recorta el overflow de la tabla ni del modal, queda por
|
|
6
|
+
// encima del diálogo y se abre hacia arriba si abajo no hay espacio.
|
|
7
|
+
//
|
|
8
|
+
// Teclado: ↑/↓ recorren, Enter elige (el grid pasa el foco a «Cant.»), Esc
|
|
9
|
+
// cierra, Tab cierra y avanza normal. El texto escrito sin elegir queda como
|
|
10
|
+
// descripción libre (el renglón libre sigue funcionando igual).
|
|
11
|
+
import { forwardRef, useId, useMemo, useRef, useState } from 'react';
|
|
12
|
+
import { useTranslation } from 'react-i18next';
|
|
13
|
+
import { Loader2, Search } from 'lucide-react';
|
|
14
|
+
import { Badge, Input, Popover, PopoverAnchor, PopoverContent } from '@asteby/metacore-ui/primitives';
|
|
15
|
+
import { useFormatter } from './format';
|
|
16
|
+
import { useAsyncSearch } from './use-async-search';
|
|
17
|
+
import { useDebouncedValue } from '../use-debounced-value';
|
|
18
|
+
import { availableStock, parseProductQuery } from './product-search';
|
|
19
|
+
export const LineProductCell = forwardRef(function LineProductCell({ search, text, onTextChange, onPick, onKeyDownClosed, warehouseId, currency, placeholder, ariaLabel, disabled, invalid, className, dataCell }, ref) {
|
|
20
|
+
const { t } = useTranslation();
|
|
21
|
+
const fmt = useFormatter({ currency });
|
|
22
|
+
const listId = useId();
|
|
23
|
+
const anchorRef = useRef(null);
|
|
24
|
+
// Solo se abre mientras se escribe: enfocar un renglón libre ya capturado no
|
|
25
|
+
// despliega «sin resultados» encima de la tabla.
|
|
26
|
+
const [typing, setTyping] = useState(false);
|
|
27
|
+
const [active, setActive] = useState(0);
|
|
28
|
+
const parsed = parseProductQuery(text);
|
|
29
|
+
const run = useMemo(() => (q, signal) => {
|
|
30
|
+
const p = parseProductQuery(q);
|
|
31
|
+
return p.kind === 'empty' ? Promise.resolve([]) : search(p, signal);
|
|
32
|
+
}, [search]);
|
|
33
|
+
const minChars = parsed.kind === 'barcode' ? 8 : 2;
|
|
34
|
+
const delay = parsed.kind === 'barcode' ? 0 : 250;
|
|
35
|
+
const { results, loading: fetching } = useAsyncSearch(typing ? text : '', run, { minChars, delay });
|
|
36
|
+
// Mientras el debounce no alcanza lo escrito, la lista dice «Buscando…» y no
|
|
37
|
+
// un «sin coincidencias» prematuro.
|
|
38
|
+
const settled = useDebouncedValue(text.trim(), delay) === text.trim();
|
|
39
|
+
const loading = fetching || !settled;
|
|
40
|
+
const flat = useMemo(() => results.flatMap((p) => (p.variants?.length ?? 0) > 0 ? p.variants.map((v) => ({ product: p, variant: v })) : [{ product: p }]), [results]);
|
|
41
|
+
const queryReady = typing && text.trim().length >= minChars;
|
|
42
|
+
const open = queryReady && (loading || flat.length > 0 || text.trim().length >= 2);
|
|
43
|
+
const hasHits = flat.length > 0;
|
|
44
|
+
const close = () => {
|
|
45
|
+
setTyping(false);
|
|
46
|
+
setActive(0);
|
|
47
|
+
};
|
|
48
|
+
const choose = (idx) => {
|
|
49
|
+
const hit = flat[idx];
|
|
50
|
+
if (!hit)
|
|
51
|
+
return;
|
|
52
|
+
close();
|
|
53
|
+
onPick(hit.product, hit.variant);
|
|
54
|
+
};
|
|
55
|
+
const onKeyDown = (e) => {
|
|
56
|
+
if (open && hasHits) {
|
|
57
|
+
if (e.key === 'ArrowDown') {
|
|
58
|
+
e.preventDefault();
|
|
59
|
+
setActive((n) => Math.min(flat.length - 1, n + 1));
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
if (e.key === 'ArrowUp') {
|
|
63
|
+
e.preventDefault();
|
|
64
|
+
setActive((n) => Math.max(0, n - 1));
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
if (e.key === 'Enter') {
|
|
68
|
+
e.preventDefault();
|
|
69
|
+
choose(active);
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
if (open && e.key === 'Escape') {
|
|
74
|
+
// Cierra la lista sin cerrar el diálogo que la contiene.
|
|
75
|
+
e.preventDefault();
|
|
76
|
+
e.stopPropagation();
|
|
77
|
+
close();
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
if (e.key === 'Tab')
|
|
81
|
+
close();
|
|
82
|
+
onKeyDownClosed?.(e);
|
|
83
|
+
};
|
|
84
|
+
return (_jsxs(Popover, { open: open, onOpenChange: (o) => !o && close(), children: [_jsx(PopoverAnchor, { asChild: true, children: _jsxs("div", { ref: anchorRef, className: "relative w-full min-w-0", "data-slot": "line-product-cell", children: [_jsx(Search, { className: "pointer-events-none absolute left-2.5 top-1/2 size-3.5 -translate-y-1/2 text-muted-foreground", "aria-hidden": true }), _jsx(Input, { ref: ref, value: text, disabled: disabled, role: "combobox", "aria-autocomplete": "list", "aria-expanded": open, "aria-controls": open ? listId : undefined, "aria-activedescendant": open && hasHits ? `${listId}-${active}` : undefined, "aria-label": ariaLabel, "aria-invalid": invalid || undefined, autoComplete: "off", "data-cell": dataCell, placeholder: placeholder ?? t('lineItems.searchPlaceholder', { defaultValue: 'Producto, medida (205/55R16), SKU o código de barras' }), className: 'h-8 pl-8 ' + (className ?? ''), onChange: (e) => {
|
|
85
|
+
onTextChange(e.target.value);
|
|
86
|
+
setTyping(true);
|
|
87
|
+
setActive(0);
|
|
88
|
+
}, onBlur: () => close(), onKeyDown: onKeyDown })] }) }), _jsx(PopoverContent, { align: "start", sideOffset: 4, collisionPadding: 8, className: "p-1", "data-slot": "line-product-results",
|
|
89
|
+
// El foco se queda en la celda: se sigue escribiendo mientras se ve la lista.
|
|
90
|
+
onOpenAutoFocus: (e) => e.preventDefault(), onCloseAutoFocus: (e) => e.preventDefault(), onInteractOutside: (e) => {
|
|
91
|
+
if (anchorRef.current?.contains(e.target))
|
|
92
|
+
e.preventDefault();
|
|
93
|
+
}, style: { width: 'max(var(--radix-popover-trigger-width), 22rem)', maxWidth: 'calc(100vw - 1rem)' }, children: hasHits ? (_jsx("ul", { id: listId, role: "listbox", "aria-label": ariaLabel, className: "max-h-72 overflow-auto", children: flat.map((hit, idx) => {
|
|
94
|
+
const price = hit.variant?.price ?? hit.product.price;
|
|
95
|
+
const stock = availableStock(hit.variant ?? hit.product, warehouseId);
|
|
96
|
+
const meta = [hit.variant?.sku ?? hit.product.sku, hit.product.tire?.normalized].filter(Boolean).join(' · ');
|
|
97
|
+
return (_jsx("li", { id: `${listId}-${idx}`, role: "option", "aria-selected": idx === active, children: _jsxs("button", { type: "button", tabIndex: -1, className: 'flex w-full items-center justify-between gap-3 rounded-sm px-2 py-1.5 text-left text-sm outline-none hover:bg-accent hover:text-accent-foreground ' +
|
|
98
|
+
(idx === active ? 'bg-accent text-accent-foreground' : ''), onMouseDown: (e) => e.preventDefault(), onMouseEnter: () => setActive(idx), onClick: () => choose(idx), children: [_jsxs("span", { className: "min-w-0", children: [_jsx("span", { className: "block truncate font-medium", children: hit.variant ? `${hit.product.name} · ${hit.variant.label}` : hit.product.name }), meta && _jsx("span", { className: "block truncate text-xs text-muted-foreground", children: meta })] }), _jsxs("span", { className: "flex shrink-0 items-center gap-2", children: [stock != null && (_jsx(Badge, { variant: "secondary", className: "font-normal tabular-nums", children: stock })), price != null && _jsx("span", { className: "tabular-nums", children: fmt.money(price) })] })] }) }, `${hit.product.id}:${hit.variant?.id ?? ''}`));
|
|
99
|
+
}) })) : loading ? (_jsxs("p", { className: "flex items-center justify-center gap-2 py-4 text-sm text-muted-foreground", children: [_jsx(Loader2, { className: "size-4 animate-spin", "aria-hidden": true }), t('common.searching', { defaultValue: 'Buscando…' })] })) : (_jsx("p", { className: "px-2 py-3 text-sm text-muted-foreground", children: t('lineItems.noProduct', { defaultValue: 'Sin coincidencias. Se usará como descripción libre.' }) })) })] }));
|
|
100
|
+
});
|
|
@@ -73,7 +73,8 @@ export interface DynamicSelectFieldProps {
|
|
|
73
73
|
*/
|
|
74
74
|
descriptionAsBadge?: boolean;
|
|
75
75
|
/**
|
|
76
|
-
* Hide the inline "+"
|
|
76
|
+
* Hide the inline create/edit affordances (joined "+" / pencil and the
|
|
77
|
+
* "Crear …" list footer) even when `field.ref` is set.
|
|
77
78
|
* Useful for static / filtered lists where creating a new record is not
|
|
78
79
|
* meaningful in context.
|
|
79
80
|
*/
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"dynamic-select-field.d.ts","sourceRoot":"","sources":["../src/dynamic-select-field.tsx"],"names":[],"mappings":"AA6CA,OAAO,EAAsB,KAAK,cAAc,EAAE,MAAM,wBAAwB,CAAA;AAEhF,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,SAAS,CAAA;AAE7C;;;;GAIG;AACH,eAAO,MAAM,oBAAoB,gDAAgD,CAAA;AAEjF;;;;;;;;GAQG;AACH,wBAAgB,WAAW,CAAC,EACxB,KAAK,EACL,IAAI,EACJ,IAAS,GACZ,EAAE;IACC,KAAK,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;IACrB,IAAI,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;IACpB,IAAI,CAAC,EAAE,MAAM,CAAA;CAChB,+BA4BA;AAED;;;;GAIG;AACH,wBAAgB,UAAU,CAAC,EACvB,MAAM,EACN,IAAS,GACZ,EAAE;IACC,MAAM,CAAC,EAAE,IAAI,CAAC,cAAc,EAAE,OAAO,GAAG,OAAO,GAAG,MAAM,GAAG,OAAO,CAAC,GAAG,IAAI,CAAA;IAC1E,IAAI,CAAC,EAAE,MAAM,CAAA;CAChB,sCA4BA;AAMD,MAAM,WAAW,uBAAuB;IACpC,KAAK,EAAE,cAAc,CAAA;IACrB,KAAK,EAAE,GAAG,CAAA;IACV,QAAQ,EAAE,CAAC,CAAC,EAAE,GAAG,KAAK,IAAI,CAAA;IAC1B,yFAAyF;IACzF,MAAM,CAAC,EAAE,CAAC,GAAG,EAAE,cAAc,KAAK,IAAI,CAAA;IACtC;;;;;OAKG;IACH,UAAU,CAAC,EAAE,cAAc,GAAG,IAAI,CAAA;IAClC;;;;;;OAMG;IACH,YAAY,CAAC,EAAE,MAAM,CAAA;IACrB,6EAA6E;IAC7E,WAAW,CAAC,EAAE,MAAM,CAAA;IACpB;;;;;;OAMG;IACH,QAAQ,CAAC,EAAE,OAAO,CAAA;IAClB;;;;;OAKG;IACH,aAAa,CAAC,EAAE,SAAS,cAAc,EAAE,GAAG,IAAI,CAAA;IAChD;;;OAGG;IACH,kBAAkB,CAAC,EAAE,OAAO,CAAA;IAC5B
|
|
1
|
+
{"version":3,"file":"dynamic-select-field.d.ts","sourceRoot":"","sources":["../src/dynamic-select-field.tsx"],"names":[],"mappings":"AA6CA,OAAO,EAAsB,KAAK,cAAc,EAAE,MAAM,wBAAwB,CAAA;AAEhF,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,SAAS,CAAA;AAE7C;;;;GAIG;AACH,eAAO,MAAM,oBAAoB,gDAAgD,CAAA;AAEjF;;;;;;;;GAQG;AACH,wBAAgB,WAAW,CAAC,EACxB,KAAK,EACL,IAAI,EACJ,IAAS,GACZ,EAAE;IACC,KAAK,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;IACrB,IAAI,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;IACpB,IAAI,CAAC,EAAE,MAAM,CAAA;CAChB,+BA4BA;AAED;;;;GAIG;AACH,wBAAgB,UAAU,CAAC,EACvB,MAAM,EACN,IAAS,GACZ,EAAE;IACC,MAAM,CAAC,EAAE,IAAI,CAAC,cAAc,EAAE,OAAO,GAAG,OAAO,GAAG,MAAM,GAAG,OAAO,CAAC,GAAG,IAAI,CAAA;IAC1E,IAAI,CAAC,EAAE,MAAM,CAAA;CAChB,sCA4BA;AAMD,MAAM,WAAW,uBAAuB;IACpC,KAAK,EAAE,cAAc,CAAA;IACrB,KAAK,EAAE,GAAG,CAAA;IACV,QAAQ,EAAE,CAAC,CAAC,EAAE,GAAG,KAAK,IAAI,CAAA;IAC1B,yFAAyF;IACzF,MAAM,CAAC,EAAE,CAAC,GAAG,EAAE,cAAc,KAAK,IAAI,CAAA;IACtC;;;;;OAKG;IACH,UAAU,CAAC,EAAE,cAAc,GAAG,IAAI,CAAA;IAClC;;;;;;OAMG;IACH,YAAY,CAAC,EAAE,MAAM,CAAA;IACrB,6EAA6E;IAC7E,WAAW,CAAC,EAAE,MAAM,CAAA;IACpB;;;;;;OAMG;IACH,QAAQ,CAAC,EAAE,OAAO,CAAA;IAClB;;;;;OAKG;IACH,aAAa,CAAC,EAAE,SAAS,cAAc,EAAE,GAAG,IAAI,CAAA;IAChD;;;OAGG;IACH,kBAAkB,CAAC,EAAE,OAAO,CAAA;IAC5B;;;;;OAKG;IACH,UAAU,CAAC,EAAE,OAAO,CAAA;IACpB;;;;;OAKG;IACH,cAAc,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAA;IACxC;;;OAGG;IACH,kBAAkB,CAAC,EAAE,MAAM,EAAE,CAAA;IAC7B,oEAAoE;IACpE,OAAO,CAAC,EAAE,OAAO,CAAA;CACpB;AAED,wBAAgB,kBAAkB,CAAC,EAC/B,KAAK,EACL,KAAK,EACL,QAAQ,EACR,MAAM,EACN,UAAU,EACV,YAAY,EACZ,WAAW,EACX,QAAgB,EAChB,aAAoB,EACpB,kBAA0B,EAC1B,UAAkB,EAClB,cAAc,EACd,kBAAkB,EAClB,OAAe,GAClB,EAAE,uBAAuB,+BA0VzB;AAED,eAAe,kBAAkB,CAAA"}
|
|
@@ -25,10 +25,11 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
|
25
25
|
import { useEffect, useRef, useState } from 'react';
|
|
26
26
|
import { getOptionFilter } from './option-filter';
|
|
27
27
|
import { useTranslation } from 'react-i18next';
|
|
28
|
-
import { Badge, Button, Command,
|
|
29
|
-
import { Check, ChevronsUpDown, Loader2,
|
|
28
|
+
import { Badge, Button, Command, CommandGroup, CommandInput, CommandItem, CommandList, Popover, PopoverContent, PopoverTrigger, InitialsAvatar, } from '@asteby/metacore-ui/primitives';
|
|
29
|
+
import { Check, ChevronsUpDown, Loader2, ScanLine } from 'lucide-react';
|
|
30
30
|
import { resolveColorCss } from '@asteby/metacore-ui/lib';
|
|
31
31
|
import { BarcodeScanner } from './barcode-scanner';
|
|
32
|
+
import { JOINED_TRIGGER_CLASS, PickerCreateItem, RecordPickerAction, hasRecordPickerAction } from './record-picker-actions';
|
|
32
33
|
import { DynamicIcon, isLucideIconName } from './dynamic-icon';
|
|
33
34
|
import { useDebouncedValue } from './use-debounced-value';
|
|
34
35
|
import { useOptionsResolver } from './use-options-resolver';
|
|
@@ -193,9 +194,31 @@ export function DynamicSelectField({ field, value, onChange, onPick, seedOption,
|
|
|
193
194
|
// via a decoupled window event the host listens for. On success the host
|
|
194
195
|
// hands back the new record and we select it immediately. No host import →
|
|
195
196
|
// no circular dependency; works for ANY dynamic_select with a `ref`.
|
|
197
|
+
// Inline-edit: with a value selected the joined pencil opens THAT record's
|
|
198
|
+
// edit modal through the sibling `metacore:edit-record` event (the host's
|
|
199
|
+
// RecordCreateBridge already listens for it). On save the trigger label
|
|
200
|
+
// refreshes from the returned record.
|
|
201
|
+
const openEdit = () => {
|
|
202
|
+
if (!fieldRef || !value || typeof window === 'undefined')
|
|
203
|
+
return;
|
|
204
|
+
setOpen(false);
|
|
205
|
+
window.dispatchEvent(new CustomEvent('metacore:edit-record', {
|
|
206
|
+
detail: {
|
|
207
|
+
model: fieldRef,
|
|
208
|
+
recordId: String(value),
|
|
209
|
+
onSaved: (rec) => {
|
|
210
|
+
const id = String(value);
|
|
211
|
+
const label = rec ? rec.name ?? rec.label ?? rec.title : undefined;
|
|
212
|
+
if (label != null)
|
|
213
|
+
setPicked({ ...(selectedOption ?? { id, value: id }), id, value: id, label: String(label), name: String(label) });
|
|
214
|
+
},
|
|
215
|
+
},
|
|
216
|
+
}));
|
|
217
|
+
};
|
|
196
218
|
const openCreate = () => {
|
|
197
219
|
if (!fieldRef || typeof window === 'undefined')
|
|
198
220
|
return;
|
|
221
|
+
setOpen(false);
|
|
199
222
|
window.dispatchEvent(new CustomEvent('metacore:create-record', {
|
|
200
223
|
detail: {
|
|
201
224
|
model: fieldRef,
|
|
@@ -225,22 +248,29 @@ export function DynamicSelectField({ field, value, onChange, onPick, seedOption,
|
|
|
225
248
|
// to the cell. Without min-w-0 the combobox+button row sizes to its content
|
|
226
249
|
// (the long empty-state placeholder) and overflows the column, pushing the
|
|
227
250
|
// "+" off-screen — it only "fit" once a short value was selected.
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
251
|
+
const canMutate = !!fieldRef && !hideCreate && !useStatic;
|
|
252
|
+
const joined = canMutate && !blockedByDependency && hasRecordPickerAction(!!value, openCreate, openEdit);
|
|
253
|
+
const fieldName = field.label ? t(field.label, { defaultValue: field.label }) : fieldRef ?? '';
|
|
254
|
+
return (_jsxs("div", { className: "flex w-full min-w-0 items-center gap-1.5", "data-slot": "dynamic-select", children: [_jsxs("div", { className: "flex min-w-0 flex-1 items-center", children: [_jsxs(Popover, { open: open && !blockedByDependency, onOpenChange: (o) => { if (!blockedByDependency)
|
|
255
|
+
setOpen(o); }, children: [_jsx(PopoverTrigger, { asChild: true, children: _jsxs(Button, { type: "button", variant: "outline", role: "combobox", "aria-expanded": open, id: field.key, disabled: blockedByDependency, "aria-invalid": invalid || undefined, className: 'min-w-0 flex-1 justify-between font-normal' +
|
|
256
|
+
(joined ? ` ${JOINED_TRIGGER_CLASS}` : '') +
|
|
257
|
+
(invalid ? ' border-destructive ring-1 ring-destructive/30' : ''), "data-empty": !value, "data-depends-blocked": blockedByDependency ? '' : undefined, children: [_jsxs("span", { className: "flex min-w-0 flex-1 items-center gap-2 text-left", children: [value && selectedOption ? (_jsx(OptionLead, { option: selectedOption, size: 20 })) : null, _jsx("span", { className: 'min-w-0 flex-1 truncate ' + (selectedLabel ? '' : 'text-muted-foreground'), children: blockedByDependency
|
|
258
|
+
? (dependsHint || DEFAULT_DEPENDS_HINT)
|
|
259
|
+
: selectedLabel || ph('Buscar…') }), descriptionAsBadge && selectedOption?.description ? (_jsx(Badge, { variant: "secondary", className: "shrink-0 font-normal tabular-nums", children: selectedOption.description })) : null] }), _jsx(ChevronsUpDown, { className: "ml-2 size-4 shrink-0 opacity-50" })] }) }), _jsx(PopoverContent, { className: "p-0", align: "start",
|
|
260
|
+
// Portaled (never clipped by a table/modal overflow) and
|
|
261
|
+
// collision-aware: flips above the trigger when there is no
|
|
262
|
+
// room below. Match the trigger width without an arbitrary
|
|
263
|
+
// Tailwind class (those don't always survive a consuming app's
|
|
264
|
+
// Tailwind scan), with a floor so short cells stay readable.
|
|
265
|
+
collisionPadding: 8, style: { width: 'max(var(--radix-popover-trigger-width), 14rem)', maxWidth: 'calc(100vw - 1rem)' }, children: _jsxs(Command, { shouldFilter: false, children: [_jsx(CommandInput, { placeholder: ph('Buscar…'), value: search, onValueChange: setSearch }), _jsxs(CommandList, { children: [loading && (_jsxs("div", { className: "text-muted-foreground flex items-center justify-center gap-2 py-6 text-sm", children: [_jsx(Loader2, { className: "size-4 animate-spin" }), "Buscando\u2026"] })), !loading && options.length === 0 && (_jsx("div", { className: "text-muted-foreground py-6 text-center text-sm", "data-slot": "picker-empty", children: useStatic
|
|
266
|
+
? debounced
|
|
267
|
+
? 'Sin resultados'
|
|
268
|
+
: 'Sin opciones'
|
|
269
|
+
: debounced
|
|
270
|
+
? 'Sin resultados'
|
|
271
|
+
: 'Sin resultados — escribe para filtrar' })), !loading && options.length > 0 && (_jsx(CommandGroup, { className: "max-h-64 overflow-auto", children: options.map((opt) => {
|
|
272
|
+
const isSel = String(opt.id) === String(value);
|
|
273
|
+
return (_jsxs(CommandItem, { value: String(opt.id), onSelect: () => handlePick(opt), children: [_jsx(Check, { className: 'mr-2 size-4 shrink-0 ' + (isSel ? 'opacity-100' : 'opacity-0') }), _jsx(OptionLead, { option: opt, size: 24 }), descriptionAsBadge ? (_jsxs("div", { className: "ml-2 flex min-w-0 flex-1 items-center gap-2", children: [_jsx("span", { className: "min-w-0 flex-1 truncate", children: opt.label }), opt.description ? (_jsx(Badge, { variant: "secondary", className: "shrink-0 font-normal tabular-nums", children: opt.description })) : null] })) : (_jsxs("div", { className: "ml-2 flex min-w-0 flex-col", children: [_jsx("span", { className: "truncate", children: opt.label }), opt.description && (_jsx("span", { className: "text-muted-foreground truncate text-xs", children: opt.description }))] }))] }, String(opt.id)));
|
|
274
|
+
}) })), canMutate && !loading && (_jsx(PickerCreateItem, { label: fieldName, onSelect: openCreate }))] })] }) })] }), canMutate && !blockedByDependency && (_jsx(RecordPickerAction, { hasValue: !!value, label: fieldName, onCreate: openCreate, onEdit: openEdit }))] }), scanEnabled && (_jsx(Button, { type: "button", variant: "outline", size: "icon", className: "size-9 shrink-0", onClick: () => setScanOpen(true), title: "Escanear con la c\u00E1mara", "aria-label": "Escanear c\u00F3digo de barras con la c\u00E1mara", children: _jsx(ScanLine, { className: "size-4" }) })), scanEnabled && (_jsx(BarcodeScanner, { open: scanOpen, onClose: () => setScanOpen(false), onDetected: handleScanDetected, continuous: false, position: "fixed", title: `Escanear ${field.label ?? ''}`.trim() }))] }));
|
|
245
275
|
}
|
|
246
276
|
export default DynamicSelectField;
|