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 { Button } from '@/components/ui/button';
|
||||||
import { Badge } from '@/components/ui/badge';
|
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 { ArchiveConfirmDialog } from '@/components/shared/archive-confirm-dialog';
|
||||||
|
import { PermissionGate } from '@/components/shared/permission-gate';
|
||||||
import { YachtForm } from '@/components/yachts/yacht-form';
|
import { YachtForm } from '@/components/yachts/yacht-form';
|
||||||
|
import { YachtTransferDialog } from '@/components/yachts/yacht-transfer-dialog';
|
||||||
import { apiFetch } from '@/lib/api/client';
|
import { apiFetch } from '@/lib/api/client';
|
||||||
|
|
||||||
interface YachtDetailHeaderYacht {
|
interface YachtDetailHeaderYacht {
|
||||||
@@ -174,15 +168,17 @@ export function YachtDetailHeader({ yacht }: YachtDetailHeaderProps) {
|
|||||||
<Pencil className="mr-1.5 h-3.5 w-3.5" />
|
<Pencil className="mr-1.5 h-3.5 w-3.5" />
|
||||||
Edit
|
Edit
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<PermissionGate resource="yachts" action="transfer">
|
||||||
variant="outline"
|
<Button
|
||||||
size="sm"
|
variant="outline"
|
||||||
onClick={() => setTransferOpen(true)}
|
size="sm"
|
||||||
disabled={isArchived}
|
onClick={() => setTransferOpen(true)}
|
||||||
>
|
disabled={isArchived}
|
||||||
<ArrowRightLeft className="mr-1.5 h-3.5 w-3.5" />
|
>
|
||||||
Transfer
|
<ArrowRightLeft className="mr-1.5 h-3.5 w-3.5" />
|
||||||
</Button>
|
Transfer
|
||||||
|
</Button>
|
||||||
|
</PermissionGate>
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
@@ -234,30 +230,12 @@ export function YachtDetailHeader({ yacht }: YachtDetailHeaderProps) {
|
|||||||
isLoading={archiveMutation.isPending}
|
isLoading={archiveMutation.isPending}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* TODO(Task 5.5): Replace with real YachtTransferDialog component. */}
|
<YachtTransferDialog
|
||||||
<Dialog open={transferOpen} onOpenChange={setTransferOpen}>
|
open={transferOpen}
|
||||||
<DialogContent>
|
onOpenChange={setTransferOpen}
|
||||||
<DialogHeader>
|
yachtId={yacht.id}
|
||||||
<DialogTitle>Transfer Ownership</DialogTitle>
|
currentOwner={{ type: yacht.currentOwnerType, id: yacht.currentOwnerId }}
|
||||||
<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>
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
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