Files
pn-new-crm/src/components/residential/residential-clients-list.tsx

247 lines
8.1 KiB
TypeScript
Raw Normal View History

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
'use client';
import { useState } from 'react';
import Link from 'next/link';
import { useParams } from 'next/navigation';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { Plus } from 'lucide-react';
import { toast } from 'sonner';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Sheet, SheetContent, SheetFooter, SheetHeader, SheetTitle } from '@/components/ui/sheet';
import { PageHeader } from '@/components/shared/page-header';
import { useRealtimeInvalidation } from '@/hooks/use-realtime-invalidation';
import { apiFetch } from '@/lib/api/client';
interface ResidentialClientRow {
id: string;
fullName: string;
email: string | null;
phone: string | null;
placeOfResidence: string | null;
status: string;
source: string | null;
updatedAt: string;
}
interface ListResponse {
data: ResidentialClientRow[];
pagination: { total: number; page: number; pageSize: number };
}
const STATUS_LABELS: Record<string, string> = {
prospect: 'Prospect',
active: 'Active',
inactive: 'Inactive',
};
export function ResidentialClientsList() {
const params = useParams<{ portSlug: string }>();
const portSlug = params?.portSlug ?? '';
const [createOpen, setCreateOpen] = useState(false);
const [search, setSearch] = useState('');
const { data, isLoading } = useQuery<ListResponse>({
queryKey: ['residential-clients', { search }],
queryFn: () => {
const qs = new URLSearchParams({ search, limit: '50' });
return apiFetch(`/api/v1/residential/clients?${qs.toString()}`);
},
});
useRealtimeInvalidation({
'residential_client:created': [['residential-clients']],
'residential_client:updated': [['residential-clients']],
'residential_client:archived': [['residential-clients']],
'residential_client:restored': [['residential-clients']],
});
return (
<div className="space-y-4">
<PageHeader
title="Residential Clients"
description="Inquiries and clients for the residential side"
actions={
<Button size="sm" onClick={() => setCreateOpen(true)}>
<Plus className="mr-1.5 h-3.5 w-3.5" />
New
</Button>
}
/>
<div className="flex items-center gap-2">
<Input
placeholder="Search by name, email, phone, residence…"
value={search}
onChange={(e) => setSearch(e.target.value)}
className="max-w-sm"
/>
</div>
<div className="rounded-lg border bg-card overflow-hidden">
<table className="w-full text-sm">
<thead className="bg-muted/40 text-xs text-muted-foreground">
<tr>
<th className="text-left font-medium px-3 py-2">Name</th>
<th className="text-left font-medium px-3 py-2">Email</th>
<th className="text-left font-medium px-3 py-2">Phone</th>
<th className="text-left font-medium px-3 py-2">Residence</th>
<th className="text-left font-medium px-3 py-2">Status</th>
<th className="text-left font-medium px-3 py-2">Source</th>
</tr>
</thead>
<tbody>
{isLoading && (
<tr>
<td colSpan={6} className="px-3 py-8 text-center text-muted-foreground">
Loading
</td>
</tr>
)}
{!isLoading && data?.data.length === 0 && (
<tr>
<td colSpan={6} className="px-3 py-8 text-center text-muted-foreground">
No residential clients yet.
</td>
</tr>
)}
{data?.data.map((c) => (
<tr
key={c.id}
className="border-t hover:bg-muted/30 transition-colors cursor-pointer"
>
<td className="px-3 py-2">
<Link
/* eslint-disable-next-line @typescript-eslint/no-explicit-any */
href={`/${portSlug}/residential/clients/${c.id}` as any}
className="font-medium hover:underline"
>
{c.fullName}
</Link>
</td>
<td className="px-3 py-2 text-muted-foreground">{c.email ?? '—'}</td>
<td className="px-3 py-2 text-muted-foreground">{c.phone ?? '—'}</td>
<td className="px-3 py-2 text-muted-foreground">{c.placeOfResidence ?? '—'}</td>
<td className="px-3 py-2">{STATUS_LABELS[c.status] ?? c.status}</td>
<td className="px-3 py-2 capitalize text-muted-foreground">{c.source ?? '—'}</td>
</tr>
))}
</tbody>
</table>
</div>
<NewResidentialClientSheet open={createOpen} onOpenChange={setCreateOpen} />
</div>
);
}
function NewResidentialClientSheet({
open,
onOpenChange,
}: {
open: boolean;
onOpenChange: (v: boolean) => void;
}) {
const qc = useQueryClient();
const [fullName, setFullName] = useState('');
const [email, setEmail] = useState('');
const [phone, setPhone] = useState('');
const [placeOfResidence, setPlaceOfResidence] = useState('');
const [notes, setNotes] = useState('');
const create = useMutation({
mutationFn: () =>
apiFetch('/api/v1/residential/clients', {
method: 'POST',
body: {
fullName,
email: email || undefined,
phone: phone || undefined,
placeOfResidence: placeOfResidence || undefined,
notes: notes || undefined,
source: 'manual',
},
}),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['residential-clients'] });
onOpenChange(false);
setFullName('');
setEmail('');
setPhone('');
setPlaceOfResidence('');
setNotes('');
toast.success('Residential client added');
},
onError: (err) => {
toast.error(err instanceof Error ? err.message : 'Failed to create');
},
});
return (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent>
<SheetHeader>
<SheetTitle>New residential client</SheetTitle>
</SheetHeader>
<form
className="mt-6 space-y-4"
onSubmit={(e) => {
e.preventDefault();
create.mutate();
}}
>
<div className="space-y-1.5">
<Label htmlFor="rc-name">Full name *</Label>
<Input
id="rc-name"
value={fullName}
onChange={(e) => setFullName(e.target.value)}
required
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="rc-email">Email</Label>
<Input
id="rc-email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="rc-phone">Phone</Label>
<Input id="rc-phone" value={phone} onChange={(e) => setPhone(e.target.value)} />
</div>
<div className="space-y-1.5">
<Label htmlFor="rc-residence">Place of residence</Label>
<Input
id="rc-residence"
value={placeOfResidence}
onChange={(e) => setPlaceOfResidence(e.target.value)}
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="rc-notes">Notes</Label>
<Input id="rc-notes" value={notes} onChange={(e) => setNotes(e.target.value)} />
</div>
<SheetFooter>
<Button
type="button"
variant="outline"
onClick={() => onOpenChange(false)}
disabled={create.isPending}
>
Cancel
</Button>
<Button type="submit" disabled={!fullName.trim() || create.isPending}>
{create.isPending ? 'Saving…' : 'Create'}
</Button>
</SheetFooter>
</form>
</SheetContent>
</Sheet>
);
}