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:
@@ -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>;
|
||||
}
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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"
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user