feat(ui): add yacht-form for create/edit

Sheet-based react-hook-form + zod component for yacht CRUD.
CREATE mode uses OwnerPicker to set the yacht's owner (required
by createYachtSchema). EDIT mode hides the picker and shows a
notice directing users to the Transfer button, matching the
service-layer guard that blocks owner mutation via PATCH.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Matt Ciaccio
2026-04-24 13:34:55 +02:00
parent d4f58abb9c
commit a604223c17

View File

@@ -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<string | null>(null);
const {
register,
handleSubmit,
watch,
setValue,
reset,
formState: { errors, isSubmitting },
} = useForm<CreateYachtInput>({
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 (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent className="w-full sm:max-w-2xl overflow-y-auto">
<SheetHeader>
<SheetTitle>{isEdit ? 'Edit Yacht' : 'New Yacht'}</SheetTitle>
</SheetHeader>
<form
onSubmit={handleSubmit((data) => {
setFormError(null);
mutation.mutate(data);
})}
className="space-y-6 py-6"
>
{/* Basic */}
<div className="space-y-4">
<h3 className="text-sm font-medium text-muted-foreground uppercase tracking-wide">
Basic
</h3>
<div className="grid grid-cols-2 gap-4">
<div className="col-span-2 space-y-1">
<Label>Name *</Label>
<Input {...register('name')} placeholder="Sea Breeze" />
{errors.name && <p className="text-xs text-destructive">{errors.name.message}</p>}
</div>
<div className="space-y-1">
<Label>Hull Number</Label>
<Input {...register('hullNumber')} placeholder="HIN" />
</div>
<div className="space-y-1">
<Label>Registration</Label>
<Input {...register('registration')} placeholder="Registration #" />
</div>
<div className="space-y-1">
<Label>Flag</Label>
<Input {...register('flag')} placeholder="e.g. MT" />
</div>
<div className="space-y-1">
<Label>Year Built</Label>
<Input
type="number"
{...register('yearBuilt', { valueAsNumber: true })}
placeholder="2015"
/>
{errors.yearBuilt && (
<p className="text-xs text-destructive">{errors.yearBuilt.message}</p>
)}
</div>
</div>
</div>
<Separator />
{/* Build */}
<div className="space-y-4">
<h3 className="text-sm font-medium text-muted-foreground uppercase tracking-wide">
Build
</h3>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-1">
<Label>Builder</Label>
<Input {...register('builder')} placeholder="Benetti" />
</div>
<div className="space-y-1">
<Label>Model</Label>
<Input {...register('model')} placeholder="Classic 120" />
</div>
<div className="col-span-2 space-y-1">
<Label>Hull Material</Label>
<Input {...register('hullMaterial')} placeholder="Aluminium" />
</div>
</div>
</div>
<Separator />
{/* Dimensions (ft) */}
<div className="space-y-4">
<h3 className="text-sm font-medium text-muted-foreground uppercase tracking-wide">
Dimensions (ft)
</h3>
<div className="grid grid-cols-3 gap-4">
<div className="space-y-1">
<Label>Length (ft)</Label>
<Input {...register('lengthFt')} placeholder="120" />
</div>
<div className="space-y-1">
<Label>Width (ft)</Label>
<Input {...register('widthFt')} placeholder="25" />
</div>
<div className="space-y-1">
<Label>Draft (ft)</Label>
<Input {...register('draftFt')} placeholder="8" />
</div>
</div>
</div>
<Separator />
{/* Dimensions (m) */}
<div className="space-y-4">
<h3 className="text-sm font-medium text-muted-foreground uppercase tracking-wide">
Dimensions (m)
</h3>
<div className="grid grid-cols-3 gap-4">
<div className="space-y-1">
<Label>Length (m)</Label>
<Input {...register('lengthM')} placeholder="36.5" />
</div>
<div className="space-y-1">
<Label>Width (m)</Label>
<Input {...register('widthM')} placeholder="7.6" />
</div>
<div className="space-y-1">
<Label>Draft (m)</Label>
<Input {...register('draftM')} placeholder="2.4" />
</div>
</div>
</div>
<Separator />
{/* Ownership */}
<div className="space-y-4">
<h3 className="text-sm font-medium text-muted-foreground uppercase tracking-wide">
Ownership
</h3>
{isEdit ? (
<p className="rounded-md border bg-muted/30 px-3 py-2 text-xs text-muted-foreground">
Ownership changes use the Transfer button.
</p>
) : (
<div className="space-y-1">
<Label>Owner *</Label>
<OwnerPicker
value={owner ?? null}
onChange={(v) => {
if (v) {
setValue('owner', v, { shouldValidate: true });
}
}}
/>
{errors.owner && (
<p className="text-xs text-destructive">
{errors.owner.message ?? 'Owner is required'}
</p>
)}
</div>
)}
</div>
<Separator />
{/* Status */}
<div className="space-y-2">
<Label>Status</Label>
<Select value={status} onValueChange={(v) => setValue('status', v as YachtStatus)}>
<SelectTrigger>
<SelectValue placeholder="Select status" />
</SelectTrigger>
<SelectContent>
<SelectItem value="active">Active</SelectItem>
<SelectItem value="retired">Retired</SelectItem>
<SelectItem value="sold_away">Sold away</SelectItem>
</SelectContent>
</Select>
</div>
<Separator />
{/* Notes */}
<div className="space-y-2">
<Label>Notes</Label>
<Textarea
{...register('notes')}
placeholder="Internal notes about this yacht"
rows={4}
/>
</div>
<Separator />
{/* Tags */}
<div className="space-y-2">
<Label>Tags</Label>
<TagPicker selectedIds={tagIds} onChange={(ids) => setValue('tagIds', ids)} />
</div>
{formError && (
<p className="text-sm text-destructive" role="alert">
{formError}
</p>
)}
<SheetFooter>
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button type="submit" disabled={isSubmitting || mutation.isPending}>
{(isSubmitting || mutation.isPending) && (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
)}
{isEdit ? 'Save Changes' : 'Create Yacht'}
</Button>
</SheetFooter>
</form>
</SheetContent>
</Sheet>
);
}