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,243 @@
'use client';
import { useEffect, useState } from 'react';
import { useMutation } from '@tanstack/react-query';
import { Plus, Trash2 } 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 { Switch } from '@/components/ui/switch';
import { Textarea } from '@/components/ui/textarea';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Sheet, SheetContent, SheetFooter, SheetHeader, SheetTitle } from '@/components/ui/sheet';
import { apiFetch } from '@/lib/api/client';
import type { FormField } from '@/lib/validators/form-templates';
interface FormTemplate {
id: string;
name: string;
description: string | null;
fields: FormField[];
isActive: boolean;
}
interface Props {
open: boolean;
onOpenChange: (open: boolean) => void;
template: FormTemplate | null;
onSaved: () => void;
}
const DEFAULT_FIELD: FormField = {
key: '',
label: '',
type: 'text',
required: false,
};
const FIELD_TYPES: Array<{ value: FormField['type']; label: string }> = [
{ value: 'text', label: 'Text' },
{ value: 'textarea', label: 'Long text' },
{ value: 'email', label: 'Email' },
{ value: 'phone', label: 'Phone' },
{ value: 'number', label: 'Number' },
{ value: 'select', label: 'Select' },
{ value: 'checkbox', label: 'Checkbox' },
];
export function FormTemplateForm({ open, onOpenChange, template, onSaved }: Props) {
const [name, setName] = useState('');
const [description, setDescription] = useState('');
const [isActive, setIsActive] = useState(true);
const [fields, setFields] = useState<FormField[]>([{ ...DEFAULT_FIELD }]);
useEffect(() => {
if (template) {
setName(template.name);
setDescription(template.description ?? '');
setIsActive(template.isActive);
setFields(template.fields.length > 0 ? template.fields : [{ ...DEFAULT_FIELD }]);
} else {
setName('');
setDescription('');
setIsActive(true);
setFields([{ ...DEFAULT_FIELD }]);
}
}, [template, open]);
const saveMutation = useMutation({
mutationFn: () => {
const payload = {
name,
description: description || undefined,
fields,
isActive,
};
if (template) {
return apiFetch(`/api/v1/admin/form-templates/${template.id}`, {
method: 'PATCH',
body: payload,
});
}
return apiFetch('/api/v1/admin/form-templates', {
method: 'POST',
body: payload,
});
},
onSuccess: () => {
toast.success(template ? 'Template saved' : 'Template created');
onSaved();
onOpenChange(false);
},
onError: (err) => toast.error(err instanceof Error ? err.message : 'Save failed'),
});
function updateField(idx: number, patch: Partial<FormField>) {
setFields((prev) => prev.map((f, i) => (i === idx ? { ...f, ...patch } : f)));
}
function addField() {
setFields((prev) => [...prev, { ...DEFAULT_FIELD }]);
}
function removeField(idx: number) {
setFields((prev) => (prev.length === 1 ? prev : prev.filter((_, i) => i !== idx)));
}
return (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent className="overflow-y-auto sm:max-w-2xl">
<SheetHeader>
<SheetTitle>{template ? 'Edit form template' : 'New form template'}</SheetTitle>
</SheetHeader>
<div className="space-y-4 py-4">
<div className="space-y-1">
<Label>Name</Label>
<Input value={name} onChange={(e) => setName(e.target.value)} />
</div>
<div className="space-y-1">
<Label>Description</Label>
<Textarea
rows={2}
value={description}
onChange={(e) => setDescription(e.target.value)}
/>
</div>
<div className="flex items-center gap-2">
<Switch checked={isActive} onCheckedChange={setIsActive} />
<Label>Active</Label>
</div>
<div className="border-t pt-3 space-y-3">
<div className="flex items-center justify-between">
<Label className="text-sm font-medium">Fields</Label>
<Button variant="outline" size="sm" onClick={addField}>
<Plus className="h-3.5 w-3.5 mr-1" />
Add field
</Button>
</div>
{fields.map((f, i) => (
<div key={i} className="rounded-md border p-3 space-y-2">
<div className="flex items-center justify-between">
<span className="text-xs text-muted-foreground">Field {i + 1}</span>
{fields.length > 1 && (
<Button
variant="ghost"
size="icon"
className="text-destructive h-7 w-7"
onClick={() => removeField(i)}
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
)}
</div>
<div className="grid grid-cols-2 gap-2">
<div className="space-y-1">
<Label className="text-xs">Key (no spaces)</Label>
<Input
value={f.key}
onChange={(e) => updateField(i, { key: e.target.value })}
placeholder="email"
/>
</div>
<div className="space-y-1">
<Label className="text-xs">Label</Label>
<Input
value={f.label}
onChange={(e) => updateField(i, { label: e.target.value })}
placeholder="Email address"
/>
</div>
<div className="space-y-1">
<Label className="text-xs">Type</Label>
<Select
value={f.type}
onValueChange={(v) => updateField(i, { type: v as FormField['type'] })}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{FIELD_TYPES.map((ft) => (
<SelectItem key={ft.value} value={ft.value}>
{ft.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-1 flex items-end gap-2 pb-1">
<Switch
checked={!!f.required}
onCheckedChange={(v) => updateField(i, { required: v })}
/>
<Label className="text-xs">Required</Label>
</div>
</div>
{f.type === 'select' && (
<div className="space-y-1">
<Label className="text-xs">Options (comma-separated)</Label>
<Input
value={(f.options ?? []).join(', ')}
onChange={(e) =>
updateField(i, {
options: e.target.value
.split(',')
.map((s) => s.trim())
.filter(Boolean),
})
}
/>
</div>
)}
</div>
))}
</div>
</div>
<SheetFooter>
<Button variant="ghost" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button
onClick={() => saveMutation.mutate()}
disabled={
saveMutation.isPending ||
!name.trim() ||
fields.some((f) => !f.key.trim() || !f.label.trim())
}
>
{saveMutation.isPending ? 'Saving…' : 'Save template'}
</Button>
</SheetFooter>
</SheetContent>
</Sheet>
);
}

View File

@@ -0,0 +1,123 @@
'use client';
import { useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { format } from 'date-fns';
import { Pencil, Plus, Trash2 } from 'lucide-react';
import { toast } from 'sonner';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { ConfirmationDialog } from '@/components/shared/confirmation-dialog';
import { PageHeader } from '@/components/shared/page-header';
import { apiFetch } from '@/lib/api/client';
import type { FormField } from '@/lib/validators/form-templates';
import { FormTemplateForm } from './form-template-form';
export interface FormTemplate {
id: string;
name: string;
description: string | null;
fields: FormField[];
isActive: boolean;
updatedAt: string;
}
export function FormTemplateList() {
const qc = useQueryClient();
const [formOpen, setFormOpen] = useState(false);
const [editing, setEditing] = useState<FormTemplate | null>(null);
const { data: templates = [], isLoading } = useQuery<FormTemplate[]>({
queryKey: ['admin', 'form-templates'],
queryFn: () =>
apiFetch<{ data: FormTemplate[] }>('/api/v1/admin/form-templates').then((r) => r.data),
});
const deleteMutation = useMutation({
mutationFn: (id: string) =>
apiFetch(`/api/v1/admin/form-templates/${id}`, { method: 'DELETE' }),
onSuccess: () => {
toast.success('Template deleted');
qc.invalidateQueries({ queryKey: ['admin', 'form-templates'] });
},
onError: (err) => toast.error(err instanceof Error ? err.message : 'Delete failed'),
});
return (
<div>
<PageHeader
title="Form Templates"
description="Public intake forms for clients (residential inquiries, EOI supplements, etc.)"
actions={
<Button
onClick={() => {
setEditing(null);
setFormOpen(true);
}}
>
<Plus className="h-4 w-4 mr-1.5" />
New template
</Button>
}
/>
{isLoading ? (
<p className="text-sm text-muted-foreground">Loading</p>
) : templates.length === 0 ? (
<div className="rounded-lg border border-dashed p-8 text-center text-muted-foreground">
<p className="text-sm">No form templates yet.</p>
</div>
) : (
<div className="rounded-lg border divide-y">
{templates.map((t) => (
<div key={t.id} className="flex items-center gap-3 p-3">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="text-sm font-medium">{t.name}</span>
{!t.isActive && (
<Badge variant="outline" className="text-xs">
Inactive
</Badge>
)}
</div>
<div className="text-xs text-muted-foreground">
{t.fields.length} field{t.fields.length === 1 ? '' : 's'} · updated{' '}
{format(new Date(t.updatedAt), 'MMM d, yyyy')}
</div>
</div>
<Button
variant="ghost"
size="icon"
onClick={() => {
setEditing(t);
setFormOpen(true);
}}
>
<Pencil className="h-4 w-4" />
</Button>
<ConfirmationDialog
trigger={
<Button variant="ghost" size="icon" className="text-destructive">
<Trash2 className="h-4 w-4" />
</Button>
}
title="Delete form template"
description={`Delete "${t.name}"? Existing submissions will be preserved.`}
confirmLabel="Delete"
onConfirm={() => deleteMutation.mutate(t.id)}
/>
</div>
))}
</div>
)}
<FormTemplateForm
open={formOpen}
onOpenChange={setFormOpen}
template={editing}
onSaved={() => qc.invalidateQueries({ queryKey: ['admin', 'form-templates'] })}
/>
</div>
);
}

View File

@@ -77,6 +77,14 @@ const DEFAULT_PERMISSIONS: Record<string, Record<string, boolean>> = {
manage_tags: false,
system_backup: false,
},
residential_clients: { view: false, create: false, edit: false, delete: false },
residential_interests: {
view: false,
create: false,
edit: false,
delete: false,
change_stage: false,
},
};
const GROUP_LABELS: Record<string, string> = {

View File

@@ -30,6 +30,14 @@ const KNOWN_SETTINGS: Array<{
type: 'boolean' | 'number' | 'json' | 'string';
defaultValue: unknown;
}> = [
{
key: 'client_portal_enabled',
label: 'Client Portal',
description:
'Allow clients of this port to sign in and manage their account through the client portal.',
type: 'boolean',
defaultValue: true,
},
{
key: 'ai_interest_scoring',
label: 'AI Interest Scoring',
@@ -89,6 +97,14 @@ const KNOWN_SETTINGS: Array<{
type: 'json',
defaultValue: [],
},
{
key: 'residential_notification_recipients',
label: 'Residential Notification Recipients',
description:
'Email addresses (JSON array) that receive sales alerts for new residential inquiries. Falls back to Inquiry Contact Email when empty.',
type: 'json',
defaultValue: [],
},
];
export function SettingsManager() {

View File

@@ -30,6 +30,7 @@ interface UserFormProps {
phone: string | null;
isActive: boolean;
role: { id: string; name: string };
residentialAccess?: boolean;
} | null;
onSuccess: () => void;
}
@@ -43,6 +44,7 @@ export function UserForm({ open, onOpenChange, user, onSuccess }: UserFormProps)
const [phone, setPhone] = useState('');
const [roleId, setRoleId] = useState('');
const [isActive, setIsActive] = useState(true);
const [residentialAccess, setResidentialAccess] = useState(false);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
@@ -63,6 +65,7 @@ export function UserForm({ open, onOpenChange, user, onSuccess }: UserFormProps)
setPhone(user.phone ?? '');
setRoleId(user.role.id);
setIsActive(user.isActive);
setResidentialAccess(user.residentialAccess ?? false);
setPassword('');
} else {
setName('');
@@ -71,6 +74,7 @@ export function UserForm({ open, onOpenChange, user, onSuccess }: UserFormProps)
setPhone('');
setRoleId('');
setIsActive(true);
setResidentialAccess(false);
setPassword('');
}
setError(null);
@@ -91,6 +95,7 @@ export function UserForm({ open, onOpenChange, user, onSuccess }: UserFormProps)
phone: phone || null,
roleId,
isActive,
residentialAccess,
},
});
} else {
@@ -103,6 +108,7 @@ export function UserForm({ open, onOpenChange, user, onSuccess }: UserFormProps)
displayName,
phone: phone || undefined,
roleId,
residentialAccess,
},
});
}
@@ -190,6 +196,21 @@ export function UserForm({ open, onOpenChange, user, onSuccess }: UserFormProps)
</Select>
</div>
<div className="flex items-center justify-between rounded-lg border p-3">
<div>
<Label htmlFor="user-residential">Residential access</Label>
<p className="text-xs text-muted-foreground">
Grant this user access to residential clients and interests in addition to their
primary role.
</p>
</div>
<Switch
id="user-residential"
checked={residentialAccess}
onCheckedChange={setResidentialAccess}
/>
</div>
{isEdit && (
<div className="flex items-center justify-between rounded-lg border p-3">
<div>

View File

@@ -38,7 +38,7 @@ export function BerthStatusSuggestionDialog({
mutationFn: () =>
apiFetch(`/api/v1/berths/${berthId}/status`, {
method: 'PATCH',
body: JSON.stringify({ status: suggestedStatus, reason }),
body: { status: suggestedStatus, reason },
}),
onSuccess: () => {
onApplied();
@@ -66,21 +66,14 @@ export function BerthStatusSuggestionDialog({
</Badge>
</div>
{reason && (
<p className="text-sm text-muted-foreground text-center px-4">{reason}</p>
)}
{reason && <p className="text-sm text-muted-foreground text-center px-4">{reason}</p>}
<DialogFooter className="gap-2">
<Button variant="outline" onClick={() => onOpenChange(false)}>
Dismiss
</Button>
<Button
onClick={() => applyMutation.mutate()}
disabled={applyMutation.isPending}
>
{applyMutation.isPending && (
<Loader2 className="mr-1.5 h-4 w-4 animate-spin" />
)}
<Button onClick={() => applyMutation.mutate()} disabled={applyMutation.isPending}>
{applyMutation.isPending && <Loader2 className="mr-1.5 h-4 w-4 animate-spin" />}
Apply Change
</Button>
</DialogFooter>

View File

@@ -10,11 +10,7 @@ import {
useSensor,
useSensors,
} from '@dnd-kit/core';
import {
SortableContext,
verticalListSortingStrategy,
useSortable,
} from '@dnd-kit/sortable';
import { SortableContext, verticalListSortingStrategy, useSortable } from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import { GripVertical, Plus, Loader2, Trash2 } from 'lucide-react';
@@ -81,9 +77,7 @@ function SortableEntry({
<div className="flex-1 min-w-0">
<p className="text-sm truncate">{entry.clientId}</p>
{entry.notes && (
<p className="text-xs text-muted-foreground truncate">{entry.notes}</p>
)}
{entry.notes && <p className="text-xs text-muted-foreground truncate">{entry.notes}</p>}
</div>
<Badge variant={entry.priority === 'high' ? 'destructive' : 'secondary'}>
@@ -118,7 +112,7 @@ export function WaitingListManager({ berthId }: WaitingListManagerProps) {
mutationFn: (body: { entryId: string; newPosition: number }) =>
apiFetch(`/api/v1/berths/${berthId}/waiting-list`, {
method: 'PATCH',
body: JSON.stringify(body),
body,
}),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['berth-waiting-list', berthId] });
@@ -129,7 +123,7 @@ export function WaitingListManager({ berthId }: WaitingListManagerProps) {
mutationFn: (entries: WaitingListEntry[]) =>
apiFetch(`/api/v1/berths/${berthId}/waiting-list`, {
method: 'PUT',
body: JSON.stringify({ entries }),
body: { entries },
}),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['berth-waiting-list', berthId] });
@@ -207,10 +201,7 @@ export function WaitingListManager({ berthId }: WaitingListManagerProps) {
value={newClientId}
onChange={(e) => setNewClientId(e.target.value)}
/>
<Select
value={newPriority}
onValueChange={(v) => setNewPriority(v as 'normal' | 'high')}
>
<Select value={newPriority} onValueChange={(v) => setNewPriority(v as 'normal' | 'high')}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
@@ -226,9 +217,7 @@ export function WaitingListManager({ berthId }: WaitingListManagerProps) {
/>
<div className="flex gap-2">
<Button size="sm" onClick={handleAdd} disabled={addMutation.isPending}>
{addMutation.isPending && (
<Loader2 className="mr-1.5 h-4 w-4 animate-spin" />
)}
{addMutation.isPending && <Loader2 className="mr-1.5 h-4 w-4 animate-spin" />}
Add to List
</Button>
<Button size="sm" variant="ghost" onClick={() => setShowAddForm(false)}>
@@ -243,22 +232,11 @@ export function WaitingListManager({ berthId }: WaitingListManagerProps) {
No entries on waiting list.
</p>
) : (
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={handleDragEnd}
>
<SortableContext
items={entries.map((e) => e.id)}
strategy={verticalListSortingStrategy}
>
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
<SortableContext items={entries.map((e) => e.id)} strategy={verticalListSortingStrategy}>
<div className="space-y-2">
{entries.map((entry) => (
<SortableEntry
key={entry.id}
entry={entry}
onRemove={handleRemove}
/>
<SortableEntry key={entry.id} entry={entry} onRemove={handleRemove} />
))}
</div>
</SortableContext>

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>
);
}

View File

@@ -1,9 +1,31 @@
'use client';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import type { DetailTab } from '@/components/shared/detail-layout';
import { EmptyState } from '@/components/shared/empty-state';
import { InlineEditableField } from '@/components/shared/inline-editable-field';
import { InlineTagEditor } from '@/components/shared/inline-tag-editor';
import { NotesList } from '@/components/shared/notes-list';
import { CompanyMembersTab } from '@/components/companies/company-members-tab';
import { CompanyOwnedYachtsTab } from '@/components/companies/company-owned-yachts-tab';
import { apiFetch } from '@/lib/api/client';
type CompanyPatchField =
| 'name'
| 'legalName'
| 'taxId'
| 'registrationNumber'
| 'incorporationCountry'
| 'incorporationDate'
| 'status'
| 'billingEmail'
| 'notes';
const STATUS_OPTIONS = [
{ value: 'active', label: 'Active' },
{ value: 'dissolved', label: 'Dissolved' },
];
interface CompanyTabsCompany {
id: string;
@@ -16,6 +38,7 @@ interface CompanyTabsCompany {
status: string;
billingEmail: string | null;
notes: string | null;
tags?: Array<{ id: string; name: string; color: string }>;
}
interface CompanyTabsOptions {
@@ -25,30 +48,34 @@ interface CompanyTabsOptions {
company: CompanyTabsCompany;
}
const STATUS_LABELS: Record<string, string> = {
active: 'Active',
dissolved: 'Dissolved',
};
function useCompanyPatch(companyId: string) {
const qc = useQueryClient();
return useMutation({
mutationFn: async (patch: Partial<Record<CompanyPatchField, string | null>>) =>
apiFetch(`/api/v1/companies/${companyId}`, {
method: 'PATCH',
body: patch,
}),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['companies', companyId] });
},
});
}
function InfoRow({ label, value }: { label: string; value?: string | number | null }) {
if (value === null || value === undefined || value === '') return null;
function EditableRow({ label, children }: { label: string; children: React.ReactNode }) {
return (
<div className="flex gap-2 py-1.5 border-b last:border-0">
<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="text-sm">{value}</dd>
<dd className="flex-1 min-w-0">{children}</dd>
</div>
);
}
function formatDate(value: string | null): string | null {
if (!value) return null;
const date = new Date(value);
if (Number.isNaN(date.getTime())) return value;
return date.toLocaleDateString();
}
function OverviewTab({ company }: { company: CompanyTabsCompany }) {
const incorporationDate = formatDate(company.incorporationDate);
function OverviewTab({ companyId, company }: { companyId: string; company: CompanyTabsCompany }) {
const mutation = useCompanyPatch(companyId);
const save = (field: CompanyPatchField) => async (next: string | null) => {
await mutation.mutateAsync({ [field]: next });
};
return (
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
@@ -56,47 +83,82 @@ function OverviewTab({ company }: { company: CompanyTabsCompany }) {
<div className="space-y-1">
<h3 className="text-sm font-medium mb-2">Identity</h3>
<dl>
<InfoRow label="Name" value={company.name} />
<InfoRow label="Legal Name" value={company.legalName} />
<InfoRow label="Status" value={STATUS_LABELS[company.status] ?? company.status} />
<EditableRow label="Name">
<InlineEditableField value={company.name} onSave={save('name')} />
</EditableRow>
<EditableRow label="Legal Name">
<InlineEditableField value={company.legalName} onSave={save('legalName')} />
</EditableRow>
<EditableRow label="Status">
<InlineEditableField
variant="select"
options={STATUS_OPTIONS}
value={company.status}
onSave={save('status')}
/>
</EditableRow>
</dl>
</div>
{/* Registration */}
{(company.taxId ||
company.registrationNumber ||
company.incorporationCountry ||
incorporationDate) && (
<div className="space-y-1">
<h3 className="text-sm font-medium mb-2">Registration</h3>
<dl>
<InfoRow label="Tax ID" value={company.taxId} />
<InfoRow label="Registration Number" value={company.registrationNumber} />
<InfoRow label="Incorporation Country" value={company.incorporationCountry} />
<InfoRow label="Incorporation Date" value={incorporationDate} />
</dl>
</div>
)}
<div className="space-y-1">
<h3 className="text-sm font-medium mb-2">Registration</h3>
<dl>
<EditableRow label="Tax ID">
<InlineEditableField value={company.taxId} onSave={save('taxId')} />
</EditableRow>
<EditableRow label="Registration Number">
<InlineEditableField
value={company.registrationNumber}
onSave={save('registrationNumber')}
/>
</EditableRow>
<EditableRow label="Incorporation Country">
<InlineEditableField
value={company.incorporationCountry}
onSave={save('incorporationCountry')}
/>
</EditableRow>
<EditableRow label="Incorporation Date">
<InlineEditableField
value={company.incorporationDate}
placeholder="YYYY-MM-DD"
onSave={save('incorporationDate')}
/>
</EditableRow>
</dl>
</div>
{/* Contact */}
{company.billingEmail && (
<div className="space-y-1">
<h3 className="text-sm font-medium mb-2">Contact</h3>
<dl>
<InfoRow label="Billing Email" value={company.billingEmail} />
</dl>
</div>
)}
<div className="space-y-1">
<h3 className="text-sm font-medium mb-2">Contact</h3>
<dl>
<EditableRow label="Billing Email">
<InlineEditableField value={company.billingEmail} onSave={save('billingEmail')} />
</EditableRow>
</dl>
</div>
{/* Notes */}
{company.notes && (
<div className="space-y-1 md:col-span-2">
<h3 className="text-sm font-medium mb-2">Notes</h3>
<p className="text-sm whitespace-pre-wrap rounded-md border bg-muted/30 p-3">
{company.notes}
</p>
</div>
)}
<div className="space-y-1 md:col-span-2">
<h3 className="text-sm font-medium mb-2">Notes</h3>
<InlineEditableField
variant="textarea"
value={company.notes}
onSave={save('notes')}
emptyText="No notes — click to add"
/>
</div>
{/* Tags */}
<div className="space-y-1 md:col-span-2">
<h3 className="text-sm font-medium mb-2">Tags</h3>
<InlineTagEditor
endpoint={`/api/v1/companies/${companyId}/tags`}
currentTags={company.tags ?? []}
invalidateKey={['companies', companyId]}
/>
</div>
</div>
);
}
@@ -104,17 +166,14 @@ function OverviewTab({ company }: { company: CompanyTabsCompany }) {
export function getCompanyTabs({
companyId,
portSlug,
// currentUserId reserved for when NotesList supports entityType='companies'.
currentUserId: _currentUserId,
currentUserId,
company,
}: CompanyTabsOptions): DetailTab[] {
void _currentUserId;
return [
{
id: 'overview',
label: 'Overview',
content: <OverviewTab company={company} />,
content: <OverviewTab companyId={companyId} company={company} />,
},
{
id: 'members',
@@ -129,7 +188,6 @@ export function getCompanyTabs({
{
id: 'addresses',
label: 'Addresses',
// TODO: wire to future company-addresses endpoint (see company-addresses schema).
content: (
<EmptyState
title="Addresses"
@@ -145,22 +203,8 @@ export function getCompanyTabs({
{
id: 'notes',
label: 'Notes',
// TODO: NotesList currently supports entityType 'clients' | 'interests'.
// Extend NotesList (or swap to a company-notes endpoint) in a follow-up.
content: (
<EmptyState
title="Notes"
description="Company notes coming soon — the notes endpoint is pending wiring."
/>
),
},
{
id: 'tags',
label: 'Tags',
// TODO: replace with an inline tag editor once one exists; company tags
// can be edited via the Edit form in the meantime.
content: (
<EmptyState title="Tags" description="Manage tags from the Edit company form for now." />
<NotesList entityType="companies" entityId={companyId} currentUserId={currentUserId} />
),
},
];

View File

@@ -0,0 +1,153 @@
'use client';
import { useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { toast } from 'sonner';
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 {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { apiFetch } from '@/lib/api/client';
interface Account {
id: string;
emailAddress: string;
isActive: boolean;
}
interface ComposeDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
defaultTo?: string;
defaultSubject?: string;
}
export function ComposeDialog({
open,
onOpenChange,
defaultTo = '',
defaultSubject = '',
}: ComposeDialogProps) {
const qc = useQueryClient();
const [accountId, setAccountId] = useState('');
const [to, setTo] = useState(defaultTo);
const [cc, setCc] = useState('');
const [subject, setSubject] = useState(defaultSubject);
const [body, setBody] = useState('');
const { data: accounts = [] } = useQuery<Account[]>({
queryKey: ['email', 'accounts'],
queryFn: () => apiFetch<{ data: Account[] }>('/api/v1/email/accounts').then((r) => r.data),
});
const activeAccounts = accounts.filter((a) => a.isActive);
const sendMutation = useMutation({
mutationFn: () =>
apiFetch('/api/v1/email/compose', {
method: 'POST',
body: {
accountId,
to: to
.split(',')
.map((s) => s.trim())
.filter(Boolean),
cc: cc
? cc
.split(',')
.map((s) => s.trim())
.filter(Boolean)
: undefined,
subject,
bodyHtml: body.replace(/\n/g, '<br>'),
},
}),
onSuccess: () => {
toast.success('Email sent');
qc.invalidateQueries({ queryKey: ['email', 'threads'] });
onOpenChange(false);
setTo('');
setCc('');
setSubject('');
setBody('');
},
onError: (err) => toast.error(err instanceof Error ? err.message : 'Send failed'),
});
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle>Compose email</DialogTitle>
</DialogHeader>
<div className="space-y-3">
<div className="space-y-1">
<Label>From</Label>
<Select value={accountId} onValueChange={setAccountId}>
<SelectTrigger>
<SelectValue placeholder="Choose account" />
</SelectTrigger>
<SelectContent>
{activeAccounts.length === 0 ? (
<div className="px-2 py-1.5 text-sm text-muted-foreground">
No active accounts
</div>
) : (
activeAccounts.map((a) => (
<SelectItem key={a.id} value={a.id}>
{a.emailAddress}
</SelectItem>
))
)}
</SelectContent>
</Select>
</div>
<div className="space-y-1">
<Label>To (comma-separated)</Label>
<Input value={to} onChange={(e) => setTo(e.target.value)} />
</div>
<div className="space-y-1">
<Label>CC</Label>
<Input value={cc} onChange={(e) => setCc(e.target.value)} />
</div>
<div className="space-y-1">
<Label>Subject</Label>
<Input value={subject} onChange={(e) => setSubject(e.target.value)} />
</div>
<div className="space-y-1">
<Label>Message</Label>
<Textarea rows={10} value={body} onChange={(e) => setBody(e.target.value)} />
</div>
</div>
<DialogFooter>
<Button variant="ghost" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button
onClick={() => sendMutation.mutate()}
disabled={
sendMutation.isPending || !accountId || !to.trim() || !subject.trim() || !body.trim()
}
>
{sendMutation.isPending ? 'Sending…' : 'Send'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,264 @@
'use client';
import { useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { Mail, Plus, Trash2, RefreshCw } 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 { Switch } from '@/components/ui/switch';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetFooter } from '@/components/ui/sheet';
import { ConfirmationDialog } from '@/components/shared/confirmation-dialog';
import { apiFetch } from '@/lib/api/client';
interface Account {
id: string;
provider: 'google' | 'outlook' | 'custom';
emailAddress: string;
smtpHost: string;
smtpPort: number;
imapHost: string;
imapPort: number;
username: string;
isActive: boolean;
lastSyncAt: string | null;
}
const PROVIDER_DEFAULTS: Record<
string,
{ smtpHost: string; smtpPort: number; imapHost: string; imapPort: number }
> = {
google: { smtpHost: 'smtp.gmail.com', smtpPort: 587, imapHost: 'imap.gmail.com', imapPort: 993 },
outlook: {
smtpHost: 'smtp.office365.com',
smtpPort: 587,
imapHost: 'outlook.office365.com',
imapPort: 993,
},
custom: { smtpHost: '', smtpPort: 587, imapHost: '', imapPort: 993 },
};
export function EmailAccountsList() {
const qc = useQueryClient();
const [sheetOpen, setSheetOpen] = useState(false);
const [form, setForm] = useState({
provider: 'google' as 'google' | 'outlook' | 'custom',
emailAddress: '',
smtpHost: PROVIDER_DEFAULTS.google!.smtpHost,
smtpPort: PROVIDER_DEFAULTS.google!.smtpPort,
imapHost: PROVIDER_DEFAULTS.google!.imapHost,
imapPort: PROVIDER_DEFAULTS.google!.imapPort,
username: '',
password: '',
});
const { data: accounts = [], isLoading } = useQuery<Account[]>({
queryKey: ['email', 'accounts'],
queryFn: () => apiFetch<{ data: Account[] }>('/api/v1/email/accounts').then((r) => r.data),
});
const createMutation = useMutation({
mutationFn: () => apiFetch('/api/v1/email/accounts', { method: 'POST', body: form }),
onSuccess: () => {
toast.success('Account connected');
setSheetOpen(false);
qc.invalidateQueries({ queryKey: ['email', 'accounts'] });
},
onError: (err) => toast.error(err instanceof Error ? err.message : 'Failed to connect account'),
});
const toggleMutation = useMutation({
mutationFn: ({ id, isActive }: { id: string; isActive: boolean }) =>
apiFetch(`/api/v1/email/accounts/${id}`, {
method: 'PATCH',
body: { isActive },
}),
onSuccess: () => qc.invalidateQueries({ queryKey: ['email', 'accounts'] }),
});
const deleteMutation = useMutation({
mutationFn: (id: string) => apiFetch(`/api/v1/email/accounts/${id}`, { method: 'DELETE' }),
onSuccess: () => {
toast.success('Account removed');
qc.invalidateQueries({ queryKey: ['email', 'accounts'] });
},
});
const syncMutation = useMutation({
mutationFn: (id: string) => apiFetch(`/api/v1/email/accounts/${id}/sync`, { method: 'POST' }),
onSuccess: () => {
toast.success('Sync started');
qc.invalidateQueries({ queryKey: ['email', 'accounts'] });
},
});
function setProvider(provider: 'google' | 'outlook' | 'custom') {
const defaults = PROVIDER_DEFAULTS[provider]!;
setForm({ ...form, provider, ...defaults });
}
return (
<div className="space-y-4">
<div className="flex justify-between items-center">
<div>
<h2 className="text-lg font-semibold">Connected Accounts</h2>
<p className="text-sm text-muted-foreground">
IMAP/SMTP accounts used for sending and receiving client emails.
</p>
</div>
<Button onClick={() => setSheetOpen(true)}>
<Plus className="h-4 w-4 mr-1.5" />
Add account
</Button>
</div>
{isLoading ? (
<p className="text-sm text-muted-foreground">Loading</p>
) : accounts.length === 0 ? (
<div className="rounded-lg border border-dashed p-8 text-center text-muted-foreground">
<Mail className="mx-auto h-6 w-6 mb-2" />
<p className="text-sm">No email accounts connected.</p>
</div>
) : (
<div className="rounded-lg border divide-y">
{accounts.map((a) => (
<div key={a.id} className="flex items-center gap-3 p-3">
<Mail className="h-5 w-5 text-muted-foreground" />
<div className="flex-1 min-w-0">
<div className="text-sm font-medium truncate">{a.emailAddress}</div>
<div className="text-xs text-muted-foreground">
{a.provider} · {a.imapHost}
{a.lastSyncAt && ` · last sync ${new Date(a.lastSyncAt).toLocaleString()}`}
</div>
</div>
<Switch
checked={a.isActive}
onCheckedChange={(v) => toggleMutation.mutate({ id: a.id, isActive: v })}
/>
<Button
variant="ghost"
size="icon"
onClick={() => syncMutation.mutate(a.id)}
disabled={syncMutation.isPending}
title="Sync now"
>
<RefreshCw className="h-4 w-4" />
</Button>
<ConfirmationDialog
trigger={
<Button variant="ghost" size="icon" className="text-destructive">
<Trash2 className="h-4 w-4" />
</Button>
}
title="Remove account"
description={`Disconnect ${a.emailAddress}?`}
confirmLabel="Remove"
onConfirm={() => deleteMutation.mutate(a.id)}
/>
</div>
))}
</div>
)}
<Sheet open={sheetOpen} onOpenChange={setSheetOpen}>
<SheetContent className="overflow-y-auto">
<SheetHeader>
<SheetTitle>Connect email account</SheetTitle>
</SheetHeader>
<div className="space-y-3 py-4">
<div className="space-y-1">
<Label>Provider</Label>
<Select
value={form.provider}
onValueChange={(v) => setProvider(v as 'google' | 'outlook' | 'custom')}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="google">Google</SelectItem>
<SelectItem value="outlook">Outlook / Office 365</SelectItem>
<SelectItem value="custom">Custom IMAP/SMTP</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-1">
<Label>Email address</Label>
<Input
type="email"
value={form.emailAddress}
onChange={(e) =>
setForm({ ...form, emailAddress: e.target.value, username: e.target.value })
}
/>
</div>
<div className="space-y-1">
<Label>Username</Label>
<Input
value={form.username}
onChange={(e) => setForm({ ...form, username: e.target.value })}
/>
</div>
<div className="space-y-1">
<Label>Password / App password</Label>
<Input
type="password"
value={form.password}
onChange={(e) => setForm({ ...form, password: e.target.value })}
/>
</div>
<div className="grid grid-cols-2 gap-2">
<div className="space-y-1">
<Label>SMTP host</Label>
<Input
value={form.smtpHost}
onChange={(e) => setForm({ ...form, smtpHost: e.target.value })}
/>
</div>
<div className="space-y-1">
<Label>SMTP port</Label>
<Input
type="number"
value={form.smtpPort}
onChange={(e) => setForm({ ...form, smtpPort: Number(e.target.value) })}
/>
</div>
<div className="space-y-1">
<Label>IMAP host</Label>
<Input
value={form.imapHost}
onChange={(e) => setForm({ ...form, imapHost: e.target.value })}
/>
</div>
<div className="space-y-1">
<Label>IMAP port</Label>
<Input
type="number"
value={form.imapPort}
onChange={(e) => setForm({ ...form, imapPort: Number(e.target.value) })}
/>
</div>
</div>
</div>
<SheetFooter>
<Button variant="ghost" onClick={() => setSheetOpen(false)}>
Cancel
</Button>
<Button onClick={() => createMutation.mutate()} disabled={createMutation.isPending}>
{createMutation.isPending ? 'Connecting…' : 'Connect'}
</Button>
</SheetFooter>
</SheetContent>
</Sheet>
</div>
);
}

View File

@@ -0,0 +1,70 @@
'use client';
import { useQuery } from '@tanstack/react-query';
import { formatDistanceToNow } from 'date-fns';
import { Mail } from 'lucide-react';
import { apiFetch } from '@/lib/api/client';
interface Thread {
id: string;
subject: string;
snippet: string | null;
lastMessageAt: string;
participants: string[];
unreadCount: number;
}
interface ThreadsResponse {
data: Thread[];
total: number;
}
export function EmailThreadsList() {
const { data, isLoading } = useQuery<ThreadsResponse>({
queryKey: ['email', 'threads'],
queryFn: () => apiFetch<ThreadsResponse>('/api/v1/email/threads'),
});
if (isLoading) {
return <p className="text-sm text-muted-foreground">Loading threads</p>;
}
const threads = data?.data ?? [];
if (threads.length === 0) {
return (
<div className="rounded-lg border border-dashed p-8 text-center text-muted-foreground">
<Mail className="mx-auto h-6 w-6 mb-2" />
<p className="text-sm">No email threads yet.</p>
<p className="text-xs">
Connect an account and trigger a sync to see incoming threads here.
</p>
</div>
);
}
return (
<div className="rounded-lg border divide-y">
{threads.map((t) => (
<div key={t.id} className="p-3 hover:bg-muted/40">
<div className="flex items-center justify-between gap-2">
<div className="text-sm font-medium truncate">{t.subject || '(no subject)'}</div>
<div className="text-xs text-muted-foreground shrink-0">
{formatDistanceToNow(new Date(t.lastMessageAt), { addSuffix: true })}
</div>
</div>
<div className="text-xs text-muted-foreground truncate">{t.participants.join(', ')}</div>
{t.snippet && (
<div className="text-xs text-muted-foreground mt-1 line-clamp-1">{t.snippet}</div>
)}
{t.unreadCount > 0 && (
<span className="inline-block mt-1 rounded-full bg-primary/10 px-2 py-0.5 text-xs text-primary">
{t.unreadCount} unread
</span>
)}
</div>
))}
</div>
);
}

View File

@@ -1,16 +1,30 @@
'use client';
import { format } from 'date-fns';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import type { DetailTab } from '@/components/shared/detail-layout';
import { NotesList } from '@/components/shared/notes-list';
import { InlineEditableField } from '@/components/shared/inline-editable-field';
import { InlineTagEditor } from '@/components/shared/inline-tag-editor';
import { RecommendationList } from '@/components/interests/recommendation-list';
import { InterestTimeline } from '@/components/interests/interest-timeline';
import { LEAD_CATEGORIES } from '@/lib/constants';
import { apiFetch } from '@/lib/api/client';
type InterestPatchField = 'leadCategory' | 'source' | 'notes';
const LEAD_CATEGORY_OPTIONS = LEAD_CATEGORIES.map((c) => ({
value: c,
label: c.replace(/_/g, ' ').replace(/\b\w/g, (m) => m.toUpperCase()),
}));
interface InterestTabsOptions {
interestId: string;
currentUserId?: string;
interest: {
leadCategory: string | null;
source: string | null;
eoiStatus: string | null;
contractStatus: string | null;
depositStatus: string | null;
@@ -26,9 +40,33 @@ interface InterestTabsOptions {
reminderDays: number | null;
reminderLastFired: string | null;
notes: string | null;
tags?: Array<{ id: string; name: string; color: string }>;
};
}
function useInterestPatch(interestId: string) {
const qc = useQueryClient();
return useMutation({
mutationFn: async (patch: Partial<Record<InterestPatchField, string | null>>) =>
apiFetch(`/api/v1/interests/${interestId}`, {
method: 'PATCH',
body: patch,
}),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['interests', interestId] });
},
});
}
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-44 shrink-0 text-sm text-muted-foreground">{label}</dt>
<dd className="flex-1 min-w-0">{children}</dd>
</div>
);
}
function InfoRow({ label, value }: { label: string; value?: string | null }) {
if (!value) return null;
return (
@@ -44,10 +82,39 @@ function formatDate(date: string | null) {
return format(new Date(date), 'MMM d, yyyy');
}
function OverviewTab({ interest }: { interest: InterestTabsOptions['interest'] }) {
function OverviewTab({
interestId,
interest,
}: {
interestId: string;
interest: InterestTabsOptions['interest'];
}) {
const mutation = useInterestPatch(interestId);
const save = (field: InterestPatchField) => async (next: string | null) => {
await mutation.mutateAsync({ [field]: next });
};
return (
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{/* EOI & Contract Status */}
{/* Lead & Source (editable) */}
<div className="space-y-1">
<h3 className="text-sm font-medium mb-2">Lead</h3>
<dl>
<EditableRow label="Lead Category">
<InlineEditableField
variant="select"
options={LEAD_CATEGORY_OPTIONS}
value={interest.leadCategory}
onSave={save('leadCategory')}
/>
</EditableRow>
<EditableRow label="Source">
<InlineEditableField value={interest.source} onSave={save('source')} />
</EditableRow>
</dl>
</div>
{/* EOI & Contract Status (read-only — derived) */}
<div className="space-y-1">
<h3 className="text-sm font-medium mb-2">Status</h3>
<dl>
@@ -58,8 +125,8 @@ function OverviewTab({ interest }: { interest: InterestTabsOptions['interest'] }
</dl>
</div>
{/* Key Dates */}
<div className="space-y-1">
{/* Key Dates (read-only — set by workflow events) */}
<div className="space-y-1 md:col-span-2">
<h3 className="text-sm font-medium mb-2">Key Dates</h3>
<dl>
<InfoRow label="First Contact" value={formatDate(interest.dateFirstContact)} />
@@ -81,23 +148,31 @@ function OverviewTab({ interest }: { interest: InterestTabsOptions['interest'] }
label="Reminder Days"
value={interest.reminderDays ? `${interest.reminderDays} days` : null}
/>
<InfoRow
label="Last Fired"
value={formatDate(interest.reminderLastFired)}
/>
<InfoRow label="Last Fired" value={formatDate(interest.reminderLastFired)} />
</dl>
</div>
)}
{/* Notes */}
{interest.notes && (
<div className="space-y-1 md:col-span-2">
<h3 className="text-sm font-medium mb-2">Notes</h3>
<p className="text-sm text-muted-foreground whitespace-pre-wrap">
{interest.notes}
</p>
</div>
)}
{/* Notes (editable, multiline) */}
<div className="space-y-1 md:col-span-2">
<h3 className="text-sm font-medium mb-2">Notes</h3>
<InlineEditableField
variant="textarea"
value={interest.notes}
onSave={save('notes')}
emptyText="No notes — click to add"
/>
</div>
{/* Tags */}
<div className="space-y-1 md:col-span-2">
<h3 className="text-sm font-medium mb-2">Tags</h3>
<InlineTagEditor
endpoint={`/api/v1/interests/${interestId}/tags`}
currentTags={interest.tags ?? []}
invalidateKey={['interests', interestId]}
/>
</div>
</div>
);
}
@@ -111,17 +186,13 @@ export function getInterestTabs({
{
id: 'overview',
label: 'Overview',
content: <OverviewTab interest={interest} />,
content: <OverviewTab interestId={interestId} interest={interest} />,
},
{
id: 'notes',
label: 'Notes',
content: (
<NotesList
entityType="interests"
entityId={interestId}
currentUserId={currentUserId}
/>
<NotesList entityType="interests" entityId={interestId} currentUserId={currentUserId} />
),
},
{

View File

@@ -74,7 +74,7 @@ export function PipelineBoard() {
let newStage = over.id as string;
// If dropped on a card (not a stage), find which stage that card belongs to
if (!PIPELINE_STAGES.includes(newStage as typeof PIPELINE_STAGES[number])) {
if (!PIPELINE_STAGES.includes(newStage as (typeof PIPELINE_STAGES)[number])) {
const targetInterest = interests.find((i) => i.id === newStage);
if (!targetInterest) return;
newStage = targetInterest.pipelineStage;
@@ -85,23 +85,18 @@ export function PipelineBoard() {
if (!currentInterest || currentInterest.pipelineStage === newStage) return;
// Optimistic update
queryClient.setQueryData<{ data: InterestRow[] }>(
['interests-board', portSlug],
(old) => {
if (!old) return old;
return {
...old,
data: old.data.map((i) =>
i.id === interestId ? { ...i, pipelineStage: newStage } : i,
),
};
},
);
queryClient.setQueryData<{ data: InterestRow[] }>(['interests-board', portSlug], (old) => {
if (!old) return old;
return {
...old,
data: old.data.map((i) => (i.id === interestId ? { ...i, pipelineStage: newStage } : i)),
};
});
try {
await apiFetch(`/api/v1/interests/${interestId}/stage`, {
method: 'PATCH',
body: JSON.stringify({ pipelineStage: newStage }),
body: { pipelineStage: newStage },
});
queryClient.invalidateQueries({ queryKey: ['interests'] });
} catch {

View File

@@ -41,8 +41,7 @@ export function InvoiceDetail({ invoiceId }: InvoiceDetailProps) {
});
const sendMutation = useMutation({
mutationFn: () =>
apiFetch(`/api/v1/invoices/${invoiceId}/send`, { method: 'POST' }),
mutationFn: () => apiFetch(`/api/v1/invoices/${invoiceId}/send`, { method: 'POST' }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['invoices', invoiceId] });
queryClient.invalidateQueries({ queryKey: ['invoices'] });
@@ -58,7 +57,7 @@ export function InvoiceDetail({ invoiceId }: InvoiceDetailProps) {
mutationFn: (values: RecordPaymentInput) =>
apiFetch(`/api/v1/invoices/${invoiceId}/payment`, {
method: 'PATCH',
body: JSON.stringify(values),
body: values,
}),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['invoices', invoiceId] });
@@ -76,9 +75,7 @@ export function InvoiceDetail({ invoiceId }: InvoiceDetailProps) {
if (error || !data?.data) {
return (
<div className="p-6 text-center text-muted-foreground">
Failed to load invoice details.
</div>
<div className="p-6 text-center text-muted-foreground">Failed to load invoice details.</div>
);
}
@@ -230,9 +227,7 @@ export function InvoiceDetail({ invoiceId }: InvoiceDetailProps) {
<CardTitle className="text-sm font-medium">Notes</CardTitle>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground whitespace-pre-wrap">
{invoice.notes}
</p>
<p className="text-sm text-muted-foreground whitespace-pre-wrap">{invoice.notes}</p>
</CardContent>
</Card>
)}
@@ -249,9 +244,7 @@ export function InvoiceDetail({ invoiceId }: InvoiceDetailProps) {
className="flex items-center justify-between p-3 border rounded-md text-sm"
>
<div>
<p className="font-medium">
{exp.establishmentName ?? 'Unnamed Expense'}
</p>
<p className="font-medium">{exp.establishmentName ?? 'Unnamed Expense'}</p>
<p className="text-muted-foreground text-xs">
{exp.category ?? '—'} &middot; {exp.expenseDate}
</p>
@@ -271,10 +264,7 @@ export function InvoiceDetail({ invoiceId }: InvoiceDetailProps) {
{/* PDF Preview */}
<TabsContent value="pdf" className="pt-4">
<InvoicePdfPreview
invoiceId={invoiceId}
pdfFileId={invoice.pdfFileId}
/>
<InvoicePdfPreview invoiceId={invoiceId} pdfFileId={invoice.pdfFileId} />
</TabsContent>
{/* Payment */}
@@ -283,10 +273,7 @@ export function InvoiceDetail({ invoiceId }: InvoiceDetailProps) {
<Card>
<CardContent className="pt-6 space-y-3 text-sm">
<div className="flex items-center gap-2">
<Badge
variant="outline"
className="bg-green-100 text-green-700 border-green-200"
>
<Badge variant="outline" className="bg-green-100 text-green-700 border-green-200">
Paid
</Badge>
</div>
@@ -297,9 +284,7 @@ export function InvoiceDetail({ invoiceId }: InvoiceDetailProps) {
</div>
<div>
<span className="text-muted-foreground">Method</span>
<p className="mt-0.5 capitalize">
{invoice.paymentMethod ?? '—'}
</p>
<p className="mt-0.5 capitalize">{invoice.paymentMethod ?? '—'}</p>
</div>
<div>
<span className="text-muted-foreground">Reference</span>
@@ -315,18 +300,12 @@ export function InvoiceDetail({ invoiceId }: InvoiceDetailProps) {
</CardHeader>
<CardContent>
<form
onSubmit={paymentForm.handleSubmit((values) =>
paymentMutation.mutate(values),
)}
onSubmit={paymentForm.handleSubmit((values) => paymentMutation.mutate(values))}
className="space-y-4"
>
<div className="space-y-1">
<Label htmlFor="paymentDate">Payment Date</Label>
<Input
id="paymentDate"
type="date"
{...paymentForm.register('paymentDate')}
/>
<Input id="paymentDate" type="date" {...paymentForm.register('paymentDate')} />
{paymentForm.formState.errors.paymentDate && (
<p className="text-xs text-destructive">
{paymentForm.formState.errors.paymentDate.message}
@@ -349,10 +328,7 @@ export function InvoiceDetail({ invoiceId }: InvoiceDetailProps) {
{...paymentForm.register('paymentReference')}
/>
</div>
<Button
type="submit"
disabled={paymentMutation.isPending}
>
<Button type="submit" disabled={paymentMutation.isPending}>
{paymentMutation.isPending ? (
<Loader2 className="mr-1.5 h-4 w-4 animate-spin" />
) : (

View File

@@ -35,8 +35,18 @@ const SEGMENT_LABELS: Record<string, string> = {
profile: 'Profile',
};
// UUID v4-ish (or any 36-char hex+dash) — used to skip entity-id segments
// from the breadcrumbs since the page H1 already shows the entity name.
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
function isIdSegment(segment: string): boolean {
return UUID_RE.test(segment);
}
function formatSegment(segment: string): string {
return SEGMENT_LABELS[segment] ?? segment.replace(/-/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase());
return (
SEGMENT_LABELS[segment] ?? segment.replace(/-/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase())
);
}
export function Breadcrumbs() {
@@ -46,10 +56,11 @@ export function Breadcrumbs() {
// Split pathname and filter empty segments
const rawSegments = pathname.split('/').filter(Boolean);
// Remove the portSlug segment from display
const segments = currentPortSlug
? rawSegments.filter((seg) => seg !== currentPortSlug)
: rawSegments;
// Remove the portSlug segment and any UUID-ish entity-id segments — the
// page H1 already shows the entity name, no need to leak the raw id.
const segments = (
currentPortSlug ? rawSegments.filter((seg) => seg !== currentPortSlug) : rawSegments
).filter((seg) => !isIdSegment(seg));
if (segments.length === 0) {
return (

View File

@@ -17,6 +17,7 @@ import {
Bell,
Settings,
Shield,
Home,
ChevronLeft,
ChevronRight,
Menu,
@@ -38,6 +39,7 @@ import type { Role } from '@/lib/db/schema/users';
interface SidebarProps {
portRoles: (UserPortRole & { port: { id: string; slug: string; name: string }; role: Role })[];
isSuperAdmin?: boolean;
}
interface NavItem {
@@ -51,6 +53,10 @@ interface NavSection {
title: string;
items: NavItem[];
adminRequired?: boolean;
/** When true, only render if the user has marina-side access. */
marinaRequired?: boolean;
/** When true, only render if the user has residential-side access. */
residentialRequired?: boolean;
}
function buildNavSections(portSlug: string | undefined): NavSection[] {
@@ -59,6 +65,7 @@ function buildNavSections(portSlug: string | undefined): NavSection[] {
return [
{
title: 'Main',
marinaRequired: true,
items: [
{ href: `${base}/dashboard`, label: 'Dashboard', icon: LayoutDashboard },
{ href: `${base}/clients`, label: 'Clients', icon: Users },
@@ -68,8 +75,25 @@ function buildNavSections(portSlug: string | undefined): NavSection[] {
{ href: `${base}/berths`, label: 'Berths', icon: Anchor },
],
},
{
title: 'Residential',
residentialRequired: true,
items: [
{
href: `${base}/residential/clients`,
label: 'Residential Clients',
icon: Home,
},
{
href: `${base}/residential/interests`,
label: 'Residential Interests',
icon: Bookmark,
},
],
},
{
title: 'Documents',
marinaRequired: true,
items: [
{ href: `${base}/documents`, label: 'Documents', icon: FileText },
{ href: `${base}/documents/files`, label: 'Files', icon: FolderOpen },
@@ -77,6 +101,7 @@ function buildNavSections(portSlug: string | undefined): NavSection[] {
},
{
title: 'Financial',
marinaRequired: true,
items: [
{ href: `${base}/expenses`, label: 'Expenses', icon: Receipt },
{ href: `${base}/invoices`, label: 'Invoices', icon: FileText },
@@ -84,6 +109,7 @@ function buildNavSections(portSlug: string | undefined): NavSection[] {
},
{
title: 'Communication',
marinaRequired: true,
items: [
{ href: `${base}/email`, label: 'Email', icon: Mail },
{ href: `${base}/reminders`, label: 'Reminders', icon: Bell },
@@ -150,11 +176,15 @@ function SidebarContent({
portSlug,
portRoles,
hasAdminAccess,
hasMarinaAccess,
hasResidentialAccess,
}: {
collapsed: boolean;
portSlug: string | undefined;
portRoles: SidebarProps['portRoles'];
hasAdminAccess: boolean;
hasMarinaAccess: boolean;
hasResidentialAccess: boolean;
}) {
const pathname = usePathname();
const [adminExpanded, setAdminExpanded] = useState(false);
@@ -191,6 +221,8 @@ function SidebarContent({
<nav className="px-2 space-y-4">
{sections.map((section) => {
if (section.adminRequired && !hasAdminAccess) return null;
if (section.marinaRequired && !hasMarinaAccess) return null;
if (section.residentialRequired && !hasResidentialAccess) return null;
return (
<div key={section.title}>
@@ -272,16 +304,25 @@ function SidebarContent({
);
}
export function Sidebar({ portRoles }: SidebarProps) {
export function Sidebar({ portRoles, isSuperAdmin = false }: SidebarProps) {
const sidebarCollapsed = useUIStore((s) => s.sidebarCollapsed);
const toggleSidebar = useUIStore((s) => s.toggleSidebar);
const currentPortSlug = useUIStore((s) => s.currentPortSlug);
// Check for admin access based on role permissions
const hasAdminAccess = portRoles.some(
(pr) =>
pr.role?.permissions?.admin?.manage_users || pr.role?.permissions?.admin?.manage_settings,
);
// Super admins see every section regardless of role rows.
const hasAdminAccess =
isSuperAdmin ||
portRoles.some(
(pr) =>
pr.role?.permissions?.admin?.manage_users || pr.role?.permissions?.admin?.manage_settings,
);
const hasMarinaAccess =
isSuperAdmin || portRoles.some((pr) => pr.role?.permissions?.clients?.view);
const hasResidentialAccess =
isSuperAdmin ||
portRoles.some((pr) => pr.residentialAccess || pr.role?.permissions?.residential_clients?.view);
return (
<>
@@ -298,6 +339,8 @@ export function Sidebar({ portRoles }: SidebarProps) {
portSlug={currentPortSlug ?? undefined}
portRoles={portRoles}
hasAdminAccess={hasAdminAccess}
hasMarinaAccess={hasMarinaAccess}
hasResidentialAccess={hasResidentialAccess}
/>
{/* Collapse toggle */}
@@ -337,6 +380,8 @@ export function Sidebar({ portRoles }: SidebarProps) {
portSlug={currentPortSlug ?? undefined}
portRoles={portRoles}
hasAdminAccess={hasAdminAccess}
hasMarinaAccess={hasMarinaAccess}
hasResidentialAccess={hasResidentialAccess}
/>
</SheetContent>
</Sheet>

View File

@@ -1,6 +1,6 @@
'use client';
import { Plus, Moon, Sun, LogOut, User, Settings } from 'lucide-react';
import { Plus, Moon, Sun, LogOut, User, Settings, Bell } from 'lucide-react';
import { useRouter } from 'next/navigation';
import { useUIStore } from '@/stores/ui-store';
@@ -113,6 +113,13 @@ export function Topbar({ ports }: TopbarProps) {
<Settings className="w-4 h-4 mr-2" />
Settings
</DropdownMenuItem>
<DropdownMenuItem
// eslint-disable-next-line @typescript-eslint/no-explicit-any
onClick={() => router.push(`${base}/notifications/preferences` as any)}
>
<Bell className="w-4 h-4 mr-2" />
Notification preferences
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={handleToggleDarkMode}>
{darkMode ? (

View File

@@ -0,0 +1,131 @@
'use client';
import { useEffect, useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { toast } from 'sonner';
import { Button } from '@/components/ui/button';
import { Switch } from '@/components/ui/switch';
import { apiFetch } from '@/lib/api/client';
interface Pref {
notificationType: string;
inApp: boolean;
email: boolean;
}
const KNOWN_TYPES: Array<{ key: string; label: string; description: string }> = [
{
key: 'mention',
label: 'Mentions',
description: 'When someone @-mentions you in a note.',
},
{
key: 'reminder_overdue',
label: 'Overdue Reminders',
description: 'When an interest reminder you own becomes overdue.',
},
{
key: 'interest_stage_changed',
label: 'Interest Stage Changes',
description: 'When a pipeline stage changes on an interest assigned to you.',
},
{
key: 'system_alert',
label: 'System Alerts',
description: 'Background job failures and maintenance notices.',
},
];
export function NotificationPreferencesForm() {
const qc = useQueryClient();
const { data, isLoading } = useQuery<Pref[]>({
queryKey: ['notifications', 'preferences'],
queryFn: () =>
apiFetch<{ data: Pref[] }>('/api/v1/notifications/preferences').then((r) => r.data),
});
const [prefs, setPrefs] = useState<Map<string, Pref>>(new Map());
useEffect(() => {
const map = new Map<string, Pref>();
for (const t of KNOWN_TYPES) {
map.set(t.key, { notificationType: t.key, inApp: true, email: true });
}
if (data) {
for (const p of data) {
map.set(p.notificationType, p);
}
}
setPrefs(map);
}, [data]);
const mutation = useMutation({
mutationFn: async () => {
const payload = { preferences: Array.from(prefs.values()) };
return apiFetch('/api/v1/notifications/preferences', {
method: 'PUT',
body: payload,
});
},
onSuccess: () => {
toast.success('Preferences saved');
qc.invalidateQueries({ queryKey: ['notifications', 'preferences'] });
},
onError: (err) => toast.error(err instanceof Error ? err.message : 'Save failed'),
});
function update(type: string, field: 'inApp' | 'email', value: boolean) {
setPrefs((prev) => {
const next = new Map(prev);
const existing = next.get(type) ?? { notificationType: type, inApp: true, email: true };
next.set(type, { ...existing, [field]: value });
return next;
});
}
if (isLoading) {
return <div className="text-sm text-muted-foreground">Loading preferences</div>;
}
return (
<div className="space-y-4">
<div className="rounded-lg border divide-y">
<div className="grid grid-cols-[1fr_auto_auto] gap-4 px-4 py-2 text-xs font-medium uppercase text-muted-foreground">
<div>Type</div>
<div className="w-16 text-center">In-app</div>
<div className="w-16 text-center">Email</div>
</div>
{KNOWN_TYPES.map((t) => {
const pref = prefs.get(t.key) ?? {
notificationType: t.key,
inApp: true,
email: true,
};
return (
<div
key={t.key}
className="grid grid-cols-[1fr_auto_auto] gap-4 px-4 py-3 items-center"
>
<div>
<div className="text-sm font-medium">{t.label}</div>
<div className="text-xs text-muted-foreground">{t.description}</div>
</div>
<div className="w-16 flex justify-center">
<Switch checked={pref.inApp} onCheckedChange={(v) => update(t.key, 'inApp', v)} />
</div>
<div className="w-16 flex justify-center">
<Switch checked={pref.email} onCheckedChange={(v) => update(t.key, 'email', v)} />
</div>
</div>
);
})}
</div>
<div className="flex justify-end">
<Button onClick={() => mutation.mutate()} disabled={mutation.isPending}>
{mutation.isPending ? 'Saving…' : 'Save preferences'}
</Button>
</div>
</div>
);
}

View File

@@ -8,7 +8,7 @@ import { CheckCircle2, Loader2 } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { PortalAuthShell } from '@/components/portal/portal-auth-shell';
import { BrandedAuthShell } from '@/components/shared/branded-auth-shell';
interface PasswordSetFormProps {
/** API endpoint that accepts `{ token, password }` and sets / resets the password. */
@@ -75,7 +75,7 @@ export function PasswordSetForm({
if (!token) {
return (
<PortalAuthShell>
<BrandedAuthShell>
<div className="text-center space-y-3">
<h1 className="text-xl font-semibold text-gray-900">Link is missing or invalid</h1>
<p className="text-sm text-gray-500">
@@ -89,13 +89,13 @@ export function PasswordSetForm({
Request a new link
</Link>
</div>
</PortalAuthShell>
</BrandedAuthShell>
);
}
if (done) {
return (
<PortalAuthShell>
<BrandedAuthShell>
<div className="text-center">
<div className="inline-flex items-center justify-center w-14 h-14 rounded-full bg-green-50 mb-4">
<CheckCircle2 className="h-7 w-7 text-green-600" />
@@ -109,12 +109,12 @@ export function PasswordSetForm({
Sign in
</Link>
</div>
</PortalAuthShell>
</BrandedAuthShell>
);
}
return (
<PortalAuthShell>
<BrandedAuthShell>
<div className="mb-6">
<h1 className="text-xl font-semibold text-gray-900">{title}</h1>
<p className="text-sm text-gray-500 mt-1">{description}</p>
@@ -173,6 +173,6 @@ export function PasswordSetForm({
)}
</Button>
</form>
</PortalAuthShell>
</BrandedAuthShell>
);
}

View File

@@ -195,13 +195,18 @@ export function ReservationList({
</TableCell>
<TableCell>
{r.contractFileId ? (
// TODO: Confirm final file-download endpoint URL when available
<a
href={`/api/v1/files/${r.contractFileId}/download`}
<button
type="button"
className="text-primary hover:underline"
onClick={async () => {
const res = await apiFetch<{ data: { url: string } }>(
`/api/v1/files/${r.contractFileId}/download`,
);
window.open(res.data.url, '_blank', 'noopener,noreferrer');
}}
>
View contract
</a>
</button>
) : (
'—'
)}

View File

@@ -0,0 +1,299 @@
'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 { ArrowLeft, 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 { InlineEditableField } from '@/components/shared/inline-editable-field';
import { useRealtimeInvalidation } from '@/hooks/use-realtime-invalidation';
import { apiFetch } from '@/lib/api/client';
interface ResidentialInterestSummary {
id: string;
pipelineStage: string;
source: string | null;
notes: string | null;
preferences: string | null;
updatedAt: string;
}
interface ResidentialClientDetail {
id: string;
fullName: string;
email: string | null;
phone: string | null;
placeOfResidence: string | null;
preferredContactMethod: string | null;
status: string;
source: string | null;
notes: string | null;
interests: ResidentialInterestSummary[];
}
const STATUS_OPTIONS = [
{ value: 'prospect', label: 'Prospect' },
{ value: 'active', label: 'Active' },
{ value: 'inactive', label: 'Inactive' },
];
const CONTACT_OPTIONS = [
{ value: 'email', label: 'Email' },
{ value: 'phone', label: 'Phone' },
];
const SOURCE_OPTIONS = [
{ value: 'website', label: 'Website' },
{ value: 'manual', label: 'Manual' },
{ value: 'referral', label: 'Referral' },
{ value: 'broker', label: 'Broker' },
];
const STAGE_LABELS: Record<string, string> = {
new: 'New',
contacted: 'Contacted',
viewing_scheduled: 'Viewing scheduled',
offer_made: 'Offer made',
offer_accepted: 'Offer accepted',
closed_won: 'Closed — won',
closed_lost: 'Closed — lost',
};
export function ResidentialClientDetail({ clientId }: { clientId: string }) {
const params = useParams<{ portSlug: string }>();
const portSlug = params?.portSlug ?? '';
const qc = useQueryClient();
const [newInterestOpen, setNewInterestOpen] = useState(false);
const { data, isLoading } = useQuery<{ data: ResidentialClientDetail }>({
queryKey: ['residential-client', clientId],
queryFn: () => apiFetch(`/api/v1/residential/clients/${clientId}`),
});
useRealtimeInvalidation({
'residential_client:updated': [['residential-client', clientId]],
'residential_interest:created': [['residential-client', clientId]],
'residential_interest:updated': [['residential-client', clientId]],
'residential_interest:archived': [['residential-client', clientId]],
});
const update = useMutation({
mutationFn: (patch: Record<string, unknown>) =>
apiFetch(`/api/v1/residential/clients/${clientId}`, {
method: 'PATCH',
body: patch,
}),
onSuccess: () => qc.invalidateQueries({ queryKey: ['residential-client', clientId] }),
});
const save = (field: string) => async (next: string | null) => {
await update.mutateAsync({ [field]: next });
};
if (isLoading || !data) {
return <div className="text-sm text-muted-foreground">Loading</div>;
}
const client = data.data;
return (
<div className="space-y-6">
<div>
<Link
/* eslint-disable-next-line @typescript-eslint/no-explicit-any */
href={`/${portSlug}/residential/clients` as any}
className="text-sm text-muted-foreground hover:text-foreground inline-flex items-center gap-1"
>
<ArrowLeft className="h-3 w-3" /> All residential clients
</Link>
</div>
<div className="rounded-lg border bg-card p-6 space-y-6">
<div>
<h1 className="text-2xl font-semibold">
<InlineEditableField value={client.fullName} onSave={save('fullName')} />
</h1>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="space-y-1">
<h3 className="text-sm font-medium mb-2">Contact</h3>
<Row label="Email">
<InlineEditableField value={client.email} onSave={save('email')} />
</Row>
<Row label="Phone">
<InlineEditableField value={client.phone} onSave={save('phone')} />
</Row>
<Row label="Preferred contact">
<InlineEditableField
variant="select"
options={CONTACT_OPTIONS}
value={client.preferredContactMethod}
onSave={save('preferredContactMethod')}
/>
</Row>
<Row label="Place of residence">
<InlineEditableField
value={client.placeOfResidence}
onSave={save('placeOfResidence')}
/>
</Row>
</div>
<div className="space-y-1">
<h3 className="text-sm font-medium mb-2">Status</h3>
<Row label="Status">
<InlineEditableField
variant="select"
options={STATUS_OPTIONS}
value={client.status}
onSave={save('status')}
/>
</Row>
<Row label="Source">
<InlineEditableField
variant="select"
options={SOURCE_OPTIONS}
value={client.source}
onSave={save('source')}
/>
</Row>
<Row label="Notes">
<InlineEditableField value={client.notes} onSave={save('notes')} />
</Row>
</div>
</div>
</div>
<div className="rounded-lg border bg-card p-6">
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-semibold">Interests</h2>
<Button size="sm" onClick={() => setNewInterestOpen(true)}>
<Plus className="h-3.5 w-3.5 mr-1.5" />
New interest
</Button>
</div>
{client.interests.length === 0 ? (
<p className="text-sm text-muted-foreground">No interests yet.</p>
) : (
<ul className="space-y-2">
{client.interests.map((i) => (
<li key={i.id} className="flex items-center gap-3 p-3 rounded-md border bg-muted/30">
<span className="text-xs font-medium uppercase text-muted-foreground w-32 shrink-0">
{STAGE_LABELS[i.pipelineStage] ?? i.pipelineStage}
</span>
<span className="flex-1 truncate text-sm">{i.preferences || i.notes || '—'}</span>
<Link
/* eslint-disable-next-line @typescript-eslint/no-explicit-any */
href={`/${portSlug}/residential/interests/${i.id}` as any}
className="text-xs text-primary hover:underline"
>
View
</Link>
</li>
))}
</ul>
)}
</div>
<NewInterestSheet
clientId={clientId}
open={newInterestOpen}
onOpenChange={setNewInterestOpen}
/>
</div>
);
}
function Row({ 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>
);
}
function NewInterestSheet({
clientId,
open,
onOpenChange,
}: {
clientId: string;
open: boolean;
onOpenChange: (v: boolean) => void;
}) {
const qc = useQueryClient();
const [preferences, setPreferences] = useState('');
const [notes, setNotes] = useState('');
const create = useMutation({
mutationFn: () =>
apiFetch('/api/v1/residential/interests', {
method: 'POST',
body: {
residentialClientId: clientId,
preferences: preferences || undefined,
notes: notes || undefined,
source: 'manual',
},
}),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['residential-client', clientId] });
onOpenChange(false);
setPreferences('');
setNotes('');
toast.success('Interest added');
},
onError: (err) => toast.error(err instanceof Error ? err.message : 'Failed to add'),
});
return (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent>
<SheetHeader>
<SheetTitle>New interest</SheetTitle>
</SheetHeader>
<form
className="mt-6 space-y-4"
onSubmit={(e) => {
e.preventDefault();
create.mutate();
}}
>
<div className="space-y-1.5">
<Label htmlFor="ri-prefs">Preferences</Label>
<Input
id="ri-prefs"
value={preferences}
onChange={(e) => setPreferences(e.target.value)}
placeholder="Unit type, size, budget…"
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="ri-notes">Notes</Label>
<Input id="ri-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={create.isPending}>
{create.isPending ? 'Saving…' : 'Create'}
</Button>
</SheetFooter>
</form>
</SheetContent>
</Sheet>
);
}

View File

@@ -0,0 +1,246 @@
'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>
);
}

View File

@@ -0,0 +1,158 @@
'use client';
import Link from 'next/link';
import { useParams } from 'next/navigation';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { ArrowLeft } from 'lucide-react';
import { InlineEditableField } from '@/components/shared/inline-editable-field';
import { useRealtimeInvalidation } from '@/hooks/use-realtime-invalidation';
import { apiFetch } from '@/lib/api/client';
import { PIPELINE_STAGES } from '@/lib/validators/residential';
interface ResidentialInterestDetail {
id: string;
residentialClientId: string;
pipelineStage: string;
source: string | null;
notes: string | null;
preferences: string | null;
assignedTo: string | null;
client: { id: string; fullName: string } | null;
}
const STAGE_LABELS: Record<string, string> = {
new: 'New',
contacted: 'Contacted',
viewing_scheduled: 'Viewing scheduled',
offer_made: 'Offer made',
offer_accepted: 'Offer accepted',
closed_won: 'Closed — won',
closed_lost: 'Closed — lost',
};
const STAGE_OPTIONS = PIPELINE_STAGES.map((s) => ({
value: s,
label: STAGE_LABELS[s] ?? s,
}));
const SOURCE_OPTIONS = [
{ value: 'website', label: 'Website' },
{ value: 'manual', label: 'Manual' },
{ value: 'referral', label: 'Referral' },
{ value: 'broker', label: 'Broker' },
];
export function ResidentialInterestDetail({ interestId }: { interestId: string }) {
const params = useParams<{ portSlug: string }>();
const portSlug = params?.portSlug ?? '';
const qc = useQueryClient();
const { data, isLoading } = useQuery<{ data: ResidentialInterestDetail }>({
queryKey: ['residential-interest', interestId],
queryFn: () => apiFetch(`/api/v1/residential/interests/${interestId}`),
});
useRealtimeInvalidation({
'residential_interest:updated': [['residential-interest', interestId]],
});
const update = useMutation({
mutationFn: (patch: Record<string, unknown>) =>
apiFetch(`/api/v1/residential/interests/${interestId}`, {
method: 'PATCH',
body: patch,
}),
onSuccess: () => qc.invalidateQueries({ queryKey: ['residential-interest', interestId] }),
});
const save = (field: string) => async (next: string | null) => {
await update.mutateAsync({ [field]: next });
};
if (isLoading || !data) {
return <div className="text-sm text-muted-foreground">Loading</div>;
}
const interest = data.data;
return (
<div className="space-y-6">
<div>
<Link
/* eslint-disable-next-line @typescript-eslint/no-explicit-any */
href={`/${portSlug}/residential/interests` as any}
className="text-sm text-muted-foreground hover:text-foreground inline-flex items-center gap-1"
>
<ArrowLeft className="h-3 w-3" /> All residential interests
</Link>
</div>
<div className="rounded-lg border bg-card p-6 space-y-6">
<div>
<p className="text-xs uppercase text-muted-foreground tracking-wider mb-1">
Residential interest
</p>
{interest.client && (
<h1 className="text-2xl font-semibold">
<Link
/* eslint-disable-next-line @typescript-eslint/no-explicit-any */
href={`/${portSlug}/residential/clients/${interest.client.id}` as any}
className="hover:underline"
>
{interest.client.fullName}
</Link>
</h1>
)}
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="space-y-1">
<h3 className="text-sm font-medium mb-2">Pipeline</h3>
<Row label="Stage">
<InlineEditableField
variant="select"
options={STAGE_OPTIONS}
value={interest.pipelineStage}
onSave={save('pipelineStage')}
/>
</Row>
<Row label="Source">
<InlineEditableField
variant="select"
options={SOURCE_OPTIONS}
value={interest.source}
onSave={save('source')}
/>
</Row>
<Row label="Assigned to">
<InlineEditableField
value={interest.assignedTo}
onSave={save('assignedTo')}
placeholder="user id"
/>
</Row>
</div>
<div className="space-y-1">
<h3 className="text-sm font-medium mb-2">Details</h3>
<Row label="Preferences">
<InlineEditableField value={interest.preferences} onSave={save('preferences')} />
</Row>
<Row label="Notes">
<InlineEditableField value={interest.notes} onSave={save('notes')} />
</Row>
</div>
</div>
</div>
</div>
);
}
function Row({ 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>
);
}

View File

@@ -0,0 +1,154 @@
'use client';
import { useState } from 'react';
import Link from 'next/link';
import { useParams } from 'next/navigation';
import { useQuery } from '@tanstack/react-query';
import { Input } from '@/components/ui/input';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { PageHeader } from '@/components/shared/page-header';
import { useRealtimeInvalidation } from '@/hooks/use-realtime-invalidation';
import { apiFetch } from '@/lib/api/client';
import { PIPELINE_STAGES } from '@/lib/validators/residential';
interface ResidentialInterestRow {
id: string;
residentialClientId: string;
pipelineStage: string;
source: string | null;
notes: string | null;
preferences: string | null;
assignedTo: string | null;
updatedAt: string;
}
interface ListResponse {
data: ResidentialInterestRow[];
pagination: { total: number };
}
const STAGE_LABELS: Record<string, string> = {
new: 'New',
contacted: 'Contacted',
viewing_scheduled: 'Viewing scheduled',
offer_made: 'Offer made',
offer_accepted: 'Offer accepted',
closed_won: 'Closed — won',
closed_lost: 'Closed — lost',
};
export function ResidentialInterestsList() {
const params = useParams<{ portSlug: string }>();
const portSlug = params?.portSlug ?? '';
const [search, setSearch] = useState('');
const [stage, setStage] = useState<string>('all');
const { data, isLoading } = useQuery<ListResponse>({
queryKey: ['residential-interests', { search, stage }],
queryFn: () => {
const qs = new URLSearchParams({ search, limit: '50' });
if (stage !== 'all') qs.set('pipelineStage', stage);
return apiFetch(`/api/v1/residential/interests?${qs.toString()}`);
},
});
useRealtimeInvalidation({
'residential_interest:created': [['residential-interests']],
'residential_interest:updated': [['residential-interests']],
'residential_interest:archived': [['residential-interests']],
});
return (
<div className="space-y-4">
<PageHeader
title="Residential Interests"
description="Inquiries flowing through the residential pipeline"
/>
<div className="flex items-center gap-2">
<Input
placeholder="Search notes / preferences…"
value={search}
onChange={(e) => setSearch(e.target.value)}
className="max-w-sm"
/>
<Select value={stage} onValueChange={setStage}>
<SelectTrigger className="w-52">
<SelectValue placeholder="All stages" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All stages</SelectItem>
{PIPELINE_STAGES.map((s) => (
<SelectItem key={s} value={s}>
{STAGE_LABELS[s] ?? s}
</SelectItem>
))}
</SelectContent>
</Select>
</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">Stage</th>
<th className="text-left font-medium px-3 py-2">Preferences</th>
<th className="text-left font-medium px-3 py-2">Notes</th>
<th className="text-left font-medium px-3 py-2">Source</th>
<th className="text-left font-medium px-3 py-2">Updated</th>
</tr>
</thead>
<tbody>
{isLoading && (
<tr>
<td colSpan={5} className="px-3 py-8 text-center text-muted-foreground">
Loading
</td>
</tr>
)}
{!isLoading && data?.data.length === 0 && (
<tr>
<td colSpan={5} className="px-3 py-8 text-center text-muted-foreground">
No interests match.
</td>
</tr>
)}
{data?.data.map((i) => (
<tr
key={i.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/interests/${i.id}` as any}
className="font-medium hover:underline"
>
{STAGE_LABELS[i.pipelineStage] ?? i.pipelineStage}
</Link>
</td>
<td className="px-3 py-2 text-muted-foreground truncate max-w-xs">
{i.preferences ?? '—'}
</td>
<td className="px-3 py-2 text-muted-foreground truncate max-w-xs">
{i.notes ?? '—'}
</td>
<td className="px-3 py-2 capitalize text-muted-foreground">{i.source ?? '—'}</td>
<td className="px-3 py-2 text-muted-foreground text-xs">
{new Date(i.updatedAt).toLocaleDateString()}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
);
}

View File

@@ -2,7 +2,13 @@ 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';
export function PortalAuthShell({ children }: { children: React.ReactNode }) {
/**
* 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"

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 ? (

View File

@@ -1,8 +1,38 @@
'use client';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import type { DetailTab } from '@/components/shared/detail-layout';
import { EmptyState } from '@/components/shared/empty-state';
import { InlineEditableField } from '@/components/shared/inline-editable-field';
import { InlineTagEditor } from '@/components/shared/inline-tag-editor';
import { NotesList } from '@/components/shared/notes-list';
import { YachtOwnershipHistory } from '@/components/yachts/yacht-ownership-history';
import { apiFetch } from '@/lib/api/client';
type YachtPatchField =
| 'name'
| 'hullNumber'
| 'registration'
| 'flag'
| 'yearBuilt'
| 'builder'
| 'model'
| 'hullMaterial'
| 'lengthFt'
| 'widthFt'
| 'draftFt'
| 'lengthM'
| 'widthM'
| 'draftM'
| 'status'
| 'notes';
const STATUS_OPTIONS = [
{ value: 'active', label: 'Active' },
{ value: 'retired', label: 'Retired' },
{ value: 'sold_away', label: 'Sold away' },
];
interface YachtTabsYacht {
id: string;
@@ -22,6 +52,7 @@ interface YachtTabsYacht {
draftM: string | null;
status: string;
notes: string | null;
tags?: Array<{ id: string; name: string; color: string }>;
}
interface YachtTabsOptions {
@@ -30,25 +61,43 @@ interface YachtTabsOptions {
yacht: YachtTabsYacht;
}
function InfoRow({ label, value }: { label: string; value?: string | number | null }) {
if (value === null || value === undefined || value === '') return null;
function useYachtPatch(yachtId: string) {
const qc = useQueryClient();
return useMutation({
mutationFn: async (patch: Partial<Record<YachtPatchField, string | number | null>>) =>
apiFetch(`/api/v1/yachts/${yachtId}`, {
method: 'PATCH',
body: patch,
}),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['yachts', yachtId] });
},
});
}
function EditableRow({ label, children }: { label: string; children: React.ReactNode }) {
return (
<div className="flex gap-2 py-1.5 border-b last:border-0">
<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="text-sm">{value}</dd>
<dd className="flex-1 min-w-0">{children}</dd>
</div>
);
}
const STATUS_LABELS: Record<string, string> = {
active: 'Active',
retired: 'Retired',
sold_away: 'Sold away',
};
function OverviewTab({ yacht }: { yacht: YachtTabsYacht }) {
const hasFtDimensions = yacht.lengthFt || yacht.widthFt || yacht.draftFt;
const hasMDimensions = yacht.lengthM || yacht.widthM || yacht.draftM;
function OverviewTab({ yachtId, yacht }: { yachtId: string; yacht: YachtTabsYacht }) {
const mutation = useYachtPatch(yachtId);
const save =
(field: YachtPatchField, transform?: (v: string | null) => string | number | null) =>
async (next: string | null) => {
const value = transform ? transform(next) : next;
await mutation.mutateAsync({ [field]: value });
};
const numericString = (next: string | null) => (next === null ? null : next);
const yearTransform = (next: string | null) => {
if (next === null) return null;
const n = Number.parseInt(next, 10);
return Number.isNaN(n) ? null : n;
};
return (
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
@@ -56,77 +105,113 @@ function OverviewTab({ yacht }: { yacht: YachtTabsYacht }) {
<div className="space-y-1">
<h3 className="text-sm font-medium mb-2">Identity</h3>
<dl>
<InfoRow label="Name" value={yacht.name} />
<InfoRow label="Hull Number" value={yacht.hullNumber} />
<InfoRow label="Registration" value={yacht.registration} />
<InfoRow label="Flag" value={yacht.flag} />
<InfoRow label="Year Built" value={yacht.yearBuilt} />
<InfoRow label="Status" value={STATUS_LABELS[yacht.status] ?? yacht.status} />
<EditableRow label="Name">
<InlineEditableField value={yacht.name} onSave={save('name')} />
</EditableRow>
<EditableRow label="Hull Number">
<InlineEditableField value={yacht.hullNumber} onSave={save('hullNumber')} />
</EditableRow>
<EditableRow label="Registration">
<InlineEditableField value={yacht.registration} onSave={save('registration')} />
</EditableRow>
<EditableRow label="Flag">
<InlineEditableField value={yacht.flag} onSave={save('flag')} />
</EditableRow>
<EditableRow label="Year Built">
<InlineEditableField
value={yacht.yearBuilt?.toString() ?? null}
onSave={save('yearBuilt', yearTransform)}
/>
</EditableRow>
<EditableRow label="Status">
<InlineEditableField
variant="select"
options={STATUS_OPTIONS}
value={yacht.status}
onSave={save('status')}
/>
</EditableRow>
</dl>
</div>
{/* Build */}
{(yacht.builder || yacht.model || yacht.hullMaterial) && (
<div className="space-y-1">
<h3 className="text-sm font-medium mb-2">Build</h3>
<dl>
<InfoRow label="Builder" value={yacht.builder} />
<InfoRow label="Model" value={yacht.model} />
<InfoRow label="Hull Material" value={yacht.hullMaterial} />
</dl>
</div>
)}
<div className="space-y-1">
<h3 className="text-sm font-medium mb-2">Build</h3>
<dl>
<EditableRow label="Builder">
<InlineEditableField value={yacht.builder} onSave={save('builder')} />
</EditableRow>
<EditableRow label="Model">
<InlineEditableField value={yacht.model} onSave={save('model')} />
</EditableRow>
<EditableRow label="Hull Material">
<InlineEditableField value={yacht.hullMaterial} onSave={save('hullMaterial')} />
</EditableRow>
</dl>
</div>
{/* Dimensions (ft) */}
{hasFtDimensions && (
<div className="space-y-1">
<h3 className="text-sm font-medium mb-2">Dimensions (ft)</h3>
<dl>
<InfoRow label="Length" value={yacht.lengthFt ? `${yacht.lengthFt} ft` : null} />
<InfoRow label="Width" value={yacht.widthFt ? `${yacht.widthFt} ft` : null} />
<InfoRow label="Draft" value={yacht.draftFt ? `${yacht.draftFt} ft` : null} />
</dl>
</div>
)}
<div className="space-y-1">
<h3 className="text-sm font-medium mb-2">Dimensions (ft)</h3>
<dl>
<EditableRow label="Length (ft)">
<InlineEditableField value={yacht.lengthFt} onSave={save('lengthFt', numericString)} />
</EditableRow>
<EditableRow label="Width (ft)">
<InlineEditableField value={yacht.widthFt} onSave={save('widthFt', numericString)} />
</EditableRow>
<EditableRow label="Draft (ft)">
<InlineEditableField value={yacht.draftFt} onSave={save('draftFt', numericString)} />
</EditableRow>
</dl>
</div>
{/* Dimensions (m) */}
{hasMDimensions && (
<div className="space-y-1">
<h3 className="text-sm font-medium mb-2">Dimensions (m)</h3>
<dl>
<InfoRow label="Length" value={yacht.lengthM ? `${yacht.lengthM} m` : null} />
<InfoRow label="Width" value={yacht.widthM ? `${yacht.widthM} m` : null} />
<InfoRow label="Draft" value={yacht.draftM ? `${yacht.draftM} m` : null} />
</dl>
</div>
)}
<div className="space-y-1">
<h3 className="text-sm font-medium mb-2">Dimensions (m)</h3>
<dl>
<EditableRow label="Length (m)">
<InlineEditableField value={yacht.lengthM} onSave={save('lengthM', numericString)} />
</EditableRow>
<EditableRow label="Width (m)">
<InlineEditableField value={yacht.widthM} onSave={save('widthM', numericString)} />
</EditableRow>
<EditableRow label="Draft (m)">
<InlineEditableField value={yacht.draftM} onSave={save('draftM', numericString)} />
</EditableRow>
</dl>
</div>
{/* Notes */}
{yacht.notes && (
<div className="space-y-1 md:col-span-2">
<h3 className="text-sm font-medium mb-2">Notes</h3>
<p className="text-sm whitespace-pre-wrap rounded-md border bg-muted/30 p-3">
{yacht.notes}
</p>
</div>
)}
<div className="space-y-1 md:col-span-2">
<h3 className="text-sm font-medium mb-2">Notes</h3>
<InlineEditableField
variant="textarea"
value={yacht.notes}
onSave={save('notes')}
emptyText="No notes — click to add"
/>
</div>
{/* Tags */}
<div className="space-y-1 md:col-span-2">
<h3 className="text-sm font-medium mb-2">Tags</h3>
<InlineTagEditor
endpoint={`/api/v1/yachts/${yachtId}/tags`}
currentTags={yacht.tags ?? []}
invalidateKey={['yachts', yachtId]}
/>
</div>
</div>
);
}
export function getYachtTabs({
yachtId,
// currentUserId reserved for when NotesList supports entityType='yachts'.
currentUserId: _currentUserId,
yacht,
}: YachtTabsOptions): DetailTab[] {
void _currentUserId;
export function getYachtTabs({ yachtId, currentUserId, yacht }: YachtTabsOptions): DetailTab[] {
return [
{
id: 'overview',
label: 'Overview',
content: <OverviewTab yacht={yacht} />,
content: <OverviewTab yachtId={yachtId} yacht={yacht} />,
},
{
id: 'ownership-history',
@@ -146,23 +231,7 @@ export function getYachtTabs({
{
id: 'notes',
label: 'Notes',
// TODO: NotesList currently supports entityType 'clients' | 'interests'.
// Extend NotesList (or swap to a yacht-notes endpoint) in a follow-up.
content: (
<EmptyState
title="Notes"
description="Yacht notes coming soon — the notes endpoint is pending wiring."
/>
),
},
{
id: 'tags',
label: 'Tags',
// TODO: replace with an inline tag editor once one exists; yacht tags
// can be edited via the Edit form in the meantime.
content: (
<EmptyState title="Tags" description="Manage tags from the Edit yacht form for now." />
),
content: <NotesList entityType="yachts" entityId={yachtId} currentUserId={currentUserId} />,
},
];
}