diff --git a/src/app/api/v1/berths/[id]/active-interests/route.ts b/src/app/api/v1/berths/[id]/active-interests/route.ts new file mode 100644 index 00000000..98e35cdb --- /dev/null +++ b/src/app/api/v1/berths/[id]/active-interests/route.ts @@ -0,0 +1,70 @@ +import { NextResponse } from 'next/server'; +import { and, eq, isNull, desc } from 'drizzle-orm'; + +import { withAuth, withPermission } from '@/lib/api/helpers'; +import { errorResponse, NotFoundError } from '@/lib/errors'; +import { db } from '@/lib/db'; +import { berths } from '@/lib/db/schema/berths'; +import { interestBerths, interests } from '@/lib/db/schema/interests'; +import { clients } from '@/lib/db/schema/clients'; + +/** + * GET /api/v1/berths/[id]/active-interests + * + * Lightweight read for the berth-list popover: every non-archived + * non-terminal interest currently linked to this berth, plus the link's + * flags (primary, in-EOI-bundle). Sorted most-recently-updated first so + * the popover surfaces the hottest deals at the top. + * + * Tenancy: the berth row must belong to the caller's port; the inner + * join to interests carries an implicit port filter via the interest. + * Throws NotFoundError when the berth doesn't exist or is cross-port + * (same enumeration-prevention as the other berth routes). + */ +export const GET = withAuth( + withPermission('berths', 'view', async (_req, ctx, params) => { + try { + const berthId = params.id!; + const berth = await db.query.berths.findFirst({ + where: and(eq(berths.id, berthId), eq(berths.portId, ctx.portId)), + columns: { id: true }, + }); + if (!berth) throw new NotFoundError('Berth'); + + const rows = await db + .select({ + interestId: interests.id, + clientName: clients.fullName, + pipelineStage: interests.pipelineStage, + isPrimary: interestBerths.isPrimary, + isInEoiBundle: interestBerths.isInEoiBundle, + updatedAt: interests.updatedAt, + }) + .from(interestBerths) + .innerJoin(interests, eq(interests.id, interestBerths.interestId)) + .innerJoin(clients, eq(clients.id, interests.clientId)) + .where( + and( + eq(interestBerths.berthId, berthId), + eq(interests.portId, ctx.portId), + isNull(interests.archivedAt), + isNull(interests.outcome), + ), + ) + .orderBy(desc(interests.updatedAt)) + .limit(20); + + return NextResponse.json({ + data: rows.map((r) => ({ + interestId: r.interestId, + clientName: r.clientName, + pipelineStage: r.pipelineStage, + isPrimary: r.isPrimary, + isInEoiBundle: r.isInEoiBundle, + })), + }); + } catch (error) { + return errorResponse(error); + } + }), +); diff --git a/src/components/berths/active-interests-popover.tsx b/src/components/berths/active-interests-popover.tsx new file mode 100644 index 00000000..50021a28 --- /dev/null +++ b/src/components/berths/active-interests-popover.tsx @@ -0,0 +1,101 @@ +'use client'; + +import Link from 'next/link'; +import { useQuery } from '@tanstack/react-query'; +import { Loader2, Star } from 'lucide-react'; + +import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'; +import { apiFetch } from '@/lib/api/client'; +import { stageBadgeClass, stageLabel } from '@/lib/constants'; + +interface ActiveInterestRow { + interestId: string; + clientName: string; + pipelineStage: string; + isPrimary: boolean; + isInEoiBundle: boolean; +} + +interface Props { + berthId: string; + portSlug: string; + count: number; +} + +/** + * Click-to-expand popover for the berth-list "Active interests" cell. + * Lazy-loads the linked-interest list when the rep opens it; cached + * for 30s. Each row links to the interest detail page and shows the + * pipeline-stage chip + the primary/EOI-bundle flags so the rep can + * judge urgency without leaving the berth list. + */ +export function ActiveInterestsPopover({ berthId, portSlug, count }: Props) { + // Lazy-load: only fetch when the popover opens. Pattern from the + // detail-label fallback queries elsewhere in the codebase — the + // `enabled` flag flips on first open. + const { data, isLoading, isError } = useQuery<{ data: ActiveInterestRow[] }>({ + queryKey: ['berth', berthId, 'active-interests'], + queryFn: () => + apiFetch<{ data: ActiveInterestRow[] }>(`/api/v1/berths/${berthId}/active-interests`), + staleTime: 30_000, + // The popover only renders when the trigger is clicked, so the + // enabled gate falls out naturally from React Query's behaviour + // inside the conditionally-rendered PopoverContent. + }); + + if (count === 0) return ; + + return ( + + + + + +
+ Active interests +
+ {isLoading ? ( +
+ + Loading… +
+ ) : isError ? ( +
Failed to load interests.
+ ) : (data?.data ?? []).length === 0 ? ( +
No active interests.
+ ) : ( + + )} +
+
+ ); +} diff --git a/src/components/berths/berth-columns.tsx b/src/components/berths/berth-columns.tsx index 92a2ae86..c1de54c4 100644 --- a/src/components/berths/berth-columns.tsx +++ b/src/components/berths/berth-columns.tsx @@ -18,6 +18,7 @@ import { formatCurrency } from '@/lib/utils/currency'; import { mooringLetterDot } from './mooring-letter-tone'; import { stageBadgeClass, stageLabel } from '@/lib/constants'; import { CatchUpWizard } from '@/components/berths/catch-up-wizard'; +import { ActiveInterestsPopover } from '@/components/berths/active-interests-popover'; export type BerthRow = { id: string; @@ -226,6 +227,12 @@ function ActionsCell({ row }: { row: { original: BerthRow } }) { ); } +function ActiveInterestsCell({ berthId, count }: { berthId: string; count: number }) { + const params = useParams<{ portSlug: string }>(); + const portSlug = params?.portSlug ?? ''; + return ; +} + function joinNonNull(parts: Array, sep = ' · '): string { return parts.filter((p): p is string => Boolean(p)).join(sep); } @@ -290,11 +297,12 @@ export const berthColumns: ColumnDef[] = [ id: 'activeInterestCount', accessorKey: 'activeInterestCount', header: 'Active interests', - cell: ({ row }) => { - const n = row.original.activeInterestCount ?? 0; - if (n === 0) return ; - return {n}; - }, + cell: ({ row }) => ( + + ), }, { id: 'sidePontoon', diff --git a/src/components/berths/berth-list.tsx b/src/components/berths/berth-list.tsx index 4ca6e791..fea78537 100644 --- a/src/components/berths/berth-list.tsx +++ b/src/components/berths/berth-list.tsx @@ -3,7 +3,7 @@ import { useEffect } from 'react'; import Link from 'next/link'; import { useRouter, useParams } from 'next/navigation'; -import { Anchor, Plus } from 'lucide-react'; +import { Anchor, Plus, Rows3, Rows4 } from 'lucide-react'; import { useMobileChrome } from '@/components/layout/mobile/mobile-layout-provider'; import { DataTable } from '@/components/shared/data-table'; @@ -66,8 +66,13 @@ export function BerthList() { 'berth:statusChanged': [['berths']], }); - // Persisted column visibility — same pattern as ClientList / InterestList. - const { hidden, setHidden } = useTablePreferences('berths', BERTH_DEFAULT_HIDDEN); + // Persisted column visibility + row density — same pattern as + // ClientList / InterestList; density is new and falls back to + // 'comfortable' for users who haven't picked yet. + const { hidden, setHidden, density, setDensity } = useTablePreferences( + 'berths', + BERTH_DEFAULT_HIDDEN, + ); const columnVisibility = Object.fromEntries(hidden.map((id) => [id, false])); return ( @@ -112,6 +117,24 @@ export function BerthList() { setAllFilters(savedFilters); }} /> +