From e5fa07bba393e2068f8d304bd7107b88f2d5e04f Mon Sep 17 00:00:00 2001 From: Simone Cavalli Date: Sat, 11 Jul 2026 11:33:18 +0200 Subject: [PATCH] feat: Lead Details redesign to Quiet Luxury + unified interaction timeline MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- design-reference/DESIGN-SYSTEM.md | 9 + design-reference/pagina-lead-details | 353 ++++++++++++ src/components/admin/leads/LeadDetail.tsx | 507 +++++++++++------- src/components/admin/leads/LeadForm.tsx | 12 +- .../admin/leads/LogActivityModal.tsx | 9 +- .../admin/leads/TranscriptModal.tsx | 9 +- 6 files changed, 691 insertions(+), 208 deletions(-) create mode 100644 design-reference/pagina-lead-details diff --git a/design-reference/DESIGN-SYSTEM.md b/design-reference/DESIGN-SYSTEM.md index 00a0d8e..08a59bc 100644 --- a/design-reference/DESIGN-SYSTEM.md +++ b/design-reference/DESIGN-SYSTEM.md @@ -147,6 +147,15 @@ across all future admin pages. | `LeadsKanbanBoard` | `src/components/admin/leads/LeadsKanbanBoard.tsx` | Kanban restyle: `bg-muted/60` columns with count pills, `bg-card` cards with `hover:border-primary/30`. Uses `@dnd-kit`, keeps all 6 lead stages. | | `LeadsViewToggle` | `src/components/admin/leads/LeadsViewToggle.tsx` | Wires `SearchInput` + `SegmentedToggle` together with the table/kanban views. | | `MilestoneStepper` | `src/components/client/MilestoneStepper.tsx` | Client-portal horizontal milestone stepper: one node per real phase, connecting line filled by `global_progress_pct`. Done = filled `bg-primary` ✓, active = `border-primary` outline, upcoming = `bg-muted`. | +| `LeadDetail` | `src/components/admin/leads/LeadDetail.tsx` | Lead detail page (`/admin/pipeline/[id]`): bespoke header (name + inline `StatusBadge` + action bar), asymmetric `lg:grid-cols-3` layout (1/3 profile card w/ icon-chip rows + tags, 2/3 Note/Follow-up + unified timeline). | + +**Unified timeline pattern**: activities + transcripts merged into one +reverse-chronological "Storico delle Interazioni". Vertical connector = absolute +`w-px bg-border` at `left-[19px]` behind `w-10 h-10 rounded-full` nodes. +Transcript node is highlighted (`bg-primary text-primary-foreground`) and +expandable (`line-clamp-2` → "Mostra tutto/meno"); activity nodes are muted +(`bg-muted border-border`). Dates are `font-mono` — activities relative +(`formatDistanceToNow`), transcripts absolute (`d MMMM yyyy`). ### Client Portal restyle notes diff --git a/design-reference/pagina-lead-details b/design-reference/pagina-lead-details new file mode 100644 index 0000000..7d9e9de --- /dev/null +++ b/design-reference/pagina-lead-details @@ -0,0 +1,353 @@ + + + + + + Dettagli Lead — Rossi Inc + + + + + + + + + + + + + + + +
+ + +
+
+ + + Area di Lavoro +
+
+ Demo Account +
+
+ + +
+ + +
+
+
+

Nome Cognome

+ Qualified +
+

Azienda Srl

+
+ + +
+ + + + + + +
+
+ + +
+ + +
+

Profilo Lead

+ +
+ +
+
+ +
+
+

Email

+ test@gmail.com +
+
+ + +
+
+ +
+
+

Telefono

+ +39 444 3322111 +
+
+ + +
+
+ +
+
+

Ultimo Contatto

+ 23 giorni fa +
+
+ + +
+
+ +
+
+

Prossima Azione programmata

+ +
+
+
+ + +
+

Tag Lead

+
+ B2B + Design + +
+
+
+ + +
+ + +
+ + +
+

Note

+
+ Brand di fashion design di lusso milanese interessato ad una ristrutturazione completa del canale ecommerce. +
+
+ + +
+

