diff --git a/src/components/yachts/yacht-form.tsx b/src/components/yachts/yacht-form.tsx new file mode 100644 index 0000000..4c6f003 --- /dev/null +++ b/src/components/yachts/yacht-form.tsx @@ -0,0 +1,356 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import { useForm } from 'react-hook-form'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { Loader2 } from 'lucide-react'; + +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { Textarea } from '@/components/ui/textarea'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; +import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetFooter } from '@/components/ui/sheet'; +import { Separator } from '@/components/ui/separator'; +import { OwnerPicker, type OwnerRef } from '@/components/shared/owner-picker'; +import { TagPicker } from '@/components/shared/tag-picker'; +import { apiFetch } from '@/lib/api/client'; +import { createYachtSchema, type CreateYachtInput } from '@/lib/validators/yachts'; + +interface YachtFormProps { + open: boolean; + onOpenChange: (open: boolean) => void; + /** If provided, form is in edit mode */ + yacht?: { + id: string; + name: string; + hullNumber?: string | null; + registration?: string | null; + flag?: string | null; + yearBuilt?: number | null; + builder?: string | null; + model?: string | null; + hullMaterial?: string | null; + lengthFt?: string | null; + widthFt?: string | null; + draftFt?: string | null; + lengthM?: string | null; + widthM?: string | null; + draftM?: string | null; + currentOwnerType: 'client' | 'company'; + currentOwnerId: string; + status?: string | null; + notes?: string | null; + }; +} + +type YachtStatus = 'active' | 'retired' | 'sold_away'; + +export function YachtForm({ open, onOpenChange, yacht }: YachtFormProps) { + const queryClient = useQueryClient(); + const isEdit = !!yacht; + const [formError, setFormError] = useState(null); + + const { + register, + handleSubmit, + watch, + setValue, + reset, + formState: { errors, isSubmitting }, + } = useForm({ + resolver: zodResolver(createYachtSchema), + defaultValues: { + name: '', + status: 'active', + tagIds: [], + }, + }); + + const tagIds = watch('tagIds') ?? []; + const owner = watch('owner') as OwnerRef | undefined; + const status = watch('status') ?? 'active'; + + // Populate form when editing, or reset to defaults in create mode. + useEffect(() => { + if (yacht && open) { + reset({ + name: yacht.name, + hullNumber: yacht.hullNumber ?? undefined, + registration: yacht.registration ?? undefined, + flag: yacht.flag ?? undefined, + yearBuilt: yacht.yearBuilt ?? undefined, + builder: yacht.builder ?? undefined, + model: yacht.model ?? undefined, + hullMaterial: yacht.hullMaterial ?? undefined, + lengthFt: yacht.lengthFt ?? undefined, + widthFt: yacht.widthFt ?? undefined, + draftFt: yacht.draftFt ?? undefined, + lengthM: yacht.lengthM ?? undefined, + widthM: yacht.widthM ?? undefined, + draftM: yacht.draftM ?? undefined, + // Owner is required by the schema in create mode. In edit mode we + // strip it before PATCH, but we still satisfy the resolver by + // supplying the current owner. + owner: { type: yacht.currentOwnerType, id: yacht.currentOwnerId }, + status: (yacht.status as YachtStatus | null) ?? 'active', + notes: yacht.notes ?? undefined, + tagIds: [], + }); + } else if (!yacht && open) { + reset({ + name: '', + status: 'active', + tagIds: [], + }); + } + setFormError(null); + }, [yacht, open, reset]); + + const mutation = useMutation({ + mutationFn: async (data: CreateYachtInput) => { + if (isEdit) { + // updateYachtSchema omits owner + tagIds — strip them from PATCH body. + const { owner: _owner, tagIds: _tIds, ...rest } = data; + void _owner; + void _tIds; + await apiFetch(`/api/v1/yachts/${yacht!.id}`, { + method: 'PATCH', + body: rest, + }); + } else { + await apiFetch('/api/v1/yachts', { method: 'POST', body: data }); + } + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['yachts'] }); + onOpenChange(false); + }, + onError: (err: Error) => { + setFormError(err.message || 'Failed to save yacht'); + }, + }); + + return ( + + + + {isEdit ? 'Edit Yacht' : 'New Yacht'} + + +
{ + setFormError(null); + mutation.mutate(data); + })} + className="space-y-6 py-6" + > + {/* Basic */} +
+

+ Basic +

+ +
+
+ + + {errors.name &&

{errors.name.message}

} +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + + {errors.yearBuilt && ( +

{errors.yearBuilt.message}

+ )} +
+
+
+ + + + {/* Build */} +
+

+ Build +

+
+
+ + +
+
+ + +
+
+ + +
+
+
+ + + + {/* Dimensions (ft) */} +
+

+ Dimensions (ft) +

+
+
+ + +
+
+ + +
+
+ + +
+
+
+ + + + {/* Dimensions (m) */} +
+

+ Dimensions (m) +

+
+
+ + +
+
+ + +
+
+ + +
+
+
+ + + + {/* Ownership */} +
+

+ Ownership +

+ + {isEdit ? ( +

+ Ownership changes use the Transfer button. +

+ ) : ( +
+ + { + if (v) { + setValue('owner', v, { shouldValidate: true }); + } + }} + /> + {errors.owner && ( +

+ {errors.owner.message ?? 'Owner is required'} +

+ )} +
+ )} +
+ + + + {/* Status */} +
+ + +
+ + + + {/* Notes */} +
+ +