// DynamicCRUDPage — drop-in route component for any model that has // `DefineTable` metadata on the backend. Pulls the title and CRUD flag // from `/metadata/table/`, mounts , wires // `` for create, and exposes `` / // `` from a single toolbar. // // The whole thing exists so apps stop reinventing the same ~150 LOC of // page chrome around DynamicTable. Override anything via props: // // // title="Mis clientes" // default: metadata.title // newLabel="Nuevo cliente" // default: "New " // hideExport hideImport hideCreate // toolbar trimming // headerExtras={} // injected before the title row // toolbarExtras={} // injected before "Nuevo X" // i18n={{ refresh: 'Refrescar', export: 'Exportar', ... }} // onChange={() => analytics.track('table.refresh')} // /> // // Apps that only need to swap the create endpoint or hide a button keep // the boilerplate to one line. Apps that need richer per-model headers // register a model-extension registry and feed it via `headerExtras`. import React, { useCallback, useEffect, useMemo, useState, } from 'react' import { Plus, Download, Upload, RefreshCw } from 'lucide-react' import { useApi } from './api-context' import { useMetadataCache } from './metadata-cache' import { DynamicTable } from './dynamic-table' import { DynamicRecordDialog } from './dialogs/dynamic-record' import { DocumentFormDialog, resolveDocumentForms, scopeDocumentFormsToFilter, withoutDelegatedTypes, type DelegatedCreate, } from './document-form-dialog' import { ExportDialog } from './dialogs/export' import { ImportDialog } from './dialogs/import' import { getModelExtension } from './model-extension-registry' import { ModelActionToolbar } from './model-action-toolbar' import { resolveListPrimaryAction } from './list-primary-action' import { useCan, modelCapability } from './permissions-context' import type { DocumentFormsManifest, TableMetadata } from './types' export interface DynamicCRUDPageStrings { refresh?: string export?: string import?: string /** Used as the create button label when `newLabel` is not provided. * Receives the singular form of the title. */ newPrefix?: string } const defaultStrings: Required = { refresh: 'Refresh', export: 'Export', import: 'Import', newPrefix: 'New', } export interface DynamicCRUDPageClasses { root?: string container?: string header?: string title?: string toolbar?: string tableWrapper?: string } export interface DynamicCRUDPageProps { /** Model key as registered on the backend (e.g. "customers"). */ model: string /** Override the data endpoint. Defaults to `/dynamic/`. */ endpoint?: string /** Override the human title. Defaults to `metadata.title`. */ title?: string /** Override the create button label. Defaults to `${newPrefix} ${singular}`. */ newLabel?: string /** Strings used in default labels — pass when the host has its own i18n. */ i18n?: DynamicCRUDPageStrings /** Hide the create button + dialog even when metadata says CRUD is enabled. */ hideCreate?: boolean hideExport?: boolean hideImport?: boolean hideRefresh?: boolean /** Slot rendered above the title row (e.g. branch switcher, kpi strip). */ headerExtras?: React.ReactNode /** Slot rendered in the toolbar, before the create button. */ toolbarExtras?: React.ReactNode /** Tailwind class overrides for layout primitives. */ classes?: DynamicCRUDPageClasses /** Fired after a create/import/refresh successfully reloads the table. */ onChange?: () => void /** * Guided create by document type (FAC-12). Wins over `metadata.document_forms`; * with neither, the generic record dialog is used as before. */ documentForms?: DocumentFormsManifest /** * Filtro fijo de la vista (el `filter` del nav). Si va sobre el `type_field` * del alta guiada, «Crear» abre ese tipo directo o se oculta cuando no hay * formulario para él. */ filter?: Record /** * Abre el alta guiada al montar («crear desde»): tipo de documento y origen * a precargar (`sources[].key` + id), p. ej. leídos de la URL * `?create=&from=&from_id=`. Solo con document_forms. */ initialCreate?: { type?: string; source?: { key: string; id: string } } /** * Alta delegada (`document_forms.types[].create_model`): el host abre la * página del modelo destino con su alta abierta. Sin él, esos tipos no se * ofrecen (y si la vista queda acotada a uno, no hay «Crear»). */ onDelegateCreate?: (target: DelegatedCreate) => void } /** * Page-level CRUD shell wired around . Hosts mount a single * `` per route and the SDK takes care of * metadata fetch, dialogs and toolbar. */ export function DynamicCRUDPage(props: DynamicCRUDPageProps) { const { model, endpoint, title: titleOverride, newLabel, i18n, hideCreate, hideExport, hideImport, hideRefresh, headerExtras, toolbarExtras, classes, onChange, documentForms, filter, initialCreate, onDelegateCreate, } = props const strings = { ...defaultStrings, ...(i18n ?? {}) } const dataEndpoint = endpoint ?? `/dynamic/${model}` const ext = getModelExtension(model) const api = useApi() const cachedMeta = useMetadataCache((s) => s.getMetadata(model)) const [metadata, setMetadata] = useState(cachedMeta ?? null) const [refreshKey, setRefreshKey] = useState(0) const [openCreate, setOpenCreate] = useState(!!initialCreate) const [openExport, setOpenExport] = useState(false) const [openImport, setOpenImport] = useState(false) useEffect(() => { // Stale-while-revalidate: paint from the persisted cache immediately, // then ALWAYS fetch /metadata/table/:model. Returning early on a cache // hit left hosts stranded on pre-upgrade actions (missing toolbar // buttons after an addon HotRegister) because hard refresh does not // clear zustand localStorage. if (cachedMeta) { setMetadata(cachedMeta) } let cancelled = false api .get(`/metadata/table/${model}`) .then((res) => { if (cancelled) return const meta = (res.data?.data ?? res.data) as TableMetadata setMetadata(meta ?? null) }) .catch(() => { if (!cancelled && !cachedMeta) setMetadata(null) }) return () => { cancelled = true } }, [model, cachedMeta, api]) const title = titleOverride ?? ext?.title ?? metadata?.title ?? model const resolvedNewLabel = newLabel ?? ext?.newLabel const singular = useMemo(() => { const t = title.replace(/s$/i, '') return t.charAt(0).toUpperCase() + t.slice(1) }, [title]) const enableCRUD = metadata?.enableCRUDActions ?? false // Una sola primaria: placement create, replaces_create, create_mode o la // extensión del modelo ocultan el Crear genérico. El shell de ops debe // usar el mismo resolveListPrimaryAction. const primary = resolveListPrimaryAction({ enableCRUD, canCreate: metadata?.canCreate, hideCreate: hideCreate || ext?.hideCreate, createMode: ext?.createMode ?? metadata?.create_mode, primaryActionKey: ext?.primaryActionKey, actions: metadata?.actions, }) const effectiveHideExport = hideExport || ext?.hideExport const effectiveHideImport = hideImport || ext?.hideImport // Refresh defaults to hidden in the page header — ships // its own refresh icon next to the View / column-visibility toolbar, so // showing one in the page chrome is just visual duplication. Apps that // want it back can pass `hideRefresh={false}`. const effectiveHideRefresh = hideRefresh ?? ext?.hideRefresh ?? true // Capability gating — no-op unless the host mounts // (useCan defaults to always-true), in which case create/export/import // require `lowercase(model).create|export|import`. const can = useCan() const declaredForms = resolveDocumentForms(metadata, documentForms) const scoped = scopeDocumentFormsToFilter( onDelegateCreate ? declaredForms : withoutDelegatedTypes(declaredForms), filter, ) const guidedForms = scoped.forms const showCreate = primary.showGenericCreate && !scoped.hideCreate && can(modelCapability(model, 'create')) const showImport = enableCRUD && !effectiveHideImport && can(modelCapability(model, 'import')) const showExport = !effectiveHideExport && can(modelCapability(model, 'export')) const showRefresh = !effectiveHideRefresh // El mismo filtro acota la tabla (forma `eq:` que espera DynamicTable). const filterKey = filter ? JSON.stringify(filter) : '' const tableFilters = useMemo(() => { if (!filter || Object.keys(filter).length === 0) return undefined return Object.fromEntries( Object.entries(filter).map(([k, v]) => [k, String(v).includes(':') ? String(v) : `eq:${v}`]), ) // eslint-disable-next-line react-hooks/exhaustive-deps }, [filterKey]) const handleRefresh = useCallback(() => { setRefreshKey((k) => k + 1) onChange?.() }, [onChange]) const rootCls = classes?.root ?? 'flex flex-col h-full overflow-hidden' const containerCls = classes?.container ?? 'flex flex-col flex-1 p-6 lg:p-8 gap-4 overflow-hidden' const headerCls = classes?.header ?? 'flex items-center justify-between shrink-0' const titleCls = classes?.title ?? 'text-2xl font-bold tracking-tight' const toolbarCls = classes?.toolbar ?? 'flex items-center gap-2' const tableWrapperCls = classes?.tableWrapper ?? 'flex-1 min-h-0' return (
{ext?.headerExtras && } {headerExtras}
{metadata ? (

{title}

) : (
)}
{showRefresh && ( )} {metadata && showExport && ( )} {metadata && showImport && ( )} {ext?.toolbarExtras && } {toolbarExtras} {showCreate && ( )}
{showCreate && guidedForms && ( )} {showCreate && !guidedForms && ( )} {metadata && showExport && ( )} {metadata && showImport && ( )}
) }