// DynamicTable — metadata-driven CRUD table used by every metacore host. // Originally extracted from a host app and generalized so the host-specific // aliases are swapped for metacore packages + context-injected peer deps: // * `@/lib/api` → (see api-context.tsx) // * `@/stores/branch-store` → (optional) // * `@/stores/metadata-cache` → internal ./metadata-cache zustand store // * `@/components/ui/*` → @asteby/metacore-ui/primitives // * `@/components/data-table/*` → @asteby/metacore-ui/data-table // * `@/components/dynamic/{record,export,import}-dialog` → ./dialogs/* // * `@/components/dynamic/dynamic-columns` → host-injected via the // `getDynamicColumns` prop (hosts retain ownership because the rendered // column cells are tightly coupled to their design system). import { useEffect, useState, useMemo, useCallback, useRef, type MouseEvent } from 'react' import { useTranslation } from 'react-i18next' import { format } from 'date-fns' import type { DateRange } from 'react-day-picker' import { useVirtualizer } from '@tanstack/react-virtual' import { type SortingState, type VisibilityState, type ColumnFiltersState, type PaginationState, type ColumnDef, type HeaderGroup, type Header, type Row, type Cell, flexRender, getCoreRowModel, getFacetedRowModel, getFacetedUniqueValues, getFilteredRowModel, getPaginationRowModel, getSortedRowModel, useReactTable, } from '@tanstack/react-table' import { cn } from '@asteby/metacore-ui/lib' import { Table, TableBody, TableCell, TableFooter, TableHead, TableHeader, TableRow, Button, Skeleton, AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, } from '@asteby/metacore-ui/primitives' import { DataTablePagination, DataTableToolbar, DataTableBulkActions, type FilterOption as DynamicFilterOption, } from '@asteby/metacore-ui/data-table' import { Inbox, Download, Upload, Trash2 } from 'lucide-react' import { toast } from 'sonner' import { Progress } from './dialogs/_primitives' import { useMetadataCache } from './metadata-cache' import { useApi, useCurrentBranch } from './api-context' import type { ColumnFilterConfig, GetDynamicColumns } from './dynamic-columns-shim' import { defaultGetDynamicColumns, DATE_CELL_TYPES, aggregateOf, formatAggregateTotal } from './dynamic-columns' import { useFacetLoaders, isLongTextColumn } from './use-facet-loaders' import { translateOptionLabels } from './filter-chips' import { dedupeById, useInfiniteScrollSentinel } from './use-infinite-scroll' import { DYNAMIC_TABLE_CARD_ESTIMATE_PX, DYNAMIC_TABLE_ROW_ESTIMATE_PX, resolveVirtualizeThreshold, } from './table-virtualization' import { OptionsContext } from './options-context' import type { TableMetadata, ApiResponse, ColumnDefinition } from './types' import { getSearchableColumnKeys } from './column-visibility' import { useDebouncedValue } from './use-debounced-value' import { useCan, usePermissionsActive, gateTableMetadata } from './permissions-context' import { useDynamicRowActions } from './dynamic-row-actions' import { ExportDialog } from './dialogs/export' import { ImportDialog } from './dialogs/import' import { buildListScopeValues, evaluateVisibleWhenForListScope, getVisibleWhen, scopeValueFromFilterToken, } from './dynamic-form-schema' // --------------------------------------------------------------------------- // Row-data cache (perceived performance). // // The table fetches rows into local state, so a full page reload starts empty // and shows a full skeleton until `/data/:model` resolves — even for a view the // user just looked at. We stash the last first-page result and seed the initial // state from it so a reload paints the previous rows instantly and the fetch // below revalidates in the background (stale-while-revalidate). // // Deliberately `sessionStorage`, NOT localStorage: row data is org/user-scoped, // so it must not outlive the tab session (a browser restart or a different login // starts clean). The key includes model+endpoint+branch+URL params so a // different filter / sort / page never paints the wrong rows. Only the first // page is cached (capped) — infinite-scroll top-ups re-fetch on scroll. const TBL_DATA_CACHE_PREFIX = 'mc:tbl:data:v1' interface TableDataCacheEntry { rows: any[]; rowCount: number; ts: number } function tableDataCacheKey( model: string | undefined, endpoint: string | undefined, branchId: string | number | null | undefined, search: string, ): string { const p = new URLSearchParams(search) const parts: string[] = [] p.forEach((v, k) => parts.push(`${k}=${v}`)) parts.sort() return `${TBL_DATA_CACHE_PREFIX}|${model || ''}|${endpoint || ''}|${branchId || ''}|${parts.join('&')}` } function readTableDataCache(key: string): TableDataCacheEntry | null { try { const raw = sessionStorage.getItem(key) if (!raw) return null const parsed = JSON.parse(raw) return parsed && Array.isArray(parsed.rows) ? parsed : null } catch { return null } } function writeTableDataCache(key: string, rows: any[], rowCount: number): void { try { sessionStorage.setItem( key, JSON.stringify({ rows: rows.slice(0, 50), rowCount, ts: Date.now() }), ) } catch { // quota / private mode — the cache is a nicety, never fatal } } // Chosen page size, persisted per table so a user's preferred density survives // reloads. localStorage ON PURPOSE (unlike the row-data cache above): a page // size is a UI preference, not org/user-scoped data, so outliving the tab is // desired. Keyed by model. const TBL_PAGE_SIZE_PREFIX = 'mc:tbl:pageSize:v1' function readStoredPageSize(model: string): number | null { try { const raw = localStorage.getItem(`${TBL_PAGE_SIZE_PREFIX}|${model}`) if (!raw) return null const n = parseInt(raw, 10) return Number.isFinite(n) && n > 0 ? n : null } catch { return null } } function writeStoredPageSize(model: string, size: number | null): void { try { if (size === null) localStorage.removeItem(`${TBL_PAGE_SIZE_PREFIX}|${model}`) else localStorage.setItem(`${TBL_PAGE_SIZE_PREFIX}|${model}`, String(size)) } catch { // quota / private mode — persistence is a nicety, never fatal } } export interface DynamicTableProps { model: string endpoint?: string /** * Endpoint base for WRITES (row delete, bulk delete) when it differs from * the list endpoint — e.g. listing from a role-scoped `/dynamic//me` * while deleting against `/dynamic//`. Falls back to `endpoint`. */ mutationEndpoint?: string enableUrlSync?: boolean /** * Hide the import action on THIS view even when the model supports it. * A role-scoped view (e.g. a rep seeing only their own records) usually * wants the table without a bulk-import entry point, while the same model * keeps it on the admin view. Absent → the model's metadata decides. */ hideImport?: boolean /** Hide the export action on this view. See `hideImport`. */ hideExport?: boolean hiddenColumns?: string[] /** * Row-action ALLOWLIST for this table instance, by action key (manifest * v3 NavItem.actions, carried through the host's nav item for this route). * When set, only these row actions render — even though the model may * declare more. Lets two views of the SAME model differ: a generic list * keeps every action, while a purpose-built screen (e.g. a credit-approval * queue) shows only authorize_credit/reject_credit instead of every action * SalesOrder declares (Generar factura, Cancelar, etc. included). * Undefined → every row action the model declares (unchanged default). */ allowedActionKeys?: string[] onAction?: (action: string, row: any) => void /** * Called when the user clicks anywhere on a data row (not on a checkbox, * action button, or interactive element inside the cell). When provided, * each row becomes focusable (cursor-pointer). Absent → rows are not * clickable and the behaviour is unchanged. */ onRowClick?: (row: any) => void refreshTrigger?: any defaultFilters?: Record extraColumns?: ColumnDef[] /** * Host-provided factory that turns metadata into TanStack column defs. * Lives in the host because the rendered cells depend on the host's * design system (Badge, Avatar, MediaGallery, phone flags, etc.). * Optional — a sensible default maps each column to { accessorKey, header }. */ getDynamicColumns?: GetDynamicColumns /** * IANA timezone (e.g. the org's `America/Mexico_City`) used to render * datetime/timestamp cells. When provided, instants are displayed in this * zone instead of the viewer's browser zone, so the day/time never shifts. * Optional — omitting it preserves the legacy browser-local formatting. */ timeZone?: string /** * ISO 4217 currency code (e.g. the org's `MXN`) used as the fallback for * money cells (`type:'number'` + `cellStyle:'currency'`) that don't carry * an explicit per-column currency. Optional — defaults to 'USD'. */ currency?: string /** * Pagination mode. * - 'pages' (default): classic pager footer (DataTablePagination — rows * per page selector, "página X de Y", first/prev/next/last). Each page * change fetches and REPLACES the visible rows (page/per_page against * the same server params the infinite mode uses; pageCount derives * from meta.total). The chosen page size persists per table in * localStorage (keyed by model). Changing any filter/sort/search * resets to page 1. * - 'infinite': rows accumulate as the user scrolls (a sentinel at the * bottom fetches + appends the next page, deduped by id, respecting * the active filters/search). Changing any filter/sort/search resets * to page 1. */ pagination?: 'pages' | 'infinite' /** * @deprecated Use `pagination="infinite"`. Kept for back-compat: when the * new `pagination` prop is not provided, `infiniteScroll` still selects the * mode exactly as before. */ infiniteScroll?: boolean /** * Row virtualization. When the visible row model has at least N rows, * only viewport rows are mounted (helps infinite scroll and large page * sizes). `true` / omitted → default threshold (40). `false` → always * render every row. A positive number overrides the threshold. */ virtualizeRows?: boolean | number } export function DynamicTable({ model, endpoint, mutationEndpoint, enableUrlSync = true, hideImport, hideExport, hiddenColumns = [], allowedActionKeys, onAction, onRowClick, refreshTrigger, defaultFilters, extraColumns = [], getDynamicColumns = defaultGetDynamicColumns, timeZone, currency, pagination: paginationMode, infiniteScroll: infiniteScrollProp = false, virtualizeRows, }: DynamicTableProps) { // The explicit `pagination` prop wins; the legacy `infiniteScroll` boolean // still selects the mode when `pagination` is absent (back-compat). const infiniteScroll = paginationMode ? paginationMode === 'infinite' : infiniteScrollProp const { t, i18n } = useTranslation() const api = useApi() const currentBranch = useCurrentBranch() const prevBranchId = useRef(currentBranch?.id) const { getMetadata, setMetadata: cacheMetadata } = useMetadataCache() const cachedMeta = getMetadata(model) const [metadata, setMetadata] = useState(cachedMeta || null) // Read the row-data cache ONCE at mount (before the fetch effects run) so the // first paint uses the previous rows for this exact view instead of skeletons. const bootDataKey = tableDataCacheKey( model, endpoint, currentBranch?.id, typeof window !== 'undefined' ? window.location.search : '', ) const bootDataRef = useRef(undefined) if (bootDataRef.current === undefined) { bootDataRef.current = enableUrlSync ? readTableDataCache(bootDataKey) : null } const bootData = bootDataRef.current const [data, setData] = useState(bootData?.rows ?? []) // Footer totals: per-column SUM over the FILTERED set, fetched from a // separate /aggregate endpoint (NOT summed from the visible page). const [footerTotals, setFooterTotals] = useState>({}) const [loading, setLoading] = useState(!cachedMeta) // Cached rows → no full-table skeleton on reload; the background fetch still // runs and swaps in fresh data. const [loadingData, setLoadingData] = useState(!(bootData?.rows?.length)) // Infinite-scroll: a top-up page is in flight (distinct from the initial // page load so only a small bottom spinner shows, not the whole-table one). const [loadingMore, setLoadingMore] = useState(false) // True once the backend returned a short/empty page: no more rows exist // even if meta.total says otherwise (count/list drift would otherwise // keep the sentinel re-firing forever). Reset by any page-1 fetch. const [infExhausted, setInfExhausted] = useState(false) const infPageRef = useRef(1) const [optionsMap, setOptionsMap] = useState>(new Map()) const [exportOpen, setExportOpen] = useState(false) const [importOpen, setImportOpen] = useState(false) const [showBulkDeleteConfirm, setShowBulkDeleteConfirm] = useState(false) const [isBulkDeleting, setIsBulkDeleting] = useState(false) const [bulkDeleteProgress, setBulkDeleteProgress] = useState(0) const [bulkDeleteTotal, setBulkDeleteTotal] = useState(0) const [rowSelection, setRowSelection] = useState({}) const [sorting, setSorting] = useState([]) const [columnVisibility, setColumnVisibility] = useState(() => { const initial: VisibilityState = {} hiddenColumns.forEach(col => { initial[col] = false }) return initial }) const [columnFilters, setColumnFilters] = useState([]) // The user's persisted page-size preference for this table (pages mode). // Read once at mount; a URL `per_page` still wins over it (deep-links stay // exact), and it wins over the model's server default. const storedPageSizeRef = useRef(undefined) if (storedPageSizeRef.current === undefined) { storedPageSizeRef.current = readStoredPageSize(model) } const [pagination, setPagination] = useState({ pageIndex: 0, pageSize: storedPageSizeRef.current ?? 10, }) const [globalFilter, setGlobalFilter] = useState('') // Debounce search → URL + fetch so each keystroke does not thrash the server. const debouncedGlobalFilter = useDebouncedValue(globalFilter) const [rowCount, setRowCount] = useState(bootData?.rowCount ?? 0) const [dateRange, setDateRange] = useState(undefined) const [dynamicFilters, setDynamicFilters] = useState>({}) const [filterOptionsMap, setFilterOptionsMap] = useState>(new Map()) const initializedFromUrl = useRef(false) const urlHadPerPage = useRef(false) // The model's server-declared default page size. When the current page size // is just this default (the user hasn't paged/resized and the URL didn't // pin one), we deliberately DON'T stamp `per_page` into the URL — otherwise // the table appends `per_page=15` to the sidebar's clean deep-link on mount, // which reads as a URL flicker and fights the router's spelling. Starts at 10 // to match the initial pagination state until metadata arrives. const defaultPerPage = useRef(10) // Has the table finished adopting its state FROM the URL yet? The write // effect must not run before this flips true. `initializedFromUrl` is a ref // set synchronously at the top of the init effect, so on the very first // commit the write effect (which runs right after, in the SAME commit) sees // `initialized=true` but `dynamicFilters` still EMPTY — the init effect's // setState hasn't re-rendered yet — and writes a URL WITHOUT `f_status`, // stripping a deep-linked filter (the "recarga quita el filtro y parpadea" // bug). A STATE flag defers the first write to the render AFTER the URL has // been adopted, where `dynamicFilters`/`pagination` already mirror the URL, // so the write is a no-op and the filter never gets stripped. const [urlSynced, setUrlSynced] = useState(false) // Keys that left `defaultFilters` (e.g. branch_id when switching to // "Todas las sucursales"). The URL write effect used to carry-through every // leftover `f_*` from location — that resurrected the locked branch filter // forever after the host dropped it from defaultFilters. const prevDefaultFilterKeys = useRef>(new Set()) const releasedDefaultFilterKeys = useRef>(new Set()) useEffect(() => { const next = new Set(Object.keys(defaultFilters ?? {})) const prev = prevDefaultFilterKeys.current const released = new Set() prev.forEach((k) => { if (!next.has(k)) released.add(k) }) releasedDefaultFilterKeys.current = released if (released.size > 0) { setDynamicFilters((df) => { let changed = false const copy = { ...df } released.forEach((k) => { if (k in copy) { delete copy[k] changed = true } }) return changed ? copy : df }) } prevDefaultFilterKeys.current = next }, [defaultFilters]) useEffect(() => { if (prevBranchId.current !== currentBranch?.id) { prevBranchId.current = currentBranch?.id setPagination((prev: PaginationState) => ({ ...prev, pageIndex: 0 })) setRowSelection({}) } }, [currentBranch?.id]) const urlAliasToOperator: Record = { 'contains': 'ILIKE', 'like': 'LIKE', 'in': 'IN', 'not_in': 'NOT_IN', 'gt': 'GT', 'lt': 'LT', 'gte': 'GTE', 'lte': 'LTE', 'range': 'RANGE', 'null': 'NULL', 'not_null': 'NOT_NULL', } const operatorToUrlAlias: Record = Object.fromEntries( Object.entries(urlAliasToOperator).map(([alias, op]) => [op, alias]) ) const urlValueToInternal = (value: string): string => { const colonIdx = value.indexOf(':') if (colonIdx === -1) return value const prefix = value.substring(0, colonIdx).toLowerCase() const rest = value.substring(colonIdx + 1) // `eq:` is the wire's explicit equality operator, but internally a // select stores the bare value — unwrap it so `f_status=eq:reception` // matches the option "reception" (header filter + chip label). if (prefix === 'eq') return rest const operator = urlAliasToOperator[prefix] return operator ? `${operator}:${rest}` : value } const internalValueToUrl = (value: string): string => { const colonIdx = value.indexOf(':') if (colonIdx === -1) return value const prefix = value.substring(0, colonIdx) const rest = value.substring(colonIdx + 1) const alias = operatorToUrlAlias[prefix] return alias ? `${alias}:${rest}` : value } // Order-, encoding- and operator-independent fingerprint of a query string. // The host router (TanStack) and the sidebar deep-link the filter in THEIR // canonical form — different key order, percent-encoded operator colons, and // the explicit `eq:` operator (`f_status=eq%3Ain_progress`) — while the table // internally unwraps `eq:` for selects and would write the bare value // (`f_status=in_progress`). Comparing raw (or even just decoded) strings then // NEVER matches, so the table rewrites the URL on every navigation and the // two spellings visibly ping-pong ("parpadeo", "Throttling navigation…" + // React #185). Normalising each `f_` value through `urlValueToInternal` // collapses `eq:X` and `X` to the same token, so a sorted `key=value` list is // a true semantic fingerprint and the write effect can detect real no-ops — // leaving the router's own spelling untouched (so the sidebar active-state // match on the exact href keeps working too). const canonicalSearch = (searchStr: string): string => { const p = new URLSearchParams(searchStr) const entries: string[] = [] p.forEach((value, key) => { const norm = key.startsWith('f_') ? urlValueToInternal(value) : value entries.push(`${key}=${norm}`) }) entries.sort() return entries.join('&') } useEffect(() => { if (!enableUrlSync || initializedFromUrl.current) return initializedFromUrl.current = true const params = new URLSearchParams(window.location.search) const page = params.get('page') const perPage = params.get('per_page') if (perPage) urlHadPerPage.current = true if (page || perPage) { setPagination((prev: PaginationState) => ({ pageIndex: page ? Math.max(0, parseInt(page, 10) - 1) : prev.pageIndex, pageSize: perPage ? parseInt(perPage, 10) : prev.pageSize, })) } const sortBy = params.get('sortBy') const order = params.get('order') if (sortBy) setSorting([{ id: sortBy, desc: order === 'desc' }]) const search = params.get('search') if (search) setGlobalFilter(search) const filters: Record = {} params.forEach((rawValue, key) => { if (key.startsWith('f_')) { const filterKey = key.substring(2) if (defaultFilters && filterKey in defaultFilters) return const value = urlValueToInternal(rawValue) if (value.startsWith('IN:')) filters[filterKey] = value.substring(3).split(',') else filters[filterKey] = [value] } }) if (Object.keys(filters).length > 0) setDynamicFilters(filters) // Adopted everything the URL carries; the write effect may now run on the // NEXT render (where the setState above has landed) without clobbering it. setUrlSynced(true) }, []) // eslint-disable-line react-hooks/exhaustive-deps // The exact query string this table last wrote to the URL — lets the // resync effect below tell "our own replaceState" apart from an external // rewrite by the host router. const lastSelfSearch = useRef(null) useEffect(() => { if (!enableUrlSync || !urlSynced) return const params = new URLSearchParams() // Preserve the route-owned view params. The table rebuilds the query // string from scratch (it only knows its own page/sort/filter keys), but // `view`/`group_by` belong to the renderer choice + the sidebar's // active-state matcher. Carrying them over keeps `?view=table` (vs // `?view=kanban`) in the URL so the open sibling stays highlighted and a // deep-link survives — without this the table wipes `?view` on mount, // the URL goes bare, and the sidebar falls back to the model default // (the "click twice to move the active to the right entry" bug). const current = new URLSearchParams(window.location.search) // `action` is host-owned (?action=stamp_fiscal deep-links from // notifications). Without carrying it, the first URL rewrite drops it // before the list route can open ActionModalDispatcher. for (const key of ['view', 'group_by', 'action']) { const v = current.get(key) if (v) params.set(key, v) } if (pagination.pageIndex > 0) params.set('page', String(pagination.pageIndex + 1)) // Only pin per_page when it deviates from the model's default (an explicit // user page-size change) or the URL already carried one — not for the // plain server default, which would needlessly mutate a clean deep-link. if (pagination.pageSize !== defaultPerPage.current || urlHadPerPage.current) params.set('per_page', String(pagination.pageSize)) if (sorting.length > 0) { params.set('sortBy', sorting[0].id) params.set('order', sorting[0].desc ? 'desc' : 'asc') } if (debouncedGlobalFilter) params.set('search', debouncedGlobalFilter) Object.entries(dynamicFilters).forEach(([key, values]) => { if (values.length === 0) return if (defaultFilters && key in defaultFilters) return if (values.length === 1) params.set(`f_${key}`, internalValueToUrl(values[0])) else params.set(`f_${key}`, `in:${values.join(',')}`) }) // Re-pin locked scope into the URL so sidebar matching + deep-links keep // working after "Limpiar filtros" (those keys are not in dynamicFilters). if (defaultFilters) { Object.entries(defaultFilters).forEach(([key, value]) => { params.set(`f_${key}`, internalValueToUrl(String(value ?? ''))) }) } // Preserve f_* already in the location that we did not rebuild above. // Sidebar deep-links (CxC→CxP) push f_party_type=eq:supplier before React // re-renders with matching defaultFilters; without this carry-through the // write effect races and strips the filter down to bare ?view=list. // Do NOT resurrect keys the host just released from defaultFilters // (branch_id when switching to "all" — otherwise San Felipe sticks). current.forEach((value, key) => { if (!key.startsWith('f_')) return if (params.has(key)) return const filterKey = key.substring(2) if (releasedDefaultFilterKeys.current.has(filterKey)) return params.set(key, value) }) const search = params.toString() // If what we'd write is semantically identical to what's already in the // bar (only key order / colon-encoding differ from the router's form), // skip the write entirely. Rewriting would flip the raw string, the // router would re-serialize back to its form, and the two would ping-pong // forever. We still adopt the current location as "ours" so the resync // effect below doesn't treat the router's spelling as an external change. if (canonicalSearch(search) === canonicalSearch(window.location.search)) { lastSelfSearch.current = window.location.search return } const newUrl = search ? `${window.location.pathname}?${search}` : window.location.pathname lastSelfSearch.current = search ? `?${search}` : '' window.history.replaceState(null, '', newUrl) }, [enableUrlSync, urlSynced, pagination, sorting, debouncedGlobalFilter, dynamicFilters, defaultFilters]) // The host router can rewrite the query string WITHOUT remounting the // table — e.g. sidebar sibling entries deep-link different `f_` filters // into the same model route. The mount-time init above never re-runs, so // the table kept showing the previous filter. On every render where the // location's search differs from the last URL we ourselves wrote, // re-parse the `f_` params and adopt them. useEffect(() => { if (!enableUrlSync || !initializedFromUrl.current) return // Read the URL FRESH inside the effect — NOT a value captured during // render. The write effect above mutates the URL imperatively via // `history.replaceState`, which does NOT trigger a re-render, so a search // string snapshotted at render time lags one commit behind what we just // wrote. Comparing that stale snapshot against `lastSelfSearch` (which the // write effect updated in this same commit) always mismatched, so the // resync "adopted" a phantom external change and reset the filters — the // write effect then re-wrote them, and the two effects ping-ponged the // filters `{almacen:['Test']} <-> {}` on every render, spinning the // infinite-scroll fetch forever the instant any column filter was applied. // Reading fresh here means that right after our own write, `now` already // equals `lastSelfSearch` and we bail out early. Only a genuine external // rewrite (router deep-link from a sidebar sibling, browser back/forward) // makes `now` diverge, which is exactly when we want to adopt it. const now = typeof window !== 'undefined' ? window.location.search : '' // Semantic compare: the router's spelling of the query string we just // wrote (different order/encoding) is NOT an external change. if (canonicalSearch(now) === canonicalSearch(lastSelfSearch.current ?? '')) return lastSelfSearch.current = now const params = new URLSearchParams(now) const filters: Record = {} params.forEach((rawValue, key) => { if (!key.startsWith('f_')) return const filterKey = key.substring(2) if (defaultFilters && filterKey in defaultFilters) return const value = urlValueToInternal(rawValue) if (value.startsWith('IN:')) filters[filterKey] = value.substring(3).split(',') else filters[filterKey] = [value] }) // Compare against the current filters directly (the effect re-runs every // render, so this closure is always fresh) and only touch state on a real // change — resetting pagination OUTSIDE any updater, never nested inside // another setState's updater. if (JSON.stringify(dynamicFilters) !== JSON.stringify(filters)) { setDynamicFilters(filters) setPagination((p: PaginationState) => ({ ...p, pageIndex: 0 })) } const search = params.get('search') if (search !== null && search !== globalFilter) setGlobalFilter(search) }) const prefetchOptions = useCallback(async (endpoints: string[]) => { if (endpoints.length === 0) return new Map() const uniqueEndpoints = Array.from(new Set(endpoints)) const promises = uniqueEndpoints.map(async (ep) => { try { const res = await api.get(ep) return { endpoint: ep, data: res.data?.success ? res.data.data : [] } } catch (e) { console.error(`Failed to fetch options for ${ep}`, e) return { endpoint: ep, data: [] } } }) const results = await Promise.all(promises) const map = new Map() results.forEach(r => map.set(r.endpoint, r.data)) return map }, [api]) const metaInitRef = useRef(false) useEffect(() => { if (metaInitRef.current) return metaInitRef.current = true const initMetadataAndOptions = async () => { const cached = getMetadata(model) // Stale-while-revalidate: paint with the cached metadata if any so // the table renders instantly, then always re-fetch in the // background so a backend metadata change (new column, new // filterable flag) propagates without users having to clear // localStorage. if (cached) { setMetadata(cached) defaultPerPage.current = cached.defaultPerPage || 10 if (!urlHadPerPage.current && storedPageSizeRef.current == null) setPagination((prev: PaginationState) => ({ ...prev, pageSize: cached.defaultPerPage || 10 })) setLoading(false) } else { setLoading(true) } let meta: TableMetadata | null = cached || null try { const res = await api.get(`/metadata/table/${model}`) as { data: ApiResponse } if (res.data.success) { const fresh = res.data.data meta = fresh setMetadata(fresh) cacheMetadata(model, fresh) defaultPerPage.current = fresh.defaultPerPage || 10 if (!urlHadPerPage.current && storedPageSizeRef.current == null) setPagination((prev: PaginationState) => ({ ...prev, pageSize: fresh.defaultPerPage || 10 })) } } catch (error) { if (!cached) console.error('Error al cargar la configuración de la tabla', error) } finally { setLoading(false) } if (!meta) return const columnEndpoints = meta.columns.filter(c => c.useOptions && c.searchEndpoint).map(c => c.searchEndpoint!) const filterEndpoints = (meta.filters || []).filter(f => f.searchEndpoint && (f.type === 'select' || f.type === 'dynamic_select' || f.type === 'boolean')).map(f => f.searchEndpoint!) // Relation (`ref`/`dynamic_select`) columns flagged `filterable` // also need their options preloaded so the per-column multi-select // combobox has something to show. Mirrors the explicit-filter path // above for columns that drive their filter off the column def. const columnFilterEndpoints = meta.columns .filter(c => c.filterable && c.searchEndpoint) .map(c => c.searchEndpoint!) const allEndpoints = [...columnEndpoints, ...filterEndpoints, ...columnFilterEndpoints] if (allEndpoints.length > 0) { prefetchOptions(allEndpoints).then(fetchedMap => { const colMap = new Map() columnEndpoints.forEach(ep => { if (fetchedMap.has(ep)) colMap.set(ep, fetchedMap.get(ep)!) }) setOptionsMap(colMap) const fMap = new Map() const projectFilterOptions = (ep: string) => { if (!fetchedMap.has(ep) || fMap.has(ep)) return fMap.set(ep, (fetchedMap.get(ep) || []).map((item: any) => ({ label: item.label || item.name || '', value: String(item.value ?? item.id ?? ''), icon: item.icon, color: item.color || item.class, }))) } filterEndpoints.forEach(projectFilterOptions) columnFilterEndpoints.forEach(projectFilterOptions) setFilterOptionsMap(fMap) }) } } initMetadataAndOptions() }, [model]) // eslint-disable-line react-hooks/exhaustive-deps // Derived from `metadata.columns[].searchable`. `null` means the kernel // didn't emit the flag for any column → preserve legacy "search every // column" behaviour by not narrowing the request. An empty array means // every column was explicitly opted out → skip sending `search` at all. const searchableKeys = useMemo( () => (metadata ? getSearchableColumnKeys(metadata) : null), [metadata], ) // Permission gating — only active when the host mounts . // Without it `viewMetadata === metadata` and nothing changes (everything // stays visible, exactly the legacy behaviour). With it, export/import // buttons and row actions (incl. the implicit View/Edit/Delete trio) are // filtered by `can(lowercase(model).)`. const can = useCan() const permissionsActive = usePermissionsActive() const viewMetadata = useMemo(() => { if (!metadata || !permissionsActive) return metadata return gateTableMetadata(metadata, model, can, (key, fallback) => t(key, { defaultValue: fallback })) }, [metadata, permissionsActive, can, model, t]) // Importing is offered when the host opts in explicitly (`canImport`) or, // when it says nothing, whenever the kernel served an import spec for the // model — which it does for every model with at least one importable form // field. That makes the flow work out of the box instead of requiring each // host to flip a flag per view. const importEnabled = !hideImport && (viewMetadata?.canImport ?? Boolean(viewMetadata?.import?.columns?.length)) const exportEnabled = !hideExport && Boolean(viewMetadata?.canExport) const listScopeValues = useMemo(() => { const base = buildListScopeValues(defaultFilters, dynamicFilters) // Deep-links pin f_* in the URL before defaultFilters / nav matching // catch up; adopt single-eq scope values so visible_when columns hide // on first paint (CxC hides Proveedor without waiting for navFilter). if (!enableUrlSync || typeof window === 'undefined') return base const params = new URLSearchParams(window.location.search) const fromUrl: Record = {} params.forEach((raw, key) => { if (!key.startsWith('f_')) return const col = key.slice(2) if (!col || col in base) return const v = scopeValueFromFilterToken(raw) if (!v) return // Multi-value / range tokens are not a single list-scope pin. if (v.includes(',') || /^(in|not_in|range|gte|lte):/i.test(String(raw))) return fromUrl[col] = v }) return Object.keys(fromUrl).length === 0 ? base : { ...fromUrl, ...base } }, [defaultFilters, dynamicFilters, enableUrlSync, urlSynced]) // Columns whose kernel `visible_when` fails against the known list scope // (locked nav defaultFilters, single-eq chips). Host `hiddenColumns` still // wins; this is the scalable path so CxC hides Proveedor without each nav // item re-listing every allowed column. const scopeHiddenColumns = useMemo(() => { const cols = (viewMetadata?.columns ?? metadata?.columns ?? []) as ColumnDefinition[] if (cols.length === 0 || Object.keys(listScopeValues).length === 0) return [] as string[] const hidden: string[] = [] for (const col of cols) { const key = col.key if (!key) continue if (!evaluateVisibleWhenForListScope(getVisibleWhen(col), listScopeValues)) { hidden.push(key) } } return hidden }, [metadata, viewMetadata, listScopeValues]) const effectiveHiddenColumns = useMemo(() => { if (scopeHiddenColumns.length === 0) return hiddenColumns const set = new Set(hiddenColumns) for (const k of scopeHiddenColumns) set.add(k) return Array.from(set) }, [hiddenColumns, scopeHiddenColumns]) const buildFilterParams = useCallback(() => { const params: Record = {} if (sorting.length > 0) { params.sortBy = sorting[0].id params.order = sorting[0].desc ? 'desc' : 'asc' } if (debouncedGlobalFilter) { if (searchableKeys === null) { params.search = debouncedGlobalFilter } else if (searchableKeys.length > 0) { params.search = debouncedGlobalFilter params.search_columns = searchableKeys.join(',') } // searchableKeys === [] → drop the search request entirely } columnFilters.forEach((filter: { id: string; value: unknown }) => { if (defaultFilters && filter.id in defaultFilters) return params[`f_${filter.id}`] = filter.value }) Object.entries(dynamicFilters).forEach(([key, values]) => { if (defaultFilters && key in defaultFilters) return if (values.length === 0) return const gteVal = values.find(v => v.startsWith('GTE:')) const lteVal = values.find(v => v.startsWith('LTE:')) if (gteVal || lteVal) { const min = gteVal ? gteVal.replace('GTE:', '') : '' const max = lteVal ? lteVal.replace('LTE:', '') : '' params[`f_${key}`] = `RANGE:${min},${max}` return } if (values.length === 1) params[`f_${key}`] = values[0] else params[`f_${key}`] = `IN:${values.join(',')}` }) // Locked scope last so it always wins over stale dynamic/column filters. if (defaultFilters) Object.entries(defaultFilters).forEach(([key, value]) => { params[`f_${key}`] = value }) if (dateRange?.from) { const startDate = format(dateRange.from, 'yyyy-MM-dd') const endDate = dateRange.to ? format(dateRange.to, 'yyyy-MM-dd') : startDate params['f_created_at'] = `${startDate}_${endDate}` } return params }, [sorting, debouncedGlobalFilter, columnFilters, defaultFilters, dynamicFilters, dateRange, searchableKeys]) const hasActiveFilters = useMemo(() => { if (globalFilter) return true if (columnFilters.length > 0) return true if (Object.values(dynamicFilters).some(v => v.length > 0)) return true if (dateRange?.from) return true return false }, [globalFilter, columnFilters, dynamicFilters, dateRange]) const fetchData = useCallback(async () => { if (!metadata) return setLoadingData(true) try { const params: Record = { page: pagination.pageIndex + 1, per_page: pagination.pageSize, ...buildFilterParams(), } const res = await api.get(endpoint || `/data/${model}`, { params }) as { data: ApiResponse } if (res.data.success) { const rows = res.data.data || [] setData(rows) if (res.data.meta) setRowCount(res.data.meta.total) // Cache the first page for an instant reload paint (see the cache // helpers). Keyed off the live URL so it matches the next mount. if (enableUrlSync && pagination.pageIndex === 0) { writeTableDataCache( tableDataCacheKey(model, endpoint, currentBranch?.id, window.location.search), rows, res.data.meta?.total ?? rows.length, ) } } } catch (error) { console.error('Error al cargar los datos', error) } finally { setLoadingData(false) } }, [model, metadata, pagination, buildFilterParams, refreshTrigger, endpoint, currentBranch?.id, api, enableUrlSync]) // Columns whose metadata opts into a footer total (display_config.aggregate // → styleConfig.aggregate). When empty, no footer row is rendered and no // aggregate request is made. const aggregateColumns = useMemo( () => (metadata?.columns ?? []).filter((c) => aggregateOf(c as any)), [metadata], ) // fetchAggregates GETs the SUM of each aggregate-flagged column over the SAME // filtered set as the list (reuses buildFilterParams), then stores the totals // keyed by column. Sort/pagination are irrelevant to a footer total and are // omitted (the backend ignores them); only filters/search drive the result. const fetchAggregates = useCallback(async () => { if (!metadata || aggregateColumns.length === 0) return try { const { sortBy, order, ...filterParams } = buildFilterParams() const base = endpoint || `/data/${model}` const res = (await api.get(`${base}/aggregate`, { params: filterParams })) as { data: ApiResponse> } if (res.data.success) setFooterTotals(res.data.data || {}) } catch (error) { console.error('Error al cargar los totales', error) } }, [model, metadata, aggregateColumns, buildFilterParams, endpoint, currentBranch?.id, api]) // ---- infinite scroll: page fetch that REPLACES (page 1) or APPENDS ---- const infPageSize = 30 const fetchPage = useCallback( async (page: number, append: boolean) => { if (!metadata) return if (append) setLoadingMore(true) else setLoadingData(true) try { const params: Record = { page, per_page: infPageSize, ...buildFilterParams(), } const res = (await api.get(endpoint || `/data/${model}`, { params, })) as { data: ApiResponse } if (res.data.success) { const rows = res.data.data || [] setData((prev) => (append ? dedupeById(prev, rows) : rows)) if (res.data.meta) setRowCount(res.data.meta.total) // Cache the first (replace) page for an instant reload paint. if (!append && enableUrlSync) { writeTableDataCache( tableDataCacheKey(model, endpoint, currentBranch?.id, window.location.search), rows, res.data.meta?.total ?? rows.length, ) } // A short page means the backend has no more rows, even if // meta.total disagrees with the visible count (count query // vs list query drift, dedupe). Without this the sentinel // re-fires forever on empty pages. setInfExhausted(rows.length < infPageSize) } } catch (error) { console.error('Error al cargar los datos', error) } finally { if (append) setLoadingMore(false) else setLoadingData(false) } }, [metadata, buildFilterParams, endpoint, model, api, currentBranch?.id, enableUrlSync], ) // Signature of everything that must reset the incremental list to page 1: // the filters/search AND the sort (both live in buildFilterParams). const filterSignature = useMemo( () => JSON.stringify(buildFilterParams()), [buildFilterParams], ) // Pages mode: any filter/search/sort change snaps back to page 1 (same // contract as the infinite reset above — a filtered set has its own page // space). Ref-compared so paging itself never triggers it, and armed only // AFTER the URL adoption has settled so a deep-linked `?page=3&sortBy=...` // is not immediately reset by its own sort arriving. const pagesSigArmed = useRef(false) const pagesPrevSig = useRef(null) useEffect(() => { if (infiniteScroll) return if (enableUrlSync && !urlSynced) return if (!pagesSigArmed.current) { pagesSigArmed.current = true pagesPrevSig.current = filterSignature return } if (pagesPrevSig.current === filterSignature) return pagesPrevSig.current = filterSignature setPagination((p: PaginationState) => (p.pageIndex === 0 ? p : { ...p, pageIndex: 0 })) }, [infiniteScroll, enableUrlSync, urlSynced, filterSignature]) // Persist the chosen page size per table (localStorage, keyed by model). // The server default is stored as an explicit removal so a later backend // change of `defaultPerPage` still propagates to users who never deviated. useEffect(() => { if (!metadata) return const chosen = pagination.pageSize writeStoredPageSize(model, chosen === defaultPerPage.current ? null : chosen) }, [metadata, model, pagination.pageSize]) const loadNextPage = useCallback(() => { if (loadingMore || loadingData || infExhausted) return if (data.length >= rowCount) return infPageRef.current += 1 void fetchPage(infPageRef.current, true) }, [loadingMore, loadingData, infExhausted, data.length, rowCount, fetchPage]) // Infinite-scroll sentinels. There are two scroll containers (desktop // table + mobile card list) but only one is laid out at a time — the CSS // `hidden`/`sm:hidden` container has no box, so its observer never fires. // Each sentinel drives the SAME `loadNextPage`; its internal guards + the // `disabled` flag keep concurrent/exhausted fetches from doubling up. const infScrollDisabled = !infiniteScroll || loadingMore || loadingData || infExhausted || data.length >= rowCount const { rootRef: infDesktopRoot, sentinelRef: infDesktopSentinel } = useInfiniteScrollSentinel({ onLoadMore: loadNextPage, disabled: infScrollDisabled }) const { rootRef: infMobileRoot, sentinelRef: infMobileSentinel } = useInfiniteScrollSentinel({ onLoadMore: loadNextPage, disabled: infScrollDisabled }) const initialFetchDone = useRef(false) useEffect(() => { if (!metadata) return // Infinite mode owns its own fetching (reset-to-page-1 effect below); // the classic pagination-driven path is skipped entirely. if (infiniteScroll) return if (!initialFetchDone.current) { initialFetchDone.current = true fetchData() fetchAggregates() return } const timeoutId = setTimeout(() => { fetchData() fetchAggregates() }, 300) return () => clearTimeout(timeoutId) }, [fetchData, fetchAggregates, metadata, infiniteScroll]) // Infinite mode: (re)load page 1 on mount and whenever the filters/sort/ // search change (or an explicit refreshTrigger) — replacing the accumulated // rows and resetting the cursor. useEffect(() => { if (!infiniteScroll || !metadata) return infPageRef.current = 1 const first = !initialFetchDone.current initialFetchDone.current = true if (first) { void fetchPage(1, false) void fetchAggregates() return } const timeoutId = setTimeout(() => { void fetchPage(1, false) void fetchAggregates() }, 300) return () => clearTimeout(timeoutId) // refreshTrigger is included so an external bump (e.g. a create/edit/ // delete elsewhere on the page) reloads page 1 in infinite mode too — // matching the classic path (fetchData carries refreshTrigger in its // deps). Without it the comment above lied: infinite lists silently // failed to reload after a create ("a veces no recarga la tabla"). // eslint-disable-next-line react-hooks/exhaustive-deps }, [infiniteScroll, metadata, filterSignature, refreshTrigger]) const handleRefresh = useCallback(() => { // Infinite mode owns its own list: refresh reloads page 1 and drops the // accumulated pages (a classic fetchData would collapse it to one small // pagination page). Classic mode keeps the pagination-driven refetch. if (infiniteScroll) { infPageRef.current = 1 void fetchPage(1, false) } else { fetchData() } fetchAggregates() }, [infiniteScroll, fetchPage, fetchData, fetchAggregates]) // Per-row action dispatch (view/edit/delete/link/custom) + its dialogs live // in the shared hook so DynamicKanban's card menu behaves identically. const { handleInternalAction, dialogs: rowActionDialogs } = useDynamicRowActions({ model, endpoint, mutationEndpoint, metadata, onAction, onRefresh: handleRefresh, }) const confirmBulkDelete = async () => { const selectedRows = table.getFilteredSelectedRowModel().rows if (selectedRows.length === 0) return setIsBulkDeleting(true) setBulkDeleteTotal(selectedRows.length) setBulkDeleteProgress(0) let successCount = 0, errorCount = 0 for (let i = 0; i < selectedRows.length; i++) { const row = selectedRows[i] try { const writeBase = mutationEndpoint ?? endpoint const deleteEndpoint = writeBase ? `${writeBase}/${row.original.id}` : `/data/${model}/${row.original.id}` const res = await api.delete(deleteEndpoint) if (res.data.success) successCount++; else errorCount++ } catch (e) { console.error('Error al eliminar', e); errorCount++ } setBulkDeleteProgress(i + 1) } await new Promise(resolve => setTimeout(resolve, 500)) setIsBulkDeleting(false) setShowBulkDeleteConfirm(false) setBulkDeleteProgress(0) setBulkDeleteTotal(0) setRowSelection({}) if (successCount > 0) toast.success(t('dynamic.bulk_delete_success', { count: successCount, defaultValue: '{{count}} registro(s) eliminado(s) correctamente' })) if (errorCount > 0) toast.error(t('dynamic.bulk_delete_error', { count: errorCount, defaultValue: '{{count}} registro(s) no pudieron ser eliminados' })) handleRefresh() } const handleDynamicFilterChange = useCallback((filterKey: string, values: string[]) => { // Locked scope (nav / branch defaultFilters) cannot be changed from the UI. if (defaultFilters && filterKey in defaultFilters) return setDynamicFilters((prev: Record) => ({ ...prev, [filterKey]: values })) setPagination((prev: PaginationState) => ({ ...prev, pageIndex: 0 })) }, [defaultFilters]) // Same facet loader machinery the board uses, so a text column filters // identically in the table header and the kanban Sheet (the host's // getDynamicColumns forwards `loadOptions` into the column meta). Facets base // derived off the list endpoint exactly like the aggregate endpoint below. const facetsBase = endpoint ? `${endpoint}/facets` : model ? `/data/${model}/facets` : null const { getFacetLoader, prefetchFacets, facetOptions } = useFacetLoaders(facetsBase) const columnFilterConfigs = useMemo(() => { const map = new Map() if (!metadata) return map // Option labels arrive as manifest i18n keys; translate them here (the ui // package has no i18n) so header filters, chips and value summaries show // localized text. A raw value with no key falls through via defaultValue. const tr = (label: string) => t(label, { defaultValue: label }) const stageOptions = (metadata.stages ?? []).map((s) => ({ label: s.label, value: s.key, color: s.color, })) const groupBy = metadata.group_by // Explicit `metadata.filters` wins. When the backend does not emit // them, derive a filter chip from every column flagged // `filterable: true` — keeps the kernel API minimal (one flag on the // column) while still rendering the FilterableColumnHeader. for (const f of metadata.filters ?? []) { const filterCol = f.column || f.key if (defaultFilters && filterCol in defaultFilters) continue if (effectiveHiddenColumns.includes(filterCol)) continue let fType = f.type as ColumnFilterConfig['filterType'] let options: { label: string; value: string; icon?: string; color?: string }[] = [] if (f.options && f.options.length > 0) { options = f.options.map(o => ({ label: o.label, value: String(o.value), icon: o.icon, color: o.color })) } if (f.searchEndpoint && filterOptionsMap.has(f.searchEndpoint)) { options = filterOptionsMap.get(f.searchEndpoint) || [] } // (A) A stage column with no options of its own inherits the // pipeline stages (with their colors) as a real select. if (options.length === 0 && !f.searchEndpoint && (f.column || f.key) === groupBy && stageOptions.length > 0) { fType = 'select' options = stageOptions } // (B) A plain text filter becomes a facet value-picker when a facets // endpoint is available (degrades to "Contiene..." if it yields nothing). let loadOptions: ColumnFilterConfig['loadOptions'] if (fType === 'text' && facetsBase) { const loader = getFacetLoader(f.column || f.key) if (loader) { fType = 'facet' loadOptions = loader // Prewarmed values (from prefetchFacets) → the header filter // opens with the list already there, no "Cargando…" flash. options = facetOptions.get(f.column || f.key) ?? [] } } if (fType === 'select' && options.length === 0 && !f.searchEndpoint) continue map.set(f.key, { filterType: fType, filterKey: f.column || f.key, options: translateOptionLabels(options, tr), selectedValues: dynamicFilters[f.column || f.key] || [], onFilterChange: handleDynamicFilterChange, loading: f.searchEndpoint ? !filterOptionsMap.has(f.searchEndpoint) : false, searchEndpoint: f.searchEndpoint, loadOptions: loadOptions ? (q?: string) => loadOptions!(q).then((o) => translateOptionLabels(o, tr)) : undefined, }) } for (const c of metadata.columns ?? []) { if (!c.filterable || map.has(c.key)) continue if (defaultFilters && c.key in defaultFilters) continue if (effectiveHiddenColumns.includes(c.key)) continue const hasStaticOptions = (c.options?.length ?? 0) > 0 const hasEndpoint = !!c.searchEndpoint const isRelation = !!c.ref || c.filterType === 'dynamic_select' // Pick the filter UI. The backend's explicit `filterType` wins; when // absent we infer it from the column shape: // - ref/dynamic_select column → relation multi-select // (options stream from searchEndpoint = /options/) // - inline options or searchEndpoint → static multi-select // - boolean → boolean toggle (renders as select under the hood) // - number / number_range / numeric → number range // - date → date range picker (start/end calendar) // - everything else (text, email, phone, tags…) → text contains // (A) Stage column with no options of its own → colored select. const isStageColumn = c.key === groupBy && !hasStaticOptions && !hasEndpoint && !c.filterType && stageOptions.length > 0 let filterType: ColumnFilterConfig['filterType'] if (isStageColumn) filterType = 'select' else if (c.filterType) filterType = c.filterType else if (isRelation && hasEndpoint) filterType = 'dynamic_select' else if (hasStaticOptions || hasEndpoint) filterType = 'select' else if (c.type === 'boolean') filterType = 'boolean' else if (c.type === 'number') filterType = 'number_range' else if ((DATE_CELL_TYPES as readonly string[]).includes(c.type)) filterType = 'date_range' else filterType = 'text' let options = hasStaticOptions ? c.options!.map(o => ({ label: o.label, value: String(o.value), icon: o.icon, color: o.color, })) : hasEndpoint && filterOptionsMap.has(c.searchEndpoint!) ? filterOptionsMap.get(c.searchEndpoint!) || [] : [] if (isStageColumn) options = stageOptions // (B) Upgrade a plain text filter to a facet value-picker unless it's // a long-text/body column (too many unique values to enumerate). let loadOptions: ColumnFilterConfig['loadOptions'] if (filterType === 'text' && facetsBase && !isLongTextColumn(c)) { const loader = getFacetLoader(c.key) if (loader) { filterType = 'facet' loadOptions = loader // Prewarmed values (from prefetchFacets) → instant open. options = facetOptions.get(c.key) ?? [] } } map.set(c.key, { filterType, filterKey: c.key, options: translateOptionLabels(options, tr), selectedValues: dynamicFilters[c.key] || [], onFilterChange: handleDynamicFilterChange, loading: hasEndpoint && !filterOptionsMap.has(c.searchEndpoint!), searchEndpoint: c.searchEndpoint, loadOptions: loadOptions ? (q?: string) => loadOptions!(q).then((o) => translateOptionLabels(o, tr)) : undefined, }) } return map }, [metadata, filterOptionsMap, dynamicFilters, handleDynamicFilterChange, facetsBase, getFacetLoader, facetOptions, t, defaultFilters, effectiveHiddenColumns]) // Prewarm every facet field once the configs settle, so a text column's // header filter opens instantly with values + counts (same as the kanban). const facetFieldsSig = useMemo(() => { const keys: string[] = [] for (const config of columnFilterConfigs.values()) { if (config.filterType === 'facet') keys.push(config.filterKey) } return keys.join('|') }, [columnFilterConfigs]) useEffect(() => { if (!facetFieldsSig) return prefetchFacets(facetFieldsSig.split('|')) }, [facetFieldsSig, prefetchFacets]) const columns = useMemo(() => { if (!viewMetadata) return [] // Row-action column only renders per-row actions. Table-level placements // ("table"/"create") are surfaced by at the page // level, so strip them here to avoid a meaningless per-row button. // `allowedActionKeys`, when given, further narrows the row set to a // per-VIEW allowlist — two nav entries on the same model (a generic // list vs. a purpose-built approval queue) can then show different // actions instead of every action the model declares. const rowMetadata = (() => { let actions = viewMetadata.actions if (actions?.some((a) => a.placement === 'table' || a.placement === 'create')) { actions = actions.filter((a) => !a.placement || a.placement === 'row') } if (allowedActionKeys && actions) { const allowed = new Set(allowedActionKeys) actions = actions.filter((a) => allowed.has(a.key)) } return actions === viewMetadata.actions ? viewMetadata : { ...viewMetadata, actions } })() const baseColumns = getDynamicColumns(rowMetadata, handleInternalAction, t, i18n.language, columnFilterConfigs, timeZone, currency) const filteredBase = baseColumns.filter((col: ColumnDef) => !effectiveHiddenColumns.includes(col.id as string)) const actionsCol = filteredBase.find((c: ColumnDef) => c.id === 'actions') const otherCols = filteredBase.filter((c: ColumnDef) => c.id !== 'actions') return [...otherCols, ...extraColumns, ...(actionsCol ? [actionsCol] : [])] }, [viewMetadata, handleInternalAction, effectiveHiddenColumns, allowedActionKeys, extraColumns, t, i18n.language, columnFilterConfigs, getDynamicColumns, timeZone, currency]) const filters = useMemo(() => [], []) const table = useReactTable({ data, columns, state: { sorting, columnVisibility, rowSelection, columnFilters, globalFilter, pagination }, pageCount: Math.ceil(rowCount / pagination.pageSize), manualPagination: true, manualSorting: true, manualFiltering: true, enableRowSelection: true, onRowSelectionChange: setRowSelection, onSortingChange: setSorting, onColumnVisibilityChange: setColumnVisibility, onColumnFiltersChange: setColumnFilters, onGlobalFilterChange: setGlobalFilter, onPaginationChange: setPagination, getCoreRowModel: getCoreRowModel(), getFilteredRowModel: getFilteredRowModel(), getPaginationRowModel: getPaginationRowModel(), getSortedRowModel: getSortedRowModel(), getFacetedRowModel: getFacetedRowModel(), getFacetedUniqueValues: getFacetedUniqueValues(), }) const tableRows = table.getRowModel().rows const virtualizeThreshold = resolveVirtualizeThreshold(virtualizeRows) const shouldVirtualize = virtualizeThreshold !== false && tableRows.length >= virtualizeThreshold const colSpan = Math.max(columns.length, 1) const desktopVirtualizer = useVirtualizer({ count: shouldVirtualize ? tableRows.length : 0, getScrollElement: () => infDesktopRoot.current, estimateSize: () => DYNAMIC_TABLE_ROW_ESTIMATE_PX, overscan: 8, enabled: shouldVirtualize, }) const mobileVirtualizer = useVirtualizer({ count: shouldVirtualize ? tableRows.length : 0, getScrollElement: () => infMobileRoot.current, estimateSize: () => DYNAMIC_TABLE_CARD_ESTIMATE_PX, overscan: 6, enabled: shouldVirtualize, }) const TableSkeleton = () => ( <> {Array.from({ length: 5 }).map((_, i) => ( ))} ) if (loading) { return (
) } if (!metadata) { return
Error al cargar la configuración de la tabla.
} return (
setShowBulkDeleteConfirm(true)} extraActions={ <> {exportEnabled && ( )} {importEnabled && ( )} } />
{/* Desktop: classic horizontal-scroll table. Hidden on phones — a 7-column table forces a wide horizontal scroll there, so we render a card-per-row list instead (see MobileCards below). */}
0 && Object.keys(footerTotals).length > 0 && 'h-full')}> {table.getHeaderGroups().map((headerGroup: HeaderGroup) => ( {headerGroup.headers.map((header: Header) => { const isActionsColumn = header.id === 'actions' return ( {header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())} ) })} ))} {loadingData && data.length === 0 ? ( ) : tableRows.length ? ( <> {shouldVirtualize ? ( <> {desktopVirtualizer.getVirtualItems().length > 0 && ( )} {desktopVirtualizer.getVirtualItems().map((virtualRow) => { const row = tableRows[virtualRow.index] if (!row) return null return ( onRowClick(row.original) : undefined} > {row.getVisibleCells().map((cell: Cell) => { const isActionsColumn = cell.column.id === 'actions' const isSelectColumn = cell.column.id === 'select' return ( e.stopPropagation() : undefined} > {flexRender(cell.column.columnDef.cell, cell.getContext())} ) })} ) })} {(() => { const items = desktopVirtualizer.getVirtualItems() const last = items[items.length - 1] const pad = last != null ? desktopVirtualizer.getTotalSize() - last.end : 0 if (pad <= 0) return null return ( ) })()} ) : ( tableRows.map((row: Row) => ( onRowClick(row.original) : undefined} > {row.getVisibleCells().map((cell: Cell) => { const isActionsColumn = cell.column.id === 'actions' const isSelectColumn = cell.column.id === 'select' return ( e.stopPropagation() : undefined} > {flexRender(cell.column.columnDef.cell, cell.getContext())} ) })} )) )} {/* Spacer row: absorbs the table's leftover height (table is h-full when a footer shows) so the totals footer is pinned to the bottom of the box even with only a few rows. */} {aggregateColumns.length > 0 && Object.keys(footerTotals).length > 0 && ( )} ) : (

No se encontraron resultados

No hay datos para mostrar en este momento.

)}
{aggregateColumns.length > 0 && Object.keys(footerTotals).length > 0 && ( // Sticky footer: the totals row stays pinned to the bottom of // the scroll area instead of scrolling away with the rows. The // sticky lives on the cells (a can't be position:sticky // reliably); each carries an opaque bg + top border so the body // scrolls underneath cleanly. {table.getVisibleLeafColumns().map((leaf: any, idx: number) => { const col = (metadata?.columns ?? []).find( (c) => c.key === leaf.id, ) const isFirst = idx === 0 const stickyBase = 'sticky bottom-0 z-10 border-t bg-background py-2 font-semibold' // Aggregate cell: render the SUM formatted like the body cell. if (col && aggregateOf(col as any)) { return ( {formatAggregateTotal( col as any, footerTotals[leaf.id], currency, i18n.language, )} ) } // First non-aggregate column carries the "Total" label. return ( {isFirst ? t('common.total', 'Total') : ''} ) })} )}
{infiniteScroll && ( <> {loadingMore && (
)} {/* Sentinel stays mounted for the whole infinite-scroll session so its observer attaches once; `disabled` (no more pages / load in flight) gates the fetch. */}
)}
{/* Mobile: one card per row — no horizontal scroll. Each card stacks its columns as label : value pairs with the row actions pinned at the bottom. */}
{loadingData && data.length === 0 ? ( Array.from({ length: 5 }).map((_, i) => (
)) ) : tableRows.length ? ( shouldVirtualize ? (
{mobileVirtualizer.getVirtualItems().map((virtualRow) => { const row = tableRows[virtualRow.index] if (!row) return null const cells = row.getVisibleCells() const actionsCell = cells.find((c: Cell) => c.column.id === 'actions') const dataCells = cells.filter( (c: Cell) => c.column.id !== 'actions' && c.column.id !== 'select', ) return (
onRowClick(row.original) : undefined} > {dataCells.map((cell: Cell) => { const cellMeta = cell.column.columnDef.meta as | { label?: string } | undefined const header = cell.column.columnDef.header const label = cellMeta?.label ?? (typeof header === 'string' ? header : cell.column.id) return (
{label} {flexRender(cell.column.columnDef.cell, cell.getContext())}
) })} {actionsCell && (
e.stopPropagation() : undefined} > {flexRender(actionsCell.column.columnDef.cell, actionsCell.getContext())}
)}
) })}
) : ( tableRows.map((row: Row) => { const cells = row.getVisibleCells() const actionsCell = cells.find((c: Cell) => c.column.id === 'actions') const dataCells = cells.filter( (c: Cell) => c.column.id !== 'actions' && c.column.id !== 'select', ) return (
onRowClick(row.original) : undefined} > {dataCells.map((cell: Cell) => { // El label humano vive en columnDef.meta.label (lo // adjunta la fábrica de columnas); el header casi // nunca es string (es un componente), así que caer a // column.id mostraba keys crudas ('user.avatar', // 'created_at') en las cards móviles. const cellMeta = cell.column.columnDef.meta as | { label?: string } | undefined const header = cell.column.columnDef.header const label = cellMeta?.label ?? (typeof header === 'string' ? header : cell.column.id) return (
{label} {/* overflow-hidden + badges envueltos: un badge largo (whitespace-nowrap por defecto) no puede encoger y empuja el ancho de TODA la página en móvil. Dentro de la card se le permite multilínea y max-w-full. */} {flexRender(cell.column.columnDef.cell, cell.getContext())}
) })} {actionsCell && (
e.stopPropagation() : undefined} > {flexRender(actionsCell.column.columnDef.cell, actionsCell.getContext())}
)}
) }) ) ) : (

No se encontraron resultados

No hay datos para mostrar en este momento.

)} {infiniteScroll && ( <> {loadingMore && ( )}
)}
{infiniteScroll ? ( data.length > 0 && (

{t('common.showingCount', { defaultValue: '{{count}} de {{total}}', count: data.length, total: rowCount, })}

) ) : ( )}
{rowActionDialogs} !open && !isBulkDeleting && setShowBulkDeleteConfirm(false)}> {isBulkDeleting ? 'Eliminando registros...' : '¿Eliminar múltiples registros?'} {isBulkDeleting ? (

Procesando {bulkDeleteProgress} de {bulkDeleteTotal} registros...

) : ( <>Esta acción no se puede deshacer. Se eliminarán permanentemente {Object.keys(rowSelection).length} registro(s) de nuestros servidores. )}
{!isBulkDeleting && ( {t('common.cancel')} { e.preventDefault(); confirmBulkDelete() }} className="bg-red-600 hover:bg-red-700">Eliminar todos )}
{exportEnabled && ( )} {importEnabled && ( )} ) }