feat(platform): residential module + admin UI + reliability fixes
Residential platform - New schema: residentialClients, residentialInterests (separate from marina/yacht clients) with migration 0010 - Service layer with CRUD + audit + sockets + per-port portal toggle - v1 + public API routes (/api/v1/residential/*, /api/public/residential-inquiries) - List + detail pages with inline editing for clients and interests - Per-user residentialAccess toggle on userPortRoles (migration 0011) - Permission keys: residential_clients, residential_interests - Sidebar nav + role form integration - Smoke spec covering page loads, UI create flow, public endpoint Admin & shared UI - Admin → Forms (form templates CRUD) with validators + service - Notification preferences page (in-app + email per type) - Email composition + accounts list + threads view - Branded auth shell shared across CRM + portal auth surfaces - Inline editing extended to yacht/company/interest detail pages - InlineTagEditor + per-entity tags endpoints (yachts, companies) - Notes service polymorphic across clients/interests/yachts/companies - Client list columns: yachtCount + companyCount badges - Reservation file-download via presigned URL (replaces stale <a href>) Route handler refactor - Extracted yachts/companies/berths reservation handlers to sibling handlers.ts files (Next.js 15 route.ts only allows specific exports) Reliability fixes - apiFetch double-stringify bug fixed across 13 components (apiFetch already JSON.stringifies its body; passing a stringified body produced double-encoded JSON which failed zod validation) - SocketProvider gated behind useSyncExternalStore-based mount check to avoid useSession() SSR crashes under React 19 + Next 15 - apiFetch falls back to URL-pathname → port-id resolution when the Zustand store hasn't hydrated yet (fresh contexts, e2e tests) - CRM invite flow (schema, service, route, email, dev script) - Dashboard route → [portSlug]/dashboard/page.tsx + redirect - Document the dev-server restart-after-migration gotcha in CLAUDE.md Tests - 5-case residential smoke spec - Integration test updates for new service signatures Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -17,6 +17,7 @@ import {
|
||||
Bell,
|
||||
Settings,
|
||||
Shield,
|
||||
Home,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
Menu,
|
||||
@@ -38,6 +39,7 @@ import type { Role } from '@/lib/db/schema/users';
|
||||
|
||||
interface SidebarProps {
|
||||
portRoles: (UserPortRole & { port: { id: string; slug: string; name: string }; role: Role })[];
|
||||
isSuperAdmin?: boolean;
|
||||
}
|
||||
|
||||
interface NavItem {
|
||||
@@ -51,6 +53,10 @@ interface NavSection {
|
||||
title: string;
|
||||
items: NavItem[];
|
||||
adminRequired?: boolean;
|
||||
/** When true, only render if the user has marina-side access. */
|
||||
marinaRequired?: boolean;
|
||||
/** When true, only render if the user has residential-side access. */
|
||||
residentialRequired?: boolean;
|
||||
}
|
||||
|
||||
function buildNavSections(portSlug: string | undefined): NavSection[] {
|
||||
@@ -59,6 +65,7 @@ function buildNavSections(portSlug: string | undefined): NavSection[] {
|
||||
return [
|
||||
{
|
||||
title: 'Main',
|
||||
marinaRequired: true,
|
||||
items: [
|
||||
{ href: `${base}/dashboard`, label: 'Dashboard', icon: LayoutDashboard },
|
||||
{ href: `${base}/clients`, label: 'Clients', icon: Users },
|
||||
@@ -68,8 +75,25 @@ function buildNavSections(portSlug: string | undefined): NavSection[] {
|
||||
{ href: `${base}/berths`, label: 'Berths', icon: Anchor },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Residential',
|
||||
residentialRequired: true,
|
||||
items: [
|
||||
{
|
||||
href: `${base}/residential/clients`,
|
||||
label: 'Residential Clients',
|
||||
icon: Home,
|
||||
},
|
||||
{
|
||||
href: `${base}/residential/interests`,
|
||||
label: 'Residential Interests',
|
||||
icon: Bookmark,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Documents',
|
||||
marinaRequired: true,
|
||||
items: [
|
||||
{ href: `${base}/documents`, label: 'Documents', icon: FileText },
|
||||
{ href: `${base}/documents/files`, label: 'Files', icon: FolderOpen },
|
||||
@@ -77,6 +101,7 @@ function buildNavSections(portSlug: string | undefined): NavSection[] {
|
||||
},
|
||||
{
|
||||
title: 'Financial',
|
||||
marinaRequired: true,
|
||||
items: [
|
||||
{ href: `${base}/expenses`, label: 'Expenses', icon: Receipt },
|
||||
{ href: `${base}/invoices`, label: 'Invoices', icon: FileText },
|
||||
@@ -84,6 +109,7 @@ function buildNavSections(portSlug: string | undefined): NavSection[] {
|
||||
},
|
||||
{
|
||||
title: 'Communication',
|
||||
marinaRequired: true,
|
||||
items: [
|
||||
{ href: `${base}/email`, label: 'Email', icon: Mail },
|
||||
{ href: `${base}/reminders`, label: 'Reminders', icon: Bell },
|
||||
@@ -150,11 +176,15 @@ function SidebarContent({
|
||||
portSlug,
|
||||
portRoles,
|
||||
hasAdminAccess,
|
||||
hasMarinaAccess,
|
||||
hasResidentialAccess,
|
||||
}: {
|
||||
collapsed: boolean;
|
||||
portSlug: string | undefined;
|
||||
portRoles: SidebarProps['portRoles'];
|
||||
hasAdminAccess: boolean;
|
||||
hasMarinaAccess: boolean;
|
||||
hasResidentialAccess: boolean;
|
||||
}) {
|
||||
const pathname = usePathname();
|
||||
const [adminExpanded, setAdminExpanded] = useState(false);
|
||||
@@ -191,6 +221,8 @@ function SidebarContent({
|
||||
<nav className="px-2 space-y-4">
|
||||
{sections.map((section) => {
|
||||
if (section.adminRequired && !hasAdminAccess) return null;
|
||||
if (section.marinaRequired && !hasMarinaAccess) return null;
|
||||
if (section.residentialRequired && !hasResidentialAccess) return null;
|
||||
|
||||
return (
|
||||
<div key={section.title}>
|
||||
@@ -272,16 +304,25 @@ function SidebarContent({
|
||||
);
|
||||
}
|
||||
|
||||
export function Sidebar({ portRoles }: SidebarProps) {
|
||||
export function Sidebar({ portRoles, isSuperAdmin = false }: SidebarProps) {
|
||||
const sidebarCollapsed = useUIStore((s) => s.sidebarCollapsed);
|
||||
const toggleSidebar = useUIStore((s) => s.toggleSidebar);
|
||||
const currentPortSlug = useUIStore((s) => s.currentPortSlug);
|
||||
|
||||
// Check for admin access based on role permissions
|
||||
const hasAdminAccess = portRoles.some(
|
||||
(pr) =>
|
||||
pr.role?.permissions?.admin?.manage_users || pr.role?.permissions?.admin?.manage_settings,
|
||||
);
|
||||
// Super admins see every section regardless of role rows.
|
||||
const hasAdminAccess =
|
||||
isSuperAdmin ||
|
||||
portRoles.some(
|
||||
(pr) =>
|
||||
pr.role?.permissions?.admin?.manage_users || pr.role?.permissions?.admin?.manage_settings,
|
||||
);
|
||||
|
||||
const hasMarinaAccess =
|
||||
isSuperAdmin || portRoles.some((pr) => pr.role?.permissions?.clients?.view);
|
||||
|
||||
const hasResidentialAccess =
|
||||
isSuperAdmin ||
|
||||
portRoles.some((pr) => pr.residentialAccess || pr.role?.permissions?.residential_clients?.view);
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -298,6 +339,8 @@ export function Sidebar({ portRoles }: SidebarProps) {
|
||||
portSlug={currentPortSlug ?? undefined}
|
||||
portRoles={portRoles}
|
||||
hasAdminAccess={hasAdminAccess}
|
||||
hasMarinaAccess={hasMarinaAccess}
|
||||
hasResidentialAccess={hasResidentialAccess}
|
||||
/>
|
||||
|
||||
{/* Collapse toggle */}
|
||||
@@ -337,6 +380,8 @@ export function Sidebar({ portRoles }: SidebarProps) {
|
||||
portSlug={currentPortSlug ?? undefined}
|
||||
portRoles={portRoles}
|
||||
hasAdminAccess={hasAdminAccess}
|
||||
hasMarinaAccess={hasMarinaAccess}
|
||||
hasResidentialAccess={hasResidentialAccess}
|
||||
/>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
|
||||
Reference in New Issue
Block a user