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