@asteby/metacore-runtime-react 4.0.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.
Files changed (81) hide show
  1. package/CHANGELOG.md +31 -0
  2. package/LICENSE +201 -0
  3. package/README.md +59 -0
  4. package/dist/action-modal-dispatcher.d.ts +4 -0
  5. package/dist/action-modal-dispatcher.d.ts.map +1 -0
  6. package/dist/action-modal-dispatcher.js +123 -0
  7. package/dist/addon-loader.d.ts +27 -0
  8. package/dist/addon-loader.d.ts.map +1 -0
  9. package/dist/addon-loader.js +73 -0
  10. package/dist/api-context.d.ts +40 -0
  11. package/dist/api-context.d.ts.map +1 -0
  12. package/dist/api-context.js +25 -0
  13. package/dist/capability-gate.d.ts +29 -0
  14. package/dist/capability-gate.d.ts.map +1 -0
  15. package/dist/capability-gate.js +43 -0
  16. package/dist/dialogs/_primitives.d.ts +29 -0
  17. package/dist/dialogs/_primitives.d.ts.map +1 -0
  18. package/dist/dialogs/_primitives.js +35 -0
  19. package/dist/dialogs/dynamic-record.d.ts +11 -0
  20. package/dist/dialogs/dynamic-record.d.ts.map +1 -0
  21. package/dist/dialogs/dynamic-record.js +377 -0
  22. package/dist/dialogs/export.d.ts +12 -0
  23. package/dist/dialogs/export.d.ts.map +1 -0
  24. package/dist/dialogs/export.js +146 -0
  25. package/dist/dialogs/import.d.ts +11 -0
  26. package/dist/dialogs/import.d.ts.map +1 -0
  27. package/dist/dialogs/import.js +128 -0
  28. package/dist/dynamic-columns-shim.d.ts +25 -0
  29. package/dist/dynamic-columns-shim.d.ts.map +1 -0
  30. package/dist/dynamic-columns-shim.js +1 -0
  31. package/dist/dynamic-form.d.ts +12 -0
  32. package/dist/dynamic-form.d.ts.map +1 -0
  33. package/dist/dynamic-form.js +51 -0
  34. package/dist/dynamic-icon.d.ts +6 -0
  35. package/dist/dynamic-icon.d.ts.map +1 -0
  36. package/dist/dynamic-icon.js +11 -0
  37. package/dist/dynamic-table.d.ts +22 -0
  38. package/dist/dynamic-table.d.ts.map +1 -0
  39. package/dist/dynamic-table.js +516 -0
  40. package/dist/i18n-provider.d.ts +16 -0
  41. package/dist/i18n-provider.d.ts.map +1 -0
  42. package/dist/i18n-provider.js +16 -0
  43. package/dist/index.d.ts +18 -0
  44. package/dist/index.d.ts.map +1 -0
  45. package/dist/index.js +21 -0
  46. package/dist/metadata-cache.d.ts +42 -0
  47. package/dist/metadata-cache.d.ts.map +1 -0
  48. package/dist/metadata-cache.js +71 -0
  49. package/dist/navigation-builder.d.ts +34 -0
  50. package/dist/navigation-builder.d.ts.map +1 -0
  51. package/dist/navigation-builder.js +45 -0
  52. package/dist/options-context.d.ts +8 -0
  53. package/dist/options-context.d.ts.map +1 -0
  54. package/dist/options-context.js +5 -0
  55. package/dist/slot.d.ts +32 -0
  56. package/dist/slot.d.ts.map +1 -0
  57. package/dist/slot.js +45 -0
  58. package/dist/types.d.ts +114 -0
  59. package/dist/types.d.ts.map +1 -0
  60. package/dist/types.js +1 -0
  61. package/package.json +67 -0
  62. package/src/action-modal-dispatcher.tsx +275 -0
  63. package/src/addon-loader.tsx +111 -0
  64. package/src/api-context.tsx +55 -0
  65. package/src/capability-gate.tsx +69 -0
  66. package/src/dialogs/_primitives.tsx +114 -0
  67. package/src/dialogs/dynamic-record.tsx +770 -0
  68. package/src/dialogs/export.tsx +339 -0
  69. package/src/dialogs/import.tsx +404 -0
  70. package/src/dynamic-columns-shim.ts +36 -0
  71. package/src/dynamic-form.tsx +108 -0
  72. package/src/dynamic-icon.tsx +15 -0
  73. package/src/dynamic-table.tsx +766 -0
  74. package/src/i18n-provider.tsx +33 -0
  75. package/src/index.ts +30 -0
  76. package/src/metadata-cache.ts +103 -0
  77. package/src/navigation-builder.tsx +77 -0
  78. package/src/options-context.tsx +11 -0
  79. package/src/slot.tsx +77 -0
  80. package/src/types.ts +112 -0
  81. package/tsconfig.json +16 -0
