feat(residential-toggle): port-level module gate for Residential

Adds a `residential_module_enabled` port setting (default ON) that
hides/disables the entire Residential surface when an admin turns it
off, mirroring the Tenancies / Invoices / Expenses module-toggle
pattern. Disabling is a soft hide — residential clients/interests are
preserved and reappear on re-enable.

Surfaces gated:
- Route guard: new residential/layout.tsx renders ModuleDisabledPage
  (covers all 5 residential pages)
- Sidebar "Residential" section + mobile more-sheet tile (SSR-resolved
  residentialModuleByPort threaded layout → app-shell → sidebar)
- Global search: residential client/interest buckets early-return at
  the shared chokepoint so disabled-port records don't dead-end
- Public intake: /api/public/residential-inquiries 404s when off
- Admin Switch in settings-manager (writes via settings PUT)

Service TDD'd (residential-module.test.ts, 6 tests) plus a
disabled-port rejection test on the public endpoint. tsc + lint clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-31 18:49:16 +02:00
parent cb8292464c
commit 172af02f81
13 changed files with 380 additions and 1 deletions

View File

@@ -61,6 +61,11 @@ interface SidebarProps {
* the dashboard layout. Defaults to true (feature on) per port when
* the map is missing for the active port. */
expensesModuleByPort?: Record<string, boolean>;
/** Per-port `residential_module_enabled` resolution. Gates the entire
* "Residential" sidebar section. Resolved server-side in the dashboard
* layout. Defaults to true (feature on) per port when the map is
* missing for the active port. */
residentialModuleByPort?: Record<string, boolean>;
}
interface NavItem {
@@ -78,6 +83,9 @@ interface NavSection {
marinaRequired?: boolean;
/** When true, only render if the user has residential-side access. */
residentialRequired?: boolean;
/** When true, only render if the residential module is enabled for the
* current port. Resolved against `residentialModuleByPort`. */
requiresResidentialModule?: boolean;
/** When true, only render if Umami analytics is wired up for the port. */
umamiRequired?: boolean;
}
@@ -119,6 +127,7 @@ function buildNavSections(portSlug: string | undefined): NavSection[] {
{
title: 'Residential',
residentialRequired: true,
requiresResidentialModule: true,
items: [
{
href: `${base}/residential/clients`,
@@ -280,6 +289,7 @@ function SidebarContent({
hasResidentialAccess,
tenanciesModuleEnabled,
expensesModuleEnabled,
residentialModuleEnabled,
user,
ports,
currentPort,
@@ -295,6 +305,7 @@ function SidebarContent({
hasResidentialAccess: boolean;
tenanciesModuleEnabled: boolean;
expensesModuleEnabled: boolean;
residentialModuleEnabled: boolean;
user?: SidebarProps['user'];
ports?: Port[];
currentPort: Port | null;
@@ -388,6 +399,7 @@ function SidebarContent({
if (section.adminRequired && !hasAdminAccess) return null;
if (section.marinaRequired && !hasMarinaAccess) return null;
if (section.residentialRequired && !hasResidentialAccess) return null;
if (section.requiresResidentialModule && !residentialModuleEnabled) return null;
if (section.umamiRequired && !umamiConfigured) return null;
return (
@@ -514,6 +526,7 @@ export function Sidebar({
portLogoUrls,
tenanciesModuleByPort,
expensesModuleByPort,
residentialModuleByPort,
}: SidebarProps) {
// Sidebar collapse removed - design preference is the always-expanded
// form. Forcibly false; the store flag stays for backwards-compat with
@@ -532,6 +545,12 @@ export function Sidebar({
const expensesModuleEnabled = currentPortId
? (expensesModuleByPort?.[currentPortId] ?? true)
: true;
// Residential defaults to enabled when the port's entry is missing -
// the registry default is `true`, so a port that's never explicitly
// toggled the feature keeps the section visible.
const residentialModuleEnabled = currentPortId
? (residentialModuleByPort?.[currentPortId] ?? true)
: true;
// Super admins see every section regardless of role rows.
const hasAdminAccess =
@@ -565,6 +584,7 @@ export function Sidebar({
hasResidentialAccess={hasResidentialAccess}
tenanciesModuleEnabled={tenanciesModuleEnabled}
expensesModuleEnabled={expensesModuleEnabled}
residentialModuleEnabled={residentialModuleEnabled}
user={user}
ports={ports}
currentPort={currentPort}