@asteby/metacore-runtime-react 28.6.0 → 28.8.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.
@@ -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,
@@ -0,0 +1,75 @@
1
+ // PrintDocumentButton — a drop-in button that prints/downloads a server-rendered
2
+ // document via usePrintDocument, so an addon doesn't re-wire the hook + loading
3
+ // state every time. Headless-friendly: it renders a plain <button> you style
4
+ // with `className`, disables itself while the PDF is fetching, and reports
5
+ // failures through `onError` (no toast dependency baked in).
6
+ //
7
+ // Example:
8
+ // <PrintDocumentButton model="SalesOrder" id={sale.id} documentKey="sale_ticket"
9
+ // className="btn">Imprimir ticket</PrintDocumentButton>
10
+ import React, { useCallback, useState } from 'react'
11
+ import { usePrintDocument, type PrintDocumentArgs } from './use-print-document'
12
+
13
+ export interface PrintDocumentButtonProps
14
+ extends Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, 'onError'> {
15
+ /** Model KEY the document is declared against (e.g. "SalesOrder"). */
16
+ model: string
17
+ /** Record id. */
18
+ id: string
19
+ /** Document key from contributions.documents[].key (e.g. "sale_ticket"). */
20
+ documentKey: string
21
+ /** print (default) | download | open — see usePrintDocument. */
22
+ mode?: PrintDocumentArgs['mode']
23
+ /** Download filename (mode="download"). */
24
+ filename?: string
25
+ /** Called if the fetch/print fails (surface it to a toast in the host). */
26
+ onError?: (err: unknown) => void
27
+ /** Rendered while the PDF is being fetched, in place of children. */
28
+ pendingLabel?: React.ReactNode
29
+ children?: React.ReactNode
30
+ }
31
+
32
+ export function PrintDocumentButton({
33
+ model,
34
+ id,
35
+ documentKey,
36
+ mode,
37
+ filename,
38
+ onError,
39
+ pendingLabel,
40
+ children,
41
+ disabled,
42
+ onClick,
43
+ ...rest
44
+ }: PrintDocumentButtonProps) {
45
+ const printDocument = usePrintDocument()
46
+ const [busy, setBusy] = useState(false)
47
+
48
+ const handleClick = useCallback(
49
+ async (e: React.MouseEvent<HTMLButtonElement>) => {
50
+ onClick?.(e)
51
+ if (e.defaultPrevented) return
52
+ setBusy(true)
53
+ try {
54
+ await printDocument({ model, id, key: documentKey, mode, filename })
55
+ } catch (err) {
56
+ onError?.(err)
57
+ } finally {
58
+ setBusy(false)
59
+ }
60
+ },
61
+ [printDocument, model, id, documentKey, mode, filename, onError, onClick],
62
+ )
63
+
64
+ return (
65
+ <button
66
+ type="button"
67
+ {...rest}
68
+ disabled={disabled || busy}
69
+ aria-busy={busy || undefined}
70
+ onClick={handleClick}
71
+ >
72
+ {busy && pendingLabel != null ? pendingLabel : children}
73
+ </button>
74
+ )
75
+ }
package/src/types.ts CHANGED
@@ -1,6 +1,27 @@
1
1
  // Shared metadata shape consumed by every host. Some hosts add a `link`
2
2
  // action type with a `linkUrl` template — represented here as part of the
3
3
  // `type` union so the SDK can render it uniformly.
