Files
pn-new-crm/src/app/(dashboard)/layout.tsx
Matt 1750e265e7
All checks were successful
Build & Push Docker Images / lint (push) Successful in 2m45s
Build & Push Docker Images / build-and-push (push) Successful in 8m11s
feat(berths): inline spec-PDF preview, manual-pin badge, maintenance module toggle, under-offer popover
Post-cutover UAT batch #3:
- #62 Spec tab renders the current berth spec PDF inline (lazy PdfViewer,
  toggleable, default-open) + explicit download. Interest Documents tab
  already previews/downloads linked deal docs inline (verified).
- #57 Surface berths.status_override_mode through the interest-berths API;
  linked-berth rows show an amber "Pin overrides pitch" badge + corrected
  consequence copy when a berth is specifically-pitched but manually pinned
  (the soft-pin wins on the public map).
- #63 New maintenance-module gate (maintenance_module_enabled, default on):
  registry + admin Settings toggle, maintenance-module.service, port-provider
  useMaintenanceModuleEnabled, layout wiring, buildBerthTabs hides the
  Maintenance tab when off, and both maintenance log routes assert the gate.
- #66 BerthOccupancyChip: >1 competing interest opens a popover listing every
  deal (name + stage + in-EOI/primary + link); single stays a direct link.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 19:15:04 +02:00

187 lines
7.9 KiB
TypeScript

