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:
33
src/components/shared/branded-auth-shell.tsx
Normal file
33
src/components/shared/branded-auth-shell.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
272
src/components/shared/inline-editable-field.tsx
Normal file
272
src/components/shared/inline-editable-field.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
132
src/components/shared/inline-tag-editor.tsx
Normal file
132
src/components/shared/inline-tag-editor.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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 ? (
|
||||
|
||||
Reference in New Issue
Block a user