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:
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user