fix(audit-wave-9): standardize on Sheet for previews; doctrine in CLAUDE.md

Swap the one outlier (client-interests-tab.tsx) from Vaul Drawer to
Sheet side=right so every detail-preview surface uses the same
primitive. Document the doctrine: Sheet for side panels on both desktop
and mobile; Vaul Drawer reserved for mobile-only bottom-sheet UX
(currently just MoreSheet).

Closes ui/ux M11.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-13 11:50:07 +02:00
parent b2588ecdd8
commit 4233aa3ac3
94 changed files with 1674 additions and 895 deletions

View File

@@ -9,7 +9,7 @@
* yacht ownership rows that resolve to this client.
*/
import { and, eq, or } from 'drizzle-orm';
import { and, eq, inArray, or, sql } from 'drizzle-orm';
import { db } from '@/lib/db';
import { NotFoundError } from '@/lib/errors';
@@ -20,15 +20,21 @@ import {
clientNotes,
clientRelationships,
clientTags,
clientMergeLog,
} from '@/lib/db/schema/clients';
import { tags } from '@/lib/db/schema/system';
import { tags, scratchpadNotes } from '@/lib/db/schema/system';
import { companies, companyMemberships } from '@/lib/db/schema/companies';
import { yachts } from '@/lib/db/schema/yachts';
import { interests } from '@/lib/db/schema/interests';
import { berthReservations } from '@/lib/db/schema/reservations';
import { invoices } from '@/lib/db/schema/financial';
import { documents } from '@/lib/db/schema/documents';
import { documents, files, formSubmissions } from '@/lib/db/schema/documents';
import { auditLogs } from '@/lib/db/schema/system';
import { portalUsers } from '@/lib/db/schema/portal';
import { emailThreads, emailMessages } from '@/lib/db/schema/email';
import { reminders, interestContactLog } from '@/lib/db/schema/operations';
import { documentSends } from '@/lib/db/schema/brochures';
import { websiteSubmissions } from '@/lib/db/schema/website-submissions';
export interface GdprBundle {
/** Bundle metadata for traceability. */
@@ -50,9 +56,22 @@ export interface GdprBundle {
company: Record<string, unknown>;
}>;
interests: Record<string, unknown>[];
contactLog: Record<string, unknown>[];
reservations: Record<string, unknown>[];
invoices: Record<string, unknown>[];
documents: Record<string, unknown>[];
files: Record<string, unknown>[];
formSubmissions: Record<string, unknown>[];
websiteSubmissions: Record<string, unknown>[];
documentSends: Record<string, unknown>[];
emailThreads: Array<{
thread: Record<string, unknown>;
messages: Record<string, unknown>[];
}>;
reminders: Record<string, unknown>[];
scratchpadNotes: Record<string, unknown>[];
portalUsers: Record<string, unknown>[];
mergeLog: Record<string, unknown>[];
auditTrail: Record<string, unknown>[];
}
@@ -79,6 +98,14 @@ export async function buildClientBundle(clientId: string, portId: string): Promi
reservationRows,
invoiceRows,
documentRows,
fileRows,
formSubmissionRows,
documentSendRows,
threadRows,
reminderRows,
scratchpadRows,
portalUserRows,
mergeLogRows,
auditRows,
] = await Promise.all([
db.query.clientContacts.findMany({ where: eq(clientContacts.clientId, clientId) }),
@@ -127,6 +154,34 @@ export async function buildClientBundle(clientId: string, portId: string): Promi
db.query.documents.findMany({
where: and(eq(documents.portId, portId), eq(documents.clientId, clientId)),
}),
db.query.files.findMany({
where: and(eq(files.portId, portId), eq(files.clientId, clientId)),
}),
db.query.formSubmissions.findMany({
where: eq(formSubmissions.clientId, clientId),
}),
db.query.documentSends.findMany({
where: and(eq(documentSends.portId, portId), eq(documentSends.clientId, clientId)),
}),
db.query.emailThreads.findMany({
where: and(eq(emailThreads.portId, portId), eq(emailThreads.clientId, clientId)),
}),
db.query.reminders.findMany({
where: and(eq(reminders.portId, portId), eq(reminders.clientId, clientId)),
}),
db.query.scratchpadNotes.findMany({
where: eq(scratchpadNotes.linkedClientId, clientId),
}),
db.query.portalUsers.findMany({ where: eq(portalUsers.clientId, clientId) }),
db.query.clientMergeLog.findMany({
where: and(
eq(clientMergeLog.portId, portId),
or(
eq(clientMergeLog.survivingClientId, clientId),
eq(clientMergeLog.mergedClientId, clientId),
),
),
}),
db.query.auditLogs.findMany({
where: and(
eq(auditLogs.portId, portId),
@@ -138,6 +193,56 @@ export async function buildClientBundle(clientId: string, portId: string): Promi
}),
]);
// Email messages are linked through threads (no direct clientId column).
const threadIds = threadRows.map((t) => t.id);
const messageRows = threadIds.length
? await db.query.emailMessages.findMany({
where: inArray(emailMessages.threadId, threadIds),
orderBy: (t, { asc }) => [asc(t.sentAt)],
})
: [];
const messagesByThread = new Map<string, Record<string, unknown>[]>();
for (const m of messageRows) {
const list = messagesByThread.get(m.threadId) ?? [];
list.push(toJsonRow(m));
messagesByThread.set(m.threadId, list);
}
const emailThreadBundle = threadRows.map((t) => ({
thread: toJsonRow(t),
messages: messagesByThread.get(t.id) ?? [],
}));
// Interest contact-log has no clientId — fetch via the client's interests.
const interestIds = interestRows.map((i) => i.id);
const contactLogRows = interestIds.length
? await db.query.interestContactLog.findMany({
where: and(
eq(interestContactLog.portId, portId),
inArray(interestContactLog.interestId, interestIds),
),
orderBy: (t, { desc }) => [desc(t.occurredAt)],
})
: [];
// Website submissions pre-date the client record (no FK). Match by any
// of the client's email contacts against payload->>'email' (case-
// insensitive) so the bundle includes inquiry forms that became this
// client.
const emailValues = contacts
.filter((c) => c.channel === 'email' && c.value)
.map((c) => c.value.toLowerCase());
const websiteSubmissionRows = emailValues.length
? await db
.select()
.from(websiteSubmissions)
.where(
and(
eq(websiteSubmissions.portId, portId),
inArray(sql<string>`LOWER(${websiteSubmissions.payload}->>'email')`, emailValues),
),
)
: [];
return {
meta: {
generatedAt: new Date().toISOString(),
@@ -162,9 +267,19 @@ export async function buildClientBundle(clientId: string, portId: string): Promi
company: toJsonRow(row.company),
})),
interests: interestRows.map(toJsonRow),
contactLog: contactLogRows.map(toJsonRow),
reservations: reservationRows.map(toJsonRow),
invoices: invoiceRows.map(toJsonRow),
documents: documentRows.map(toJsonRow),
files: fileRows.map(toJsonRow),
formSubmissions: formSubmissionRows.map(toJsonRow),
websiteSubmissions: websiteSubmissionRows.map(toJsonRow),
documentSends: documentSendRows.map(toJsonRow),
emailThreads: emailThreadBundle,
reminders: reminderRows.map(toJsonRow),
scratchpadNotes: scratchpadRows.map(toJsonRow),
portalUsers: portalUserRows.map(toJsonRow),
mergeLog: mergeLogRows.map(toJsonRow),
auditTrail: auditRows.map(toJsonRow),
};
}
@@ -245,9 +360,29 @@ export function renderBundleHtml(bundle: GdprBundle): string {
})),
),
tableSection('Interests', bundle.interests),
tableSection('Contact log', bundle.contactLog),
tableSection('Reservations', bundle.reservations),
tableSection('Invoices', bundle.invoices),
tableSection('Documents', bundle.documents),
tableSection('Files', bundle.files),
tableSection('Form submissions', bundle.formSubmissions),
tableSection('Website submissions (inquiry forms)', bundle.websiteSubmissions),
tableSection('Document sends (PDFs / brochures emailed)', bundle.documentSends),
tableSection(
'Email threads',
bundle.emailThreads.map((t) => ({
...t.thread,
messageCount: t.messages.length,
})),
),
tableSection(
'Email messages',
bundle.emailThreads.flatMap((t) => t.messages.map((m) => ({ threadId: t.thread.id, ...m }))),
),
tableSection('Reminders', bundle.reminders),
tableSection('Scratchpad notes', bundle.scratchpadNotes),
tableSection('Portal users', bundle.portalUsers),
tableSection('Merge log', bundle.mergeLog),
tableSection('Audit trail (last 500 events)', bundle.auditTrail),
].join('\n');