@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
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
// LineProductCell — buscador de producto DENTRO de la celda «Descripción» de un
|
|
2
|
+
// renglón (patrón de editor de facturas pro: Odoo/Holded/Linear). Ocupa solo su
|
|
3
|
+
// celda; la lista de resultados va en un portal (Popover de Radix anclado al
|
|
4
|
+
// input): nunca la recorta el overflow de la tabla ni del modal, queda por
|
|
5
|
+
// encima del diálogo y se abre hacia arriba si abajo no hay espacio.
|
|
6
|
+
//
|
|
7
|
+
// Teclado: ↑/↓ recorren, Enter elige (el grid pasa el foco a «Cant.»), Esc
|
|
8
|
+
// cierra, Tab cierra y avanza normal. El texto escrito sin elegir queda como
|
|
9
|
+
// descripción libre (el renglón libre sigue funcionando igual).
|
|
10
|
+
import { forwardRef, useId, useMemo, useRef, useState, type KeyboardEvent } from 'react'
|
|
11
|
+
import { useTranslation } from 'react-i18next'
|
|
12
|
+
import { Loader2, Search } from 'lucide-react'
|
|
13
|
+
import { Badge, Input, Popover, PopoverAnchor, PopoverContent } from '@asteby/metacore-ui/primitives'
|
|
14
|
+
import { useFormatter } from './format'
|
|
15
|
+
import { useAsyncSearch } from './use-async-search'
|
|
16
|
+
import { useDebouncedValue } from '../use-debounced-value'
|
|
17
|
+
import { availableStock, parseProductQuery, type ProductQuery, type ProductResult, type ProductVariant } from './product-search'
|
|
18
|
+
|
|
19
|
+
export type LineProductSearch = (query: Exclude<ProductQuery, { kind: 'empty' }>, signal: AbortSignal) => Promise<ProductResult[]>
|
|
20
|
+
|
|
21
|
+
export interface LineProductHit {
|
|
22
|
+
product: ProductResult
|
|
23
|
+
variant?: ProductVariant
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface LineProductCellProps {
|
|
27
|
+
search: LineProductSearch
|
|
28
|
+
/** Texto de la celda (la descripción del renglón, o el borrador del renglón vacío). */
|
|
29
|
+
text: string
|
|
30
|
+
onTextChange: (text: string) => void
|
|
31
|
+
onPick: (product: ProductResult, variant?: ProductVariant) => void
|
|
32
|
+
/**
|
|
33
|
+
* Teclas que el buscador no consume (lista cerrada): el grid aplica sus
|
|
34
|
+
* comandos (Enter agrega, Supr en celda vacía borra…).
|
|
35
|
+
*/
|
|
36
|
+
onKeyDownClosed?: (e: KeyboardEvent<HTMLInputElement>) => void
|
|
37
|
+
warehouseId?: string
|
|
38
|
+
currency?: string
|
|
39
|
+
placeholder?: string
|
|
40
|
+
ariaLabel: string
|
|
41
|
+
disabled?: boolean
|
|
42
|
+
invalid?: boolean
|
|
43
|
+
className?: string
|
|
44
|
+
/** Atributos de datos para que el grid ubique la celda (foco por teclado). */
|
|
45
|
+
dataCell?: string
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export const LineProductCell = forwardRef<HTMLInputElement, LineProductCellProps>(function LineProductCell(
|
|
49
|
+
{ search, text, onTextChange, onPick, onKeyDownClosed, warehouseId, currency, placeholder, ariaLabel, disabled, invalid, className, dataCell },
|
|
50
|
+
ref,
|
|
51
|
+
) {
|
|
52
|
+
const { t } = useTranslation()
|
|
53
|
+
const fmt = useFormatter({ currency })
|
|
54
|
+
const listId = useId()
|
|
55
|
+
const anchorRef = useRef<HTMLDivElement>(null)
|
|
56
|
+
// Solo se abre mientras se escribe: enfocar un renglón libre ya capturado no
|
|
57
|
+
// despliega «sin resultados» encima de la tabla.
|
|
58
|
+
const [typing, setTyping] = useState(false)
|
|
59
|
+
const [active, setActive] = useState(0)
|
|
60
|
+
const parsed = parseProductQuery(text)
|
|
61
|
+
const run = useMemo(
|
|
62
|
+
() => (q: string, signal: AbortSignal) => {
|
|
63
|
+
const p = parseProductQuery(q)
|
|
64
|
+
return p.kind === 'empty' ? Promise.resolve([]) : search(p, signal)
|
|
65
|
+
},
|
|
66
|
+
[search],
|
|
67
|
+
)
|
|
68
|
+
const minChars = parsed.kind === 'barcode' ? 8 : 2
|
|
69
|
+
const delay = parsed.kind === 'barcode' ? 0 : 250
|
|
70
|
+
const { results, loading: fetching } = useAsyncSearch(typing ? text : '', run, { minChars, delay })
|
|
71
|
+
// Mientras el debounce no alcanza lo escrito, la lista dice «Buscando…» y no
|
|
72
|
+
// un «sin coincidencias» prematuro.
|
|
73
|
+
const settled = useDebouncedValue(text.trim(), delay) === text.trim()
|
|
74
|
+
const loading = fetching || !settled
|
|
75
|
+
const flat = useMemo<LineProductHit[]>(
|
|
76
|
+
() =>
|
|
77
|
+
results.flatMap((p) =>
|
|
78
|
+
(p.variants?.length ?? 0) > 0 ? p.variants!.map((v) => ({ product: p, variant: v })) : [{ product: p }],
|
|
79
|
+
),
|
|
80
|
+
[results],
|
|
81
|
+
)
|
|
82
|
+
const queryReady = typing && text.trim().length >= minChars
|
|
83
|
+
const open = queryReady && (loading || flat.length > 0 || text.trim().length >= 2)
|
|
84
|
+
const hasHits = flat.length > 0
|
|
85
|
+
|
|
86
|
+
const close = () => {
|
|
87
|
+
setTyping(false)
|
|
88
|
+
setActive(0)
|
|
89
|
+
}
|
|
90
|
+
const choose = (idx: number) => {
|
|
91
|
+
const hit = flat[idx]
|
|
92
|
+
if (!hit) return
|
|
93
|
+
close()
|
|
94
|
+
onPick(hit.product, hit.variant)
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const onKeyDown = (e: KeyboardEvent<HTMLInputElement>) => {
|
|
98
|
+
if (open && hasHits) {
|
|
99
|
+
if (e.key === 'ArrowDown') {
|
|
100
|
+
e.preventDefault()
|
|
101
|
+
setActive((n) => Math.min(flat.length - 1, n + 1))
|
|
102
|
+
return
|
|
103
|
+
}
|
|
104
|
+
if (e.key === 'ArrowUp') {
|
|
105
|
+
e.preventDefault()
|
|
106
|
+
setActive((n) => Math.max(0, n - 1))
|
|
107
|
+
return
|
|
108
|
+
}
|
|
109
|
+
if (e.key === 'Enter') {
|
|
110
|
+
e.preventDefault()
|
|
111
|
+
choose(active)
|
|
112
|
+
return
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
if (open && e.key === 'Escape') {
|
|
116
|
+
// Cierra la lista sin cerrar el diálogo que la contiene.
|
|
117
|
+
e.preventDefault()
|
|
118
|
+
e.stopPropagation()
|
|
119
|
+
close()
|
|
120
|
+
return
|
|
121
|
+
}
|
|
122
|
+
if (e.key === 'Tab') close()
|
|
123
|
+
onKeyDownClosed?.(e)
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
return (
|
|
127
|
+
<Popover open={open} onOpenChange={(o) => !o && close()}>
|
|
128
|
+
<PopoverAnchor asChild>
|
|
129
|
+
<div ref={anchorRef} className="relative w-full min-w-0" data-slot="line-product-cell">
|
|
130
|
+
<Search className="pointer-events-none absolute left-2.5 top-1/2 size-3.5 -translate-y-1/2 text-muted-foreground" aria-hidden />
|
|
131
|
+
<Input
|
|
132
|
+
ref={ref}
|
|
133
|
+
value={text}
|
|
134
|
+
disabled={disabled}
|
|
135
|
+
role="combobox"
|
|
136
|
+
aria-autocomplete="list"
|
|
137
|
+
aria-expanded={open}
|
|
138
|
+
aria-controls={open ? listId : undefined}
|
|
139
|
+
aria-activedescendant={open && hasHits ? `${listId}-${active}` : undefined}
|
|
140
|
+
aria-label={ariaLabel}
|
|
141
|
+
aria-invalid={invalid || undefined}
|
|
142
|
+
autoComplete="off"
|
|
143
|
+
data-cell={dataCell}
|
|
144
|
+
placeholder={placeholder ?? t('lineItems.searchPlaceholder', { defaultValue: 'Producto, medida (205/55R16), SKU o código de barras' })}
|
|
145
|
+
className={'h-8 pl-8 ' + (className ?? '')}
|
|
146
|
+
onChange={(e) => {
|
|
147
|
+
onTextChange(e.target.value)
|
|
148
|
+
setTyping(true)
|
|
149
|
+
setActive(0)
|
|
150
|
+
}}
|
|
151
|
+
onBlur={() => close()}
|
|
152
|
+
onKeyDown={onKeyDown}
|
|
153
|
+
/>
|
|
154
|
+
</div>
|
|
155
|
+
</PopoverAnchor>
|
|
156
|
+
<PopoverContent
|
|
157
|
+
align="start"
|
|
158
|
+
sideOffset={4}
|
|
159
|
+
collisionPadding={8}
|
|
160
|
+
className="p-1"
|
|
161
|
+
data-slot="line-product-results"
|
|
162
|
+
// El foco se queda en la celda: se sigue escribiendo mientras se ve la lista.
|
|
163
|
+
onOpenAutoFocus={(e) => e.preventDefault()}
|
|
164
|
+
onCloseAutoFocus={(e) => e.preventDefault()}
|
|
165
|
+
onInteractOutside={(e) => {
|
|
166
|
+
if (anchorRef.current?.contains(e.target as Node)) e.preventDefault()
|
|
167
|
+
}}
|
|
168
|
+
style={{ width: 'max(var(--radix-popover-trigger-width), 22rem)', maxWidth: 'calc(100vw - 1rem)' }}
|
|
169
|
+
>
|
|
170
|
+
{hasHits ? (
|
|
171
|
+
<ul id={listId} role="listbox" aria-label={ariaLabel} className="max-h-72 overflow-auto">
|
|
172
|
+
{flat.map((hit, idx) => {
|
|
173
|
+
const price = hit.variant?.price ?? hit.product.price
|
|
174
|
+
const stock = availableStock(hit.variant ?? hit.product, warehouseId)
|
|
175
|
+
const meta = [hit.variant?.sku ?? hit.product.sku, hit.product.tire?.normalized].filter(Boolean).join(' · ')
|
|
176
|
+
return (
|
|
177
|
+
<li key={`${hit.product.id}:${hit.variant?.id ?? ''}`} id={`${listId}-${idx}`} role="option" aria-selected={idx === active}>
|
|
178
|
+
<button
|
|
179
|
+
type="button"
|
|
180
|
+
tabIndex={-1}
|
|
181
|
+
className={
|
|
182
|
+
'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 ' +
|
|
183
|
+
(idx === active ? 'bg-accent text-accent-foreground' : '')
|
|
184
|
+
}
|
|
185
|
+
onMouseDown={(e) => e.preventDefault()}
|
|
186
|
+
onMouseEnter={() => setActive(idx)}
|
|
187
|
+
onClick={() => choose(idx)}
|
|
188
|
+
>
|
|
189
|
+
<span className="min-w-0">
|
|
190
|
+
<span className="block truncate font-medium">{hit.variant ? `${hit.product.name} · ${hit.variant.label}` : hit.product.name}</span>
|
|
191
|
+
{meta && <span className="block truncate text-xs text-muted-foreground">{meta}</span>}
|
|
192
|
+
</span>
|
|
193
|
+
<span className="flex shrink-0 items-center gap-2">
|
|
194
|
+
{stock != null && (
|
|
195
|
+
<Badge variant="secondary" className="font-normal tabular-nums">
|
|
196
|
+
{stock}
|
|
197
|
+
</Badge>
|
|
198
|
+
)}
|
|
199
|
+
{price != null && <span className="tabular-nums">{fmt.money(price)}</span>}
|
|
200
|
+
</span>
|
|
201
|
+
</button>
|
|
202
|
+
</li>
|
|
203
|
+
)
|
|
204
|
+
})}
|
|
205
|
+
</ul>
|
|
206
|
+
) : loading ? (
|
|
207
|
+
<p className="flex items-center justify-center gap-2 py-4 text-sm text-muted-foreground">
|
|
208
|
+
<Loader2 className="size-4 animate-spin" aria-hidden />
|
|
209
|
+
{t('common.searching', { defaultValue: 'Buscando…' })}
|
|
210
|
+
</p>
|
|
211
|
+
) : (
|
|
212
|
+
<p className="px-2 py-3 text-sm text-muted-foreground">
|
|
213
|
+
{t('lineItems.noProduct', { defaultValue: 'Sin coincidencias. Se usará como descripción libre.' })}
|
|
214
|
+
</p>
|
|
215
|
+
)}
|
|
216
|
+
</PopoverContent>
|
|
217
|
+
</Popover>
|
|
218
|
+
)
|
|
219
|
+
})
|
|
@@ -28,7 +28,6 @@ import {
|
|
|
28
28
|
Badge,
|
|
29
29
|
Button,
|
|
30
30
|
Command,
|
|
31
|
-
CommandEmpty,
|
|
32
31
|
CommandGroup,
|
|
33
32
|
CommandInput,
|
|
34
33
|
CommandItem,
|
|
@@ -38,9 +37,10 @@ import {
|
|
|
38
37
|
PopoverTrigger,
|
|
39
38
|
InitialsAvatar,
|
|
40
39
|
} from '@asteby/metacore-ui/primitives'
|
|
41
|
-
import { Check, ChevronsUpDown, Loader2,
|
|
40
|
+
import { Check, ChevronsUpDown, Loader2, ScanLine } from 'lucide-react'
|
|
42
41
|
import { resolveColorCss } from '@asteby/metacore-ui/lib'
|
|
43
42
|
import { BarcodeScanner } from './barcode-scanner'
|
|
43
|
+
import { JOINED_TRIGGER_CLASS, PickerCreateItem, RecordPickerAction, hasRecordPickerAction } from './record-picker-actions'
|
|
44
44
|
import { DynamicIcon, isLucideIconName } from './dynamic-icon'
|
|
45
45
|
import { useDebouncedValue } from './use-debounced-value'
|
|
46
46
|
import { useOptionsResolver, type ResolvedOption } from './use-options-resolver'
|
|
@@ -190,7 +190,8 @@ export interface DynamicSelectFieldProps {
|
|
|
190
190
|
*/
|
|
191
191
|
descriptionAsBadge?: boolean
|
|
192
192
|
/**
|
|
193
|
-
* Hide the inline "+"
|
|
193
|
+
* Hide the inline create/edit affordances (joined "+" / pencil and the
|
|
194
|
+
* "Crear …" list footer) even when `field.ref` is set.
|
|
194
195
|
* Useful for static / filtered lists where creating a new record is not
|
|
195
196
|
* meaningful in context.
|
|
196
197
|
*/
|
|
@@ -344,8 +345,31 @@ export function DynamicSelectField({
|
|
|
344
345
|
// via a decoupled window event the host listens for. On success the host
|
|
345
346
|
// hands back the new record and we select it immediately. No host import →
|
|
346
347
|
// no circular dependency; works for ANY dynamic_select with a `ref`.
|
|
348
|
+
// Inline-edit: with a value selected the joined pencil opens THAT record's
|
|
349
|
+
// edit modal through the sibling `metacore:edit-record` event (the host's
|
|
350
|
+
// RecordCreateBridge already listens for it). On save the trigger label
|
|
351
|
+
// refreshes from the returned record.
|
|
352
|
+
const openEdit = () => {
|
|
353
|
+
if (!fieldRef || !value || typeof window === 'undefined') return
|
|
354
|
+
setOpen(false)
|
|
355
|
+
window.dispatchEvent(
|
|
356
|
+
new CustomEvent('metacore:edit-record', {
|
|
357
|
+
detail: {
|
|
358
|
+
model: fieldRef,
|
|
359
|
+
recordId: String(value),
|
|
360
|
+
onSaved: (rec: any) => {
|
|
361
|
+
const id = String(value)
|
|
362
|
+
const label = rec ? rec.name ?? rec.label ?? rec.title : undefined
|
|
363
|
+
if (label != null) setPicked({ ...(selectedOption ?? { id, value: id }), id, value: id, label: String(label), name: String(label) })
|
|
364
|
+
},
|
|
365
|
+
},
|
|
366
|
+
}),
|
|
367
|
+
)
|
|
368
|
+
}
|
|
369
|
+
|
|
347
370
|
const openCreate = () => {
|
|
348
371
|
if (!fieldRef || typeof window === 'undefined') return
|
|
372
|
+
setOpen(false)
|
|
349
373
|
window.dispatchEvent(
|
|
350
374
|
new CustomEvent('metacore:create-record', {
|
|
351
375
|
detail: {
|
|
@@ -398,8 +422,12 @@ export function DynamicSelectField({
|
|
|
398
422
|
// to the cell. Without min-w-0 the combobox+button row sizes to its content
|
|
399
423
|
// (the long empty-state placeholder) and overflows the column, pushing the
|
|
400
424
|
// "+" off-screen — it only "fit" once a short value was selected.
|
|
425
|
+
const canMutate = !!fieldRef && !hideCreate && !useStatic
|
|
426
|
+
const joined = canMutate && !blockedByDependency && hasRecordPickerAction(!!value, openCreate, openEdit)
|
|
427
|
+
const fieldName = field.label ? t(field.label, { defaultValue: field.label }) : fieldRef ?? ''
|
|
401
428
|
return (
|
|
402
|
-
<div className="flex w-full min-w-0 items-center gap-1.5">
|
|
429
|
+
<div className="flex w-full min-w-0 items-center gap-1.5" data-slot="dynamic-select">
|
|
430
|
+
<div className="flex min-w-0 flex-1 items-center">
|
|
403
431
|
<Popover open={open && !blockedByDependency} onOpenChange={(o: boolean) => { if (!blockedByDependency) setOpen(o) }}>
|
|
404
432
|
<PopoverTrigger asChild>
|
|
405
433
|
<Button
|
|
@@ -412,6 +440,7 @@ export function DynamicSelectField({
|
|
|
412
440
|
aria-invalid={invalid || undefined}
|
|
413
441
|
className={
|
|
414
442
|
'min-w-0 flex-1 justify-between font-normal' +
|
|
443
|
+
(joined ? ` ${JOINED_TRIGGER_CLASS}` : '') +
|
|
415
444
|
(invalid ? ' border-destructive ring-1 ring-destructive/30' : '')
|
|
416
445
|
}
|
|
417
446
|
data-empty={!value}
|
|
@@ -438,9 +467,13 @@ export function DynamicSelectField({
|
|
|
438
467
|
<PopoverContent
|
|
439
468
|
className="p-0"
|
|
440
469
|
align="start"
|
|
441
|
-
//
|
|
442
|
-
//
|
|
443
|
-
|
|
470
|
+
// Portaled (never clipped by a table/modal overflow) and
|
|
471
|
+
// collision-aware: flips above the trigger when there is no
|
|
472
|
+
// room below. Match the trigger width without an arbitrary
|
|
473
|
+
// Tailwind class (those don't always survive a consuming app's
|
|
474
|
+
// Tailwind scan), with a floor so short cells stay readable.
|
|
475
|
+
collisionPadding={8}
|
|
476
|
+
style={{ width: 'max(var(--radix-popover-trigger-width), 14rem)', maxWidth: 'calc(100vw - 1rem)' }}
|
|
444
477
|
>
|
|
445
478
|
<Command shouldFilter={false}>
|
|
446
479
|
<CommandInput
|
|
@@ -456,7 +489,9 @@ export function DynamicSelectField({
|
|
|
456
489
|
</div>
|
|
457
490
|
)}
|
|
458
491
|
{!loading && options.length === 0 && (
|
|
459
|
-
<CommandEmpty
|
|
492
|
+
// Plain block, not <CommandEmpty>: cmdk hides Empty while
|
|
493
|
+
// any item is mounted, and the "Crear …" footer is one.
|
|
494
|
+
<div className="text-muted-foreground py-6 text-center text-sm" data-slot="picker-empty">
|
|
460
495
|
{useStatic
|
|
461
496
|
? debounced
|
|
462
497
|
? 'Sin resultados'
|
|
@@ -464,7 +499,7 @@ export function DynamicSelectField({
|
|
|
464
499
|
: debounced
|
|
465
500
|
? 'Sin resultados'
|
|
466
501
|
: 'Sin resultados — escribe para filtrar'}
|
|
467
|
-
</
|
|
502
|
+
</div>
|
|
468
503
|
)}
|
|
469
504
|
{!loading && options.length > 0 && (
|
|
470
505
|
<CommandGroup className="max-h-64 overflow-auto">
|
|
@@ -502,10 +537,17 @@ export function DynamicSelectField({
|
|
|
502
537
|
})}
|
|
503
538
|
</CommandGroup>
|
|
504
539
|
)}
|
|
540
|
+
{canMutate && !loading && (
|
|
541
|
+
<PickerCreateItem label={fieldName} onSelect={openCreate} />
|
|
542
|
+
)}
|
|
505
543
|
</CommandList>
|
|
506
544
|
</Command>
|
|
507
545
|
</PopoverContent>
|
|
508
546
|
</Popover>
|
|
547
|
+
{canMutate && !blockedByDependency && (
|
|
548
|
+
<RecordPickerAction hasValue={!!value} label={fieldName} onCreate={openCreate} onEdit={openEdit} />
|
|
549
|
+
)}
|
|
550
|
+
</div>
|
|
509
551
|
{scanEnabled && (
|
|
510
552
|
<Button
|
|
511
553
|
type="button"
|
|
@@ -519,19 +561,6 @@ export function DynamicSelectField({
|
|
|
519
561
|
<ScanLine className="size-4" />
|
|
520
562
|
</Button>
|
|
521
563
|
)}
|
|
522
|
-
{fieldRef && !hideCreate && !useStatic && (
|
|
523
|
-
<Button
|
|
524
|
-
type="button"
|
|
525
|
-
variant="outline"
|
|
526
|
-
size="icon"
|
|
527
|
-
className="size-9 shrink-0"
|
|
528
|
-
onClick={openCreate}
|
|
529
|
-
title={`Crear ${field.label ?? fieldRef}`}
|
|
530
|
-
aria-label={`Crear ${field.label ?? fieldRef}`}
|
|
531
|
-
>
|
|
532
|
-
<Plus className="size-4" />
|
|
533
|
-
</Button>
|
|
534
|
-
)}
|
|
535
564
|
{scanEnabled && (
|
|
536
565
|
<BarcodeScanner
|
|
537
566
|
open={scanOpen}
|
package/src/dynamic-table.tsx
CHANGED
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
// * `@/components/dynamic/dynamic-columns` → host-injected via the
|
|
11
11
|
// `getDynamicColumns` prop (hosts retain ownership because the rendered
|
|
12
12
|
// column cells are tightly coupled to their design system).
|
|
13
|
-
import { useEffect, useState, useMemo, useCallback, useRef, type MouseEvent } from 'react'
|
|
13
|
+
import { useEffect, useState, useMemo, useCallback, useRef, type MouseEvent, type ReactNode } from 'react'
|
|
14
14
|
import { useTranslation } from 'react-i18next'
|
|
15
15
|
import { useTimeZone, useCurrency } from './org-runtime-context'
|
|
16
16
|
import { format } from 'date-fns'
|
|
@@ -264,6 +264,36 @@ export interface DynamicTableProps {
|
|
|
264
264
|
* render every row. A positive number overrides the threshold.
|
|
265
265
|
*/
|
|
266
266
|
virtualizeRows?: boolean | number
|
|
267
|
+
/**
|
|
268
|
+
* Host-provided actions rendered in the floating bar that appears while
|
|
269
|
+
* rows are selected, next to the built-in "Eliminar". Pass a node, or a
|
|
270
|
+
* function that receives the current selection:
|
|
271
|
+
* - `selectedRows`: the selected row objects (current page only);
|
|
272
|
+
* - `selectedIds`: their `id`s;
|
|
273
|
+
* - `clearSelection()`: unselect everything (call after a successful action);
|
|
274
|
+
* - `refresh()`: reload the list.
|
|
275
|
+
* The selection is cleared whenever the page, page size, sort, search or
|
|
276
|
+
* column filters change (classic pagination), so a bulk action can never
|
|
277
|
+
* hit rows the user no longer sees. The host owns the write call and its
|
|
278
|
+
* authorization — the table only supplies the selection.
|
|
279
|
+
*/
|
|
280
|
+
extraBulkActions?: ReactNode | ((ctx: DynamicTableBulkContext) => ReactNode)
|
|
281
|
+
/** Hide the built-in bulk "Eliminar" (e.g. a model whose rows must not be deleted in batch). */
|
|
282
|
+
hideBulkDelete?: boolean
|
|
283
|
+
/**
|
|
284
|
+
* First-use content shown instead of "No se encontraron resultados" when the
|
|
285
|
+
* list loaded fine, is empty, and no search/filter is active. Never shown
|
|
286
|
+
* after a load error (a retry is offered) or a 403.
|
|
287
|
+
*/
|
|
288
|
+
emptyState?: ReactNode
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/** Context handed to `DynamicTableProps.extraBulkActions` when it is a function. */
|
|
292
|
+
export interface DynamicTableBulkContext {
|
|
293
|
+
selectedRows: any[]
|
|
294
|
+
selectedIds: Array<string | number>
|
|
295
|
+
clearSelection: () => void
|
|
296
|
+
refresh: () => void
|
|
267
297
|
}
|
|
268
298
|
|
|
269
299
|
/** True when an api-client error is an HTTP 403 (axios-style or fetch-style). */
|
|
@@ -292,6 +322,9 @@ export function DynamicTable({
|
|
|
292
322
|
pagination: paginationMode,
|
|
293
323
|
infiniteScroll: infiniteScrollProp = false,
|
|
294
324
|
virtualizeRows,
|
|
325
|
+
extraBulkActions,
|
|
326
|
+
hideBulkDelete,
|
|
327
|
+
emptyState,
|
|
295
328
|
}: DynamicTableProps) {
|
|
296
329
|
// The org's timezone/currency: an explicit prop wins, else the app-wide
|
|
297
330
|
// OrgRuntimeProvider (without it, money fell back to USD).
|
|
@@ -347,6 +380,9 @@ export function DynamicTable({
|
|
|
347
380
|
// model. Rendered as "no permission" instead of masquerading as an empty
|
|
348
381
|
// list. Purely display state — never a fetch dependency (no refetch loop).
|
|
349
382
|
const [forbidden, setForbidden] = useState(false)
|
|
383
|
+
// A list request that failed for a reason other than 403 (500, network,
|
|
384
|
+
// `success: false`). Shown with a retry instead of a misleading "no results".
|
|
385
|
+
const [loadError, setLoadError] = useState(false)
|
|
350
386
|
const infPageRef = useRef(1)
|
|
351
387
|
const [optionsMap, setOptionsMap] = useState<Map<string, any[]>>(new Map())
|
|
352
388
|
|
|
@@ -897,6 +933,7 @@ export function DynamicTable({
|
|
|
897
933
|
if (relationInclude) params.include = relationInclude
|
|
898
934
|
const res = await api.get(endpoint || `/data/${model}`, { params }) as { data: ApiResponse<any[]> }
|
|
899
935
|
setForbidden(false)
|
|
936
|
+
setLoadError(!res.data.success)
|
|
900
937
|
if (res.data.success) {
|
|
901
938
|
const rows = res.data.data || []
|
|
902
939
|
setData(rows)
|
|
@@ -914,10 +951,12 @@ export function DynamicTable({
|
|
|
914
951
|
} catch (error) {
|
|
915
952
|
if (isForbiddenError(error)) {
|
|
916
953
|
setForbidden(true)
|
|
954
|
+
setLoadError(false)
|
|
917
955
|
setData([])
|
|
918
956
|
setRowCount(0)
|
|
919
957
|
} else {
|
|
920
958
|
console.error('Error al cargar los datos', error)
|
|
959
|
+
setLoadError(true)
|
|
921
960
|
}
|
|
922
961
|
} finally {
|
|
923
962
|
setLoadingData(false)
|
|
@@ -968,6 +1007,7 @@ export function DynamicTable({
|
|
|
968
1007
|
params,
|
|
969
1008
|
})) as { data: ApiResponse<any[]> }
|
|
970
1009
|
setForbidden(false)
|
|
1010
|
+
setLoadError(!res.data.success)
|
|
971
1011
|
if (res.data.success) {
|
|
972
1012
|
const rows = res.data.data || []
|
|
973
1013
|
setData((prev) => (append ? dedupeById(prev, rows) : rows))
|
|
@@ -989,6 +1029,7 @@ export function DynamicTable({
|
|
|
989
1029
|
} catch (error) {
|
|
990
1030
|
if (isForbiddenError(error)) {
|
|
991
1031
|
setForbidden(true)
|
|
1032
|
+
setLoadError(false)
|
|
992
1033
|
if (!append) {
|
|
993
1034
|
setData([])
|
|
994
1035
|
setRowCount(0)
|
|
@@ -996,6 +1037,7 @@ export function DynamicTable({
|
|
|
996
1037
|
setInfExhausted(true)
|
|
997
1038
|
} else {
|
|
998
1039
|
console.error('Error al cargar los datos', error)
|
|
1040
|
+
setLoadError(true)
|
|
999
1041
|
}
|
|
1000
1042
|
} finally {
|
|
1001
1043
|
if (append) setLoadingMore(false)
|
|
@@ -1372,6 +1414,10 @@ export function DynamicTable({
|
|
|
1372
1414
|
manualSorting: true,
|
|
1373
1415
|
manualFiltering: true,
|
|
1374
1416
|
enableRowSelection: true,
|
|
1417
|
+
// Key the selection by record id, not by position in the page: a bulk
|
|
1418
|
+
// action must act on the rows the user ticked, never on whatever row
|
|
1419
|
+
// lands at the same index after the data changes.
|
|
1420
|
+
getRowId: (row: any, index: number) => (row?.id != null ? String(row.id) : String(index)),
|
|
1375
1421
|
onRowSelectionChange: setRowSelection,
|
|
1376
1422
|
onSortingChange: setSorting,
|
|
1377
1423
|
onColumnVisibilityChange: setColumnVisibility,
|
|
@@ -1387,6 +1433,21 @@ export function DynamicTable({
|
|
|
1387
1433
|
})
|
|
1388
1434
|
|
|
1389
1435
|
const tableRows = table.getRowModel().rows
|
|
1436
|
+
const bulkSelectedRows = table.getFilteredSelectedRowModel().rows.map((r) => r.original as any)
|
|
1437
|
+
|
|
1438
|
+
// Classic pagination swaps the visible rows on every page / sort / search /
|
|
1439
|
+
// filter change; drop the selection so it never refers to rows that are no
|
|
1440
|
+
// longer on screen. Infinite mode accumulates rows (selection is id-keyed,
|
|
1441
|
+
// so it stays valid) and is left alone.
|
|
1442
|
+
const selectionScope = infiniteScroll ? '' : JSON.stringify([pagination.pageIndex, pagination.pageSize, sorting, columnFilters, globalFilter])
|
|
1443
|
+
const prevSelectionScope = useRef(selectionScope)
|
|
1444
|
+
useEffect(() => {
|
|
1445
|
+
if (prevSelectionScope.current !== selectionScope) {
|
|
1446
|
+
prevSelectionScope.current = selectionScope
|
|
1447
|
+
setRowSelection({})
|
|
1448
|
+
}
|
|
1449
|
+
}, [selectionScope])
|
|
1450
|
+
|
|
1390
1451
|
const virtualizeThreshold = resolveVirtualizeThreshold(virtualizeRows)
|
|
1391
1452
|
const shouldVirtualize =
|
|
1392
1453
|
virtualizeThreshold !== false && tableRows.length >= virtualizeThreshold
|
|
@@ -1624,6 +1685,14 @@ export function DynamicTable({
|
|
|
1624
1685
|
<h3 className="text-lg font-semibold text-foreground" role="alert">{t('dynamic.forbidden_title', { defaultValue: 'Sin permiso para ver este módulo' })}</h3>
|
|
1625
1686
|
<p className="text-sm text-muted-foreground">{t('dynamic.forbidden_hint', { defaultValue: 'Tu rol no tiene acceso de lectura a estos datos. Pide acceso a un administrador.' })}</p>
|
|
1626
1687
|
</>
|
|
1688
|
+
) : loadError ? (
|
|
1689
|
+
<>
|
|
1690
|
+
<h3 className="text-lg font-semibold text-foreground" role="alert">{t('dynamic.load_error_title', { defaultValue: 'No se pudieron cargar los datos' })}</h3>
|
|
1691
|
+
<p className="text-sm text-muted-foreground">{t('dynamic.load_error_hint', { defaultValue: 'Ocurrió un error al consultar la lista. Inténtalo de nuevo.' })}</p>
|
|
1692
|
+
<Button variant="outline" size="sm" className="mt-2" onClick={handleRefresh}>{t('dynamic.retry', { defaultValue: 'Reintentar' })}</Button>
|
|
1693
|
+
</>
|
|
1694
|
+
) : emptyState && !hasActiveFilters ? (
|
|
1695
|
+
emptyState
|
|
1627
1696
|
) : (
|
|
1628
1697
|
<>
|
|
1629
1698
|
<h3 className="text-lg font-semibold text-foreground">No se encontraron resultados</h3>
|
|
@@ -1823,6 +1892,14 @@ export function DynamicTable({
|
|
|
1823
1892
|
<h3 className='text-base font-semibold text-foreground' role='alert'>{t('dynamic.forbidden_title', { defaultValue: 'Sin permiso para ver este módulo' })}</h3>
|
|
1824
1893
|
<p className='text-sm text-muted-foreground'>{t('dynamic.forbidden_hint', { defaultValue: 'Tu rol no tiene acceso de lectura a estos datos. Pide acceso a un administrador.' })}</p>
|
|
1825
1894
|
</>
|
|
1895
|
+
) : loadError ? (
|
|
1896
|
+
<>
|
|
1897
|
+
<h3 className='text-base font-semibold text-foreground' role='alert'>{t('dynamic.load_error_title', { defaultValue: 'No se pudieron cargar los datos' })}</h3>
|
|
1898
|
+
<p className='text-sm text-muted-foreground'>{t('dynamic.load_error_hint', { defaultValue: 'Ocurrió un error al consultar la lista. Inténtalo de nuevo.' })}</p>
|
|
1899
|
+
<Button variant='outline' size='sm' onClick={handleRefresh}>{t('dynamic.retry', { defaultValue: 'Reintentar' })}</Button>
|
|
1900
|
+
</>
|
|
1901
|
+
) : emptyState && !hasActiveFilters ? (
|
|
1902
|
+
emptyState
|
|
1826
1903
|
) : (
|
|
1827
1904
|
<>
|
|
1828
1905
|
<h3 className='text-base font-semibold text-foreground'>No se encontraron resultados</h3>
|
|
@@ -1896,9 +1973,19 @@ export function DynamicTable({
|
|
|
1896
1973
|
<ImportDialog open={importOpen} onOpenChange={setImportOpen} model={model} metadata={metadata} onImported={handleRefresh} />
|
|
1897
1974
|
)}
|
|
1898
1975
|
<DataTableBulkActions table={table} entityName="registro">
|
|
1899
|
-
|
|
1900
|
-
|
|
1901
|
-
|
|
1976
|
+
{typeof extraBulkActions === 'function'
|
|
1977
|
+
? extraBulkActions({
|
|
1978
|
+
selectedRows: bulkSelectedRows,
|
|
1979
|
+
selectedIds: bulkSelectedRows.map((r) => r.id).filter((id) => id != null),
|
|
1980
|
+
clearSelection: () => table.resetRowSelection(),
|
|
1981
|
+
refresh: handleRefresh,
|
|
1982
|
+
})
|
|
1983
|
+
: extraBulkActions}
|
|
1984
|
+
{!hideBulkDelete && (
|
|
1985
|
+
<Button variant="destructive" size="sm" className="h-8" onClick={() => setShowBulkDeleteConfirm(true)}>
|
|
1986
|
+
<Trash2 className="h-4 w-4 mr-1.5" /> Eliminar
|
|
1987
|
+
</Button>
|
|
1988
|
+
)}
|
|
1902
1989
|
</DataTableBulkActions>
|
|
1903
1990
|
</OptionsContext.Provider>
|
|
1904
1991
|
</RowActionsModelContext.Provider>
|