// 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 { ExportDialog } from './dialogs/export' import { ImportDialog } from './dialogs/import' import { getModelExtension } from './model-extension-registry' import { ModelActionToolbar } from './model-action-toolbar' import { useCan, modelCapability } from './permissions-context' import type { 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 } /** * 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, } = 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(false) const [openExport, setOpenExport] = useState(false) const [openImport, setOpenImport] = useState(false) useEffect(() => { if (cachedMeta) { setMetadata(cachedMeta) return } 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) 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 // A "create"-placement action ships a custom create experience — it // replaces the generic create button (the ModelActionToolbar renders it). const hasCreateAction = metadata?.actions?.some((a) => a.placement === 'create') ?? false const effectiveHideCreate = hideCreate || ext?.hideCreate || hasCreateAction 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 showCreate = enableCRUD && !effectiveHideCreate && can(modelCapability(model, 'create')) const showImport = enableCRUD && !effectiveHideImport && can(modelCapability(model, 'import')) const showExport = !effectiveHideExport && can(modelCapability(model, 'export')) const showRefresh = !effectiveHideRefresh 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 && ( )} {metadata && showExport && ( )} {metadata && showImport && ( )}
) }