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:
101
src/components/berths/active-interests-popover.tsx
Normal file
101
src/components/berths/active-interests-popover.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -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={{
|
||||
|
||||
@@ -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>
|
||||
))}
|
||||
|
||||
Reference in New Issue
Block a user