Files
pn-new-crm/src/components/yachts/yacht-transfer-dialog.tsx
Matt Ciaccio 508518b6c8 feat(ui): yacht transfer dialog with atomic ownership change
Replaces the Task 5.3 stub with a real YachtTransferDialog backed by
OwnerPicker, a date input, reason select, and notes textarea. Submits to
POST /api/v1/yachts/{id}/transfer, invalidates yacht + ownership-history
queries on success, and surfaces API errors (same-owner 400, cross-tenant
404, no-permission 403) as form-level messages. Transfer button is now
gated by PermissionGate resource="yachts" action="transfer".

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 13:47:26 +02:00

203 lines
6.1 KiB
TypeScript

'use client';
import { useEffect, useState } from 'react';
import { useForm } from 'react-hook-form';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { Loader2 } from 'lucide-react';
import { toast } from 'sonner';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Label } from '@/components/ui/label';
import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { OwnerPicker, type OwnerRef } from '@/components/shared/owner-picker';
import { apiFetch } from '@/lib/api/client';
type TransferReason = 'sale' | 'inheritance' | 'gift' | 'company_restructure' | 'other';
type FormValues = {
newOwner: OwnerRef | undefined;
effectiveDate: string; // ISO date string from <input type="date">
transferReason?: TransferReason;
transferNotes?: string;
};
interface YachtTransferDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
yachtId: string;
/** Current owner shown for reference; used to guard against selecting the same owner. */
currentOwner?: OwnerRef;
}
const todayIso = (): string => new Date().toISOString().slice(0, 10);
export function YachtTransferDialog({
open,
onOpenChange,
yachtId,
currentOwner,
}: YachtTransferDialogProps) {
const queryClient = useQueryClient();
const [formError, setFormError] = useState<string | null>(null);
const {
register,
handleSubmit,
watch,
setValue,
reset,
formState: { errors, isSubmitting },
} = useForm<FormValues>({
defaultValues: {
newOwner: undefined,
effectiveDate: todayIso(),
transferReason: undefined,
transferNotes: '',
},
});
useEffect(() => {
if (open) {
setFormError(null);
reset({
newOwner: undefined,
effectiveDate: todayIso(),
transferReason: undefined,
transferNotes: '',
});
}
}, [open, reset]);
const newOwner = watch('newOwner');
const transferReason = watch('transferReason');
const mutation = useMutation({
mutationFn: async (data: FormValues) => {
if (!data.newOwner) {
throw new Error('Please select a new owner');
}
if (
currentOwner &&
data.newOwner.type === currentOwner.type &&
data.newOwner.id === currentOwner.id
) {
throw new Error('New owner must be different from the current owner');
}
await apiFetch(`/api/v1/yachts/${yachtId}/transfer`, {
method: 'POST',
body: {
newOwner: data.newOwner,
effectiveDate: data.effectiveDate,
transferReason: data.transferReason,
transferNotes: data.transferNotes?.trim() || undefined,
},
});
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['yachts', yachtId] });
queryClient.invalidateQueries({ queryKey: ['yachts', yachtId, 'ownership-history'] });
queryClient.invalidateQueries({ queryKey: ['yachts'] });
toast.success('Ownership transferred');
onOpenChange(false);
},
onError: (err: unknown) => {
const msg = err instanceof Error ? err.message : 'Failed to transfer ownership';
setFormError(msg);
},
});
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>Transfer ownership</DialogTitle>
<DialogDescription>
This will close the current ownership record and open a new one. The change is auditable
and atomic.
</DialogDescription>
</DialogHeader>
<form
onSubmit={handleSubmit((data) => {
setFormError(null);
mutation.mutate(data);
})}
className="space-y-4"
>
<div className="space-y-2">
<Label>New owner</Label>
<OwnerPicker
value={newOwner ?? null}
onChange={(v) => setValue('newOwner', v ?? undefined)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="effectiveDate">Effective date</Label>
<Input
id="effectiveDate"
type="date"
{...register('effectiveDate', { required: true })}
/>
{errors.effectiveDate && <p className="text-xs text-destructive">Required</p>}
</div>
<div className="space-y-2">
<Label>Reason (optional)</Label>
<Select
onValueChange={(v) => setValue('transferReason', v as TransferReason)}
value={transferReason ?? ''}
>
<SelectTrigger>
<SelectValue placeholder="Select a reason..." />
</SelectTrigger>
<SelectContent>
<SelectItem value="sale">Sale</SelectItem>
<SelectItem value="inheritance">Inheritance</SelectItem>
<SelectItem value="gift">Gift</SelectItem>
<SelectItem value="company_restructure">Company restructure</SelectItem>
<SelectItem value="other">Other</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="transferNotes">Notes (optional)</Label>
<Textarea id="transferNotes" rows={3} {...register('transferNotes')} />
</div>
{formError && <p className="text-sm text-destructive">{formError}</p>}
<DialogFooter>
<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" />
)}
Transfer
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}