From 72f50b681c01135d4c8135696d58e381fe91f597 Mon Sep 17 00:00:00 2001 From: Matt Date: Sat, 9 May 2026 18:37:16 +0200 Subject: [PATCH] feat(berths): split Documents tab into Spec + Deal Documents MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Berth detail page now has two tabs: - Spec: the existing versioned berth-spec PDF surface (current panel, version history, parser badge). - Deal Documents: NEW. Lists EOIs / contracts / etc. attached to interests currently linked to this berth via interest_berths. New service helper listDealDocumentsForBerth joins documents → interests → interest_berths with a port_id guard on both sides. GET /api/v1/berths/[id]/deal-documents wraps it, gated on berths.view. Read-only — title, type, status badge, and an Open link to the source interest page. Edits / sends still happen on the interest's own page. The Spec tab paragraph now points reps to the new Deal Documents tab instead of telling them to navigate via Interests. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../v1/berths/[id]/deal-documents/route.ts | 24 +++++ .../berths/berth-deal-documents-tab.tsx | 90 +++++++++++++++++++ src/components/berths/berth-documents-tab.tsx | 4 +- src/components/berths/berth-tabs.tsx | 10 ++- src/lib/services/documents.service.ts | 61 ++++++++++++- 5 files changed, 184 insertions(+), 5 deletions(-) create mode 100644 src/app/api/v1/berths/[id]/deal-documents/route.ts create mode 100644 src/components/berths/berth-deal-documents-tab.tsx diff --git a/src/app/api/v1/berths/[id]/deal-documents/route.ts b/src/app/api/v1/berths/[id]/deal-documents/route.ts new file mode 100644 index 00000000..f6e24bce --- /dev/null +++ b/src/app/api/v1/berths/[id]/deal-documents/route.ts @@ -0,0 +1,24 @@ +import { NextResponse } from 'next/server'; + +import { withAuth, withPermission } from '@/lib/api/helpers'; +import { errorResponse, NotFoundError } from '@/lib/errors'; +import { listDealDocumentsForBerth } from '@/lib/services/documents.service'; + +/** + * GET /api/v1/berths/[id]/deal-documents + * + * Lists documents attached to interests currently linked to this berth. + * Same permission gate as the berth page itself (berths.view). + */ +export const GET = withAuth( + withPermission('berths', 'view', async (_req, ctx, params) => { + try { + const berthId = params.id; + if (!berthId) throw new NotFoundError('Berth'); + const data = await listDealDocumentsForBerth(ctx.portId, berthId); + return NextResponse.json({ data }); + } catch (error) { + return errorResponse(error); + } + }), +); diff --git a/src/components/berths/berth-deal-documents-tab.tsx b/src/components/berths/berth-deal-documents-tab.tsx new file mode 100644 index 00000000..adc94f4d --- /dev/null +++ b/src/components/berths/berth-deal-documents-tab.tsx @@ -0,0 +1,90 @@ +'use client'; + +import Link from 'next/link'; +import { useParams } from 'next/navigation'; +import { useQuery } from '@tanstack/react-query'; +import { FileText, ExternalLink } from 'lucide-react'; + +import { apiFetch } from '@/lib/api/client'; +import { Badge } from '@/components/ui/badge'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; + +interface BerthDealDoc { + id: string; + title: string; + documentType: string; + status: string; + createdAt: string; + interestId: string; +} + +const STATUS_TONE: Record = { + draft: 'outline', + sent: 'secondary', + partially_signed: 'secondary', + completed: 'default', + expired: 'destructive', + cancelled: 'destructive', +}; + +export function BerthDealDocumentsTab({ berthId }: { berthId: string }) { + const params = useParams<{ portSlug: string }>(); + const portSlug = params?.portSlug ?? ''; + + const { data: docs = [], isLoading } = useQuery({ + queryKey: ['berth-deal-documents', berthId], + queryFn: () => + apiFetch<{ data: BerthDealDoc[] }>(`/api/v1/berths/${berthId}/deal-documents`).then( + (r) => r.data, + ), + }); + + return ( +
+

+ EOIs, contracts, and other deal documents attached to interests currently linked to this + berth. Read-only — to send, sign, or edit, open the document on the linked interest's + page. +

