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

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