2026-04-08 15:47:11 -04:00
|
|
|
'use client';
|
|
|
|
|
|
|
|
|
|
import { useState, useEffect } from 'react';
|
|
|
|
|
import { Button } from '@/components/ui/button';
|
|
|
|
|
import { Input } from '@/components/ui/input';
|
|
|
|
|
import { Label } from '@/components/ui/label';
|
|
|
|
|
import { Textarea } from '@/components/ui/textarea';
|
|
|
|
|
import { Checkbox } from '@/components/ui/checkbox';
|
|
|
|
|
import { ScrollArea } from '@/components/ui/scroll-area';
|
|
|
|
|
import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetFooter } from '@/components/ui/sheet';
|
|
|
|
|
import {
|
|
|
|
|
Accordion,
|
|
|
|
|
AccordionContent,
|
|
|
|
|
AccordionItem,
|
|
|
|
|
AccordionTrigger,
|
|
|
|
|
} from '@/components/ui/accordion';
|
|
|
|
|
import { apiFetch } from '@/lib/api/client';
|
|
|
|
|
|
|
|
|
|
/** Default permissions structure matching RolePermissions type */
|
|
|
|
|
const DEFAULT_PERMISSIONS: Record<string, Record<string, boolean>> = {
|
|
|
|
|
clients: { view: false, create: false, edit: false, delete: false, merge: false, export: false },
|
|
|
|
|
interests: {
|
|
|
|
|
view: false,
|
|
|
|
|
create: false,
|
|
|
|
|
edit: false,
|
|
|
|
|
delete: false,
|
|
|
|
|
change_stage: false,
|
|
|
|
|
generate_eoi: false,
|
|
|
|
|
export: false,
|
|
|
|
|
},
|
|
|
|
|
berths: { view: false, edit: false, import: false, manage_waiting_list: false },
|
|
|
|
|
documents: {
|
|
|
|
|
view: false,
|
|
|
|
|
create: false,
|
|
|
|
|
send_for_signing: false,
|
|
|
|
|
upload_signed: false,
|
|
|
|
|
delete: false,
|
|
|
|
|
},
|
|
|
|
|
expenses: {
|
|
|
|
|
view: false,
|
|
|
|
|
create: false,
|
|
|
|
|
edit: false,
|
|
|
|
|
delete: false,
|
|
|
|
|
export: false,
|
|
|
|
|
scan_receipt: false,
|
|
|
|
|
},
|
|
|
|
|
invoices: {
|
|
|
|
|
view: false,
|
|
|
|
|
create: false,
|
|
|
|
|
edit: false,
|
|
|
|
|
delete: false,
|
|
|
|
|
send: false,
|
|
|
|
|
record_payment: false,
|
|
|
|
|
export: false,
|
|
|
|
|
},
|
|
|
|
|
files: { view: false, upload: false, delete: false, manage_folders: false },
|
|
|
|
|
email: { view: false, send: false, configure_account: false },
|
|
|
|
|
reminders: {
|
|
|
|
|
view_own: false,
|
|
|
|
|
view_all: false,
|
|
|
|
|
create: false,
|
|
|
|
|
edit_own: false,
|
|
|
|
|
edit_all: false,
|
|
|
|
|
assign_others: false,
|
|
|
|
|
},
|
|
|
|
|
calendar: { connect: false, view_events: false },
|
|
|
|
|
reports: { view_dashboard: false, view_analytics: false, export: false },
|
|
|
|
|
document_templates: { view: false, generate: false, manage: false },
|
|
|
|
|
admin: {
|
|
|
|
|
manage_users: false,
|
|
|
|
|
view_audit_log: false,
|
|
|
|
|
manage_settings: false,
|
|
|
|
|
manage_webhooks: false,
|
|
|
|
|
manage_reports: false,
|
|
|
|
|
manage_custom_fields: false,
|
|
|
|
|
manage_forms: false,
|
|
|
|
|
manage_tags: false,
|
|
|
|
|
system_backup: false,
|
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>
2026-04-27 21:54:32 +02:00
|
|
|
},
|
|
|
|
|
residential_clients: { view: false, create: false, edit: false, delete: false },
|
|
|
|
|
residential_interests: {
|
|
|
|
|
view: false,
|
|
|
|
|
create: false,
|
|
|
|
|
edit: false,
|
|
|
|
|
delete: false,
|
|
|
|
|
change_stage: false,
|
2026-04-08 15:47:11 -04:00
|
|
|
},
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const GROUP_LABELS: Record<string, string> = {
|
|
|
|
|
clients: 'Clients',
|
|
|
|
|
interests: 'Interests / Pipeline',
|
|
|
|
|
berths: 'Berths',
|
|
|
|
|
documents: 'Documents',
|
|
|
|
|
expenses: 'Expenses',
|
|
|
|
|
invoices: 'Invoices',
|
|
|
|
|
files: 'Files',
|
|
|
|
|
email: 'Email',
|
|
|
|
|
reminders: 'Reminders',
|
|
|
|
|
calendar: 'Calendar',
|
|
|
|
|
reports: 'Reports',
|
|
|
|
|
document_templates: 'Document Templates',
|
|
|
|
|
admin: 'Administration',
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
function formatAction(action: string): string {
|
|
|
|
|
return action.replace(/_/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
interface RoleFormProps {
|
|
|
|
|
open: boolean;
|
|
|
|
|
onOpenChange: (open: boolean) => void;
|
|
|
|
|
role?: {
|
|
|
|
|
id: string;
|
|
|
|
|
name: string;
|
|
|
|
|
description: string | null;
|
|
|
|
|
isSystem: boolean;
|
|
|
|
|
permissions: Record<string, Record<string, boolean>>;
|
|
|
|
|
} | null;
|
|
|
|
|
onSuccess: () => void;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function RoleForm({ open, onOpenChange, role, onSuccess }: RoleFormProps) {
|
|
|
|
|
const [name, setName] = useState('');
|
|
|
|
|
const [description, setDescription] = useState('');
|
|
|
|
|
const [permissions, setPermissions] = useState<Record<string, Record<string, boolean>>>(
|
|
|
|
|
structuredClone(DEFAULT_PERMISSIONS),
|
|
|
|
|
);
|
|
|
|
|
const [loading, setLoading] = useState(false);
|
|
|
|
|
const [error, setError] = useState<string | null>(null);
|
|
|
|
|
|
|
|
|
|
const isEdit = !!role;
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
if (open) {
|
|
|
|
|
if (role) {
|
|
|
|
|
setName(role.name);
|
|
|
|
|
setDescription(role.description ?? '');
|
|
|
|
|
// Merge role permissions over defaults to fill any missing keys
|
|
|
|
|
const merged = structuredClone(DEFAULT_PERMISSIONS);
|
|
|
|
|
for (const [group, actions] of Object.entries(role.permissions)) {
|
|
|
|
|
if (merged[group]) {
|
|
|
|
|
for (const [action, value] of Object.entries(actions as Record<string, boolean>)) {
|
|
|
|
|
merged[group]![action] = value;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
setPermissions(merged);
|
|
|
|
|
} else {
|
|
|
|
|
setName('');
|
|
|
|
|
setDescription('');
|
|
|
|
|
setPermissions(structuredClone(DEFAULT_PERMISSIONS));
|
|
|
|
|
}
|
|
|
|
|
setError(null);
|
|
|
|
|
}
|
|
|
|
|
}, [open, role]);
|
|
|
|
|
|
|
|
|
|
function togglePermission(group: string, action: string) {
|
|
|
|
|
setPermissions((prev) => {
|
|
|
|
|
const next = structuredClone(prev);
|
|
|
|
|
next[group]![action] = !next[group]![action];
|
|
|
|
|
return next;
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function toggleGroup(group: string, value: boolean) {
|
|
|
|
|
setPermissions((prev) => {
|
|
|
|
|
const next = structuredClone(prev);
|
|
|
|
|
for (const action of Object.keys(next[group]!)) {
|
|
|
|
|
next[group]![action] = value;
|
|
|
|
|
}
|
|
|
|
|
return next;
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function isGroupAllChecked(group: string): boolean {
|
|
|
|
|
return Object.values(permissions[group]!).every(Boolean);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function isGroupPartial(group: string): boolean {
|
|
|
|
|
const vals = Object.values(permissions[group]!);
|
|
|
|
|
return vals.some(Boolean) && !vals.every(Boolean);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function handleSubmit(e: React.FormEvent) {
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
setError(null);
|
|
|
|
|
setLoading(true);
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
if (isEdit) {
|
|
|
|
|
await apiFetch(`/api/v1/admin/roles/${role.id}`, {
|
|
|
|
|
method: 'PATCH',
|
|
|
|
|
body: { name, description: description || null, permissions },
|
|
|
|
|
});
|
|
|
|
|
} else {
|
|
|
|
|
await apiFetch('/api/v1/admin/roles', {
|
|
|
|
|
method: 'POST',
|
|
|
|
|
body: { name, description: description || undefined, permissions },
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
onSuccess();
|
|
|
|
|
onOpenChange(false);
|
|
|
|
|
} catch (err: unknown) {
|
|
|
|
|
const message = err instanceof Error ? err.message : 'Something went wrong';
|
|
|
|
|
setError(message);
|
|
|
|
|
} finally {
|
|
|
|
|
setLoading(false);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<Sheet open={open} onOpenChange={onOpenChange}>
|
|
|
|
|
<SheetContent className="w-[500px] sm:max-w-[500px]">
|
|
|
|
|
<SheetHeader>
|
|
|
|
|
<SheetTitle>{isEdit ? 'Edit Role' : 'New Role'}</SheetTitle>
|
|
|
|
|
</SheetHeader>
|
|
|
|
|
|
|
|
|
|
<form onSubmit={handleSubmit} className="mt-6 flex flex-col h-[calc(100vh-140px)]">
|
|
|
|
|
<div className="space-y-4 mb-4">
|
|
|
|
|
<div className="space-y-2">
|
|
|
|
|
<Label htmlFor="role-name">Name</Label>
|
|
|
|
|
<Input
|
|
|
|
|
id="role-name"
|
|
|
|
|
value={name}
|
|
|
|
|
onChange={(e) => setName(e.target.value)}
|
|
|
|
|
placeholder="e.g. Sales Manager"
|
|
|
|
|
required
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div className="space-y-2">
|
|
|
|
|
<Label htmlFor="role-description">Description</Label>
|
|
|
|
|
<Textarea
|
|
|
|
|
id="role-description"
|
|
|
|
|
value={description}
|
|
|
|
|
onChange={(e) => setDescription(e.target.value)}
|
|
|
|
|
placeholder="What this role is for..."
|
|
|
|
|
rows={2}
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<Label className="mb-2">Permissions</Label>
|
|
|
|
|
<ScrollArea className="flex-1 rounded-md border">
|
|
|
|
|
<Accordion type="multiple" className="px-3">
|
|
|
|
|
{Object.entries(permissions).map(([group, actions]) => (
|
|
|
|
|
<AccordionItem key={group} value={group}>
|
|
|
|
|
<AccordionTrigger className="text-sm">
|
|
|
|
|
<div className="flex items-center gap-3">
|
|
|
|
|
<Checkbox
|
|
|
|
|
checked={isGroupAllChecked(group)}
|
|
|
|
|
ref={(el) => {
|
|
|
|
|
if (el) {
|
|
|
|
|
(el as unknown as HTMLInputElement).indeterminate =
|
|
|
|
|
isGroupPartial(group);
|
|
|
|
|
}
|
|
|
|
|
}}
|
|
|
|
|
onCheckedChange={(checked) => toggleGroup(group, checked === true)}
|
|
|
|
|
onClick={(e) => e.stopPropagation()}
|
|
|
|
|
/>
|
|
|
|
|
<span>{GROUP_LABELS[group] ?? group}</span>
|
|
|
|
|
</div>
|
|
|
|
|
</AccordionTrigger>
|
|
|
|
|
<AccordionContent>
|
|
|
|
|
<div className="grid grid-cols-2 gap-2 pl-8 pb-2">
|
|
|
|
|
{Object.entries(actions).map(([action, checked]) => (
|
|
|
|
|
<label
|
|
|
|
|
key={action}
|
|
|
|
|
className="flex items-center gap-2 text-sm cursor-pointer"
|
|
|
|
|
>
|
|
|
|
|
<Checkbox
|
|
|
|
|
checked={checked}
|
|
|
|
|
onCheckedChange={() => togglePermission(group, action)}
|
|
|
|
|
/>
|
|
|
|
|
{formatAction(action)}
|
|
|
|
|
</label>
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
|
|
|
|
</AccordionContent>
|
|
|
|
|
</AccordionItem>
|
|
|
|
|
))}
|
|
|
|
|
</Accordion>
|
|
|
|
|
</ScrollArea>
|
|
|
|
|
|
|
|
|
|
{error && <p className="mt-2 text-sm text-destructive">{error}</p>}
|
|
|
|
|
|
|
|
|
|
<SheetFooter className="mt-4">
|
|
|
|
|
<Button
|
|
|
|
|
type="button"
|
|
|
|
|
variant="outline"
|
|
|
|
|
onClick={() => onOpenChange(false)}
|
|
|
|
|
disabled={loading}
|
|
|
|
|
>
|
|
|
|
|
Cancel
|
|
|
|
|
</Button>
|
|
|
|
|
<Button type="submit" disabled={loading || !name.trim()}>
|
|
|
|
|
{loading ? 'Saving...' : isEdit ? 'Save Changes' : 'Create Role'}
|
|
|
|
|
</Button>
|
|
|
|
|
</SheetFooter>
|
|
|
|
|
</form>
|
|
|
|
|
</SheetContent>
|
|
|
|
|
</Sheet>
|
|
|
|
|
);
|
|
|
|
|
}
|