+ + + Linked deal documents + + + {isLoading ? ( +

Loading…

+ ) : docs.length === 0 ? ( +

+ No deal documents yet. Documents created on a linked interest will appear here. +

+ ) : ( +
    + {docs.map((doc) => ( +
  • +
    + + {doc.title} + {doc.documentType} +
    +
    + {doc.status} + + Open + +
    +
  • + ))} +
+ )} +
+
+
+ ); +} diff --git a/src/components/berths/berth-documents-tab.tsx b/src/components/berths/berth-documents-tab.tsx index 6ad8ee19..2d57c7fa 100644 --- a/src/components/berths/berth-documents-tab.tsx +++ b/src/components/berths/berth-documents-tab.tsx @@ -164,8 +164,8 @@ export function BerthDocumentsTab({ berthId }: { berthId: string }) {

Berth-spec PDF: the dimensional drawing or surveyor sheet for this slip. Versioned so a - misparse can be rolled back. For documents tied to a prospect on this berth (EOI, contract, - etc.), open the matching deal in the Interests tab. + misparse can be rolled back. Deal documents (EOI, contract, etc.) live on the “Deal + Documents” tab.

diff --git a/src/components/berths/berth-tabs.tsx b/src/components/berths/berth-tabs.tsx index c73008aa..668497a8 100644 --- a/src/components/berths/berth-tabs.tsx +++ b/src/components/berths/berth-tabs.tsx @@ -23,6 +23,7 @@ import { BerthReservationsTab } from './berth-reservations-tab'; import { BerthInterestsTab } from './berth-interests-tab'; import { BerthInterestPulse } from './berth-interest-pulse'; import { BerthDocumentsTab } from './berth-documents-tab'; +import { BerthDealDocumentsTab } from './berth-deal-documents-tab'; type BerthData = { id: string; @@ -413,10 +414,15 @@ export function buildBerthTabs(berth: BerthData): DetailTab[] { content: , }, { - id: 'documents', - label: 'Documents', + id: 'spec', + label: 'Spec', content: , }, + { + id: 'deal-documents', + label: 'Deal Documents', + content: , + }, // Waiting List + Maintenance Log tabs were stubs ("coming soon") // visible to every operator. Hidden here until the // berth_waiting_list / berth_maintenance_log feature surfaces ship. diff --git a/src/lib/services/documents.service.ts b/src/lib/services/documents.service.ts index dad58d39..4af2dc85 100644 --- a/src/lib/services/documents.service.ts +++ b/src/lib/services/documents.service.ts @@ -8,7 +8,7 @@ import { documentWatchers, files, } from '@/lib/db/schema/documents'; -import { interests } from '@/lib/db/schema/interests'; +import { interests, interestBerths } from '@/lib/db/schema/interests'; import { clients } from '@/lib/db/schema/clients'; import { companies } from '@/lib/db/schema/companies'; import { yachts } from '@/lib/db/schema/yachts'; @@ -213,6 +213,65 @@ export async function listDocuments( }); } +// ─── Deal docs for a berth ──────────────────────────────────────────────────── + +export interface BerthDealDoc { + id: string; + title: string; + documentType: string; + status: string; + createdAt: Date; + interestId: string; +} + +/** + * Documents attached to any interest currently linked to this berth via + * `interest_berths`. Used by the Deal Documents tab on the berth detail + * page so reps can see EOIs / contracts / etc. associated with active + * prospects on this slip without leaving the page. + * + * Read-only; visibility piggybacks on the interest tenancy (the + * permission-gate on the berth page guards entry, and we only return + * documents for interests in the same port). Edits / sends still happen + * from the interest's own page. + */ +export async function listDealDocumentsForBerth( + portId: string, + berthId: string, +): Promise { + const rows = await db + .select({ + id: documents.id, + title: documents.title, + documentType: documents.documentType, + status: documents.status, + createdAt: documents.createdAt, + interestId: documents.interestId, + }) + .from(documents) + .innerJoin(interestBerths, eq(interestBerths.interestId, documents.interestId)) + .innerJoin(interests, eq(interests.id, documents.interestId)) + .where( + and( + eq(interestBerths.berthId, berthId), + eq(documents.portId, portId), + eq(interests.portId, portId), + ), + ) + .orderBy(sql`${documents.createdAt} DESC`); + + return rows + .filter((r): r is typeof r & { interestId: string } => Boolean(r.interestId)) + .map((r) => ({ + id: r.id, + title: r.title, + documentType: r.documentType, + status: r.status, + createdAt: r.createdAt, + interestId: r.interestId, + })); +} + // ─── Hub tab counts ─────────────────────────────────────────────────────────── export interface HubTabCounts {