feat: Quiet Luxury design system + Pipeline (ex-Lead) redesign
- Design system foundations: Plus Jakarta Sans font, shadow-card/radius tokens in @theme, design-reference/DESIGN-SYSTEM.md with component inventory - Collapsible admin shell (AdminShell): smooth w-64<->w-20 sidebar, new top header with "Admin" + avatar placeholder, localStorage-persisted state - Rename route /admin/leads -> /admin/pipeline (redirect stubs preserved), nav label Lead -> Pipeline; DB table unchanged - Reusable primitives: StatusBadge, SearchInput, SegmentedToggle (dual-theme) - Luxury restyle of lead table, kanban (6 stages + @dnd-kit intact), PageHeader - Tokenize editable-cell / option-multi-select for dark-mode legibility Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -13,7 +13,7 @@ import {
|
||||
renameLeadTag,
|
||||
deleteTranscript,
|
||||
convertLeadToClient,
|
||||
} from "@/app/admin/leads/actions";
|
||||
} from "@/app/admin/pipeline/actions";
|
||||
import { formatDistanceToNow, format } from "date-fns";
|
||||
import { it } from "date-fns/locale";
|
||||
import Link from "next/link";
|
||||
|
||||
@@ -5,7 +5,7 @@ import { useForm } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { createLeadSchema, LEAD_STAGES, type CreateLeadInput } from "@/lib/lead-validators";
|
||||
import { Lead } from "@/db/schema";
|
||||
import { createLead, updateLead } from "@/app/admin/leads/actions";
|
||||
import { createLead, updateLead } from "@/app/admin/pipeline/actions";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
|
||||
@@ -3,28 +3,17 @@
|
||||
import Link from "next/link";
|
||||
import { useState, useRef, useEffect, useTransition } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Check } from "lucide-react";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Check, ArrowRight } from "lucide-react";
|
||||
import { EditableCell } from "@/components/ui/editable-cell";
|
||||
import { OptionMultiSelect } from "@/components/ui/option-multi-select";
|
||||
import { StatusBadge } from "@/components/ui/StatusBadge";
|
||||
import {
|
||||
updateLeadField,
|
||||
addLeadTag,
|
||||
removeLeadTag,
|
||||
renameLeadTag,
|
||||
} from "@/app/admin/leads/actions";
|
||||
} from "@/app/admin/pipeline/actions";
|
||||
import type { LeadWithTags, LeadFieldOptions } from "@/lib/admin-queries";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
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",
|
||||
};
|
||||
|
||||
const COLUMN_HEADERS = [
|
||||
"Nome",
|
||||
@@ -61,23 +50,15 @@ function StatusCell({
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className="relative w-full min-w-[120px]">
|
||||
<div ref={containerRef} className="relative w-full min-w-[120px] flex justify-center">
|
||||
<div
|
||||
onClick={() => setIsOpen((v) => !v)}
|
||||
className="flex items-center gap-1 px-2 py-1 rounded transition-colors duration-150 min-h-[28px] cursor-pointer hover:bg-[#f0f0f0]"
|
||||
className="flex items-center gap-1 px-2 py-1 rounded transition-colors duration-150 min-h-[28px] cursor-pointer hover:bg-muted"
|
||||
>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={cn(
|
||||
STAGE_COLOR[lead.status] ?? "",
|
||||
"border-transparent text-xs px-2 py-0.5 font-medium truncate"
|
||||
)}
|
||||
>
|
||||
{lead.status.replace(/_/g, " ")}
|
||||
</Badge>
|
||||
<StatusBadge status={lead.status} />
|
||||
</div>
|
||||
{isOpen && (
|
||||
<div className="absolute top-full left-0 mt-1 bg-white border border-[#e5e7eb] rounded-md shadow-md p-1.5 z-20 min-w-[180px]">
|
||||
<div className="absolute top-full left-1/2 -translate-x-1/2 mt-1 bg-popover border border-border rounded-md shadow-card-hover p-1.5 z-20 min-w-[180px]">
|
||||
<div className="flex flex-col gap-0.5">
|
||||
{options.status.map((stage) => (
|
||||
<button
|
||||
@@ -87,17 +68,12 @@ function StatusCell({
|
||||
setIsOpen(false);
|
||||
run(() => updateLeadField(lead.id, "status", stage));
|
||||
}}
|
||||
className="flex items-center gap-2 rounded px-1.5 py-1 hover:bg-[#f5f5f5] text-left"
|
||||
className="flex items-center gap-2 rounded px-1.5 py-1 hover:bg-muted text-left"
|
||||
>
|
||||
<span className="w-3.5 flex-shrink-0">
|
||||
{lead.status === stage && <Check className="h-3.5 w-3.5 text-[#1A463C]" />}
|
||||
{lead.status === stage && <Check className="h-3.5 w-3.5 text-primary" />}
|
||||
</span>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={cn(STAGE_COLOR[stage] ?? "", "border-transparent text-xs px-2 py-0.5 font-medium truncate")}
|
||||
>
|
||||
{stage.replace(/_/g, " ")}
|
||||
</Badge>
|
||||
<StatusBadge status={stage} />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
@@ -132,8 +108,8 @@ function LeadRow({
|
||||
|
||||
return (
|
||||
<>
|
||||
<tr className="border-b border-[#e5e7eb] hover:bg-[#f9f9f9] transition-colors duration-150">
|
||||
<td className="py-2 px-3 font-medium text-[#1a1a1a] min-w-[160px]">
|
||||
<tr className="hover:bg-muted/40 transition-colors duration-150">
|
||||
<td className="py-4 px-6 font-medium text-foreground min-w-[160px]">
|
||||
<EditableCell
|
||||
value={lead.name}
|
||||
type="text"
|
||||
@@ -141,7 +117,7 @@ function LeadRow({
|
||||
onSave={(v) => run(() => updateLeadField(lead.id, "name", v))}
|
||||
/>
|
||||
</td>
|
||||
<td className="py-2 px-3 min-w-[160px]">
|
||||
<td className="py-4 px-6 min-w-[160px] text-muted-foreground text-xs">
|
||||
<EditableCell
|
||||
value={lead.email ?? ""}
|
||||
type="text"
|
||||
@@ -149,7 +125,7 @@ function LeadRow({
|
||||
onSave={(v) => run(() => updateLeadField(lead.id, "email", v))}
|
||||
/>
|
||||
</td>
|
||||
<td className="py-2 px-3 min-w-[140px]">
|
||||
<td className="py-4 px-6 min-w-[140px] text-muted-foreground text-xs font-mono">
|
||||
<EditableCell
|
||||
value={lead.phone ?? ""}
|
||||
type="text"
|
||||
@@ -157,7 +133,7 @@ function LeadRow({
|
||||
onSave={(v) => run(() => updateLeadField(lead.id, "phone", v))}
|
||||
/>
|
||||
</td>
|
||||
<td className="py-2 px-3 min-w-[140px]">
|
||||
<td className="py-4 px-6 min-w-[140px]">
|
||||
<EditableCell
|
||||
value={lead.company ?? ""}
|
||||
type="text"
|
||||
@@ -165,10 +141,10 @@ function LeadRow({
|
||||
onSave={(v) => run(() => updateLeadField(lead.id, "company", v))}
|
||||
/>
|
||||
</td>
|
||||
<td className="py-2 px-3 min-w-[120px]">
|
||||
<td className="py-4 px-6 min-w-[120px] text-center">
|
||||
<StatusCell lead={lead} options={options} run={run} />
|
||||
</td>
|
||||
<td className="py-2 px-3 min-w-[180px]">
|
||||
<td className="py-4 px-6 min-w-[180px]">
|
||||
<EditableCell
|
||||
value={lead.next_action ?? ""}
|
||||
type="text"
|
||||
@@ -176,7 +152,7 @@ function LeadRow({
|
||||
onSave={(v) => run(() => updateLeadField(lead.id, "next_action", v))}
|
||||
/>
|
||||
</td>
|
||||
<td className="py-2 px-3 min-w-[180px]">
|
||||
<td className="py-4 px-6 min-w-[180px] text-center">
|
||||
<OptionMultiSelect
|
||||
values={lead.tags}
|
||||
options={options.tags}
|
||||
@@ -185,15 +161,19 @@ function LeadRow({
|
||||
onRename={(o, n) => run(() => renameLeadTag(o, n))}
|
||||
/>
|
||||
</td>
|
||||
<td className="py-2 px-3 min-w-[80px]">
|
||||
<Button variant="ghost" size="sm" asChild>
|
||||
<Link href={`/admin/leads/${lead.id}`}>Dettagli</Link>
|
||||
</Button>
|
||||
<td className="py-4 px-6 min-w-[80px] text-right">
|
||||
<Link
|
||||
href={`/admin/pipeline/${lead.id}`}
|
||||
className="text-xs font-medium text-primary hover:text-primary/80 inline-flex items-center gap-1 group transition-colors"
|
||||
>
|
||||
Dettagli
|
||||
<ArrowRight className="w-3 h-3 transform group-hover:translate-x-0.5 transition-transform" />
|
||||
</Link>
|
||||
</td>
|
||||
</tr>
|
||||
{error && (
|
||||
<tr>
|
||||
<td colSpan={COL_COUNT} className="py-1 px-3 text-xs text-red-600">
|
||||
<td colSpan={COL_COUNT} className="py-1 px-6 text-xs text-destructive">
|
||||
{error}
|
||||
</td>
|
||||
</tr>
|
||||
@@ -210,22 +190,19 @@ export function LeadTable({
|
||||
options: LeadFieldOptions;
|
||||
}) {
|
||||
return (
|
||||
<div className="bg-white rounded-xl border border-[#e5e7eb]">
|
||||
<div className="bg-card rounded-xl border border-border shadow-card overflow-hidden">
|
||||
<div className="overflow-x-auto min-h-[300px]">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-[#f9f9f9] border-b border-[#e5e7eb] sticky top-0 z-[1]">
|
||||
<tr>
|
||||
<table className="w-full text-left border-collapse">
|
||||
<thead>
|
||||
<tr className="border-b border-border bg-muted/50 text-muted-foreground text-[11px] font-semibold uppercase tracking-wider">
|
||||
{COLUMN_HEADERS.map((header) => (
|
||||
<th
|
||||
key={header}
|
||||
className="text-left py-2 px-3 font-semibold text-[#71717a]"
|
||||
>
|
||||
<th key={header} className="py-4 px-6">
|
||||
{header}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tbody className="divide-y divide-border text-sm">
|
||||
{leads.map((lead) => (
|
||||
<LeadRow key={lead.id} lead={lead} options={options} />
|
||||
))}
|
||||
@@ -234,7 +211,7 @@ export function LeadTable({
|
||||
</div>
|
||||
|
||||
{leads.length === 0 && (
|
||||
<div className="text-center py-8 text-gray-500">Nessun lead trovato</div>
|
||||
<div className="text-center py-8 text-muted-foreground">Nessun lead trovato</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
useDroppable,
|
||||
useDraggable,
|
||||
} from "@dnd-kit/core";
|
||||
import { updateLeadField } from "@/app/admin/leads/actions";
|
||||
import { updateLeadField } from "@/app/admin/pipeline/actions";
|
||||
import type { LeadWithTags } from "@/lib/admin-queries";
|
||||
|
||||
type LeadStage = "contacted" | "qualified" | "proposal_sent" | "negotiating" | "won" | "lost";
|
||||
@@ -24,12 +24,12 @@ const LEAD_COLUMNS: {
|
||||
headerClass: string;
|
||||
dotClass: string;
|
||||
}[] = [
|
||||
{ id: "contacted", label: "Contattato", headerClass: "text-[#71717a]", dotClass: "bg-[#d4d4d8]" },
|
||||
{ id: "qualified", label: "Qualificato", headerClass: "text-purple-700", dotClass: "bg-purple-400" },
|
||||
{ id: "proposal_sent", label: "Offerta inviata", headerClass: "text-amber-700", dotClass: "bg-amber-400" },
|
||||
{ id: "negotiating", label: "Trattativa", headerClass: "text-orange-700", dotClass: "bg-orange-400" },
|
||||
{ id: "won", label: "Vinto", headerClass: "text-green-700", dotClass: "bg-green-500" },
|
||||
{ id: "lost", label: "Perso", headerClass: "text-red-700", dotClass: "bg-red-400" },
|
||||
{ id: "contacted", label: "Contattato", headerClass: "text-muted-foreground", dotClass: "bg-blue-400 dark:bg-blue-500" },
|
||||
{ id: "qualified", label: "Qualificato", headerClass: "text-purple-700 dark:text-purple-400", dotClass: "bg-purple-400 dark:bg-purple-500" },
|
||||
{ id: "proposal_sent", label: "Offerta inviata", headerClass: "text-amber-700 dark:text-amber-400", dotClass: "bg-amber-400 dark:bg-amber-500" },
|
||||
{ id: "negotiating", label: "Trattativa", headerClass: "text-orange-700 dark:text-orange-400", dotClass: "bg-orange-400 dark:bg-orange-500" },
|
||||
{ id: "won", label: "Vinto", headerClass: "text-emerald-700 dark:text-emerald-400", dotClass: "bg-emerald-500" },
|
||||
{ id: "lost", label: "Perso", headerClass: "text-red-700 dark:text-red-400", dotClass: "bg-red-400 dark:bg-red-500" },
|
||||
];
|
||||
|
||||
const VALID_STAGES = LEAD_COLUMNS.map((c) => c.id) as string[];
|
||||
@@ -49,26 +49,26 @@ function DroppableColumn({
|
||||
return (
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
className={`flex flex-col rounded-xl border-2 transition-colors ${
|
||||
isOver ? "border-[#1A463C] bg-[#1A463C]/5" : "border-[#e5e7eb] bg-[#f9f9f9]"
|
||||
className={`flex flex-col rounded-xl border p-4 transition-colors ${
|
||||
isOver ? "border-primary bg-primary/5" : "border-border bg-muted/60"
|
||||
} min-h-[240px]`}
|
||||
>
|
||||
<div className={`px-4 py-3 flex items-center justify-between ${headerClass}`}>
|
||||
<div className={`flex items-center justify-between pb-2 mb-2 border-b border-border ${headerClass}`}>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`w-2 h-2 rounded-full ${dotClass}`} />
|
||||
<span className="text-xs font-bold uppercase tracking-wider">{label}</span>
|
||||
</div>
|
||||
<span className="text-xs font-semibold tabular-nums bg-white rounded-full px-2 py-0.5 border border-[#e5e7eb]">
|
||||
<span className="text-xs font-semibold tabular-nums bg-card rounded-full px-2 py-0.5 border border-border text-muted-foreground">
|
||||
{leads.length}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 p-3 space-y-2">
|
||||
<div className="flex-1 space-y-2">
|
||||
{leads.map((lead) => (
|
||||
<DraggableLeadCard key={lead.id} lead={lead} isActive={activeId === lead.id} />
|
||||
))}
|
||||
{leads.length === 0 && (
|
||||
<p className="text-xs text-[#d4d4d8] italic text-center py-10 select-none">
|
||||
<p className="text-xs text-muted-foreground italic text-center py-10 select-none">
|
||||
Nessun lead
|
||||
</p>
|
||||
)}
|
||||
@@ -90,16 +90,16 @@ function DraggableLeadCard({ lead, isActive }: { lead: LeadWithTags; isActive: b
|
||||
style={style}
|
||||
{...listeners}
|
||||
{...attributes}
|
||||
className={`bg-white rounded-lg border border-[#e5e7eb] px-3 py-2.5 cursor-grab active:cursor-grabbing shadow-sm select-none transition-opacity ${
|
||||
isDragging ? "opacity-30" : "hover:border-[#1A463C]/40 hover:shadow"
|
||||
className={`bg-card rounded-lg border border-border px-3 py-2.5 cursor-grab active:cursor-grabbing shadow-sm select-none transition-all duration-200 ${
|
||||
isDragging ? "opacity-30" : "hover:border-primary/30 hover:shadow-card-hover"
|
||||
}`}
|
||||
>
|
||||
<p className="text-sm font-medium text-[#1a1a1a]">{lead.name}</p>
|
||||
<p className="text-sm font-medium text-foreground">{lead.name}</p>
|
||||
{lead.company && (
|
||||
<p className="text-xs text-[#71717a]">{lead.company}</p>
|
||||
<p className="text-xs text-muted-foreground">{lead.company}</p>
|
||||
)}
|
||||
{lead.next_action && (
|
||||
<p className="text-xs text-[#71717a] mt-1 line-clamp-1">{lead.next_action}</p>
|
||||
<p className="text-xs text-muted-foreground mt-1 line-clamp-1">{lead.next_action}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
@@ -170,10 +170,10 @@ export function LeadsKanbanBoard({ leads }: { leads: LeadWithTags[] }) {
|
||||
|
||||
<DragOverlay dropAnimation={null}>
|
||||
{activeLead && (
|
||||
<div className="bg-white rounded-lg border-2 border-[#1A463C] px-3 py-2.5 shadow-xl rotate-1 pointer-events-none">
|
||||
<p className="text-sm font-medium text-[#1a1a1a]">{activeLead.name}</p>
|
||||
<div className="bg-card rounded-lg border-2 border-primary px-3 py-2.5 shadow-xl rotate-1 pointer-events-none">
|
||||
<p className="text-sm font-medium text-foreground">{activeLead.name}</p>
|
||||
{activeLead.company && (
|
||||
<p className="text-xs text-[#71717a]">{activeLead.company}</p>
|
||||
<p className="text-xs text-muted-foreground">{activeLead.company}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useMemo } from "react";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Search } from "lucide-react";
|
||||
import { SearchInput } from "@/components/ui/SearchInput";
|
||||
import { SegmentedToggle } from "@/components/ui/SegmentedToggle";
|
||||
import { LeadTable } from "@/components/admin/leads/LeadTable";
|
||||
import { LeadsKanbanBoard } from "@/components/admin/leads/LeadsKanbanBoard";
|
||||
import type { LeadWithTags, LeadFieldOptions } from "@/lib/admin-queries";
|
||||
@@ -32,39 +32,22 @@ export function LeadsViewToggle({
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div className="relative max-w-sm flex-1">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-[#71717a]" />
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="Cerca lead..."
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
className="pl-9 h-9"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 bg-[#f4f4f5] rounded-lg p-1 w-fit flex-shrink-0">
|
||||
<button
|
||||
onClick={() => setView("list")}
|
||||
className={`px-3 py-1.5 rounded-md text-xs font-semibold transition-all ${
|
||||
view === "list"
|
||||
? "bg-white text-[#1A463C] shadow-sm"
|
||||
: "text-[#71717a] hover:text-[#1a1a1a]"
|
||||
}`}
|
||||
>
|
||||
Lista
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setView("kanban")}
|
||||
className={`px-3 py-1.5 rounded-md text-xs font-semibold transition-all ${
|
||||
view === "kanban"
|
||||
? "bg-white text-[#1A463C] shadow-sm"
|
||||
: "text-[#71717a] hover:text-[#1a1a1a]"
|
||||
}`}
|
||||
>
|
||||
Kanban
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex flex-col md:flex-row items-start md:items-center justify-between gap-4">
|
||||
<SearchInput
|
||||
placeholder="Cerca lead..."
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
containerClassName="w-full md:w-80"
|
||||
/>
|
||||
<SegmentedToggle
|
||||
options={[
|
||||
{ value: "list", label: "Lista" },
|
||||
{ value: "kanban", label: "Kanban" },
|
||||
]}
|
||||
value={view}
|
||||
onChange={setView}
|
||||
className="self-start md:self-auto flex-shrink-0"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{view === "list" ? (
|
||||
|
||||
@@ -5,7 +5,7 @@ import { useForm } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { z } from "zod";
|
||||
import { createActivitySchema, ACTIVITY_TYPES } from "@/lib/lead-validators";
|
||||
import { logActivity } from "@/app/admin/leads/actions";
|
||||
import { logActivity } from "@/app/admin/pipeline/actions";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { z } from "zod";
|
||||
import { assignQuoteToLead } from "@/app/admin/leads/actions";
|
||||
import { assignQuoteToLead } from "@/app/admin/pipeline/actions";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { z } from "zod";
|
||||
import { addTranscript } from "@/app/admin/leads/actions";
|
||||
import { addTranscript } from "@/app/admin/pipeline/actions";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
|
||||
Reference in New Issue
Block a user