@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.
Files changed (55) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/dist/api-context.d.ts +9 -1
  3. package/dist/api-context.d.ts.map +1 -1
  4. package/dist/api-context.js +6 -4
  5. package/dist/business/document-editor-panels.d.ts +2 -1
  6. package/dist/business/document-editor-panels.d.ts.map +1 -1
  7. package/dist/business/document-editor-panels.js +6 -6
  8. package/dist/business/document-editor.d.ts.map +1 -1
  9. package/dist/business/document-editor.js +23 -9
  10. package/dist/business/index.d.ts +1 -0
  11. package/dist/business/index.d.ts.map +1 -1
  12. package/dist/business/index.js +1 -0
  13. package/dist/business/line-items-editor.d.ts +6 -1
  14. package/dist/business/line-items-editor.d.ts.map +1 -1
  15. package/dist/business/line-items-editor.js +109 -83
  16. package/dist/business/line-product-cell.d.ts +32 -0
  17. package/dist/business/line-product-cell.d.ts.map +1 -0
  18. package/dist/business/line-product-cell.js +100 -0
  19. package/dist/dynamic-select-field.d.ts +2 -1
  20. package/dist/dynamic-select-field.d.ts.map +1 -1
  21. package/dist/dynamic-select-field.js +49 -19
  22. package/dist/dynamic-table.d.ts +31 -1
  23. package/dist/dynamic-table.d.ts.map +1 -1
  24. package/dist/dynamic-table.js +37 -4
  25. package/dist/entity-select.d.ts.map +1 -1
  26. package/dist/entity-select.js +13 -11
  27. package/dist/field-grid.d.ts +7 -1
  28. package/dist/field-grid.d.ts.map +1 -1
  29. package/dist/field-grid.js +4 -2
  30. package/dist/index.d.ts +2 -0
  31. package/dist/index.d.ts.map +1 -1
  32. package/dist/index.js +1 -0
  33. package/dist/query-batch.d.ts.map +1 -1
  34. package/dist/query-batch.js +17 -1
  35. package/dist/record-picker-actions.d.ts +27 -0
  36. package/dist/record-picker-actions.d.ts.map +1 -0
  37. package/dist/record-picker-actions.js +32 -0
  38. package/package.json +1 -1
  39. package/src/__tests__/api-batch-opt-out.test.tsx +83 -0
  40. package/src/__tests__/document-editor-inline-selects.test.tsx +202 -0
  41. package/src/__tests__/dynamic-table-bulk-actions.test.tsx +140 -0
  42. package/src/__tests__/dynamic-table-load-error.test.tsx +97 -0
  43. package/src/api-context.tsx +19 -4
  44. package/src/business/document-editor-panels.tsx +13 -10
  45. package/src/business/document-editor.tsx +72 -20
  46. package/src/business/index.ts +1 -0
  47. package/src/business/line-items-editor.tsx +229 -174
  48. package/src/business/line-product-cell.tsx +219 -0
  49. package/src/dynamic-select-field.tsx +51 -22
  50. package/src/dynamic-table.tsx +91 -4
  51. package/src/entity-select.tsx +17 -39
  52. package/src/field-grid.tsx +15 -1
  53. package/src/index.ts +2 -0
  54. package/src/query-batch.ts +18 -1
  55. 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, Search, StickyNote, Trash2 } from 'lucide-react';
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 { useAsyncSearch } from './use-async-search';
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
- emit(addProductLine(value, product, variant, { priceSource, warehouseId, mergeSameProduct }));
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
- return (_jsxs("div", { "data-slot": "line-items-editor", "data-component": "DocumentLinesGrid", "data-mode": mode, className: "space-y-2", children: [_jsx("div", { className: "overflow-x-auto rounded-md border", children: _jsxs("table", { className: "w-full text-sm", children: [_jsx("thead", { className: "bg-muted/50 text-left text-xs text-muted-foreground", children: _jsxs("tr", { children: [_jsx("th", { className: "px-2 py-2", children: t('lineItems.description', { defaultValue: 'Descripción' }) }), show('unit') && _jsx("th", { className: "w-20 px-2 py-2", children: t('lineItems.unit', { defaultValue: 'Unidad' }) }), _jsx("th", { className: "w-36 px-2 py-2 text-right", children: t('lineItems.quantity', { defaultValue: 'Cant.' }) }), _jsx("th", { className: "w-32 px-2 py-2 text-right", children: priceSource === 'cost'
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: "w-28 px-2 py-2 text-right", children: discountMode === 'amount'
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-20 px-2 py-2 text-right", children: t('lineItems.tax', { defaultValue: 'IVA %' }) }), show('technician') && _jsx("th", { className: "w-32 px-2 py-2", children: t('lineItems.technician', { defaultValue: 'Técnico' }) }), show('lot') && _jsx("th", { className: "w-28 px-2 py-2", children: t('lineItems.lot', { defaultValue: 'Lote' }) }), show('dot') && _jsx("th", { className: "w-24 px-2 py-2", children: "DOT" }), _jsx("th", { className: "w-32 px-2 py-2 text-right", children: t('lineItems.amount', { defaultValue: 'Importe' }) }), _jsx("th", { className: "w-10" })] }) }), _jsxs("tbody", { children: [value.map((l, i) => {
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: "px-2 py-1.5", 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: l.kind === 'section' ? 'font-semibold' : 'italic', onChange: (e) => patch(i, { description: e.target.value }), onKeyDown: (e) => {
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: "px-2 py-1.5", children: [_jsx(Input, { value: l.description, disabled: locked, "aria-label": t('lineItems.description', { defaultValue: 'Descripción' }), "aria-invalid": !!cell(i, 'description'), onChange: (e) => patch(i, { description: e.target.value }), onKeyDown: (e) => {
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: "text-right", 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: "px-2 py-1.5", children: [_jsx(Input, { inputMode: "decimal", className: "text-right", 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 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: "px-2 py-1.5", 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: "text-right", 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: "px-2 py-1.5", children: _jsx(Input, { inputMode: "decimal", className: "text-right", 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: "px-2 py-1.5", children: _jsx(Input, { value: l.technician_id ?? '', disabled: locked, onChange: (e) => patch(i, { technician_id: e.target.value }) }) })), show('lot') && (_jsx("td", { className: "px-2 py-1.5", children: _jsx(Input, { value: l.lot ?? '', disabled: locked, onChange: (e) => patch(i, { lot: e.target.value }) }) })), show('dot') && (_jsx("td", { className: "px-2 py-1.5", children: _jsx(Input, { value: l.dot ?? '', disabled: locked, onChange: (e) => patch(i, { dot: e.target.value }) }) })), _jsx("td", { className: "px-2 py-2 text-right tabular-nums", "data-slot": "line-amount", children: fmt.money(a.net) }), _jsx("td", { className: "px-1 py-1.5", children: 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" }) })) })] }, l.key));
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(SearchRow, { search: search, warehouseId: warehouseId, currency: currency, onPick: pickProduct, onAddFree: () => emit([...value, makeLine(discountMode === 'amount' ? { discount_kind: 'amount' } : {})]) }))] })] }) }), 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: () => {
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' }) })] })), _jsxs("div", { className: "flex flex-wrap items-start justify-between gap-3", children: [canAdd ? (_jsxs("div", { className: "flex flex-wrap gap-2", 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: "outline", onClick: () => emit([...value, makeLine(discountMode === 'amount' ? { discount_kind: 'amount' } : {})]), 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' })] })] }))] })) : (_jsx("span", {})), _jsxs("dl", { className: "min-w-56 space-y-0.5 text-sm tabular-nums", children: [_jsxs("div", { className: "flex justify-between gap-6", children: [_jsx("dt", { className: "text-muted-foreground", children: t('lineItems.subtotal', { defaultValue: 'Subtotal' }) }), _jsx("dd", { children: fmt.money(totals.subtotal) })] }), totals.discount > 0 && _jsxs("div", { className: "flex justify-between gap-6", children: [_jsx("dt", { className: "text-muted-foreground", children: t('lineItems.discountTotal', { defaultValue: 'Descuento' }) }), _jsxs("dd", { children: ["-", fmt.money(totals.discount)] })] }), totals.tax > 0 && _jsxs("div", { className: "flex justify-between gap-6", children: [_jsx("dt", { className: "text-muted-foreground", children: t('lineItems.taxTotal', { defaultValue: 'Impuestos' }) }), _jsx("dd", { "data-slot": "tax-total", children: fmt.money(totals.tax) })] }), _jsxs("div", { className: "flex justify-between gap-6 font-semibold", children: [_jsx("dt", { children: t('lineItems.total', { defaultValue: 'Total' }) }), _jsx("dd", { "data-slot": "grand-total", children: fmt.money(totals.total) })] })] })] })] }));
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
- function SearchRow({ search, warehouseId, currency, onPick, onAddFree, }) {
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
- const [active, setActive] = useState(0);
109
- const parsed = parseProductQuery(text);
110
- const run = useMemo(() => {
111
- return (q, signal) => {
112
- const p = parseProductQuery(q);
113
- return p.kind === 'empty' ? Promise.resolve([]) : search(p, signal);
114
- };
115
- }, [search]);
116
- const { results, loading } = useAsyncSearch(text, run, {
117
- minChars: parsed.kind === 'barcode' ? 8 : 2,
118
- delay: parsed.kind === 'barcode' ? 0 : 250,
119
- });
120
- const flat = results.flatMap((p) => (p.variants?.length ?? 0) > 0 ? p.variants.map((v) => ({ product: p, variant: v })) : [{ product: p, variant: undefined }]);
121
- const open = flat.length > 0;
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
- function priceOf(p, v, _warehouseId) {
160
- return v?.price ?? p.price;
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 "+" create affordance even when `field.ref` is set.
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;;;;OAIG;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,+BA8TzB;AAED,eAAe,kBAAkB,CAAA"}
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, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList, Popover, PopoverContent, PopoverTrigger, InitialsAvatar, } from '@asteby/metacore-ui/primitives';
29
- import { Check, ChevronsUpDown, Loader2, Plus, ScanLine } from 'lucide-react';
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
- return (_jsxs("div", { className: "flex w-full min-w-0 items-center gap-1.5", children: [_jsxs(Popover, { open: open && !blockedByDependency, onOpenChange: (o) => { if (!blockedByDependency)
229
- 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' +
230
- (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
231
- ? (dependsHint || DEFAULT_DEPENDS_HINT)
232
- : 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",
233
- // Match the trigger width without an arbitrary Tailwind class
234
- // (those don't always survive a consuming app's Tailwind scan).
235
- style: { width: 'var(--radix-popover-trigger-width)' }, 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(CommandEmpty, { children: useStatic
236
- ? debounced
237
- ? 'Sin resultados'
238
- : 'Sin opciones'
239
- : debounced
240
- ? 'Sin resultados'
241
- : 'Sin resultados — escribe para filtrar' })), !loading && options.length > 0 && (_jsx(CommandGroup, { className: "max-h-64 overflow-auto", children: options.map((opt) => {
242
- const isSel = String(opt.id) === String(value);
243
- 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)));
244
- }) }))] })] }) })] }), 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" }) })), fieldRef && !hideCreate && !useStatic && (_jsx(Button, { type: "button", variant: "outline", size: "icon", className: "size-9 shrink-0", onClick: openCreate, title: `Crear ${field.label ?? fieldRef}`, "aria-label": `Crear ${field.label ?? fieldRef}`, children: _jsx(Plus, { className: "size-4" }) })), scanEnabled && (_jsx(BarcodeScanner, { open: scanOpen, onClose: () => setScanOpen(false), onDetected: handleScanDetected, continuous: false, position: "fixed", title: `Escanear ${field.label ?? ''}`.trim() }))] }));
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;