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:
@@ -35,8 +35,18 @@ const SEGMENT_LABELS: Record<string, string> = {
|
||||
profile: 'Profile',
|
||||
};
|
||||
|
||||
// UUID v4-ish (or any 36-char hex+dash) — used to skip entity-id segments
|
||||
// from the breadcrumbs since the page H1 already shows the entity name.
|
||||
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
||||
|
||||
function isIdSegment(segment: string): boolean {
|
||||
return UUID_RE.test(segment);
|
||||
}
|
||||
|
||||
function formatSegment(segment: string): string {
|
||||
return SEGMENT_LABELS[segment] ?? segment.replace(/-/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase());
|
||||
return (
|
||||
SEGMENT_LABELS[segment] ?? segment.replace(/-/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase())
|
||||
);
|
||||
}
|
||||
|
||||
export function Breadcrumbs() {
|
||||
@@ -46,10 +56,11 @@ export function Breadcrumbs() {
|
||||
// Split pathname and filter empty segments
|
||||
const rawSegments = pathname.split('/').filter(Boolean);
|
||||
|
||||
// Remove the portSlug segment from display
|
||||
const segments = currentPortSlug
|
||||
? rawSegments.filter((seg) => seg !== currentPortSlug)
|
||||
: rawSegments;
|
||||
// Remove the portSlug segment and any UUID-ish entity-id segments — the
|
||||
// page H1 already shows the entity name, no need to leak the raw id.
|
||||
const segments = (
|
||||
currentPortSlug ? rawSegments.filter((seg) => seg !== currentPortSlug) : rawSegments
|
||||
).filter((seg) => !isIdSegment(seg));
|
||||
|
||||
if (segments.length === 0) {
|
||||
return (
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { Plus, Moon, Sun, LogOut, User, Settings } from 'lucide-react';
|
||||
import { Plus, Moon, Sun, LogOut, User, Settings, Bell } from 'lucide-react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
import { useUIStore } from '@/stores/ui-store';
|
||||
@@ -113,6 +113,13 @@ export function Topbar({ ports }: TopbarProps) {
|
||||
<Settings className="w-4 h-4 mr-2" />
|
||||
Settings
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
onClick={() => router.push(`${base}/notifications/preferences` as any)}
|
||||
>
|
||||
<Bell className="w-4 h-4 mr-2" />
|
||||
Notification preferences
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={handleToggleDarkMode}>
|
||||
{darkMode ? (
|
||||
|
||||
Reference in New Issue
Block a user