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>
This commit is contained in:
@@ -9,16 +9,10 @@ import { toast } from 'sonner';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { ArchiveConfirmDialog } from '@/components/shared/archive-confirm-dialog';
|
||||
import { PermissionGate } from '@/components/shared/permission-gate';
|
||||
import { YachtForm } from '@/components/yachts/yacht-form';
|
||||
import { YachtTransferDialog } from '@/components/yachts/yacht-transfer-dialog';
|
||||
import { apiFetch } from '@/lib/api/client';
|
||||
|
||||
interface YachtDetailHeaderYacht {
|
||||
@@ -174,6 +168,7 @@ export function YachtDetailHeader({ yacht }: YachtDetailHeaderProps) {
|
||||
<Pencil className="mr-1.5 h-3.5 w-3.5" />
|
||||
Edit
|
||||
</Button>
|
||||
<PermissionGate resource="yachts" action="transfer">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
@@ -183,6 +178,7 @@ export function YachtDetailHeader({ yacht }: YachtDetailHeaderProps) {
|
||||
<ArrowRightLeft className="mr-1.5 h-3.5 w-3.5" />
|
||||
Transfer
|
||||
</Button>
|
||||
</PermissionGate>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
@@ -234,30 +230,12 @@ export function YachtDetailHeader({ yacht }: YachtDetailHeaderProps) {
|
||||
isLoading={archiveMutation.isPending}
|
||||
/>
|
||||
|
||||
{/* TODO(Task 5.5): Replace with real YachtTransferDialog component. */}
|
||||
<Dialog open={transferOpen} onOpenChange={setTransferOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Transfer Ownership</DialogTitle>
|
||||
<DialogDescription>
|
||||
The yacht ownership transfer flow will be implemented in Task 5.5.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="py-2 text-sm text-muted-foreground">
|
||||
This stub will be replaced with a form that lets you pick a new owner, effective date,
|
||||
reason, and notes — then calls{' '}
|
||||
<code className="rounded bg-muted px-1 text-xs">
|
||||
POST /api/v1/yachts/{'{id}'}/transfer
|
||||
</code>
|
||||
.
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setTransferOpen(false)}>
|
||||
Close
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
<YachtTransferDialog
|
||||
open={transferOpen}
|
||||
onOpenChange={setTransferOpen}
|
||||
yachtId={yacht.id}
|
||||
currentOwner={{ type: yacht.currentOwnerType, id: yacht.currentOwnerId }}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
202
src/components/yachts/yacht-transfer-dialog.tsx
Normal file
202
src/components/yachts/yacht-transfer-dialog.tsx
Normal file
@@ -0,0 +1,202 @@
|
||||
'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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user