From 292a8b5e4a0cb8a65f5f7b7db19b0149a893ca70 Mon Sep 17 00:00:00 2001 From: Matt Date: Thu, 21 May 2026 19:56:00 +0200 Subject: [PATCH] feat(berths): active-interests popover + row-density toggle on berth list MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two complementary UX upgrades on the berth list: 1. Active-interests popover — replaces the plain "Active interests" count cell with a click-to-expand popover. Each row shows the linked deal's client name, pipeline stage (with stage-badge tint), and a primary-star icon. Lazy-loads on first open (30s stale), capped at 20 entries server-side, sorted most-recently-updated first. Backed by `GET /api/v1/berths/[id]/active-interests`. 2. Row-density toggle — DataTable gains a `density: 'comfortable' | 'compact'` prop. Compact drops cell vertical padding from py-3 to py-1.5 so reps can scan many more berths per viewport on the high-density admin lists. Persisted alongside hidden-columns in `user_profiles.preferences. tablePreferences[entityType].density`. Hook returns `density + setDensity`; defaults to 'comfortable' for users who haven't chosen. The setter shares the same debounced PATCH with setHidden so toggling both doesn't multiply the network round-trips. Toolbar adds a Rows3/Rows4 icon button between the saved-views dropdown and the ColumnPicker. tooltip + aria-label flip to communicate the next state. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../v1/berths/[id]/active-interests/route.ts | 70 ++++++++++++ .../berths/active-interests-popover.tsx | 101 ++++++++++++++++++ src/components/berths/berth-columns.tsx | 18 +++- src/components/berths/berth-list.tsx | 30 +++++- src/components/shared/data-table.tsx | 13 ++- src/hooks/use-table-preferences.ts | 46 +++++--- src/lib/db/schema/users.ts | 4 + 7 files changed, 261 insertions(+), 21 deletions(-) create mode 100644 src/app/api/v1/berths/[id]/active-interests/route.ts create mode 100644 src/components/berths/active-interests-popover.tsx 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.
+ ) : ( +
    + {(data?.data ?? []).map((row) => ( +
  • + + + {row.isPrimary ? ( + + ) : null} + {row.clientName} + + + {stageLabel(row.pipelineStage)} + + +
  • + ))} +
+ )} +
+
+ ); +} 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); }} /> +