@asteby/metacore-runtime-react 28.7.0 → 29.0.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 +43 -0
- package/dist/action-modal-dispatcher.js +1 -1
- package/dist/dialogs/dynamic-record.d.ts.map +1 -1
- package/dist/dialogs/dynamic-record.js +1 -1
- package/dist/dynamic-columns.d.ts.map +1 -1
- package/dist/dynamic-columns.js +13 -1
- package/dist/dynamic-relation.d.ts +4 -0
- package/dist/dynamic-relation.d.ts.map +1 -1
- package/dist/dynamic-relation.js +80 -16
- package/dist/dynamic-relations.d.ts +22 -1
- package/dist/dynamic-relations.d.ts.map +1 -1
- package/dist/dynamic-relations.js +16 -3
- package/dist/entity-select.d.ts +43 -0
- package/dist/entity-select.d.ts.map +1 -0
- package/dist/entity-select.js +124 -0
- package/dist/index.d.ts +5 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -1
- package/dist/print-document-button.d.ts +21 -0
- package/dist/print-document-button.d.ts.map +1 -0
- package/dist/print-document-button.js +32 -0
- package/dist/types.d.ts +9 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/use-print-document.d.ts +25 -0
- package/dist/use-print-document.d.ts.map +1 -0
- package/dist/use-print-document.js +79 -0
- package/package.json +3 -3
- package/src/__tests__/relation-embed-gate.test.tsx +110 -0
- package/src/action-modal-dispatcher.tsx +3 -1
- package/src/dialogs/dynamic-record.tsx +6 -1
- package/src/dynamic-columns.tsx +14 -1
- package/src/dynamic-relation.tsx +121 -23
- package/src/dynamic-relations.tsx +34 -2
- package/src/entity-select.tsx +316 -0
- package/src/index.ts +5 -0
- package/src/print-document-button.tsx +75 -0
- package/src/types.ts +9 -0
- package/src/use-print-document.ts +112 -0
package/src/dynamic-relation.tsx
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
// - "one_to_many": lista inline editable que cuelga del registro padre.
|
|
4
4
|
// - "many_to_many": multi-select sobre la tabla destino con sync a la pivot.
|
|
5
5
|
// La RFC completa vive en `packages/runtime-react/docs/relations.md`.
|
|
6
|
-
import { useCallback, useEffect, useMemo, useState } from 'react'
|
|
6
|
+
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
|
7
7
|
import { useTranslation } from 'react-i18next'
|
|
8
8
|
import {
|
|
9
9
|
type ColumnDef,
|
|
@@ -31,6 +31,7 @@ import {
|
|
|
31
31
|
DialogContent,
|
|
32
32
|
DialogHeader,
|
|
33
33
|
DialogTitle,
|
|
34
|
+
Input,
|
|
34
35
|
MultiSelect,
|
|
35
36
|
Table,
|
|
36
37
|
TableBody,
|
|
@@ -39,7 +40,7 @@ import {
|
|
|
39
40
|
TableHeader,
|
|
40
41
|
TableRow,
|
|
41
42
|
} from '@asteby/metacore-ui/primitives'
|
|
42
|
-
import { Plus, Trash2, Pencil } from 'lucide-react'
|
|
43
|
+
import { Plus, Trash2, Pencil, Search } from 'lucide-react'
|
|
43
44
|
import { useApi } from './api-context'
|
|
44
45
|
import { useMetadataCache } from './metadata-cache'
|
|
45
46
|
import { DynamicForm } from './dynamic-form'
|
|
@@ -48,6 +49,7 @@ import { useTimeZone, useCurrency } from './org-runtime-context'
|
|
|
48
49
|
import { makeDefaultGetDynamicColumns } from './dynamic-columns'
|
|
49
50
|
import { isColumnVisibleInLineSubtable } from './column-visibility'
|
|
50
51
|
import { useOptionsResolver } from './use-options-resolver'
|
|
52
|
+
import { dedupeById, useInfiniteScrollSentinel } from './use-infinite-scroll'
|
|
51
53
|
import type { ApiResponse, ColumnDefinition, TableMetadata } from './types'
|
|
52
54
|
import {
|
|
53
55
|
buildCreatePayload,
|
|
@@ -90,6 +92,10 @@ export interface DynamicRelationStrings {
|
|
|
90
92
|
selectPlaceholder: string
|
|
91
93
|
selectSearchPlaceholder: string
|
|
92
94
|
selectEmpty: string
|
|
95
|
+
/** Placeholder del buscador de la sub-tabla 1:N. */
|
|
96
|
+
searchPlaceholder: string
|
|
97
|
+
/** Pie de la sub-tabla: "{{loaded}} de {{total}}". */
|
|
98
|
+
countLabel: string
|
|
93
99
|
}
|
|
94
100
|
|
|
95
101
|
const DEFAULT_STRINGS: DynamicRelationStrings = {
|
|
@@ -105,8 +111,15 @@ const DEFAULT_STRINGS: DynamicRelationStrings = {
|
|
|
105
111
|
selectPlaceholder: 'Seleccionar…',
|
|
106
112
|
selectSearchPlaceholder: 'Buscar…',
|
|
107
113
|
selectEmpty: 'Sin resultados.',
|
|
114
|
+
searchPlaceholder: 'Buscar…',
|
|
115
|
+
countLabel: '{{loaded}} de {{total}}',
|
|
108
116
|
}
|
|
109
117
|
|
|
118
|
+
// Tamaño de página de la sub-tabla 1:N. Antes la lista pedía el hijo COMPLETO
|
|
119
|
+
// (sin page/per_page), así que abrir un almacén traía todas sus existencias y
|
|
120
|
+
// traspasos. Se pagina de a 25 y el resto entra por scroll infinito.
|
|
121
|
+
const REL_PAGE_SIZE = 25
|
|
122
|
+
|
|
110
123
|
interface CommonProps {
|
|
111
124
|
/** id del registro padre. */
|
|
112
125
|
parentId: string | number
|
|
@@ -233,6 +246,15 @@ function OneToManyRelation({
|
|
|
233
246
|
const [metadata, setMetadata] = useState<TableMetadata | null>(cachedMeta || null)
|
|
234
247
|
const [rows, setRows] = useState<any[]>([])
|
|
235
248
|
const [loading, setLoading] = useState(true)
|
|
249
|
+
// Paginación server-side + scroll infinito, igual que <DynamicTable>.
|
|
250
|
+
const [total, setTotal] = useState(0)
|
|
251
|
+
const [page, setPage] = useState(1)
|
|
252
|
+
const [loadingMore, setLoadingMore] = useState(false)
|
|
253
|
+
// El backend devolvió una página corta: no hay más filas aunque meta.total
|
|
254
|
+
// diga otra cosa (drift count/list dispararía el sentinel para siempre).
|
|
255
|
+
const [exhausted, setExhausted] = useState(false)
|
|
256
|
+
const [search, setSearch] = useState('')
|
|
257
|
+
const [debouncedSearch, setDebouncedSearch] = useState('')
|
|
236
258
|
const [formOpen, setFormOpen] = useState(false)
|
|
237
259
|
const [editingRow, setEditingRow] = useState<any | null>(null)
|
|
238
260
|
const [rowToDelete, setRowToDelete] = useState<any | null>(null)
|
|
@@ -244,12 +266,31 @@ function OneToManyRelation({
|
|
|
244
266
|
// still reacting to real scope changes.
|
|
245
267
|
const filtersKey = useMemo(() => (filters ? JSON.stringify(filters) : ''), [filters])
|
|
246
268
|
|
|
247
|
-
|
|
248
|
-
|
|
269
|
+
// La metadata se lee por ref dentro del fetch: si entrara como dependencia,
|
|
270
|
+
// resolverla dispararía un segundo fetch de datos por cada montaje.
|
|
271
|
+
const metadataRef = useRef<TableMetadata | null>(metadata)
|
|
272
|
+
metadataRef.current = metadata
|
|
273
|
+
|
|
274
|
+
// Debounce del buscador: cada tecla no puede pegarle al servidor.
|
|
275
|
+
useEffect(() => {
|
|
276
|
+
const id = setTimeout(() => setDebouncedSearch(search.trim()), 300)
|
|
277
|
+
return () => clearTimeout(id)
|
|
278
|
+
}, [search])
|
|
279
|
+
|
|
280
|
+
// fetchPage REEMPLAZA (página 1) o ANEXA (scroll infinito). Antes esta lista
|
|
281
|
+
// pedía el modelo hijo entero sin page/per_page.
|
|
282
|
+
const fetchPage = useCallback(async (nextPage: number, append: boolean) => {
|
|
283
|
+
if (append) setLoadingMore(true)
|
|
284
|
+
else setLoading(true)
|
|
249
285
|
try {
|
|
250
|
-
const params
|
|
286
|
+
const params: Record<string, any> = {
|
|
287
|
+
...buildRelationFilterParams(foreignKey, parentId, filters),
|
|
288
|
+
page: nextPage,
|
|
289
|
+
per_page: REL_PAGE_SIZE,
|
|
290
|
+
}
|
|
291
|
+
if (debouncedSearch) params.search = debouncedSearch
|
|
251
292
|
const [metaRes, dataRes] = await Promise.all([
|
|
252
|
-
|
|
293
|
+
metadataRef.current ? Promise.resolve(null) : api.get(`/metadata/table/${model}`),
|
|
253
294
|
api.get(dataEndpoint, { params }),
|
|
254
295
|
])
|
|
255
296
|
if (metaRes && (metaRes as any).data?.success) {
|
|
@@ -258,16 +299,41 @@ function OneToManyRelation({
|
|
|
258
299
|
cacheMetadata(model, fresh)
|
|
259
300
|
}
|
|
260
301
|
const list = (dataRes as { data: ApiResponse<any[]> }).data
|
|
261
|
-
if (list.success)
|
|
302
|
+
if (list.success) {
|
|
303
|
+
const fetched = list.data || []
|
|
304
|
+
setRows((prev) => (append ? dedupeById(prev, fetched) : fetched))
|
|
305
|
+
setPage(nextPage)
|
|
306
|
+
if (list.meta?.total !== undefined) setTotal(list.meta.total)
|
|
307
|
+
else if (!append) setTotal(fetched.length)
|
|
308
|
+
setExhausted(fetched.length < REL_PAGE_SIZE)
|
|
309
|
+
}
|
|
262
310
|
} catch (err) {
|
|
263
311
|
console.error('DynamicRelation fetch error', err)
|
|
264
312
|
} finally {
|
|
265
|
-
|
|
313
|
+
if (append) setLoadingMore(false)
|
|
314
|
+
else setLoading(false)
|
|
266
315
|
}
|
|
267
316
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
268
|
-
}, [api, dataEndpoint, foreignKey, parentId, filtersKey,
|
|
317
|
+
}, [api, dataEndpoint, foreignKey, parentId, filtersKey, model, cacheMetadata, debouncedSearch])
|
|
318
|
+
|
|
319
|
+
// Recarga desde la primera página. Es lo que corren create/edit/delete.
|
|
320
|
+
const fetchAll = useCallback(async () => {
|
|
321
|
+
await fetchPage(1, false)
|
|
322
|
+
}, [fetchPage])
|
|
269
323
|
|
|
270
|
-
|
|
324
|
+
// Un cambio de padre/scope/búsqueda vuelve a la página 1.
|
|
325
|
+
useEffect(() => { fetchPage(1, false) }, [fetchPage])
|
|
326
|
+
|
|
327
|
+
const canLoadMore = !loading && !loadingMore && !exhausted && rows.length < total
|
|
328
|
+
const { rootRef, sentinelRef } = useInfiniteScrollSentinel<HTMLDivElement, HTMLDivElement>({
|
|
329
|
+
onLoadMore: () => { if (canLoadMore) fetchPage(page + 1, true) },
|
|
330
|
+
disabled: !canLoadMore,
|
|
331
|
+
})
|
|
332
|
+
|
|
333
|
+
// El buscador solo aparece cuando hay algo que buscar: una sub-tabla de 3
|
|
334
|
+
// líneas no necesita chrome extra. Se queda visible mientras haya término
|
|
335
|
+
// activo para no dejar al usuario sin forma de limpiarlo.
|
|
336
|
+
const showSearch = debouncedSearch !== '' || search !== '' || total > REL_PAGE_SIZE
|
|
271
337
|
|
|
272
338
|
const formFields = useMemo(
|
|
273
339
|
() => deriveRelationFormFields(metadata, foreignKey),
|
|
@@ -410,19 +476,32 @@ function OneToManyRelation({
|
|
|
410
476
|
|
|
411
477
|
return (
|
|
412
478
|
<div className={className} data-relation-kind={kind} data-relation-model={model}>
|
|
413
|
-
{(labels.title || canCreate) && (
|
|
414
|
-
<div className="flex items-center justify-between pb-3">
|
|
479
|
+
{(labels.title || canCreate || showSearch) && (
|
|
480
|
+
<div className="flex items-center justify-between gap-2 pb-3">
|
|
415
481
|
{labels.title ? <h3 className="text-sm font-medium">{labels.title}</h3> : <span />}
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
482
|
+
<div className="flex items-center gap-2">
|
|
483
|
+
{showSearch && (
|
|
484
|
+
<div className="relative">
|
|
485
|
+
<Search className="absolute start-2 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground" />
|
|
486
|
+
<Input
|
|
487
|
+
value={search}
|
|
488
|
+
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setSearch(e.target.value)}
|
|
489
|
+
placeholder={labels.searchPlaceholder}
|
|
490
|
+
className="h-8 w-40 ps-7 sm:w-56"
|
|
491
|
+
/>
|
|
492
|
+
</div>
|
|
493
|
+
)}
|
|
494
|
+
{canCreate && (
|
|
495
|
+
<Button
|
|
496
|
+
size="sm"
|
|
497
|
+
variant="outline"
|
|
498
|
+
onClick={() => { setEditingRow(null); setFormOpen(true) }}
|
|
499
|
+
>
|
|
500
|
+
<Plus className="h-4 w-4 mr-1" />
|
|
501
|
+
{labels.addLabel}
|
|
502
|
+
</Button>
|
|
503
|
+
)}
|
|
504
|
+
</div>
|
|
426
505
|
</div>
|
|
427
506
|
)}
|
|
428
507
|
|
|
@@ -440,7 +519,10 @@ function OneToManyRelation({
|
|
|
440
519
|
// Real metadata-driven table — same metacore-ui primitives and
|
|
441
520
|
// cell renderers as `<DynamicTable>` so headers, money/currency,
|
|
442
521
|
// FK thumbnails, dates and badges all match the main table.
|
|
443
|
-
|
|
522
|
+
// `max-h` + scroll propio: la sub-tabla no puede estirar el modal
|
|
523
|
+
// del padre a lo alto de cientos de filas. El sentinel del fondo
|
|
524
|
+
// pide la página siguiente al entrar en vista.
|
|
525
|
+
<div ref={rootRef} className="overflow-auto max-h-[60vh] border rounded-md bg-card">
|
|
444
526
|
<Table noWrapper className="w-full">
|
|
445
527
|
<TableHeader>
|
|
446
528
|
{table.getHeaderGroups().map((headerGroup: HeaderGroup<any>) => (
|
|
@@ -477,6 +559,22 @@ function OneToManyRelation({
|
|
|
477
559
|
))}
|
|
478
560
|
</TableBody>
|
|
479
561
|
</Table>
|
|
562
|
+
{loadingMore && (
|
|
563
|
+
<div className="p-2">
|
|
564
|
+
<Skeleton className="h-8 w-full" />
|
|
565
|
+
</div>
|
|
566
|
+
)}
|
|
567
|
+
<div ref={sentinelRef} aria-hidden className="h-px w-full" />
|
|
568
|
+
</div>
|
|
569
|
+
)}
|
|
570
|
+
|
|
571
|
+
{/* Cuánto de la relación se ve. Sin esto una sub-tabla paginada
|
|
572
|
+
miente: parece completa cuando solo trae la primera página. */}
|
|
573
|
+
{!loading && rows.length > 0 && total > rows.length && (
|
|
574
|
+
<div className="pt-2 text-end text-xs text-muted-foreground">
|
|
575
|
+
{labels.countLabel
|
|
576
|
+
.replace('{{loaded}}', String(rows.length))
|
|
577
|
+
.replace('{{total}}', String(total))}
|
|
480
578
|
</div>
|
|
481
579
|
)}
|
|
482
580
|
|
|
@@ -49,6 +49,19 @@ export interface DynamicRelationsProps {
|
|
|
49
49
|
* autónoma conserva todas las columnas.
|
|
50
50
|
*/
|
|
51
51
|
lineSubtable?: boolean
|
|
52
|
+
/**
|
|
53
|
+
* Solo renderiza las relaciones de COMPOSICIÓN — las que el kernel marca
|
|
54
|
+
* con `embed: true` (las líneas de un documento). Es lo que usan los MODALES
|
|
55
|
+
* de registro: antes embebían TODAS las relaciones one_to_many del modelo,
|
|
56
|
+
* así que abrir "Editar Almacén" arrastraba miles de existencias y traspasos
|
|
57
|
+
* al formulario. Las relaciones no embebidas siguen accesibles desde su
|
|
58
|
+
* propia página / la vista de detalle, que renderiza el listado completo.
|
|
59
|
+
*
|
|
60
|
+
* Default false: una página de detalle autónoma sigue mostrando todas.
|
|
61
|
+
* Una relación sin `embed` (kernel viejo) NO se embebe — el gate falla del
|
|
62
|
+
* lado seguro.
|
|
63
|
+
*/
|
|
64
|
+
embedOnly?: boolean
|
|
52
65
|
/** Bubble up when any panel's data changes (create/delete/attach/detach). */
|
|
53
66
|
onChange?: (relation: RelationMeta) => void
|
|
54
67
|
}
|
|
@@ -90,6 +103,17 @@ export function buildRelationFilters(
|
|
|
90
103
|
return out
|
|
91
104
|
}
|
|
92
105
|
|
|
106
|
+
/**
|
|
107
|
+
* ¿La relación es de COMPOSICIÓN (embebible en un modal)? Solo `embed: true`
|
|
108
|
+
* califica: la ausencia del flag — un kernel viejo que todavía no lo sirve —
|
|
109
|
+
* significa NO embeber, que es el lado seguro (el costo de un falso negativo
|
|
110
|
+
* es un panel de menos en el modal; el de un falso positivo, miles de filas
|
|
111
|
+
* dentro de un formulario).
|
|
112
|
+
*/
|
|
113
|
+
export function isEmbedded(rel: Pick<RelationMeta, 'embed'>): boolean {
|
|
114
|
+
return rel.embed === true
|
|
115
|
+
}
|
|
116
|
+
|
|
93
117
|
/** Stable React key for a relation panel. */
|
|
94
118
|
function relationKey(rel: RelationMeta, idx: number): string {
|
|
95
119
|
return rel.name || `${rel.through}-${rel.foreign_key}-${idx}`
|
|
@@ -106,6 +130,7 @@ export function DynamicRelations({
|
|
|
106
130
|
canEdit = true,
|
|
107
131
|
strings,
|
|
108
132
|
lineSubtable = false,
|
|
133
|
+
embedOnly = false,
|
|
109
134
|
onChange,
|
|
110
135
|
}: DynamicRelationsProps) {
|
|
111
136
|
const parentId = useMemo(
|
|
@@ -113,7 +138,14 @@ export function DynamicRelations({
|
|
|
113
138
|
[record, parentIdKey],
|
|
114
139
|
)
|
|
115
140
|
|
|
116
|
-
|
|
141
|
+
// Gate de composición: en un modal solo entran las relaciones marcadas
|
|
142
|
+
// `embed` por el kernel. Fuera del modal la lista pasa entera.
|
|
143
|
+
const visible = useMemo(
|
|
144
|
+
() => (embedOnly ? (relations || []).filter(isEmbedded) : relations || []),
|
|
145
|
+
[relations, embedOnly],
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
if (parentId === undefined || visible.length === 0) {
|
|
117
149
|
return null
|
|
118
150
|
}
|
|
119
151
|
|
|
@@ -122,7 +154,7 @@ export function DynamicRelations({
|
|
|
122
154
|
// pedido" and "Facturas" in the view modal) — without it consecutive
|
|
123
155
|
// panels sat flush against each other with no breathing room.
|
|
124
156
|
<div className={cn('space-y-6', className)} data-dynamic-relations="">
|
|
125
|
-
{
|
|
157
|
+
{visible.map((rel, idx) => {
|
|
126
158
|
const filters = buildRelationFilters(rel, parentId)
|
|
127
159
|
const panelStrings: Partial<DynamicRelationStrings> = {
|
|
128
160
|
...(strings || {}),
|
|
@@ -0,0 +1,316 @@
|
|
|
1
|
+
// EntitySelect — the shared, permission-aware single-select for a related model.
|
|
2
|
+
//
|
|
3
|
+
// A searchable async combobox over a kernel model's records, plus the two
|
|
4
|
+
// affordances every "pick a related record" control should have, exactly like
|
|
5
|
+
// the dynamic create modal's relation fields (Categoría/Marca) do:
|
|
6
|
+
//
|
|
7
|
+
// - nothing selected → a "+" that opens the model's CREATE dialog, and
|
|
8
|
+
// auto-selects the record it creates;
|
|
9
|
+
// - a record selected → a pencil that opens that record's EDIT dialog.
|
|
10
|
+
//
|
|
11
|
+
// Both affordances are gated by the kernel permissions (useCan): the "+" only
|
|
12
|
+
// shows when the user can create the model, the pencil only when they can edit
|
|
13
|
+
// it. Everything is DYNAMIC — the create/edit form comes from the model's
|
|
14
|
+
// `/metadata/modal/:model` schema via <CreateRecordDialog>, so no per-model form
|
|
15
|
+
// code is needed. This lives in the SDK so POS, purchases and any future addon
|
|
16
|
+
// share ONE implementation instead of each re-porting a bespoke picker.
|
|
17
|
+
import { useCallback, useEffect, useState } from 'react'
|
|
18
|
+
import { Search, X, Plus, Pencil, type LucideIcon } from 'lucide-react'
|
|
19
|
+
import {
|
|
20
|
+
Button,
|
|
21
|
+
Command,
|
|
22
|
+
CommandEmpty,
|
|
23
|
+
CommandGroup,
|
|
24
|
+
CommandInput,
|
|
25
|
+
CommandItem,
|
|
26
|
+
CommandList,
|
|
27
|
+
Popover,
|
|
28
|
+
PopoverContent,
|
|
29
|
+
PopoverTrigger,
|
|
30
|
+
} from '@asteby/metacore-ui'
|
|
31
|
+
import { CreateRecordDialog } from './dialogs/create-record-dialog'
|
|
32
|
+
import { useCan } from './permissions-context'
|
|
33
|
+
import { useApi } from './api-context'
|
|
34
|
+
|
|
35
|
+
/** One searchable option: `value` is the id, `label` the display text. */
|
|
36
|
+
export interface EntitySelectOption {
|
|
37
|
+
value: string
|
|
38
|
+
label: string
|
|
39
|
+
description?: string
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface EntitySelectProps {
|
|
43
|
+
/** Kernel model key (e.g. "Supplier", "Warehouse", "Category"). */
|
|
44
|
+
model: string
|
|
45
|
+
/** Currently selected id (or null). */
|
|
46
|
+
value: string | null
|
|
47
|
+
/** Label of the selected record (rendered without a re-fetch). */
|
|
48
|
+
label: string | null
|
|
49
|
+
/** Called with (id, label) on select/create/clear. */
|
|
50
|
+
onSelect: (id: string | null, label: string | null) => void
|
|
51
|
+
/** Async search over the model. Callers pass their `/api/options/<model>` fetcher. */
|
|
52
|
+
fetcher: (q: string, signal: AbortSignal) => Promise<EntitySelectOption[]>
|
|
53
|
+
|
|
54
|
+
icon?: LucideIcon
|
|
55
|
+
placeholder?: string
|
|
56
|
+
searchPlaceholder?: string
|
|
57
|
+
emptyText?: string
|
|
58
|
+
/** Preload first results on open and drop the 2-char gate. */
|
|
59
|
+
preload?: boolean
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Permission overrides. By default create/edit are gated by the kernel
|
|
63
|
+
* permissions `<model>.create` / `<model>.update` (useCan). Pass explicit
|
|
64
|
+
* booleans to force them (e.g. a read-only surface).
|
|
65
|
+
*/
|
|
66
|
+
canCreate?: boolean
|
|
67
|
+
canEdit?: boolean
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* CRUD endpoint base for the create/edit dialog. Defaults to the standard
|
|
71
|
+
* org-scoped `/data/<model>/me`, with edit at `/data/<model>/me/<id>`.
|
|
72
|
+
*/
|
|
73
|
+
endpoint?: string
|
|
74
|
+
/** Record field used as the label after create/edit (default "name"). */
|
|
75
|
+
labelField?: string
|
|
76
|
+
/** Disable the whole control. */
|
|
77
|
+
disabled?: boolean
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Lowercase model → permission capability namespace (Supplier → supplier). */
|
|
81
|
+
function capabilityNamespace(model: string): string {
|
|
82
|
+
return model.replace(/([a-z0-9])([A-Z])/g, '$1_$2').toLowerCase()
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function EntitySelect({
|
|
86
|
+
model,
|
|
87
|
+
value,
|
|
88
|
+
label,
|
|
89
|
+
onSelect,
|
|
90
|
+
fetcher,
|
|
91
|
+
icon: Icon,
|
|
92
|
+
placeholder = 'Seleccionar…',
|
|
93
|
+
searchPlaceholder = 'Buscar…',
|
|
94
|
+
emptyText = 'Sin resultados',
|
|
95
|
+
preload = false,
|
|
96
|
+
canCreate,
|
|
97
|
+
canEdit,
|
|
98
|
+
endpoint,
|
|
99
|
+
labelField = 'name',
|
|
100
|
+
disabled = false,
|
|
101
|
+
}: EntitySelectProps) {
|
|
102
|
+
const can = useCan()
|
|
103
|
+
const api = useApi()
|
|
104
|
+
|
|
105
|
+
const ns = capabilityNamespace(model)
|
|
106
|
+
const mayCreate = canCreate ?? can(`${ns}.create`)
|
|
107
|
+
const mayEdit = canEdit ?? can(`${ns}.update`)
|
|
108
|
+
const base = endpoint ?? `/data/${model}/me`
|
|
109
|
+
|
|
110
|
+
const [open, setOpen] = useState(false)
|
|
111
|
+
const [dialogOpen, setDialogOpen] = useState(false)
|
|
112
|
+
const [dialogRecordId, setDialogRecordId] = useState<string | undefined>(undefined)
|
|
113
|
+
const [searchTerm, setSearchTerm] = useState('')
|
|
114
|
+
const [results, setResults] = useState<EntitySelectOption[]>([])
|
|
115
|
+
const [isLoading, setIsLoading] = useState(false)
|
|
116
|
+
|
|
117
|
+
const minChars = preload ? 0 : 2
|
|
118
|
+
|
|
119
|
+
const run = useCallback(
|
|
120
|
+
async (q: string, signal: AbortSignal) => {
|
|
121
|
+
if (q.length < minChars) {
|
|
122
|
+
setResults([])
|
|
123
|
+
return
|
|
124
|
+
}
|
|
125
|
+
setIsLoading(true)
|
|
126
|
+
try {
|
|
127
|
+
const rows = await fetcher(q, signal)
|
|
128
|
+
if (!signal.aborted) setResults(rows)
|
|
129
|
+
} catch {
|
|
130
|
+
if (!signal.aborted) setResults([])
|
|
131
|
+
} finally {
|
|
132
|
+
if (!signal.aborted) setIsLoading(false)
|
|
133
|
+
}
|
|
134
|
+
},
|
|
135
|
+
[fetcher, minChars],
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
useEffect(() => {
|
|
139
|
+
if (!open) return
|
|
140
|
+
const controller = new AbortController()
|
|
141
|
+
const timeout = setTimeout(() => run(searchTerm, controller.signal), 250)
|
|
142
|
+
return () => {
|
|
143
|
+
clearTimeout(timeout)
|
|
144
|
+
controller.abort()
|
|
145
|
+
}
|
|
146
|
+
}, [searchTerm, run, open])
|
|
147
|
+
|
|
148
|
+
const pick = (row: EntitySelectOption) => {
|
|
149
|
+
onSelect(row.value, row.label)
|
|
150
|
+
setOpen(false)
|
|
151
|
+
setSearchTerm('')
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const clear = (e: React.MouseEvent) => {
|
|
155
|
+
e.stopPropagation()
|
|
156
|
+
onSelect(null, null)
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const openCreate = (e: React.MouseEvent) => {
|
|
160
|
+
e.stopPropagation()
|
|
161
|
+
setDialogRecordId(undefined)
|
|
162
|
+
setDialogOpen(true)
|
|
163
|
+
}
|
|
164
|
+
const openEdit = (e: React.MouseEvent) => {
|
|
165
|
+
e.stopPropagation()
|
|
166
|
+
if (!value) return
|
|
167
|
+
setDialogRecordId(value)
|
|
168
|
+
setDialogOpen(true)
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// Read the {id,label} off a saved record and select it. The transport
|
|
172
|
+
// matches the standard org-scoped CRUD the dynamic modal uses, so create/edit
|
|
173
|
+
// stay consistent with the rest of the app.
|
|
174
|
+
const selectSaved = (rec: Record<string, unknown> | undefined | null) => {
|
|
175
|
+
if (!rec) return
|
|
176
|
+
const id = rec.id != null ? String(rec.id) : value ?? ''
|
|
177
|
+
const lbl =
|
|
178
|
+
(rec[labelField] != null && String(rec[labelField])) ||
|
|
179
|
+
(rec.name != null && String(rec.name)) ||
|
|
180
|
+
label ||
|
|
181
|
+
id
|
|
182
|
+
onSelect(id, lbl)
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
return (
|
|
186
|
+
<div className="flex items-center gap-1.5">
|
|
187
|
+
<Popover open={open} onOpenChange={disabled ? undefined : setOpen}>
|
|
188
|
+
<PopoverTrigger asChild>
|
|
189
|
+
<Button
|
|
190
|
+
variant="outline"
|
|
191
|
+
disabled={disabled}
|
|
192
|
+
className="w-full flex-1 justify-start gap-2 font-normal"
|
|
193
|
+
>
|
|
194
|
+
{Icon && <Icon className="text-muted-foreground size-4 shrink-0" />}
|
|
195
|
+
<span className="flex-1 truncate text-left">{label ?? placeholder}</span>
|
|
196
|
+
{value && (
|
|
197
|
+
<span
|
|
198
|
+
role="button"
|
|
199
|
+
tabIndex={0}
|
|
200
|
+
onClick={clear}
|
|
201
|
+
onKeyDown={(e) => {
|
|
202
|
+
if (e.key === 'Enter' || e.key === ' ')
|
|
203
|
+
clear(e as unknown as React.MouseEvent)
|
|
204
|
+
}}
|
|
205
|
+
className="hover:bg-accent ml-auto shrink-0 rounded-sm p-0.5"
|
|
206
|
+
>
|
|
207
|
+
<X className="size-3.5" />
|
|
208
|
+
</span>
|
|
209
|
+
)}
|
|
210
|
+
</Button>
|
|
211
|
+
</PopoverTrigger>
|
|
212
|
+
<PopoverContent
|
|
213
|
+
className="p-0"
|
|
214
|
+
align="start"
|
|
215
|
+
style={{ width: 'var(--radix-popover-trigger-width)' }}
|
|
216
|
+
>
|
|
217
|
+
<Command shouldFilter={false}>
|
|
218
|
+
<CommandInput
|
|
219
|
+
placeholder={searchPlaceholder}
|
|
220
|
+
value={searchTerm}
|
|
221
|
+
onValueChange={setSearchTerm}
|
|
222
|
+
/>
|
|
223
|
+
<CommandList>
|
|
224
|
+
{isLoading && (
|
|
225
|
+
<div className="text-muted-foreground py-4 text-center text-sm">
|
|
226
|
+
Buscando…
|
|
227
|
+
</div>
|
|
228
|
+
)}
|
|
229
|
+
{!isLoading &&
|
|
230
|
+
searchTerm.length >= minChars &&
|
|
231
|
+
results.length === 0 && <CommandEmpty>{emptyText}</CommandEmpty>}
|
|
232
|
+
{!isLoading && results.length > 0 && (
|
|
233
|
+
<CommandGroup className="max-h-64 overflow-auto">
|
|
234
|
+
{results.map((row) => (
|
|
235
|
+
<CommandItem
|
|
236
|
+
key={row.value}
|
|
237
|
+
value={row.value}
|
|
238
|
+
onSelect={() => pick(row)}
|
|
239
|
+
className="flex flex-col items-start gap-0.5"
|
|
240
|
+
>
|
|
241
|
+
<span className="text-sm font-medium">{row.label}</span>
|
|
242
|
+
{row.description && (
|
|
243
|
+
<span className="text-muted-foreground text-xs">
|
|
244
|
+
{row.description}
|
|
245
|
+
</span>
|
|
246
|
+
)}
|
|
247
|
+
</CommandItem>
|
|
248
|
+
))}
|
|
249
|
+
</CommandGroup>
|
|
250
|
+
)}
|
|
251
|
+
{!isLoading && !preload && searchTerm.length < minChars && (
|
|
252
|
+
<div className="text-muted-foreground flex flex-col items-center gap-1 py-6">
|
|
253
|
+
<Search className="size-5" />
|
|
254
|
+
<span className="text-xs">Escribe al menos 2 caracteres</span>
|
|
255
|
+
</div>
|
|
256
|
+
)}
|
|
257
|
+
</CommandList>
|
|
258
|
+
</Command>
|
|
259
|
+
</PopoverContent>
|
|
260
|
+
</Popover>
|
|
261
|
+
|
|
262
|
+
{/* Selected → edit (pencil); empty → create (+). Each gated by perms. */}
|
|
263
|
+
{value
|
|
264
|
+
? mayEdit && (
|
|
265
|
+
<Button
|
|
266
|
+
type="button"
|
|
267
|
+
variant="outline"
|
|
268
|
+
size="icon"
|
|
269
|
+
disabled={disabled}
|
|
270
|
+
onClick={openEdit}
|
|
271
|
+
aria-label="Editar"
|
|
272
|
+
title="Editar"
|
|
273
|
+
className="shrink-0"
|
|
274
|
+
>
|
|
275
|
+
<Pencil className="size-4" />
|
|
276
|
+
</Button>
|
|
277
|
+
)
|
|
278
|
+
: mayCreate && (
|
|
279
|
+
<Button
|
|
280
|
+
type="button"
|
|
281
|
+
variant="outline"
|
|
282
|
+
size="icon"
|
|
283
|
+
disabled={disabled}
|
|
284
|
+
onClick={openCreate}
|
|
285
|
+
aria-label="Crear"
|
|
286
|
+
title="Crear"
|
|
287
|
+
className="shrink-0"
|
|
288
|
+
>
|
|
289
|
+
<Plus className="size-4" />
|
|
290
|
+
</Button>
|
|
291
|
+
)}
|
|
292
|
+
|
|
293
|
+
{dialogOpen && (
|
|
294
|
+
<CreateRecordDialog
|
|
295
|
+
modelKey={model}
|
|
296
|
+
open={dialogOpen}
|
|
297
|
+
onOpenChange={setDialogOpen}
|
|
298
|
+
recordId={dialogRecordId}
|
|
299
|
+
endpoint={base}
|
|
300
|
+
onCreate={async (data) => {
|
|
301
|
+
const res = await api.post(base, data)
|
|
302
|
+
const rec = (res.data?.data ?? res.data) as Record<string, unknown>
|
|
303
|
+
selectSaved(rec)
|
|
304
|
+
return rec.id != null ? { id: String(rec.id) } : undefined
|
|
305
|
+
}}
|
|
306
|
+
onUpdate={async (id, data) => {
|
|
307
|
+
const res = await api.put(`${base}/${id}`, data)
|
|
308
|
+
const rec = (res.data?.data ?? res.data) as Record<string, unknown>
|
|
309
|
+
selectSaved({ id, ...rec })
|
|
310
|
+
return { id: String(id) }
|
|
311
|
+
}}
|
|
312
|
+
/>
|
|
313
|
+
)}
|
|
314
|
+
</div>
|
|
315
|
+
)
|
|
316
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -175,6 +175,8 @@ export * from './navigation-builder'
|
|
|
175
175
|
export * from './i18n-provider'
|
|
176
176
|
export * from './api-context'
|
|
177
177
|
export * from './use-addon-settings'
|
|
178
|
+
export * from './use-print-document'
|
|
179
|
+
export * from './print-document-button'
|
|
178
180
|
export * from './metadata-cache'
|
|
179
181
|
export {
|
|
180
182
|
ADDON_MANIFEST_CHANGED_TYPE,
|
|
@@ -242,6 +244,8 @@ export { DynamicRecordDialog, ViewValue } from './dialogs/dynamic-record'
|
|
|
242
244
|
export { normalizeRefFieldsForSubmit } from './dialogs/normalize-submit'
|
|
243
245
|
export type { DynamicRecordDialogProps, FieldDef, FieldOption, GetImageUrl } from './dialogs/dynamic-record'
|
|
244
246
|
export { CreateRecordDialog } from './dialogs/create-record-dialog'
|
|
247
|
+
export { EntitySelect } from './entity-select'
|
|
248
|
+
export type { EntitySelectProps, EntitySelectOption } from './entity-select'
|
|
245
249
|
export { ViewRecordDialog } from './dialogs/view-record-dialog'
|
|
246
250
|
export type {
|
|
247
251
|
ModelKey,
|
|
@@ -273,6 +277,7 @@ export {
|
|
|
273
277
|
DynamicRelations,
|
|
274
278
|
resolveParentId,
|
|
275
279
|
buildRelationFilters,
|
|
280
|
+
isEmbedded,
|
|
276
281
|
type DynamicRelationsProps,
|
|
277
282
|
} from './dynamic-relations'
|
|
278
283
|
export {
|