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.
) : (
)}
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,