diff --git a/src/components/admin/settings/settings-manager.tsx b/src/components/admin/settings/settings-manager.tsx index 3de3e9c1..837fcc25 100644 --- a/src/components/admin/settings/settings-manager.tsx +++ b/src/components/admin/settings/settings-manager.tsx @@ -47,6 +47,14 @@ const KNOWN_SETTINGS: Array<{ type: 'boolean', defaultValue: true, }, + { + key: 'tenancies_module_enabled', + label: 'Tenancies Module', + description: + 'Enable the per-berth tenancy tracker (lease windows, renewals, transfers). Off by default; auto-enables when the first tenancy row is created via webhook or manual add. Disabling here hides the sidebar entry and entity tabs, but never deletes underlying tenancy rows - re-enabling brings them back.', + type: 'boolean', + defaultValue: false, + }, { key: 'ai_interest_scoring', label: 'AI Interest Scoring', diff --git a/src/components/clients/client-interests-tab.tsx b/src/components/clients/client-interests-tab.tsx index 68847130..a70178ec 100644 --- a/src/components/clients/client-interests-tab.tsx +++ b/src/components/clients/client-interests-tab.tsx @@ -24,6 +24,7 @@ import { type ClientInterestRow, } from '@/components/clients/client-pipeline-summary'; import { InterestForm } from '@/components/interests/interest-form'; +import { TagBadge } from '@/components/shared/tag-badge'; const LEAD_CATEGORY_LABELS: Record = { general_interest: 'General interest', @@ -87,6 +88,16 @@ function InterestRowItem({
+ {interest.tags && interest.tags.length > 0 ? ( +
+ {interest.tags.slice(0, 4).map((t) => ( + + ))} + {interest.tags.length > 4 ? ( + +{interest.tags.length - 4} more + ) : null} +
+ ) : null} ); } @@ -117,6 +128,7 @@ interface InterestDetail { eoiDocStatus: string | null; reservationDocStatus: string | null; contractDocStatus: string | null; + tags?: Array<{ id: string; name: string; color: string }>; } function useInterestDetail(id: string | null) { @@ -261,6 +273,13 @@ function InterestPreviewSheet({ Pipeline progress

+ {detail.data?.data.tags && detail.data.data.tags.length > 0 ? ( +
+ {detail.data.data.tags.map((t) => ( + + ))} +
+ ) : null} ) : null} diff --git a/src/components/clients/client-pipeline-summary.tsx b/src/components/clients/client-pipeline-summary.tsx index 0f7003ed..321b4eca 100644 --- a/src/components/clients/client-pipeline-summary.tsx +++ b/src/components/clients/client-pipeline-summary.tsx @@ -9,6 +9,7 @@ import { formatDistanceToNowStrict } from 'date-fns'; import { apiFetch } from '@/lib/api/client'; import { Skeleton } from '@/components/ui/skeleton'; +import { TagBadge } from '@/components/shared/tag-badge'; import { deriveInterestBerthLabel } from '@/lib/templates/interest-berth-label'; import { cn } from '@/lib/utils'; import { @@ -38,6 +39,9 @@ export interface ClientInterestRow { desiredWidthFt?: string | null; desiredDraftFt?: string | null; source?: string | null; + /** Tag chips surfaced alongside the StageStepper. Ship by `getInterests` + * (list endpoint resolves the join on every row in a single batch). */ + tags?: Array<{ id: string; name: string; color: string }>; } interface InterestsResponse { @@ -223,6 +227,16 @@ function HeroVariant({ clientId, portSlug }: { clientId: string; portSlug: strin
+ {top.tags && top.tags.length > 0 ? ( +
+ {top.tags.slice(0, 4).map((t) => ( + + ))} + {top.tags.length > 4 ? ( + +{top.tags.length - 4} more + ) : null} +
+ ) : null}
@@ -339,6 +353,16 @@ function PanelVariant({ clientId, portSlug }: { clientId: string; portSlug: stri
+ {i.tags && i.tags.length > 0 ? ( +
+ {i.tags.slice(0, 3).map((t) => ( + + ))} + {i.tags.length > 3 ? ( + +{i.tags.length - 3} + ) : null} +
+ ) : null}
No files yet. ) : (
    - {filesData.map((f) => ( -
  • - - - {new Date(f.createdAt).toLocaleDateString(undefined)} - -
  • - ))} +
    + +
    + {f.folderId && f.folderName ? ( + + 📁 + {f.folderName} + + ) : null} + {entityBadge ? ( + + {entityBadge.label} + + ) : null} +
    +
    + + {new Date(f.createdAt).toLocaleDateString(undefined)} + + + ); + })}
)} diff --git a/src/components/interests/interest-tabs.tsx b/src/components/interests/interest-tabs.tsx index 1ef3095c..6268038f 100644 --- a/src/components/interests/interest-tabs.tsx +++ b/src/components/interests/interest-tabs.tsx @@ -189,6 +189,10 @@ interface InterestTabsOptions { /** Surfaced by getInterestById for the Overview "most recent note" * teaser - saves a click into the Notes tab to peek at the latest. */ notesCount?: number; + /** Aggregated note count across linked entities (interest + client + + * yacht + companies the client is a member of). Drives the badge + * on the Notes tab so reps see the full surface area at a glance. */ + notesCountAggregated?: number; recentNote?: { id: string; content: string; @@ -1699,6 +1703,10 @@ export function getInterestTabs({ { id: 'notes', label: 'Notes', + badge: + interest.notesCountAggregated && interest.notesCountAggregated > 0 + ? interest.notesCountAggregated + : undefined, content: ( ; + 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 ──────────────────────────────────────────────────────────────── diff --git a/src/lib/services/interests.service.ts b/src/lib/services/interests.service.ts index 4ab7dcd5..9d32e572 100644 --- a/src/lib/services/interests.service.ts +++ b/src/lib/services/interests.service.ts @@ -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`count(*)::int` }) + .from(clientNotes) + .where(eq(clientNotes.clientId, interest.clientId)); + const yachtNotesP = interest.yachtId + ? db + .select({ count: sql`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`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,