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

@@ -32,6 +32,10 @@ interface AppShellProps {
* + How-to-upload-receipts sidebar entries SSR-side. Defaults to
* true so existing ports keep the feature. */
expensesModuleByPort: Record<string, boolean>;
/** Per-port `residential_module_enabled` resolution. Gates the
* "Residential" sidebar section + mobile entry SSR-side. Defaults to
* true so existing ports keep the feature. */
residentialModuleByPort: Record<string, boolean>;
/**
* Server-rendered form-factor hint (from the request User-Agent). The
* shell mounts the matching tree on first render so we never paint the
@@ -96,6 +100,7 @@ export function AppShell({
portLogoUrls,
tenanciesModuleByPort,
expensesModuleByPort,
residentialModuleByPort,
initialFormFactor,
children,
}: AppShellProps) {
@@ -104,6 +109,7 @@ export function AppShell({
const [searchOpen, setSearchOpen] = useState(false);
const [tabletSidebarOpen, setTabletSidebarOpen] = useState(false);
const currentPortSlug = useUIStore((s) => s.currentPortSlug);
const currentPortId = useUIStore((s) => s.currentPortId);
const logoUrl = currentPortSlug ? portLogoUrls[currentPortSlug] : null;
useEffect(() => {
@@ -149,8 +155,15 @@ export function AppShell({
portLogoUrls,
tenanciesModuleByPort,
expensesModuleByPort,
residentialModuleByPort,
};
// Resolve the current port's residential flag for the mobile More sheet
// (the sidebar resolves its own copy internally from the by-port map).
const residentialModuleEnabled = currentPortId
? (residentialModuleByPort[currentPortId] ?? true)
: true;
// Chrome subtree per tier.
let chrome: ReactNode = null;
if (isMobile) {
@@ -177,7 +190,11 @@ export function AppShell({
onMoreClick={() => setMoreOpen(true)}
onSearchClick={() => setSearchOpen(true)}
/>
<MoreSheet open={moreOpen} onOpenChange={setMoreOpen} />
<MoreSheet
open={moreOpen}
onOpenChange={setMoreOpen}
residentialModuleEnabled={residentialModuleEnabled}
/>
<MobileSearchOverlay open={searchOpen} onOpenChange={setSearchOpen} />
</>
) : null;