@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,770 @@
1
+ // DynamicRecordDialog — renders a create/edit/view modal for a model based
2
+ // on metadata fetched from `/metadata/modal/:model`. Ported from the ops
3
+ // starter. Host-owned infra that was referenced by alias (axios client,
4
+ // branch store) now flows through <ApiProvider> from runtime-react.
5
+ import { createContext, useContext, useEffect, useRef, useState } from 'react'
6
+ import {
7
+ Dialog,
8
+ DialogContent,
9
+ DialogHeader,
10
+ DialogTitle,
11
+ DialogDescription,
12
+ DialogFooter,
13
+ Button,
14
+ Input,
15
+ Textarea,
16
+ Label,
17
+ Select,
18
+ SelectContent,
19
+ SelectItem,
20
+ SelectTrigger,
21
+ SelectValue,
22
+ Switch,
23
+ Skeleton,
24
+ Badge,
25
+ Popover,
26
+ PopoverContent,
27
+ PopoverTrigger,
28
+ Command,
29
+ CommandEmpty,
30
+ CommandGroup,
31
+ CommandInput,
32
+ CommandItem,
33
+ CommandList,
34
+ } from '@asteby/metacore-ui/primitives'
35
+ import { cn } from '@asteby/metacore-ui/lib'
36
+ import { Calendar } from './_primitives'
37
+ import { toast } from 'sonner'
38
+ import { format, parseISO } from 'date-fns'
39
+ import { es } from 'date-fns/locale'
40
+ import { ExternalLink, Loader2, CalendarIcon, ChevronDown, Check, Upload, X as XIcon } from 'lucide-react'
41
+ import { useApi } from '../api-context'
42
+
43
+ interface FieldOption {
44
+ value: string
45
+ label: string
46
+ }
47
+
48
+ interface FieldDef {
49
+ key: string
50
+ label: string
51
+ type: 'text' | 'textarea' | 'select' | 'search' | 'number' | 'date' | 'email' | 'url' | 'boolean' | 'image' | string
52
+ required?: boolean
53
+ options?: FieldOption[]
54
+ defaultValue?: any
55
+ placeholder?: string
56
+ readonly?: boolean
57
+ hidden?: boolean
58
+ searchEndpoint?: string
59
+ filterBy?: string
60
+ }
61
+
62
+ interface ModalMetadata {
63
+ title: string
64
+ createTitle: string
65
+ editTitle: string
66
+ fields: FieldDef[]
67
+ }
68
+
69
+ export interface DynamicRecordDialogProps {
70
+ open: boolean
71
+ onOpenChange: (open: boolean) => void
72
+ mode: 'view' | 'edit' | 'create'
73
+ model: string
74
+ recordId?: string | null
75
+ endpoint?: string
76
+ onSaved?: () => void
77
+ }
78
+
79
+ function resolvePath(obj: any, path: string): any {
80
+ return path.split('.').reduce((acc, part) => acc?.[part], obj)
81
+ }
82
+
83
+ function formatDisplayValue(value: any, field: FieldDef): string {
84
+ if (value === null || value === undefined || value === '') return '—'
85
+ if (field.type === 'boolean' || typeof value === 'boolean') return value ? 'Sí' : 'No'
86
+
87
+ if (field.type === 'date') {
88
+ try {
89
+ return new Date(value).toLocaleDateString('es-MX', {
90
+ day: 'numeric', month: 'long', year: 'numeric',
91
+ })
92
+ } catch {
93
+ return String(value)
94
+ }
95
+ }
96
+
97
+ if (field.type === 'select' && field.options?.length) {
98
+ const match = field.options.find(o => o.value === String(value))
99
+ return match?.label ?? String(value)
100
+ }
101
+
102
+ return String(value)
103
+ }
104
+
105
+ const MODE_CONFIG = {
106
+ create: {
107
+ getTitle: (meta: ModalMetadata) => meta.createTitle || meta.title || 'Nuevo registro',
108
+ description: 'Completa los campos para crear un nuevo registro.',
109
+ submitLabel: 'Crear',
110
+ submittingLabel: 'Creando...',
111
+ cancelLabel: 'Cancelar',
112
+ },
113
+ edit: {
114
+ getTitle: (meta: ModalMetadata) => meta.editTitle || meta.title || 'Editar registro',
115
+ description: 'Modifica los campos y guarda los cambios.',
116
+ submitLabel: 'Guardar cambios',
117
+ submittingLabel: 'Guardando...',
118
+ cancelLabel: 'Cancelar',
119
+ },
120
+ view: {
121
+ getTitle: (meta: ModalMetadata) => meta.title || 'Ver registro',
122
+ description: 'Información detallada del registro.',
123
+ submitLabel: '',
124
+ submittingLabel: '',
125
+ cancelLabel: 'Cerrar',
126
+ },
127
+ }
128
+
129
+ const ModelContext = createContext('')
130
+
131
+ export function DynamicRecordDialog({
132
+ open,
133
+ onOpenChange,
134
+ mode,
135
+ model,
136
+ recordId,
137
+ endpoint,
138
+ onSaved,
139
+ }: DynamicRecordDialogProps) {
140
+ const api = useApi()
141
+ const [modalMeta, setModalMeta] = useState<ModalMetadata | null>(null)
142
+ const [record, setRecord] = useState<any | null>(null)
143
+ const [formValues, setFormValues] = useState<Record<string, any>>({})
144
+ const [loading, setLoading] = useState(false)
145
+ const [saving, setSaving] = useState(false)
146
+
147
+ const isCreate = mode === 'create'
148
+ const isEditable = mode === 'create' || mode === 'edit'
149
+ const config = MODE_CONFIG[mode]
150
+
151
+ useEffect(() => {
152
+ if (!open) return
153
+ if (!isCreate && !recordId) return
154
+
155
+ let cancelled = false
156
+
157
+ const load = async () => {
158
+ setLoading(true)
159
+ try {
160
+ const metaRes = await api.get(`/metadata/modal/${model}`)
161
+ if (cancelled) return
162
+
163
+ const meta: ModalMetadata = metaRes.data?.data ?? metaRes.data
164
+ setModalMeta(meta)
165
+
166
+ if (isCreate) {
167
+ const initial: Record<string, any> = {}
168
+ for (const field of meta.fields ?? []) {
169
+ initial[field.key] = field.defaultValue ?? ''
170
+ }
171
+ setFormValues(initial)
172
+ } else {
173
+ const recordEndpoint = endpoint
174
+ ? `${endpoint}/${recordId}`
175
+ : `/data/${model}/${recordId}`
176
+
177
+ const recRes = await api.get(recordEndpoint)
178
+ if (cancelled) return
179
+
180
+ const rec = recRes.data?.data ?? recRes.data
181
+ setRecord(rec)
182
+
183
+ const initial: Record<string, any> = {}
184
+ for (const field of meta.fields ?? []) {
185
+ initial[field.key] = resolvePath(rec, field.key) ?? field.defaultValue ?? ''
186
+ }
187
+ setFormValues(initial)
188
+ }
189
+ } catch (err) {
190
+ console.error('[DynamicRecordDialog] load error:', err)
191
+ toast.error('Error al cargar los datos')
192
+ } finally {
193
+ if (!cancelled) setLoading(false)
194
+ }
195
+ }
196
+
197
+ load()
198
+ return () => { cancelled = true }
199
+ }, [open, recordId, model, endpoint, isCreate])
200
+
201
+ useEffect(() => {
202
+ if (!open) {
203
+ setModalMeta(null)
204
+ setRecord(null)
205
+ setFormValues({})
206
+ }
207
+ }, [open])
208
+
209
+ const handleSubmit = async (e?: React.FormEvent) => {
210
+ e?.preventDefault()
211
+ if (!modalMeta) return
212
+
213
+ if (isEditable) {
214
+ for (const field of modalMeta.fields) {
215
+ if (field.required && !formValues[field.key] && formValues[field.key] !== 0 && formValues[field.key] !== false) {
216
+ toast.error(`El campo "${field.label}" es obligatorio`)
217
+ return
218
+ }
219
+ }
220
+ }
221
+
222
+ setSaving(true)
223
+ try {
224
+ let res
225
+ if (isCreate) {
226
+ const createEndpoint = endpoint || `/data/${model}`
227
+ res = await api.post(createEndpoint, formValues)
228
+ } else {
229
+ const updateEndpoint = endpoint
230
+ ? `${endpoint}/${recordId}`
231
+ : `/data/${model}/${recordId}`
232
+ res = await api.put(updateEndpoint, formValues)
233
+ }
234
+
235
+ if (res.data?.success !== false) {
236
+ toast.success(res.data?.message || (isCreate ? 'Registro creado correctamente' : 'Guardado correctamente'))
237
+ onSaved?.()
238
+ onOpenChange(false)
239
+ } else {
240
+ toast.error(res.data?.message || 'Error al guardar')
241
+ }
242
+ } catch (err: any) {
243
+ toast.error(err?.response?.data?.message || 'Error al guardar')
244
+ } finally {
245
+ setSaving(false)
246
+ }
247
+ }
248
+
249
+ const title = modalMeta ? config.getTitle(modalMeta) : ''
250
+
251
+ const visibleFields = modalMeta?.fields?.filter(f => {
252
+ if (f.hidden) return false
253
+ if (isCreate && f.readonly) return false
254
+ return true
255
+ }) ?? []
256
+
257
+ return (
258
+ <Dialog open={open} onOpenChange={onOpenChange}>
259
+ <DialogContent className="sm:max-w-2xl max-h-[90vh] flex flex-col p-0 gap-0 overflow-hidden">
260
+ <DialogHeader className="p-6 pb-4 border-b shrink-0">
261
+ <DialogTitle>{title}</DialogTitle>
262
+ <DialogDescription>{config.description}</DialogDescription>
263
+ </DialogHeader>
264
+
265
+ <div className="flex-1 overflow-y-auto p-6">
266
+ {loading ? (
267
+ <LoadingSkeleton />
268
+ ) : modalMeta ? (
269
+ <ModelContext.Provider value={model}>
270
+ <form
271
+ id="dynamic-record-form"
272
+ onSubmit={handleSubmit}
273
+ className="grid grid-cols-1 sm:grid-cols-2 gap-x-6 gap-y-4"
274
+ >
275
+ {visibleFields.map(field => {
276
+ const isFullWidth = field.type === 'textarea'
277
+ return (
278
+ <div
279
+ key={field.key}
280
+ className={isFullWidth ? 'sm:col-span-2' : ''}
281
+ >
282
+ <FieldRow
283
+ field={field}
284
+ record={record}
285
+ value={formValues[field.key] ?? ''}
286
+ mode={mode}
287
+ onChange={val =>
288
+ setFormValues((prev: Record<string, any>) => ({ ...prev, [field.key]: val }))
289
+ }
290
+ />
291
+ </div>
292
+ )
293
+ })}
294
+
295
+ {record?.external_url && (
296
+ <div className="sm:col-span-2">
297
+ <a
298
+ href={record.external_url}
299
+ target="_blank"
300
+ rel="noreferrer"
301
+ className="inline-flex items-center gap-1.5 text-sm text-primary hover:underline mt-1"
302
+ >
303
+ <ExternalLink className="h-3.5 w-3.5" />
304
+ Ver en {record.external_provider ?? 'proveedor externo'}
305
+ </a>
306
+ </div>
307
+ )}
308
+ </form>
309
+ </ModelContext.Provider>
310
+ ) : null}
311
+ </div>
312
+
313
+ <DialogFooter className="p-4 border-t shrink-0">
314
+ <Button variant="outline" onClick={() => onOpenChange(false)} disabled={saving}>
315
+ {config.cancelLabel}
316
+ </Button>
317
+ {isEditable && (
318
+ <Button
319
+ type="submit"
320
+ form="dynamic-record-form"
321
+ disabled={saving || loading}
322
+ >
323
+ {saving && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
324
+ {saving ? config.submittingLabel : config.submitLabel}
325
+ </Button>
326
+ )}
327
+ </DialogFooter>
328
+ </DialogContent>
329
+ </Dialog>
330
+ )
331
+ }
332
+
333
+ function LoadingSkeleton() {
334
+ return (
335
+ <div className="grid grid-cols-1 sm:grid-cols-2 gap-x-6 gap-y-4">
336
+ {Array.from({ length: 6 }).map((_, i) => (
337
+ <div key={i} className="flex flex-col gap-1.5">
338
+ <Skeleton className="h-3.5 w-24" />
339
+ <Skeleton className="h-9 w-full" />
340
+ </div>
341
+ ))}
342
+ </div>
343
+ )
344
+ }
345
+
346
+ interface FieldRowProps {
347
+ field: FieldDef
348
+ record: any
349
+ value: any
350
+ mode: 'view' | 'edit' | 'create'
351
+ onChange: (val: any) => void
352
+ }
353
+
354
+ function FieldRow({ field, record, value, mode, onChange }: FieldRowProps) {
355
+ const isReadonly = field.readonly || mode === 'view'
356
+
357
+ return (
358
+ <div className="flex flex-col gap-1.5">
359
+ <Label className="text-xs font-medium text-muted-foreground uppercase tracking-wide">
360
+ {field.label}
361
+ {field.required && mode !== 'view' && (
362
+ <span className="text-destructive ml-0.5">*</span>
363
+ )}
364
+ </Label>
365
+
366
+ {isReadonly ? (
367
+ <ViewValue field={field} value={value} record={record} />
368
+ ) : (
369
+ <EditField field={field} value={value} onChange={onChange} />
370
+ )}
371
+ </div>
372
+ )
373
+ }
374
+
375
+ function ViewValue({ field, value }: { field: FieldDef; value: any; record: any }) {
376
+ if (field.type === 'search' && value) {
377
+ return <SearchViewValue field={field} value={value} />
378
+ }
379
+
380
+ if (field.type === 'boolean' || typeof value === 'boolean') {
381
+ return (
382
+ <div className="flex items-center gap-2 py-1">
383
+ <Switch checked={!!value} disabled />
384
+ <span className="text-sm text-muted-foreground">
385
+ {value ? 'Sí' : 'No'}
386
+ </span>
387
+ </div>
388
+ )
389
+ }
390
+
391
+ if (field.type === 'color') {
392
+ return value ? (
393
+ <div className="flex items-center gap-2">
394
+ <div className="h-5 w-5 rounded-full border shadow-sm" style={{ backgroundColor: value }} />
395
+ <span className="text-sm">{value}</span>
396
+ </div>
397
+ ) : (
398
+ <p className="text-sm py-1 text-muted-foreground">-</p>
399
+ )
400
+ }
401
+
402
+ if (field.type === 'image') {
403
+ return value ? (
404
+ <img src={value} alt={field.label} className="h-16 w-16 rounded-lg object-cover border" />
405
+ ) : (
406
+ <p className="text-sm py-1 text-muted-foreground">Sin imagen</p>
407
+ )
408
+ }
409
+
410
+ if (field.type === 'url' && value) {
411
+ return (
412
+ <a
413
+ href={value}
414
+ target="_blank"
415
+ rel="noreferrer"
416
+ className="text-sm text-primary hover:underline truncate"
417
+ >
418
+ {value}
419
+ </a>
420
+ )
421
+ }
422
+
423
+ if (field.type === 'select' && field.options?.length) {
424
+ const match = field.options.find(o => o.value === String(value ?? ''))
425
+ if (match) {
426
+ return <Badge variant="secondary" className="w-fit">{match.label}</Badge>
427
+ }
428
+ }
429
+
430
+ const display = formatDisplayValue(value, field)
431
+
432
+ if (field.type === 'textarea') {
433
+ return (
434
+ <p className="text-sm whitespace-pre-wrap rounded-md bg-muted/40 p-3 min-h-[60px]">
435
+ {display}
436
+ </p>
437
+ )
438
+ }
439
+
440
+ return <p className="text-sm py-1">{display}</p>
441
+ }
442
+
443
+ function EditField({ field, value, onChange }: {
444
+ field: FieldDef
445
+ value: any
446
+ onChange: (val: any) => void
447
+ }) {
448
+ if (field.type === 'boolean') {
449
+ return (
450
+ <div className="flex items-center gap-2 py-1">
451
+ <Switch
452
+ checked={!!value}
453
+ onCheckedChange={onChange}
454
+ />
455
+ <span className="text-sm text-muted-foreground">
456
+ {value ? 'Sí' : 'No'}
457
+ </span>
458
+ </div>
459
+ )
460
+ }
461
+
462
+ if (field.type === 'textarea') {
463
+ return (
464
+ <Textarea
465
+ value={value ?? ''}
466
+ onChange={(e: React.ChangeEvent<HTMLTextAreaElement>) => onChange(e.target.value)}
467
+ placeholder={field.placeholder}
468
+ rows={4}
469
+ />
470
+ )
471
+ }
472
+
473
+ if (field.type === 'image') {
474
+ return <ImageUploadField field={field} value={value} onChange={onChange} />
475
+ }
476
+
477
+ if (field.type === 'search' && field.searchEndpoint) {
478
+ return <SearchField field={field} value={value} onChange={onChange} />
479
+ }
480
+
481
+ if (field.type === 'select' && field.searchEndpoint && !field.options?.length) {
482
+ return <SearchField field={{ ...field, type: 'search' }} value={value} onChange={onChange} />
483
+ }
484
+
485
+ if (field.type === 'select' && field.options?.length) {
486
+ return (
487
+ <Select value={String(value ?? '')} onValueChange={onChange}>
488
+ <SelectTrigger>
489
+ <SelectValue placeholder="Seleccionar..." />
490
+ </SelectTrigger>
491
+ <SelectContent>
492
+ {field.options.map(opt => (
493
+ <SelectItem key={opt.value} value={opt.value}>
494
+ {opt.label}
495
+ </SelectItem>
496
+ ))}
497
+ </SelectContent>
498
+ </Select>
499
+ )
500
+ }
501
+
502
+ if (field.type === 'color') {
503
+ return (
504
+ <div className="flex items-center gap-2">
505
+ <input
506
+ type="color"
507
+ value={value || '#6366f1'}
508
+ onChange={(e) => onChange(e.target.value)}
509
+ className="h-9 w-14 cursor-pointer rounded-md border p-1"
510
+ />
511
+ <Input
512
+ value={value || ''}
513
+ onChange={(e: React.ChangeEvent<HTMLInputElement>) => onChange(e.target.value)}
514
+ placeholder="#6366f1"
515
+ className="flex-1 h-9"
516
+ />
517
+ </div>
518
+ )
519
+ }
520
+
521
+ if (field.type === 'date') {
522
+ const dateValue = value ? (typeof value === 'string' ? parseISO(value) : new Date(value)) : undefined
523
+ const validDate = dateValue && !isNaN(dateValue.getTime()) ? dateValue : undefined
524
+
525
+ return (
526
+ <Popover>
527
+ <PopoverTrigger asChild>
528
+ <Button
529
+ variant="outline"
530
+ className={cn(
531
+ "w-full justify-start text-left font-normal h-9",
532
+ !validDate && "text-muted-foreground"
533
+ )}
534
+ >
535
+ <CalendarIcon className="mr-2 h-4 w-4" />
536
+ {validDate
537
+ ? format(validDate, 'PPP', { locale: es })
538
+ : "Seleccionar fecha"}
539
+ </Button>
540
+ </PopoverTrigger>
541
+ <PopoverContent className="w-auto p-0" align="start">
542
+ <Calendar
543
+ mode="single"
544
+ selected={validDate}
545
+ onSelect={(date) => onChange(date ? format(date, 'yyyy-MM-dd') : '')}
546
+ locale={es}
547
+ />
548
+ </PopoverContent>
549
+ </Popover>
550
+ )
551
+ }
552
+
553
+ const inputType = field.type === 'number'
554
+ ? 'number'
555
+ : field.type === 'email'
556
+ ? 'email'
557
+ : 'text'
558
+
559
+ return (
560
+ <Input
561
+ type={inputType}
562
+ value={value ?? ''}
563
+ onChange={(e: React.ChangeEvent<HTMLInputElement>) => onChange(
564
+ field.type === 'number' ? (e.target.value === '' ? '' : Number(e.target.value)) : e.target.value
565
+ )}
566
+ placeholder={field.placeholder}
567
+ />
568
+ )
569
+ }
570
+
571
+ function ImageUploadField({ field: _field, value, onChange }: { field: FieldDef; value: any; onChange: (val: any) => void }) {
572
+ const api = useApi()
573
+ const model = useContext(ModelContext)
574
+ const [uploading, setUploading] = useState(false)
575
+ const inputRef = useRef<HTMLInputElement>(null)
576
+
577
+ async function handleFile(e: React.ChangeEvent<HTMLInputElement>) {
578
+ const file = e.target.files?.[0]
579
+ if (!file) return
580
+
581
+ setUploading(true)
582
+ try {
583
+ const formData = new FormData()
584
+ formData.append('file', file)
585
+ formData.append('folder', model || 'uploads')
586
+ const res = await api.post('/upload', formData)
587
+ const url = res.data?.data?.url || res.data?.url
588
+ if (url) onChange(url)
589
+ } catch {
590
+ toast.error('Error al subir imagen')
591
+ } finally {
592
+ setUploading(false)
593
+ if (inputRef.current) inputRef.current.value = ''
594
+ }
595
+ }
596
+
597
+ return (
598
+ <div className="flex items-center gap-3">
599
+ {value ? (
600
+ <div className="relative">
601
+ <img src={value} alt="" className="h-16 w-16 rounded-lg object-cover border" />
602
+ <button
603
+ type="button"
604
+ onClick={() => onChange('')}
605
+ className="absolute -top-1.5 -right-1.5 size-5 bg-destructive text-white rounded-full flex items-center justify-center hover:bg-destructive/90"
606
+ >
607
+ <XIcon className="size-3" />
608
+ </button>
609
+ </div>
610
+ ) : (
611
+ <button
612
+ type="button"
613
+ onClick={() => inputRef.current?.click()}
614
+ disabled={uploading}
615
+ className="h-16 w-16 rounded-lg border-2 border-dashed border-muted-foreground/30 flex flex-col items-center justify-center gap-1 hover:border-primary/50 hover:bg-muted/50 transition-colors disabled:opacity-50"
616
+ >
617
+ {uploading ? (
618
+ <Loader2 className="size-4 animate-spin text-muted-foreground" />
619
+ ) : (
620
+ <Upload className="size-4 text-muted-foreground" />
621
+ )}
622
+ </button>
623
+ )}
624
+ <input ref={inputRef} type="file" accept="image/*" onChange={handleFile} className="hidden" />
625
+ {!value && <span className="text-xs text-muted-foreground">PNG, JPG, WebP</span>}
626
+ </div>
627
+ )
628
+ }
629
+
630
+ function extractArray(res: any): any[] {
631
+ const d = res.data
632
+ if (Array.isArray(d)) return d
633
+ if (d?.data && Array.isArray(d.data)) return d.data
634
+ return []
635
+ }
636
+
637
+ const searchCache = new Map<string, any[]>()
638
+
639
+ function SearchViewValue({ field, value }: { field: FieldDef; value: any }) {
640
+ const api = useApi()
641
+ const [label, setLabel] = useState(String(value))
642
+
643
+ useEffect(() => {
644
+ if (!field.searchEndpoint || !value) return
645
+ const cacheKey = field.searchEndpoint
646
+ const cached = searchCache.get(cacheKey)
647
+ if (cached) {
648
+ const match = cached.find((item: any) => item.value === value || item.id === value)
649
+ if (match) { setLabel(match.label || match.name || String(value)); return }
650
+ }
651
+ api.get(field.searchEndpoint, { params: { search: '', limit: 50 } }).then(res => {
652
+ const items = extractArray(res)
653
+ searchCache.set(cacheKey, items)
654
+ const match = items.find((item: any) => item.value === value || item.id === value)
655
+ if (match) setLabel(match.label || match.name || String(value))
656
+ }).catch(() => {})
657
+ }, [value, field.searchEndpoint])
658
+
659
+ return <p className="text-sm py-1">{label}</p>
660
+ }
661
+
662
+ function SearchField({ field, value, onChange }: { field: FieldDef; value: any; onChange: (val: any) => void }) {
663
+ const api = useApi()
664
+ const [open, setOpen] = useState(false)
665
+ const [query, setQuery] = useState('')
666
+ const [results, setResults] = useState<any[]>([])
667
+ const [loading, setLoading] = useState(false)
668
+ const [selectedLabel, setSelectedLabel] = useState('')
669
+
670
+ useEffect(() => {
671
+ if (!value || !field.searchEndpoint) return
672
+ const cached = searchCache.get(field.searchEndpoint)
673
+ if (cached) {
674
+ const match = cached.find((item: any) => item.value === value || item.id === value)
675
+ if (match) { setSelectedLabel(match.label || match.name || ''); return }
676
+ }
677
+ api.get(field.searchEndpoint, { params: { search: '', limit: 50 } }).then(res => {
678
+ const items = extractArray(res)
679
+ searchCache.set(field.searchEndpoint!, items)
680
+ const match = items.find((item: any) => item.value === value || item.id === value)
681
+ if (match) setSelectedLabel(match.label || match.name || '')
682
+ }).catch(() => {})
683
+ }, [value, field.searchEndpoint])
684
+
685
+ useEffect(() => {
686
+ if (!open || !field.searchEndpoint) return
687
+ if (!query) {
688
+ const cached = searchCache.get(field.searchEndpoint)
689
+ if (cached) { setResults(cached); return }
690
+ }
691
+ setLoading(true)
692
+ const timer = setTimeout(() => {
693
+ api.get(field.searchEndpoint!, { params: { search: query, limit: 20 } }).then(res => {
694
+ const items = extractArray(res)
695
+ if (!query) searchCache.set(field.searchEndpoint!, items)
696
+ setResults(items)
697
+ }).catch(() => setResults([]))
698
+ .finally(() => setLoading(false))
699
+ }, query ? 250 : 0)
700
+ return () => clearTimeout(timer)
701
+ }, [query, open, field.searchEndpoint])
702
+
703
+ return (
704
+ <Popover open={open} onOpenChange={setOpen}>
705
+ <PopoverTrigger asChild>
706
+ <Button
707
+ variant="outline"
708
+ role="combobox"
709
+ className={cn(
710
+ "w-full justify-between font-normal h-9",
711
+ !value && "text-muted-foreground"
712
+ )}
713
+ >
714
+ <span className="truncate">{selectedLabel || `Seleccionar ${field.label?.toLowerCase() || ''}...`}</span>
715
+ <ChevronDown className="ml-auto h-4 w-4 shrink-0 opacity-50" />
716
+ </Button>
717
+ </PopoverTrigger>
718
+ <PopoverContent className="w-[--radix-popover-trigger-width] p-0" align="start" side="bottom" sideOffset={4}>
719
+ <Command shouldFilter={false}>
720
+ <CommandInput
721
+ placeholder={`Buscar ${field.label?.toLowerCase() || ''}...`}
722
+ value={query}
723
+ onValueChange={setQuery}
724
+ />
725
+ <CommandList className="max-h-[200px]">
726
+ {loading ? (
727
+ <div className="py-6 text-center text-sm">
728
+ <Loader2 className="h-4 w-4 animate-spin mx-auto mb-1 text-muted-foreground" />
729
+ <span className="text-muted-foreground text-xs">Buscando...</span>
730
+ </div>
731
+ ) : results.length === 0 ? (
732
+ <CommandEmpty>Sin resultados.</CommandEmpty>
733
+ ) : (
734
+ <CommandGroup>
735
+ {results.map((item: any) => {
736
+ const itemValue = item.value ?? item.id
737
+ const itemLabel = item.label ?? item.name ?? ''
738
+ const isSelected = value === itemValue
739
+ return (
740
+ <CommandItem
741
+ key={itemValue}
742
+ value={String(itemValue)}
743
+ onSelect={() => {
744
+ onChange(itemValue)
745
+ setSelectedLabel(itemLabel)
746
+ setOpen(false)
747
+ setQuery('')
748
+ }}
749
+ >
750
+ {isSelected && <Check className="mr-2 h-3.5 w-3.5 shrink-0 text-primary" />}
751
+ {item.image && (
752
+ <img src={item.image} className="h-5 w-5 rounded mr-2 object-cover shrink-0" alt="" />
753
+ )}
754
+ <div className="flex flex-col min-w-0">
755
+ <span className="truncate">{itemLabel}</span>
756
+ {item.description && (
757
+ <span className="text-[11px] text-muted-foreground truncate">{item.description}</span>
758
+ )}
759
+ </div>
760
+ </CommandItem>
761
+ )
762
+ })}
763
+ </CommandGroup>
764
+ )}
765
+ </CommandList>
766
+ </Command>
767
+ </PopoverContent>
768
+ </Popover>
769
+ )
770
+ }