@@ -0,0 +1,766 @@
1
+ // DynamicTable — metadata-driven CRUD table used by every metacore host.
2
+ // Ported from the ops starter but with the host-specific aliases swapped
3
+ // for metacore packages + context-injected peer deps:
4
+ // * `@/lib/api` → <ApiProvider> (see api-context.tsx)
5
+ // * `@/stores/branch-store` → <BranchProvider> (optional)
6
+ // * `@/stores/metadata-cache` → internal ./metadata-cache zustand store
7
+ // * `@/components/ui/*` → @asteby/metacore-ui/primitives
8
+ // * `@/components/data-table/*` → @asteby/metacore-ui/data-table
9
+ // * `@/components/dynamic/{record,export,import}-dialog` → ./dialogs/*
10
+ // * `@/components/dynamic/dynamic-columns` → host-injected via the
11
+ // `getDynamicColumns` prop (hosts retain ownership because the rendered
12
+ // column cells are tightly coupled to their design system).
13
+ import { useEffect, useState, useMemo, useCallback, useRef } from 'react'
14
+ import { useNavigate } from '@tanstack/react-router'
15
+ import { useTranslation } from 'react-i18next'
16
+ import { format } from 'date-fns'
17
+ import type { DateRange } from 'react-day-picker'
18
+ import {
19
+ type SortingState,
20
+ type VisibilityState,
21
+ type ColumnFiltersState,
22
+ type PaginationState,
23
+ type ColumnDef,
24
+ type HeaderGroup,
25
+ type Header,
26
+ type Row,
27
+ type Cell,
28
+ flexRender,
29
+ getCoreRowModel,
30
+ getFacetedRowModel,
31
+ getFacetedUniqueValues,
32
+ getFilteredRowModel,
33
+ getPaginationRowModel,
34
+ getSortedRowModel,
35
+ useReactTable,
36
+ } from '@tanstack/react-table'
37
+ import { cn } from '@asteby/metacore-ui/lib'
38
+ import {
39
+ Table,
40
+ TableBody,
41
+ TableCell,
42
+ TableHead,
43
+ TableHeader,
44
+ TableRow,
45
+ Button,
46
+ Skeleton,
47
+ AlertDialog,
48
+ AlertDialogAction,
49
+ AlertDialogCancel,
50
+ AlertDialogContent,
51
+ AlertDialogDescription,
52
+ AlertDialogFooter,
53
+ AlertDialogHeader,
54
+ AlertDialogTitle,
55
+ } from '@asteby/metacore-ui/primitives'
56
+ import {
57
+ DataTablePagination,
58
+ DataTableToolbar,
59
+ DataTableBulkActions,
60
+ type FilterOption as DynamicFilterOption,
61
+ } from '@asteby/metacore-ui/data-table'
62
+ import { Inbox, Download, Upload, Trash2 } from 'lucide-react'
63
+ import { toast } from 'sonner'
64
+ import { Progress } from './dialogs/_primitives'
65
+ import { useMetadataCache } from './metadata-cache'
66
+ import { useApi, useCurrentBranch } from './api-context'
67
+ import type { ColumnFilterConfig, GetDynamicColumns } from './dynamic-columns-shim'
68
+ import { OptionsContext } from './options-context'
69
+ import { ActionModalDispatcher } from './action-modal-dispatcher'
70
+ import type { TableMetadata, ApiResponse, ActionMetadata } from './types'
71
+ import { DynamicRecordDialog } from './dialogs/dynamic-record'
72
+ import { ExportDialog } from './dialogs/export'
73
+ import { ImportDialog } from './dialogs/import'
74
+
75
+ interface DynamicTableProps {
76
+ model: string
77
+ endpoint?: string
78
+ enableUrlSync?: boolean
79
+ hiddenColumns?: string[]
80
+ onAction?: (action: string, row: any) => void
81
+ refreshTrigger?: any
82
+ defaultFilters?: Record<string, any>
83
+ extraColumns?: ColumnDef<any>[]
84
+ /**
85
+ * Host-provided factory that turns metadata into TanStack column defs.
86
+ * Lives in the host because the rendered cells depend on the host's
87
+ * design system (Badge, Avatar, MediaGallery, phone flags, etc.).
88
+ * Optional — a sensible default maps each column to { accessorKey, header }.
89
+ */
90
+ getDynamicColumns?: GetDynamicColumns
91
+ }
92
+
93
+ export function DynamicTable({
94
+ model,
95
+ endpoint,
96
+ enableUrlSync = true,
97
+ hiddenColumns = [],
98
+ onAction,
99
+ refreshTrigger,
100
+ defaultFilters,
101
+ extraColumns = [],
102
+ getDynamicColumns = defaultGetDynamicColumns,
103
+ }: DynamicTableProps) {
104
+ const { t, i18n } = useTranslation()
105
+ const api = useApi()
106
+ const currentBranch = useCurrentBranch()
107
+ const navigate = useNavigate()
108
+
109
+ const prevBranchId = useRef(currentBranch?.id)
110
+
111
+ const { getMetadata, setMetadata: cacheMetadata } = useMetadataCache()
112
+ const cachedMeta = getMetadata(model)
113
+
114
+ const [metadata, setMetadata] = useState<TableMetadata | null>(cachedMeta || null)
115
+ const [data, setData] = useState<any[]>([])
116
+ const [loading, setLoading] = useState(!cachedMeta)
117
+ const [loadingData, setLoadingData] = useState(true)
118
+ const [optionsMap, setOptionsMap] = useState<Map<string, any[]>>(new Map())
119
+
120
+ const [recordDialog, setRecordDialog] = useState<{
121
+ open: boolean
122
+ mode: 'view' | 'edit' | 'create'
123
+ recordId: string | null
124
+ }>({ open: false, mode: 'view', recordId: null })
125
+
126
+ const [rowToDelete, setRowToDelete] = useState<any | null>(null)
127
+ const [isDeleting, setIsDeleting] = useState(false)
128
+
129
+ const [exportOpen, setExportOpen] = useState(false)
130
+ const [importOpen, setImportOpen] = useState(false)
131
+
132
+ const [actionModal, setActionModal] = useState<{
133
+ open: boolean
134
+ action: ActionMetadata | null
135
+ record: any | null
136
+ }>({ open: false, action: null, record: null })
137
+
138
+ const [showBulkDeleteConfirm, setShowBulkDeleteConfirm] = useState(false)
139
+ const [isBulkDeleting, setIsBulkDeleting] = useState(false)
140
+ const [bulkDeleteProgress, setBulkDeleteProgress] = useState(0)
141
+ const [bulkDeleteTotal, setBulkDeleteTotal] = useState(0)
142
+
143
+ const [rowSelection, setRowSelection] = useState({})
144
+ const [sorting, setSorting] = useState<SortingState>([])
145
+ const [columnVisibility, setColumnVisibility] = useState<VisibilityState>(() => {
146
+ const initial: VisibilityState = {}
147
+ hiddenColumns.forEach(col => { initial[col] = false })
148
+ return initial
149
+ })
150
+ const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([])
151
+ const [pagination, setPagination] = useState<PaginationState>({ pageIndex: 0, pageSize: 10 })
152
+ const [globalFilter, setGlobalFilter] = useState('')
153
+ const [rowCount, setRowCount] = useState(0)
154
+
155
+ const [dateRange, setDateRange] = useState<DateRange | undefined>(undefined)
156
+ const [dynamicFilters, setDynamicFilters] = useState<Record<string, string[]>>({})
157
+ const [filterOptionsMap, setFilterOptionsMap] = useState<Map<string, DynamicFilterOption[]>>(new Map())
158
+
159
+ const initializedFromUrl = useRef(false)
160
+ const urlHadPerPage = useRef(false)
161
+
162
+ useEffect(() => {
163
+ if (prevBranchId.current !== currentBranch?.id) {
164
+ prevBranchId.current = currentBranch?.id
165
+ setPagination((prev: PaginationState) => ({ ...prev, pageIndex: 0 }))
166
+ setRowSelection({})
167
+ }
168
+ }, [currentBranch?.id])
169
+
170
+ const urlAliasToOperator: Record<string, string> = {
171
+ 'contains': 'ILIKE', 'like': 'LIKE', 'in': 'IN', 'not_in': 'NOT_IN',
172
+ 'gt': 'GT', 'lt': 'LT', 'gte': 'GTE', 'lte': 'LTE',
173
+ 'range': 'RANGE', 'null': 'NULL', 'not_null': 'NOT_NULL',
174
+ }
175
+ const operatorToUrlAlias: Record<string, string> = Object.fromEntries(
176
+ Object.entries(urlAliasToOperator).map(([alias, op]) => [op, alias])
177
+ )
178
+
179
+ const urlValueToInternal = (value: string): string => {
180
+ const colonIdx = value.indexOf(':')
181
+ if (colonIdx === -1) return value
182
+ const prefix = value.substring(0, colonIdx).toLowerCase()
183
+ const rest = value.substring(colonIdx + 1)
184
+ const operator = urlAliasToOperator[prefix]
185
+ return operator ? `${operator}:${rest}` : value
186
+ }
187
+ const internalValueToUrl = (value: string): string => {
188
+ const colonIdx = value.indexOf(':')
189
+ if (colonIdx === -1) return value
190
+ const prefix = value.substring(0, colonIdx)
191
+ const rest = value.substring(colonIdx + 1)
192
+ const alias = operatorToUrlAlias[prefix]
193
+ return alias ? `${alias}:${rest}` : value
194
+ }
195
+
196
+ useEffect(() => {
197
+ if (!enableUrlSync || initializedFromUrl.current) return
198
+ initializedFromUrl.current = true
199
+ const params = new URLSearchParams(window.location.search)
200
+ const page = params.get('page')
201
+ const perPage = params.get('per_page')
202
+ if (perPage) urlHadPerPage.current = true
203
+ if (page || perPage) {
204
+ setPagination((prev: PaginationState) => ({
205
+ pageIndex: page ? Math.max(0, parseInt(page, 10) - 1) : prev.pageIndex,
206
+ pageSize: perPage ? parseInt(perPage, 10) : prev.pageSize,
207
+ }))
208
+ }
209
+ const sortBy = params.get('sortBy')
210
+ const order = params.get('order')
211
+ if (sortBy) setSorting([{ id: sortBy, desc: order === 'desc' }])
212
+ const search = params.get('search')
213
+ if (search) setGlobalFilter(search)
214
+ const filters: Record<string, string[]> = {}
215
+ params.forEach((rawValue, key) => {
216
+ if (key.startsWith('f_')) {
217
+ const filterKey = key.substring(2)
218
+ if (defaultFilters && filterKey in defaultFilters) return
219
+ const value = urlValueToInternal(rawValue)
220
+ if (value.startsWith('IN:')) filters[filterKey] = value.substring(3).split(',')
221
+ else filters[filterKey] = [value]
222
+ }
223
+ })
224
+ if (Object.keys(filters).length > 0) setDynamicFilters(filters)
225
+ }, []) // eslint-disable-line react-hooks/exhaustive-deps
226
+
227
+ useEffect(() => {
228
+ if (!enableUrlSync || !initializedFromUrl.current) return
229
+ const params = new URLSearchParams()
230
+ if (pagination.pageIndex > 0) params.set('page', String(pagination.pageIndex + 1))
231
+ if (pagination.pageSize !== 10) params.set('per_page', String(pagination.pageSize))
232
+ if (sorting.length > 0) {
233
+ params.set('sortBy', sorting[0].id)
234
+ params.set('order', sorting[0].desc ? 'desc' : 'asc')
235
+ }
236
+ if (globalFilter) params.set('search', globalFilter)
237
+ Object.entries(dynamicFilters).forEach(([key, values]) => {
238
+ if (values.length === 0) return
239
+ if (defaultFilters && key in defaultFilters) return
240
+ if (values.length === 1) params.set(`f_${key}`, internalValueToUrl(values[0]))
241
+ else params.set(`f_${key}`, `in:${values.join(',')}`)
242
+ })
243
+ const search = params.toString()
244
+ const newUrl = search ? `${window.location.pathname}?${search}` : window.location.pathname
245
+ window.history.replaceState(null, '', newUrl)
246
+ }, [enableUrlSync, pagination, sorting, globalFilter, dynamicFilters, defaultFilters])
247
+
248
+ const prefetchOptions = useCallback(async (endpoints: string[]) => {
249
+ if (endpoints.length === 0) return new Map<string, any[]>()
250
+ const uniqueEndpoints = Array.from(new Set(endpoints))
251
+ const promises = uniqueEndpoints.map(async (ep) => {
252
+ try {
253
+ const res = await api.get(ep)
254
+ return { endpoint: ep, data: res.data?.success ? res.data.data : [] }
255
+ } catch (e) {
256
+ console.error(`Failed to fetch options for ${ep}`, e)
257
+ return { endpoint: ep, data: [] }
258
+ }
259
+ })
260
+ const results = await Promise.all(promises)
261
+ const map = new Map<string, any[]>()
262
+ results.forEach(r => map.set(r.endpoint, r.data))
263
+ return map
264
+ }, [api])
265
+
266
+ const metaInitRef = useRef(false)
267
+ useEffect(() => {
268
+ if (metaInitRef.current) return
269
+ metaInitRef.current = true
270
+ const initMetadataAndOptions = async () => {
271
+ let meta: TableMetadata
272
+ const cached = getMetadata(model)
273
+ if (cached) {
274
+ meta = cached
275
+ setMetadata(meta)
276
+ if (!urlHadPerPage.current) setPagination((prev: PaginationState) => ({ ...prev, pageSize: meta.defaultPerPage || 10 }))
277
+ setLoading(false)
278
+ } else {
279
+ setLoading(true)
280
+ try {
281
+ const res = await api.get(`/metadata/table/${model}`) as { data: ApiResponse<TableMetadata> }
282
+ if (!res.data.success) return
283
+ meta = res.data.data
284
+ setMetadata(meta)
285
+ cacheMetadata(model, meta)
286
+ if (!urlHadPerPage.current) setPagination((prev: PaginationState) => ({ ...prev, pageSize: meta.defaultPerPage || 10 }))
287
+ } catch (error) {
288
+ console.error('Error al cargar la configuración de la tabla', error)
289
+ return
290
+ } finally {
291
+ setLoading(false)
292
+ }
293
+ }
294
+ const columnEndpoints = meta.columns.filter(c => c.useOptions && c.searchEndpoint).map(c => c.searchEndpoint!)
295
+ const filterEndpoints = (meta.filters || []).filter(f => f.searchEndpoint && (f.type === 'select' || f.type === 'boolean')).map(f => f.searchEndpoint!)
296
+ const allEndpoints = [...columnEndpoints, ...filterEndpoints]
297
+ if (allEndpoints.length > 0) {
298
+ prefetchOptions(allEndpoints).then(fetchedMap => {
299
+ const colMap = new Map<string, any[]>()
300
+ columnEndpoints.forEach(ep => { if (fetchedMap.has(ep)) colMap.set(ep, fetchedMap.get(ep)!) })
301
+ setOptionsMap(colMap)
302
+ const fMap = new Map<string, DynamicFilterOption[]>()
303
+ filterEndpoints.forEach(ep => {
304
+ if (fetchedMap.has(ep)) {
305
+ fMap.set(ep, (fetchedMap.get(ep) || []).map((item: any) => ({
306
+ label: item.label || item.name || '',
307
+ value: String(item.value ?? item.id ?? ''),
308
+ icon: item.icon,
309
+ color: item.color || item.class,
310
+ })))
311
+ }
312
+ })
313
+ setFilterOptionsMap(fMap)
314
+ })
315
+ }
316
+ }
317
+ initMetadataAndOptions()
318
+ }, [model]) // eslint-disable-line react-hooks/exhaustive-deps
319
+
320
+ const buildFilterParams = useCallback(() => {
321
+ const params: Record<string, any> = {}
322
+ if (sorting.length > 0) {
323
+ params.sortBy = sorting[0].id
324
+ params.order = sorting[0].desc ? 'desc' : 'asc'
325
+ }
326
+ if (globalFilter) params.search = globalFilter
327
+ columnFilters.forEach((filter: { id: string; value: unknown }) => { params[`f_${filter.id}`] = filter.value })
328
+ if (defaultFilters) Object.entries(defaultFilters).forEach(([key, value]) => { params[`f_${key}`] = value })
329
+ Object.entries(dynamicFilters).forEach(([key, values]) => {
330
+ if (values.length === 0) return
331
+ const gteVal = values.find(v => v.startsWith('GTE:'))
332
+ const lteVal = values.find(v => v.startsWith('LTE:'))
333
+ if (gteVal || lteVal) {
334
+ const min = gteVal ? gteVal.replace('GTE:', '') : ''
335
+ const max = lteVal ? lteVal.replace('LTE:', '') : ''
336
+ params[`f_${key}`] = `RANGE:${min},${max}`
337
+ return
338
+ }
339
+ if (values.length === 1) params[`f_${key}`] = values[0]
340
+ else params[`f_${key}`] = `IN:${values.join(',')}`
341
+ })
342
+ if (dateRange?.from) {
343
+ const startDate = format(dateRange.from, 'yyyy-MM-dd')
344
+ const endDate = dateRange.to ? format(dateRange.to, 'yyyy-MM-dd') : startDate
345
+ params['f_created_at'] = `${startDate}_${endDate}`
346
+ }
347
+ return params
348
+ }, [sorting, globalFilter, columnFilters, defaultFilters, dynamicFilters, dateRange])
349
+
350
+ const hasActiveFilters = useMemo(() => {
351
+ if (globalFilter) return true
352
+ if (columnFilters.length > 0) return true
353
+ if (Object.values(dynamicFilters).some(v => v.length > 0)) return true
354
+ if (dateRange?.from) return true
355
+ return false
356
+ }, [globalFilter, columnFilters, dynamicFilters, dateRange])
357
+
358
+ const fetchData = useCallback(async () => {
359
+ if (!metadata) return
360
+ setLoadingData(true)
361
+ try {
362
+ const params: Record<string, any> = {
363
+ page: pagination.pageIndex + 1,
364
+ per_page: pagination.pageSize,
365
+ ...buildFilterParams(),
366
+ }
367
+ const res = await api.get(endpoint || `/data/${model}`, { params }) as { data: ApiResponse<any[]> }
368
+ if (res.data.success) {
369
+ setData(res.data.data || [])
370
+ if (res.data.meta) setRowCount(res.data.meta.total)
371
+ }
372
+ } catch (error) {
373
+ console.error('Error al cargar los datos', error)
374
+ } finally {
375
+ setLoadingData(false)
376
+ }
377
+ }, [model, metadata, pagination, buildFilterParams, refreshTrigger, endpoint, currentBranch?.id, api])
378
+
379
+ const initialFetchDone = useRef(false)
380
+ useEffect(() => {
381
+ if (!metadata) return
382
+ if (!initialFetchDone.current) {
383
+ initialFetchDone.current = true
384
+ fetchData()
385
+ return
386
+ }
387
+ const timeoutId = setTimeout(fetchData, 300)
388
+ return () => clearTimeout(timeoutId)
389
+ }, [fetchData, metadata])
390
+
391
+ const handleRefresh = useCallback(() => { fetchData() }, [fetchData])
392
+
393
+ const handleInternalAction = useCallback(async (action: string, row: any) => {
394
+ if (action === 'delete') { setRowToDelete(row); return }
395
+ if (action === 'view' || action === 'edit') {
396
+ if (onAction) await Promise.resolve(onAction(action, row))
397
+ else setRecordDialog({ open: true, mode: action, recordId: row.id })
398
+ return
399
+ }
400
+ const linkDef = metadata?.actions?.find((a) => a.key === action && a.type === 'link')
401
+ if (linkDef?.linkUrl) {
402
+ const url = linkDef.linkUrl.replace(/\{(\w+)\}/g, (_: string, field: string) => String(row[field] ?? ''))
403
+ navigate({ to: url })
404
+ return
405
+ }
406
+ const actionDef = metadata?.actions?.find((a) => a.key === action)
407
+ if (actionDef && (actionDef.fields?.length || actionDef.confirm || actionDef.executable)) {
408
+ setActionModal({
409
+ open: true,
410
+ action: {
411
+ key: actionDef.key,
412
+ label: actionDef.label,
413
+ icon: actionDef.icon || 'Zap',
414
+ color: actionDef.color,
415
+ confirm: actionDef.confirm,
416
+ confirmMessage: actionDef.confirmMessage,
417
+ fields: actionDef.fields,
418
+ requiresState: actionDef.requiresState,
419
+ executable: actionDef.executable,
420
+ },
421
+ record: row,
422
+ })
423
+ return
424
+ }
425
+ if (onAction) { await Promise.resolve(onAction(action, row)); handleRefresh() }
426
+ else handleRefresh()
427
+ }, [onAction, handleRefresh, metadata, navigate])
428
+
429
+ const confirmDelete = async () => {
430
+ if (!rowToDelete) return
431
+ setIsDeleting(true)
432
+ try {
433
+ const deleteEndpoint = endpoint ? `${endpoint}/${rowToDelete.id}` : `/data/${model}/${rowToDelete.id}`
434
+ const res = await api.delete(deleteEndpoint)
435
+ if (res.data.success) { toast.success(res.data.message || 'Eliminado correctamente'); handleRefresh() }
436
+ else toast.error(res.data.message || 'Error al eliminar')
437
+ } catch (error) {
438
+ console.error('Error al eliminar', error)
439
+ toast.error('Error al eliminar el registro')
440
+ } finally {
441
+ setIsDeleting(false)
442
+ setRowToDelete(null)
443
+ }
444
+ }
445
+
446
+ const confirmBulkDelete = async () => {
447
+ const selectedRows = table.getFilteredSelectedRowModel().rows
448
+ if (selectedRows.length === 0) return
449
+ setIsBulkDeleting(true)
450
+ setBulkDeleteTotal(selectedRows.length)
451
+ setBulkDeleteProgress(0)
452
+ let successCount = 0, errorCount = 0
453
+ for (let i = 0; i < selectedRows.length; i++) {
454
+ const row = selectedRows[i]
455
+ try {
456
+ const deleteEndpoint = endpoint ? `${endpoint}/${row.original.id}` : `/data/${model}/${row.original.id}`
457
+ const res = await api.delete(deleteEndpoint)
458
+ if (res.data.success) successCount++; else errorCount++
459
+ } catch (e) { console.error('Error al eliminar', e); errorCount++ }
460
+ setBulkDeleteProgress(i + 1)
461
+ }
462
+ await new Promise(resolve => setTimeout(resolve, 500))
463
+ setIsBulkDeleting(false)
464
+ setShowBulkDeleteConfirm(false)
465
+ setBulkDeleteProgress(0)
466
+ setBulkDeleteTotal(0)
467
+ setRowSelection({})
468
+ if (successCount > 0) toast.success(`${successCount} registro(s) eliminado(s) correctamente`)
469
+ if (errorCount > 0) toast.error(`${errorCount} registro(s) no pudieron ser eliminados`)
470
+ handleRefresh()
471
+ }
472
+
473
+ const handleDynamicFilterChange = useCallback((filterKey: string, values: string[]) => {
474
+ setDynamicFilters((prev: Record<string, string[]>) => ({ ...prev, [filterKey]: values }))
475
+ setPagination((prev: PaginationState) => ({ ...prev, pageIndex: 0 }))
476
+ }, [])
477
+
478
+ const columnFilterConfigs = useMemo(() => {
479
+ const map = new Map<string, ColumnFilterConfig>()
480
+ if (!metadata?.filters) return map
481
+ for (const f of metadata.filters) {
482
+ const fType = f.type as ColumnFilterConfig['filterType']
483
+ let options: { label: string; value: string; icon?: string; color?: string }[] = []
484
+ if (f.options && f.options.length > 0) {
485
+ options = f.options.map(o => ({ label: o.label, value: String(o.value), icon: o.icon, color: o.color }))
486
+ }
487
+ if (f.searchEndpoint && filterOptionsMap.has(f.searchEndpoint)) {
488
+ options = filterOptionsMap.get(f.searchEndpoint) || []
489
+ }
490
+ if (fType === 'select' && options.length === 0 && !f.searchEndpoint) continue
491
+ map.set(f.key, {
492
+ filterType: fType,
493
+ filterKey: f.column || f.key,
494
+ options,
495
+ selectedValues: dynamicFilters[f.column || f.key] || [],
496
+ onFilterChange: handleDynamicFilterChange,
497
+ loading: f.searchEndpoint ? !filterOptionsMap.has(f.searchEndpoint) : false,
498
+ searchEndpoint: f.searchEndpoint,
499
+ })
500
+ }
501
+ return map
502
+ }, [metadata, filterOptionsMap, dynamicFilters, handleDynamicFilterChange])
503
+
504
+ const columns = useMemo(() => {
505
+ if (!metadata) return []
506
+ const baseColumns = getDynamicColumns(metadata, handleInternalAction, t, i18n.language, columnFilterConfigs)
507
+ const filteredBase = baseColumns.filter((col: ColumnDef<any>) => !hiddenColumns.includes(col.id as string))
508
+ const actionsCol = filteredBase.find((c: ColumnDef<any>) => c.id === 'actions')
509
+ const otherCols = filteredBase.filter((c: ColumnDef<any>) => c.id !== 'actions')
510
+ return [...otherCols, ...extraColumns, ...(actionsCol ? [actionsCol] : [])]
511
+ }, [metadata, handleInternalAction, hiddenColumns, extraColumns, t, i18n.language, columnFilterConfigs, getDynamicColumns])
512
+
513
+ const filters = useMemo(() => [], [])
514
+
515
+ const table = useReactTable({
516
+ data,
517
+ columns,
518
+ state: { sorting, columnVisibility, rowSelection, columnFilters, globalFilter, pagination },
519
+ pageCount: Math.ceil(rowCount / pagination.pageSize),
520
+ manualPagination: true,
521
+ manualSorting: true,
522
+ manualFiltering: true,
523
+ enableRowSelection: true,
524
+ onRowSelectionChange: setRowSelection,
525
+ onSortingChange: setSorting,
526
+ onColumnVisibilityChange: setColumnVisibility,
527
+ onColumnFiltersChange: setColumnFilters,
528
+ onGlobalFilterChange: setGlobalFilter,
529
+ onPaginationChange: setPagination,
530
+ getCoreRowModel: getCoreRowModel(),
531
+ getFilteredRowModel: getFilteredRowModel(),
532
+ getPaginationRowModel: getPaginationRowModel(),
533
+ getSortedRowModel: getSortedRowModel(),
534
+ getFacetedRowModel: getFacetedRowModel(),
535
+ getFacetedUniqueValues: getFacetedUniqueValues(),
536
+ })
537
+
538
+ const TableSkeleton = () => (
539
+ <>
540
+ {Array.from({ length: 5 }).map((_, i) => (
541
+ <TableRow key={`skeleton-${i}`} className="hover:bg-transparent">
542
+ <TableCell className="py-3 w-10"><Skeleton className="h-4 w-4" /></TableCell>
543
+ <TableCell className="py-3"><Skeleton className="h-4 w-[70%]" /></TableCell>
544
+ <TableCell className="py-3"><Skeleton className="h-4 w-[50%]" /></TableCell>
545
+ <TableCell className="py-3"><Skeleton className="h-4 w-[60%]" /></TableCell>
546
+ <TableCell className="py-3 w-16"><Skeleton className="h-6 w-6" /></TableCell>
547
+ </TableRow>
548
+ ))}
549
+ </>
550
+ )
551
+
552
+ if (loading) {
553
+ return (
554
+ <div className='flex flex-col h-full min-h-0 w-full'>
555
+ <div className='pb-4 shrink-0'>
556
+ <div className="flex items-center justify-between">
557
+ <Skeleton className="h-9 w-[280px]" />
558
+ <div className="flex items-center gap-2">
559
+ <Skeleton className="h-9 w-9" />
560
+ <Skeleton className="h-9 w-[70px]" />
561
+ </div>
562
+ </div>
563
+ </div>
564
+ <div className='flex-1 min-h-0 overflow-auto border rounded-md bg-card'>
565
+ <Table className='min-w-max w-full'>
566
+ <TableHeader className='sticky top-0 z-10'>
567
+ <TableRow className='border-b-0 hover:bg-transparent'>
568
+ <TableHead className='bg-card border-b h-10 w-10'><Skeleton className="h-4 w-4" /></TableHead>
569
+ <TableHead className='bg-card border-b h-10'><Skeleton className="h-4 w-16" /></TableHead>
570
+ <TableHead className='bg-card border-b h-10'><Skeleton className="h-4 w-14" /></TableHead>
571
+ <TableHead className='bg-card border-b h-10'><Skeleton className="h-4 w-20" /></TableHead>
572
+ <TableHead className='bg-card border-b h-10 w-16'><Skeleton className="h-4 w-12" /></TableHead>
573
+ </TableRow>
574
+ </TableHeader>
575
+ <TableBody><TableSkeleton /></TableBody>
576
+ </Table>
577
+ </div>
578
+ </div>
579
+ )
580
+ }
581
+
582
+ if (!metadata) {
583
+ return <div className="text-center text-muted-foreground py-8">Error al cargar la configuración de la tabla.</div>
584
+ }
585
+
586
+ return (
587
+ <OptionsContext.Provider value={{ optionsMap }}>
588
+ <div className='flex flex-col h-full min-h-0 w-full'>
589
+ <div className='pb-4 shrink-0'>
590
+ <DataTableToolbar
591
+ table={table}
592
+ searchPlaceholder={metadata.searchPlaceholder || 'Buscar...'}
593
+ filters={filters}
594
+ activeFilters={dynamicFilters}
595
+ onDynamicFilterChange={handleDynamicFilterChange}
596
+ dateFilter={{ value: dateRange, onChange: setDateRange, placeholder: 'Filtrar por fecha' }}
597
+ perPageOptions={metadata.perPageOptions}
598
+ onRefresh={handleRefresh}
599
+ isLoading={loadingData}
600
+ selectedCount={Object.keys(rowSelection).length}
601
+ onBulkDelete={() => setShowBulkDeleteConfirm(true)}
602
+ extraActions={
603
+ <>
604
+ {metadata.canExport && (
605
+ <Button variant="outline" size="sm" className="h-8" onClick={() => setExportOpen(true)}>
606
+ <Download className="h-4 w-4 mr-1" /> Exportar
607
+ </Button>
608
+ )}
609
+ {metadata.canImport && (
610
+ <Button variant="outline" size="sm" className="h-8" onClick={() => setImportOpen(true)}>
611
+ <Upload className="h-4 w-4 mr-1" /> Importar
612
+ </Button>
613
+ )}
614
+ </>
615
+ }
616
+ />
617
+ </div>
618
+ <div className='flex-1 min-h-0 overflow-auto border rounded-md bg-card'>
619
+ <Table className='min-w-max w-full'>
620
+ <TableHeader className='sticky top-0 z-10'>
621
+ {table.getHeaderGroups().map((headerGroup: HeaderGroup<any>) => (
622
+ <TableRow key={headerGroup.id} className='border-b-0 hover:bg-transparent'>
623
+ {headerGroup.headers.map((header: Header<any, unknown>) => {
624
+ const isActionsColumn = header.id === 'actions'
625
+ return (
626
+ <TableHead
627
+ key={header.id}
628
+ colSpan={header.colSpan}
629
+ style={header.column.columnDef.size ? { width: header.column.columnDef.size } : undefined}
630
+ className={cn('bg-card border-b h-10', isActionsColumn && 'sticky right-0 z-20 bg-card shadow-[-2px_0_5px_-2px_rgba(0,0,0,0.1)]')}
631
+ >
632
+ {header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())}
633
+ </TableHead>
634
+ )
635
+ })}
636
+ </TableRow>
637
+ ))}
638
+ </TableHeader>
639
+ <TableBody>
640
+ {loadingData && data.length === 0 ? (
641
+ <TableSkeleton />
642
+ ) : table.getRowModel().rows?.length ? (
643
+ table.getRowModel().rows.map((row: Row<any>) => (
644
+ <TableRow key={row.id} data-state={row.getIsSelected() && 'selected'}>
645
+ {row.getVisibleCells().map((cell: Cell<any, unknown>) => {
646
+ const isActionsColumn = cell.column.id === 'actions'
647
+ return (
648
+ <TableCell
649
+ key={cell.id}
650
+ style={cell.column.columnDef.size ? { width: cell.column.columnDef.size } : undefined}
651
+ className={cn('py-2', isActionsColumn && 'sticky right-0 bg-card shadow-[-2px_0_5px_-2px_rgba(0,0,0,0.1)]')}
652
+ >
653
+ {flexRender(cell.column.columnDef.cell, cell.getContext())}
654
+ </TableCell>
655
+ )
656
+ })}
657
+ </TableRow>
658
+ ))
659
+ ) : (
660
+ <TableRow className='border-b-0 hover:bg-transparent'>
661
+ <TableCell colSpan={columns.length} className='h-full p-0'>
662
+ <div className="flex h-full py-12 flex-col items-center justify-center gap-2 text-muted-foreground">
663
+ <div className="flex h-20 w-20 items-center justify-center rounded-full bg-muted/50">
664
+ <Inbox className="h-10 w-10" />
665
+ </div>
666
+ <div className="flex flex-col items-center gap-1">
667
+ <h3 className="text-lg font-semibold text-foreground">No se encontraron resultados</h3>
668
+ <p className="text-sm text-muted-foreground">No hay datos para mostrar en este momento.</p>
669
+ </div>
670
+ </div>
671
+ </TableCell>
672
+ </TableRow>
673
+ )}
674
+ </TableBody>
675
+ </Table>
676
+ </div>
677
+ <div className='shrink-0 pt-4'>
678
+ <DataTablePagination
679
+ table={table}
680
+ pageSizeOptions={metadata.perPageOptions}
681
+ />
682
+ </div>
683
+ </div>
684
+
685
+ <AlertDialog open={!!rowToDelete} onOpenChange={(open: boolean) => !open && setRowToDelete(null)}>
686
+ <AlertDialogContent>
687
+ <AlertDialogHeader>
688
+ <AlertDialogTitle>¿Está absolutamente seguro?</AlertDialogTitle>
689
+ <AlertDialogDescription>Esta acción no se puede deshacer. Esto eliminará permanentemente el registro seleccionado de nuestros servidores.</AlertDialogDescription>
690
+ </AlertDialogHeader>
691
+ <AlertDialogFooter>
692
+ <AlertDialogCancel disabled={isDeleting}>{t('common.cancel')}</AlertDialogCancel>
693
+ <AlertDialogAction onClick={(e: React.MouseEvent) => { e.preventDefault(); confirmDelete() }} className="bg-red-600 hover:bg-red-700" disabled={isDeleting}>
694
+ {isDeleting ? 'Eliminando...' : 'Eliminar'}
695
+ </AlertDialogAction>
696
+ </AlertDialogFooter>
697
+ </AlertDialogContent>
698
+ </AlertDialog>
699
+
700
+ <AlertDialog open={showBulkDeleteConfirm} onOpenChange={(open: boolean) => !open && !isBulkDeleting && setShowBulkDeleteConfirm(false)}>
701
+ <AlertDialogContent>
702
+ <AlertDialogHeader>
703
+ <AlertDialogTitle>{isBulkDeleting ? 'Eliminando registros...' : '¿Eliminar múltiples registros?'}</AlertDialogTitle>
704
+ <AlertDialogDescription>
705
+ {isBulkDeleting ? (
706
+ <div className="space-y-4 mt-4">
707
+ <Progress value={(bulkDeleteProgress / bulkDeleteTotal) * 100} />
708
+ <p className="text-center text-sm">Procesando {bulkDeleteProgress} de {bulkDeleteTotal} registros...</p>
709
+ </div>
710
+ ) : (
711
+ <>Esta acción no se puede deshacer. Se eliminarán permanentemente <strong>{Object.keys(rowSelection).length}</strong> registro(s) de nuestros servidores.</>
712
+ )}
713
+ </AlertDialogDescription>
714
+ </AlertDialogHeader>
715
+ {!isBulkDeleting && (
716
+ <AlertDialogFooter>
717
+ <AlertDialogCancel>{t('common.cancel')}</AlertDialogCancel>
718
+ <AlertDialogAction onClick={(e: React.MouseEvent) => { e.preventDefault(); confirmBulkDelete() }} className="bg-red-600 hover:bg-red-700">Eliminar todos</AlertDialogAction>
719
+ </AlertDialogFooter>
720
+ )}
721
+ </AlertDialogContent>
722
+ </AlertDialog>
723
+
724
+ <DynamicRecordDialog
725
+ open={recordDialog.open}
726
+ onOpenChange={(open: boolean) => setRecordDialog((prev) => ({ ...prev, open }))}
727
+ mode={recordDialog.mode}
728
+ model={model}
729
+ recordId={recordDialog.recordId}
730
+ endpoint={endpoint}
731
+ onSaved={handleRefresh}
732
+ />
733
+
734
+ {metadata.canExport && (
735
+ <ExportDialog open={exportOpen} onOpenChange={setExportOpen} model={model} metadata={metadata} currentFilters={buildFilterParams()} hasActiveFilters={hasActiveFilters} />
736
+ )}
737
+ {metadata.canImport && (
738
+ <ImportDialog open={importOpen} onOpenChange={setImportOpen} model={model} metadata={metadata} onImported={handleRefresh} />
739
+ )}
740
+ {actionModal.action && (
741
+ <ActionModalDispatcher
742
+ open={actionModal.open}
743
+ onOpenChange={(open: boolean) => setActionModal((prev) => ({ ...prev, open }))}
744
+ action={actionModal.action}
745
+ model={model}
746
+ record={actionModal.record}
747
+ endpoint={endpoint}
748
+ onSuccess={handleRefresh}
749
+ />
750
+ )}
751
+ <DataTableBulkActions table={table} entityName="registro">
752
+ <Button variant="destructive" size="sm" className="h-8" onClick={() => setShowBulkDeleteConfirm(true)}>
753
+ <Trash2 className="h-4 w-4 mr-1.5" /> Eliminar
754
+ </Button>
755
+ </DataTableBulkActions>
756
+ </OptionsContext.Provider>
757
+ )
758
+ }
759
+
760
+ /** Sensible default when hosts don't provide their own getDynamicColumns. */
761
+ const defaultGetDynamicColumns: GetDynamicColumns = (metadata, _handleAction, _t, _lang, _filters) =>
762
+ (metadata.columns ?? []).map((col: any) => ({
763
+ accessorKey: col.name,
764
+ header: col.label ?? col.name,
765
+ enableSorting: col.sortable ?? false,
766
+ }))