e5fa07bba3
Rewrite /admin/pipeline/[id] LeadDetail with token-based dual-theme layout: bespoke header with inline StatusBadge, asymmetric 1/3 profile + 2/3 content columns, and a unified reverse-chronological timeline merging activities and transcripts (transcript node highlighted + expandable + deletable). Restyle modal triggers (Registra Attività, Aggiungi Transcript, Modifica-as-icon) to the mock outline style. Remove the confusing "Invia Preventivo" button (it only linked a token + advanced stage, no email). Keep Converti in cliente for won leads. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
444 lines
17 KiB
TypeScript
444 lines
17 KiB
TypeScript
"use client";
|
|
|
|
import { useState, useTransition } from "react";
|
|
import { useRouter } from "next/navigation";
|
|
import { Lead, Activity, Reminder, ClientTranscript } from "@/db/schema";
|
|
import { StatusBadge } from "@/components/ui/StatusBadge";
|
|
import { OptionMultiSelect } from "@/components/ui/option-multi-select";
|
|
import {
|
|
addLeadTag,
|
|
removeLeadTag,
|
|
renameLeadTag,
|
|
deleteTranscript,
|
|
convertLeadToClient,
|
|
} from "@/app/admin/pipeline/actions";
|
|
import { formatDistanceToNow, format } from "date-fns";
|
|
import { it } from "date-fns/locale";
|
|
import Link from "next/link";
|
|
import {
|
|
Mail,
|
|
Phone,
|
|
Clock,
|
|
CalendarDays,
|
|
Calendar,
|
|
StickyNote,
|
|
Mic,
|
|
Trash2,
|
|
Sparkles,
|
|
type LucideIcon,
|
|
} from "lucide-react";
|
|
import { LogActivityModal } from "./LogActivityModal";
|
|
import { TranscriptModal } from "./TranscriptModal";
|
|
import { EditLeadModal } from "./LeadForm";
|
|
|
|
const ACTIVITY_META: Record<string, { label: string; Icon: LucideIcon }> = {
|
|
call: { label: "Chiamata effettuata", Icon: Phone },
|
|
email: { label: "E-mail inviata", Icon: Mail },
|
|
meeting: { label: "Meeting", Icon: Calendar },
|
|
note: { label: "Nota", Icon: StickyNote },
|
|
};
|
|
|
|
type TimelineItem =
|
|
| { kind: "transcript"; date: Date; data: ClientTranscript }
|
|
| { kind: "activity"; date: Date; data: Activity };
|
|
|
|
export function LeadDetail({
|
|
lead,
|
|
activities,
|
|
reminders,
|
|
tags,
|
|
tagOptions,
|
|
transcripts,
|
|
convertedClientId,
|
|
}: {
|
|
lead: Lead;
|
|
activities: Activity[];
|
|
reminders: Reminder[];
|
|
tags: string[];
|
|
tagOptions: string[];
|
|
transcripts: ClientTranscript[];
|
|
convertedClientId?: string | null;
|
|
}) {
|
|
const router = useRouter();
|
|
const [, startTransition] = useTransition();
|
|
const [converting, startConvert] = useTransition();
|
|
const [tagError, setTagError] = useState<string | null>(null);
|
|
const [expandedIds, setExpandedIds] = useState<Set<string>>(new Set());
|
|
const [deletingId, setDeletingId] = useState<string | null>(null);
|
|
|
|
function handleConvert() {
|
|
const ok = window.confirm(
|
|
`Convertire "${lead.name}" in cliente?\n\nVerrà creato un cliente (con email, telefono e transcript del lead) e il lead verrà archiviato mantenendo lo stato "vinto".`
|
|
);
|
|
if (!ok) return;
|
|
startConvert(() => convertLeadToClient(lead.id));
|
|
}
|
|
|
|
function run(fn: () => Promise<unknown>) {
|
|
setTagError(null);
|
|
startTransition(async () => {
|
|
try {
|
|
await fn();
|
|
router.refresh();
|
|
} catch (e) {
|
|
setTagError(e instanceof Error ? e.message : "Errore nel salvataggio");
|
|
}
|
|
});
|
|
}
|
|
|
|
function toggleExpand(id: string) {
|
|
setExpandedIds((prev) => {
|
|
const next = new Set(prev);
|
|
if (next.has(id)) next.delete(id);
|
|
else next.add(id);
|
|
return next;
|
|
});
|
|
}
|
|
|
|
function handleDeleteTranscript(transcriptId: string) {
|
|
setDeletingId(transcriptId);
|
|
startTransition(async () => {
|
|
try {
|
|
await deleteTranscript(transcriptId, lead.id);
|
|
router.refresh();
|
|
} catch (e) {
|
|
console.error("deleteTranscript error:", e);
|
|
} finally {
|
|
setDeletingId(null);
|
|
}
|
|
});
|
|
}
|
|
|
|
// Unified, reverse-chronological timeline of activities + transcripts.
|
|
const timeline: TimelineItem[] = [
|
|
...activities.map((a) => ({
|
|
kind: "activity" as const,
|
|
date: new Date(a.activity_date),
|
|
data: a,
|
|
})),
|
|
...transcripts.map((t) => ({
|
|
kind: "transcript" as const,
|
|
date: new Date(t.call_date + "T00:00:00"),
|
|
data: t,
|
|
})),
|
|
].sort((a, b) => b.date.getTime() - a.date.getTime());
|
|
|
|
return (
|
|
<div className="flex flex-col gap-8">
|
|
{/* Header + Actions */}
|
|
<div className="flex flex-col lg:flex-row justify-between lg:items-start gap-6 border-b border-border pb-6">
|
|
<div>
|
|
<div className="flex items-center gap-3">
|
|
<h1 className="text-2xl font-semibold tracking-tight text-foreground">
|
|
{lead.name}
|
|
</h1>
|
|
<StatusBadge status={lead.status} />
|
|
</div>
|
|
<p className="text-sm font-medium text-muted-foreground mt-1">
|
|
{lead.company || "Azienda non specificata"}
|
|
</p>
|
|
</div>
|
|
|
|
<div className="flex flex-wrap items-center gap-2.5">
|
|
<LogActivityModal leadId={lead.id} />
|
|
<TranscriptModal leadId={lead.id} />
|
|
|
|
{convertedClientId ? (
|
|
<Link
|
|
href={`/admin/clients/${convertedClientId}`}
|
|
className="inline-flex items-center gap-2 px-4 py-2.5 border border-border text-primary hover:bg-muted text-xs font-semibold rounded-lg transition-colors"
|
|
>
|
|
✓ Convertito → Apri cliente
|
|
</Link>
|
|
) : (
|
|
lead.status === "won" && (
|
|
<button
|
|
type="button"
|
|
onClick={handleConvert}
|
|
disabled={converting}
|
|
className="inline-flex items-center gap-2 px-4 py-2.5 border border-border text-muted-foreground hover:text-foreground hover:bg-muted text-xs font-semibold rounded-lg transition-colors disabled:opacity-50"
|
|
>
|
|
{converting ? "Conversione..." : "Converti in cliente"}
|
|
</button>
|
|
)
|
|
)}
|
|
|
|
{/* Primary action */}
|
|
<Link
|
|
href={`/admin/preventivi/genera?lead_id=${lead.id}`}
|
|
className="inline-flex items-center gap-2 px-5 py-2.5 bg-primary text-primary-foreground hover:bg-primary/90 text-xs font-medium tracking-wide rounded-lg shadow-sm transition-colors"
|
|
>
|
|
<Sparkles className="w-4 h-4" />
|
|
Genera preventivo
|
|
</Link>
|
|
|
|
<EditLeadModal lead={lead} />
|
|
</div>
|
|
</div>
|
|
|
|
{/* Body: asymmetric columns */}
|
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8 items-start">
|
|
{/* Left (1/3): Profile card */}
|
|
<div className="lg:col-span-1 bg-card border border-border rounded-xl p-6 shadow-card flex flex-col gap-6">
|
|
<h3 className="text-xs font-bold uppercase tracking-wider text-muted-foreground">
|
|
Profilo Lead
|
|
</h3>
|
|
|
|
<div className="space-y-4">
|
|
<ProfileRow Icon={Mail} label="Email">
|
|
{lead.email ? (
|
|
<a
|
|
href={`mailto:${lead.email}`}
|
|
className="font-semibold text-foreground hover:text-primary transition-colors"
|
|
>
|
|
{lead.email}
|
|
</a>
|
|
) : (
|
|
<span className="font-semibold text-muted-foreground">—</span>
|
|
)}
|
|
</ProfileRow>
|
|
|
|
<ProfileRow Icon={Phone} label="Telefono">
|
|
<span className="font-semibold text-foreground font-mono">
|
|
{lead.phone || "—"}
|
|
</span>
|
|
</ProfileRow>
|
|
|
|
<ProfileRow Icon={Clock} label="Ultimo contatto">
|
|
<span className="font-semibold text-foreground">
|
|
{lead.last_contact_date
|
|
? formatDistanceToNow(new Date(lead.last_contact_date), {
|
|
addSuffix: true,
|
|
locale: it,
|
|
})
|
|
: "—"}
|
|
</span>
|
|
</ProfileRow>
|
|
|
|
<ProfileRow Icon={CalendarDays} label="Prossima azione programmata">
|
|
<span
|
|
className={
|
|
lead.next_action
|
|
? "font-semibold text-foreground"
|
|
: "font-semibold text-muted-foreground"
|
|
}
|
|
>
|
|
{lead.next_action || "—"}
|
|
</span>
|
|
</ProfileRow>
|
|
</div>
|
|
|
|
{/* Tags */}
|
|
<div className="border-t border-border pt-5">
|
|
<p className="text-[10px] uppercase font-bold text-muted-foreground tracking-wider mb-2.5">
|
|
Tag Lead
|
|
</p>
|
|
<OptionMultiSelect
|
|
values={tags}
|
|
options={tagOptions}
|
|
onAdd={(v) => run(() => addLeadTag(lead.id, v))}
|
|
onRemove={(v) => run(() => removeLeadTag(lead.id, v))}
|
|
onRename={(o, n) => run(() => renameLeadTag(o, n))}
|
|
/>
|
|
{tagError && <p className="text-xs text-destructive mt-1">{tagError}</p>}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Right (2/3): Note, Follow-up, Timeline */}
|
|
<div className="lg:col-span-2 flex flex-col gap-6">
|
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
|
{/* Note */}
|
|
<div className="bg-card border border-border rounded-xl p-5 shadow-card">
|
|
<h3 className="text-xs font-bold uppercase tracking-wider text-muted-foreground mb-3">
|
|
Note
|
|
</h3>
|
|
{lead.notes ? (
|
|
<div className="text-xs text-foreground/80 leading-relaxed min-h-[80px] p-2.5 bg-muted/50 rounded-lg border border-border">
|
|
{lead.notes}
|
|
</div>
|
|
) : (
|
|
<div className="flex items-center justify-center min-h-[80px] text-[11px] font-medium text-muted-foreground p-2.5 bg-muted/50 rounded-lg border border-border">
|
|
Nessuna nota
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Next follow-up */}
|
|
<div className="bg-card border border-border rounded-xl p-5 shadow-card">
|
|
<h3 className="text-xs font-bold uppercase tracking-wider text-muted-foreground mb-3">
|
|
Prossimo Follow-up
|
|
</h3>
|
|
{reminders.length > 0 ? (
|
|
<ul className="space-y-2.5">
|
|
{reminders.map((r) => (
|
|
<li
|
|
key={r.id}
|
|
className="border-l-2 border-primary/40 pl-2.5 text-xs"
|
|
>
|
|
<p className="font-semibold text-foreground">{r.title}</p>
|
|
<p className="text-muted-foreground font-mono text-[11px] mt-0.5">
|
|
{format(new Date(r.due_date), "dd MMM yyyy", { locale: it })}
|
|
</p>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
) : (
|
|
<div className="flex flex-col items-center justify-center min-h-[80px] border border-dashed border-border rounded-lg text-muted-foreground p-3">
|
|
<Clock className="w-5 h-5 mb-1.5 text-muted-foreground/60" />
|
|
<span className="text-[11px] font-medium">
|
|
Nessun reminder impostato
|
|
</span>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Unified timeline */}
|
|
<div className="bg-card border border-border rounded-xl p-6 shadow-card">
|
|
<h3 className="text-xs font-bold uppercase tracking-wider text-muted-foreground mb-6">
|
|
Storico delle Interazioni
|
|
</h3>
|
|
|
|
{timeline.length > 0 ? (
|
|
<div className="relative space-y-6">
|
|
{/* vertical connector line */}
|
|
<div className="absolute top-2 bottom-2 left-[19px] w-px bg-border z-0" />
|
|
|
|
{timeline.map((item) => {
|
|
if (item.kind === "transcript") {
|
|
const t = item.data;
|
|
const isExpanded = expandedIds.has(t.id);
|
|
const isLong =
|
|
t.content.length > 140 || t.content.split("\n").length > 2;
|
|
return (
|
|
<div
|
|
key={`t-${t.id}`}
|
|
className="relative z-10 pl-10 flex gap-4 items-start group"
|
|
>
|
|
<div className="absolute left-0 w-10 h-10 rounded-full bg-primary text-primary-foreground flex items-center justify-center shadow">
|
|
<Mic className="w-4 h-4" />
|
|
</div>
|
|
<div className="flex-1 bg-muted border border-border rounded-xl p-4 hover:border-muted-foreground/30 transition-colors">
|
|
<div className="flex flex-wrap justify-between items-start gap-2 mb-2">
|
|
<div>
|
|
<span className="text-[10px] font-bold text-primary uppercase tracking-wide">
|
|
Transcript Chiamata AI
|
|
</span>
|
|
{t.title && (
|
|
<h4 className="text-xs font-bold text-foreground">
|
|
{t.title}
|
|
</h4>
|
|
)}
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<span className="text-[10px] text-muted-foreground font-mono">
|
|
{format(new Date(t.call_date + "T00:00:00"), "d MMMM yyyy", {
|
|
locale: it,
|
|
})}
|
|
</span>
|
|
<button
|
|
type="button"
|
|
onClick={() => handleDeleteTranscript(t.id)}
|
|
disabled={deletingId === t.id}
|
|
aria-label="Elimina transcript"
|
|
className="opacity-0 group-hover:opacity-100 text-muted-foreground hover:text-destructive transition-all disabled:opacity-50"
|
|
>
|
|
<Trash2 className="w-3.5 h-3.5" />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<div className="text-xs text-foreground/80 leading-relaxed pt-2 border-t border-border/60">
|
|
<p
|
|
className={
|
|
isExpanded ? "whitespace-pre-wrap" : "line-clamp-2"
|
|
}
|
|
>
|
|
{t.content}
|
|
</p>
|
|
{isLong && (
|
|
<button
|
|
type="button"
|
|
onClick={() => toggleExpand(t.id)}
|
|
className="text-[11px] font-bold text-primary hover:text-primary/80 mt-2 inline-flex items-center gap-1"
|
|
>
|
|
{isExpanded ? "Mostra meno" : "Mostra tutto"}
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const a = item.data;
|
|
const meta = ACTIVITY_META[a.type] ?? ACTIVITY_META.note;
|
|
const Icon = meta.Icon;
|
|
return (
|
|
<div
|
|
key={`a-${a.id}`}
|
|
className="relative z-10 pl-10 flex gap-4 items-start"
|
|
>
|
|
<div className="absolute left-0 w-10 h-10 rounded-full bg-muted text-muted-foreground border border-border flex items-center justify-center">
|
|
<Icon className="w-4 h-4" />
|
|
</div>
|
|
<div className="flex-1 border border-border rounded-xl p-4 hover:border-muted-foreground/30 transition-colors">
|
|
<div className="flex justify-between items-center gap-2 mb-1.5">
|
|
<span className="text-[10px] font-bold text-muted-foreground uppercase tracking-wide">
|
|
{meta.label}
|
|
</span>
|
|
<span className="text-[10px] text-muted-foreground font-mono">
|
|
{formatDistanceToNow(new Date(a.activity_date), {
|
|
addSuffix: true,
|
|
locale: it,
|
|
})}
|
|
</span>
|
|
</div>
|
|
{a.notes && (
|
|
<p className="text-xs text-muted-foreground leading-relaxed">
|
|
{a.notes}
|
|
</p>
|
|
)}
|
|
{a.duration_minutes && (
|
|
<p className="text-[11px] text-muted-foreground/70 font-mono mt-1.5">
|
|
Durata: {a.duration_minutes} min
|
|
</p>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
) : (
|
|
<p className="text-sm text-muted-foreground text-center py-6">
|
|
Nessuna interazione registrata
|
|
</p>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function ProfileRow({
|
|
Icon,
|
|
label,
|
|
children,
|
|
}: {
|
|
Icon: LucideIcon;
|
|
label: string;
|
|
children: React.ReactNode;
|
|
}) {
|
|
return (
|
|
<div className="flex items-center gap-3.5 text-xs">
|
|
<div className="p-2 bg-muted text-muted-foreground rounded-lg shrink-0">
|
|
<Icon className="w-4 h-4" />
|
|
</div>
|
|
<div className="min-w-0">
|
|
<p className="text-[10px] text-muted-foreground font-medium">{label}</p>
|
|
{children}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|