feat(berths): active-interests popover + row-density toggle on berth list

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) <noreply@anthropic.com>
This commit is contained in:
2026-05-21 19:56:00 +02:00
parent 3999d4bbea
commit 292a8b5e4a
7 changed files with 261 additions and 21 deletions

View File

@@ -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 <span className="text-muted-foreground"></span>;
return (
<Popover>
<PopoverTrigger asChild>
<button
type="button"
className="inline-flex items-center gap-1 rounded-sm px-1 font-medium tabular-nums hover:bg-muted/60 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand/40"
aria-label={`Show ${count} active ${count === 1 ? 'interest' : 'interests'}`}
>
{count}
</button>
</PopoverTrigger>
<PopoverContent side="right" align="start" className="w-72 p-2">
<div className="mb-1 px-1 text-xs font-semibold text-muted-foreground uppercase tracking-wide">
Active interests
</div>
{isLoading ? (
<div className="flex items-center gap-2 px-2 py-3 text-sm text-muted-foreground">
<Loader2 className="h-3.5 w-3.5 animate-spin" aria-hidden />
Loading
</div>
) : isError ? (
<div className="px-2 py-3 text-sm text-destructive">Failed to load interests.</div>
) : (data?.data ?? []).length === 0 ? (
<div className="px-2 py-3 text-sm text-muted-foreground">No active interests.</div>
) : (
<ul className="divide-y">
{(data?.data ?? []).map((row) => (
<li key={row.interestId} className="px-1 py-1.5">
<Link
href={`/${portSlug}/interests/${row.interestId}`}
className="flex items-center justify-between gap-2 rounded-sm px-1 py-0.5 text-sm hover:bg-muted/60"
>
<span className="flex min-w-0 items-center gap-1 truncate">
{row.isPrimary ? (
<Star className="h-3 w-3 shrink-0 text-amber-500" aria-hidden />
) : null}
<span className="truncate">{row.clientName}</span>
</span>
<span
className={`shrink-0 inline-flex items-center rounded-full px-2 py-0.5 text-[10px] font-medium ${stageBadgeClass(
row.pipelineStage,
)}`}
>
{stageLabel(row.pipelineStage)}
</span>
</Link>
</li>
))}
</ul>
)}
</PopoverContent>
</Popover>
);
}

View File

@@ -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 <ActiveInterestsPopover berthId={berthId} portSlug={portSlug} count={count} />;
}
function joinNonNull(parts: Array<string | null | undefined>, sep = ' · '): string {
return parts.filter((p): p is string => Boolean(p)).join(sep);
}
@@ -290,11 +297,12 @@ export const berthColumns: ColumnDef<BerthRow, unknown>[] = [
id: 'activeInterestCount',
accessorKey: 'activeInterestCount',
header: 'Active interests',
cell: ({ row }) => {
const n = row.original.activeInterestCount ?? 0;
if (n === 0) return <span className="text-muted-foreground"></span>;
return <span className="font-medium tabular-nums">{n}</span>;
},
cell: ({ row }) => (
<ActiveInterestsCell
berthId={row.original.id}
count={row.original.activeInterestCount ?? 0}
/>
),
},
{
id: 'sidePontoon',

View File

@@ -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);
}}
/>
<Button
type="button"
size="sm"
variant="outline"
onClick={() => setDensity(density === 'compact' ? 'comfortable' : 'compact')}
aria-label={
density === 'compact'
? 'Switch to comfortable row spacing'
: 'Switch to compact row spacing'
}
title={density === 'compact' ? 'Comfortable rows' : 'Compact rows'}
>
{density === 'compact' ? (
<Rows3 className="h-4 w-4" aria-hidden />
) : (
<Rows4 className="h-4 w-4" aria-hidden />
)}
</Button>
<ColumnPicker columns={BERTH_COLUMN_OPTIONS} hidden={hidden} onChange={setHidden} />
{canBulkAdd && (
<Button asChild size="sm" variant="default">
@@ -127,6 +150,7 @@ export function BerthList() {
<DataTable<BerthRow>
columns={berthColumns}
columnVisibility={columnVisibility}
density={density}
data={data}
isLoading={isLoading}
pagination={{

View File

@@ -82,6 +82,13 @@ interface DataTableProps<TData> {
* needing a preferences migration.
*/
columnVisibility?: VisibilityState;
/**
* Row density. `'comfortable'` (default) keeps shadcn's default
* cell padding. `'compact'` drops vertical padding so reps can scan
* more rows per viewport, useful for berth tables and admin lists.
* Affects desktop only — the mobile card list ignores it.
*/
density?: 'comfortable' | 'compact';
/**
* Opt-in row virtualization. Only renders rows in the viewport (plus a
* small overscan), so a 5000-row client-export list stays at 60 fps.
@@ -125,6 +132,7 @@ export function DataTable<TData>({
cardRender,
mobileGroupBy,
columnVisibility,
density = 'comfortable',
virtual,
virtualHeightPx = 600,
virtualRowHeightPx = 48,
@@ -312,7 +320,10 @@ export function DataTable<TData>({
onClick={() => onRowClick?.(row.original)}
>
{row.getVisibleCells().map((cell) => (
<TableCell key={cell.id}>
<TableCell
key={cell.id}
className={density === 'compact' ? 'py-1.5' : undefined}
>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</TableCell>
))}