@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,404 @@
1
+ // ImportDialog — three-step CSV/JSON import flow (upload → validate → import
2
+ // with per-row error report). Ported from the ops starter. Axios-like client
3
+ // is provided by <ApiProvider>.
4
+ import { useState, useEffect, useRef } from 'react'
5
+ import {
6
+ Dialog,
7
+ DialogContent,
8
+ DialogHeader,
9
+ DialogTitle,
10
+ DialogDescription,
11
+ DialogFooter,
12
+ Button,
13
+ Input,
14
+ Label,
15
+ Table,
16
+ TableBody,
17
+ TableCell,
18
+ TableHead,
19
+ TableHeader,
20
+ TableRow,
21
+ } from '@asteby/metacore-ui/primitives'
22
+ import { Progress } from './_primitives'
23
+ import { toast } from 'sonner'
24
+ import { FileDown, Loader2, Check, AlertCircle } from 'lucide-react'
25
+ import type { TableMetadata } from '../types'
26
+ import { useApi } from '../api-context'
27
+
28
+ interface ImportDialogProps {
29
+ open: boolean
30
+ onOpenChange: (open: boolean) => void
31
+ model: string
32
+ metadata: TableMetadata
33
+ onImported?: () => void
34
+ }
35
+
36
+ interface ValidationError {
37
+ row: number
38
+ field: string
39
+ message: string
40
+ }
41
+
42
+ interface ValidationResult {
43
+ valid: number
44
+ errors: ValidationError[]
45
+ }
46
+
47
+ interface ImportResult {
48
+ created: number
49
+ errors: ValidationError[]
50
+ }
51
+
52
+ type Step = 'upload' | 'validation' | 'results'
53
+
54
+ export function ImportDialog({
55
+ open,
56
+ onOpenChange,
57
+ model,
58
+ metadata,
59
+ onImported,
60
+ }: ImportDialogProps) {
61
+ const api = useApi()
62
+ const [step, setStep] = useState<Step>('upload')
63
+ const [file, setFile] = useState<File | null>(null)
64
+ const [validating, setValidating] = useState(false)
65
+ const [importing, setImporting] = useState(false)
66
+ const [validationResult, setValidationResult] = useState<ValidationResult | null>(null)
67
+ const [importResult, setImportResult] = useState<ImportResult | null>(null)
68
+ const [progress, setProgress] = useState(0)
69
+ const fileInputRef = useRef<HTMLInputElement>(null)
70
+
71
+ useEffect(() => {
72
+ if (open) {
73
+ setStep('upload')
74
+ setFile(null)
75
+ setValidating(false)
76
+ setImporting(false)
77
+ setValidationResult(null)
78
+ setImportResult(null)
79
+ setProgress(0)
80
+ if (fileInputRef.current) fileInputRef.current.value = ''
81
+ }
82
+ }, [open])
83
+
84
+ const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
85
+ const selectedFile = e.target.files?.[0] ?? null
86
+ setFile(selectedFile)
87
+ }
88
+
89
+ const handleDownloadTemplate = async () => {
90
+ try {
91
+ const response = await api.get(`/data/${model}/export/template`, {
92
+ responseType: 'blob',
93
+ })
94
+ const url = window.URL.createObjectURL(response.data)
95
+ const link = document.createElement('a')
96
+ link.href = url
97
+ link.download = `${model}-plantilla.csv`
98
+ document.body.appendChild(link)
99
+ link.click()
100
+ document.body.removeChild(link)
101
+ window.URL.revokeObjectURL(url)
102
+ } catch {
103
+ toast.error('Error al descargar la plantilla')
104
+ }
105
+ }
106
+
107
+ const handleValidate = async () => {
108
+ if (!file) {
109
+ toast.error('Selecciona un archivo para validar')
110
+ return
111
+ }
112
+
113
+ setValidating(true)
114
+ try {
115
+ const formData = new FormData()
116
+ formData.append('file', file)
117
+
118
+ const res = await api.post(`/data/${model}/import/validate`, formData, {
119
+ headers: { 'Content-Type': 'multipart/form-data' },
120
+ })
121
+
122
+ const data = res.data?.data ?? res.data
123
+ setValidationResult({
124
+ valid: data.valid ?? 0,
125
+ errors: data.errors ?? [],
126
+ })
127
+ setStep('validation')
128
+ } catch (err: any) {
129
+ const message =
130
+ err?.response?.data?.message || 'Error al validar el archivo'
131
+ toast.error(message)
132
+ } finally {
133
+ setValidating(false)
134
+ }
135
+ }
136
+
137
+ const handleImport = async () => {
138
+ if (!file) return
139
+
140
+ setImporting(true)
141
+ setProgress(0)
142
+
143
+ try {
144
+ const formData = new FormData()
145
+ formData.append('file', file)
146
+
147
+ const res = await api.post(`/data/${model}/import`, formData, {
148
+ headers: { 'Content-Type': 'multipart/form-data' },
149
+ onUploadProgress: (progressEvent: { loaded: number; total?: number }) => {
150
+ if (progressEvent.total) {
151
+ setProgress(
152
+ Math.round((progressEvent.loaded / progressEvent.total) * 100)
153
+ )
154
+ }
155
+ },
156
+ })
157
+
158
+ const data = res.data?.data ?? res.data
159
+ setImportResult({
160
+ created: data.created ?? 0,
161
+ errors: data.errors ?? [],
162
+ })
163
+ setStep('results')
164
+
165
+ if ((data.created ?? 0) > 0) {
166
+ onImported?.()
167
+ }
168
+ } catch (err: any) {
169
+ const message =
170
+ err?.response?.data?.message || 'Error al importar los datos'
171
+ toast.error(message)
172
+ } finally {
173
+ setImporting(false)
174
+ setProgress(0)
175
+ }
176
+ }
177
+
178
+ const handleClose = () => {
179
+ onOpenChange(false)
180
+ }
181
+
182
+ const stepTitle = {
183
+ upload: 'Subir archivo',
184
+ validation: 'Validacion',
185
+ results: 'Resultados',
186
+ }
187
+
188
+ return (
189
+ <Dialog open={open} onOpenChange={onOpenChange}>
190
+ <DialogContent className="sm:max-w-lg max-h-[90vh] flex flex-col p-0 gap-0 overflow-hidden">
191
+ <DialogHeader className="p-6 pb-4 border-b shrink-0">
192
+ <DialogTitle>
193
+ Importar {metadata.title}
194
+ </DialogTitle>
195
+ <DialogDescription>
196
+ {stepTitle[step]}
197
+ </DialogDescription>
198
+ </DialogHeader>
199
+
200
+ <div className="flex-1 overflow-y-auto p-6">
201
+ {step === 'upload' && (
202
+ <div className="space-y-6">
203
+ <div>
204
+ <Button
205
+ variant="link"
206
+ className="px-0 h-auto text-sm"
207
+ onClick={handleDownloadTemplate}
208
+ >
209
+ <FileDown className="h-4 w-4 mr-1" />
210
+ Descargar plantilla CSV
211
+ </Button>
212
+ <p className="text-xs text-muted-foreground mt-1">
213
+ Descarga la plantilla para asegurar el formato correcto.
214
+ </p>
215
+ </div>
216
+
217
+ <div className="space-y-2">
218
+ <Label htmlFor="import-file" className="text-sm font-medium">
219
+ Archivo
220
+ </Label>
221
+ <Input
222
+ ref={fileInputRef}
223
+ id="import-file"
224
+ type="file"
225
+ accept=".csv,.json"
226
+ onChange={handleFileChange}
227
+ className="cursor-pointer"
228
+ />
229
+ <p className="text-xs text-muted-foreground">
230
+ Formatos aceptados: CSV, JSON
231
+ </p>
232
+ </div>
233
+ </div>
234
+ )}
235
+
236
+ {step === 'validation' && validationResult && (
237
+ <div className="space-y-4">
238
+ <div className="flex items-center gap-4">
239
+ <div className="flex items-center gap-2 text-sm">
240
+ <Check className="h-4 w-4 text-green-600" />
241
+ <span>
242
+ <strong>{validationResult.valid}</strong> valido(s)
243
+ </span>
244
+ </div>
245
+ {validationResult.errors.length > 0 && (
246
+ <div className="flex items-center gap-2 text-sm">
247
+ <AlertCircle className="h-4 w-4 text-destructive" />
248
+ <span>
249
+ <strong>{validationResult.errors.length}</strong> error(es)
250
+ </span>
251
+ </div>
252
+ )}
253
+ </div>
254
+
255
+ {validationResult.errors.length > 0 && (
256
+ <div className="border rounded-md max-h-60 overflow-auto">
257
+ <Table>
258
+ <TableHeader>
259
+ <TableRow>
260
+ <TableHead className="w-16">Fila</TableHead>
261
+ <TableHead>Campo</TableHead>
262
+ <TableHead>Error</TableHead>
263
+ </TableRow>
264
+ </TableHeader>
265
+ <TableBody>
266
+ {validationResult.errors.map((error, idx) => (
267
+ <TableRow key={idx}>
268
+ <TableCell className="font-mono text-xs">
269
+ {error.row}
270
+ </TableCell>
271
+ <TableCell className="text-sm">
272
+ {error.field}
273
+ </TableCell>
274
+ <TableCell className="text-sm text-destructive">
275
+ {error.message}
276
+ </TableCell>
277
+ </TableRow>
278
+ ))}
279
+ </TableBody>
280
+ </Table>
281
+ </div>
282
+ )}
283
+
284
+ {importing && (
285
+ <div className="space-y-2">
286
+ <Progress value={progress} />
287
+ <p className="text-xs text-muted-foreground text-center">
288
+ Importando... {progress > 0 ? `${progress}%` : ''}
289
+ </p>
290
+ </div>
291
+ )}
292
+ </div>
293
+ )}
294
+
295
+ {step === 'results' && importResult && (
296
+ <div className="space-y-4">
297
+ <div className="flex items-center gap-4">
298
+ {importResult.created > 0 && (
299
+ <div className="flex items-center gap-2 text-sm">
300
+ <Check className="h-4 w-4 text-green-600" />
301
+ <span>
302
+ <strong>{importResult.created}</strong> creado(s)
303
+ </span>
304
+ </div>
305
+ )}
306
+ {importResult.errors.length > 0 && (
307
+ <div className="flex items-center gap-2 text-sm">
308
+ <AlertCircle className="h-4 w-4 text-destructive" />
309
+ <span>
310
+ <strong>{importResult.errors.length}</strong> error(es)
311
+ </span>
312
+ </div>
313
+ )}
314
+ </div>
315
+
316
+ {importResult.created > 0 && importResult.errors.length === 0 && (
317
+ <div className="flex items-center gap-2 rounded-md bg-green-50 dark:bg-green-950/20 p-3 text-sm text-green-700 dark:text-green-400">
318
+ <Check className="h-4 w-4 shrink-0" />
319
+ Todos los registros fueron importados correctamente.
320
+ </div>
321
+ )}
322
+
323
+ {importResult.errors.length > 0 && (
324
+ <div className="border rounded-md max-h-60 overflow-auto">
325
+ <Table>
326
+ <TableHeader>
327
+ <TableRow>
328
+ <TableHead className="w-16">Fila</TableHead>
329
+ <TableHead>Campo</TableHead>
330
+ <TableHead>Error</TableHead>
331
+ </TableRow>
332
+ </TableHeader>
333
+ <TableBody>
334
+ {importResult.errors.map((error, idx) => (
335
+ <TableRow key={idx}>
336
+ <TableCell className="font-mono text-xs">
337
+ {error.row}
338
+ </TableCell>
339
+ <TableCell className="text-sm">
340
+ {error.field}
341
+ </TableCell>
342
+ <TableCell className="text-sm text-destructive">
343
+ {error.message}
344
+ </TableCell>
345
+ </TableRow>
346
+ ))}
347
+ </TableBody>
348
+ </Table>
349
+ </div>
350
+ )}
351
+ </div>
352
+ )}
353
+ </div>
354
+
355
+ <DialogFooter className="p-4 border-t shrink-0">
356
+ {step === 'upload' && (
357
+ <>
358
+ <Button variant="outline" onClick={handleClose}>
359
+ Cancelar
360
+ </Button>
361
+ <Button
362
+ onClick={handleValidate}
363
+ disabled={!file || validating}
364
+ >
365
+ {validating && (
366
+ <Loader2 className="h-4 w-4 mr-1 animate-spin" />
367
+ )}
368
+ {validating ? 'Validando...' : 'Validar'}
369
+ </Button>
370
+ </>
371
+ )}
372
+
373
+ {step === 'validation' && (
374
+ <>
375
+ <Button
376
+ variant="outline"
377
+ onClick={() => setStep('upload')}
378
+ disabled={importing}
379
+ >
380
+ Atras
381
+ </Button>
382
+ <Button
383
+ onClick={handleImport}
384
+ disabled={
385
+ importing ||
386
+ (validationResult !== null && validationResult.valid === 0)
387
+ }
388
+ >
389
+ {importing && (
390
+ <Loader2 className="h-4 w-4 mr-1 animate-spin" />
391
+ )}
392
+ {importing ? 'Importando...' : 'Importar'}
393
+ </Button>
394
+ </>
395
+ )}
396
+
397
+ {step === 'results' && (
398
+ <Button onClick={handleClose}>Cerrar</Button>
399
+ )}
400
+ </DialogFooter>
401
+ </DialogContent>
402
+ </Dialog>
403
+ )
404
+ }
@@ -0,0 +1,36 @@
1
+ // Type-only definitions for dynamic column builders. The actual
2
+ // `getDynamicColumns` implementation is host-owned (it renders design-system
3
+ // specific primitives like Badge/Avatar/MediaGallery tied to the host's
4
+ // shadcn theme). Hosts pass their implementation into <DynamicTable> via the
5
+ // `getDynamicColumns` prop.
6
+ import type { ColumnDef } from '@tanstack/react-table'
7
+ import type { TableMetadata } from './types'
8
+
9
+ export interface FilterOption {
10
+ label: string
11
+ value: string
12
+ icon?: string
13
+ color?: string
14
+ }
15
+
16
+ export interface ColumnFilterConfig {
17
+ filterType: 'select' | 'boolean' | 'date_range' | 'number_range' | 'text' | string
18
+ filterKey: string
19
+ options: FilterOption[]
20
+ selectedValues: string[]
21
+ onFilterChange: (filterKey: string, values: string[]) => void
22
+ loading?: boolean
23
+ searchEndpoint?: string
24
+ }
25
+
26
+ /** Signature for the host-provided `getDynamicColumns` factory. */
27
+ export type GetDynamicColumns = (
28
+ metadata: TableMetadata,
29
+ handleAction: (action: string, row: any) => void,
30
+ t: (key: string, options?: any) => string,
31
+ language: string,
32
+ columnFilterConfigs: Map<string, ColumnFilterConfig>,
33
+ ) => ColumnDef<any>[]
34
+
35
+ /** Signature for the host-provided `DynamicIcon` renderer. */
36
+ export type DynamicIconComponent = React.ComponentType<{ name: string; className?: string }>
@@ -0,0 +1,108 @@
1
+ // Minimal standalone DynamicForm. Factored from the dynamic-record-dialog
2
+ // pattern + ActionFieldDef renderer so callers can reuse the form layout
3
+ // outside the full record-edit modal.
4
+ import { useEffect, useState } from 'react'
5
+ import {
6
+ Input,
7
+ Textarea,
8
+ Label,
9
+ Switch,
10
+ Button,
11
+ Select,
12
+ SelectContent,
13
+ SelectItem,
14
+ SelectTrigger,
15
+ SelectValue,
16
+ } from '@asteby/metacore-ui/primitives'
17
+ import type { ActionFieldDef } from './types'
18
+
19
+ export interface DynamicFormProps {
20
+ fields: ActionFieldDef[]
21
+ initialValues?: Record<string, any>
22
+ onSubmit: (values: Record<string, any>) => void | Promise<void>
23
+ onCancel?: () => void
24
+ submitLabel?: string
25
+ cancelLabel?: string
26
+ disabled?: boolean
27
+ }
28
+
29
+ export function DynamicForm({
30
+ fields,
31
+ initialValues,
32
+ onSubmit,
33
+ onCancel,
34
+ submitLabel = 'Guardar',
35
+ cancelLabel = 'Cancelar',
36
+ disabled = false,
37
+ }: DynamicFormProps) {
38
+ const [values, setValues] = useState<Record<string, any>>({})
39
+ const [submitting, setSubmitting] = useState(false)
40
+
41
+ useEffect(() => {
42
+ const defaults: Record<string, any> = {}
43
+ for (const f of fields) {
44
+ defaults[f.key] = initialValues?.[f.key] ?? f.defaultValue ?? (f.type === 'boolean' ? false : '')
45
+ }
46
+ setValues(defaults)
47
+ }, [fields, initialValues])
48
+
49
+ const update = (k: string, v: any) => setValues((prev: Record<string, any>) => ({ ...prev, [k]: v }))
50
+
51
+ const handleSubmit = async (e: React.FormEvent) => {
52
+ e.preventDefault()
53
+ for (const f of fields) {
54
+ if (f.required && !values[f.key] && values[f.key] !== false) {
55
+ alert(`${f.label} es requerido`)
56
+ return
57
+ }
58
+ }
59
+ setSubmitting(true)
60
+ try { await onSubmit(values) } finally { setSubmitting(false) }
61
+ }
62
+
63
+ return (
64
+ <form onSubmit={handleSubmit} className="grid gap-4">
65
+ {fields.map((field) => (
66
+ <div key={field.key} className="grid gap-2">
67
+ <Label htmlFor={field.key}>
68
+ {field.label}
69
+ {field.required && <span className="text-red-500 ml-1">*</span>}
70
+ </Label>
71
+ {renderField(field, values[field.key], (v: any) => update(field.key, v))}
72
+ </div>
73
+ ))}
74
+ <div className="flex justify-end gap-2 pt-2">
75
+ {onCancel && (
76
+ <Button type="button" variant="outline" onClick={onCancel} disabled={submitting || disabled}>
77
+ {cancelLabel}
78
+ </Button>
79
+ )}
80
+ <Button type="submit" disabled={submitting || disabled}>{submitLabel}</Button>
81
+ </div>
82
+ </form>
83
+ )
84
+ }
85
+
86
+ function renderField(field: ActionFieldDef, value: any, onChange: (v: any) => void) {
87
+ switch (field.type) {
88
+ case 'textarea':
89
+ return <Textarea id={field.key} value={value || ''} onChange={(e: React.ChangeEvent<HTMLTextAreaElement>) => onChange(e.target.value)} placeholder={field.placeholder} />
90
+ case 'select':
91
+ return (
92
+ <Select value={value || ''} onValueChange={onChange}>
93
+ <SelectTrigger><SelectValue placeholder={field.placeholder || 'Seleccionar...'} /></SelectTrigger>
94
+ <SelectContent>
95
+ {field.options?.map((opt) => <SelectItem key={opt.value} value={opt.value}>{opt.label}</SelectItem>)}
96
+ </SelectContent>
97
+ </Select>
98
+ )
99
+ case 'boolean':
100
+ return <Switch id={field.key} checked={!!value} onCheckedChange={onChange} />
101
+ case 'number':
102
+ return <Input id={field.key} type="number" value={value ?? ''} onChange={(e: React.ChangeEvent<HTMLInputElement>) => onChange(e.target.valueAsNumber || '')} placeholder={field.placeholder} />
103
+ case 'date':
104
+ return <Input id={field.key} type="date" value={value || ''} onChange={(e: React.ChangeEvent<HTMLInputElement>) => onChange(e.target.value)} />
105
+ default:
106
+ return <Input id={field.key} type={field.type === 'email' ? 'email' : field.type === 'url' ? 'url' : 'text'} value={value || ''} onChange={(e: React.ChangeEvent<HTMLInputElement>) => onChange(e.target.value)} placeholder={field.placeholder} />
107
+ }
108
+ }
@@ -0,0 +1,15 @@
1
+ // Minimal DynamicIcon — resolves a lucide-react icon by name. Used across
2
+ // action modals and the default row-action menus. Hosts that need custom
3
+ // icon sets can override by shadowing this component via their own prop.
4
+ import * as icons from 'lucide-react'
5
+
6
+ export interface DynamicIconProps {
7
+ name: string
8
+ className?: string
9
+ }
10
+
11
+ export function DynamicIcon({ name, className }: DynamicIconProps) {
12
+ const Icon = (icons as unknown as Record<string, React.ComponentType<{ className?: string }>>)[name]
13
+ if (!Icon) return null
14
+ return <Icon className={className} />
15
+ }