feat(reservations): detail page with agreement flow + contract mirror
Adds /berth-reservations/[id] with state-aware agreement card (none / in-flight / completed) and the Generate-agreement entry point that opens the wizard prefilled. handleDocumentCompleted now mirrors a signed reservation_agreement onto berth_reservations.contractFileId so the portal can resolve contracts without joining through documents. Reservation merge tokens (startDate/endDate/tenureType/termSummary/ signedDate) added to the catalog. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,10 @@
|
||||
import { ReservationDetail } from '@/components/reservations/reservation-detail';
|
||||
|
||||
interface PageProps {
|
||||
params: Promise<{ portSlug: string; id: string }>;
|
||||
}
|
||||
|
||||
export default async function ReservationDetailPage({ params }: PageProps) {
|
||||
const { portSlug, id } = await params;
|
||||
return <ReservationDetail reservationId={id} portSlug={portSlug} />;
|
||||
}
|
||||
292
src/components/reservations/reservation-detail.tsx
Normal file
292
src/components/reservations/reservation-detail.tsx
Normal file
@@ -0,0 +1,292 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import type { Route } from 'next';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { ArrowLeft, Bell, Download, FileSignature, Mail } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
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';
|
||||
|
||||
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',
|
||||
};
|
||||
|
||||
interface ReservationDetailProps {
|
||||
reservationId: string;
|
||||
portSlug: string;
|
||||
}
|
||||
|
||||
export function ReservationDetail({ reservationId, portSlug }: ReservationDetailProps) {
|
||||
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={
|
||||
<Button asChild variant="outline">
|
||||
<Link href={`/${portSlug}/berths`}>
|
||||
<ArrowLeft className="mr-1.5 h-4 w-4" /> Back to berths
|
||||
</Link>
|
||||
</Button>
|
||||
}
|
||||
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>
|
||||
<dd>
|
||||
<Link
|
||||
href={`/${portSlug}/berths/${res.berthId}` as Route}
|
||||
className="font-medium text-brand hover:underline"
|
||||
>
|
||||
{res.berthId.slice(0, 8)}…
|
||||
</Link>
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-xs text-muted-foreground">Yacht</dt>
|
||||
<dd>
|
||||
<Link
|
||||
href={`/${portSlug}/yachts/${res.yachtId}` as Route}
|
||||
className="font-medium text-brand hover:underline"
|
||||
>
|
||||
{res.yachtId.slice(0, 8)}…
|
||||
</Link>
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-xs text-muted-foreground">Client</dt>
|
||||
<dd>
|
||||
<Link
|
||||
href={`/${portSlug}/clients/${res.clientId}` as Route}
|
||||
className="font-medium text-brand hover:underline"
|
||||
>
|
||||
{res.clientId.slice(0, 8)}…
|
||||
</Link>
|
||||
</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>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -725,6 +725,17 @@ export async function handleDocumentCompleted(eventData: { documentId: string })
|
||||
.update(documents)
|
||||
.set({ status: 'completed', signedFileId: fileRecord!.id, updatedAt: new Date() })
|
||||
.where(eq(documents.id, doc.id));
|
||||
|
||||
// Reservation agreements mirror their signed PDF onto
|
||||
// berth_reservations.contractFileId so the portal "My Reservations" view
|
||||
// can resolve the contract without joining through documents.
|
||||
if (doc.documentType === 'reservation_agreement' && doc.reservationId) {
|
||||
const { berthReservations } = await import('@/lib/db/schema/reservations');
|
||||
await db
|
||||
.update(berthReservations)
|
||||
.set({ contractFileId: fileRecord!.id, updatedAt: new Date() })
|
||||
.where(eq(berthReservations.id, doc.reservationId));
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error({ err, documentId: doc.id }, 'Failed to download/store signed PDF');
|
||||
await db
|
||||
|
||||
@@ -61,6 +61,13 @@ export const MERGE_FIELDS: MergeFieldCatalog = {
|
||||
{ token: '{{berth.tenureType}}', label: 'Tenure Type', required: false },
|
||||
{ token: '{{berth.tenureYears}}', label: 'Tenure Years', required: false },
|
||||
],
|
||||
reservation: [
|
||||
{ token: '{{reservation.startDate}}', label: 'Reservation Start Date', required: false },
|
||||
{ token: '{{reservation.endDate}}', label: 'Reservation End Date', required: false },
|
||||
{ token: '{{reservation.tenureType}}', label: 'Reservation Tenure Type', required: false },
|
||||
{ token: '{{reservation.termSummary}}', label: 'Reservation Term Summary', required: false },
|
||||
{ token: '{{reservation.signedDate}}', label: 'Reservation Signed Date', required: false },
|
||||
],
|
||||
port: [
|
||||
{ token: '{{port.name}}', label: 'Port Name', required: false },
|
||||
{ token: '{{port.defaultCurrency}}', label: 'Default Currency', required: false },
|
||||
|
||||
Reference in New Issue
Block a user