@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.
- package/README.md +3 -1
- package/hedhog/frontend/app/[resource]/page.tsx.ejs +648 -636
- package/hedhog/frontend/app/_components/catalog-resource-form-sheet.tsx.ejs +964 -964
- package/hedhog/frontend/app/_lib/catalog-resources.tsx.ejs +1154 -1154
- package/hedhog/frontend/app/dashboard/page.tsx.ejs +562 -562
- package/hedhog/frontend/app/page.tsx.ejs +5 -5
- package/hedhog/frontend/messages/en.json +296 -293
- package/hedhog/frontend/messages/pt.json +296 -293
- package/package.json +7 -7
|
@@ -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
|
+
}
|