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 { mooringLetterDot } from './mooring-letter-tone';
|
||||||
import { stageBadgeClass, stageLabel } from '@/lib/constants';
|
import { stageBadgeClass, stageLabel } from '@/lib/constants';
|
||||||
import { CatchUpWizard } from '@/components/berths/catch-up-wizard';
|
import { CatchUpWizard } from '@/components/berths/catch-up-wizard';
|
||||||
|
import { ActiveInterestsPopover } from '@/components/berths/active-interests-popover';
|
||||||
|
|
||||||
export type BerthRow = {
|
export type BerthRow = {
|
||||||
id: string;
|
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 {
|
function joinNonNull(parts: Array<string | null | undefined>, sep = ' · '): string {
|
||||||
return parts.filter((p): p is string => Boolean(p)).join(sep);
|
return parts.filter((p): p is string => Boolean(p)).join(sep);
|
||||||
}
|
}
|
||||||
@@ -290,11 +297,12 @@ export const berthColumns: ColumnDef<BerthRow, unknown>[] = [
|
|||||||
id: 'activeInterestCount',
|
id: 'activeInterestCount',
|
||||||
accessorKey: 'activeInterestCount',
|
accessorKey: 'activeInterestCount',
|
||||||
header: 'Active interests',
|
header: 'Active interests',
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => (
|
||||||
const n = row.original.activeInterestCount ?? 0;
|
<ActiveInterestsCell
|
||||||
if (n === 0) return <span className="text-muted-foreground">—</span>;
|
berthId={row.original.id}
|
||||||
return <span className="font-medium tabular-nums">{n}</span>;
|
count={row.original.activeInterestCount ?? 0}
|
||||||
},
|
/>
|
||||||
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'sidePontoon',
|
id: 'sidePontoon',
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
import { useEffect } from 'react';
|
import { useEffect } from 'react';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { useRouter, useParams } from 'next/navigation';
|
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 { useMobileChrome } from '@/components/layout/mobile/mobile-layout-provider';
|
||||||
import { DataTable } from '@/components/shared/data-table';
|
import { DataTable } from '@/components/shared/data-table';
|
||||||
@@ -66,8 +66,13 @@ export function BerthList() {
|
|||||||
'berth:statusChanged': [['berths']],
|
'berth:statusChanged': [['berths']],
|
||||||
});
|
});
|
||||||
|
|
||||||
// Persisted column visibility — same pattern as ClientList / InterestList.
|
// Persisted column visibility + row density — same pattern as
|
||||||
const { hidden, setHidden } = useTablePreferences('berths', BERTH_DEFAULT_HIDDEN);
|
// 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]));
|
const columnVisibility = Object.fromEntries(hidden.map((id) => [id, false]));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -112,6 +117,24 @@ export function BerthList() {
|
|||||||
setAllFilters(savedFilters);
|
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} />
|
<ColumnPicker columns={BERTH_COLUMN_OPTIONS} hidden={hidden} onChange={setHidden} />
|
||||||
{canBulkAdd && (
|
{canBulkAdd && (
|
||||||
<Button asChild size="sm" variant="default">
|
<Button asChild size="sm" variant="default">
|
||||||
@@ -127,6 +150,7 @@ export function BerthList() {
|
|||||||
<DataTable<BerthRow>
|
<DataTable<BerthRow>
|
||||||
columns={berthColumns}
|
columns={berthColumns}
|
||||||
columnVisibility={columnVisibility}
|
columnVisibility={columnVisibility}
|
||||||
|
density={density}
|
||||||
data={data}
|
data={data}
|
||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
pagination={{
|
pagination={{
|
||||||
|
|||||||
@@ -82,6 +82,13 @@ interface DataTableProps<TData> {
|
|||||||
* needing a preferences migration.
|
* needing a preferences migration.
|
||||||
*/
|
*/
|
||||||
columnVisibility?: VisibilityState;
|
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
|
* 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.
|
* small overscan), so a 5000-row client-export list stays at 60 fps.
|
||||||
@@ -125,6 +132,7 @@ export function DataTable<TData>({
|
|||||||
cardRender,
|
cardRender,
|
||||||
mobileGroupBy,
|
mobileGroupBy,
|
||||||
columnVisibility,
|
columnVisibility,
|
||||||
|
density = 'comfortable',
|
||||||
virtual,
|
virtual,
|
||||||
virtualHeightPx = 600,
|
virtualHeightPx = 600,
|
||||||
virtualRowHeightPx = 48,
|
virtualRowHeightPx = 48,
|
||||||
@@ -312,7 +320,10 @@ export function DataTable<TData>({
|
|||||||
onClick={() => onRowClick?.(row.original)}
|
onClick={() => onRowClick?.(row.original)}
|
||||||
>
|
>
|
||||||
{row.getVisibleCells().map((cell) => (
|
{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())}
|
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -38,32 +38,35 @@ export function useTablePreferences(entityType: string, defaultHidden: string[]
|
|||||||
staleTime: 5 * 60_000,
|
staleTime: 5 * 60_000,
|
||||||
});
|
});
|
||||||
|
|
||||||
const remoteHidden =
|
const remoteEntry = meQuery.data?.data.preferences?.tablePreferences?.[entityType];
|
||||||
meQuery.data?.data.preferences?.tablePreferences?.[entityType]?.hiddenColumns;
|
const remoteHidden = remoteEntry?.hiddenColumns;
|
||||||
|
const remoteDensity = remoteEntry?.density;
|
||||||
// Local edits win over the server-loaded prefs. The render-phase
|
// 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
|
// replaces the prior useEffect(setLocalHidden, [remoteHidden]) sync
|
||||||
// that the Compiler flagged as set-state-in-effect.
|
// that the Compiler flagged as set-state-in-effect.
|
||||||
const [localHidden, setLocalHidden] = useState<string[] | null>(null);
|
const [localHidden, setLocalHidden] = useState<string[] | null>(null);
|
||||||
|
const [localDensity, setLocalDensity] = useState<'comfortable' | 'compact' | null>(null);
|
||||||
|
|
||||||
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
|
||||||
const setHidden = useCallback(
|
// Single PATCH for both hidden + density edits. Each setter writes to
|
||||||
(next: string[]) => {
|
// its local state immediately for instant UX, then the debounced
|
||||||
setLocalHidden(next);
|
// network round-trip merges the entity's current preferences.
|
||||||
|
const flush = useCallback(
|
||||||
// Debounce the PATCH so a user clicking through 5 checkboxes
|
(patch: Partial<TablePreferences>) => {
|
||||||
// produces 1 server round-trip, not 5.
|
|
||||||
if (debounceRef.current) clearTimeout(debounceRef.current);
|
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||||||
debounceRef.current = setTimeout(() => {
|
debounceRef.current = setTimeout(() => {
|
||||||
const existing = meQuery.data?.data.preferences?.tablePreferences ?? {};
|
const existing = meQuery.data?.data.preferences?.tablePreferences ?? {};
|
||||||
|
const merged: TablePreferences = {
|
||||||
|
...(existing[entityType] ?? {}),
|
||||||
|
...patch,
|
||||||
|
};
|
||||||
const updated: Record<string, TablePreferences> = {
|
const updated: Record<string, TablePreferences> = {
|
||||||
...existing,
|
...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) => {
|
queryClient.setQueryData<MeResponse>(['me'], (old) => {
|
||||||
if (!old) return old;
|
if (!old) return old;
|
||||||
return {
|
return {
|
||||||
@@ -90,6 +93,22 @@ export function useTablePreferences(entityType: string, defaultHidden: string[]
|
|||||||
[entityType, meQuery.data, queryClient],
|
[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
|
// Cleanup pending timer on unmount so React doesn't warn about
|
||||||
// setting state after the component is gone.
|
// setting state after the component is gone.
|
||||||
useEffect(
|
useEffect(
|
||||||
@@ -105,10 +124,13 @@ export function useTablePreferences(entityType: string, defaultHidden: string[]
|
|||||||
// touch it" behavior — saved value (even []) wins, defaults only fill
|
// touch it" behavior — saved value (even []) wins, defaults only fill
|
||||||
// the never-saved case.
|
// the never-saved case.
|
||||||
const resolved = localHidden ?? remoteHidden ?? defaultHidden;
|
const resolved = localHidden ?? remoteHidden ?? defaultHidden;
|
||||||
|
const density: 'comfortable' | 'compact' = localDensity ?? remoteDensity ?? 'comfortable';
|
||||||
|
|
||||||
return {
|
return {
|
||||||
hidden: resolved,
|
hidden: resolved,
|
||||||
setHidden,
|
setHidden,
|
||||||
|
density,
|
||||||
|
setDensity,
|
||||||
isLoaded: !meQuery.isLoading,
|
isLoaded: !meQuery.isLoading,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -174,6 +174,10 @@ export type RolePermissions = {
|
|||||||
*/
|
*/
|
||||||
export type TablePreferences = {
|
export type TablePreferences = {
|
||||||
hiddenColumns?: string[];
|
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 = {
|
export type UserPreferences = {
|
||||||
|
|||||||
Reference in New Issue
Block a user