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:
@@ -47,6 +47,14 @@ const KNOWN_SETTINGS: Array<{
|
|||||||
type: 'boolean',
|
type: 'boolean',
|
||||||
defaultValue: true,
|
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',
|
key: 'ai_interest_scoring',
|
||||||
label: 'AI Interest Scoring',
|
label: 'AI Interest Scoring',
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import {
|
|||||||
type ClientInterestRow,
|
type ClientInterestRow,
|
||||||
} from '@/components/clients/client-pipeline-summary';
|
} from '@/components/clients/client-pipeline-summary';
|
||||||
import { InterestForm } from '@/components/interests/interest-form';
|
import { InterestForm } from '@/components/interests/interest-form';
|
||||||
|
import { TagBadge } from '@/components/shared/tag-badge';
|
||||||
|
|
||||||
const LEAD_CATEGORY_LABELS: Record<string, string> = {
|
const LEAD_CATEGORY_LABELS: Record<string, string> = {
|
||||||
general_interest: 'General interest',
|
general_interest: 'General interest',
|
||||||
@@ -87,6 +88,16 @@ function InterestRowItem({
|
|||||||
<div className="mt-3">
|
<div className="mt-3">
|
||||||
<StageStepper current={stage} />
|
<StageStepper current={stage} />
|
||||||
</div>
|
</div>
|
||||||
|
{interest.tags && interest.tags.length > 0 ? (
|
||||||
|
<div className="mt-2 flex flex-wrap gap-1">
|
||||||
|
{interest.tags.slice(0, 4).map((t) => (
|
||||||
|
<TagBadge key={t.id} name={t.name} color={t.color} />
|
||||||
|
))}
|
||||||
|
{interest.tags.length > 4 ? (
|
||||||
|
<span className="text-xs text-muted-foreground">+{interest.tags.length - 4} more</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -117,6 +128,7 @@ interface InterestDetail {
|
|||||||
eoiDocStatus: string | null;
|
eoiDocStatus: string | null;
|
||||||
reservationDocStatus: string | null;
|
reservationDocStatus: string | null;
|
||||||
contractDocStatus: string | null;
|
contractDocStatus: string | null;
|
||||||
|
tags?: Array<{ id: string; name: string; color: string }>;
|
||||||
}
|
}
|
||||||
|
|
||||||
function useInterestDetail(id: string | null) {
|
function useInterestDetail(id: string | null) {
|
||||||
@@ -261,6 +273,13 @@ function InterestPreviewSheet({
|
|||||||
Pipeline progress
|
Pipeline progress
|
||||||
</p>
|
</p>
|
||||||
<StageStepper current={stage} />
|
<StageStepper current={stage} />
|
||||||
|
{detail.data?.data.tags && detail.data.data.tags.length > 0 ? (
|
||||||
|
<div className="mt-2 flex flex-wrap gap-1">
|
||||||
|
{detail.data.data.tags.map((t) => (
|
||||||
|
<TagBadge key={t.id} name={t.name} color={t.color} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { formatDistanceToNowStrict } from 'date-fns';
|
|||||||
|
|
||||||
import { apiFetch } from '@/lib/api/client';
|
import { apiFetch } from '@/lib/api/client';
|
||||||
import { Skeleton } from '@/components/ui/skeleton';
|
import { Skeleton } from '@/components/ui/skeleton';
|
||||||
|
import { TagBadge } from '@/components/shared/tag-badge';
|
||||||
import { deriveInterestBerthLabel } from '@/lib/templates/interest-berth-label';
|
import { deriveInterestBerthLabel } from '@/lib/templates/interest-berth-label';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import {
|
import {
|
||||||
@@ -38,6 +39,9 @@ export interface ClientInterestRow {
|
|||||||
desiredWidthFt?: string | null;
|
desiredWidthFt?: string | null;
|
||||||
desiredDraftFt?: string | null;
|
desiredDraftFt?: string | null;
|
||||||
source?: 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 {
|
interface InterestsResponse {
|
||||||
@@ -223,6 +227,16 @@ function HeroVariant({ clientId, portSlug }: { clientId: string; portSlug: strin
|
|||||||
<div className="mt-1.5">
|
<div className="mt-1.5">
|
||||||
<StageStepper current={stage} size="xs" />
|
<StageStepper current={stage} size="xs" />
|
||||||
</div>
|
</div>
|
||||||
|
{top.tags && top.tags.length > 0 ? (
|
||||||
|
<div className="mt-1.5 flex flex-wrap gap-1">
|
||||||
|
{top.tags.slice(0, 4).map((t) => (
|
||||||
|
<TagBadge key={t.id} name={t.name} color={t.color} />
|
||||||
|
))}
|
||||||
|
{top.tags.length > 4 ? (
|
||||||
|
<span className="text-xs text-muted-foreground">+{top.tags.length - 4} more</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
<div className="flex items-center justify-between gap-2 text-xs text-muted-foreground">
|
<div className="flex items-center justify-between gap-2 text-xs text-muted-foreground">
|
||||||
@@ -339,6 +353,16 @@ function PanelVariant({ clientId, portSlug }: { clientId: string; portSlug: stri
|
|||||||
<div className="mt-1">
|
<div className="mt-1">
|
||||||
<StageStepper current={stage} size="xs" />
|
<StageStepper current={stage} size="xs" />
|
||||||
</div>
|
</div>
|
||||||
|
{i.tags && i.tags.length > 0 ? (
|
||||||
|
<div className="mt-1 flex flex-wrap gap-1">
|
||||||
|
{i.tags.slice(0, 3).map((t) => (
|
||||||
|
<TagBadge key={t.id} name={t.name} color={t.color} />
|
||||||
|
))}
|
||||||
|
{i.tags.length > 3 ? (
|
||||||
|
<span className="text-xs text-muted-foreground">+{i.tags.length - 3}</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
<ChevronRight
|
<ChevronRight
|
||||||
className="size-3.5 shrink-0 text-muted-foreground transition-transform group-hover:translate-x-0.5"
|
className="size-3.5 shrink-0 text-muted-foreground transition-transform group-hover:translate-x-0.5"
|
||||||
|
|||||||
@@ -21,6 +21,16 @@ interface HubRootFile {
|
|||||||
filename: string;
|
filename: string;
|
||||||
mimeType: string | null;
|
mimeType: string | null;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
|
folderId: string | null;
|
||||||
|
folderName: string | null;
|
||||||
|
clientId: string | null;
|
||||||
|
clientName: string | null;
|
||||||
|
yachtId: string | null;
|
||||||
|
yachtName: string | null;
|
||||||
|
companyId: string | null;
|
||||||
|
companyName: string | null;
|
||||||
|
interestId: string | null;
|
||||||
|
interestSummary: { stage: string; clientName: string | null } | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -94,23 +104,76 @@ export function HubRootView({ portSlug }: Props) {
|
|||||||
<div className="p-3 text-sm text-muted-foreground">No files yet.</div>
|
<div className="p-3 text-sm text-muted-foreground">No files yet.</div>
|
||||||
) : (
|
) : (
|
||||||
<ul className="divide-y">
|
<ul className="divide-y">
|
||||||
{filesData.map((f) => (
|
{filesData.map((f) => {
|
||||||
<li key={f.id} className="flex items-center justify-between px-3 py-2 text-sm">
|
const entityBadge = (() => {
|
||||||
<button
|
if (f.interestId)
|
||||||
type="button"
|
return {
|
||||||
className="truncate text-left hover:text-brand hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand/40 rounded-sm"
|
label: f.interestSummary?.clientName
|
||||||
onClick={() =>
|
? `Interest: ${f.interestSummary.clientName}`
|
||||||
setPreviewFile({ id: f.id, name: f.filename, mimeType: f.mimeType })
|
: 'Interest',
|
||||||
}
|
href: `/${portSlug}/interests/${f.interestId}`,
|
||||||
aria-label={`Preview ${f.filename}`}
|
};
|
||||||
|
if (f.clientId)
|
||||||
|
return {
|
||||||
|
label: f.clientName ?? 'Client',
|
||||||
|
href: `/${portSlug}/clients/${f.clientId}`,
|
||||||
|
};
|
||||||
|
if (f.yachtId)
|
||||||
|
return {
|
||||||
|
label: f.yachtName ?? 'Yacht',
|
||||||
|
href: `/${portSlug}/yachts/${f.yachtId}`,
|
||||||
|
};
|
||||||
|
if (f.companyId)
|
||||||
|
return {
|
||||||
|
label: f.companyName ?? 'Company',
|
||||||
|
href: `/${portSlug}/companies/${f.companyId}`,
|
||||||
|
};
|
||||||
|
return null;
|
||||||
|
})();
|
||||||
|
return (
|
||||||
|
<li
|
||||||
|
key={f.id}
|
||||||
|
className="flex items-center justify-between gap-3 px-3 py-2 text-sm"
|
||||||
>
|
>
|
||||||
{f.filename}
|
<div className="min-w-0 flex-1">
|
||||||
</button>
|
<button
|
||||||
<span className="text-xs text-muted-foreground tabular-nums">
|
type="button"
|
||||||
{new Date(f.createdAt).toLocaleDateString(undefined)}
|
className="block truncate text-left hover:text-brand hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand/40 rounded-sm"
|
||||||
</span>
|
onClick={() =>
|
||||||
</li>
|
setPreviewFile({ id: f.id, name: f.filename, mimeType: f.mimeType })
|
||||||
))}
|
}
|
||||||
|
aria-label={`Preview ${f.filename}`}
|
||||||
|
>
|
||||||
|
{f.filename}
|
||||||
|
</button>
|
||||||
|
<div className="mt-0.5 flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
|
||||||
|
{f.folderId && f.folderName ? (
|
||||||
|
<Link
|
||||||
|
/* eslint-disable-next-line @typescript-eslint/no-explicit-any */
|
||||||
|
href={`/${portSlug}/documents?folderId=${f.folderId}` as any}
|
||||||
|
className="inline-flex items-center gap-1 hover:underline"
|
||||||
|
>
|
||||||
|
<span aria-hidden>📁</span>
|
||||||
|
{f.folderName}
|
||||||
|
</Link>
|
||||||
|
) : null}
|
||||||
|
{entityBadge ? (
|
||||||
|
<Link
|
||||||
|
/* eslint-disable-next-line @typescript-eslint/no-explicit-any */
|
||||||
|
href={entityBadge.href as any}
|
||||||
|
className="inline-flex items-center rounded-full border bg-muted px-1.5 py-0.5 text-[10px] font-medium uppercase tracking-wide text-muted-foreground hover:bg-muted/70"
|
||||||
|
>
|
||||||
|
{entityBadge.label}
|
||||||
|
</Link>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span className="text-xs text-muted-foreground tabular-nums shrink-0">
|
||||||
|
{new Date(f.createdAt).toLocaleDateString(undefined)}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</ul>
|
</ul>
|
||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -189,6 +189,10 @@ interface InterestTabsOptions {
|
|||||||
/** Surfaced by getInterestById for the Overview "most recent note"
|
/** Surfaced by getInterestById for the Overview "most recent note"
|
||||||
* teaser - saves a click into the Notes tab to peek at the latest. */
|
* teaser - saves a click into the Notes tab to peek at the latest. */
|
||||||
notesCount?: number;
|
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?: {
|
recentNote?: {
|
||||||
id: string;
|
id: string;
|
||||||
content: string;
|
content: string;
|
||||||
@@ -1699,6 +1703,10 @@ export function getInterestTabs({
|
|||||||
{
|
{
|
||||||
id: 'notes',
|
id: 'notes',
|
||||||
label: 'Notes',
|
label: 'Notes',
|
||||||
|
badge:
|
||||||
|
interest.notesCountAggregated && interest.notesCountAggregated > 0
|
||||||
|
? interest.notesCountAggregated
|
||||||
|
: undefined,
|
||||||
content: (
|
content: (
|
||||||
<NotesList
|
<NotesList
|
||||||
entityType="interests"
|
entityType="interests"
|
||||||
|
|||||||
@@ -311,7 +311,7 @@ export async function listFiles(portId: string, query: ListFilesInput) {
|
|||||||
const sortColumn =
|
const sortColumn =
|
||||||
sort === 'filename' ? files.filename : sort === 'sizeBytes' ? files.sizeBytes : files.createdAt;
|
sort === 'filename' ? files.filename : sort === 'sizeBytes' ? files.sizeBytes : files.createdAt;
|
||||||
|
|
||||||
return buildListQuery({
|
const base = await buildListQuery({
|
||||||
table: files,
|
table: files,
|
||||||
portIdColumn: files.portId,
|
portIdColumn: files.portId,
|
||||||
portId,
|
portId,
|
||||||
@@ -325,6 +325,91 @@ export async function listFiles(portId: string, query: ListFilesInput) {
|
|||||||
pageSize: limit,
|
pageSize: limit,
|
||||||
// no archivedAtColumn - files are immutable records
|
// 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 ────────────────────────────────────────────────────────────────
|
// ─── 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 { db } from '@/lib/db';
|
||||||
import { interests, interestBerths, interestTags, interestNotes } from '@/lib/db/schema/interests';
|
import { interests, interestBerths, interestTags, interestNotes } from '@/lib/db/schema/interests';
|
||||||
import { reminders, interestContactLog } from '@/lib/db/schema/operations';
|
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 { berths } from '@/lib/db/schema/berths';
|
||||||
import { documents, documentEvents } from '@/lib/db/schema/documents';
|
import { documents, documentEvents } from '@/lib/db/schema/documents';
|
||||||
import { berthTenancies } from '@/lib/db/schema/tenancies';
|
import { berthTenancies } from '@/lib/db/schema/tenancies';
|
||||||
import { yachts } from '@/lib/db/schema/yachts';
|
import { yachts, yachtNotes } from '@/lib/db/schema/yachts';
|
||||||
import { companyMemberships } from '@/lib/db/schema/companies';
|
import { companyMemberships, companyNotes } from '@/lib/db/schema/companies';
|
||||||
import { tags } from '@/lib/db/schema/system';
|
import { tags } from '@/lib/db/schema/system';
|
||||||
import { userProfiles, userPortRoles, roles } from '@/lib/db/schema/users';
|
import { userProfiles, userPortRoles, roles } from '@/lib/db/schema/users';
|
||||||
import { createAuditLog, type AuditMeta } from '@/lib/audit';
|
import { createAuditLog, type AuditMeta } from '@/lib/audit';
|
||||||
@@ -583,6 +583,48 @@ export async function getInterestById(id: string, portId: string) {
|
|||||||
.from(interestNotes)
|
.from(interestNotes)
|
||||||
.where(eq(interestNotes.interestId, id));
|
.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
|
// Active reminder count for the interest's bell badge. Counts reminders
|
||||||
// directly linked via interestId - `pending` and `snoozed` only;
|
// directly linked via interestId - `pending` and `snoozed` only;
|
||||||
// completed/dismissed don't surface.
|
// completed/dismissed don't surface.
|
||||||
@@ -734,6 +776,7 @@ export async function getInterestById(id: string, portId: string) {
|
|||||||
linkedBerthCount,
|
linkedBerthCount,
|
||||||
tags: tagRows,
|
tags: tagRows,
|
||||||
notesCount,
|
notesCount,
|
||||||
|
notesCountAggregated,
|
||||||
recentNote: recentNote ?? null,
|
recentNote: recentNote ?? null,
|
||||||
activeReminderCount,
|
activeReminderCount,
|
||||||
assignedToName,
|
assignedToName,
|
||||||
|
|||||||
Reference in New Issue
Block a user