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

@@ -22,6 +22,8 @@ export interface ClientRow {
source: string | null;
archivedAt: string | null;
createdAt: string;
yachtCount?: number;
companyCount?: number;
contacts?: Array<{ channel: string; value: string; isPrimary: boolean }>;
tags?: Array<{ id: string; name: string; color: string }>;
}
@@ -39,10 +41,6 @@ interface GetColumnsOptions {
onArchive: (client: ClientRow) => void;
}
// TODO: Add "Yachts" (count) and "Primary company" columns once the
// GET /api/v1/clients list endpoint joins owned-yachts and primary-company
// data into the row shape. Until then, the columns are omitted rather than
// shown as empty placeholders.
export function getClientColumns({
portSlug,
onEdit,
@@ -100,6 +98,36 @@ export function getClientColumns({
);
},
},
{
id: 'yachtCount',
header: 'Yachts',
enableSorting: false,
cell: ({ row }) => {
const c = row.original.yachtCount ?? 0;
return c === 0 ? (
<span className="text-muted-foreground"></span>
) : (
<Badge variant="secondary" className="text-xs">
{c}
</Badge>
);
},
},
{
id: 'companyCount',
header: 'Companies',
enableSorting: false,
cell: ({ row }) => {
const c = row.original.companyCount ?? 0;
return c === 0 ? (
<span className="text-muted-foreground"></span>
) : (
<Badge variant="secondary" className="text-xs">
{c}
</Badge>
);
},
},
{
id: 'tags',
header: 'Tags',

View File

@@ -2,13 +2,12 @@
import { useState } from 'react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { Pencil, Archive, RotateCcw, Mail, Phone } from 'lucide-react';
import { Archive, RotateCcw, Mail, Phone } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { TagBadge } from '@/components/shared/tag-badge';
import { ArchiveConfirmDialog } from '@/components/shared/archive-confirm-dialog';
import { ClientForm } from '@/components/clients/client-form';
import { PortalInviteButton } from '@/components/clients/portal-invite-button';
import { apiFetch } from '@/lib/api/client';
@@ -25,22 +24,10 @@ interface ClientDetailHeaderProps {
archivedAt?: string | null;
contacts?: Array<{ channel: string; value: string; isPrimary: boolean; label?: string | null }>;
tags?: Array<{ id: string; name: string; color: string }>;
clientPortalEnabled?: boolean;
};
}
type ClientFormClient = {
id: string;
fullName: string;
nationality?: string | null;
preferredContactMethod?: string | null;
preferredLanguage?: string | null;
timezone?: string | null;
source?: string | null;
sourceDetails?: string | null;
contacts?: Array<{ channel: string; value: string; label?: string | null; isPrimary?: boolean }>;
tags?: Array<{ id: string }>;
};
const SOURCE_LABELS: Record<string, string> = {
website: 'Website',
manual: 'Manual',
@@ -50,7 +37,6 @@ const SOURCE_LABELS: Record<string, string> = {
export function ClientDetailHeader({ client }: ClientDetailHeaderProps) {
const queryClient = useQueryClient();
const [editOpen, setEditOpen] = useState(false);
const [archiveOpen, setArchiveOpen] = useState(false);
const isArchived = !!client.archivedAt;
@@ -128,17 +114,13 @@ export function ClientDetailHeader({ client }: ClientDetailHeaderProps) {
{/* Actions */}
<div className="flex items-center gap-2">
{!isArchived && (
{!isArchived && client.clientPortalEnabled !== false && (
<PortalInviteButton
clientId={client.id}
clientName={client.fullName}
defaultEmail={primaryEmail?.value}
/>
)}
<Button variant="outline" size="sm" onClick={() => setEditOpen(true)}>
<Pencil className="mr-1.5 h-3.5 w-3.5" />
Edit
</Button>
<Button
variant={isArchived ? 'outline' : 'outline'}
size="sm"
@@ -160,12 +142,6 @@ export function ClientDetailHeader({ client }: ClientDetailHeaderProps) {
</div>
</div>
<ClientForm
open={editOpen}
onOpenChange={setEditOpen}
client={client as unknown as ClientFormClient}
/>
<ArchiveConfirmDialog
open={archiveOpen}
onOpenChange={setArchiveOpen}

View File

@@ -19,6 +19,7 @@ interface ClientData {
source: string | null;
sourceDetails: string | null;
archivedAt: string | null;
clientPortalEnabled: boolean;
createdAt: string;
updatedAt: string;
contacts: Array<{

View File

@@ -1,10 +1,62 @@
'use client';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import type { DetailTab } from '@/components/shared/detail-layout';
import { InlineEditableField } from '@/components/shared/inline-editable-field';
import { InlineTagEditor } from '@/components/shared/inline-tag-editor';
import { NotesList } from '@/components/shared/notes-list';
import { ClientYachtsTab } from '@/components/clients/client-yachts-tab';
import { ClientCompaniesTab } from '@/components/clients/client-companies-tab';
import { ClientReservationsTab } from '@/components/clients/client-reservations-tab';
import { ContactsEditor } from '@/components/clients/contacts-editor';
import { apiFetch } from '@/lib/api/client';
type ClientPatchField =
| 'fullName'
| 'nationality'
| 'preferredContactMethod'
| 'preferredLanguage'
| 'timezone'
| 'source'
| 'sourceDetails';
const SOURCE_OPTIONS = [
{ value: 'website', label: 'Website' },
{ value: 'manual', label: 'Manual' },
{ value: 'referral', label: 'Referral' },
{ value: 'broker', label: 'Broker' },
];
const CONTACT_METHOD_OPTIONS = [
{ value: 'email', label: 'Email' },
{ value: 'phone', label: 'Phone' },
{ value: 'whatsapp', label: 'WhatsApp' },
];
function useClientPatch(clientId: string) {
const qc = useQueryClient();
return useMutation({
mutationFn: async (patch: Partial<Record<ClientPatchField, string | null>>) => {
return apiFetch(`/api/v1/clients/${clientId}`, {
method: 'PATCH',
body: patch,
});
},
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['clients', clientId] });
},
});
}
function EditableRow({ label, children }: { label: string; children: React.ReactNode }) {
return (
<div className="flex gap-2 py-1.5 border-b last:border-0 items-center">
<dt className="w-40 shrink-0 text-sm text-muted-foreground">{label}</dt>
<dd className="flex-1 min-w-0">{children}</dd>
</div>
);
}
interface ClientTabsOptions {
clientId: string;
@@ -57,83 +109,83 @@ interface ClientTabsOptions {
};
}
function InfoRow({ label, value }: { label: string; value?: string | null }) {
if (!value) return null;
return (
<div className="flex gap-2 py-1.5 border-b last:border-0">
<dt className="w-40 shrink-0 text-sm text-muted-foreground">{label}</dt>
<dd className="text-sm">{value}</dd>
</div>
);
}
function OverviewTab({
clientId,
client,
}: {
clientId: string;
client: ClientTabsOptions['client'];
}) {
const mutation = useClientPatch(clientId);
const save = (field: ClientPatchField) => async (next: string | null) => {
await mutation.mutateAsync({ [field]: next });
};
function OverviewTab({ client }: { client: ClientTabsOptions['client'] }) {
return (
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{/* Personal Info */}
<div className="space-y-1">
<h3 className="text-sm font-medium mb-2">Personal Information</h3>
<dl>
<InfoRow label="Full Name" value={client.fullName} />
<InfoRow label="Nationality" value={client.nationality} />
<InfoRow label="Preferred Language" value={client.preferredLanguage} />
<InfoRow label="Timezone" value={client.timezone} />
<InfoRow label="Preferred Contact" value={client.preferredContactMethod} />
<EditableRow label="Full Name">
<InlineEditableField value={client.fullName} onSave={save('fullName')} />
</EditableRow>
<EditableRow label="Nationality">
<InlineEditableField value={client.nationality} onSave={save('nationality')} />
</EditableRow>
<EditableRow label="Preferred Language">
<InlineEditableField
value={client.preferredLanguage}
onSave={save('preferredLanguage')}
/>
</EditableRow>
<EditableRow label="Timezone">
<InlineEditableField value={client.timezone} onSave={save('timezone')} />
</EditableRow>
<EditableRow label="Preferred Contact">
<InlineEditableField
variant="select"
options={CONTACT_METHOD_OPTIONS}
value={client.preferredContactMethod}
onSave={save('preferredContactMethod')}
/>
</EditableRow>
</dl>
</div>
{/* Contacts */}
<div className="space-y-1">
<h3 className="text-sm font-medium mb-2">Contact Details</h3>
{client.contacts && client.contacts.length > 0 ? (
<div className="space-y-2">
{client.contacts.map((c) => (
<div
key={c.id}
className="flex items-center gap-2 p-2 rounded-lg border bg-muted/30 text-sm"
>
<span className="capitalize text-muted-foreground w-20 shrink-0">{c.channel}</span>
<span className="flex-1">{c.value}</span>
{c.label && (
<span className="text-xs text-muted-foreground capitalize">{c.label}</span>
)}
{c.isPrimary && <span className="text-xs font-medium text-primary">Primary</span>}
</div>
))}
</div>
) : (
<p className="text-sm text-muted-foreground">No contacts added</p>
)}
<ContactsEditor clientId={clientId} contacts={client.contacts ?? []} />
</div>
{/* Source */}
{(client.source || client.sourceDetails) && (
<div className="space-y-1">
<h3 className="text-sm font-medium mb-2">Source</h3>
<dl>
<InfoRow label="Source" value={client.source} />
<InfoRow label="Source Details" value={client.sourceDetails} />
</dl>
</div>
)}
<div className="space-y-1">
<h3 className="text-sm font-medium mb-2">Source</h3>
<dl>
<EditableRow label="Source">
<InlineEditableField
variant="select"
options={SOURCE_OPTIONS}
value={client.source}
onSave={save('source')}
/>
</EditableRow>
<EditableRow label="Source Details">
<InlineEditableField value={client.sourceDetails} onSave={save('sourceDetails')} />
</EditableRow>
</dl>
</div>
{/* Tags */}
{client.tags && client.tags.length > 0 && (
<div className="space-y-1">
<h3 className="text-sm font-medium mb-2">Tags</h3>
<div className="flex flex-wrap gap-1">
{client.tags.map((tag) => (
<span
key={tag.id}
className="inline-block rounded-full px-2 py-0.5 text-xs font-medium"
style={{ backgroundColor: `${tag.color}20`, color: tag.color }}
>
{tag.name}
</span>
))}
</div>
</div>
)}
<div className="space-y-1">
<h3 className="text-sm font-medium mb-2">Tags</h3>
<InlineTagEditor
endpoint={`/api/v1/clients/${clientId}/tags`}
currentTags={client.tags ?? []}
invalidateKey={['clients', clientId]}
/>
</div>
</div>
);
}
@@ -143,7 +195,7 @@ export function getClientTabs({ clientId, currentUserId, client }: ClientTabsOpt
{
id: 'overview',
label: 'Overview',
content: <OverviewTab client={client} />,
content: <OverviewTab clientId={clientId} client={client} />,
},
{
id: 'yachts',

View File

@@ -0,0 +1,329 @@
'use client';
import { useState } from 'react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import {
Loader2,
Mail,
MessageSquare,
MoreHorizontal,
Phone,
Plus,
Star,
Trash2,
} from 'lucide-react';
import { toast } from 'sonner';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { InlineEditableField } from '@/components/shared/inline-editable-field';
import { apiFetch } from '@/lib/api/client';
import { cn } from '@/lib/utils';
interface Contact {
id: string;
channel: string;
value: string;
label?: string | null;
isPrimary: boolean;
}
const CHANNEL_OPTIONS = [
{ value: 'email', label: 'Email' },
{ value: 'phone', label: 'Phone' },
{ value: 'whatsapp', label: 'WhatsApp' },
{ value: 'other', label: 'Other' },
];
const CHANNEL_ICONS: Record<string, React.ComponentType<{ className?: string }>> = {
email: Mail,
phone: Phone,
whatsapp: MessageSquare,
other: MoreHorizontal,
};
export function ContactsEditor({ clientId, contacts }: { clientId: string; contacts: Contact[] }) {
const qc = useQueryClient();
const [adding, setAdding] = useState(false);
function invalidate() {
qc.invalidateQueries({ queryKey: ['clients', clientId] });
}
const updateMutation = useMutation({
mutationFn: async ({
contactId,
patch,
}: {
contactId: string;
patch: Partial<Pick<Contact, 'channel' | 'value' | 'label' | 'isPrimary'>>;
}) =>
apiFetch(`/api/v1/clients/${clientId}/contacts/${contactId}`, {
method: 'PATCH',
body: patch,
}),
onSuccess: invalidate,
});
const addMutation = useMutation({
mutationFn: async (data: { channel: string; value: string; label?: string }) =>
apiFetch(`/api/v1/clients/${clientId}/contacts`, {
method: 'POST',
body: { ...data, isPrimary: false },
}),
onSuccess: invalidate,
});
const removeMutation = useMutation({
mutationFn: async (contactId: string) =>
apiFetch(`/api/v1/clients/${clientId}/contacts/${contactId}`, { method: 'DELETE' }),
onSuccess: invalidate,
});
return (
<div className="space-y-2">
{contacts.length === 0 && !adding && (
<p className="text-sm text-muted-foreground">No contacts yet</p>
)}
{contacts.map((c) => (
<ContactRow
key={c.id}
contact={c}
onUpdate={(patch) => updateMutation.mutateAsync({ contactId: c.id, patch })}
onRemove={async () => {
if (!confirm('Remove this contact?')) return;
await removeMutation.mutateAsync(c.id);
}}
/>
))}
{adding ? (
<NewContactForm
onCancel={() => setAdding(false)}
onSave={async (data) => {
await addMutation.mutateAsync(data);
setAdding(false);
}}
/>
) : (
<Button
type="button"
variant="outline"
size="sm"
onClick={() => setAdding(true)}
className="w-full justify-center"
>
<Plus className="h-3.5 w-3.5 mr-1.5" />
Add contact
</Button>
)}
</div>
);
}
function ContactRow({
contact,
onUpdate,
onRemove,
}: {
contact: Contact;
onUpdate: (
patch: Partial<Pick<Contact, 'channel' | 'value' | 'label' | 'isPrimary'>>,
) => Promise<unknown>;
onRemove: () => void;
}) {
const Icon = CHANNEL_ICONS[contact.channel] ?? MoreHorizontal;
async function togglePrimary() {
try {
await onUpdate({ isPrimary: !contact.isPrimary });
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Failed to update');
}
}
async function changeChannel(next: string) {
if (next === contact.channel) return;
try {
await onUpdate({ channel: next });
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Failed to update');
}
}
return (
<div className="group flex items-center gap-2 p-2 rounded-lg border bg-muted/30 text-sm">
{/* Left: channel + value */}
<div className="flex items-center gap-2 flex-1 min-w-0">
<ChannelPicker value={contact.channel} onChange={changeChannel}>
<Icon className="h-3.5 w-3.5 text-muted-foreground" />
</ChannelPicker>
<div className="min-w-0">
<InlineEditableField
value={contact.value}
onSave={async (v) => {
if (!v) {
toast.error('Value is required');
return;
}
await onUpdate({ value: v });
}}
/>
</div>
</div>
{/* Right: tag + actions */}
<div className="flex items-center gap-2 shrink-0">
<div className="w-28 text-xs text-muted-foreground text-right">
<InlineEditableField
value={
contact.label && contact.label.toLowerCase() !== 'primary' ? contact.label : null
}
emptyText="Add tag"
placeholder="work, home…"
onSave={async (v) => {
await onUpdate({ label: v });
}}
/>
</div>
<button
type="button"
onClick={togglePrimary}
title={contact.isPrimary ? 'Primary' : 'Make primary'}
className={cn(
'p-1 rounded hover:bg-background/60 transition-colors',
contact.isPrimary ? 'text-primary' : 'text-muted-foreground/50',
)}
>
<Star className={cn('h-3.5 w-3.5', contact.isPrimary && 'fill-current')} />
</button>
<button
type="button"
onClick={onRemove}
title="Remove"
className="p-1 rounded text-muted-foreground/50 hover:text-destructive hover:bg-background/60 opacity-0 group-hover:opacity-100 transition-all"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
</div>
</div>
);
}
function ChannelPicker({
value,
onChange,
children,
}: {
value: string;
onChange: (next: string) => void;
children: React.ReactNode;
}) {
return (
<Select value={value} onValueChange={onChange}>
<SelectTrigger
className="h-7 w-7 p-0 border-none bg-transparent hover:bg-background/60 [&>svg]:hidden justify-center"
aria-label="Channel"
>
<SelectValue>{children}</SelectValue>
</SelectTrigger>
<SelectContent>
{CHANNEL_OPTIONS.map((o) => (
<SelectItem key={o.value} value={o.value}>
{o.label}
</SelectItem>
))}
</SelectContent>
</Select>
);
}
function NewContactForm({
onSave,
onCancel,
}: {
onSave: (data: { channel: string; value: string; label?: string }) => Promise<void>;
onCancel: () => void;
}) {
const [channel, setChannel] = useState('email');
const [value, setValue] = useState('');
const [label, setLabel] = useState('');
const [saving, setSaving] = useState(false);
async function submit() {
if (!value.trim()) return;
setSaving(true);
try {
await onSave({ channel, value: value.trim(), label: label.trim() || undefined });
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Failed to add contact');
} finally {
setSaving(false);
}
}
return (
<div className="flex items-center gap-2 p-2 rounded-lg border bg-muted/30 text-sm">
<Select value={channel} onValueChange={setChannel}>
<SelectTrigger className="h-7 w-28 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
{CHANNEL_OPTIONS.map((o) => (
<SelectItem key={o.value} value={o.value}>
{o.label}
</SelectItem>
))}
</SelectContent>
</Select>
<Input
value={value}
onChange={(e) => setValue(e.target.value)}
placeholder={channel === 'email' ? 'name@example.com' : '+1 555 0100'}
className="h-7 text-sm flex-1 min-w-0"
autoFocus
disabled={saving}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
void submit();
}
if (e.key === 'Escape') onCancel();
}}
/>
<Input
value={label}
onChange={(e) => setLabel(e.target.value)}
placeholder="tag (optional)"
className="h-7 text-xs w-28"
disabled={saving}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
void submit();
}
if (e.key === 'Escape') onCancel();
}}
/>
<Button type="button" size="sm" onClick={submit} disabled={!value.trim() || saving}>
{saving ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : 'Save'}
</Button>
<Button type="button" size="sm" variant="ghost" onClick={onCancel} disabled={saving}>
Cancel
</Button>
</div>
);
}