Files
pn-new-crm/src/components/interests/interest-contact-log-tab.tsx

436 lines
15 KiB
TypeScript
Raw Normal View History

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>
2026-05-07 20:59:28 +02:00
'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 &ldquo;Last contact&rdquo;
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())}`;
}