@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.
- package/CHANGELOG.md +31 -0
- package/LICENSE +201 -0
- package/README.md +59 -0
- package/dist/action-modal-dispatcher.d.ts +4 -0
- package/dist/action-modal-dispatcher.d.ts.map +1 -0
- package/dist/action-modal-dispatcher.js +123 -0
- package/dist/addon-loader.d.ts +27 -0
- package/dist/addon-loader.d.ts.map +1 -0
- package/dist/addon-loader.js +73 -0
- package/dist/api-context.d.ts +40 -0
- package/dist/api-context.d.ts.map +1 -0
- package/dist/api-context.js +25 -0
- package/dist/capability-gate.d.ts +29 -0
- package/dist/capability-gate.d.ts.map +1 -0
- package/dist/capability-gate.js +43 -0
- package/dist/dialogs/_primitives.d.ts +29 -0
- package/dist/dialogs/_primitives.d.ts.map +1 -0
- package/dist/dialogs/_primitives.js +35 -0
- package/dist/dialogs/dynamic-record.d.ts +11 -0
- package/dist/dialogs/dynamic-record.d.ts.map +1 -0
- package/dist/dialogs/dynamic-record.js +377 -0
- package/dist/dialogs/export.d.ts +12 -0
- package/dist/dialogs/export.d.ts.map +1 -0
- package/dist/dialogs/export.js +146 -0
- package/dist/dialogs/import.d.ts +11 -0
- package/dist/dialogs/import.d.ts.map +1 -0
- package/dist/dialogs/import.js +128 -0
- package/dist/dynamic-columns-shim.d.ts +25 -0
- package/dist/dynamic-columns-shim.d.ts.map +1 -0
- package/dist/dynamic-columns-shim.js +1 -0
- package/dist/dynamic-form.d.ts +12 -0
- package/dist/dynamic-form.d.ts.map +1 -0
- package/dist/dynamic-form.js +51 -0
- package/dist/dynamic-icon.d.ts +6 -0
- package/dist/dynamic-icon.d.ts.map +1 -0
- package/dist/dynamic-icon.js +11 -0
- package/dist/dynamic-table.d.ts +22 -0
- package/dist/dynamic-table.d.ts.map +1 -0
- package/dist/dynamic-table.js +516 -0
- package/dist/i18n-provider.d.ts +16 -0
- package/dist/i18n-provider.d.ts.map +1 -0
- package/dist/i18n-provider.js +16 -0
- package/dist/index.d.ts +18 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +21 -0
- package/dist/metadata-cache.d.ts +42 -0
- package/dist/metadata-cache.d.ts.map +1 -0
- package/dist/metadata-cache.js +71 -0
- package/dist/navigation-builder.d.ts +34 -0
- package/dist/navigation-builder.d.ts.map +1 -0
- package/dist/navigation-builder.js +45 -0
- package/dist/options-context.d.ts +8 -0
- package/dist/options-context.d.ts.map +1 -0
- package/dist/options-context.js +5 -0
- package/dist/slot.d.ts +32 -0
- package/dist/slot.d.ts.map +1 -0
- package/dist/slot.js +45 -0
- package/dist/types.d.ts +114 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +1 -0
- package/package.json +67 -0
- package/src/action-modal-dispatcher.tsx +275 -0
- package/src/addon-loader.tsx +111 -0
- package/src/api-context.tsx +55 -0
- package/src/capability-gate.tsx +69 -0
- package/src/dialogs/_primitives.tsx +114 -0
- package/src/dialogs/dynamic-record.tsx +770 -0
- package/src/dialogs/export.tsx +339 -0
- package/src/dialogs/import.tsx +404 -0
- package/src/dynamic-columns-shim.ts +36 -0
- package/src/dynamic-form.tsx +108 -0
- package/src/dynamic-icon.tsx +15 -0
- package/src/dynamic-table.tsx +766 -0
- package/src/i18n-provider.tsx +33 -0
- package/src/index.ts +30 -0
- package/src/metadata-cache.ts +103 -0
- package/src/navigation-builder.tsx +77 -0
- package/src/options-context.tsx +11 -0
- package/src/slot.tsx +77 -0
- package/src/types.ts +112 -0
- 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
|
+
}))
|