2026-04-24 13:34:55 +02:00
|
|
|
'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;
|
|
|
|
|
};
|
fix(audit): backlog sweep — partial archived indexes, custom-fields per-entity gate, polish
Wave through the 2026-05-07 backlog of small/concrete audit-final-deferred
items (deferring the Documenso Phases 2-7 build and items needing design
decisions or live external instances).
DB schema:
- Migration 0046 converts 5 composite (port_id, archived_at) indexes to
partial WHERE archived_at IS NULL — clients, interests, yachts, and
both residential tables. Smaller, faster planner choice for the
dominant list-query shape.
Multi-tenant isolation:
- document_sends now verifies recipient.interestId belongs to the port
before landing on the audit row (the surrounding clientId check was
already port-scoped; interestId pollution was the gap).
Routes / API:
- /api/v1/custom-fields/[entityId] requires entityType query param and
gates on the matching resource permission (clients/interests/berths/
yachts/companies). Fixes the cross-resource gap where a user with
clients.view could read company custom-field values.
- Admin user list trash button wrapped in PermissionGate (edit was
already gated; remove was not).
Service polish:
- berth-recommender accepts string-shaped JSONB booleans
('true'/'false') so admin UIs that wrap values as strings don't
silently fall through to defaults.
- expense-pdf renderReceiptHeader anchors all text positions to a
captured baseY rather than reading mutating doc.y after rect+stroke.
Headers no longer drift on the first receipt page after a soft page
break.
- berth-pdf apply: collect non-finite numeric coercion drops + warn-log
them so partial silent drops are observable (was invisible because
the no-fields-supplied check only fires when ALL drop).
- Storage cache fingerprint comment documenting the encrypted-secret
invariant + the explicit invalidation hook.
UI polish:
- invoice-detail typed: replaced two `any` casts with a proper
InvoiceDetailData / LineItem / LinkedExpense interface set.
- YachtForm now accepts initialOwner prop. Wired through:
- client-yachts-tab passes { type: 'client', id: clientId }
- interest-form passes { type: 'client', id: selectedClientId }
- Interest-form yacht picker now includes company-owned yachts where
the selected client is a member (fetches client.companies and feeds
YachtPicker an array filter). Plus an inline "Add new" button that
opens YachtForm pre-bound to the client.
- YachtPicker accepts ownerFilter as single OR array for "match any"
semantics.
BACKLOG.md updated with what landed vs what's still deferred (and why
each deferred item is genuinely larger than this push warrants).
Tests: 1185/1185 vitest, tsc clean.
2026-05-07 21:45:42 +02:00
|
|
|
/**
|
|
|
|
|
* In create mode, pre-select the owner so a user opening this form from
|
|
|
|
|
* a client/company detail page doesn't have to manually re-pick the
|
|
|
|
|
* entity they're already on. Ignored in edit mode (the existing
|
|
|
|
|
* owner-history workflow is the right surface for ownership changes).
|
|
|
|
|
*/
|
|
|
|
|
initialOwner?: { type: 'client' | 'company'; id: string };
|
2026-04-24 13:34:55 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
type YachtStatus = 'active' | 'retired' | 'sold_away';
|
|
|
|
|
|
fix(audit): backlog sweep — partial archived indexes, custom-fields per-entity gate, polish
Wave through the 2026-05-07 backlog of small/concrete audit-final-deferred
items (deferring the Documenso Phases 2-7 build and items needing design
decisions or live external instances).
DB schema:
- Migration 0046 converts 5 composite (port_id, archived_at) indexes to
partial WHERE archived_at IS NULL — clients, interests, yachts, and
both residential tables. Smaller, faster planner choice for the
dominant list-query shape.
Multi-tenant isolation:
- document_sends now verifies recipient.interestId belongs to the port
before landing on the audit row (the surrounding clientId check was
already port-scoped; interestId pollution was the gap).
Routes / API:
- /api/v1/custom-fields/[entityId] requires entityType query param and
gates on the matching resource permission (clients/interests/berths/
yachts/companies). Fixes the cross-resource gap where a user with
clients.view could read company custom-field values.
- Admin user list trash button wrapped in PermissionGate (edit was
already gated; remove was not).
Service polish:
- berth-recommender accepts string-shaped JSONB booleans
('true'/'false') so admin UIs that wrap values as strings don't
silently fall through to defaults.
- expense-pdf renderReceiptHeader anchors all text positions to a
captured baseY rather than reading mutating doc.y after rect+stroke.
Headers no longer drift on the first receipt page after a soft page
break.
- berth-pdf apply: collect non-finite numeric coercion drops + warn-log
them so partial silent drops are observable (was invisible because
the no-fields-supplied check only fires when ALL drop).
- Storage cache fingerprint comment documenting the encrypted-secret
invariant + the explicit invalidation hook.
UI polish:
- invoice-detail typed: replaced two `any` casts with a proper
InvoiceDetailData / LineItem / LinkedExpense interface set.
- YachtForm now accepts initialOwner prop. Wired through:
- client-yachts-tab passes { type: 'client', id: clientId }
- interest-form passes { type: 'client', id: selectedClientId }
- Interest-form yacht picker now includes company-owned yachts where
the selected client is a member (fetches client.companies and feeds
YachtPicker an array filter). Plus an inline "Add new" button that
opens YachtForm pre-bound to the client.
- YachtPicker accepts ownerFilter as single OR array for "match any"
semantics.
BACKLOG.md updated with what landed vs what's still deferred (and why
each deferred item is genuinely larger than this push warrants).
Tests: 1185/1185 vitest, tsc clean.
2026-05-07 21:45:42 +02:00
|
|
|
export function YachtForm({ open, onOpenChange, yacht, initialOwner }: YachtFormProps) {
|
2026-04-24 13:34:55 +02:00
|
|
|
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: [],
|
fix(audit): backlog sweep — partial archived indexes, custom-fields per-entity gate, polish
Wave through the 2026-05-07 backlog of small/concrete audit-final-deferred
items (deferring the Documenso Phases 2-7 build and items needing design
decisions or live external instances).
DB schema:
- Migration 0046 converts 5 composite (port_id, archived_at) indexes to
partial WHERE archived_at IS NULL — clients, interests, yachts, and
both residential tables. Smaller, faster planner choice for the
dominant list-query shape.
Multi-tenant isolation:
- document_sends now verifies recipient.interestId belongs to the port
before landing on the audit row (the surrounding clientId check was
already port-scoped; interestId pollution was the gap).
Routes / API:
- /api/v1/custom-fields/[entityId] requires entityType query param and
gates on the matching resource permission (clients/interests/berths/
yachts/companies). Fixes the cross-resource gap where a user with
clients.view could read company custom-field values.
- Admin user list trash button wrapped in PermissionGate (edit was
already gated; remove was not).
Service polish:
- berth-recommender accepts string-shaped JSONB booleans
('true'/'false') so admin UIs that wrap values as strings don't
silently fall through to defaults.
- expense-pdf renderReceiptHeader anchors all text positions to a
captured baseY rather than reading mutating doc.y after rect+stroke.
Headers no longer drift on the first receipt page after a soft page
break.
- berth-pdf apply: collect non-finite numeric coercion drops + warn-log
them so partial silent drops are observable (was invisible because
the no-fields-supplied check only fires when ALL drop).
- Storage cache fingerprint comment documenting the encrypted-secret
invariant + the explicit invalidation hook.
UI polish:
- invoice-detail typed: replaced two `any` casts with a proper
InvoiceDetailData / LineItem / LinkedExpense interface set.
- YachtForm now accepts initialOwner prop. Wired through:
- client-yachts-tab passes { type: 'client', id: clientId }
- interest-form passes { type: 'client', id: selectedClientId }
- Interest-form yacht picker now includes company-owned yachts where
the selected client is a member (fetches client.companies and feeds
YachtPicker an array filter). Plus an inline "Add new" button that
opens YachtForm pre-bound to the client.
- YachtPicker accepts ownerFilter as single OR array for "match any"
semantics.
BACKLOG.md updated with what landed vs what's still deferred (and why
each deferred item is genuinely larger than this push warrants).
Tests: 1185/1185 vitest, tsc clean.
2026-05-07 21:45:42 +02:00
|
|
|
...(initialOwner ? { owner: initialOwner } : {}),
|
2026-04-24 13:34:55 +02:00
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
setFormError(null);
|
fix(audit): backlog sweep — partial archived indexes, custom-fields per-entity gate, polish
Wave through the 2026-05-07 backlog of small/concrete audit-final-deferred
items (deferring the Documenso Phases 2-7 build and items needing design
decisions or live external instances).
DB schema:
- Migration 0046 converts 5 composite (port_id, archived_at) indexes to
partial WHERE archived_at IS NULL — clients, interests, yachts, and
both residential tables. Smaller, faster planner choice for the
dominant list-query shape.
Multi-tenant isolation:
- document_sends now verifies recipient.interestId belongs to the port
before landing on the audit row (the surrounding clientId check was
already port-scoped; interestId pollution was the gap).
Routes / API:
- /api/v1/custom-fields/[entityId] requires entityType query param and
gates on the matching resource permission (clients/interests/berths/
yachts/companies). Fixes the cross-resource gap where a user with
clients.view could read company custom-field values.
- Admin user list trash button wrapped in PermissionGate (edit was
already gated; remove was not).
Service polish:
- berth-recommender accepts string-shaped JSONB booleans
('true'/'false') so admin UIs that wrap values as strings don't
silently fall through to defaults.
- expense-pdf renderReceiptHeader anchors all text positions to a
captured baseY rather than reading mutating doc.y after rect+stroke.
Headers no longer drift on the first receipt page after a soft page
break.
- berth-pdf apply: collect non-finite numeric coercion drops + warn-log
them so partial silent drops are observable (was invisible because
the no-fields-supplied check only fires when ALL drop).
- Storage cache fingerprint comment documenting the encrypted-secret
invariant + the explicit invalidation hook.
UI polish:
- invoice-detail typed: replaced two `any` casts with a proper
InvoiceDetailData / LineItem / LinkedExpense interface set.
- YachtForm now accepts initialOwner prop. Wired through:
- client-yachts-tab passes { type: 'client', id: clientId }
- interest-form passes { type: 'client', id: selectedClientId }
- Interest-form yacht picker now includes company-owned yachts where
the selected client is a member (fetches client.companies and feeds
YachtPicker an array filter). Plus an inline "Add new" button that
opens YachtForm pre-bound to the client.
- YachtPicker accepts ownerFilter as single OR array for "match any"
semantics.
BACKLOG.md updated with what landed vs what's still deferred (and why
each deferred item is genuinely larger than this push warrants).
Tests: 1185/1185 vitest, tsc clean.
2026-05-07 21:45:42 +02:00
|
|
|
}, [yacht, open, reset, initialOwner]);
|
2026-04-24 13:34:55 +02:00
|
|
|
|
|
|
|
|
const mutation = useMutation({
|
|
|
|
|
mutationFn: async (data: CreateYachtInput) => {
|
|
|
|
|
if (isEdit) {
|
2026-05-04 22:57:01 +02:00
|
|
|
// updateYachtSchema omits owner + tagIds - strip them from PATCH body.
|
2026-04-24 13:34:55 +02:00
|
|
|
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>
|
|
|
|
|
);
|
|
|
|
|
}
|