feat(interests): EOI/contract/reservation tabs + contact log + berth interest milestone + interest list overhaul
Major interest workflow expansion driven by the rapid-fire UX session.
EOI / Contract / Reservation tabs replace the generic Documents tab when
the deal is at the relevant stage — workspace pattern with active-doc
hero, signing progress, paper-signed upload, and history strip. Stage-
conditional visibility wired through interest-tabs.tsx so the tab set
shrinks/expands as the deal moves through the pipeline.
Contact log: per-interaction structured log (channel/direction/summary/
optional follow-up reminder). New `interest_contact_log` table + service
+ tab UI (timeline with channel-coded icons + compose dialog).
auto-creates a reminder when followUpAt is set.
Berth Interest milestone: first milestone in the OverviewTab's pipeline
strip, completes the moment any berth is linked via the junction. Drives
the "have we captured what they want?" sanity check for general_interest
leads before they move to EOI.
Stage-conditional milestones: past phases collapse into a one-liner
strip, current phase expands, future phases hide behind a "Show
upcoming" toggle. Inline stage picker now defers reason capture to an
override-confirm view (only required for illegal transitions, not the
default flow).
Notes blob → threaded: dropped `interests.notes` column entirely; the
threaded `interest_notes` table is the single source of truth. Latest-
note teaser on Overview links into the dedicated Notes tab. Polymorphic
notes service gains aggregated client view (unions client + interest +
yacht notes with source chips and group-by-source toggle).
Berth interest list overhaul:
- Configurable columns via ColumnPicker (18 toggleable, 5 default-on)
- Natural-sort SQL ORDER BY on mooring number (A1, A2, A10 not A10, A2)
- Per-letter row tinting via colored left-border accent + dot in cell
- Documents tab merged Files (single attachments section)
Topbar improvements:
- Always-visible back arrow on detail pages (path depth > 2)
- Breadcrumb-hint store + useBreadcrumbHint hook so detail pages can
push their entity hierarchy (Clients › Mary Smith › Interest › B17)
- Tighter spacing, softer separators, 160px crumb truncation
DataTable upgrades:
- Page-size selector with All option (validator cap raised to 1000)
- getRowClassName slot for per-row styling (used by berth tinting)
- Fixed Radix SelectItem crash on empty-string values via __any__
sentinel (was crashing every list page that opened a select filter)
Interest list:
- Configurable columns picker
- Stage cell clickable into detail
- TagPicker + SavedViewsDropdown sized h-8 to match adjacent buttons
- Save view moved into ColumnPicker menu; Views button hidden when
no views are saved
- Pipeline kanban board endpoint at /api/v1/interests/board with
minimal projection, 5000-row cap + truncated banner, filter
pass-through
Mobile chrome + sidebar collapse removed (always-expanded design choice).
User management lists super-admins (was inner-joined on user_port_roles
which excluded global super-admins).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
435
src/components/interests/interest-contact-log-tab.tsx
Normal file
435
src/components/interests/interest-contact-log-tab.tsx
Normal file
@@ -0,0 +1,435 @@
|
||||
'use client';
|
||||
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import {
|
||||
Bell,
|
||||
CalendarDays,
|
||||
Mail,
|
||||
MessageCircle,
|
||||
MoreVertical,
|
||||
Phone,
|
||||
Pencil,
|
||||
Plus,
|
||||
Trash2,
|
||||
Users,
|
||||
Video,
|
||||
} from 'lucide-react';
|
||||
import { format, formatDistanceToNowStrict } from 'date-fns';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { apiFetch } from '@/lib/api/client';
|
||||
import { toastError } from '@/lib/api/toast-error';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface InterestContactLogTabProps {
|
||||
interestId: string;
|
||||
}
|
||||
|
||||
type Channel = 'email' | 'phone' | 'whatsapp' | 'in_person' | 'video' | 'other';
|
||||
type Direction = 'outbound' | 'inbound';
|
||||
|
||||
interface ContactLogEntry {
|
||||
id: string;
|
||||
occurredAt: string;
|
||||
channel: Channel;
|
||||
direction: Direction;
|
||||
summary: string;
|
||||
followUpAt: string | null;
|
||||
reminderId: string | null;
|
||||
createdBy: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
const CHANNEL_META: Record<Channel, { label: string; icon: typeof Phone; tone: string }> = {
|
||||
email: { label: 'Email', icon: Mail, tone: 'bg-sky-100 text-sky-700' },
|
||||
phone: { label: 'Phone', icon: Phone, tone: 'bg-emerald-100 text-emerald-700' },
|
||||
whatsapp: { label: 'WhatsApp', icon: MessageCircle, tone: 'bg-emerald-100 text-emerald-700' },
|
||||
in_person: { label: 'In person', icon: Users, tone: 'bg-amber-100 text-amber-800' },
|
||||
video: { label: 'Video', icon: Video, tone: 'bg-violet-100 text-violet-700' },
|
||||
other: { label: 'Other', icon: CalendarDays, tone: 'bg-slate-100 text-slate-700' },
|
||||
};
|
||||
|
||||
/**
|
||||
* Per-interaction contact log. Sales reps log every email / call /
|
||||
* WhatsApp / meeting touch with the client here so the team has a
|
||||
* structured history of "what was the last conversation about" — not
|
||||
* just the bare "last contact 8d ago" timestamp on the interest.
|
||||
*
|
||||
* Each entry can optionally schedule a follow-up that auto-creates a
|
||||
* reminder pointing back at the interest. Editing the entry's
|
||||
* follow-up date keeps the linked reminder in sync; deleting the
|
||||
* entry removes the reminder.
|
||||
*/
|
||||
export function InterestContactLogTab({ interestId }: InterestContactLogTabProps) {
|
||||
const [composeOpen, setComposeOpen] = useState(false);
|
||||
const [editTarget, setEditTarget] = useState<ContactLogEntry | null>(null);
|
||||
|
||||
const { data: res, isLoading } = useQuery<{ data: ContactLogEntry[] }>({
|
||||
queryKey: ['interests', interestId, 'contact-log'],
|
||||
queryFn: () =>
|
||||
apiFetch<{ data: ContactLogEntry[] }>(`/api/v1/interests/${interestId}/contact-log`),
|
||||
});
|
||||
|
||||
const entries = res?.data ?? [];
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-foreground">Contact log</h3>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Record each conversation. The most recent log entry sets the “Last contact”
|
||||
chip on the interest header.
|
||||
</p>
|
||||
</div>
|
||||
<Button size="sm" onClick={() => setComposeOpen(true)} className="gap-1.5">
|
||||
<Plus className="size-4" />
|
||||
Log contact
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-20 rounded-lg" />
|
||||
<Skeleton className="h-20 rounded-lg" />
|
||||
</div>
|
||||
) : entries.length === 0 ? (
|
||||
<EmptyState onAdd={() => setComposeOpen(true)} />
|
||||
) : (
|
||||
<ol className="space-y-2">
|
||||
{entries.map((e) => (
|
||||
<ContactLogRow key={e.id} entry={e} interestId={interestId} onEdit={setEditTarget} />
|
||||
))}
|
||||
</ol>
|
||||
)}
|
||||
|
||||
<ComposeDialog interestId={interestId} open={composeOpen} onOpenChange={setComposeOpen} />
|
||||
{editTarget && (
|
||||
<ComposeDialog
|
||||
interestId={interestId}
|
||||
existing={editTarget}
|
||||
open={!!editTarget}
|
||||
onOpenChange={(o) => !o && setEditTarget(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Row ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
function ContactLogRow({
|
||||
entry,
|
||||
interestId,
|
||||
onEdit,
|
||||
}: {
|
||||
entry: ContactLogEntry;
|
||||
interestId: string;
|
||||
onEdit: (e: ContactLogEntry) => void;
|
||||
}) {
|
||||
const queryClient = useQueryClient();
|
||||
const channelMeta = CHANNEL_META[entry.channel];
|
||||
const Icon = channelMeta.icon;
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: () => apiFetch(`/api/v1/contact-log/${entry.id}`, { method: 'DELETE' }),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['interests', interestId, 'contact-log'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['interests', interestId] });
|
||||
toast.success('Contact log entry deleted.');
|
||||
},
|
||||
onError: (err) => toastError(err),
|
||||
});
|
||||
|
||||
return (
|
||||
<li className="rounded-lg border bg-background p-3">
|
||||
<div className="flex items-start gap-3">
|
||||
<span
|
||||
aria-hidden
|
||||
className={cn(
|
||||
'mt-0.5 flex size-8 shrink-0 items-center justify-center rounded-full',
|
||||
channelMeta.tone,
|
||||
)}
|
||||
>
|
||||
<Icon className="size-3.5" />
|
||||
</span>
|
||||
<div className="min-w-0 flex-1 space-y-1.5">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="text-sm font-medium text-foreground">{channelMeta.label}</span>
|
||||
<Badge variant="outline" className="text-[10px] capitalize">
|
||||
{entry.direction}
|
||||
</Badge>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{format(new Date(entry.occurredAt), 'MMM d, yyyy · HH:mm')}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
({formatDistanceToNowStrict(new Date(entry.occurredAt))} ago)
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm text-foreground whitespace-pre-wrap">{entry.summary}</p>
|
||||
{entry.followUpAt && (
|
||||
<p className="inline-flex items-center gap-1.5 rounded-md border border-amber-200 bg-amber-50 px-2 py-0.5 text-xs text-amber-900">
|
||||
<Bell className="size-3" />
|
||||
Follow up {format(new Date(entry.followUpAt), 'MMM d, yyyy')} (reminder created)
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className="h-7 w-7" aria-label="Row actions">
|
||||
<MoreVertical className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={() => onEdit(entry)}>
|
||||
<Pencil className="mr-2 size-3.5" />
|
||||
Edit
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
className="text-destructive"
|
||||
disabled={deleteMutation.isPending}
|
||||
onClick={() => {
|
||||
if (window.confirm('Delete this contact log entry?')) {
|
||||
deleteMutation.mutate();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Trash2 className="mr-2 size-3.5" />
|
||||
Delete
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Empty state ─────────────────────────────────────────────────────────────
|
||||
|
||||
function EmptyState({ onAdd }: { onAdd: () => void }) {
|
||||
return (
|
||||
<div className="rounded-xl border border-dashed bg-muted/20 p-8 text-center">
|
||||
<div className="mx-auto flex size-12 items-center justify-center rounded-full bg-background text-muted-foreground">
|
||||
<Phone className="size-5" />
|
||||
</div>
|
||||
<h3 className="mt-3 text-sm font-medium text-foreground">No contact logged yet</h3>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
Record every call, email, and meeting so the team has full context the next time someone
|
||||
picks up the deal.
|
||||
</p>
|
||||
<Button size="sm" onClick={onAdd} className="mt-4 gap-1.5">
|
||||
<Plus className="size-3.5" />
|
||||
Log first contact
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Compose / edit dialog ───────────────────────────────────────────────────
|
||||
|
||||
function ComposeDialog({
|
||||
interestId,
|
||||
existing,
|
||||
open,
|
||||
onOpenChange,
|
||||
}: {
|
||||
interestId: string;
|
||||
existing?: ContactLogEntry;
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}) {
|
||||
const queryClient = useQueryClient();
|
||||
const isEdit = !!existing;
|
||||
|
||||
const defaultOccurredAt = useMemo(() => {
|
||||
if (existing) return localIsoString(existing.occurredAt);
|
||||
return localIsoString(new Date().toISOString());
|
||||
}, [existing]);
|
||||
|
||||
const [occurredAt, setOccurredAt] = useState<string>(defaultOccurredAt);
|
||||
const [channel, setChannel] = useState<Channel>(existing?.channel ?? 'phone');
|
||||
const [direction, setDirection] = useState<Direction>(existing?.direction ?? 'outbound');
|
||||
const [summary, setSummary] = useState<string>(existing?.summary ?? '');
|
||||
const [followUpAt, setFollowUpAt] = useState<string>(
|
||||
existing?.followUpAt ? localIsoString(existing.followUpAt) : '',
|
||||
);
|
||||
|
||||
// Re-sync local state when the existing entry changes (e.g. opening
|
||||
// the edit dialog for a different row).
|
||||
useMemo(() => {
|
||||
if (open) {
|
||||
setOccurredAt(
|
||||
existing ? localIsoString(existing.occurredAt) : localIsoString(new Date().toISOString()),
|
||||
);
|
||||
setChannel(existing?.channel ?? 'phone');
|
||||
setDirection(existing?.direction ?? 'outbound');
|
||||
setSummary(existing?.summary ?? '');
|
||||
setFollowUpAt(existing?.followUpAt ? localIsoString(existing.followUpAt) : '');
|
||||
}
|
||||
}, [open, existing]);
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
const body = {
|
||||
occurredAt: new Date(occurredAt).toISOString(),
|
||||
channel,
|
||||
direction,
|
||||
summary,
|
||||
followUpAt: followUpAt ? new Date(followUpAt).toISOString() : null,
|
||||
};
|
||||
if (isEdit) {
|
||||
return apiFetch(`/api/v1/contact-log/${existing!.id}`, {
|
||||
method: 'PATCH',
|
||||
body,
|
||||
});
|
||||
}
|
||||
return apiFetch(`/api/v1/interests/${interestId}/contact-log`, {
|
||||
method: 'POST',
|
||||
body,
|
||||
});
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['interests', interestId, 'contact-log'] });
|
||||
// Bump the parent interest cache so the "Last contact" header chip
|
||||
// updates without a refresh.
|
||||
queryClient.invalidateQueries({ queryKey: ['interests', interestId] });
|
||||
toast.success(isEdit ? 'Contact log entry updated.' : 'Contact logged.');
|
||||
onOpenChange(false);
|
||||
},
|
||||
onError: (err) => toastError(err),
|
||||
});
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{isEdit ? 'Edit contact log entry' : 'Log a contact'}</DialogTitle>
|
||||
<DialogDescription>
|
||||
Record the channel, the direction, and what was discussed. Optionally schedule a
|
||||
follow-up — a reminder will be created automatically.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-3 py-1">
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="cl-channel">Channel</Label>
|
||||
<Select value={channel} onValueChange={(v) => setChannel(v as Channel)}>
|
||||
<SelectTrigger id="cl-channel">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{(Object.keys(CHANNEL_META) as Channel[]).map((c) => (
|
||||
<SelectItem key={c} value={c}>
|
||||
{CHANNEL_META[c].label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="cl-direction">Direction</Label>
|
||||
<Select value={direction} onValueChange={(v) => setDirection(v as Direction)}>
|
||||
<SelectTrigger id="cl-direction">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="outbound">Outbound (you reached out)</SelectItem>
|
||||
<SelectItem value="inbound">Inbound (they reached out)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="cl-occurred">When did the conversation happen?</Label>
|
||||
<Input
|
||||
id="cl-occurred"
|
||||
type="datetime-local"
|
||||
value={occurredAt}
|
||||
onChange={(e) => setOccurredAt(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="cl-summary">Summary</Label>
|
||||
<Textarea
|
||||
id="cl-summary"
|
||||
placeholder="e.g. Confirmed yacht size, asked about tax structure, said they'll respond after their accountant reviews."
|
||||
rows={4}
|
||||
value={summary}
|
||||
onChange={(e) => setSummary(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="cl-followup">Follow up by (optional — creates a reminder)</Label>
|
||||
<Input
|
||||
id="cl-followup"
|
||||
type="datetime-local"
|
||||
value={followUpAt}
|
||||
onChange={(e) => setFollowUpAt(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => onOpenChange(false)}
|
||||
disabled={mutation.isPending}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => mutation.mutate()}
|
||||
disabled={mutation.isPending || summary.trim().length === 0}
|
||||
>
|
||||
{mutation.isPending ? 'Saving…' : isEdit ? 'Save changes' : 'Log contact'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert an ISO string into the `YYYY-MM-DDTHH:mm` format that
|
||||
* `<input type="datetime-local">` expects, in the user's local
|
||||
* timezone. (Browsers don't accept the trailing `Z` in this input
|
||||
* type and reject anything with a timezone offset.)
|
||||
*/
|
||||
function localIsoString(iso: string): string {
|
||||
const d = new Date(iso);
|
||||
const pad = (n: number) => String(n).padStart(2, '0');
|
||||
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
||||
}
|
||||
Reference in New Issue
Block a user