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,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);
}
}),
);

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>
))}

View File

@@ -38,32 +38,35 @@ export function useTablePreferences(entityType: string, defaultHidden: string[]
staleTime: 5 * 60_000,
});
const remoteHidden =
meQuery.data?.data.preferences?.tablePreferences?.[entityType]?.hiddenColumns;
const remoteEntry = meQuery.data?.data.preferences?.tablePreferences?.[entityType];
const remoteHidden = remoteEntry?.hiddenColumns;
const remoteDensity = remoteEntry?.density;
// Local edits win over the server-loaded prefs. The render-phase
// derivation below (line 107: `localHidden ?? remoteHidden ?? defaultHidden`)
// derivation below (`localHidden ?? remoteHidden ?? defaultHidden`)
// replaces the prior useEffect(setLocalHidden, [remoteHidden]) sync
// that the Compiler flagged as set-state-in-effect.
const [localHidden, setLocalHidden] = useState<string[] | null>(null);
const [localDensity, setLocalDensity] = useState<'comfortable' | 'compact' | null>(null);
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const setHidden = useCallback(
(next: string[]) => {
setLocalHidden(next);
// Debounce the PATCH so a user clicking through 5 checkboxes
// produces 1 server round-trip, not 5.
// Single PATCH for both hidden + density edits. Each setter writes to
// its local state immediately for instant UX, then the debounced
// network round-trip merges the entity's current preferences.
const flush = useCallback(
(patch: Partial<TablePreferences>) => {
if (debounceRef.current) clearTimeout(debounceRef.current);
debounceRef.current = setTimeout(() => {
const existing = meQuery.data?.data.preferences?.tablePreferences ?? {};
const merged: TablePreferences = {
...(existing[entityType] ?? {}),
...patch,
};
const updated: Record<string, TablePreferences> = {
...existing,
[entityType]: { hiddenColumns: next },
[entityType]: merged,
};
// Optimistic cache update so a refetch doesn't blow away the
// local state; the server response will overwrite either way.
queryClient.setQueryData<MeResponse>(['me'], (old) => {
if (!old) return old;
return {
@@ -90,6 +93,22 @@ export function useTablePreferences(entityType: string, defaultHidden: string[]
[entityType, meQuery.data, queryClient],
);
const setHidden = useCallback(
(next: string[]) => {
setLocalHidden(next);
flush({ hiddenColumns: next });
},
[flush],
);
const setDensity = useCallback(
(next: 'comfortable' | 'compact') => {
setLocalDensity(next);
flush({ density: next });
},
[flush],
);
// Cleanup pending timer on unmount so React doesn't warn about
// setting state after the component is gone.
useEffect(
@@ -105,10 +124,13 @@ export function useTablePreferences(entityType: string, defaultHidden: string[]
// touch it" behavior — saved value (even []) wins, defaults only fill
// the never-saved case.
const resolved = localHidden ?? remoteHidden ?? defaultHidden;
const density: 'comfortable' | 'compact' = localDensity ?? remoteDensity ?? 'comfortable';
return {
hidden: resolved,
setHidden,
density,
setDensity,
isLoaded: !meQuery.isLoading,
};
}

View File

@@ -174,6 +174,10 @@ export type RolePermissions = {
*/
export type TablePreferences = {
hiddenColumns?: string[];
/** Row density. Absent = comfortable (the default; matches shadcn's
* default cell padding). 'compact' drops vertical padding so reps
* can scan more rows per viewport. */
density?: 'comfortable' | 'compact';
};
export type UserPreferences = {