Prossimo Follow-up

+
+ + Nessun reminder impostato +
+
+ +
+ + +
+

Storico delle Interazioni

+ + +
+ + +
+
+ +
+
+
+
+ Transcript Chiamata AI +

Discovery Call - Progetto "SaaS Networker"

+
+ 26 Giugno 2026 +
+ +
+

1. Apertura e Contesto Strategico

+

"Tu: Alessandro, grazie per la disponibilità. Sappiamo che hai 3 mesi per lanciare questo SaaS dedicato al Network Marketing. Il tempo è poco, quindi dobbiamo essere precisi. L'integrazione con i sistemi di pagamento e la sitemap sono la nostra prima priorità per..."

+ + +
+
+
+ + +
+
+ +
+
+
+ E-mail inviata + 23 giorni fa +
+

Mail di onboarding

+

Inviato pacchetto di benvenuto e richiesta accessi per iniziare l'analisi competitiva del brand.

+
+
+ + +
+
+ +
+
+
+ Chiamata effettuata + 25 giorni fa +
+

Telefonata di follow-up

+

Chiamata conoscitiva di test per allineamento sulle tempistiche di consegna.

+
+
+ +
+
+ +
+ +
+ +
+
+ + + + + \ No newline at end of file diff --git a/src/components/admin/leads/LeadDetail.tsx b/src/components/admin/leads/LeadDetail.tsx index 3a32d16..d1eff78 100644 --- a/src/components/admin/leads/LeadDetail.tsx +++ b/src/components/admin/leads/LeadDetail.tsx @@ -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 = { - call: "📞", - email: "📧", - meeting: "📅", - note: "📝", +const ACTIVITY_META: Record = { + 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 = { - 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 ( -
+
{/* Header + Actions */} -
+
-

{lead.name}

-

{lead.company || "Azienda non specificata"}

+
+

+ {lead.name} +

+ +
+

+ {lead.company || "Azienda non specificata"} +

-
+ +
- - - Genera preventivo - + {convertedClientId ? ( ✓ Convertito → Apri cliente @@ -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"} ) )} + + {/* Primary action */} + + + Genera preventivo + +
-
- {/* Lead Profile Card */} - - - Profilo - - -
- - - {lead.status.replace(/_/g, " ")} - -
-
- - run(() => addLeadTag(lead.id, v))} - onRemove={(v) => run(() => removeLeadTag(lead.id, v))} - onRename={(o, n) => run(() => renameLeadTag(o, n))} - /> - {tagError &&

{tagError}

} -
-
- -

{lead.email || "—"}

-
-
- -

{lead.phone || "—"}

-
-
- -

+ {/* Body: asymmetric columns */} +

+ {/* Left (1/3): Profile card */} +
+

+ Profilo Lead +

+ +
+ + {lead.email ? ( + + {lead.email} + + ) : ( + + )} + + + + + {lead.phone || "—"} + + + + + {lead.last_contact_date ? formatDistanceToNow(new Date(lead.last_contact_date), { addSuffix: true, locale: it, }) : "—"} -

-
-
- -

{lead.next_action || "—"}

-
- - + + - {/* Upcoming Reminders Card */} - - - Prossimi Follow-up - - - {reminders.length > 0 ? ( -
    - {reminders.map((r) => ( -
  • -

    {r.title}

    -

    - {format(new Date(r.due_date), "dd MMM yyyy", { locale: it })} -

    -
  • - ))} -
- ) : ( -

Nessun reminder

- )} -
-
+ + + {lead.next_action || "—"} + + +
- {/* Notes Card */} - - - Note - - -

{lead.notes || "Nessuna nota"}

-
-
-
+ {/* Tags */} +
+

+ Tag Lead +

+ run(() => addLeadTag(lead.id, v))} + onRemove={(v) => run(() => removeLeadTag(lead.id, v))} + onRename={(o, n) => run(() => renameLeadTag(o, n))} + /> + {tagError &&

{tagError}

} +
+
- {/* Activity Log */} - - - Storico Attività - - - {activities.length > 0 ? ( -
- {activities.map((activity) => ( -
-
- - {ACTIVITY_ICON[activity.type as keyof typeof ACTIVITY_ICON] || "📝"} - - {activity.type} - - {formatDistanceToNow(new Date(activity.activity_date), { - addSuffix: true, - locale: it, - })} - -
-

{activity.notes}

- {activity.duration_minutes && ( -

- Durata: {activity.duration_minutes} minuti -

- )} + {/* Right (2/3): Note, Follow-up, Timeline */} +
+
+ {/* Note */} +
+

+ Note +

+ {lead.notes ? ( +
+ {lead.notes}
- ))} + ) : ( +
+ Nessuna nota +
+ )}
- ) : ( -

Nessuna attività registrata

- )} - - - {/* Transcript Call */} - - - Transcript Call - - {transcripts.length} {transcripts.length === 1 ? "transcript" : "transcript"} - - - - {transcripts.length > 0 ? ( -
- {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 */} +
+

+ Prossimo Follow-up +

+ {reminders.length > 0 ? ( +
    + {reminders.map((r) => ( +
  • +

    {r.title}

    +

    + {format(new Date(r.due_date), "dd MMM yyyy", { locale: it })} +

    +
  • + ))} +
+ ) : ( +
+ + + Nessun reminder impostato + +
+ )} +
+
- return ( -
-
-
-
- {formattedDate} - {t.title && ( - — {t.title} - )} + {/* Unified timeline */} +
+

+ Storico delle Interazioni +

+ + {timeline.length > 0 ? ( +
+ {/* vertical connector line */} +
+ + {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 ( +
+
+
-
- {isExpanded ? t.content : preview} - {!isExpanded && hasMore && ( - ... - )} +
+
+
+ + Transcript Chiamata AI + + {t.title && ( +

+ {t.title} +

+ )} +
+
+ + {format(new Date(t.call_date + "T00:00:00"), "d MMMM yyyy", { + locale: it, + })} + + +
+
+
+

+ {t.content} +

+ {isLong && ( + + )} +
- {hasMore && ( - +
+ ); + } + + const a = item.data; + const meta = ACTIVITY_META[a.type] ?? ACTIVITY_META.note; + const Icon = meta.Icon; + return ( +
+
+ +
+
+
+ + {meta.label} + + + {formatDistanceToNow(new Date(a.activity_date), { + addSuffix: true, + locale: it, + })} + +
+ {a.notes && ( +

+ {a.notes} +

+ )} + {a.duration_minutes && ( +

+ Durata: {a.duration_minutes} min +

)}
-
-
- ); - })} -
- ) : ( -

Nessun transcript registrato

- )} - - + ); + })} +
+ ) : ( +

+ Nessuna interazione registrata +

+ )} +
+
+
+
+ ); +} + +function ProfileRow({ + Icon, + label, + children, +}: { + Icon: LucideIcon; + label: string; + children: React.ReactNode; +}) { + return ( +
+
+ +
+
+

{label}

+ {children} +
); } diff --git a/src/components/admin/leads/LeadForm.tsx b/src/components/admin/leads/LeadForm.tsx index 70ace9f..0f20861 100644 --- a/src/components/admin/leads/LeadForm.tsx +++ b/src/components/admin/leads/LeadForm.tsx @@ -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 ( - + diff --git a/src/components/admin/leads/LogActivityModal.tsx b/src/components/admin/leads/LogActivityModal.tsx index 18ee674..84990c0 100644 --- a/src/components/admin/leads/LogActivityModal.tsx +++ b/src/components/admin/leads/LogActivityModal.tsx @@ -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; @@ -71,9 +72,13 @@ export function LogActivityModal({ leadId }: { leadId: string }) { return ( - + diff --git a/src/components/admin/leads/TranscriptModal.tsx b/src/components/admin/leads/TranscriptModal.tsx index 71cd9c8..6623807 100644 --- a/src/components/admin/leads/TranscriptModal.tsx +++ b/src/components/admin/leads/TranscriptModal.tsx @@ -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 ( - +