feat(platform): residential module + admin UI + reliability fixes
All checks were successful
Build & Push Docker Images / lint (pull_request) Successful in 1m2s
Build & Push Docker Images / build-and-push (pull_request) Has been skipped

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:
Matt Ciaccio
2026-04-27 21:54:32 +02:00
parent fac8021156
commit e8d61c91c4
121 changed files with 34105 additions and 1016 deletions

View File

@@ -0,0 +1,33 @@
const BG_URL = 'https://s3.portnimara.com/images/Overhead_1_blur.png';
const LOGO_URL =
'https://s3.portnimara.com/images/Port%20Nimara%20New%20Logo-Circular%20Frame_250px.png';
/**
* Branded shell shared by every auth/form surface — CRM login, portal login,
* password set/reset/activate, forgot-password. Renders the blurred Port
* Nimara overhead background, the circular logo, and a centered white card
* that consumers populate with their own form/content.
*/
export function BrandedAuthShell({ children }: { children: React.ReactNode }) {
return (
<div
className="min-h-screen flex items-center justify-center px-4 py-8"
style={{
backgroundImage: `url('${BG_URL}')`,
backgroundSize: 'cover',
backgroundPosition: 'center',
backgroundColor: '#f2f2f2',
}}
>
<div className="w-full max-w-md">
<div className="bg-white rounded-lg shadow-lg p-8">
<div className="flex justify-center mb-6">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src={LOGO_URL} alt="Port Nimara" className="w-24 h-auto" />
</div>
{children}
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,272 @@
'use client';
import { useEffect, useRef, useState } from 'react';
import { Loader2, Pencil } from 'lucide-react';
import { toast } from 'sonner';
import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { cn } from '@/lib/utils';
interface SelectOption {
value: string;
label: string;
}
interface BaseProps {
value: string | null | undefined;
onSave: (next: string | null) => Promise<void>;
placeholder?: string;
emptyText?: string;
className?: string;
disabled?: boolean;
}
interface TextProps extends BaseProps {
variant?: 'text';
}
interface SelectFieldProps extends BaseProps {
variant: 'select';
options: SelectOption[];
}
interface TextareaProps extends BaseProps {
variant: 'textarea';
rows?: number;
}
export type InlineEditableFieldProps = TextProps | SelectFieldProps | TextareaProps;
/**
* Click-to-edit field used in detail panels. Shows the value as plain text
* with a pencil affordance on hover; clicking swaps to an input that saves on
* Enter/blur and cancels on Escape.
*/
export function InlineEditableField(props: InlineEditableFieldProps) {
const { value, onSave, placeholder, emptyText = '—', className, disabled } = props;
const [editing, setEditing] = useState(false);
const [draft, setDraft] = useState(value ?? '');
const [saving, setSaving] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);
const textareaRef = useRef<HTMLTextAreaElement>(null);
useEffect(() => {
setDraft(value ?? '');
}, [value]);
useEffect(() => {
if (editing) {
if (inputRef.current) {
inputRef.current.focus();
inputRef.current.select();
} else if (textareaRef.current) {
textareaRef.current.focus();
textareaRef.current.select();
}
}
}, [editing]);
async function commit(nextRaw: string) {
const trimmed = nextRaw.trim();
if (trimmed === (value ?? '')) {
setEditing(false);
return;
}
setSaving(true);
try {
await onSave(trimmed === '' ? null : trimmed);
setEditing(false);
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Failed to save');
setDraft(value ?? '');
} finally {
setSaving(false);
}
}
function cancel() {
setDraft(value ?? '');
setEditing(false);
}
if (props.variant === 'select') {
const labelFor = (v: string | null | undefined) =>
v ? (props.options.find((o) => o.value === v)?.label ?? v) : null;
if (!editing) {
return (
<ReadButton
value={labelFor(value)}
emptyText={emptyText}
disabled={disabled}
onClick={() => setEditing(true)}
className={className}
/>
);
}
return (
<div className={cn('flex items-center gap-1', className)}>
<Select
value={draft}
onValueChange={(v) => void commit(v)}
open
onOpenChange={(open) => {
if (!open && !saving) setEditing(false);
}}
>
<SelectTrigger className="h-7 text-sm w-full">
<SelectValue placeholder={placeholder} />
</SelectTrigger>
<SelectContent>
{props.options.map((o) => (
<SelectItem key={o.value} value={o.value}>
{o.label}
</SelectItem>
))}
</SelectContent>
</Select>
{saving && <Loader2 className="h-3 w-3 animate-spin text-muted-foreground" />}
</div>
);
}
if (props.variant === 'textarea') {
if (!editing) {
return (
<ReadButton
value={value || null}
emptyText={emptyText}
disabled={disabled}
onClick={() => setEditing(true)}
multiline
className={className}
/>
);
}
return (
<div className={cn('flex flex-col gap-1', className)}>
<Textarea
ref={textareaRef}
value={draft}
onChange={(e) => setDraft(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Escape') {
e.preventDefault();
cancel();
}
if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
e.preventDefault();
void commit(draft);
}
}}
onBlur={() => {
if (!saving) void commit(draft);
}}
placeholder={placeholder}
disabled={saving}
rows={props.rows ?? 4}
className="text-sm"
/>
{saving && <Loader2 className="h-3 w-3 animate-spin text-muted-foreground" />}
</div>
);
}
if (!editing) {
return (
<ReadButton
value={value || null}
emptyText={emptyText}
disabled={disabled}
onClick={() => setEditing(true)}
className={className}
/>
);
}
return (
<div className={cn('flex items-center gap-1', className)}>
<Input
ref={inputRef}
value={draft}
onChange={(e) => setDraft(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
void commit(draft);
}
if (e.key === 'Escape') {
e.preventDefault();
cancel();
}
}}
onBlur={() => {
if (!saving) void commit(draft);
}}
placeholder={placeholder}
disabled={saving}
className="h-7 text-sm"
/>
{saving && <Loader2 className="h-3 w-3 animate-spin text-muted-foreground" />}
</div>
);
}
function ReadButton({
value,
emptyText,
disabled,
onClick,
multiline,
className,
}: {
value: string | null;
emptyText: string;
disabled?: boolean;
onClick: () => void;
multiline?: boolean;
className?: string;
}) {
return (
<button
type="button"
disabled={disabled}
onClick={onClick}
className={cn(
'group rounded px-1 -mx-1 py-0.5 text-left text-sm',
multiline ? 'flex w-full items-start gap-1.5' : 'inline-flex items-center gap-1.5',
'hover:bg-muted/60 focus-visible:bg-muted/60 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring',
disabled && 'cursor-not-allowed opacity-60 hover:bg-transparent',
className,
)}
>
<span
className={cn(
'flex-1',
multiline && 'whitespace-pre-wrap',
!value && 'text-muted-foreground',
)}
>
{value ?? emptyText}
</span>
{!disabled && (
<Pencil
className={cn(
'h-3 w-3 opacity-0 transition-opacity group-hover:opacity-50',
multiline && 'mt-1 shrink-0',
)}
/>
)}
</button>
);
}

