@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,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
|
+
}
|