From e4daa482deaf5769d05dbbed2e26f6aabdbd2450 Mon Sep 17 00:00:00 2001 From: Matt Date: Mon, 25 May 2026 15:29:22 +0200 Subject: [PATCH] feat(tenancies-p6): module-gate entity tabs (berth / client / yacht) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - PortProvider exposes tenanciesModuleByPort + a useTenanciesModuleEnabled() hook that returns the flag for the currently-active port. Synchronous read off context (server-resolved in the dashboard layout), so no fetch latency / hydration flicker when the rep flips ports. - buildBerthTabs / getClientTabs / getYachtTabs gain a tenanciesModuleEnabled option. When false, the Tenancies tab is filtered out entirely. When true, it slots into the entity-specific position (after Interests on berth + yacht; after Companies on client). - BerthDetail / ClientDetail / YachtDetail pass the hook value through. Hook call ordered above the early-return so React's rules-of-hooks stays satisfied. Existing read-only tab content (Active tenancy card + History + the berth-side BerthReserveDialog "Create tenancy" CTA from P2) stays untouched — it just becomes visible when the module is on. Deferred (separate ship): generic TenancyCreateDialog that pre-fills clientId / yachtId from the parent entity context, so client / yacht tabs can mint a tenancy without bouncing through the berth detail page. Today client/yacht Tenancies tabs are read-only (the create entry-point is the berth tab); the generic dialog will land alongside the Edit / Renew / Transfer / End dialogs (design § P6 sub-tasks). Verified: tsc clean, 1493/1493 vitest. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/app/(dashboard)/layout.tsx | 6 +++++- src/components/berths/berth-detail.tsx | 4 +++- src/components/berths/berth-tabs.tsx | 20 ++++++++++++++---- src/components/clients/client-detail.tsx | 7 ++++++- src/components/clients/client-tabs.tsx | 26 ++++++++++++++++-------- src/components/yachts/yacht-detail.tsx | 7 ++++++- src/components/yachts/yacht-tabs.tsx | 24 +++++++++++++++------- src/providers/port-provider.tsx | 23 ++++++++++++++++++++- 8 files changed, 93 insertions(+), 24 deletions(-) diff --git a/src/app/(dashboard)/layout.tsx b/src/app/(dashboard)/layout.tsx index 099c886a..8966a8a0 100644 --- a/src/app/(dashboard)/layout.tsx +++ b/src/app/(dashboard)/layout.tsx @@ -91,7 +91,11 @@ export default async function DashboardLayout({ children }: { children: React.Re return ( - + diff --git a/src/components/berths/berth-detail.tsx b/src/components/berths/berth-detail.tsx index 2ddd0a39..c9ddb422 100644 --- a/src/components/berths/berth-detail.tsx +++ b/src/components/berths/berth-detail.tsx @@ -8,6 +8,7 @@ import { DetailLayout } from '@/components/shared/detail-layout'; import { DetailNotFound } from '@/components/shared/detail-not-found'; import { useMobileChrome } from '@/components/layout/mobile/mobile-layout-provider'; import { apiFetch } from '@/lib/api/client'; +import { useTenanciesModuleEnabled } from '@/providers/port-provider'; import { useRealtimeInvalidation } from '@/hooks/use-realtime-invalidation'; import { BerthDetailHeader, type BerthDetailData } from './berth-detail-header'; import { BerthForm } from './berth-form'; @@ -20,6 +21,7 @@ interface BerthDetailProps { export function BerthDetail({ berthId }: BerthDetailProps) { const params = useParams<{ portSlug: string }>(); const portSlug = params?.portSlug ?? ''; + const tenanciesModuleEnabled = useTenanciesModuleEnabled(); const { data, isLoading, error } = useQuery({ queryKey: ['berth', berthId], @@ -84,7 +86,7 @@ export function BerthDetail({ berthId }: BerthDetailProps) { : null} - tabs={berth ? buildBerthTabs(berth) : []} + tabs={berth ? buildBerthTabs(berth, { tenanciesModuleEnabled }) : []} defaultTab="overview" /> {berth ? : null} diff --git a/src/components/berths/berth-tabs.tsx b/src/components/berths/berth-tabs.tsx index ccdf3a1c..d8150c20 100644 --- a/src/components/berths/berth-tabs.tsx +++ b/src/components/berths/berth-tabs.tsx @@ -419,8 +419,11 @@ function OverviewTab({ berth }: { berth: BerthData }) { ); } -export function buildBerthTabs(berth: BerthData): DetailTab[] { - return [ +export function buildBerthTabs( + berth: BerthData, + opts: { tenanciesModuleEnabled: boolean } = { tenanciesModuleEnabled: false }, +): DetailTab[] { + const tabs: DetailTab[] = [ { id: 'overview', label: 'Overview', @@ -431,11 +434,20 @@ export function buildBerthTabs(berth: BerthData): DetailTab[] { label: 'Interests', content: , }, - { + ]; + if (opts.tenanciesModuleEnabled) { + tabs.push({ id: 'tenancies', label: 'Tenancies', content: , - }, + }); + } + tabs.push(...buildBerthDetailRemainder(berth)); + return tabs; +} + +function buildBerthDetailRemainder(berth: BerthData): DetailTab[] { + return [ { id: 'spec', label: 'Spec', diff --git a/src/components/clients/client-detail.tsx b/src/components/clients/client-detail.tsx index 50e10e5b..38961a5e 100644 --- a/src/components/clients/client-detail.tsx +++ b/src/components/clients/client-detail.tsx @@ -9,6 +9,7 @@ import { DetailNotFound } from '@/components/shared/detail-not-found'; import { useMobileChrome } from '@/components/layout/mobile/mobile-layout-provider'; import { ClientDetailHeader } from '@/components/clients/client-detail-header'; import { getClientTabs } from '@/components/clients/client-tabs'; +import { useTenanciesModuleEnabled } from '@/providers/port-provider'; import { useRealtimeInvalidation } from '@/hooks/use-realtime-invalidation'; import { useBreadcrumbHint } from '@/hooks/use-breadcrumb-hint'; import { apiFetch } from '@/lib/api/client'; @@ -118,6 +119,8 @@ export function ClientDetail({ clientId, currentUserId }: ClientDetailProps) { 'berth_tenancy:cancelled': [['clients', clientId]], }); + const tenanciesModuleEnabled = useTenanciesModuleEnabled(); + if (error && !isLoading) { const status = (error as { status?: number } | null | undefined)?.status; return ( @@ -130,7 +133,9 @@ export function ClientDetail({ clientId, currentUserId }: ClientDetailProps) { ); } - const tabs = data ? getClientTabs({ clientId, currentUserId, client: data }) : []; + const tabs = data + ? getClientTabs({ clientId, currentUserId, client: data, tenanciesModuleEnabled }) + : []; return ( , }, - { - id: 'tenancies', - label: 'Tenancies', - badge: client.activeTenancies.length, - content: , - }, { id: 'addresses', label: 'Addresses', @@ -323,4 +322,15 @@ export function getClientTabs({ clientId, currentUserId, client }: ClientTabsOpt ), }, ]; + if (tenanciesModuleEnabled) { + // Insert the Tenancies tab after Companies (index 4) so the entity + // ordering reads: Overview → Interests → Yachts → Companies → Tenancies. + tabs.splice(4, 0, { + id: 'tenancies', + label: 'Tenancies', + badge: client.activeTenancies.length, + content: , + }); + } + return tabs; } diff --git a/src/components/yachts/yacht-detail.tsx b/src/components/yachts/yacht-detail.tsx index 0076c6bd..2f5214b1 100644 --- a/src/components/yachts/yacht-detail.tsx +++ b/src/components/yachts/yacht-detail.tsx @@ -9,6 +9,7 @@ import { DetailNotFound } from '@/components/shared/detail-not-found'; import { useMobileChrome } from '@/components/layout/mobile/mobile-layout-provider'; import { YachtDetailHeader } from '@/components/yachts/yacht-detail-header'; import { getYachtTabs } from '@/components/yachts/yacht-tabs'; +import { useTenanciesModuleEnabled } from '@/providers/port-provider'; import { useRealtimeInvalidation } from '@/hooks/use-realtime-invalidation'; import { useBreadcrumbHint } from '@/hooks/use-breadcrumb-hint'; import { apiFetch } from '@/lib/api/client'; @@ -79,6 +80,8 @@ export function YachtDetail({ yachtId, currentUserId }: YachtDetailProps) { ], }); + const tenanciesModuleEnabled = useTenanciesModuleEnabled(); + if (error && !isLoading) { const status = (error as { status?: number } | null | undefined)?.status; return ( @@ -91,7 +94,9 @@ export function YachtDetail({ yachtId, currentUserId }: YachtDetailProps) { ); } - const tabs = data ? getYachtTabs({ yachtId, currentUserId, yacht: data }) : []; + const tabs = data + ? getYachtTabs({ yachtId, currentUserId, yacht: data, tenanciesModuleEnabled }) + : []; return ( , }, - { - id: 'tenancies', - label: 'Tenancies', - content: , - }, { id: 'notes', label: 'Notes', @@ -401,4 +401,14 @@ export function getYachtTabs({ yachtId, currentUserId, yacht }: YachtTabsOptions ), }, ]; + if (tenanciesModuleEnabled) { + // Insert after Interests (index 3) so the ordering reads: + // Overview → Ownership History → Interests → Tenancies. + tabs.splice(3, 0, { + id: 'tenancies', + label: 'Tenancies', + content: , + }); + } + return tabs; } diff --git a/src/providers/port-provider.tsx b/src/providers/port-provider.tsx index 64e71fe4..6b463926 100644 --- a/src/providers/port-provider.tsx +++ b/src/providers/port-provider.tsx @@ -12,6 +12,10 @@ interface PortContextValue { currentPort: Port | null; currentPortId: string | null; currentPortSlug: string | null; + /** Per-port feature-flag map — currently just the Tenancies module. + * Resolved server-side in the dashboard layout. Consumers read via + * `useTenanciesModuleEnabled()`. */ + tenanciesModuleByPort: Record; } const PortContext = createContext({ @@ -19,15 +23,22 @@ const PortContext = createContext({ currentPort: null, currentPortId: null, currentPortSlug: null, + tenanciesModuleByPort: {}, }); interface PortProviderProps { children: ReactNode; ports: Port[]; defaultPortId: string | null; + tenanciesModuleByPort?: Record; } -export function PortProvider({ children, ports, defaultPortId }: PortProviderProps) { +export function PortProvider({ + children, + ports, + defaultPortId, + tenanciesModuleByPort = {}, +}: PortProviderProps) { const params = useParams(); const portSlugFromUrl = params?.portSlug as string | undefined; @@ -75,6 +86,7 @@ export function PortProvider({ children, ports, defaultPortId }: PortProviderPro currentPort: currentPort ?? null, currentPortId: currentPort?.id ?? null, currentPortSlug: currentPort?.slug ?? null, + tenanciesModuleByPort, }} > {children} @@ -85,3 +97,12 @@ export function PortProvider({ children, ports, defaultPortId }: PortProviderPro export function usePortContext(): PortContextValue { return useContext(PortContext); } + +/** Read the tenancies-module-enabled flag for the currently-active port. + * Server-side resolved in the dashboard layout, so this is a synchronous + * read with no fetch latency or hydration flicker. */ +export function useTenanciesModuleEnabled(): boolean { + const { currentPortId, tenanciesModuleByPort } = useContext(PortContext); + if (!currentPortId) return false; + return tenanciesModuleByPort[currentPortId] ?? false; +}