// EntitySelect — the shared, permission-aware single-select for a related model. // // A searchable async combobox over a kernel model's records, plus the two // affordances every "pick a related record" control should have, exactly like // the dynamic create modal's relation fields (Categoría/Marca) do: // // - nothing selected → a "+" that opens the model's CREATE dialog, and // auto-selects the record it creates; // - a record selected → a pencil that opens that record's EDIT dialog. // // Both affordances are gated by the kernel permissions (useCan): the "+" only // shows when the user can create the model, the pencil only when they can edit // it. Everything is DYNAMIC — the create/edit form comes from the model's // `/metadata/modal/:model` schema via , so no per-model form // code is needed. This lives in the SDK so POS, purchases and any future addon // share ONE implementation instead of each re-porting a bespoke picker. import { useCallback, useEffect, useState } from 'react' import { Search, X, Plus, Pencil, type LucideIcon } from 'lucide-react' import { Button, Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList, Popover, PopoverContent, PopoverTrigger, } from '@asteby/metacore-ui' import { CreateRecordDialog } from './dialogs/create-record-dialog' import { useCan } from './permissions-context' import { useApi } from './api-context' /** One searchable option: `value` is the id, `label` the display text. */ export interface EntitySelectOption { value: string label: string description?: string } export interface EntitySelectProps { /** Kernel model key (e.g. "Supplier", "Warehouse", "Category"). */ model: string /** Currently selected id (or null). */ value: string | null /** Label of the selected record (rendered without a re-fetch). */ label: string | null /** Called with (id, label) on select/create/clear. */ onSelect: (id: string | null, label: string | null) => void /** Async search over the model. Callers pass their `/api/options/` fetcher. */ fetcher: (q: string, signal: AbortSignal) => Promise icon?: LucideIcon placeholder?: string searchPlaceholder?: string emptyText?: string /** Preload first results on open and drop the 2-char gate. */ preload?: boolean /** * Permission overrides. By default create/edit are gated by the kernel * permissions `.create` / `.update` (useCan). Pass explicit * booleans to force them (e.g. a read-only surface). */ canCreate?: boolean canEdit?: boolean /** * CRUD endpoint base for the create/edit dialog. Defaults to the standard * org-scoped `/data//me`, with edit at `/data//me/`. */ endpoint?: string /** Record field used as the label after create/edit (default "name"). */ labelField?: string /** Disable the whole control. */ disabled?: boolean /** * Values seeded into the create dialog's form (e.g. a parent id the caller * already knows — a POS sale's selected customer, when creating a vehicle * from the vehicle picker). Passed through to `CreateRecordDialog.defaults`. */ createDefaults?: Record /** * Field keys from `createDefaults` that must also render locked (visible, * disabled) on create instead of editable — e.g. don't let the user swap * the customer while creating their vehicle from this picker. Passed * through to `CreateRecordDialog.lockedFields`. */ lockedCreateFields?: string[] } /** Lowercase model → permission capability namespace (Supplier → supplier). */ function capabilityNamespace(model: string): string { return model.replace(/([a-z0-9])([A-Z])/g, '$1_$2').toLowerCase() } export function EntitySelect({ model, value, label, onSelect, fetcher, icon: Icon, placeholder = 'Seleccionar…', searchPlaceholder = 'Buscar…', emptyText = 'Sin resultados', preload = false, canCreate, canEdit, endpoint, labelField = 'name', disabled = false, createDefaults, lockedCreateFields, }: EntitySelectProps) { const can = useCan() const api = useApi() const ns = capabilityNamespace(model) const mayCreate = canCreate ?? can(`${ns}.create`) const mayEdit = canEdit ?? can(`${ns}.update`) const base = endpoint ?? `/data/${model}/me` const [open, setOpen] = useState(false) const [dialogOpen, setDialogOpen] = useState(false) const [dialogRecordId, setDialogRecordId] = useState(undefined) const [searchTerm, setSearchTerm] = useState('') const [results, setResults] = useState([]) const [isLoading, setIsLoading] = useState(false) const minChars = preload ? 0 : 2 const run = useCallback( async (q: string, signal: AbortSignal) => { if (q.length < minChars) { setResults([]) return } setIsLoading(true) try { const rows = await fetcher(q, signal) if (!signal.aborted) setResults(rows) } catch { if (!signal.aborted) setResults([]) } finally { if (!signal.aborted) setIsLoading(false) } }, [fetcher, minChars], ) useEffect(() => { if (!open) return const controller = new AbortController() const timeout = setTimeout(() => run(searchTerm, controller.signal), 250) return () => { clearTimeout(timeout) controller.abort() } }, [searchTerm, run, open]) const pick = (row: EntitySelectOption) => { onSelect(row.value, row.label) setOpen(false) setSearchTerm('') } const clear = (e: React.MouseEvent) => { e.stopPropagation() onSelect(null, null) } const openCreate = (e: React.MouseEvent) => { e.stopPropagation() setDialogRecordId(undefined) setDialogOpen(true) } const openEdit = (e: React.MouseEvent) => { e.stopPropagation() if (!value) return setDialogRecordId(value) setDialogOpen(true) } // Read the {id,label} off a saved record and select it. The transport // matches the standard org-scoped CRUD the dynamic modal uses, so create/edit // stay consistent with the rest of the app. const selectSaved = (rec: Record | undefined | null) => { if (!rec) return const id = rec.id != null ? String(rec.id) : value ?? '' const lbl = (rec[labelField] != null && String(rec[labelField])) || (rec.name != null && String(rec.name)) || label || id onSelect(id, lbl) } return (
{isLoading && (
Buscando…
)} {!isLoading && searchTerm.length >= minChars && results.length === 0 && {emptyText}} {!isLoading && results.length > 0 && ( {results.map((row) => ( pick(row)} className="flex flex-col items-start gap-0.5" > {row.label} {row.description && ( {row.description} )} ))} )} {!isLoading && !preload && searchTerm.length < minChars && (
Escribe al menos 2 caracteres
)}
{/* Selected → edit (pencil); empty → create (+). Each gated by perms. */} {value ? mayEdit && ( ) : mayCreate && ( )} {dialogOpen && ( { const res = await api.post(base, data) const rec = (res.data?.data ?? res.data) as Record selectSaved(rec) return rec.id != null ? { id: String(rec.id) } : undefined }} onUpdate={async (id, data) => { const res = await api.put(`${base}/${id}`, data) const rec = (res.data?.data ?? res.data) as Record selectSaved({ id, ...rec }) return { id: String(id) } }} /> )}
) }