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:
70
src/app/api/v1/berths/[id]/active-interests/route.ts
Normal file
70
src/app/api/v1/berths/[id]/active-interests/route.ts
Normal 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);
|
||||
}
|
||||
}),
|
||||
);
|
||||
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>
|
||||
))}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
Reference in New Issue
Block a user