4
+ /** One spreadsheet column of a model's import template. Mirrors the kernel's
5
+ * `modelbase.ImportColumn`. */
6
+ export interface ImportColumnMeta {
7
+ key: string
8
+ header: string
9
+ aliases?: string[]
10
+ required?: boolean
11
+ type?: string
12
+ example?: string
13
+ hint?: string
14
+ generator?: string
15
+ }
16
+
17
+ /** A model's spreadsheet-import declaration. Mirrors `modelbase.ImportSpec`. */
18
+ export interface ImportSpecMeta {
19
+ columns: ImportColumnMeta[]
20
+ maxRows?: number
21
+ sheetName?: string
22
+ instructions?: string[]
23
+ }
24
+
4
25
  export interface TableMetadata {
5
26
  title: string
6
27
  endpoint: string
@@ -15,6 +36,16 @@ export interface TableMetadata {
15
36
  canExport?: boolean
16
37
  canImport?: boolean
17
38
  canCreate?: boolean
39
+ /**
40
+ * The model's spreadsheet-import declaration, served by the kernel: the
41
+ * columns of the generated template and the headers accepted when parsing
42
+ * a filled file back in. The kernel derives it from the model's form
43
+ * fields when the model declares nothing, and omits the field entirely
44
+ * when the model has no importable column — so its presence is the signal
45
+ * that importing is meaningful for this model. Purely additive; older
46
+ * kernels omit it.
47
+ */
48
+ import?: ImportSpecMeta
18
49
  /**
19
50
  * Child relations of this model, served by the kernel (>= v0.41.0). A
20
51
  * generic detail page renders one `DynamicRelation` panel per entry via
@@ -0,0 +1,112 @@
1
+ // usePrintDocument — THE standard primitive for printing/downloading a
2
+ // server-rendered document (ticket, receipt, order) from any federated addon or
3
+ // host surface, without each addon reimplementing PDF fetching.
4
+ //
5
+ // The host (ops) renders documents declared in an addon's
6
+ // `contributions.documents[]` via a country/business-agnostic engine
7
+ // (pdf_chrome + document_render + org branding) and serves them at:
8
+ //
9
+ // GET /api/data/:model/:id/documents/:key.pdf → application/pdf
10
+ //
11
+ // The endpoint is auth-gated (Bearer), so we CANNOT just window.open the URL —
12
+ // that request carries no Authorization header and 401s. Instead we fetch the
13
+ // PDF through the injected ApiClient (which carries the token), turn the bytes
14
+ // into a blob URL, and print/download/open that. Re-printing is just calling
15
+ // this again — the render is an idempotent GET.
16
+ //
17
+ // The ApiClient is a PEER via <ApiProvider> (same one useAddonSettings uses), so
18
+ // this hook constructs no client of its own.
19
+ import { useCallback } from 'react'
20
+ import { useApi } from './api-context'
21
+
22
+ export interface PrintDocumentArgs {
23
+ /** The model KEY the document is declared against (e.g. "SalesOrder"). */
24
+ model: string
25
+ /** The record id. */
26
+ id: string
27
+ /** The document key from contributions.documents[].key (e.g. "sale_ticket"). */
28
+ key: string
29
+ /**
30
+ * print → open the PDF in a hidden iframe and fire the browser print dialog
31
+ * (default; best for thermal tickets — one click to the printer).
32
+ * download → save the PDF to disk.
33
+ * open → open the PDF in a new tab (user prints from the viewer).
34
+ */
35
+ mode?: 'print' | 'download' | 'open'
36
+ /** Filename for the download mode (defaults to "<key>.pdf"). */
37
+ filename?: string
38
+ }
39
+
40
+ /**
41
+ * Returns a `printDocument(args)` callback. Resolves once the PDF has been
42
+ * fetched and the browser action (print/download/open) has been kicked off;
43
+ * rejects if the fetch fails (surface the error to a toast). Returns the blob
44
+ * URL created, revoked automatically after a minute.
45
+ */
46
+ export function usePrintDocument() {
47
+ const api = useApi()
48
+ return useCallback(
49
+ async ({
50
+ model,
51
+ id,
52
+ key,
53
+ mode = 'print',
54
+ filename,
55
+ }: PrintDocumentArgs): Promise<string> => {
56
+ const url = `/data/${encodeURIComponent(model)}/${encodeURIComponent(
57
+ id,
58
+ )}/documents/${encodeURIComponent(key)}.pdf`
59
+ const res = await api.get(url, { responseType: 'blob' })
60
+ const blob =
61
+ res.data instanceof Blob
62
+ ? res.data
63
+ : new Blob([res.data], { type: 'application/pdf' })
64
+ const blobUrl = URL.createObjectURL(blob)
65
+ const cleanup = () => setTimeout(() => URL.revokeObjectURL(blobUrl), 60_000)
66
+
67
+ if (mode === 'download') {
68
+ const a = document.createElement('a')
69
+ a.href = blobUrl
70
+ a.download = filename || `${key}.pdf`
71
+ document.body.appendChild(a)
72
+ a.click()
73
+ a.remove()
74
+ cleanup()
75
+ return blobUrl
76
+ }
77
+
78
+ if (mode === 'open') {
79
+ window.open(blobUrl, '_blank')
80
+ cleanup()
81
+ return blobUrl
82
+ }
83
+
84
+ // mode === 'print': hidden iframe + contentWindow.print(). This is the
85
+ // reliable cross-browser way to auto-open the print dialog for a PDF
86
+ // blob (window.open + print() is blocked by the PDF viewer in Chrome).
87
+ const iframe = document.createElement('iframe')
88
+ iframe.style.position = 'fixed'
89
+ iframe.style.right = '0'
90
+ iframe.style.bottom = '0'
91
+ iframe.style.width = '0'
92
+ iframe.style.height = '0'
93
+ iframe.style.border = '0'
94
+ iframe.src = blobUrl
95
+ iframe.onload = () => {
96
+ try {
97
+ iframe.contentWindow?.focus()
98
+ iframe.contentWindow?.print()
99
+ } catch {
100
+ // Popup/print blocked — fall back to opening the PDF.
101
+ window.open(blobUrl, '_blank')
102
+ }
103
+ // Keep the iframe around long enough for the print dialog to read it.
104
+ setTimeout(() => iframe.remove(), 60_000)
105
+ cleanup()
106
+ }
107
+ document.body.appendChild(iframe)
108
+ return blobUrl
109
+ },
110
+ [api],
111
+ )
112
+ }