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:
2026-05-26 21:57:20 +02:00
parent 2592e28578
commit 6caf41651f
7 changed files with 270 additions and 20 deletions

View File

@@ -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',

View File

@@ -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<string, string> = {
general_interest: 'General interest',
@@ -87,6 +88,16 @@ function InterestRowItem({
<div className="mt-3">
<StageStepper current={stage} />
</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>
);
}
@@ -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
</p>
<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>
) : null}

View File

@@ -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
<div className="mt-1.5">
<StageStepper current={stage} size="xs" />
</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>
<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">
<StageStepper current={stage} size="xs" />
</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>
<ChevronRight
className="size-3.5 shrink-0 text-muted-foreground transition-transform group-hover:translate-x-0.5"

View File

@@ -21,6 +21,16 @@ interface HubRootFile {
filename: string;
mimeType: string | null;
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 {
@@ -94,23 +104,76 @@ export function HubRootView({ portSlug }: Props) {
<div className="p-3 text-sm text-muted-foreground">No files yet.</div>
) : (
<ul className="divide-y">
{filesData.map((f) => (
<li key={f.id} className="flex items-center justify-between px-3 py-2 text-sm">
<button
type="button"
className="truncate text-left hover:text-brand hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand/40 rounded-sm"
onClick={() =>
setPreviewFile({ id: f.id, name: f.filename, mimeType: f.mimeType })
}
aria-label={`Preview ${f.filename}`}
{filesData.map((f) => {
const entityBadge = (() => {
if (f.interestId)
return {
label: f.interestSummary?.clientName
? `Interest: ${f.interestSummary.clientName}`
: 'Interest',
href: `/${portSlug}/interests/${f.interestId}`,
};
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}
</button>
<span className="text-xs text-muted-foreground tabular-nums">
{new Date(f.createdAt).toLocaleDateString(undefined)}
</span>
</li>
))}
<div className="min-w-0 flex-1">
<button
type="button"
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"
onClick={() =>
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>
)}
</section>

View File

@@ -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: (
<NotesList
entityType="interests"