diff --git a/app/admin/add-barometer/add-manufacturer.tsx b/app/admin/add-barometer/add-manufacturer.tsx deleted file mode 100644 index a367b080..00000000 --- a/app/admin/add-barometer/add-manufacturer.tsx +++ /dev/null @@ -1,374 +0,0 @@ -'use client' - -import { yupResolver } from '@hookform/resolvers/yup' -import { useMutation, useQueryClient } from '@tanstack/react-query' -import { Plus, Upload, X } from 'lucide-react' -import { useCallback, useEffect, useRef, useState } from 'react' -import { useForm } from 'react-hook-form' -import { toast } from 'sonner' -import * as yup from 'yup' -import * as UI from '@/components/ui' -import { useBarometers } from '@/hooks/useBarometers' -import { addManufacturer } from '@/services/fetch' -import { generateIcon } from '@/utils' - -interface AddManufacturerProps { - onAddManufacturer: (newId: string) => void -} - -// Yup validation schema -const manufacturerSchema = yup.object().shape({ - firstName: yup.string().max(100, 'First name should be shorter than 100 characters').default(''), - name: yup - .string() - .required('Name is required') - .min(2, 'Name should be longer than 2 characters') - .max(100, 'Name should be shorter than 100 characters'), - city: yup.string().max(100, 'City should be shorter than 100 characters').default(''), - countries: yup.array().of(yup.number().required()).default([]), - description: yup.string().default(''), - icon: yup.string().nullable().default(null), -}) - -type ManufacturerFormData = yup.InferType - -export function AddManufacturer({ onAddManufacturer }: AddManufacturerProps) { - const { countries } = useBarometers() - const [open, setOpen] = useState(false) - - const form = useForm({ - resolver: yupResolver(manufacturerSchema), - defaultValues: { - firstName: '', - name: '', - city: '', - countries: [], - description: '', - icon: null, - }, - }) - - const { handleSubmit, setValue, reset, watch } = form - - const queryClient = useQueryClient() - const { mutate, isPending } = useMutation({ - mutationFn: addManufacturer, - onSuccess: ({ id }, variables) => { - queryClient.invalidateQueries({ - queryKey: ['manufacturers'], - }) - reset() - onAddManufacturer(id) - setOpen(false) - toast.success(`${variables.name} has been recorded as a manufacturer #${id ?? 0}`) - }, - onError: error => { - toast.error(error.message || 'Error adding manufacturer') - }, - }) - - useEffect(() => { - if (open) { - reset() - } - }, [open, reset]) - - const handleIconChange = useCallback( - async (file: File | null) => { - if (!file) { - setValue('icon', null) - return - } - try { - const fileUrl = URL.createObjectURL(file) - const iconData = await generateIcon(fileUrl, 50) - URL.revokeObjectURL(fileUrl) - setValue('icon', iconData) - } catch (error) { - setValue('icon', null) - toast.error(error instanceof Error ? error.message : 'Image cannot be opened') - } - }, - [setValue], - ) - - const onSubmit = (values: ManufacturerFormData) => { - mutate({ - ...values, - countries: values.countries.map(id => ({ id })), - }) - } - - return ( - - - - - - - - - - -

Add manufacturer

