@hed-hog/catalog 0.0.279 → 0.0.285

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.
@@ -1,964 +1,964 @@
1
- 'use client';
2
-
3
- import {
4
- catalogResourceMap,
5
- getCatalogLocalizedText,
6
- getCatalogRecordLabel,
7
- type CatalogFormFieldDefinition,
8
- type CatalogResourceDefinition,
9
- } from '../_lib/catalog-resources';
10
- import { RichTextEditor } from '@/components/rich-text-editor';
11
- import { Button } from '@/components/ui/button';
12
- import {
13
- Command,
14
- CommandEmpty,
15
- CommandGroup,
16
- CommandInput,
17
- CommandItem,
18
- CommandList,
19
- } from '@/components/ui/command';
20
- import {
21
- Form,
22
- FormControl,
23
- FormField,
24
- FormItem,
25
- FormLabel,
26
- FormMessage,
27
- } from '@/components/ui/form';
28
- import { Input } from '@/components/ui/input';
29
- import { InputMoney } from '@/components/ui/input-money';
30
- import {
31
- Popover,
32
- PopoverContent,
33
- PopoverTrigger,
34
- } from '@/components/ui/popover';
35
- import { Progress } from '@/components/ui/progress';
36
- import {
37
- Select,
38
- SelectContent,
39
- SelectItem,
40
- SelectTrigger,
41
- SelectValue,
42
- } from '@/components/ui/select';
43
- import {
44
- Sheet,
45
- SheetContent,
46
- SheetDescription,
47
- SheetFooter,
48
- SheetHeader,
49
- SheetTitle,
50
- } from '@/components/ui/sheet';
51
- import { Switch } from '@/components/ui/switch';
52
- import { Textarea } from '@/components/ui/textarea';
53
- import { zodResolver } from '@hookform/resolvers/zod';
54
- import { useApp, useQuery } from '@hed-hog/next-app-provider';
55
- import { ChevronsUpDown, Loader2, Plus, Save, Upload, X } from 'lucide-react';
56
- import Image from 'next/image';
57
- import { useTranslations } from 'next-intl';
58
- import { useEffect, useMemo, useRef, useState } from 'react';
59
- import { useForm } from 'react-hook-form';
60
- import { toast } from 'sonner';
61
- import { z } from 'zod';
62
-
63
- type CatalogRecord = Record<string, unknown>;
64
- type CatalogFormValues = Record<string, unknown>;
65
- type RelationOption = { id: number; label: string };
66
- type FormSheetProps = {
67
- open: boolean;
68
- onOpenChange: (open: boolean) => void;
69
- resource: string;
70
- resourceConfig: CatalogResourceDefinition;
71
- resourceTitle: string;
72
- resourceDescription: string;
73
- recordId: number | null;
74
- onSuccess: (record: CatalogRecord) => Promise<void> | void;
75
- };
76
-
77
- function toDateInputValue(value: unknown) {
78
- if (!value) return '';
79
- const date = new Date(String(value));
80
- if (Number.isNaN(date.getTime())) return '';
81
- return date.toISOString().slice(0, 10);
82
- }
83
-
84
- function toDateTimeInputValue(value: unknown) {
85
- if (!value) return '';
86
- const date = new Date(String(value));
87
- if (Number.isNaN(date.getTime())) return '';
88
- const offset = date.getTimezoneOffset();
89
- const local = new Date(date.getTime() - offset * 60 * 1000);
90
- return local.toISOString().slice(0, 16);
91
- }
92
-
93
- function buildFieldSchema(field: CatalogFormFieldDefinition) {
94
- switch (field.type) {
95
- case 'switch':
96
- return z.boolean().default(false);
97
- case 'number':
98
- case 'currency':
99
- case 'relation':
100
- case 'upload':
101
- return z.number().nullable().optional();
102
- case 'json':
103
- return z.string().superRefine((value, ctx) => {
104
- if (!value.trim()) return;
105
- try {
106
- JSON.parse(value);
107
- } catch {
108
- ctx.addIssue({
109
- code: z.ZodIssueCode.custom,
110
- message: 'JSON inválido',
111
- });
112
- }
113
- });
114
- case 'url':
115
- return z.string().superRefine((value, ctx) => {
116
- const normalized = value.trim();
117
- if (!normalized) return;
118
-
119
- try {
120
- new URL(normalized);
121
- } catch {
122
- ctx.addIssue({
123
- code: z.ZodIssueCode.custom,
124
- message: 'URL inválida',
125
- });
126
- }
127
- });
128
- default:
129
- return z.string();
130
- }
131
- }
132
-
133
- function buildFormSchema(resourceConfig: CatalogResourceDefinition) {
134
- const shape: Record<string, z.ZodTypeAny> = {};
135
-
136
- for (const section of resourceConfig.formSections) {
137
- for (const field of section.fields) {
138
- let schema = buildFieldSchema(field);
139
-
140
- if (field.required) {
141
- if (
142
- field.type === 'text' ||
143
- field.type === 'url' ||
144
- field.type === 'textarea' ||
145
- field.type === 'richtext' ||
146
- field.type === 'select' ||
147
- field.type === 'date' ||
148
- field.type === 'datetime' ||
149
- field.type === 'json'
150
- ) {
151
- schema = z.string().min(1, 'Campo obrigatório');
152
- }
153
-
154
- if (
155
- field.type === 'number' ||
156
- field.type === 'currency' ||
157
- field.type === 'relation' ||
158
- field.type === 'upload'
159
- ) {
160
- schema = z.number({
161
- required_error: 'Campo obrigatório',
162
- invalid_type_error: 'Campo obrigatório',
163
- });
164
- }
165
- }
166
-
167
- shape[field.key] = schema;
168
- }
169
- }
170
-
171
- return z.object(shape);
172
- }
173
-
174
- function serializeInitialValue(
175
- field: CatalogFormFieldDefinition,
176
- rawValue: unknown
177
- ): unknown {
178
- if (field.type === 'json') {
179
- if (rawValue === null || rawValue === undefined || rawValue === '') {
180
- return '{}';
181
- }
182
-
183
- return JSON.stringify(rawValue, null, 2);
184
- }
185
-
186
- if (field.type === 'switch') {
187
- return Boolean(rawValue);
188
- }
189
-
190
- if (field.type === 'currency' || field.type === 'number') {
191
- return rawValue === null || rawValue === undefined || rawValue === ''
192
- ? null
193
- : Number(rawValue);
194
- }
195
-
196
- if (field.type === 'relation' || field.type === 'upload') {
197
- return rawValue === null || rawValue === undefined || rawValue === ''
198
- ? null
199
- : Number(rawValue);
200
- }
201
-
202
- if (field.type === 'date') {
203
- return toDateInputValue(rawValue);
204
- }
205
-
206
- if (field.type === 'datetime') {
207
- return toDateTimeInputValue(rawValue);
208
- }
209
-
210
- return rawValue === null || rawValue === undefined ? '' : String(rawValue);
211
- }
212
-
213
- function buildDefaultValues(
214
- resourceConfig: CatalogResourceDefinition,
215
- payload: CatalogRecord
216
- ) {
217
- const defaults: CatalogFormValues = {};
218
-
219
- for (const section of resourceConfig.formSections) {
220
- for (const field of section.fields) {
221
- defaults[field.key] = serializeInitialValue(field, payload[field.key]);
222
- }
223
- }
224
-
225
- return defaults;
226
- }
227
-
228
- function normalizeSubmitValue(
229
- field: CatalogFormFieldDefinition,
230
- rawValue: unknown
231
- ) {
232
- if (field.type === 'json') {
233
- return String(rawValue || '').trim() ? JSON.parse(String(rawValue)) : {};
234
- }
235
-
236
- if (field.type === 'switch') {
237
- return Boolean(rawValue);
238
- }
239
-
240
- if (
241
- field.type === 'number' ||
242
- field.type === 'currency' ||
243
- field.type === 'relation' ||
244
- field.type === 'upload'
245
- ) {
246
- if (rawValue === null || rawValue === undefined || rawValue === '') {
247
- return null;
248
- }
249
-
250
- const parsed = Number(rawValue);
251
- return Number.isNaN(parsed) ? null : parsed;
252
- }
253
-
254
- if (
255
- field.type === 'text' ||
256
- field.type === 'url' ||
257
- field.type === 'textarea' ||
258
- field.type === 'richtext'
259
- ) {
260
- return String(rawValue || '').trim() || null;
261
- }
262
-
263
- if (field.type === 'date' || field.type === 'datetime' || field.type === 'select') {
264
- return String(rawValue || '').trim() || null;
265
- }
266
-
267
- return rawValue;
268
- }
269
-
270
- function extractRecordId(record: CatalogRecord) {
271
- return Number(record.id ?? record.category_id ?? record.content_id ?? 0);
272
- }
273
-
274
- function extractRelationLabel(
275
- item: CatalogRecord,
276
- labelKeys: string[]
277
- ) {
278
- for (const key of labelKeys) {
279
- const value = item[key];
280
-
281
- if (value !== undefined && value !== null && String(value).trim() !== '') {
282
- return String(value);
283
- }
284
- }
285
-
286
- return getCatalogRecordLabel(item);
287
- }
288
-
289
- function getLocalizedPlaceholder(
290
- field: CatalogFormFieldDefinition,
291
- localeCode?: string | null
292
- ) {
293
- if (field.placeholder) {
294
- return getCatalogLocalizedText(field.placeholder, localeCode);
295
- }
296
-
297
- const label = getCatalogLocalizedText(field.label, localeCode);
298
- return localeCode?.startsWith('pt')
299
- ? `Preencha ${label.toLowerCase()}`
300
- : `Enter ${label.toLowerCase()}`;
301
- }
302
-
303
- function CatalogRelationField({
304
- field,
305
- value,
306
- onChange,
307
- }: {
308
- field: CatalogFormFieldDefinition;
309
- value: unknown;
310
- onChange: (value: number | null) => void;
311
- }) {
312
- const { request, currentLocaleCode } = useApp();
313
- const [open, setOpen] = useState(false);
314
- const [search, setSearch] = useState('');
315
- const [createOpen, setCreateOpen] = useState(false);
316
- const [selectedLabel, setSelectedLabel] = useState('');
317
- const debounceRef = useRef<NodeJS.Timeout | null>(null);
318
- const childResource =
319
- field.relation?.createResource &&
320
- catalogResourceMap.get(field.relation.createResource);
321
-
322
- const [debouncedSearch, setDebouncedSearch] = useState('');
323
-
324
- useEffect(() => {
325
- if (debounceRef.current) {
326
- clearTimeout(debounceRef.current);
327
- }
328
-
329
- debounceRef.current = setTimeout(() => {
330
- setDebouncedSearch(search);
331
- }, 300);
332
-
333
- return () => {
334
- if (debounceRef.current) {
335
- clearTimeout(debounceRef.current);
336
- }
337
- };
338
- }, [search]);
339
-
340
- const { data: options = [], isLoading } = useQuery<RelationOption[]>({
341
- queryKey: [
342
- 'catalog-relation-options',
343
- field.key,
344
- field.relation?.endpoint,
345
- debouncedSearch,
346
- ],
347
- queryFn: async () => {
348
- if (!field.relation) return [];
349
-
350
- const params: Record<string, string | number> = {
351
- page: 1,
352
- pageSize: 20,
353
- };
354
-
355
- if (debouncedSearch.trim()) {
356
- params[field.relation.searchParam || 'search'] = debouncedSearch.trim();
357
- }
358
-
359
- const response = await request({
360
- url: field.relation.endpoint,
361
- method: 'GET',
362
- params,
363
- });
364
-
365
- const payload = response.data as
366
- | CatalogRecord[]
367
- | { data?: CatalogRecord[] }
368
- | undefined;
369
- const items = Array.isArray(payload)
370
- ? payload
371
- : Array.isArray(payload?.data)
372
- ? payload.data
373
- : [];
374
-
375
- return items.map((item) => ({
376
- id: Number(item[field.relation.valueKey || 'id']),
377
- label: extractRelationLabel(item, field.relation.labelKeys),
378
- }));
379
- },
380
- enabled: Boolean(field.relation),
381
- placeholderData: (previous) => previous ?? [],
382
- });
383
-
384
- const selectedOption = options.find((item) => item.id === Number(value));
385
- const currentLabel =
386
- selectedOption?.label ||
387
- selectedLabel ||
388
- (value ? `ID #${String(value)}` : '');
389
-
390
- return (
391
- <>
392
- <div className="flex w-full items-center gap-2">
393
- <Popover open={open} onOpenChange={setOpen}>
394
- <PopoverTrigger asChild>
395
- <Button
396
- type="button"
397
- variant="outline"
398
- className="h-9 flex-1 justify-between overflow-hidden"
399
- >
400
- <span className="truncate text-left">
401
- {currentLabel ||
402
- getCatalogLocalizedText(
403
- field.placeholder || field.label,
404
- currentLocaleCode
405
- )}
406
- </span>
407
- <ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
408
- </Button>
409
- </PopoverTrigger>
410
- <PopoverContent
411
- className="w-[var(--radix-popover-trigger-width)] p-0"
412
- align="start"
413
- >
414
- <Command shouldFilter={false}>
415
- <CommandInput
416
- placeholder={
417
- currentLocaleCode?.startsWith('pt')
418
- ? 'Buscar registro...'
419
- : 'Search record...'
420
- }
421
- value={search}
422
- onValueChange={setSearch}
423
- />
424
- <CommandList>
425
- <CommandEmpty>
426
- {isLoading
427
- ? currentLocaleCode?.startsWith('pt')
428
- ? 'Carregando...'
429
- : 'Loading...'
430
- : currentLocaleCode?.startsWith('pt')
431
- ? 'Nenhum resultado.'
432
- : 'No results.'}
433
- </CommandEmpty>
434
- <CommandGroup>
435
- {options.map((option) => (
436
- <CommandItem
437
- key={option.id}
438
- value={`${option.label}-${option.id}`}
439
- onSelect={() => {
440
- onChange(option.id);
441
- setSelectedLabel(option.label);
442
- setOpen(false);
443
- }}
444
- >
445
- {option.label}
446
- </CommandItem>
447
- ))}
448
- </CommandGroup>
449
- </CommandList>
450
- </Command>
451
- </PopoverContent>
452
- </Popover>
453
-
454
- {value ? (
455
- <Button
456
- type="button"
457
- variant="outline"
458
- size="icon"
459
- onClick={() => {
460
- onChange(null);
461
- setSelectedLabel('');
462
- }}
463
- >
464
- <X className="h-4 w-4" />
465
- </Button>
466
- ) : null}
467
-
468
- {field.relation?.allowCreate && childResource ? (
469
- <Button
470
- type="button"
471
- variant="outline"
472
- size="icon"
473
- onClick={() => setCreateOpen(true)}
474
- >
475
- <Plus className="h-4 w-4" />
476
- </Button>
477
- ) : null}
478
- </div>
479
-
480
- {childResource ? (
481
- <CatalogResourceFormSheet
482
- open={createOpen}
483
- onOpenChange={setCreateOpen}
484
- resource={childResource.resource}
485
- resourceConfig={childResource}
486
- resourceTitle={childResource.singularLabel.pt}
487
- resourceDescription=""
488
- recordId={null}
489
- onSuccess={(created) => {
490
- const createdId = extractRecordId(created);
491
- if (createdId) {
492
- onChange(createdId);
493
- }
494
-
495
- setSelectedLabel(getCatalogRecordLabel(created));
496
- }}
497
- />
498
- ) : null}
499
- </>
500
- );
501
- }
502
-
503
- function CatalogUploadField({
504
- field,
505
- value,
506
- onChange,
507
- }: {
508
- field: CatalogFormFieldDefinition;
509
- value: unknown;
510
- onChange: (value: number | null) => void;
511
- }) {
512
- const { request } = useApp();
513
- const [isUploading, setIsUploading] = useState(false);
514
- const [progress, setProgress] = useState(0);
515
- const [previewUrl, setPreviewUrl] = useState('/placeholder.png');
516
- const inputRef = useRef<HTMLInputElement>(null);
517
-
518
- useEffect(() => {
519
- const loadPreview = async () => {
520
- if (!value || Number(value) <= 0) {
521
- setPreviewUrl('/placeholder.png');
522
- return;
523
- }
524
-
525
- try {
526
- const response = await request<{ url?: string }>({
527
- url: `/file/open/${Number(value)}`,
528
- method: 'PUT',
529
- });
530
- const nextUrl = String(response.data?.url || '').trim();
531
- setPreviewUrl(
532
- /^https?:\/\//i.test(nextUrl)
533
- ? nextUrl
534
- : `${String(process.env.NEXT_PUBLIC_API_BASE_URL || '')}${nextUrl}`
535
- );
536
- } catch {
537
- setPreviewUrl('/placeholder.png');
538
- }
539
- };
540
-
541
- void loadPreview();
542
- }, [request, value]);
543
-
544
- const handleUpload = async (file: File) => {
545
- setIsUploading(true);
546
- setProgress(0);
547
-
548
- try {
549
- const formData = new FormData();
550
- formData.append('file', file);
551
- formData.append('destination', field.uploadDestination || 'catalog/file');
552
-
553
- const response = await request<{ id?: number }>({
554
- url: '/file',
555
- method: 'POST',
556
- data: formData,
557
- headers: {
558
- 'Content-Type': 'multipart/form-data',
559
- },
560
- onUploadProgress: (event) => {
561
- if (!event.total) return;
562
- setProgress(Math.round((event.loaded * 100) / event.total));
563
- },
564
- });
565
-
566
- onChange(Number(response.data?.id || 0) || null);
567
- setProgress(100);
568
- } catch (error) {
569
- toast.error(error instanceof Error ? error.message : 'Falha no upload');
570
- setProgress(0);
571
- } finally {
572
- setIsUploading(false);
573
- if (inputRef.current) {
574
- inputRef.current.value = '';
575
- }
576
- }
577
- };
578
-
579
- const handleRemove = async () => {
580
- if (value) {
581
- try {
582
- await request({
583
- url: '/file',
584
- method: 'DELETE',
585
- data: { ids: [Number(value)] },
586
- });
587
- } catch {
588
- // Ignore cleanup errors to keep the form stable.
589
- }
590
- }
591
-
592
- onChange(null);
593
- setProgress(0);
594
- };
595
-
596
- return (
597
- <div className="w-full space-y-3">
598
- <input
599
- ref={inputRef}
600
- type="file"
601
- accept={field.accept}
602
- className="hidden"
603
- onChange={(event) => {
604
- const file = event.target.files?.[0];
605
- if (file) {
606
- void handleUpload(file);
607
- }
608
- }}
609
- />
610
-
611
- <div className="flex items-start gap-3">
612
- <div className="relative size-24 shrink-0 overflow-hidden rounded-md border bg-muted/20">
613
- <Image
614
- src={previewUrl}
615
- alt={String(value ? 'Preview do arquivo' : 'Placeholder de upload')}
616
- fill
617
- unoptimized
618
- className="object-cover"
619
- />
620
- </div>
621
-
622
- <div className="flex min-w-0 flex-1 flex-col gap-2">
623
- <div className="flex flex-wrap items-center gap-2">
624
- <Button
625
- type="button"
626
- variant="outline"
627
- onClick={() => inputRef.current?.click()}
628
- disabled={isUploading}
629
- >
630
- <Upload className="mr-2 h-4 w-4" />
631
- {value ? 'Trocar arquivo' : 'Enviar arquivo'}
632
- </Button>
633
-
634
- {value ? (
635
- <Button
636
- type="button"
637
- variant="ghost"
638
- onClick={() => void handleRemove()}
639
- disabled={isUploading}
640
- >
641
- Remover
642
- </Button>
643
- ) : null}
644
- </div>
645
-
646
- <p className="text-xs text-muted-foreground">
647
- {value
648
- ? `Arquivo vinculado: #${String(value)}`
649
- : 'Nenhuma imagem enviada. O placeholder será usado até o upload.'}
650
- </p>
651
- </div>
652
- </div>
653
-
654
- {isUploading ? <Progress value={progress} /> : null}
655
- </div>
656
- );
657
- }
658
-
659
- export function CatalogResourceFormSheet({
660
- open,
661
- onOpenChange,
662
- resource,
663
- resourceConfig,
664
- resourceTitle,
665
- resourceDescription,
666
- recordId,
667
- onSuccess,
668
- }: FormSheetProps) {
669
- const { request, currentLocaleCode } = useApp();
670
- const t = useTranslations('catalog');
671
- const isEditing = Boolean(recordId);
672
- const formSchema = useMemo(
673
- () => buildFormSchema(resourceConfig),
674
- [resourceConfig]
675
- );
676
-
677
- const form = useForm<CatalogFormValues>({
678
- resolver: zodResolver(formSchema),
679
- defaultValues: buildDefaultValues(resourceConfig, resourceConfig.template),
680
- });
681
-
682
- const { data: currentRecord, isLoading } = useQuery<CatalogRecord>({
683
- queryKey: ['catalog-record-details', resource, recordId],
684
- queryFn: async () => {
685
- const response = await request({
686
- url: `/catalog/${resource}/${recordId}`,
687
- method: 'GET',
688
- });
689
-
690
- return response.data as CatalogRecord;
691
- },
692
- enabled: open && Boolean(recordId),
693
- });
694
-
695
- useEffect(() => {
696
- if (!open) {
697
- return;
698
- }
699
-
700
- const payload = currentRecord || resourceConfig.template;
701
- form.reset(buildDefaultValues(resourceConfig, payload));
702
- }, [currentRecord, form, open, resourceConfig]);
703
-
704
- const submitLabel = isEditing
705
- ? getCatalogLocalizedText(resourceConfig.editActionLabel, currentLocaleCode)
706
- : getCatalogLocalizedText(resourceConfig.createActionLabel, currentLocaleCode);
707
-
708
- const handleSubmit = async (values: CatalogFormValues) => {
709
- try {
710
- const payload: CatalogRecord = {};
711
-
712
- for (const section of resourceConfig.formSections) {
713
- for (const field of section.fields) {
714
- payload[field.key] = normalizeSubmitValue(field, values[field.key]);
715
- }
716
- }
717
-
718
- const response = await request({
719
- url: isEditing
720
- ? `/catalog/${resource}/${recordId}`
721
- : `/catalog/${resource}`,
722
- method: isEditing ? 'PATCH' : 'POST',
723
- data: payload,
724
- });
725
-
726
- const savedRecord = (response.data || payload) as CatalogRecord;
727
- toast.success(t('toasts.saveSuccess', { resource: resourceTitle }));
728
- onOpenChange(false);
729
- await onSuccess(savedRecord);
730
- } catch (error) {
731
- toast.error(
732
- error instanceof Error ? error.message : t('toasts.saveError')
733
- );
734
- }
735
- };
736
-
737
- return (
738
- <Sheet open={open} onOpenChange={onOpenChange}>
739
- <SheetContent className="w-full overflow-x-hidden overflow-y-auto sm:max-w-2xl">
740
- <SheetHeader>
741
- <SheetTitle>{submitLabel}</SheetTitle>
742
- <SheetDescription>{resourceDescription}</SheetDescription>
743
- </SheetHeader>
744
-
745
- {isEditing && isLoading ? (
746
- <div className="flex items-center gap-2 p-4 text-sm text-muted-foreground">
747
- <Loader2 className="h-4 w-4 animate-spin" />
748
- Carregando dados do registro...
749
- </div>
750
- ) : (
751
- <Form {...form}>
752
- <form
753
- id={`catalog-form-${resource}`}
754
- className="min-w-0 space-y-6 px-4 pb-4"
755
- onSubmit={form.handleSubmit(handleSubmit)}
756
- >
757
- {resourceConfig.formSections.map((section) => (
758
- <section
759
- key={section.title.en}
760
- className="min-w-0 max-w-full space-y-4"
761
- >
762
- <div>
763
- <h3 className="text-sm font-semibold">
764
- {getCatalogLocalizedText(section.title, currentLocaleCode)}
765
- </h3>
766
- {section.description ? (
767
- <p className="text-sm text-muted-foreground">
768
- {getCatalogLocalizedText(
769
- section.description,
770
- currentLocaleCode
771
- )}
772
- </p>
773
- ) : null}
774
- </div>
775
-
776
- <div className="grid min-w-0 grid-cols-1 gap-4 sm:grid-cols-2">
777
- {section.fields.map((field) => (
778
- <div
779
- key={field.key}
780
- className={`min-w-0 ${
781
- field.span === 2 ? 'sm:col-span-2' : ''
782
- }`}
783
- >
784
- <FormField
785
- control={form.control}
786
- name={field.key}
787
- render={({ field: formField }) => (
788
- <FormItem>
789
- <FormLabel>
790
- {getCatalogLocalizedText(
791
- field.label,
792
- currentLocaleCode
793
- )}
794
- </FormLabel>
795
- <FormControl>
796
- {field.type === 'text' ||
797
- field.type === 'url' ||
798
- field.type === 'date' ||
799
- field.type === 'datetime' ? (
800
- <Input
801
- type={
802
- field.type === 'url'
803
- ? 'url'
804
- : field.type === 'date'
805
- ? 'date'
806
- : field.type === 'datetime'
807
- ? 'datetime-local'
808
- : 'text'
809
- }
810
- placeholder={getLocalizedPlaceholder(
811
- field,
812
- currentLocaleCode
813
- )}
814
- className="w-full"
815
- value={String(formField.value ?? '')}
816
- onChange={formField.onChange}
817
- />
818
- ) : field.type === 'textarea' ||
819
- field.type === 'json' ? (
820
- <Textarea
821
- placeholder={getLocalizedPlaceholder(
822
- field,
823
- currentLocaleCode
824
- )}
825
- value={String(formField.value ?? '')}
826
- onChange={formField.onChange}
827
- className={
828
- field.type === 'json'
829
- ? 'min-h-40 w-full font-mono text-xs'
830
- : 'min-h-28 w-full'
831
- }
832
- />
833
- ) : field.type === 'richtext' ? (
834
- <div className="min-w-0 w-full max-w-full overflow-x-hidden space-y-2">
835
- {!String(formField.value ?? '').trim() ? (
836
- <p className="text-xs text-muted-foreground">
837
- {getLocalizedPlaceholder(
838
- field,
839
- currentLocaleCode
840
- )}
841
- </p>
842
- ) : null}
843
- <RichTextEditor
844
- value={String(formField.value ?? '')}
845
- onChange={formField.onChange}
846
- className="min-w-0 w-full max-w-full"
847
- />
848
- </div>
849
- ) : field.type === 'number' ? (
850
- <Input
851
- type="number"
852
- className={
853
- 'w-full'
854
- }
855
- placeholder={getLocalizedPlaceholder(
856
- field,
857
- currentLocaleCode
858
- )}
859
- value={
860
- formField.value === null ||
861
- formField.value === undefined
862
- ? ''
863
- : String(formField.value)
864
- }
865
- onChange={(event) =>
866
- formField.onChange(
867
- event.target.value === ''
868
- ? null
869
- : Number(event.target.value)
870
- )
871
- }
872
- />
873
- ) : field.type === 'currency' ? (
874
- <InputMoney
875
- className="w-full"
876
- value={
877
- typeof formField.value === 'number'
878
- ? formField.value
879
- : undefined
880
- }
881
- onValueChange={formField.onChange}
882
- />
883
- ) : field.type === 'switch' ? (
884
- <div className="flex h-9 w-full items-center">
885
- <Switch
886
- checked={Boolean(formField.value)}
887
- onCheckedChange={formField.onChange}
888
- />
889
- </div>
890
- ) : field.type === 'select' ? (
891
- <Select
892
- value={String(formField.value ?? '')}
893
- onValueChange={formField.onChange}
894
- >
895
- <SelectTrigger className="w-full">
896
- <SelectValue
897
- placeholder={getLocalizedPlaceholder(
898
- field,
899
- currentLocaleCode
900
- )}
901
- />
902
- </SelectTrigger>
903
- <SelectContent>
904
- {field.options?.map((option) => (
905
- <SelectItem
906
- key={option.value}
907
- value={option.value}
908
- >
909
- {getCatalogLocalizedText(
910
- option.label,
911
- currentLocaleCode
912
- )}
913
- </SelectItem>
914
- ))}
915
- </SelectContent>
916
- </Select>
917
- ) : field.type === 'relation' ? (
918
- <CatalogRelationField
919
- field={field}
920
- value={formField.value}
921
- onChange={formField.onChange}
922
- />
923
- ) : field.type === 'upload' ? (
924
- <CatalogUploadField
925
- field={field}
926
- value={formField.value}
927
- onChange={formField.onChange}
928
- />
929
- ) : null}
930
- </FormControl>
931
- <FormMessage />
932
- </FormItem>
933
- )}
934
- />
935
- </div>
936
- ))}
937
- </div>
938
- </section>
939
- ))}
940
- </form>
941
- </Form>
942
- )}
943
-
944
- <SheetFooter className="border-t">
945
- <div className="w-full">
946
- <Button
947
- type="submit"
948
- form={`catalog-form-${resource}`}
949
- className="w-full"
950
- disabled={form.formState.isSubmitting || isLoading}
951
- >
952
- {form.formState.isSubmitting ? (
953
- <Loader2 className="mr-2 h-4 w-4 animate-spin" />
954
- ) : (
955
- <Save className="mr-2 h-4 w-4" />
956
- )}
957
- {isEditing ? 'Salvar alterações' : 'Salvar'}
958
- </Button>
959
- </div>
960
- </SheetFooter>
961
- </SheetContent>
962
- </Sheet>
963
- );
964
- }
1
+ 'use client';
2
+
3
+ import {
4
+ catalogResourceMap,
5
+ getCatalogLocalizedText,
6
+ getCatalogRecordLabel,
7
+ type CatalogFormFieldDefinition,
8
+ type CatalogResourceDefinition,
9
+ } from '../_lib/catalog-resources';
10
+ import { RichTextEditor } from '@/components/rich-text-editor';
11
+ import { Button } from '@/components/ui/button';
12
+ import {
13
+ Command,
14
+ CommandEmpty,
15
+ CommandGroup,
16
+ CommandInput,
17
+ CommandItem,
18
+ CommandList,
19
+ } from '@/components/ui/command';
20
+ import {
21
+ Form,
22
+ FormControl,
23
+ FormField,
24
+ FormItem,
25
+ FormLabel,
26
+ FormMessage,
27
+ } from '@/components/ui/form';
28
+ import { Input } from '@/components/ui/input';
29
+ import { InputMoney } from '@/components/ui/input-money';
30
+ import {
31
+ Popover,
32
+ PopoverContent,
33
+ PopoverTrigger,
34
+ } from '@/components/ui/popover';
35
+ import { Progress } from '@/components/ui/progress';
36
+ import {
37
+ Select,
38
+ SelectContent,
39
+ SelectItem,
40
+ SelectTrigger,
41
+ SelectValue,
42
+ } from '@/components/ui/select';
43
+ import {
44
+ Sheet,
45
+ SheetContent,
46
+ SheetDescription,
47
+ SheetFooter,
48
+ SheetHeader,
49
+ SheetTitle,
50
+ } from '@/components/ui/sheet';
51
+ import { Switch } from '@/components/ui/switch';
52
+ import { Textarea } from '@/components/ui/textarea';
53
+ import { zodResolver } from '@hookform/resolvers/zod';
54
+ import { useApp, useQuery } from '@hed-hog/next-app-provider';
55
+ import { ChevronsUpDown, Loader2, Plus, Save, Upload, X } from 'lucide-react';
56
+ import Image from 'next/image';
57
+ import { useTranslations } from 'next-intl';
58
+ import { useEffect, useMemo, useRef, useState } from 'react';
59
+ import { useForm } from 'react-hook-form';
60
+ import { toast } from 'sonner';
61
+ import { z } from 'zod';
62
+
63
+ type CatalogRecord = Record<string, unknown>;
64
+ type CatalogFormValues = Record<string, unknown>;
65
+ type RelationOption = { id: number; label: string };
66
+ type FormSheetProps = {
67
+ open: boolean;
68
+ onOpenChange: (open: boolean) => void;
69
+ resource: string;
70
+ resourceConfig: CatalogResourceDefinition;
71
+ resourceTitle: string;
72
+ resourceDescription: string;
73
+ recordId: number | null;
74
+ onSuccess: (record: CatalogRecord) => Promise<void> | void;
75
+ };
76
+
77
+ function toDateInputValue(value: unknown) {
78
+ if (!value) return '';
79
+ const date = new Date(String(value));
80
+ if (Number.isNaN(date.getTime())) return '';
81
+ return date.toISOString().slice(0, 10);
82
+ }
83
+
84
+ function toDateTimeInputValue(value: unknown) {
85
+ if (!value) return '';
86
+ const date = new Date(String(value));
87
+ if (Number.isNaN(date.getTime())) return '';
88
+ const offset = date.getTimezoneOffset();
89
+ const local = new Date(date.getTime() - offset * 60 * 1000);
90
+ return local.toISOString().slice(0, 16);
91
+ }
92
+
93
+ function buildFieldSchema(field: CatalogFormFieldDefinition) {
94
+ switch (field.type) {
95
+ case 'switch':
96
+ return z.boolean().default(false);
97
+ case 'number':
98
+ case 'currency':
99
+ case 'relation':
100
+ case 'upload':
101
+ return z.number().nullable().optional();
102
+ case 'json':
103
+ return z.string().superRefine((value, ctx) => {
104
+ if (!value.trim()) return;
105
+ try {
106
+ JSON.parse(value);
107
+ } catch {
108
+ ctx.addIssue({
109
+ code: z.ZodIssueCode.custom,
110
+ message: 'JSON inválido',
111
+ });
112
+ }
113
+ });
114
+ case 'url':
115
+ return z.string().superRefine((value, ctx) => {
116
+ const normalized = value.trim();
117
+ if (!normalized) return;
118
+
119
+ try {
120
+ new URL(normalized);
121
+ } catch {
122
+ ctx.addIssue({
123
+ code: z.ZodIssueCode.custom,
124
+ message: 'URL inválida',
125
+ });
126
+ }
127
+ });
128
+ default:
129
+ return z.string();
130
+ }
131
+ }
132
+
133
+ function buildFormSchema(resourceConfig: CatalogResourceDefinition) {
134
+ const shape: Record<string, z.ZodTypeAny> = {};
135
+
136
+ for (const section of resourceConfig.formSections) {
137
+ for (const field of section.fields) {
138
+ let schema = buildFieldSchema(field);
139
+
140
+ if (field.required) {
141
+ if (
142
+ field.type === 'text' ||
143
+ field.type === 'url' ||
144
+ field.type === 'textarea' ||
145
+ field.type === 'richtext' ||
146
+ field.type === 'select' ||
147
+ field.type === 'date' ||
148
+ field.type === 'datetime' ||
149
+ field.type === 'json'
150
+ ) {
151
+ schema = z.string().min(1, 'Campo obrigatório');
152
+ }
153
+
154
+ if (
155
+ field.type === 'number' ||
156
+ field.type === 'currency' ||
157
+ field.type === 'relation' ||
158
+ field.type === 'upload'
159
+ ) {
160
+ schema = z.number({
161
+ required_error: 'Campo obrigatório',
162
+ invalid_type_error: 'Campo obrigatório',
163
+ });
164
+ }
165
+ }
166
+
167
+ shape[field.key] = schema;
168
+ }
169
+ }
170
+
171
+ return z.object(shape);
172
+ }
173
+
174
+ function serializeInitialValue(
175
+ field: CatalogFormFieldDefinition,
176
+ rawValue: unknown
177
+ ): unknown {
178
+ if (field.type === 'json') {
179
+ if (rawValue === null || rawValue === undefined || rawValue === '') {
180
+ return '{}';
181
+ }
182
+
183
+ return JSON.stringify(rawValue, null, 2);
184
+ }
185
+
186
+ if (field.type === 'switch') {
187
+ return Boolean(rawValue);
188
+ }
189
+
190
+ if (field.type === 'currency' || field.type === 'number') {
191
+ return rawValue === null || rawValue === undefined || rawValue === ''
192
+ ? null
193
+ : Number(rawValue);
194
+ }
195
+
196
+ if (field.type === 'relation' || field.type === 'upload') {
197
+ return rawValue === null || rawValue === undefined || rawValue === ''
198
+ ? null
199
+ : Number(rawValue);
200
+ }
201
+
202
+ if (field.type === 'date') {
203
+ return toDateInputValue(rawValue);
204
+ }
205
+
206
+ if (field.type === 'datetime') {
207
+ return toDateTimeInputValue(rawValue);
208
+ }
209
+
210
+ return rawValue === null || rawValue === undefined ? '' : String(rawValue);
211
+ }
212
+
213
+ function buildDefaultValues(
214
+ resourceConfig: CatalogResourceDefinition,
215
+ payload: CatalogRecord
216
+ ) {
217
+ const defaults: CatalogFormValues = {};
218
+
219
+ for (const section of resourceConfig.formSections) {
220
+ for (const field of section.fields) {
221
+ defaults[field.key] = serializeInitialValue(field, payload[field.key]);
222
+ }
223
+ }
224
+
225
+ return defaults;
226
+ }
227
+
228
+ function normalizeSubmitValue(
229
+ field: CatalogFormFieldDefinition,
230
+ rawValue: unknown
231
+ ) {
232
+ if (field.type === 'json') {
233
+ return String(rawValue || '').trim() ? JSON.parse(String(rawValue)) : {};
234
+ }
235
+
236
+ if (field.type === 'switch') {
237
+ return Boolean(rawValue);
238
+ }
239
+
240
+ if (
241
+ field.type === 'number' ||
242
+ field.type === 'currency' ||
243
+ field.type === 'relation' ||
244
+ field.type === 'upload'
245
+ ) {
246
+ if (rawValue === null || rawValue === undefined || rawValue === '') {
247
+ return null;
248
+ }
249
+
250
+ const parsed = Number(rawValue);
251
+ return Number.isNaN(parsed) ? null : parsed;
252
+ }
253
+
254
+ if (
255
+ field.type === 'text' ||
256
+ field.type === 'url' ||
257
+ field.type === 'textarea' ||
258
+ field.type === 'richtext'
259
+ ) {
260
+ return String(rawValue || '').trim() || null;
261
+ }
262
+
263
+ if (field.type === 'date' || field.type === 'datetime' || field.type === 'select') {
264
+ return String(rawValue || '').trim() || null;
265
+ }
266
+
267
+ return rawValue;
268
+ }
269
+
270
+ function extractRecordId(record: CatalogRecord) {
271
+ return Number(record.id ?? record.category_id ?? record.content_id ?? 0);
272
+ }
273
+
274
+ function extractRelationLabel(
275
+ item: CatalogRecord,
276
+ labelKeys: string[]
277
+ ) {
278
+ for (const key of labelKeys) {
279
+ const value = item[key];
280
+
281
+ if (value !== undefined && value !== null && String(value).trim() !== '') {
282
+ return String(value);
283
+ }
284
+ }
285
+
286
+ return getCatalogRecordLabel(item);
287
+ }
288
+
289
+ function getLocalizedPlaceholder(
290
+ field: CatalogFormFieldDefinition,
291
+ localeCode?: string | null
292
+ ) {
293
+ if (field.placeholder) {
294
+ return getCatalogLocalizedText(field.placeholder, localeCode);
295
+ }
296
+
297
+ const label = getCatalogLocalizedText(field.label, localeCode);
298
+ return localeCode?.startsWith('pt')
299
+ ? `Preencha ${label.toLowerCase()}`
300
+ : `Enter ${label.toLowerCase()}`;
301
+ }
302
+
303
+ function CatalogRelationField({
304
+ field,
305
+ value,
306
+ onChange,
307
+ }: {
308
+ field: CatalogFormFieldDefinition;
309
+ value: unknown;
310
+ onChange: (value: number | null) => void;
311
+ }) {
312
+ const { request, currentLocaleCode } = useApp();
313
+ const [open, setOpen] = useState(false);
314
+ const [search, setSearch] = useState('');
315
+ const [createOpen, setCreateOpen] = useState(false);
316
+ const [selectedLabel, setSelectedLabel] = useState('');
317
+ const debounceRef = useRef<NodeJS.Timeout | null>(null);
318
+ const childResource =
319
+ field.relation?.createResource &&
320
+ catalogResourceMap.get(field.relation.createResource);
321
+
322
+ const [debouncedSearch, setDebouncedSearch] = useState('');
323
+
324
+ useEffect(() => {
325
+ if (debounceRef.current) {
326
+ clearTimeout(debounceRef.current);
327
+ }
328
+
329
+ debounceRef.current = setTimeout(() => {
330
+ setDebouncedSearch(search);
331
+ }, 300);
332
+
333
+ return () => {
334
+ if (debounceRef.current) {
335
+ clearTimeout(debounceRef.current);
336
+ }
337
+ };
338
+ }, [search]);
339
+
340
+ const { data: options = [], isLoading } = useQuery<RelationOption[]>({
341
+ queryKey: [
342
+ 'catalog-relation-options',
343
+ field.key,
344
+ field.relation?.endpoint,
345
+ debouncedSearch,
346
+ ],
347
+ queryFn: async () => {
348
+ if (!field.relation) return [];
349
+
350
+ const params: Record<string, string | number> = {
351
+ page: 1,
352
+ pageSize: 20,
353
+ };
354
+
355
+ if (debouncedSearch.trim()) {
356
+ params[field.relation.searchParam || 'search'] = debouncedSearch.trim();
357
+ }
358
+
359
+ const response = await request({
360
+ url: field.relation.endpoint,
361
+ method: 'GET',
362
+ params,
363
+ });
364
+
365
+ const payload = response.data as
366
+ | CatalogRecord[]
367
+ | { data?: CatalogRecord[] }
368
+ | undefined;
369
+ const items = Array.isArray(payload)
370
+ ? payload
371
+ : Array.isArray(payload?.data)
372
+ ? payload.data
373
+ : [];
374
+
375
+ return items.map((item) => ({
376
+ id: Number(item[field.relation.valueKey || 'id']),
377
+ label: extractRelationLabel(item, field.relation.labelKeys),
378
+ }));
379
+ },
380
+ enabled: Boolean(field.relation),
381
+ placeholderData: (previous) => previous ?? [],
382
+ });
383
+
384
+ const selectedOption = options.find((item) => item.id === Number(value));
385
+ const currentLabel =
386
+ selectedOption?.label ||
387
+ selectedLabel ||
388
+ (value ? `ID #${String(value)}` : '');
389
+
390
+ return (
391
+ <>
392
+ <div className="flex w-full items-center gap-2">
393
+ <Popover open={open} onOpenChange={setOpen}>
394
+ <PopoverTrigger asChild>
395
+ <Button
396
+ type="button"
397
+ variant="outline"
398
+ className="h-9 flex-1 justify-between overflow-hidden"
399
+ >
400
+ <span className="truncate text-left">
401
+ {currentLabel ||
402
+ getCatalogLocalizedText(
403
+ field.placeholder || field.label,
404
+ currentLocaleCode
405
+ )}
406
+ </span>
407
+ <ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
408
+ </Button>
409
+ </PopoverTrigger>
410
+ <PopoverContent
411
+ className="w-[var(--radix-popover-trigger-width)] p-0"
412
+ align="start"
413
+ >
414
+ <Command shouldFilter={false}>
415
+ <CommandInput
416
+ placeholder={
417
+ currentLocaleCode?.startsWith('pt')
418
+ ? 'Buscar registro...'
419
+ : 'Search record...'
420
+ }
421
+ value={search}
422
+ onValueChange={setSearch}
423
+ />
424
+ <CommandList>
425
+ <CommandEmpty>
426
+ {isLoading
427
+ ? currentLocaleCode?.startsWith('pt')
428
+ ? 'Carregando...'
429
+ : 'Loading...'
430
+ : currentLocaleCode?.startsWith('pt')
431
+ ? 'Nenhum resultado.'
432
+ : 'No results.'}
433
+ </CommandEmpty>
434
+ <CommandGroup>
435
+ {options.map((option) => (
436
+ <CommandItem
437
+ key={option.id}
438
+ value={`${option.label}-${option.id}`}
439
+ onSelect={() => {
440
+ onChange(option.id);
441
+ setSelectedLabel(option.label);
442
+ setOpen(false);
443
+ }}
444
+ >
445
+ {option.label}
446
+ </CommandItem>
447
+ ))}
448
+ </CommandGroup>
449
+ </CommandList>
450
+ </Command>
451
+ </PopoverContent>
452
+ </Popover>
453
+
454
+ {value ? (
455
+ <Button
456
+ type="button"
457
+ variant="outline"
458
+ size="icon"
459
+ onClick={() => {
460
+ onChange(null);
461
+ setSelectedLabel('');
462
+ }}
463
+ >
464
+ <X className="h-4 w-4" />
465
+ </Button>
466
+ ) : null}
467
+
468
+ {field.relation?.allowCreate && childResource ? (
469
+ <Button
470
+ type="button"
471
+ variant="outline"
472
+ size="icon"
473
+ onClick={() => setCreateOpen(true)}
474
+ >
475
+ <Plus className="h-4 w-4" />
476
+ </Button>
477
+ ) : null}
478
+ </div>
479
+
480
+ {childResource ? (
481
+ <CatalogResourceFormSheet
482
+ open={createOpen}
483
+ onOpenChange={setCreateOpen}
484
+ resource={childResource.resource}
485
+ resourceConfig={childResource}
486
+ resourceTitle={childResource.singularLabel.pt}
487
+ resourceDescription=""
488
+ recordId={null}
489
+ onSuccess={(created) => {
490
+ const createdId = extractRecordId(created);
491
+ if (createdId) {
492
+ onChange(createdId);
493
+ }
494
+
495
+ setSelectedLabel(getCatalogRecordLabel(created));
496
+ }}
497
+ />
498
+ ) : null}
499
+ </>
500
+ );
501
+ }
502
+
503
+ function CatalogUploadField({
504
+ field,
505
+ value,
506
+ onChange,
507
+ }: {
508
+ field: CatalogFormFieldDefinition;
509
+ value: unknown;
510
+ onChange: (value: number | null) => void;
511
+ }) {
512
+ const { request } = useApp();
513
+ const [isUploading, setIsUploading] = useState(false);
514
+ const [progress, setProgress] = useState(0);
515
+ const [previewUrl, setPreviewUrl] = useState('/placeholder.png');
516
+ const inputRef = useRef<HTMLInputElement>(null);
517
+
518
+ useEffect(() => {
519
+ const loadPreview = async () => {
520
+ if (!value || Number(value) <= 0) {
521
+ setPreviewUrl('/placeholder.png');
522
+ return;
523
+ }
524
+
525
+ try {
526
+ const response = await request<{ url?: string }>({
527
+ url: `/file/open/${Number(value)}`,
528
+ method: 'PUT',
529
+ });
530
+ const nextUrl = String(response.data?.url || '').trim();
531
+ setPreviewUrl(
532
+ /^https?:\/\//i.test(nextUrl)
533
+ ? nextUrl
534
+ : `${String(process.env.NEXT_PUBLIC_API_BASE_URL || '')}${nextUrl}`
535
+ );
536
+ } catch {
537
+ setPreviewUrl('/placeholder.png');
538
+ }
539
+ };
540
+
541
+ void loadPreview();
542
+ }, [request, value]);
543
+
544
+ const handleUpload = async (file: File) => {
545
+ setIsUploading(true);
546
+ setProgress(0);
547
+
548
+ try {
549
+ const formData = new FormData();
550
+ formData.append('file', file);
551
+ formData.append('destination', field.uploadDestination || 'catalog/file');
552
+
553
+ const response = await request<{ id?: number }>({
554
+ url: '/file',
555
+ method: 'POST',
556
+ data: formData,
557
+ headers: {
558
+ 'Content-Type': 'multipart/form-data',
559
+ },
560
+ onUploadProgress: (event) => {
561
+ if (!event.total) return;
562
+ setProgress(Math.round((event.loaded * 100) / event.total));
563
+ },
564
+ });
565
+
566
+ onChange(Number(response.data?.id || 0) || null);
567
+ setProgress(100);
568
+ } catch (error) {
569
+ toast.error(error instanceof Error ? error.message : 'Falha no upload');
570
+ setProgress(0);
571
+ } finally {
572
+ setIsUploading(false);
573
+ if (inputRef.current) {
574
+ inputRef.current.value = '';
575
+ }
576
+ }
577
+ };
578
+
579
+ const handleRemove = async () => {
580
+ if (value) {
581
+ try {
582
+ await request({
583
+ url: '/file',
584
+ method: 'DELETE',
585
+ data: { ids: [Number(value)] },
586
+ });
587
+ } catch {
588
+ // Ignore cleanup errors to keep the form stable.
589
+ }
590
+ }
591
+
592
+ onChange(null);
593
+ setProgress(0);
594
+ };
595
+
596
+ return (
597
+ <div className="w-full space-y-3">
598
+ <input
599
+ ref={inputRef}
600
+ type="file"
601
+ accept={field.accept}
602
+ className="hidden"
603
+ onChange={(event) => {
604
+ const file = event.target.files?.[0];
605
+ if (file) {
606
+ void handleUpload(file);
607
+ }
608
+ }}
609
+ />
610
+
611
+ <div className="flex items-start gap-3">
612
+ <div className="relative size-24 shrink-0 overflow-hidden rounded-md border bg-muted/20">
613
+ <Image
614
+ src={previewUrl}
615
+ alt={String(value ? 'Preview do arquivo' : 'Placeholder de upload')}
616
+ fill
617
+ unoptimized
618
+ className="object-cover"
619
+ />
620
+ </div>
621
+
622
+ <div className="flex min-w-0 flex-1 flex-col gap-2">
623
+ <div className="flex flex-wrap items-center gap-2">
624
+ <Button
625
+ type="button"
626
+ variant="outline"
627
+ onClick={() => inputRef.current?.click()}
628
+ disabled={isUploading}
629
+ >
630
+ <Upload className="mr-2 h-4 w-4" />
631
+ {value ? 'Trocar arquivo' : 'Enviar arquivo'}
632
+ </Button>
633
+
634
+ {value ? (
635
+ <Button
636
+ type="button"
637
+ variant="ghost"
638
+ onClick={() => void handleRemove()}
639
+ disabled={isUploading}
640
+ >
641
+ Remover
642
+ </Button>
643
+ ) : null}
644
+ </div>
645
+
646
+ <p className="text-xs text-muted-foreground">
647
+ {value
648
+ ? `Arquivo vinculado: #${String(value)}`
649
+ : 'Nenhuma imagem enviada. O placeholder será usado até o upload.'}
650
+ </p>
651
+ </div>
652
+ </div>
653
+
654
+ {isUploading ? <Progress value={progress} /> : null}
655
+ </div>
656
+ );
657
+ }
658
+
659
+ export function CatalogResourceFormSheet({
660
+ open,
661
+ onOpenChange,
662
+ resource,
663
+ resourceConfig,
664
+ resourceTitle,
665
+ resourceDescription,
666
+ recordId,
667
+ onSuccess,
668
+ }: FormSheetProps) {
669
+ const { request, currentLocaleCode } = useApp();
670
+ const t = useTranslations('catalog');
671
+ const isEditing = Boolean(recordId);
672
+ const formSchema = useMemo(
673
+ () => buildFormSchema(resourceConfig),
674
+ [resourceConfig]
675
+ );
676
+
677
+ const form = useForm<CatalogFormValues>({
678
+ resolver: zodResolver(formSchema),
679
+ defaultValues: buildDefaultValues(resourceConfig, resourceConfig.template),
680
+ });
681
+
682
+ const { data: currentRecord, isLoading } = useQuery<CatalogRecord>({
683
+ queryKey: ['catalog-record-details', resource, recordId],
684
+ queryFn: async () => {
685
+ const response = await request({
686
+ url: `/catalog/${resource}/${recordId}`,
687
+ method: 'GET',
688
+ });
689
+
690
+ return response.data as CatalogRecord;
691
+ },
692
+ enabled: open && Boolean(recordId),
693
+ });
694
+
695
+ useEffect(() => {
696
+ if (!open) {
697
+ return;
698
+ }
699
+
700
+ const payload = currentRecord || resourceConfig.template;
701
+ form.reset(buildDefaultValues(resourceConfig, payload));
702
+ }, [currentRecord, form, open, resourceConfig]);
703
+
704
+ const submitLabel = isEditing
705
+ ? getCatalogLocalizedText(resourceConfig.editActionLabel, currentLocaleCode)
706
+ : getCatalogLocalizedText(resourceConfig.createActionLabel, currentLocaleCode);
707
+
708
+ const handleSubmit = async (values: CatalogFormValues) => {
709
+ try {
710
+ const payload: CatalogRecord = {};
711
+
712
+ for (const section of resourceConfig.formSections) {
713
+ for (const field of section.fields) {
714
+ payload[field.key] = normalizeSubmitValue(field, values[field.key]);
715
+ }
716
+ }
717
+
718
+ const response = await request({
719
+ url: isEditing
720
+ ? `/catalog/${resource}/${recordId}`
721
+ : `/catalog/${resource}`,
722
+ method: isEditing ? 'PATCH' : 'POST',
723
+ data: payload,
724
+ });
725
+
726
+ const savedRecord = (response.data || payload) as CatalogRecord;
727
+ toast.success(t('toasts.saveSuccess', { resource: resourceTitle }));
728
+ onOpenChange(false);
729
+ await onSuccess(savedRecord);
730
+ } catch (error) {
731
+ toast.error(
732
+ error instanceof Error ? error.message : t('toasts.saveError')
733
+ );
734
+ }
735
+ };
736
+
737
+ return (
738
+ <Sheet open={open} onOpenChange={onOpenChange}>
739
+ <SheetContent className="w-full overflow-x-hidden overflow-y-auto sm:max-w-2xl">
740
+ <SheetHeader>
741
+ <SheetTitle>{submitLabel}</SheetTitle>
742
+ <SheetDescription>{resourceDescription}</SheetDescription>
743
+ </SheetHeader>
744
+
745
+ {isEditing && isLoading ? (
746
+ <div className="flex items-center gap-2 p-4 text-sm text-muted-foreground">
747
+ <Loader2 className="h-4 w-4 animate-spin" />
748
+ Carregando dados do registro...
749
+ </div>
750
+ ) : (
751
+ <Form {...form}>
752
+ <form
753
+ id={`catalog-form-${resource}`}
754
+ className="min-w-0 space-y-6 px-4 pb-4"
755
+ onSubmit={form.handleSubmit(handleSubmit)}
756
+ >
757
+ {resourceConfig.formSections.map((section) => (
758
+ <section
759
+ key={section.title.en}
760
+ className="min-w-0 max-w-full space-y-4"
761
+ >
762
+ <div>
763
+ <h3 className="text-sm font-semibold">
764
+ {getCatalogLocalizedText(section.title, currentLocaleCode)}
765
+ </h3>
766
+ {section.description ? (
767
+ <p className="text-sm text-muted-foreground">
768
+ {getCatalogLocalizedText(
769
+ section.description,
770
+ currentLocaleCode
771
+ )}
772
+ </p>
773
+ ) : null}
774
+ </div>
775
+
776
+ <div className="grid min-w-0 grid-cols-1 gap-4 sm:grid-cols-2">
777
+ {section.fields.map((field) => (
778
+ <div
779
+ key={field.key}
780
+ className={`min-w-0 ${
781
+ field.span === 2 ? 'sm:col-span-2' : ''
782
+ }`}
783
+ >
784
+ <FormField
785
+ control={form.control}
786
+ name={field.key}
787
+ render={({ field: formField }) => (
788
+ <FormItem>
789
+ <FormLabel>
790
+ {getCatalogLocalizedText(
791
+ field.label,
792
+ currentLocaleCode
793
+ )}
794
+ </FormLabel>
795
+ <FormControl>
796
+ {field.type === 'text' ||
797
+ field.type === 'url' ||
798
+ field.type === 'date' ||
799
+ field.type === 'datetime' ? (
800
+ <Input
801
+ type={
802
+ field.type === 'url'
803
+ ? 'url'
804
+ : field.type === 'date'
805
+ ? 'date'
806
+ : field.type === 'datetime'
807
+ ? 'datetime-local'
808
+ : 'text'
809
+ }
810
+ placeholder={getLocalizedPlaceholder(
811
+ field,
812
+ currentLocaleCode
813
+ )}
814
+ className="w-full"
815
+ value={String(formField.value ?? '')}
816
+ onChange={formField.onChange}
817
+ />
818
+ ) : field.type === 'textarea' ||
819
+ field.type === 'json' ? (
820
+ <Textarea
821
+ placeholder={getLocalizedPlaceholder(
822
+ field,
823
+ currentLocaleCode
824
+ )}
825
+ value={String(formField.value ?? '')}
826
+ onChange={formField.onChange}
827
+ className={
828
+ field.type === 'json'
829
+ ? 'min-h-40 w-full font-mono text-xs'
830
+ : 'min-h-28 w-full'
831
+ }
832
+ />
833
+ ) : field.type === 'richtext' ? (
834
+ <div className="min-w-0 w-full max-w-full overflow-x-hidden space-y-2">
835
+ {!String(formField.value ?? '').trim() ? (
836
+ <p className="text-xs text-muted-foreground">
837
+ {getLocalizedPlaceholder(
838
+ field,
839
+ currentLocaleCode
840
+ )}
841
+ </p>
842
+ ) : null}
843
+ <RichTextEditor
844
+ value={String(formField.value ?? '')}
845
+ onChange={formField.onChange}
846
+ className="min-w-0 w-full max-w-full"
847
+ />
848
+ </div>
849
+ ) : field.type === 'number' ? (
850
+ <Input
851
+ type="number"
852
+ className={
853
+ 'w-full'
854
+ }
855
+ placeholder={getLocalizedPlaceholder(
856
+ field,
857
+ currentLocaleCode
858
+ )}
859
+ value={
860
+ formField.value === null ||
861
+ formField.value === undefined
862
+ ? ''
863
+ : String(formField.value)
864
+ }
865
+ onChange={(event) =>
866
+ formField.onChange(
867
+ event.target.value === ''
868
+ ? null
869
+ : Number(event.target.value)
870
+ )
871
+ }
872
+ />
873
+ ) : field.type === 'currency' ? (
874
+ <InputMoney
875
+ className="w-full"
876
+ value={
877
+ typeof formField.value === 'number'
878
+ ? formField.value
879
+ : undefined
880
+ }
881
+ onValueChange={formField.onChange}
882
+ />
883
+ ) : field.type === 'switch' ? (
884
+ <div className="flex h-9 w-full items-center">
885
+ <Switch
886
+ checked={Boolean(formField.value)}
887
+ onCheckedChange={formField.onChange}
888
+ />
889
+ </div>
890
+ ) : field.type === 'select' ? (
891
+ <Select
892
+ value={String(formField.value ?? '')}
893
+ onValueChange={formField.onChange}
894
+ >
895
+ <SelectTrigger className="w-full">
896
+ <SelectValue
897
+ placeholder={getLocalizedPlaceholder(
898
+ field,
899
+ currentLocaleCode
900
+ )}
901
+ />
902
+ </SelectTrigger>
903
+ <SelectContent>
904
+ {field.options?.map((option) => (
905
+ <SelectItem
906
+ key={option.value}
907
+ value={option.value}
908
+ >
909
+ {getCatalogLocalizedText(
910
+ option.label,
911
+ currentLocaleCode
912
+ )}
913
+ </SelectItem>
914
+ ))}
915
+ </SelectContent>
916
+ </Select>
917
+ ) : field.type === 'relation' ? (
918
+ <CatalogRelationField
919
+ field={field}
920
+ value={formField.value}
921
+ onChange={formField.onChange}
922
+ />
923
+ ) : field.type === 'upload' ? (
924
+ <CatalogUploadField
925
+ field={field}
926
+ value={formField.value}
927
+ onChange={formField.onChange}
928
+ />
929
+ ) : null}
930
+ </FormControl>
931
+ <FormMessage />
932
+ </FormItem>
933
+ )}
934
+ />
935
+ </div>
936
+ ))}
937
+ </div>
938
+ </section>
939
+ ))}
940
+ </form>
941
+ </Form>
942
+ )}
943
+
944
+ <SheetFooter className="border-t">
945
+ <div className="w-full">
946
+ <Button
947
+ type="submit"
948
+ form={`catalog-form-${resource}`}
949
+ className="w-full"
950
+ disabled={form.formState.isSubmitting || isLoading}
951
+ >
952
+ {form.formState.isSubmitting ? (
953
+ <Loader2 className="mr-2 h-4 w-4 animate-spin" />
954
+ ) : (
955
+ <Save className="mr-2 h-4 w-4" />
956
+ )}
957
+ {isEditing ? 'Salvar alterações' : 'Salvar'}
958
+ </Button>
959
+ </div>
960
+ </SheetFooter>
961
+ </SheetContent>
962
+ </Sheet>
963
+ );
964
+ }