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

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