@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.
Files changed (38) hide show
  1. package/CHANGELOG.md +43 -0
  2. package/dist/action-modal-dispatcher.js +1 -1
  3. package/dist/dialogs/dynamic-record.d.ts.map +1 -1
  4. package/dist/dialogs/dynamic-record.js +1 -1
  5. package/dist/dynamic-columns.d.ts.map +1 -1
  6. package/dist/dynamic-columns.js +13 -1
  7. package/dist/dynamic-relation.d.ts +4 -0
  8. package/dist/dynamic-relation.d.ts.map +1 -1
  9. package/dist/dynamic-relation.js +80 -16
  10. package/dist/dynamic-relations.d.ts +22 -1
  11. package/dist/dynamic-relations.d.ts.map +1 -1
  12. package/dist/dynamic-relations.js +16 -3
  13. package/dist/entity-select.d.ts +43 -0
  14. package/dist/entity-select.d.ts.map +1 -0
  15. package/dist/entity-select.js +124 -0
  16. package/dist/index.d.ts +5 -1
  17. package/dist/index.d.ts.map +1 -1
  18. package/dist/index.js +4 -1
  19. package/dist/print-document-button.d.ts +21 -0
  20. package/dist/print-document-button.d.ts.map +1 -0
  21. package/dist/print-document-button.js +32 -0
  22. package/dist/types.d.ts +9 -0
  23. package/dist/types.d.ts.map +1 -1
  24. package/dist/use-print-document.d.ts +25 -0
  25. package/dist/use-print-document.d.ts.map +1 -0
  26. package/dist/use-print-document.js +79 -0
  27. package/package.json +3 -3
  28. package/src/__tests__/relation-embed-gate.test.tsx +110 -0
  29. package/src/action-modal-dispatcher.tsx +3 -1
  30. package/src/dialogs/dynamic-record.tsx +6 -1
  31. package/src/dynamic-columns.tsx +14 -1
  32. package/src/dynamic-relation.tsx +121 -23
  33. package/src/dynamic-relations.tsx +34 -2
  34. package/src/entity-select.tsx +316 -0
  35. package/src/index.ts +5 -0
  36. package/src/print-document-button.tsx +75 -0
  37. package/src/types.ts +9 -0
  38. package/src/use-print-document.ts +112 -0
@@ -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
- const fetchAll = useCallback(async () => {
248
- setLoading(true)
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 = buildRelationFilterParams(foreignKey, parentId, filters)
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
- metadata ? Promise.resolve(null) : api.get(`/metadata/table/${model}`),
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) setRows(list.data || [])
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
- setLoading(false)
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, metadata, model, cacheMetadata])
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
- useEffect(() => { fetchAll() }, [fetchAll])
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
- {canCreate && (
417
- <Button
418
- size="sm"
419
- variant="outline"
420
- onClick={() => { setEditingRow(null); setFormOpen(true) }}
421
- >
422
- <Plus className="h-4 w-4 mr-1" />
423
- {labels.addLabel}
424
- </Button>
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
- <div className="overflow-x-auto border rounded-md bg-card">
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
- if (parentId === undefined || !relations || relations.length === 0) {
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
- {relations.map((rel, idx) => {
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 {