2026-04-28 02:45:05 +02:00
|
|
|
'use client';
|
|
|
|
|
|
2026-05-02 23:01:15 +02:00
|
|
|
import { useState } from 'react';
|
2026-04-28 02:45:05 +02:00
|
|
|
import Link from 'next/link';
|
|
|
|
|
import type { Route } from 'next';
|
2026-05-02 23:01:15 +02:00
|
|
|
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
|
|
|
|
import { ArrowLeft, Bell, Download, FileSignature, Mail, StopCircle } from 'lucide-react';
|
2026-04-28 02:45:05 +02:00
|
|
|
import { toast } from 'sonner';
|
|
|
|
|
|
|
|
|
|
import { Button } from '@/components/ui/button';
|
2026-05-02 23:01:15 +02:00
|
|
|
import {
|
|
|
|
|
Dialog,
|
|
|
|
|
DialogContent,
|
|
|
|
|
DialogFooter,
|
|
|
|
|
DialogHeader,
|
|
|
|
|
DialogTitle,
|
|
|
|
|
} from '@/components/ui/dialog';
|
|
|
|
|
import { Input } from '@/components/ui/input';
|
|
|
|
|
import { Label } from '@/components/ui/label';
|
2026-04-28 02:45:05 +02:00
|
|
|
import { PageHeader } from '@/components/shared/page-header';
|
|
|
|
|
import { StatusPill, type StatusPillStatus } from '@/components/ui/status-pill';
|
|
|
|
|
import { EmptyState } from '@/components/ui/empty-state';
|
|
|
|
|
import { useRealtimeInvalidation } from '@/hooks/use-realtime-invalidation';
|
|
|
|
|
import { apiFetch } from '@/lib/api/client';
|
2026-05-02 23:01:15 +02:00
|
|
|
import { ClientLink, YachtLink, BerthLink } from '@/components/reservations/reservation-list';
|
2026-04-28 02:45:05 +02:00
|
|
|
|
|
|
|
|
interface ReservationDoc {
|
|
|
|
|
id: string;
|
|
|
|
|
title: string;
|
|
|
|
|
status: string;
|
|
|
|
|
documentType: string;
|
|
|
|
|
signedFileId: string | null;
|
|
|
|
|
signers: Array<{ id: string; status: string; signerName: string }>;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
interface ReservationData {
|
|
|
|
|
id: string;
|
|
|
|
|
status: string;
|
|
|
|
|
startDate: string;
|
|
|
|
|
endDate: string | null;
|
|
|
|
|
tenureType: string;
|
|
|
|
|
contractFileId: string | null;
|
|
|
|
|
berthId: string;
|
|
|
|
|
yachtId: string;
|
|
|
|
|
clientId: string;
|
|
|
|
|
notes: string | null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const RESERVATION_PILL: Record<string, StatusPillStatus> = {
|
|
|
|
|
pending: 'pending',
|
|
|
|
|
active: 'active',
|
|
|
|
|
ended: 'archived',
|
|
|
|
|
cancelled: 'cancelled',
|
|
|
|
|
};
|
|
|
|
|
|
2026-05-02 23:01:15 +02:00
|
|
|
function todayIso(): string {
|
|
|
|
|
return new Date().toISOString().slice(0, 10);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
interface EndReservationDialogProps {
|
|
|
|
|
reservationId: string;
|
|
|
|
|
open: boolean;
|
|
|
|
|
onOpenChange: (open: boolean) => void;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function EndReservationDialog({ reservationId, open, onOpenChange }: EndReservationDialogProps) {
|
|
|
|
|
const qc = useQueryClient();
|
|
|
|
|
const [endDate, setEndDate] = useState(todayIso);
|
|
|
|
|
const [submitting, setSubmitting] = useState(false);
|
|
|
|
|
|
|
|
|
|
async function handleSubmit(e: React.FormEvent) {
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
setSubmitting(true);
|
|
|
|
|
try {
|
|
|
|
|
await apiFetch(`/api/v1/berth-reservations/${reservationId}`, {
|
|
|
|
|
method: 'PATCH',
|
|
|
|
|
body: { action: 'end', endDate },
|
|
|
|
|
});
|
|
|
|
|
qc.invalidateQueries({ queryKey: ['reservation', reservationId] });
|
|
|
|
|
toast.success('Reservation ended');
|
|
|
|
|
onOpenChange(false);
|
|
|
|
|
} catch (err) {
|
|
|
|
|
toast.error(err instanceof Error ? err.message : 'Failed to end reservation');
|
|
|
|
|
} finally {
|
|
|
|
|
setSubmitting(false);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
|
|
|
|
<DialogContent className="sm:max-w-sm">
|
|
|
|
|
<DialogHeader>
|
|
|
|
|
<DialogTitle>End reservation</DialogTitle>
|
|
|
|
|
</DialogHeader>
|
|
|
|
|
<form onSubmit={handleSubmit} className="space-y-4 pt-2">
|
|
|
|
|
<div className="space-y-1.5">
|
|
|
|
|
<Label htmlFor="end-date">End date</Label>
|
|
|
|
|
<Input
|
|
|
|
|
id="end-date"
|
|
|
|
|
type="date"
|
|
|
|
|
value={endDate}
|
|
|
|
|
onChange={(e) => setEndDate(e.target.value)}
|
|
|
|
|
required
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
<DialogFooter>
|
|
|
|
|
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
|
|
|
|
|
Cancel
|
|
|
|
|
</Button>
|
|
|
|
|
<Button type="submit" variant="destructive" disabled={submitting}>
|
|
|
|
|
{submitting ? 'Ending…' : 'End reservation'}
|
|
|
|
|
</Button>
|
|
|
|
|
</DialogFooter>
|
|
|
|
|
</form>
|
|
|
|
|
</DialogContent>
|
|
|
|
|
</Dialog>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-28 02:45:05 +02:00
|
|
|
interface ReservationDetailProps {
|
|
|
|
|
reservationId: string;
|
|
|
|
|
portSlug: string;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function ReservationDetail({ reservationId, portSlug }: ReservationDetailProps) {
|
2026-05-02 23:01:15 +02:00
|
|
|
const [endDialogOpen, setEndDialogOpen] = useState(false);
|
2026-04-28 02:45:05 +02:00
|
|
|
const reservation = useQuery<{ data: ReservationData }>({
|
|
|
|
|
queryKey: ['reservation', reservationId],
|
|
|
|
|
queryFn: () => apiFetch(`/api/v1/berth-reservations/${reservationId}`),
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const documentsForRes = useQuery<{ data: ReservationDoc[] }>({
|
|
|
|
|
queryKey: ['documents', 'by-reservation', reservationId],
|
|
|
|
|
queryFn: () =>
|
|
|
|
|
apiFetch(
|
|
|
|
|
`/api/v1/documents?documentType=reservation_agreement&signatureOnly=true&limit=10`,
|
|
|
|
|
).then((res) => {
|
|
|
|
|
const r = res as { data: ReservationDoc[] & Array<{ reservationId?: string }> };
|
|
|
|
|
return {
|
|
|
|
|
data: r.data.filter(
|
|
|
|
|
(d: ReservationDoc & { reservationId?: string }) => d.reservationId === reservationId,
|
|
|
|
|
),
|
|
|
|
|
} as { data: ReservationDoc[] };
|
|
|
|
|
}),
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
useRealtimeInvalidation({
|
|
|
|
|
'document:created': [['documents', 'by-reservation', reservationId]],
|
|
|
|
|
'document:completed': [
|
|
|
|
|
['documents', 'by-reservation', reservationId],
|
|
|
|
|
['reservation', reservationId],
|
|
|
|
|
],
|
|
|
|
|
'document:cancelled': [['documents', 'by-reservation', reservationId]],
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
if (reservation.isLoading) {
|
|
|
|
|
return <div className="h-32 animate-pulse rounded-md bg-muted/40" />;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (reservation.error || !reservation.data) {
|
|
|
|
|
return (
|
|
|
|
|
<PageHeader
|
|
|
|
|
title="Reservation not found"
|
|
|
|
|
actions={
|
|
|
|
|
<Button asChild variant="outline">
|
|
|
|
|
<Link href={`/${portSlug}/berths`}>
|
|
|
|
|
<ArrowLeft className="mr-1.5 h-4 w-4" /> Back
|
|
|
|
|
</Link>
|
|
|
|
|
</Button>
|
|
|
|
|
}
|
|
|
|
|
/>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const res = reservation.data.data;
|
|
|
|
|
const docs = documentsForRes.data?.data ?? [];
|
|
|
|
|
const activeAgreement = docs.find((d) => ['sent', 'partially_signed'].includes(d.status));
|
|
|
|
|
const completedAgreement = docs.find((d) => ['completed', 'signed'].includes(d.status));
|
|
|
|
|
|
|
|
|
|
const renderAgreementCard = (): React.ReactNode => {
|
|
|
|
|
if (completedAgreement) {
|
|
|
|
|
return (
|
|
|
|
|
<div className="flex flex-col gap-3 rounded-md border bg-success-bg/50 p-4">
|
|
|
|
|
<div className="flex items-start justify-between gap-2">
|
|
|
|
|
<div>
|
|
|
|
|
<h3 className="text-sm font-semibold text-foreground">Agreement signed</h3>
|
|
|
|
|
<p className="mt-1 text-xs text-muted-foreground">{completedAgreement.title}</p>
|
|
|
|
|
</div>
|
|
|
|
|
<StatusPill status="completed" withDot>
|
|
|
|
|
Completed
|
|
|
|
|
</StatusPill>
|
|
|
|
|
</div>
|
|
|
|
|
<div className="flex flex-wrap items-center gap-2">
|
|
|
|
|
{completedAgreement.signedFileId ? (
|
|
|
|
|
<Button asChild size="sm" variant="outline">
|
|
|
|
|
<Link href={`/api/v1/files/${completedAgreement.signedFileId}/download`}>
|
|
|
|
|
<Download className="mr-1.5 h-4 w-4" /> Download signed PDF
|
|
|
|
|
</Link>
|
|
|
|
|
</Button>
|
|
|
|
|
) : null}
|
|
|
|
|
<Button asChild size="sm" variant="outline">
|
|
|
|
|
<Link href={`/${portSlug}/documents/${completedAgreement.id}`}>
|
|
|
|
|
<Mail className="mr-1.5 h-4 w-4" /> View document
|
|
|
|
|
</Link>
|
|
|
|
|
</Button>
|
|
|
|
|
</div>
|
|
|
|
|
<p className="text-xs text-muted-foreground">
|
|
|
|
|
Signed contract attached to this reservation.
|
|
|
|
|
</p>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (activeAgreement) {
|
|
|
|
|
const signedCount = activeAgreement.signers.filter((s) => s.status === 'signed').length;
|
|
|
|
|
return (
|
|
|
|
|
<div className="flex flex-col gap-3 rounded-md border bg-brand-50 p-4">
|
|
|
|
|
<div className="flex items-start justify-between gap-2">
|
|
|
|
|
<div>
|
|
|
|
|
<h3 className="text-sm font-semibold text-foreground">Agreement out for signing</h3>
|
|
|
|
|
<p className="mt-1 text-xs text-muted-foreground">
|
|
|
|
|
{signedCount}/{activeAgreement.signers.length} signed · {activeAgreement.title}
|
|
|
|
|
</p>
|
|
|
|
|
</div>
|
|
|
|
|
<StatusPill
|
|
|
|
|
status={activeAgreement.status === 'partially_signed' ? 'partial' : 'sent'}
|
|
|
|
|
withDot
|
|
|
|
|
>
|
|
|
|
|
{activeAgreement.status.replace(/_/g, ' ')}
|
|
|
|
|
</StatusPill>
|
|
|
|
|
</div>
|
|
|
|
|
<div className="flex flex-wrap items-center gap-2">
|
|
|
|
|
<Button asChild size="sm">
|
|
|
|
|
<Link href={`/${portSlug}/documents/${activeAgreement.id}`}>
|
|
|
|
|
<FileSignature className="mr-1.5 h-4 w-4" /> View document
|
|
|
|
|
</Link>
|
|
|
|
|
</Button>
|
|
|
|
|
<Button
|
|
|
|
|
size="sm"
|
|
|
|
|
variant="outline"
|
|
|
|
|
onClick={async () => {
|
|
|
|
|
try {
|
|
|
|
|
await apiFetch(`/api/v1/documents/${activeAgreement.id}/remind`, {
|
|
|
|
|
method: 'POST',
|
|
|
|
|
});
|
|
|
|
|
toast.success('Reminder sent');
|
|
|
|
|
} catch (err) {
|
|
|
|
|
toast.error(err instanceof Error ? err.message : 'Failed');
|
|
|
|
|
}
|
|
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
<Bell className="mr-1.5 h-4 w-4" /> Remind signers
|
|
|
|
|
</Button>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<EmptyState
|
|
|
|
|
icon={<FileSignature className="h-7 w-7" />}
|
|
|
|
|
title="No reservation agreement yet"
|
|
|
|
|
body="Generate an agreement for the parties to sign before activation."
|
|
|
|
|
actions={
|
|
|
|
|
<Button asChild>
|
|
|
|
|
<Link
|
|
|
|
|
href={
|
|
|
|
|
`/${portSlug}/documents/new?reservationId=${reservationId}&documentType=reservation_agreement` as Route
|
|
|
|
|
}
|
|
|
|
|
>
|
|
|
|
|
Generate agreement
|
|
|
|
|
</Link>
|
|
|
|
|
</Button>
|
|
|
|
|
}
|
|
|
|
|
/>
|
|
|
|
|
);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<div className="flex flex-col gap-4">
|
|
|
|
|
<PageHeader
|
|
|
|
|
eyebrow="Berth reservation"
|
|
|
|
|
title={`Reservation #${res.id.slice(0, 8)}`}
|
|
|
|
|
description={`${res.tenureType.replace(/_/g, ' ')} · ${new Date(res.startDate).toLocaleDateString('en-GB')}${res.endDate ? ` → ${new Date(res.endDate).toLocaleDateString('en-GB')}` : ''}`}
|
|
|
|
|
kpiLine={
|
|
|
|
|
<>
|
|
|
|
|
<StatusPill status={RESERVATION_PILL[res.status] ?? 'pending'} withDot>
|
|
|
|
|
{res.status}
|
|
|
|
|
</StatusPill>
|
|
|
|
|
{res.contractFileId ? <span>Contract attached</span> : <span>No contract</span>}
|
|
|
|
|
</>
|
|
|
|
|
}
|
|
|
|
|
actions={
|
2026-05-02 23:01:15 +02:00
|
|
|
<div className="flex items-center gap-2">
|
|
|
|
|
{res.status === 'active' && (
|
|
|
|
|
<Button variant="outline" size="sm" onClick={() => setEndDialogOpen(true)}>
|
|
|
|
|
<StopCircle className="mr-1.5 h-4 w-4" />
|
|
|
|
|
End reservation
|
|
|
|
|
</Button>
|
|
|
|
|
)}
|
|
|
|
|
<Button asChild variant="outline">
|
|
|
|
|
<Link href={`/${portSlug}/berths`}>
|
|
|
|
|
<ArrowLeft className="mr-1.5 h-4 w-4" /> Back to berths
|
|
|
|
|
</Link>
|
|
|
|
|
</Button>
|
|
|
|
|
</div>
|
2026-04-28 02:45:05 +02:00
|
|
|
}
|
|
|
|
|
variant="gradient"
|
|
|
|
|
/>
|
|
|
|
|
|
|
|
|
|
<div className="grid gap-4 lg:grid-cols-[2fr_1fr]">
|
|
|
|
|
<div className="flex flex-col gap-4">
|
|
|
|
|
<section className="rounded-md border bg-white p-4">
|
|
|
|
|
<h2 className="mb-2 text-sm font-semibold uppercase tracking-wide text-muted-foreground">
|
|
|
|
|
Reservation details
|
|
|
|
|
</h2>
|
|
|
|
|
<dl className="grid grid-cols-2 gap-3 text-sm">
|
|
|
|
|
<div>
|
|
|
|
|
<dt className="text-xs text-muted-foreground">Berth</dt>
|
2026-05-02 23:01:15 +02:00
|
|
|
<dd className="font-medium">
|
|
|
|
|
<BerthLink berthId={res.berthId} portSlug={portSlug} />
|
2026-04-28 02:45:05 +02:00
|
|
|
</dd>
|
|
|
|
|
</div>
|
|
|
|
|
<div>
|
|
|
|
|
<dt className="text-xs text-muted-foreground">Yacht</dt>
|
2026-05-02 23:01:15 +02:00
|
|
|
<dd className="font-medium">
|
|
|
|
|
<YachtLink yachtId={res.yachtId} portSlug={portSlug} />
|
2026-04-28 02:45:05 +02:00
|
|
|
</dd>
|
|
|
|
|
</div>
|
|
|
|
|
<div>
|
|
|
|
|
<dt className="text-xs text-muted-foreground">Client</dt>
|
2026-05-02 23:01:15 +02:00
|
|
|
<dd className="font-medium">
|
|
|
|
|
<ClientLink clientId={res.clientId} portSlug={portSlug} />
|
2026-04-28 02:45:05 +02:00
|
|
|
</dd>
|
|
|
|
|
</div>
|
|
|
|
|
<div>
|
|
|
|
|
<dt className="text-xs text-muted-foreground">Tenure</dt>
|
|
|
|
|
<dd className="font-medium">{res.tenureType.replace(/_/g, ' ')}</dd>
|
|
|
|
|
</div>
|
|
|
|
|
</dl>
|
|
|
|
|
{res.notes ? (
|
|
|
|
|
<div className="mt-3 border-t pt-3 text-sm">
|
|
|
|
|
<div className="text-xs text-muted-foreground">Notes</div>
|
|
|
|
|
<p className="mt-1 text-foreground whitespace-pre-wrap">{res.notes}</p>
|
|
|
|
|
</div>
|
|
|
|
|
) : null}
|
|
|
|
|
</section>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div className="flex flex-col gap-4">
|
|
|
|
|
<section className="rounded-md border bg-white p-4">
|
|
|
|
|
<h2 className="mb-3 text-sm font-semibold uppercase tracking-wide text-muted-foreground">
|
|
|
|
|
Agreement
|
|
|
|
|
</h2>
|
|
|
|
|
{renderAgreementCard()}
|
|
|
|
|
</section>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
2026-05-02 23:01:15 +02:00
|
|
|
|
|
|
|
|
<EndReservationDialog
|
|
|
|
|
reservationId={reservationId}
|
|
|
|
|
open={endDialogOpen}
|
|
|
|
|
onOpenChange={setEndDialogOpen}
|
|
|
|
|
/>
|
2026-04-28 02:45:05 +02:00
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|