-
-
- - - - Add Manufacturer - - - -
- ( - - First name - - - - - - )} - /> - - ( - - Name * - - - - - - )} - /> - - setValue('countries', selected)} - /> - - ( - - City - - - - - - )} - /> - - ( - - Description - - - - - - )} - /> - -
- - {isPending ? 'Adding...' : 'Add Manufacturer'} - - -
- -
-
-
- ) -} - -// Country Multi-Select Component -interface Country { - id: number - name: string -} - -interface CountryMultiSelectProps { - countries: Country[] - selectedCountries: number[] - onCountriesChange: (selected: number[]) => void -} - -function CountryMultiSelect({ - countries, - selectedCountries, - onCountriesChange, -}: CountryMultiSelectProps) { - const [open, setOpen] = useState(false) - - const handleSelect = (countryId: number) => { - const newSelected = selectedCountries.includes(countryId) - ? selectedCountries.filter(id => id !== countryId) - : [...selectedCountries, countryId] - onCountriesChange(newSelected) - } - - const selectedCountryNames = countries - .filter(country => selectedCountries.includes(country.id)) - .map(country => country.name) - - return ( -
- Countries - - - - {selectedCountries.length > 0 - ? `${selectedCountries.length} countries selected` - : 'Select countries'} - - - - - - No country found. - - {countries.map(country => ( - handleSelect(country.id)} - > -
- handleSelect(country.id)} - className="h-4 w-4" - /> - {country.name} -
-
- ))} -
-
-
-
- - {selectedCountryNames.length > 0 && ( -
- {selectedCountryNames.map(name => ( - - {name} - { - const country = countries.find(c => c.name === name) - if (country) handleSelect(country.id) - }} - > - - - - ))} -
- )} -
- ) -} - -interface IconUploadProps { - onFileChange: (file: File | null) => void -} - -const IconUpload = ({ onFileChange }: IconUploadProps) => { - const [previewUrl, setPreviewUrl] = useState(null) - const fileInputRef = useRef(null) - - const handleFileSelect = (selectedFile: File | null) => { - onFileChange(selectedFile) - - if (selectedFile) { - const url = URL.createObjectURL(selectedFile) - setPreviewUrl(url) - } else { - setPreviewUrl(null) - } - } - - const handleButtonClick = () => { - fileInputRef.current?.click() - } - - const handleInputChange = (event: React.ChangeEvent) => { - const file = event.target.files?.[0] || null - handleFileSelect(file) - } - - useEffect(() => { - return () => { - if (previewUrl) { - URL.revokeObjectURL(previewUrl) - } - } - }, [previewUrl]) - - return ( -
- {previewUrl && ( -
- handleFileSelect(null)} - aria-label="Remove icon" - > - - - {/** biome-ignore lint/performance/noImgElement: preview requires dynamic src from blob URL */} - Icon preview -
- )} - - - - Select Icon - - - -
- ) -} diff --git a/app/admin/add-barometer/add-materials.tsx b/app/admin/add-barometer/add-materials.tsx deleted file mode 100644 index e4fe166c..00000000 --- a/app/admin/add-barometer/add-materials.tsx +++ /dev/null @@ -1,90 +0,0 @@ -'use client' - -import { Check, X } from 'lucide-react' -import { Badge } from '@/components/ui/badge' -import { Button } from '@/components/ui/button' -import { - Command, - CommandEmpty, - CommandGroup, - CommandInput, - CommandItem, - CommandList, -} from '@/components/ui/command' -import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover' - -interface MaterialsMultiSelectProps { - value: number[] - onChange: (value: number[]) => void - materials: Array<{ id: number; name: string }> -} - -export function MaterialsMultiSelect({ value, onChange, materials }: MaterialsMultiSelectProps) { - const selectedMaterials = materials.filter(material => value.includes(material.id)) - - const handleSelect = (materialId: number) => { - if (value.includes(materialId)) { - onChange(value.filter(id => id !== materialId)) - } else { - onChange([...value, materialId]) - } - } - - const handleRemove = (materialId: number) => { - onChange(value.filter(id => id !== materialId)) - } - - return ( -
- {selectedMaterials.length > 0 && ( -
- {selectedMaterials.map(material => ( - - {material.name} - - - ))} -
- )} - - - - - - - - - No materials found. - - {materials.map(material => ( - handleSelect(material.id)} - className="flex items-center space-x-2" - > -
- {value.includes(material.id) && } -
- {material.name} -
- ))} -
-
-
-
-
-
- ) -} diff --git a/app/admin/add-barometer/barometer-form.tsx b/app/admin/add-barometer/barometer-form.tsx new file mode 100644 index 00000000..481bc8f3 --- /dev/null +++ b/app/admin/add-barometer/barometer-form.tsx @@ -0,0 +1,447 @@ +'use client' + +import { zodResolver } from '@hookform/resolvers/zod' +import { Check, ChevronsUpDown } from 'lucide-react' +import { useEffect, useTransition } from 'react' +import { useForm } from 'react-hook-form' +import { toast } from 'sonner' +import { MultiSelect, RequiredFieldMark } from '@/components/elements' +import { Button } from '@/components/ui/button' +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, +} from '@/components/ui/command' +import { + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, + FormProvider, +} from '@/components/ui/form' +import { Input } from '@/components/ui/input' +import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover' +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select' +import { Textarea } from '@/components/ui/textarea' +import { createBarometer } from '@/lib/barometers/actions' +import type { AllBrandsDTO } from '@/lib/brands/queries' +import type { CategoriesDTO } from '@/lib/categories/queries' +import type { ConditionsDTO } from '@/lib/conditions/queries' +import type { MaterialsDTO } from '@/lib/materials/queries' +import type { MovementsDTO } from '@/lib/movements/queries' +import { + type BarometerFormData, + BarometerFormTransformSchema, + BarometerFormValidationSchema, +} from '@/lib/schemas/barometer-form.schema' +import { cn } from '@/utils' +import { Dimensions } from './dimensions' +import { FileUpload } from './file-upload' + +interface Props { + conditions: ConditionsDTO + categories: CategoriesDTO + movements: MovementsDTO + materials: MaterialsDTO + brands: AllBrandsDTO +} + +export default function BarometerForm({ + categories, + conditions, + movements, + materials, + brands, +}: Props) { + const [isPending, startTransition] = useTransition() + + const methods = useForm({ + resolver: zodResolver(BarometerFormValidationSchema), + defaultValues: { + collectionId: '', + name: '', + categoryId: '', + date: '1900', + dateDescription: '', + manufacturerId: '', + conditionId: '', + description: '', + dimensions: [], + images: [], + purchasedAt: '', + serial: '', + estimatedPrice: '', + subCategoryId: 'none', + materials: [], + }, + }) + + const { handleSubmit, setValue, reset, control } = methods + + const submitForm = (values: BarometerFormData) => { + startTransition(async () => { + try { + // Transform schema does ALL the heavy lifting - validation AND transformation! + const transformedData = await BarometerFormTransformSchema.parseAsync(values) + const { id } = await createBarometer(transformedData) + reset() + toast.success(`Added ${id} to the database`) + } catch (error) { + toast.error(error instanceof Error ? error.message : 'Error adding barometer') + } + }) + } + + // Set default values when data loads + useEffect(() => { + reset({ + categoryId: categories[0].id, + manufacturerId: brands[0].id, + conditionId: conditions.at(-1)?.id, + }) + }, [categories, conditions, brands, reset]) + + return ( + + +
+ ( + + + Catalogue No. + + + + + + + )} + /> + + ( + + Serial Number + + + + + + )} + /> + + ( + + + Title + + + + + + + )} + /> + + ( + + + Year + + + { + const year = e.target.value.replace(/\D/g, '').slice(0, 4) + field.onChange(year) + }} + /> + + + + )} + /> + + ( + + + Date description + + + + + + + )} + /> + + ( + + Purchase Date + +
+ + +
+
+ +
+ )} + /> + + ( + + Estimated Price, € + + + + + + )} + /> + + ( + + Materials + + ({ id: m.id, name: m.name })) ?? []} + placeholder="Select materials..." + searchPlaceholder="Search materials..." + emptyMessage="No materials found." + /> + + + + )} + /> + + ( + + + Category + + + + + )} + /> + + ( + + Movement Type + + + + )} + /> + + ( + + + Manufacturer + +
+ + + + + + + + + + + No manufacturer found. + + {brands.map(({ name, id }) => ( + { + field.onChange(String(id)) + }} + > + + {name} + + ))} + + + + + +
+ +
+ )} + /> + + ( + + + Condition + + + + + )} + /> + + + + + + ( + + Description + +