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>
203 lines
6.1 KiB
TypeScript
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>
|
|
);
|
|
}
|