import { redirect } from 'next/navigation';
import { cookies, headers } from 'next/headers';
import { eq } from 'drizzle-orm';
import { auth } from '@/lib/auth';
import { db } from '@/lib/db';
import { ports as portsTable } from '@/lib/db/schema/ports';
import { userPortRoles, userProfiles } from '@/lib/db/schema/users';
import { QueryProvider } from '@/providers/query-provider';
import { SocketProvider } from '@/providers/socket-provider';
import { PortProvider } from '@/providers/port-provider';
import { PermissionsProvider } from '@/providers/permissions-provider';
import { AppShell } from '@/components/layout/app-shell';
import { OnboardingBanner } from '@/components/admin/onboarding-banner';
import { DevModeBanner } from '@/components/shared/dev-mode-banner';
import { RealtimeToasts } from '@/components/shared/realtime-toasts';
import { WebVitalsReporter } from '@/components/shared/web-vitals-reporter';
import { classifyFormFactor } from '@/lib/form-factor';
import { getPortBrandingConfig } from '@/lib/services/port-config';
import { isTenanciesModuleEnabled } from '@/lib/services/tenancies-module.service';
import { isExpensesModuleEnabled } from '@/lib/services/expenses-module.service';
import { isResidentialModuleEnabled } from '@/lib/services/residential-module.service';
import { isMaintenanceModuleEnabled } from '@/lib/services/maintenance-module.service';
export default async function DashboardLayout({ children }: { children: React.ReactNode }) {
const headerList = await headers();
const session = await auth.api.getSession({ headers: headerList });
if (!session?.user) redirect('/login');
// Super admins have implicit access to every port; everyone else only sees
// ports they have an explicit user_port_roles row for.
const profile = await db.query.userProfiles.findFirst({
where: eq(userProfiles.userId, session.user.id),
});
const portRoles = await db.query.userPortRoles.findMany({
where: eq(userPortRoles.userId, session.user.id),
with: { port: true, role: true },
});
const ports = profile?.isSuperAdmin
? await db.query.ports.findMany({ orderBy: portsTable.createdAt })
: portRoles.map((pr) => pr.port);
// Prefer a previously-resolved tier from the client's cookie so the
// server renders the matching shell on first paint - eliminates the
// mobile↔desktop chrome flicker that happens when UA-based classification
// disagrees with the actual viewport (e.g. macOS Safari with the
// window dragged below 1024). AppShell writes the cookie after the
// first matchMedia evaluation; subsequent reloads use the hint.
const cookieStore = await cookies();
const tierCookie = cookieStore.get('pn-crm.viewport-tier')?.value;
const initialFormFactor: 'mobile' | 'desktop' =
tierCookie === 'mobile'
? 'mobile'
: tierCookie === 'tablet' || tierCookie === 'desktop'
? 'desktop'
: classifyFormFactor(headerList.get('user-agent'));
const user = {
name: profile?.displayName ?? session.user.name ?? session.user.email,
email: session.user.email,
};
// Per-port logo map for the sidebar. Resolved server-side so the
// sidebar can swap brand on port switch without an extra round-trip.
// Falls back to null per port when no logo is configured - the
// sidebar surfaces nothing rather than leaking a generic placeholder.
const portBrandingEntries = await Promise.all(
ports.map(async (p) => {
try {
const cfg = await getPortBrandingConfig(p.id);
return [p.id, cfg.logoUrl] as const;
} catch {
return [p.id, null] as const;
}
}),
);
const portLogoUrls: Record<string, string | null> = Object.fromEntries(portBrandingEntries);
// Per-port tenancies-module gate. Hidden by default; flips on either by
// the admin switch (Operations) OR the lazy auto-enable on first row.
// Resolved server-side so the sidebar nav SSRs in/out atomically with
// the layout instead of flickering after a client-side fetch.
const tenanciesModuleEntries = await Promise.all(
ports.map(async (p) => {
try {
return [p.id, await isTenanciesModuleEnabled(p.id)] as const;
} catch {
return [p.id, false] as const;
}
}),
);
const tenanciesModuleByPort: Record<string, boolean> = Object.fromEntries(tenanciesModuleEntries);
// Per-port expenses-module gate. Defaults to enabled (the registry's
// default) so existing ports keep the feature on deploy. Resolved
// server-side so the sidebar SSRs without flicker when an admin has
// turned the feature off for a tenant.
const expensesModuleEntries = await Promise.all(
ports.map(async (p) => {
try {
return [p.id, await isExpensesModuleEnabled(p.id)] as const;
} catch {
// Conservative default on lookup failure: keep the feature
// visible so a transient DB hiccup doesn't hide the module
// for a port that actually has it enabled.
return [p.id, true] as const;
}
}),
);
const expensesModuleByPort: Record<string, boolean> = Object.fromEntries(expensesModuleEntries);
// Per-port residential-module gate. Defaults to enabled (the registry's
// default) so existing ports keep the feature on deploy. Resolved
// server-side so the sidebar "Residential" section SSRs in/out without
// flicker when an admin has turned the feature off for a tenant.
const residentialModuleEntries = await Promise.all(
ports.map(async (p) => {
try {
return [p.id, await isResidentialModuleEnabled(p.id)] as const;
} catch {
// Conservative default on lookup failure: keep the feature
// visible so a transient DB hiccup doesn't hide the module.
return [p.id, true] as const;
}
}),
);
const residentialModuleByPort: Record<string, boolean> =
Object.fromEntries(residentialModuleEntries);
// Per-port maintenance-module gate. Defaults to enabled (registry
// default) so existing ports keep the berth Maintenance tab on deploy.
// Resolved server-side so the tab SSRs in/out without flicker.
const maintenanceModuleEntries = await Promise.all(
ports.map(async (p) => {
try {
return [p.id, await isMaintenanceModuleEnabled(p.id)] as const;
} catch {
// Conservative default on lookup failure: keep the feature visible.
return [p.id, true] as const;
}
}),
);
const maintenanceModuleByPort: Record<string, boolean> =
Object.fromEntries(maintenanceModuleEntries);
return (
<QueryProvider>
<PortProvider
ports={ports}
defaultPortId={ports[0]?.id ?? null}
tenanciesModuleByPort={tenanciesModuleByPort}
maintenanceModuleByPort={maintenanceModuleByPort}
>
<PermissionsProvider>
<SocketProvider>
<RealtimeToasts />
<WebVitalsReporter />
{/* Sticky banner across the app whenever EMAIL_REDIRECT_TO is
set so reps + admins always know outbound mail is being
rerouted. Production hides itself (env.ts forbids the
flag in prod) so the banner is dev/staging-only. */}
<DevModeBanner />
<OnboardingBanner />
{/* #26: AppShell mounts ONE responsive tree (desktop OR
* mobile) per render - never both - so pages don't pay the
* double-state, double-fetch, double-Tabs-provider tax. */}
<AppShell
portRoles={portRoles}
isSuperAdmin={profile?.isSuperAdmin ?? false}
user={user}
ports={ports}
portLogoUrls={portLogoUrls}
tenanciesModuleByPort={tenanciesModuleByPort}
expensesModuleByPort={expensesModuleByPort}
residentialModuleByPort={residentialModuleByPort}
initialFormFactor={initialFormFactor}
>
{children}
</AppShell>
</SocketProvider>
</PermissionsProvider>
</PortProvider>
</QueryProvider>
);
}