feat(ui): berth-reserve dialog with create-and-activate flow
This commit is contained in:
251
src/components/reservations/berth-reserve-dialog.tsx
Normal file
251
src/components/reservations/berth-reserve-dialog.tsx
Normal file
@@ -0,0 +1,251 @@
|
|||||||
|
'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,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogFooter,
|
||||||
|
DialogDescription,
|
||||||
|
} 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 { ClientPicker } from '@/components/shared/client-picker';
|
||||||
|
import { YachtPicker } from '@/components/yachts/yacht-picker';
|
||||||
|
import { apiFetch } from '@/lib/api/client';
|
||||||
|
|
||||||
|
type TenureType = 'permanent' | 'fixed_term' | 'seasonal';
|
||||||
|
|
||||||
|
type FormValues = {
|
||||||
|
clientId: string | null;
|
||||||
|
yachtId: string | null;
|
||||||
|
startDate: string; // YYYY-MM-DD
|
||||||
|
tenureType: TenureType;
|
||||||
|
notes?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
interface BerthReserveDialogProps {
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
berthId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function BerthReserveDialog({ open, onOpenChange, berthId }: BerthReserveDialogProps) {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const [formError, setFormError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const {
|
||||||
|
register,
|
||||||
|
handleSubmit,
|
||||||
|
watch,
|
||||||
|
setValue,
|
||||||
|
reset,
|
||||||
|
formState: { errors, isSubmitting },
|
||||||
|
} = useForm<FormValues>({
|
||||||
|
defaultValues: {
|
||||||
|
clientId: null,
|
||||||
|
yachtId: null,
|
||||||
|
startDate: new Date().toISOString().slice(0, 10),
|
||||||
|
tenureType: 'permanent',
|
||||||
|
notes: '',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (open) {
|
||||||
|
setFormError(null);
|
||||||
|
reset({
|
||||||
|
clientId: null,
|
||||||
|
yachtId: null,
|
||||||
|
startDate: new Date().toISOString().slice(0, 10),
|
||||||
|
tenureType: 'permanent',
|
||||||
|
notes: '',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [open, reset]);
|
||||||
|
|
||||||
|
const clientId = watch('clientId');
|
||||||
|
const yachtId = watch('yachtId');
|
||||||
|
const tenureType = watch('tenureType');
|
||||||
|
|
||||||
|
// When client changes, clear yacht (since yacht-picker is filtered to owner=client)
|
||||||
|
useEffect(() => {
|
||||||
|
setValue('yachtId', null);
|
||||||
|
}, [clientId, setValue]);
|
||||||
|
|
||||||
|
function validate(data: FormValues): string | null {
|
||||||
|
if (!data.clientId) return 'Please select a client';
|
||||||
|
if (!data.yachtId) return 'Please select a yacht';
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createPending(data: FormValues): Promise<{ id: string }> {
|
||||||
|
const res = await apiFetch<{ data: { id: string } }>(`/api/v1/berths/${berthId}/reservations`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: {
|
||||||
|
clientId: data.clientId!,
|
||||||
|
yachtId: data.yachtId!,
|
||||||
|
startDate: data.startDate,
|
||||||
|
tenureType: data.tenureType,
|
||||||
|
notes: data.notes?.trim() || undefined,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return res.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
const createMutation = useMutation({
|
||||||
|
mutationFn: async (data: FormValues) => {
|
||||||
|
const err = validate(data);
|
||||||
|
if (err) throw new Error(err);
|
||||||
|
await createPending(data);
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['berths', berthId, 'reservations'] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['berth-reservations'] });
|
||||||
|
toast.success('Reservation created');
|
||||||
|
onOpenChange(false);
|
||||||
|
},
|
||||||
|
onError: (err: unknown) => {
|
||||||
|
const msg = err instanceof Error ? err.message : 'Failed to create reservation';
|
||||||
|
setFormError(msg);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const createAndActivateMutation = useMutation({
|
||||||
|
mutationFn: async (data: FormValues) => {
|
||||||
|
const err = validate(data);
|
||||||
|
if (err) throw new Error(err);
|
||||||
|
const pending = await createPending(data);
|
||||||
|
// Immediately activate
|
||||||
|
await apiFetch(`/api/v1/berth-reservations/${pending.id}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
body: { action: 'activate' },
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['berths', berthId, 'reservations'] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['berth-reservations'] });
|
||||||
|
toast.success('Reservation created and activated');
|
||||||
|
onOpenChange(false);
|
||||||
|
},
|
||||||
|
onError: (err: unknown) => {
|
||||||
|
const msg = err instanceof Error ? err.message : 'Failed to activate';
|
||||||
|
if (/active reservation|conflict|409/i.test(msg)) {
|
||||||
|
setFormError(
|
||||||
|
'This berth already has an active reservation. The pending record was created — activate it manually once the other reservation ends.',
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
setFormError(msg);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const isPending = isSubmitting || createMutation.isPending || createAndActivateMutation.isPending;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="max-w-md">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Reserve this berth</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Create a pending reservation or activate it immediately.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<form className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Client</Label>
|
||||||
|
<ClientPicker value={clientId} onChange={(id) => setValue('clientId', id)} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Yacht</Label>
|
||||||
|
<YachtPicker
|
||||||
|
value={yachtId}
|
||||||
|
onChange={(id) => setValue('yachtId', id)}
|
||||||
|
ownerFilter={clientId ? { type: 'client', id: clientId } : undefined}
|
||||||
|
disabled={!clientId}
|
||||||
|
placeholder={clientId ? 'Select yacht...' : 'Select a client first'}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="startDate">Start date</Label>
|
||||||
|
<Input id="startDate" type="date" {...register('startDate', { required: true })} />
|
||||||
|
{errors.startDate && <p className="text-xs text-destructive">Required</p>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Tenure</Label>
|
||||||
|
<Select
|
||||||
|
value={tenureType}
|
||||||
|
onValueChange={(v) => setValue('tenureType', v as TenureType)}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="permanent">Permanent</SelectItem>
|
||||||
|
<SelectItem value="fixed_term">Fixed term</SelectItem>
|
||||||
|
<SelectItem value="seasonal">Seasonal</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="notes">Notes (optional)</Label>
|
||||||
|
<Textarea id="notes" rows={2} {...register('notes')} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{formError && <p className="text-sm text-destructive">{formError}</p>}
|
||||||
|
|
||||||
|
<DialogFooter className="flex-col-reverse sm:flex-row gap-2">
|
||||||
|
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
disabled={isPending}
|
||||||
|
onClick={handleSubmit((data) => {
|
||||||
|
setFormError(null);
|
||||||
|
createMutation.mutate(data);
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
{createMutation.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||||
|
Create reservation
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
disabled={isPending}
|
||||||
|
onClick={handleSubmit((data) => {
|
||||||
|
setFormError(null);
|
||||||
|
createAndActivateMutation.mutate(data);
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
{createAndActivateMutation.isPending && (
|
||||||
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
|
)}
|
||||||
|
Create and activate
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user