feat(uat-p5): long-tail polish - tag chips, notes counts, hub context, tenancies toggle
- StageStepper renders now carry tag chips next to the progress bar (client interest cards, pipeline summary, preview sheet). - Notes tab badge on the interest detail aggregates note counts across the interest, the linked client, the linked yacht, and any companies the client is an active member of - reps see the full surface area at a glance. - Admin Settings: Tenancies Module toggle wired into the Feature Flags card. Disabling hides nav/tabs without deleting any rows; re-enabling brings them back. Service layer was already complete; this surfaces the control on the operations page. - HubRoot recent-files rows now show folder breadcrumb + entity badge (Interest/Client/Yacht/Company) so reps can tell at a glance where a file lives. Backed by listFiles enrichment (5 batched lookups per page; no per-row queries). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -311,7 +311,7 @@ export async function listFiles(portId: string, query: ListFilesInput) {
|
||||
const sortColumn =
|
||||
sort === 'filename' ? files.filename : sort === 'sizeBytes' ? files.sizeBytes : files.createdAt;
|
||||
|
||||
return buildListQuery({
|
||||
const base = await buildListQuery({
|
||||
table: files,
|
||||
portIdColumn: files.portId,
|
||||
portId,
|
||||
@@ -325,6 +325,91 @@ export async function listFiles(portId: string, query: ListFilesInput) {
|
||||
pageSize: limit,
|
||||
// no archivedAtColumn - files are immutable records
|
||||
});
|
||||
|
||||
// Enrichment pass: resolve folder name + display name for any entity
|
||||
// FK present on the row. Drives the HubRoot recent-files context
|
||||
// (folder breadcrumb + entity badge) without forcing each caller to
|
||||
// chase separate queries. Batched so adding 20 files = 5 queries max.
|
||||
const rows = base.data as Array<{
|
||||
id: string;
|
||||
folderId: string | null;
|
||||
clientId: string | null;
|
||||
yachtId: string | null;
|
||||
companyId: string | null;
|
||||
interestId: string | null;
|
||||
}>;
|
||||
const folderIds = Array.from(
|
||||
new Set(rows.map((r) => r.folderId).filter((v): v is string => !!v)),
|
||||
);
|
||||
const clientIds = Array.from(
|
||||
new Set(rows.map((r) => r.clientId).filter((v): v is string => !!v)),
|
||||
);
|
||||
const yachtIds = Array.from(new Set(rows.map((r) => r.yachtId).filter((v): v is string => !!v)));
|
||||
const companyIds = Array.from(
|
||||
new Set(rows.map((r) => r.companyId).filter((v): v is string => !!v)),
|
||||
);
|
||||
const interestIds = Array.from(
|
||||
new Set(rows.map((r) => r.interestId).filter((v): v is string => !!v)),
|
||||
);
|
||||
|
||||
const [folderRows, clientRows, yachtRows, companyRows, interestRows] = await Promise.all([
|
||||
folderIds.length > 0
|
||||
? db
|
||||
.select({ id: documentFolders.id, name: documentFolders.name })
|
||||
.from(documentFolders)
|
||||
.where(inArray(documentFolders.id, folderIds))
|
||||
: Promise.resolve([]),
|
||||
clientIds.length > 0
|
||||
? db
|
||||
.select({ id: clients.id, name: clients.fullName })
|
||||
.from(clients)
|
||||
.where(inArray(clients.id, clientIds))
|
||||
: Promise.resolve([]),
|
||||
yachtIds.length > 0
|
||||
? db
|
||||
.select({ id: yachts.id, name: yachts.name })
|
||||
.from(yachts)
|
||||
.where(inArray(yachts.id, yachtIds))
|
||||
: Promise.resolve([]),
|
||||
companyIds.length > 0
|
||||
? db
|
||||
.select({ id: companies.id, name: companies.legalName })
|
||||
.from(companies)
|
||||
.where(inArray(companies.id, companyIds))
|
||||
: Promise.resolve([]),
|
||||
interestIds.length > 0
|
||||
? db
|
||||
.select({
|
||||
id: interests.id,
|
||||
clientId: interests.clientId,
|
||||
pipelineStage: interests.pipelineStage,
|
||||
})
|
||||
.from(interests)
|
||||
.where(inArray(interests.id, interestIds))
|
||||
: Promise.resolve([]),
|
||||
]);
|
||||
const folderMap = new Map(folderRows.map((r) => [r.id, r.name]));
|
||||
const clientMap = new Map(clientRows.map((r) => [r.id, r.name]));
|
||||
const yachtMap = new Map(yachtRows.map((r) => [r.id, r.name]));
|
||||
const companyMap = new Map(companyRows.map((r) => [r.id, r.name]));
|
||||
const interestMap = new Map(interestRows.map((r) => [r.id, r]));
|
||||
|
||||
const enriched = rows.map((r) => ({
|
||||
...r,
|
||||
folderName: r.folderId ? (folderMap.get(r.folderId) ?? null) : null,
|
||||
clientName: r.clientId ? (clientMap.get(r.clientId) ?? null) : null,
|
||||
yachtName: r.yachtId ? (yachtMap.get(r.yachtId) ?? null) : null,
|
||||
companyName: r.companyId ? (companyMap.get(r.companyId) ?? null) : null,
|
||||
interestSummary: r.interestId
|
||||
? (() => {
|
||||
const i = interestMap.get(r.interestId);
|
||||
if (!i) return null;
|
||||
const cName = i.clientId ? (clientMap.get(i.clientId) ?? null) : null;
|
||||
return { stage: i.pipelineStage, clientName: cName };
|
||||
})()
|
||||
: null,
|
||||
}));
|
||||
return { ...base, data: enriched };
|
||||
}
|
||||
|
||||
// ─── Get by ID ────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -3,12 +3,12 @@ import { and, desc, eq, exists, gte, inArray, isNull, ne, sql } from 'drizzle-or
|
||||
import { db } from '@/lib/db';
|
||||
import { interests, interestBerths, interestTags, interestNotes } from '@/lib/db/schema/interests';
|
||||
import { reminders, interestContactLog } from '@/lib/db/schema/operations';
|
||||
import { clients, clientAddresses, clientContacts } from '@/lib/db/schema/clients';
|
||||
import { clients, clientAddresses, clientContacts, clientNotes } from '@/lib/db/schema/clients';
|
||||
import { berths } from '@/lib/db/schema/berths';
|
||||
import { documents, documentEvents } from '@/lib/db/schema/documents';
|
||||
import { berthTenancies } from '@/lib/db/schema/tenancies';
|
||||
import { yachts } from '@/lib/db/schema/yachts';
|
||||
import { companyMemberships } from '@/lib/db/schema/companies';
|
||||
import { yachts, yachtNotes } from '@/lib/db/schema/yachts';
|
||||
import { companyMemberships, companyNotes } from '@/lib/db/schema/companies';
|
||||
import { tags } from '@/lib/db/schema/system';
|
||||
import { userProfiles, userPortRoles, roles } from '@/lib/db/schema/users';
|
||||
import { createAuditLog, type AuditMeta } from '@/lib/audit';
|
||||
@@ -583,6 +583,48 @@ export async function getInterestById(id: string, portId: string) {
|
||||
.from(interestNotes)
|
||||
.where(eq(interestNotes.interestId, id));
|
||||
|
||||
// Aggregated note count = direct interest notes + notes attached to
|
||||
// the linked client, yacht (if any), and any companies the linked
|
||||
// client is an active member of. Surfaces as a separate field so the
|
||||
// existing notesCount badge stays accurate to "this interest only",
|
||||
// while the Notes tab can render the broader total when reps want
|
||||
// the full picture at a glance.
|
||||
const clientNotesP = db
|
||||
.select({ count: sql<number>`count(*)::int` })
|
||||
.from(clientNotes)
|
||||
.where(eq(clientNotes.clientId, interest.clientId));
|
||||
const yachtNotesP = interest.yachtId
|
||||
? db
|
||||
.select({ count: sql<number>`count(*)::int` })
|
||||
.from(yachtNotes)
|
||||
.where(eq(yachtNotes.yachtId, interest.yachtId))
|
||||
: Promise.resolve([{ count: 0 }]);
|
||||
const companyMembershipsP = db
|
||||
.select({ companyId: companyMemberships.companyId })
|
||||
.from(companyMemberships)
|
||||
.where(
|
||||
and(eq(companyMemberships.clientId, interest.clientId), isNull(companyMemberships.endDate)),
|
||||
);
|
||||
const [clientNotesRow, yachtNotesRow, memberships] = await Promise.all([
|
||||
clientNotesP,
|
||||
yachtNotesP,
|
||||
companyMembershipsP,
|
||||
]);
|
||||
const companyIds = memberships.map((m) => m.companyId);
|
||||
let companyNotesCount = 0;
|
||||
if (companyIds.length > 0) {
|
||||
const [row] = await db
|
||||
.select({ count: sql<number>`count(*)::int` })
|
||||
.from(companyNotes)
|
||||
.where(inArray(companyNotes.companyId, companyIds));
|
||||
companyNotesCount = row?.count ?? 0;
|
||||
}
|
||||
const notesCountAggregated =
|
||||
notesCount +
|
||||
(clientNotesRow[0]?.count ?? 0) +
|
||||
(yachtNotesRow[0]?.count ?? 0) +
|
||||
companyNotesCount;
|
||||
|
||||
// Active reminder count for the interest's bell badge. Counts reminders
|
||||
// directly linked via interestId - `pending` and `snoozed` only;
|
||||
// completed/dismissed don't surface.
|
||||
@@ -734,6 +776,7 @@ export async function getInterestById(id: string, portId: string) {
|
||||
linkedBerthCount,
|
||||
tags: tagRows,
|
||||
notesCount,
|
||||
notesCountAggregated,
|
||||
recentNote: recentNote ?? null,
|
||||
activeReminderCount,
|
||||
assignedToName,
|
||||
|
||||
Reference in New Issue
Block a user