View File

@@ -0,0 +1,132 @@
'use client';
import { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { Plus, X, Check } from 'lucide-react';
import { toast } from 'sonner';
import { Button } from '@/components/ui/button';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import { apiFetch } from '@/lib/api/client';
import { cn } from '@/lib/utils';
interface Tag {
id: string;
name: string;
color: string;
}
export interface InlineTagEditorProps {
/** PUT endpoint for replacing the entity's tag list — body shape `{ tagIds: string[] }`. */
endpoint: string;
currentTags: Tag[];
/** TanStack Query key to invalidate after a successful change. */
invalidateKey: readonly unknown[];
/** Hide the "+ Add tag" button (read-only mode). */
readOnly?: boolean;
}
export function InlineTagEditor({
endpoint,
currentTags,
invalidateKey,
readOnly,
}: InlineTagEditorProps) {
const qc = useQueryClient();
const [open, setOpen] = useState(false);
const { data: allTags } = useQuery<{ data: Tag[] }>({
queryKey: ['tags'],
queryFn: () => apiFetch('/api/v1/tags'),
staleTime: 60_000,
enabled: open,
});
const setTags = useMutation({
mutationFn: (tagIds: string[]) => apiFetch(endpoint, { method: 'PUT', body: { tagIds } }),
onSuccess: () => qc.invalidateQueries({ queryKey: invalidateKey }),
onError: (err) => toast.error(err instanceof Error ? err.message : 'Failed to update tags'),
});
function toggleTag(tagId: string) {
const has = currentTags.some((t) => t.id === tagId);
const nextIds = has
? currentTags.filter((t) => t.id !== tagId).map((t) => t.id)
: [...currentTags.map((t) => t.id), tagId];
setTags.mutate(nextIds);
}
function removeTag(tagId: string) {
setTags.mutate(currentTags.filter((t) => t.id !== tagId).map((t) => t.id));
}
return (
<div className="flex flex-wrap items-center gap-1.5">
{currentTags.map((t) => (
<span
key={t.id}
className="group inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-xs font-medium"
style={{ backgroundColor: `${t.color}20`, color: t.color }}
>
{t.name}
{!readOnly && (
<button
type="button"
onClick={() => removeTag(t.id)}
className="opacity-0 group-hover:opacity-100 transition-opacity hover:opacity-100"
aria-label={`Remove tag ${t.name}`}
>
<X className="h-3 w-3" />
</button>
)}
</span>
))}
{!readOnly && (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
type="button"
size="sm"
variant="ghost"
className="h-6 px-2 text-xs text-muted-foreground hover:text-foreground"
>
<Plus className="h-3 w-3 mr-1" />
{currentTags.length === 0 ? 'Add tag' : 'Add'}
</Button>
</PopoverTrigger>
<PopoverContent className="w-64 p-0" align="start">
<div className="max-h-64 overflow-y-auto py-1">
{!allTags && <div className="px-3 py-2 text-xs text-muted-foreground">Loading</div>}
{allTags?.data.length === 0 && (
<div className="px-3 py-2 text-xs text-muted-foreground">
No tags defined yet. Create some in Admin Tags.
</div>
)}
{allTags?.data.map((t) => {
const checked = currentTags.some((c) => c.id === t.id);
return (
<button
key={t.id}
type="button"
onClick={() => toggleTag(t.id)}
className={cn(
'flex items-center gap-2 w-full px-3 py-1.5 text-sm text-left hover:bg-muted/60',
)}
>
<span
className="inline-block w-2.5 h-2.5 rounded-full shrink-0"
style={{ backgroundColor: t.color }}
/>
<span className="flex-1 truncate">{t.name}</span>
{checked && <Check className="h-3.5 w-3.5 text-primary" />}
</button>
);
})}
</div>
</PopoverContent>
</Popover>
)}
</div>
);
}

View File

@@ -21,7 +21,7 @@ interface Note {
}
interface NotesListProps {
entityType: 'clients' | 'interests';
entityType: 'clients' | 'interests' | 'yachts' | 'companies';
entityId: string;
currentUserId?: string;
}
@@ -43,8 +43,7 @@ export function NotesList({ entityType, entityId, currentUserId }: NotesListProp
});
const createMutation = useMutation({
mutationFn: (content: string) =>
apiFetch(endpoint, { method: 'POST', body: { content } }),
mutationFn: (content: string) => apiFetch(endpoint, { method: 'POST', body: { content } }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey });
setNewNote('');
@@ -61,8 +60,7 @@ export function NotesList({ entityType, entityId, currentUserId }: NotesListProp
});
const deleteMutation = useMutation({
mutationFn: (noteId: string) =>
apiFetch(`${endpoint}/${noteId}`, { method: 'DELETE' }),
mutationFn: (noteId: string) => apiFetch(`${endpoint}/${noteId}`, { method: 'DELETE' }),
onSuccess: () => queryClient.invalidateQueries({ queryKey }),
});
@@ -127,13 +125,9 @@ export function NotesList({ entityType, entityId, currentUserId }: NotesListProp
<span className="text-muted-foreground">
{formatDistanceToNow(new Date(note.createdAt), { addSuffix: true })}
</span>
{note.isLocked && (
<Lock className="h-3 w-3 text-muted-foreground" />
)}
{note.isLocked && <Lock className="h-3 w-3 text-muted-foreground" />}
{canEdit(note) && (
<span className="text-xs text-muted-foreground">
{getTimeRemaining(note)}
</span>
<span className="text-xs text-muted-foreground">{getTimeRemaining(note)}</span>
)}
</div>
{editingId === note.id ? (