diff --git a/src/app/(dashboard)/[portSlug]/berth-reservations/[id]/page.tsx b/src/app/(dashboard)/[portSlug]/berth-reservations/[id]/page.tsx new file mode 100644 index 0000000..ad5a002 --- /dev/null +++ b/src/app/(dashboard)/[portSlug]/berth-reservations/[id]/page.tsx @@ -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 ; +} diff --git a/src/components/reservations/reservation-detail.tsx b/src/components/reservations/reservation-detail.tsx new file mode 100644 index 0000000..f089fb8 --- /dev/null +++ b/src/components/reservations/reservation-detail.tsx @@ -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 = { + 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
; + } + + if (reservation.error || !reservation.data) { + return ( + + + Back + + + } + /> + ); + } + + 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 ( +
+
+
+

Agreement signed

+

{completedAgreement.title}

+
+ + Completed + +
+
+ {completedAgreement.signedFileId ? ( + + ) : null} + +
+

+ Signed contract attached to this reservation. +

+
+ ); + } + + if (activeAgreement) { + const signedCount = activeAgreement.signers.filter((s) => s.status === 'signed').length; + return ( +
+
+
+

Agreement out for signing

+

+ {signedCount}/{activeAgreement.signers.length} signed · {activeAgreement.title} +

+
+ + {activeAgreement.status.replace(/_/g, ' ')} + +
+
+ + +
+
+ ); + } + + return ( + } + title="No reservation agreement yet" + body="Generate an agreement for the parties to sign before activation." + actions={ + + } + /> + ); + }; + + return ( +
+ + + {res.status} + + {res.contractFileId ? Contract attached : No contract} + + } + actions={ + + } + variant="gradient" + /> + +
+
+
+

+ Reservation details +

+
+
+
Berth
+
+ + {res.berthId.slice(0, 8)}… + +
+
+
+
Yacht
+
+ + {res.yachtId.slice(0, 8)}… + +
+
+
+
Client
+
+ + {res.clientId.slice(0, 8)}… + +
+
+
+
Tenure
+
{res.tenureType.replace(/_/g, ' ')}
+
+
+ {res.notes ? ( +
+
Notes
+

{res.notes}

+
+ ) : null} +
+
+ +
+
+

+ Agreement +

+ {renderAgreementCard()} +
+
+
+
+ ); +} diff --git a/src/lib/services/documents.service.ts b/src/lib/services/documents.service.ts index 1514ce4..9cb5391 100644 --- a/src/lib/services/documents.service.ts +++ b/src/lib/services/documents.service.ts @@ -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 diff --git a/src/lib/templates/merge-fields.ts b/src/lib/templates/merge-fields.ts index c59882e..320373a 100644 --- a/src/lib/templates/merge-fields.ts +++ b/src/lib/templates/merge-fields.ts @@ -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 },