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>
187 lines
7.9 KiB
TypeScript
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>
|
|
);
|
|
}
|