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:
2026-07-10 16:16:44 +02:00
parent 43cb7e7469
commit 86e1499e8f
34 changed files with 3134 additions and 231 deletions
+5 -9
View File
@@ -1,4 +1,4 @@
import { AdminSidebar } from "@/components/admin/AdminSidebar";
import { AdminShell } from "@/components/admin/AdminShell";
import { getServerSession } from "next-auth";
import { authOptions } from "@/lib/auth";
@@ -8,12 +8,8 @@ export default async function AdminLayout({
children: React.ReactNode;
}) {
const session = await getServerSession(authOptions);
return (
<div className="flex min-h-screen bg-gray-50">
{session && <AdminSidebar />}
<main className="flex-1 px-8 py-8 overflow-y-auto">
{children}
</main>
</div>
);
if (!session) {
return <div className="min-h-screen bg-background">{children}</div>;
}
return <AdminShell>{children}</AdminShell>;
}
+9 -37
View File
@@ -1,40 +1,12 @@
import { notFound } from "next/navigation";
import { getActivityLog, getUpcomingReminders, getTranscripts } from "@/lib/lead-service";
import {
getLeadByIdWithTags,
getLeadFieldOptions,
getClientIdFromLead,
} from "@/lib/admin-queries";
import { LeadDetail } from "@/components/admin/leads/LeadDetail";
import { redirect } from "next/navigation";
export default async function LeadDetailPage({ params }: { params: Promise<{ id: string }> }) {
// Legacy route — Lead was renamed to Pipeline. Kept as a redirect stub so
// existing links/bookmarks to /admin/leads/<id> don't break.
export default async function LeadDetailRedirectPage({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { id } = await params;
const [lead, options, convertedClientId] = await Promise.all([
getLeadByIdWithTags(id),
getLeadFieldOptions(),
getClientIdFromLead(id),
]);
if (!lead) {
notFound();
}
const [activities, reminders, transcripts] = await Promise.all([
getActivityLog(id),
getUpcomingReminders(id),
getTranscripts(id),
]);
return (
<LeadDetail
lead={lead}
activities={activities}
reminders={reminders}
tags={lead.tags}
tagOptions={options.tags}
transcripts={transcripts}
convertedClientId={convertedClientId}
/>
);
redirect(`/admin/pipeline/${id}`);
}
+5 -18
View File
@@ -1,20 +1,7 @@
import { getLeadsWithTags, getLeadFieldOptions } from "@/lib/admin-queries";
import { LeadsViewToggle } from "@/components/admin/leads/LeadsViewToggle";
import { CreateLeadModal } from "@/components/admin/leads/LeadForm";
import { PageHeader } from "@/components/admin/PageHeader";
import { redirect } from "next/navigation";
export const revalidate = 0;
export default async function LeadsPage() {
const [leads, options] = await Promise.all([
getLeadsWithTags(),
getLeadFieldOptions(),
]);
return (
<div className="space-y-6">
<PageHeader title="Lead Pipeline" action={<CreateLeadModal />} />
<LeadsViewToggle leads={leads} options={options} />
</div>
);
// Legacy route — Lead was renamed to Pipeline. Kept as a redirect stub so
// existing links/bookmarks to /admin/leads don't break.
export default function LeadsRedirectPage() {
redirect("/admin/pipeline");
}
+40
View File
@@ -0,0 +1,40 @@
import { notFound } from "next/navigation";
import { getActivityLog, getUpcomingReminders, getTranscripts } from "@/lib/lead-service";
import {
getLeadByIdWithTags,
getLeadFieldOptions,
getClientIdFromLead,
} from "@/lib/admin-queries";
import { LeadDetail } from "@/components/admin/leads/LeadDetail";
export default async function LeadDetailPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
const [lead, options, convertedClientId] = await Promise.all([
getLeadByIdWithTags(id),
getLeadFieldOptions(),
getClientIdFromLead(id),
]);
if (!lead) {
notFound();
}
const [activities, reminders, transcripts] = await Promise.all([
getActivityLog(id),
getUpcomingReminders(id),
getTranscripts(id),
]);
return (
<LeadDetail
lead={lead}
activities={activities}
reminders={reminders}
tags={lead.tags}
tagOptions={options.tags}
transcripts={transcripts}
convertedClientId={convertedClientId}
/>
);
}
@@ -62,7 +62,7 @@ export async function convertLeadToClient(leadId: string): Promise<void> {
// Archive the lead but keep its "won" status (traceability without clutter).
await db.update(leads).set({ archived: true }).where(eq(leads.id, leadId));
revalidatePath("/admin/leads");
revalidatePath("/admin/pipeline");
revalidatePath("/admin");
redirect(`/admin/clients/${clientId}`);
}
@@ -87,7 +87,7 @@ export async function createLead(data: z.infer<typeof createLeadSchema>) {
})
.returning();
revalidatePath("/admin/leads");
revalidatePath("/admin/pipeline");
return { success: true, lead };
} catch (error) {
console.error("createLead error:", error);
@@ -118,8 +118,8 @@ export async function updateLead(id: string, data: z.infer<typeof updateLeadSche
.where(eq(leads.id, id))
.returning();
revalidatePath("/admin/leads");
revalidatePath(`/admin/leads/${id}`);
revalidatePath("/admin/pipeline");
revalidatePath(`/admin/pipeline/${id}`);
return { success: true, lead: updated };
} catch (error) {
console.error("updateLead error:", error);
@@ -130,7 +130,7 @@ export async function updateLead(id: string, data: z.infer<typeof updateLeadSche
export async function deleteLead(id: string) {
try {
await db.delete(leads).where(eq(leads.id, id));
revalidatePath("/admin/leads");
revalidatePath("/admin/pipeline");
return { success: true };
} catch (error) {
console.error("deleteLead error:", error);
@@ -155,7 +155,7 @@ export async function logActivity(data: z.infer<typeof createActivitySchema>) {
notes: parsed.data.notes,
});
revalidatePath(`/admin/leads/${parsed.data.lead_id}`);
revalidatePath(`/admin/pipeline/${parsed.data.lead_id}`);
return { success: true, activity };
} catch (error) {
console.error("logActivity error:", error);
@@ -205,7 +205,7 @@ export async function assignQuoteToLead(data: z.infer<typeof assignQuoteSchema>)
// Update lead status to "proposal_sent"
await updateLeadStage(leadId, "proposal_sent");
revalidatePath(`/admin/leads/${leadId}`);
revalidatePath(`/admin/pipeline/${leadId}`);
return { success: true };
} catch (error) {
console.error("assignQuoteToLead error:", error);
@@ -252,8 +252,8 @@ export async function updateLeadField(
.where(eq(leads.id, leadId));
}
revalidatePath("/admin/leads");
revalidatePath(`/admin/leads/${leadId}`);
revalidatePath("/admin/pipeline");
revalidatePath(`/admin/pipeline/${leadId}`);
}
// ── Lead tags (Phase 14, CRM-09) — polymorphic `tags` table, entity_type="leads" ─
@@ -270,8 +270,8 @@ export async function addLeadTag(leadId: string, value: string) {
.values({ entity_type: LEADS_TAG_ENTITY, entity_id: leadId, name: trimmed })
.onConflictDoNothing();
revalidatePath("/admin/leads");
revalidatePath(`/admin/leads/${leadId}`);
revalidatePath("/admin/pipeline");
revalidatePath(`/admin/pipeline/${leadId}`);
}
export async function removeLeadTag(leadId: string, value: string) {
@@ -287,8 +287,8 @@ export async function removeLeadTag(leadId: string, value: string) {
)
);
revalidatePath("/admin/leads");
revalidatePath(`/admin/leads/${leadId}`);
revalidatePath("/admin/pipeline");
revalidatePath(`/admin/pipeline/${leadId}`);
}
export async function renameLeadTag(oldValue: string, newValue: string) {
@@ -302,7 +302,7 @@ export async function renameLeadTag(oldValue: string, newValue: string) {
.set({ name: next })
.where(and(eq(tags.entity_type, LEADS_TAG_ENTITY), eq(tags.name, oldValue)));
revalidatePath("/admin/leads");
revalidatePath("/admin/pipeline");
}
// ── Transcript actions (Phase 20 — KB-02) ────────────────────────────────────
@@ -334,7 +334,7 @@ export async function addTranscript(data: z.infer<typeof addTranscriptSchema>) {
})
.returning();
revalidatePath(`/admin/leads/${parsed.data.lead_id}`);
revalidatePath(`/admin/pipeline/${parsed.data.lead_id}`);
return { success: true, transcript };
} catch (error) {
console.error("addTranscript error:", error);
@@ -352,7 +352,7 @@ export async function deleteTranscript(transcriptId: string, leadId: string) {
.delete(clientTranscripts)
.where(eq(clientTranscripts.id, transcriptId));
revalidatePath(`/admin/leads/${leadId}`);
revalidatePath(`/admin/pipeline/${leadId}`);
return { success: true };
} catch (error) {
console.error("deleteTranscript error:", error);
+24
View File
@@ -0,0 +1,24 @@
import { getLeadsWithTags, getLeadFieldOptions } from "@/lib/admin-queries";
import { LeadsViewToggle } from "@/components/admin/leads/LeadsViewToggle";
import { CreateLeadModal } from "@/components/admin/leads/LeadForm";
import { PageHeader } from "@/components/admin/PageHeader";
export const revalidate = 0;
export default async function LeadsPage() {
const [leads, options] = await Promise.all([
getLeadsWithTags(),
getLeadFieldOptions(),
]);
return (
<div className="space-y-6">
<PageHeader
title="Lead Pipeline"
subtitle="Gestisci e monitora i tuoi contatti commerciali"
action={<CreateLeadModal />}
/>
<LeadsViewToggle leads={leads} options={options} />
</div>
);
}
+11 -1
View File
@@ -109,7 +109,7 @@
@theme inline {
/* Font */
--font-sans: var(--font-geist-sans), system-ui, -apple-system, BlinkMacSystemFont,
--font-sans: var(--font-plus-jakarta-sans), system-ui, -apple-system, BlinkMacSystemFont,
"Segoe UI", Roboto, "Helvetica Neue", sans-serif;
--font-mono: var(--font-geist-mono);
@@ -149,6 +149,16 @@
--color-tertiary: var(--tertiary);
--color-bg-subtle: var(--bg-subtle);
--color-border-light: var(--border-light);
/* Radius — wire the base --radius into Tailwind's radius scale so
rounded-lg / rounded-xl resolve off a single token. */
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 0.25rem);
/* Luxury shadows — very low-opacity, wide-spread card elevation
("Quiet Luxury" design system). */
--shadow-card: 0 4px 20px rgba(0, 0, 0, 0.01);
--shadow-card-hover: 0 8px 30px rgba(0, 0, 0, 0.02);
}
/* =========================================================
+5 -4
View File
@@ -1,10 +1,11 @@
import type { Metadata, Viewport } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import { Plus_Jakarta_Sans, Geist_Mono } from "next/font/google";
import "./globals.css";
const geistSans = Geist({
variable: "--font-geist-sans",
const plusJakartaSans = Plus_Jakarta_Sans({
variable: "--font-plus-jakarta-sans",
subsets: ["latin"],
weight: ["300", "400", "500", "600", "700"],
});
const geistMono = Geist_Mono({
@@ -30,7 +31,7 @@ export default function RootLayout({
return (
<html
lang="it"
className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`}
className={`${plusJakartaSans.variable} ${geistMono.variable} h-full antialiased`}
>
<body className="min-h-full flex flex-col bg-background text-foreground">
<script
+65
View File
@@ -0,0 +1,65 @@
"use client";
import { useEffect, useState } from "react";
import { Menu, User } from "lucide-react";
import { AdminSidebar } from "@/components/admin/AdminSidebar";
const SIDEBAR_STORAGE_KEY = "iamcavalli:sidebar";
/**
* Client shell for every /admin/* page: collapsible sidebar + top header +
* <main>. Collapse state is persisted to localStorage and read on mount
* (starts expanded on server/first paint to avoid a hydration mismatch,
* then reconciles client-side — mirrors the pattern used by useTheme).
*/
export function AdminShell({ children }: { children: React.ReactNode }) {
const [collapsed, setCollapsed] = useState(false);
useEffect(() => {
const stored = window.localStorage.getItem(SIDEBAR_STORAGE_KEY);
if (stored === "collapsed") setCollapsed(true);
}, []);
function toggleCollapsed() {
setCollapsed((prev) => {
const next = !prev;
window.localStorage.setItem(SIDEBAR_STORAGE_KEY, next ? "collapsed" : "expanded");
return next;
});
}
return (
<div className="flex min-h-screen bg-background">
<AdminSidebar collapsed={collapsed} />
<div className="flex-1 flex flex-col min-w-0">
{/* Top header bar */}
<header className="h-16 shrink-0 bg-card border-b border-border px-6 flex items-center justify-between">
<div className="flex items-center gap-4">
<button
type="button"
onClick={toggleCollapsed}
title="Espandi/Comprimi Sidebar"
aria-label="Espandi/Comprimi Sidebar"
className="p-2 -ml-2 rounded-lg text-muted-foreground hover:bg-muted hover:text-foreground transition-colors focus:outline-none"
>
<Menu className="w-5 h-5" />
</button>
<span className="text-xs font-medium text-muted-foreground">Area di Lavoro</span>
</div>
<div className="flex items-center gap-3">
<span className="text-xs font-semibold text-foreground">Admin</span>
{/* TODO: gestione utenti/collaboratori — placeholder avatar until
real user accounts + uploaded profile images are wired up. */}
<div className="w-8 h-8 rounded-full bg-primary text-primary-foreground flex items-center justify-center shrink-0">
<User className="w-4 h-4" />
</div>
</div>
</header>
<main className="flex-1 px-8 py-8 overflow-y-auto">{children}</main>
</div>
</div>
);
}
+30 -11
View File
@@ -15,10 +15,11 @@ import {
FileText,
} from "lucide-react";
import ThemeToggle from "@/components/ThemeToggle";
import { cn } from "@/lib/utils";
const NAV_ITEMS = [
{ href: "/admin", label: "Dashboard", icon: LayoutDashboard, exact: true },
{ href: "/admin/leads", label: "Lead", icon: Zap },
{ href: "/admin/pipeline", label: "Pipeline", icon: Zap },
{ href: "/admin/clients", label: "Clienti", icon: Users },
{ href: "/admin/projects", label: "Progetti", icon: FolderOpen },
{ href: "/admin/preventivi", label: "Preventivi", icon: FileText },
@@ -27,17 +28,32 @@ const NAV_ITEMS = [
{ href: "/admin/impostazioni", label: "Impostazioni", icon: Settings },
];
export function AdminSidebar() {
export function AdminSidebar({ collapsed = false }: { collapsed?: boolean }) {
const pathname = usePathname();
const isActive = (href: string, exact?: boolean) =>
exact ? pathname === href : pathname === href || pathname.startsWith(href + "/");
// Text fades out immediately on collapse, but only fades back in once the
// width transition is underway on expand (avoids reflow mid-animation).
const textClass = cn(
"whitespace-nowrap transition-opacity duration-200 delay-150",
collapsed && "opacity-0 pointer-events-none delay-0"
);
return (
<aside className="w-56 shrink-0 min-h-screen bg-[#1A463C] flex flex-col">
<aside
className={cn(
"shrink-0 min-h-screen bg-[#1A463C] flex flex-col overflow-hidden",
"transition-[width] duration-350 ease-[cubic-bezier(0.4,0,0.2,1)]",
collapsed ? "w-20" : "w-64"
)}
>
{/* Logo */}
<div className="px-5 py-5 border-b border-white/10">
<span className="font-bold text-white tracking-tight text-sm">iamcavalli</span>
<span className={cn("font-bold text-white tracking-tight text-sm", textClass)}>
iamcavalli
</span>
</div>
{/* Nav links */}
@@ -48,14 +64,16 @@ export function AdminSidebar() {
<Link
key={href}
href={href}
className={`flex items-center gap-3 px-3 py-2 rounded-md text-sm transition-colors ${
title={collapsed ? label : undefined}
className={cn(
"flex items-center gap-3 px-3 py-2 rounded-md text-sm transition-colors",
active
? "bg-white/15 text-white font-medium"
: "text-white/65 hover:text-white hover:bg-white/10"
}`}
)}
>
<Icon size={16} strokeWidth={1.8} />
{label}
<Icon size={16} strokeWidth={1.8} className="shrink-0" />
<span className={textClass}>{label}</span>
</Link>
);
})}
@@ -64,15 +82,16 @@ export function AdminSidebar() {
{/* Theme + Logout */}
<div className="px-3 py-4 border-t border-white/10 flex flex-col gap-2">
<div className="flex items-center justify-between px-3">
<span className="text-xs text-white/50">Tema</span>
<span className={cn("text-xs text-white/50", textClass)}>Tema</span>
<ThemeToggle />
</div>
<button
onClick={() => signOut({ callbackUrl: "/admin/login" })}
title={collapsed ? "Esci" : undefined}
className="flex items-center gap-3 px-3 py-2 w-full rounded-md text-sm text-white/65 hover:text-white hover:bg-white/10 transition-colors"
>
<LogOut size={16} strokeWidth={1.8} />
Esci
<LogOut size={16} strokeWidth={1.8} className="shrink-0" />
<span className={textClass}>Esci</span>
</button>
</div>
</aside>
+3 -3
View File
@@ -6,10 +6,10 @@ export function PageHeader({ title, subtitle, action }: {
action?: ReactNode;
}) {
return (
<div className="mb-6 flex items-start justify-between gap-4">
<div className="mb-6 flex flex-col sm:flex-row items-start sm:items-center justify-between gap-4">
<div>
<h1 className="text-2xl font-bold text-[#1a1a1a]">{title}</h1>
{subtitle && <p className="mt-1 text-sm text-[#71717a]">{subtitle}</p>}
<h1 className="text-2xl font-semibold tracking-tight text-foreground">{title}</h1>
{subtitle && <p className="mt-1 text-xs text-muted-foreground">{subtitle}</p>}
</div>
{action}
</div>
@@ -47,7 +47,7 @@ export async function FollowUpWidget() {
</span>
</div>
<Link
href="/admin/leads"
href="/admin/pipeline"
className="text-xs font-medium text-[#71717a] hover:text-[#1A463C] transition-colors"
>
Vedi tutti
@@ -59,7 +59,7 @@ export async function FollowUpWidget() {
{visible.map((lead) => (
<li key={lead.id}>
<Link
href={`/admin/leads/${lead.id}`}
href={`/admin/pipeline/${lead.id}`}
className="flex items-center gap-3 px-5 py-3 hover:bg-[#f9f9f9] transition-colors group"
>
<span className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-[#1A463C]/10 text-[#1A463C] text-xs font-bold">
@@ -85,7 +85,7 @@ export async function FollowUpWidget() {
{leads.length > MAX_ROWS && (
<Link
href="/admin/leads"
href="/admin/pipeline"
className="block px-5 py-2.5 text-center text-xs font-medium text-[#71717a] hover:text-[#1A463C] border-t border-[#f4f4f5] transition-colors"
>
Vedi tutti i {leads.length} lead
+1 -1
View File
@@ -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";
+1 -1
View File
@@ -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,
+34 -57
View File
@@ -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>
);
+21 -21
View File
@@ -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>
)}
+18 -35
View File
@@ -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,
+26
View File
@@ -0,0 +1,26 @@
import { Search } from "lucide-react";
import { cn } from "@/lib/utils";
/**
* Search-icon input, extracted from the pattern originally inlined in
* LeadsViewToggle. Token-based so it themes correctly in dark mode.
*/
export function SearchInput({
className,
containerClassName,
...props
}: React.InputHTMLAttributes<HTMLInputElement> & { containerClassName?: string }) {
return (
<div className={cn("relative", containerClassName)}>
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground pointer-events-none" />
<input
type="text"
className={cn(
"w-full h-9 pl-9 pr-3 rounded-lg border border-border bg-card text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-primary focus:border-primary transition-all duration-200",
className
)}
{...props}
/>
</div>
);
}
+45
View File
@@ -0,0 +1,45 @@
import { cn } from "@/lib/utils";
export type SegmentedToggleOption<T extends string> = {
value: T;
label: string;
};
/**
* Generic segmented control (e.g. Lista/Kanban). Active option gets
* bg-card + shadow-sm to read as a "raised" tab against the bg-muted track.
*/
export function SegmentedToggle<T extends string>({
options,
value,
onChange,
className,
}: {
options: SegmentedToggleOption<T>[];
value: T;
onChange: (value: T) => void;
className?: string;
}) {
return (
<div className={cn("flex items-center gap-1 bg-muted rounded-lg p-1 w-fit", className)}>
{options.map((option) => {
const active = option.value === value;
return (
<button
key={option.value}
type="button"
onClick={() => onChange(option.value)}
className={cn(
"px-3 py-1.5 rounded-md text-xs font-semibold transition-all duration-200",
active
? "bg-card text-primary shadow-sm"
: "text-muted-foreground hover:text-foreground"
)}
>
{option.label}
</button>
);
})}
</div>
);
}
+53
View File
@@ -0,0 +1,53 @@
import { cn } from "@/lib/utils";
/**
* Lead pipeline stages. Kept in sync with LEAD_STAGES
* (src/lib/lead-validators.ts) — all 6 stages stay, per the CRM Kanban.
*/
export type LeadStage =
| "contacted"
| "qualified"
| "proposal_sent"
| "negotiating"
| "won"
| "lost";
const STAGE_STYLES: Record<LeadStage, string> = {
contacted:
"bg-blue-50 text-blue-600 border-blue-100 dark:bg-blue-950/40 dark:text-blue-300 dark:border-blue-900",
qualified:
"bg-purple-50 text-purple-600 border-purple-100 dark:bg-purple-950/40 dark:text-purple-300 dark:border-purple-900",
proposal_sent:
"bg-amber-50 text-amber-600 border-amber-100 dark:bg-amber-950/40 dark:text-amber-300 dark:border-amber-900",
negotiating:
"bg-orange-50 text-orange-600 border-orange-100 dark:bg-orange-950/40 dark:text-orange-300 dark:border-orange-900",
won: "bg-emerald-50 text-emerald-600 border-emerald-100 dark:bg-emerald-950/40 dark:text-emerald-300 dark:border-emerald-900",
lost: "bg-red-50 text-red-600 border-red-100 dark:bg-red-950/40 dark:text-red-300 dark:border-red-900",
};
/**
* Rounded-full status pill for lead stages. Replaces the old scattered
* STAGE_COLOR maps in LeadTable/LeadsKanbanBoard — one source of truth for
* stage → color, with explicit dark: variants for legibility.
*/
export function StatusBadge({
status,
className,
}: {
status: string;
className?: string;
}) {
const styles = STAGE_STYLES[status as LeadStage] ?? "bg-muted text-muted-foreground border-border";
return (
<span
className={cn(
"inline-flex items-center px-2.5 py-1 rounded-full text-[10px] font-semibold tracking-wide uppercase border whitespace-nowrap",
styles,
className
)}
>
{status.replace(/_/g, " ")}
</span>
);
}
+1 -1
View File
@@ -110,7 +110,7 @@ export function EditableCell({
"px-2 py-1 rounded transition-colors duration-150 text-sm",
disabled
? "cursor-not-allowed opacity-50"
: "cursor-pointer hover:bg-[#f0f0f0]",
: "cursor-pointer hover:bg-muted",
type === "textarea" && "line-clamp-2 max-w-md"
)}
>
+10 -10
View File
@@ -81,11 +81,11 @@ export function OptionMultiSelect({
onClick={() => !disabled && setIsOpen((v) => !v)}
className={cn(
"flex flex-wrap gap-1 items-center px-2 py-1 rounded transition-colors duration-150 min-h-[28px]",
disabled ? "cursor-not-allowed opacity-50" : "cursor-pointer hover:bg-[#f0f0f0]"
disabled ? "cursor-not-allowed opacity-50" : "cursor-pointer hover:bg-muted"
)}
>
{values.length === 0 ? (
<span className="text-xs text-[#71717a]"></span>
<span className="text-xs text-muted-foreground"></span>
) : (
values.map((v) => (
<Badge
@@ -111,11 +111,11 @@ export function OptionMultiSelect({
</Badge>
))
)}
<Plus className="h-3.5 w-3.5 text-[#71717a]" />
<Plus className="h-3.5 w-3.5 text-muted-foreground" />
</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-[220px] max-h-[280px] overflow-y-auto">
<div className="absolute top-full left-0 mt-1 bg-popover border border-border rounded-md shadow-md p-1.5 z-20 min-w-[220px] max-h-[280px] overflow-y-auto">
<Input
ref={inputRef}
type="text"
@@ -160,7 +160,7 @@ export function OptionMultiSelect({
) : (
<div
key={opt}
className="flex items-center justify-between gap-1 rounded px-1.5 py-1 hover:bg-[#f5f5f5] group"
className="flex items-center justify-between gap-1 rounded px-1.5 py-1 hover:bg-muted group"
>
<button
type="button"
@@ -168,7 +168,7 @@ export function OptionMultiSelect({
className="flex items-center gap-2 flex-1 min-w-0 text-left"
>
<span className="w-3.5 flex-shrink-0">
{selected.has(opt) && <Check className="h-3.5 w-3.5 text-[#1A463C]" />}
{selected.has(opt) && <Check className="h-3.5 w-3.5 text-primary" />}
</span>
<Badge
variant="outline"
@@ -187,7 +187,7 @@ export function OptionMultiSelect({
setRenameValue(opt);
setRenaming(opt);
}}
className="opacity-0 group-hover:opacity-100 text-[#71717a] hover:text-[#1a1a1a] p-0.5"
className="opacity-0 group-hover:opacity-100 text-muted-foreground hover:text-foreground p-0.5"
aria-label={`Rinomina ${opt}`}
>
<Pencil className="h-3 w-3" />
@@ -201,15 +201,15 @@ export function OptionMultiSelect({
<button
type="button"
onClick={createFromQuery}
className="flex items-center gap-2 rounded px-1.5 py-1.5 hover:bg-[#f5f5f5] text-left text-sm"
className="flex items-center gap-2 rounded px-1.5 py-1.5 hover:bg-muted text-left text-sm"
>
<Plus className="h-3.5 w-3.5 text-[#1A463C]" />
<Plus className="h-3.5 w-3.5 text-primary" />
Crea <span className="font-medium">«{query.trim()}»</span>
</button>
)}
{filtered.length === 0 && !q && (
<p className="text-xs text-[#71717a] px-1.5 py-1">Nessuna opzione ancora</p>
<p className="text-xs text-muted-foreground px-1.5 py-1">Nessuna opzione ancora</p>
)}
</div>
</div>