feat: Lead Details redesign to Quiet Luxury + unified interaction timeline

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>
This commit is contained in:
2026-07-11 11:33:18 +02:00
parent c110689b6a
commit e5fa07bba3
6 changed files with 691 additions and 208 deletions
+306 -201
View File
@@ -3,9 +3,7 @@
import { useState, useTransition } from "react";
import { useRouter } from "next/navigation";
import { Lead, Activity, Reminder, ClientTranscript } from "@/db/schema";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { StatusBadge } from "@/components/ui/StatusBadge";
import { OptionMultiSelect } from "@/components/ui/option-multi-select";
import {
addLeadTag,
@@ -17,26 +15,32 @@ import {
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 { SendQuoteModal } from "./SendQuoteModal";
import { EditLeadModal } from "./LeadForm";
import { TranscriptModal } from "./TranscriptModal";
import { EditLeadModal } from "./LeadForm";
const ACTIVITY_ICON: Record<string, string> = {
call: "📞",
email: "📧",
meeting: "📅",
note: "📝",
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 },
};
const STAGE_COLOR: Record<string, string> = {
contacted: "bg-blue-100 text-blue-800",
qualified: "bg-purple-100 text-purple-800",
proposal_sent: "bg-amber-100 text-amber-800",
negotiating: "bg-orange-100 text-orange-800",
won: "bg-green-100 text-green-800",
lost: "bg-red-100 text-red-800",
};
type TimelineItem =
| { kind: "transcript"; date: Date; data: ClientTranscript }
| { kind: "activity"; date: Date; data: Activity };
export function LeadDetail({
lead,
@@ -105,28 +109,44 @@ export function LeadDetail({
});
}
// 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="space-y-6">
<div className="flex flex-col gap-8">
{/* Header + Actions */}
<div className="flex justify-between items-start">
<div className="flex flex-col lg:flex-row justify-between lg:items-start gap-6 border-b border-border pb-6">
<div>
<h1 className="text-3xl font-bold">{lead.name}</h1>
<p className="text-gray-600">{lead.company || "Azienda non specificata"}</p>
<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 gap-2">
<div className="flex flex-wrap items-center gap-2.5">
<LogActivityModal leadId={lead.id} />
<TranscriptModal leadId={lead.id} />
<SendQuoteModal leadId={lead.id} />
<Link
href={`/admin/preventivi/genera?lead_id=${lead.id}`}
className="inline-flex items-center gap-1.5 px-3 py-2 bg-[#DEF168] text-[#1A463C] rounded-md text-sm font-semibold hover:bg-[#d4e85e] transition-colors"
>
Genera preventivo
</Link>
{convertedClientId ? (
<Link
href={`/admin/clients/${convertedClientId}`}
className="inline-flex items-center gap-1.5 px-3 py-2 bg-[#1A463C]/10 text-[#1A463C] rounded-md text-sm font-semibold hover:bg-[#1A463C]/15 transition-colors"
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>
@@ -136,203 +156,288 @@ export function LeadDetail({
type="button"
onClick={handleConvert}
disabled={converting}
className="inline-flex items-center gap-1.5 px-3 py-2 bg-[#1A463C] text-white rounded-md text-sm font-semibold hover:bg-[#1A463C]/90 transition-colors disabled:opacity-50"
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>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
{/* Lead Profile Card */}
<Card>
<CardHeader>
<CardTitle>Profilo</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div>
<label className="text-sm text-gray-600">Stato</label>
<Badge className={STAGE_COLOR[lead.status as keyof typeof STAGE_COLOR] || ""}>
{lead.status.replace(/_/g, " ")}
</Badge>
</div>
<div>
<label className="text-sm text-gray-600">Tag</label>
<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-red-600 mt-1">{tagError}</p>}
</div>
<div>
<label className="text-sm text-gray-600">Email</label>
<p>{lead.email || "—"}</p>
</div>
<div>
<label className="text-sm text-gray-600">Telefono</label>
<p>{lead.phone || "—"}</p>
</div>
<div>
<label className="text-sm text-gray-600">Ultimo contatto</label>
<p>
{/* 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,
})
: "—"}
</p>
</div>
<div>
<label className="text-sm text-gray-600">Prossima azione</label>
<p className="font-medium">{lead.next_action || "—"}</p>
</div>
</CardContent>
</Card>
</span>
</ProfileRow>
{/* Upcoming Reminders Card */}
<Card>
<CardHeader>
<CardTitle>Prossimi Follow-up</CardTitle>
</CardHeader>
<CardContent>
{reminders.length > 0 ? (
<ul className="space-y-2">
{reminders.map((r) => (
<li key={r.id} className="text-sm border-l-2 border-blue-300 pl-2">
<p className="font-medium">{r.title}</p>
<p className="text-gray-600">
{format(new Date(r.due_date), "dd MMM yyyy", { locale: it })}
</p>
</li>
))}
</ul>
) : (
<p className="text-gray-500 text-sm">Nessun reminder</p>
)}
</CardContent>
</Card>
<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>
{/* Notes Card */}
<Card>
<CardHeader>
<CardTitle>Note</CardTitle>
</CardHeader>
<CardContent>
<p className="text-sm">{lead.notes || "Nessuna nota"}</p>
</CardContent>
</Card>
</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>
{/* Activity Log */}
<Card>
<CardHeader>
<CardTitle>Storico Attività</CardTitle>
</CardHeader>
<CardContent>
{activities.length > 0 ? (
<div className="space-y-4">
{activities.map((activity) => (
<div key={activity.id} className="border-l-4 border-blue-300 pl-4 pb-4">
<div className="flex items-center gap-2">
<span className="text-xl">
{ACTIVITY_ICON[activity.type as keyof typeof ACTIVITY_ICON] || "📝"}
</span>
<span className="font-medium capitalize">{activity.type}</span>
<span className="text-gray-600 text-sm">
{formatDistanceToNow(new Date(activity.activity_date), {
addSuffix: true,
locale: it,
})}
</span>
</div>
<p className="text-sm mt-2">{activity.notes}</p>
{activity.duration_minutes && (
<p className="text-xs text-gray-500 mt-1">
Durata: {activity.duration_minutes} minuti
</p>
)}
{/* 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>
) : (
<p className="text-gray-500 text-sm">Nessuna attività registrata</p>
)}
</CardContent>
</Card>
{/* Transcript Call */}
<Card>
<CardHeader className="flex flex-row items-center justify-between">
<CardTitle>Transcript Call</CardTitle>
<span className="text-sm text-gray-500">
{transcripts.length} {transcripts.length === 1 ? "transcript" : "transcript"}
</span>
</CardHeader>
<CardContent>
{transcripts.length > 0 ? (
<div className="space-y-4">
{transcripts.map((t) => {
const isExpanded = expandedIds.has(t.id);
const formattedDate = format(
new Date(t.call_date + "T00:00:00"),
"d MMMM yyyy",
{ locale: it }
);
const lines = t.content.split("\n");
const preview = lines.slice(0, 3).join("\n").slice(0, 200);
const hasMore = t.content.length > preview.length || lines.length > 3;
{/* 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>
return (
<div key={t.id} className="border-l-4 border-purple-300 pl-4 pb-4">
<div className="flex items-start justify-between gap-2">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<span className="font-medium text-sm">{formattedDate}</span>
{t.title && (
<span className="text-gray-600 text-sm"> {t.title}</span>
)}
{/* 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="mt-2 text-sm text-gray-700 whitespace-pre-wrap">
{isExpanded ? t.content : preview}
{!isExpanded && hasMore && (
<span className="text-gray-400">...</span>
)}
<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>
{hasMore && (
<button
onClick={() => toggleExpand(t.id)}
className="text-xs text-blue-600 hover:underline mt-1"
>
{isExpanded ? "Mostra meno" : "Mostra tutto"}
</button>
</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>
<Button
variant="ghost"
size="sm"
className="text-red-600 hover:text-red-700 shrink-0"
disabled={deletingId === t.id}
onClick={() => handleDeleteTranscript(t.id)}
>
{deletingId === t.id ? "..." : "Elimina"}
</Button>
</div>
</div>
);
})}
</div>
) : (
<p className="text-gray-500 text-sm">Nessun transcript registrato</p>
)}
</CardContent>
</Card>
);
})}
</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>
);
}
+9 -3
View File
@@ -31,6 +31,7 @@ import {
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { Button } from "@/components/ui/button";
import { Pencil } from "lucide-react";
export function CreateLeadModal() {
const [open, setOpen] = useState(false);
@@ -228,9 +229,14 @@ export function EditLeadModal({ lead }: { lead: Lead }) {
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button variant="outline" size="sm">
Modifica
</Button>
<button
type="button"
aria-label="Modifica lead"
title="Modifica"
className="inline-flex items-center justify-center p-2.5 border border-border text-muted-foreground hover:text-foreground hover:bg-muted rounded-lg transition-colors"
>
<Pencil className="w-4 h-4" />
</button>
</DialogTrigger>
<DialogContent className="max-w-md">
<DialogHeader>
@@ -31,6 +31,7 @@ import {
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { Button } from "@/components/ui/button";
import { Activity as ActivityIcon } from "lucide-react";
type LogActivityInput = z.infer<typeof createActivitySchema>;
@@ -71,9 +72,13 @@ export function LogActivityModal({ leadId }: { leadId: string }) {
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button variant="outline" size="sm">
<button
type="button"
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"
>
<ActivityIcon className="w-4 h-4 text-muted-foreground/70" />
Registra Attività
</Button>
</button>
</DialogTrigger>
<DialogContent className="max-w-sm">
<DialogHeader>
@@ -23,6 +23,7 @@ import {
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { Button } from "@/components/ui/button";
import { Mic } from "lucide-react";
const transcriptSchema = z.object({
lead_id: z.string().min(1),
@@ -72,9 +73,13 @@ export function TranscriptModal({ leadId }: { leadId: string }) {
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button variant="outline" size="sm">
<button
type="button"
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"
>
<Mic className="w-4 h-4 text-muted-foreground/70" />
Aggiungi Transcript
</Button>
</button>
</DialogTrigger>
<DialogContent className="max-w-2xl">
<DialogHeader>