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