feat(platform): residential module + admin UI + reliability fixes
Residential platform - New schema: residentialClients, residentialInterests (separate from marina/yacht clients) with migration 0010 - Service layer with CRUD + audit + sockets + per-port portal toggle - v1 + public API routes (/api/v1/residential/*, /api/public/residential-inquiries) - List + detail pages with inline editing for clients and interests - Per-user residentialAccess toggle on userPortRoles (migration 0011) - Permission keys: residential_clients, residential_interests - Sidebar nav + role form integration - Smoke spec covering page loads, UI create flow, public endpoint Admin & shared UI - Admin → Forms (form templates CRUD) with validators + service - Notification preferences page (in-app + email per type) - Email composition + accounts list + threads view - Branded auth shell shared across CRM + portal auth surfaces - Inline editing extended to yacht/company/interest detail pages - InlineTagEditor + per-entity tags endpoints (yachts, companies) - Notes service polymorphic across clients/interests/yachts/companies - Client list columns: yachtCount + companyCount badges - Reservation file-download via presigned URL (replaces stale <a href>) Route handler refactor - Extracted yachts/companies/berths reservation handlers to sibling handlers.ts files (Next.js 15 route.ts only allows specific exports) Reliability fixes - apiFetch double-stringify bug fixed across 13 components (apiFetch already JSON.stringifies its body; passing a stringified body produced double-encoded JSON which failed zod validation) - SocketProvider gated behind useSyncExternalStore-based mount check to avoid useSession() SSR crashes under React 19 + Next 15 - apiFetch falls back to URL-pathname → port-id resolution when the Zustand store hasn't hydrated yet (fresh contexts, e2e tests) - CRM invite flow (schema, service, route, email, dev script) - Dashboard route → [portSlug]/dashboard/page.tsx + redirect - Document the dev-server restart-after-migration gotcha in CLAUDE.md Tests - 5-case residential smoke spec - Integration test updates for new service signatures Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
243
src/components/admin/forms/form-template-form.tsx
Normal file
243
src/components/admin/forms/form-template-form.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
123
src/components/admin/forms/form-template-list.tsx
Normal file
123
src/components/admin/forms/form-template-list.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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> = {
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -19,6 +19,7 @@ interface ClientData {
|
||||
source: string | null;
|
||||
sourceDetails: string | null;
|
||||
archivedAt: string | null;
|
||||
clientPortalEnabled: boolean;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
contacts: Array<{
|
||||
|
||||
@@ -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',
|
||||
|
||||
329
src/components/clients/contacts-editor.tsx
Normal file
329
src/components/clients/contacts-editor.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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} />
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
153
src/components/email/compose-dialog.tsx
Normal file
153
src/components/email/compose-dialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
264
src/components/email/email-accounts-list.tsx
Normal file
264
src/components/email/email-accounts-list.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
70
src/components/email/email-threads-list.tsx
Normal file
70
src/components/email/email-threads-list.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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} />
|
||||
),
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 ?? '—'} · {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" />
|
||||
) : (
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
131
src/components/notifications/notification-preferences-form.tsx
Normal file
131
src/components/notifications/notification-preferences-form.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
) : (
|
||||
'—'
|
||||
)}
|
||||
|
||||
299
src/components/residential/residential-client-detail.tsx
Normal file
299
src/components/residential/residential-client-detail.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
246
src/components/residential/residential-clients-list.tsx
Normal file
246
src/components/residential/residential-clients-list.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
158
src/components/residential/residential-interest-detail.tsx
Normal file
158
src/components/residential/residential-interest-detail.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
154
src/components/residential/residential-interests-list.tsx
Normal file
154
src/components/residential/residential-interests-list.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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"
|
||||
272
src/components/shared/inline-editable-field.tsx
Normal file
272
src/components/shared/inline-editable-field.tsx
Normal file
@@ -0,0 +1,272 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { Loader2, Pencil } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface SelectOption {
|
||||
value: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
interface BaseProps {
|
||||
value: string | null | undefined;
|
||||
onSave: (next: string | null) => Promise<void>;
|
||||
placeholder?: string;
|
||||
emptyText?: string;
|
||||
className?: string;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
interface TextProps extends BaseProps {
|
||||
variant?: 'text';
|
||||
}
|
||||
|
||||
interface SelectFieldProps extends BaseProps {
|
||||
variant: 'select';
|
||||
options: SelectOption[];
|
||||
}
|
||||
|
||||
interface TextareaProps extends BaseProps {
|
||||
variant: 'textarea';
|
||||
rows?: number;
|
||||
}
|
||||
|
||||
export type InlineEditableFieldProps = TextProps | SelectFieldProps | TextareaProps;
|
||||
|
||||
/**
|
||||
* Click-to-edit field used in detail panels. Shows the value as plain text
|
||||
* with a pencil affordance on hover; clicking swaps to an input that saves on
|
||||
* Enter/blur and cancels on Escape.
|
||||
*/
|
||||
export function InlineEditableField(props: InlineEditableFieldProps) {
|
||||
const { value, onSave, placeholder, emptyText = '—', className, disabled } = props;
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [draft, setDraft] = useState(value ?? '');
|
||||
const [saving, setSaving] = useState(false);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setDraft(value ?? '');
|
||||
}, [value]);
|
||||
|
||||
useEffect(() => {
|
||||
if (editing) {
|
||||
if (inputRef.current) {
|
||||
inputRef.current.focus();
|
||||
inputRef.current.select();
|
||||
} else if (textareaRef.current) {
|
||||
textareaRef.current.focus();
|
||||
textareaRef.current.select();
|
||||
}
|
||||
}
|
||||
}, [editing]);
|
||||
|
||||
async function commit(nextRaw: string) {
|
||||
const trimmed = nextRaw.trim();
|
||||
if (trimmed === (value ?? '')) {
|
||||
setEditing(false);
|
||||
return;
|
||||
}
|
||||
setSaving(true);
|
||||
try {
|
||||
await onSave(trimmed === '' ? null : trimmed);
|
||||
setEditing(false);
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : 'Failed to save');
|
||||
setDraft(value ?? '');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
function cancel() {
|
||||
setDraft(value ?? '');
|
||||
setEditing(false);
|
||||
}
|
||||
|
||||
if (props.variant === 'select') {
|
||||
const labelFor = (v: string | null | undefined) =>
|
||||
v ? (props.options.find((o) => o.value === v)?.label ?? v) : null;
|
||||
|
||||
if (!editing) {
|
||||
return (
|
||||
<ReadButton
|
||||
value={labelFor(value)}
|
||||
emptyText={emptyText}
|
||||
disabled={disabled}
|
||||
onClick={() => setEditing(true)}
|
||||
className={className}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn('flex items-center gap-1', className)}>
|
||||
<Select
|
||||
value={draft}
|
||||
onValueChange={(v) => void commit(v)}
|
||||
open
|
||||
onOpenChange={(open) => {
|
||||
if (!open && !saving) setEditing(false);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="h-7 text-sm w-full">
|
||||
<SelectValue placeholder={placeholder} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{props.options.map((o) => (
|
||||
<SelectItem key={o.value} value={o.value}>
|
||||
{o.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{saving && <Loader2 className="h-3 w-3 animate-spin text-muted-foreground" />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (props.variant === 'textarea') {
|
||||
if (!editing) {
|
||||
return (
|
||||
<ReadButton
|
||||
value={value || null}
|
||||
emptyText={emptyText}
|
||||
disabled={disabled}
|
||||
onClick={() => setEditing(true)}
|
||||
multiline
|
||||
className={className}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn('flex flex-col gap-1', className)}>
|
||||
<Textarea
|
||||
ref={textareaRef}
|
||||
value={draft}
|
||||
onChange={(e) => setDraft(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
cancel();
|
||||
}
|
||||
if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
|
||||
e.preventDefault();
|
||||
void commit(draft);
|
||||
}
|
||||
}}
|
||||
onBlur={() => {
|
||||
if (!saving) void commit(draft);
|
||||
}}
|
||||
placeholder={placeholder}
|
||||
disabled={saving}
|
||||
rows={props.rows ?? 4}
|
||||
className="text-sm"
|
||||
/>
|
||||
{saving && <Loader2 className="h-3 w-3 animate-spin text-muted-foreground" />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!editing) {
|
||||
return (
|
||||
<ReadButton
|
||||
value={value || null}
|
||||
emptyText={emptyText}
|
||||
disabled={disabled}
|
||||
onClick={() => setEditing(true)}
|
||||
className={className}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn('flex items-center gap-1', className)}>
|
||||
<Input
|
||||
ref={inputRef}
|
||||
value={draft}
|
||||
onChange={(e) => setDraft(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
void commit(draft);
|
||||
}
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
cancel();
|
||||
}
|
||||
}}
|
||||
onBlur={() => {
|
||||
if (!saving) void commit(draft);
|
||||
}}
|
||||
placeholder={placeholder}
|
||||
disabled={saving}
|
||||
className="h-7 text-sm"
|
||||
/>
|
||||
{saving && <Loader2 className="h-3 w-3 animate-spin text-muted-foreground" />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ReadButton({
|
||||
value,
|
||||
emptyText,
|
||||
disabled,
|
||||
onClick,
|
||||
multiline,
|
||||
className,
|
||||
}: {
|
||||
value: string | null;
|
||||
emptyText: string;
|
||||
disabled?: boolean;
|
||||
onClick: () => void;
|
||||
multiline?: boolean;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
onClick={onClick}
|
||||
className={cn(
|
||||
'group rounded px-1 -mx-1 py-0.5 text-left text-sm',
|
||||
multiline ? 'flex w-full items-start gap-1.5' : 'inline-flex items-center gap-1.5',
|
||||
'hover:bg-muted/60 focus-visible:bg-muted/60 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring',
|
||||
disabled && 'cursor-not-allowed opacity-60 hover:bg-transparent',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
'flex-1',
|
||||
multiline && 'whitespace-pre-wrap',
|
||||
!value && 'text-muted-foreground',
|
||||
)}
|
||||
>
|
||||
{value ?? emptyText}
|
||||
</span>
|
||||
{!disabled && (
|
||||
<Pencil
|
||||
className={cn(
|
||||
'h-3 w-3 opacity-0 transition-opacity group-hover:opacity-50',
|
||||
multiline && 'mt-1 shrink-0',
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
132
src/components/shared/inline-tag-editor.tsx
Normal file
132
src/components/shared/inline-tag-editor.tsx
Normal file
@@ -0,0 +1,132 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { Plus, X, Check } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
||||
import { apiFetch } from '@/lib/api/client';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface Tag {
|
||||
id: string;
|
||||
name: string;
|
||||
color: string;
|
||||
}
|
||||
|
||||
export interface InlineTagEditorProps {
|
||||
/** PUT endpoint for replacing the entity's tag list — body shape `{ tagIds: string[] }`. */
|
||||
endpoint: string;
|
||||
currentTags: Tag[];
|
||||
/** TanStack Query key to invalidate after a successful change. */
|
||||
invalidateKey: readonly unknown[];
|
||||
/** Hide the "+ Add tag" button (read-only mode). */
|
||||
readOnly?: boolean;
|
||||
}
|
||||
|
||||
export function InlineTagEditor({
|
||||
endpoint,
|
||||
currentTags,
|
||||
invalidateKey,
|
||||
readOnly,
|
||||
}: InlineTagEditorProps) {
|
||||
const qc = useQueryClient();
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const { data: allTags } = useQuery<{ data: Tag[] }>({
|
||||
queryKey: ['tags'],
|
||||
queryFn: () => apiFetch('/api/v1/tags'),
|
||||
staleTime: 60_000,
|
||||
enabled: open,
|
||||
});
|
||||
|
||||
const setTags = useMutation({
|
||||
mutationFn: (tagIds: string[]) => apiFetch(endpoint, { method: 'PUT', body: { tagIds } }),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: invalidateKey }),
|
||||
onError: (err) => toast.error(err instanceof Error ? err.message : 'Failed to update tags'),
|
||||
});
|
||||
|
||||
function toggleTag(tagId: string) {
|
||||
const has = currentTags.some((t) => t.id === tagId);
|
||||
const nextIds = has
|
||||
? currentTags.filter((t) => t.id !== tagId).map((t) => t.id)
|
||||
: [...currentTags.map((t) => t.id), tagId];
|
||||
setTags.mutate(nextIds);
|
||||
}
|
||||
|
||||
function removeTag(tagId: string) {
|
||||
setTags.mutate(currentTags.filter((t) => t.id !== tagId).map((t) => t.id));
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
{currentTags.map((t) => (
|
||||
<span
|
||||
key={t.id}
|
||||
className="group inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-xs font-medium"
|
||||
style={{ backgroundColor: `${t.color}20`, color: t.color }}
|
||||
>
|
||||
{t.name}
|
||||
{!readOnly && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeTag(t.id)}
|
||||
className="opacity-0 group-hover:opacity-100 transition-opacity hover:opacity-100"
|
||||
aria-label={`Remove tag ${t.name}`}
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
)}
|
||||
</span>
|
||||
))}
|
||||
|
||||
{!readOnly && (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-6 px-2 text-xs text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<Plus className="h-3 w-3 mr-1" />
|
||||
{currentTags.length === 0 ? 'Add tag' : 'Add'}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-64 p-0" align="start">
|
||||
<div className="max-h-64 overflow-y-auto py-1">
|
||||
{!allTags && <div className="px-3 py-2 text-xs text-muted-foreground">Loading…</div>}
|
||||
{allTags?.data.length === 0 && (
|
||||
<div className="px-3 py-2 text-xs text-muted-foreground">
|
||||
No tags defined yet. Create some in Admin → Tags.
|
||||
</div>
|
||||
)}
|
||||
{allTags?.data.map((t) => {
|
||||
const checked = currentTags.some((c) => c.id === t.id);
|
||||
return (
|
||||
<button
|
||||
key={t.id}
|
||||
type="button"
|
||||
onClick={() => toggleTag(t.id)}
|
||||
className={cn(
|
||||
'flex items-center gap-2 w-full px-3 py-1.5 text-sm text-left hover:bg-muted/60',
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className="inline-block w-2.5 h-2.5 rounded-full shrink-0"
|
||||
style={{ backgroundColor: t.color }}
|
||||
/>
|
||||
<span className="flex-1 truncate">{t.name}</span>
|
||||
{checked && <Check className="h-3.5 w-3.5 text-primary" />}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -21,7 +21,7 @@ interface Note {
|
||||
}
|
||||
|
||||
interface NotesListProps {
|
||||
entityType: 'clients' | 'interests';
|
||||
entityType: 'clients' | 'interests' | 'yachts' | 'companies';
|
||||
entityId: string;
|
||||
currentUserId?: string;
|
||||
}
|
||||
@@ -43,8 +43,7 @@ export function NotesList({ entityType, entityId, currentUserId }: NotesListProp
|
||||
});
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: (content: string) =>
|
||||
apiFetch(endpoint, { method: 'POST', body: { content } }),
|
||||
mutationFn: (content: string) => apiFetch(endpoint, { method: 'POST', body: { content } }),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey });
|
||||
setNewNote('');
|
||||
@@ -61,8 +60,7 @@ export function NotesList({ entityType, entityId, currentUserId }: NotesListProp
|
||||
});
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (noteId: string) =>
|
||||
apiFetch(`${endpoint}/${noteId}`, { method: 'DELETE' }),
|
||||
mutationFn: (noteId: string) => apiFetch(`${endpoint}/${noteId}`, { method: 'DELETE' }),
|
||||
onSuccess: () => queryClient.invalidateQueries({ queryKey }),
|
||||
});
|
||||
|
||||
@@ -127,13 +125,9 @@ export function NotesList({ entityType, entityId, currentUserId }: NotesListProp
|
||||
<span className="text-muted-foreground">
|
||||
{formatDistanceToNow(new Date(note.createdAt), { addSuffix: true })}
|
||||
</span>
|
||||
{note.isLocked && (
|
||||
<Lock className="h-3 w-3 text-muted-foreground" />
|
||||
)}
|
||||
{note.isLocked && <Lock className="h-3 w-3 text-muted-foreground" />}
|
||||
{canEdit(note) && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{getTimeRemaining(note)}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">{getTimeRemaining(note)}</span>
|
||||
)}
|
||||
</div>
|
||||
{editingId === note.id ? (
|
||||
|
||||
@@ -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} />,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user