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:
@@ -0,0 +1,361 @@
|
||||
"use server";
|
||||
|
||||
import { z } from "zod";
|
||||
import { db } from "@/db";
|
||||
import { leads, quotes, tags, clientTranscripts, projects } from "@/db/schema";
|
||||
import { nanoid } from "nanoid";
|
||||
import { eq, and } from "drizzle-orm";
|
||||
import {
|
||||
createLeadSchema,
|
||||
updateLeadSchema,
|
||||
createActivitySchema,
|
||||
LEAD_STAGES,
|
||||
} from "@/lib/lead-validators";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { redirect } from "next/navigation";
|
||||
import { createActivity, updateLeadStage } from "@/lib/lead-service";
|
||||
import { createClientCore } from "@/app/admin/clients/new/actions";
|
||||
import { getServerSession } from "next-auth";
|
||||
import { authOptions } from "@/lib/auth";
|
||||
|
||||
async function requireAdminSession() {
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session) throw new Error("Non autorizzato");
|
||||
}
|
||||
|
||||
// Converts a lead into a client (reusing createClientCore): creates the client
|
||||
// + default project (linked via created_from_lead_id) + payment stubs, carries
|
||||
// over the lead's call transcripts, then archives the lead while keeping its
|
||||
// "won" status. Idempotent: a lead already converted just redirects to its
|
||||
// client. Redirects to the new client page on success.
|
||||
export async function convertLeadToClient(leadId: string): Promise<void> {
|
||||
await requireAdminSession();
|
||||
|
||||
const [lead] = await db.select().from(leads).where(eq(leads.id, leadId)).limit(1);
|
||||
if (!lead) throw new Error("Lead non trovato");
|
||||
|
||||
// Already converted? Redirect to the linked client instead of duplicating.
|
||||
const [existing] = await db
|
||||
.select({ client_id: projects.client_id })
|
||||
.from(projects)
|
||||
.where(eq(projects.created_from_lead_id, leadId))
|
||||
.limit(1);
|
||||
if (existing) {
|
||||
redirect(`/admin/clients/${existing.client_id}`);
|
||||
}
|
||||
|
||||
const { clientId } = await createClientCore({
|
||||
name: lead.name,
|
||||
brand_name: lead.company?.trim() || lead.name,
|
||||
brief: lead.notes?.trim() || `Lead convertito: ${lead.name}`,
|
||||
email: lead.email,
|
||||
phone: lead.phone,
|
||||
created_from_lead_id: lead.id,
|
||||
});
|
||||
|
||||
// Carry over the lead's transcripts to the new client.
|
||||
await db
|
||||
.update(clientTranscripts)
|
||||
.set({ client_id: clientId, lead_id: null })
|
||||
.where(eq(clientTranscripts.lead_id, leadId));
|
||||
|
||||
// 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/pipeline");
|
||||
revalidatePath("/admin");
|
||||
redirect(`/admin/clients/${clientId}`);
|
||||
}
|
||||
|
||||
export async function createLead(data: z.infer<typeof createLeadSchema>) {
|
||||
const parsed = createLeadSchema.safeParse(data);
|
||||
|
||||
if (!parsed.success) {
|
||||
return { success: false, error: parsed.error.issues[0].message };
|
||||
}
|
||||
|
||||
try {
|
||||
const [lead] = await db
|
||||
.insert(leads)
|
||||
.values({
|
||||
name: parsed.data.name,
|
||||
email: parsed.data.email || null,
|
||||
phone: parsed.data.phone || null,
|
||||
company: parsed.data.company || null,
|
||||
status: parsed.data.status || "contacted",
|
||||
notes: parsed.data.notes || null,
|
||||
})
|
||||
.returning();
|
||||
|
||||
revalidatePath("/admin/pipeline");
|
||||
return { success: true, lead };
|
||||
} catch (error) {
|
||||
console.error("createLead error:", error);
|
||||
return { success: false, error: "Errore nella creazione del lead" };
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateLead(id: string, data: z.infer<typeof updateLeadSchema>) {
|
||||
const parsed = updateLeadSchema.safeParse(data);
|
||||
|
||||
if (!parsed.success) {
|
||||
return { success: false, error: parsed.error.issues[0].message };
|
||||
}
|
||||
|
||||
try {
|
||||
const updateData: Record<string, any> = { updated_at: new Date() };
|
||||
|
||||
if (parsed.data.name !== undefined) updateData.name = parsed.data.name;
|
||||
if (parsed.data.email !== undefined) updateData.email = parsed.data.email || null;
|
||||
if (parsed.data.phone !== undefined) updateData.phone = parsed.data.phone || null;
|
||||
if (parsed.data.company !== undefined) updateData.company = parsed.data.company || null;
|
||||
if (parsed.data.status !== undefined) updateData.status = parsed.data.status;
|
||||
if (parsed.data.notes !== undefined) updateData.notes = parsed.data.notes || null;
|
||||
|
||||
const [updated] = await db
|
||||
.update(leads)
|
||||
.set(updateData)
|
||||
.where(eq(leads.id, id))
|
||||
.returning();
|
||||
|
||||
revalidatePath("/admin/pipeline");
|
||||
revalidatePath(`/admin/pipeline/${id}`);
|
||||
return { success: true, lead: updated };
|
||||
} catch (error) {
|
||||
console.error("updateLead error:", error);
|
||||
return { success: false, error: "Errore nell'aggiornamento" };
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteLead(id: string) {
|
||||
try {
|
||||
await db.delete(leads).where(eq(leads.id, id));
|
||||
revalidatePath("/admin/pipeline");
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
console.error("deleteLead error:", error);
|
||||
return { success: false, error: "Errore nell'eliminazione" };
|
||||
}
|
||||
}
|
||||
|
||||
// Activity logging
|
||||
export async function logActivity(data: z.infer<typeof createActivitySchema>) {
|
||||
const parsed = createActivitySchema.safeParse(data);
|
||||
|
||||
if (!parsed.success) {
|
||||
return { success: false, error: parsed.error.issues[0].message };
|
||||
}
|
||||
|
||||
try {
|
||||
const activity = await createActivity({
|
||||
lead_id: parsed.data.lead_id,
|
||||
type: parsed.data.type,
|
||||
activity_date: new Date(parsed.data.activity_date),
|
||||
duration_minutes: parsed.data.duration_minutes,
|
||||
notes: parsed.data.notes,
|
||||
});
|
||||
|
||||
revalidatePath(`/admin/pipeline/${parsed.data.lead_id}`);
|
||||
return { success: true, activity };
|
||||
} catch (error) {
|
||||
console.error("logActivity error:", error);
|
||||
return { success: false, error: "Errore nella registrazione" };
|
||||
}
|
||||
}
|
||||
|
||||
// Quote assignment
|
||||
const assignQuoteSchema = z.object({
|
||||
lead_id: z.string().min(1),
|
||||
quote_token: z.string().optional(),
|
||||
});
|
||||
|
||||
export async function assignQuoteToLead(data: z.infer<typeof assignQuoteSchema>) {
|
||||
const parsed = assignQuoteSchema.safeParse(data);
|
||||
|
||||
if (!parsed.success) {
|
||||
return { success: false, error: parsed.error.issues[0].message };
|
||||
}
|
||||
|
||||
try {
|
||||
// Link quote to lead and update lead status
|
||||
const token = parsed.data.quote_token;
|
||||
const leadId = parsed.data.lead_id;
|
||||
|
||||
if (!token) {
|
||||
return { success: false, error: "Token preventivo richiesto" };
|
||||
}
|
||||
|
||||
// Find quote by token
|
||||
const [quote] = await db
|
||||
.select()
|
||||
.from(quotes)
|
||||
.where(eq(quotes.token, token))
|
||||
.limit(1);
|
||||
|
||||
if (!quote) {
|
||||
return { success: false, error: "Preventivo non trovato" };
|
||||
}
|
||||
|
||||
// Update quote to link to lead (if not already linked)
|
||||
await db
|
||||
.update(quotes)
|
||||
.set({ lead_id: leadId })
|
||||
.where(eq(quotes.id, quote.id));
|
||||
|
||||
// Update lead status to "proposal_sent"
|
||||
await updateLeadStage(leadId, "proposal_sent");
|
||||
|
||||
revalidatePath(`/admin/pipeline/${leadId}`);
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
console.error("assignQuoteToLead error:", error);
|
||||
return { success: false, error: "Errore nell'assegnazione" };
|
||||
}
|
||||
}
|
||||
|
||||
async function requireAdmin() {
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session) throw new Error("Non autorizzato");
|
||||
}
|
||||
|
||||
// ── Inline-edit field update (Phase 14 database-view) ───────────────────────
|
||||
|
||||
const EDITABLE_FIELDS = ["name", "email", "phone", "company", "status", "next_action"] as const;
|
||||
type EditableField = (typeof EDITABLE_FIELDS)[number];
|
||||
|
||||
export async function updateLeadField(
|
||||
leadId: string,
|
||||
fieldName: EditableField,
|
||||
value: string
|
||||
) {
|
||||
await requireAdmin();
|
||||
|
||||
if (!EDITABLE_FIELDS.includes(fieldName)) {
|
||||
throw new Error(`Campo non editabile: ${fieldName}`);
|
||||
}
|
||||
|
||||
if (fieldName === "name") {
|
||||
const s = value.trim();
|
||||
if (s.length === 0) throw new Error("Nome richiesto");
|
||||
await db.update(leads).set({ name: s, updated_at: new Date() }).where(eq(leads.id, leadId));
|
||||
} else if (fieldName === "status") {
|
||||
if (!LEAD_STAGES.includes(value as (typeof LEAD_STAGES)[number])) {
|
||||
throw new Error("Stato non valido");
|
||||
}
|
||||
await db.update(leads).set({ status: value, updated_at: new Date() }).where(eq(leads.id, leadId));
|
||||
} else {
|
||||
// email | phone | company | next_action — nullable text fields, empty clears
|
||||
const s = value.trim();
|
||||
await db
|
||||
.update(leads)
|
||||
.set({ [fieldName]: s || null, updated_at: new Date() })
|
||||
.where(eq(leads.id, leadId));
|
||||
}
|
||||
|
||||
revalidatePath("/admin/pipeline");
|
||||
revalidatePath(`/admin/pipeline/${leadId}`);
|
||||
}
|
||||
|
||||
// ── Lead tags (Phase 14, CRM-09) — polymorphic `tags` table, entity_type="leads" ─
|
||||
|
||||
const LEADS_TAG_ENTITY = "leads";
|
||||
|
||||
export async function addLeadTag(leadId: string, value: string) {
|
||||
await requireAdmin();
|
||||
const trimmed = value.trim();
|
||||
if (trimmed.length === 0) throw new Error("Valore richiesto");
|
||||
|
||||
await db
|
||||
.insert(tags)
|
||||
.values({ entity_type: LEADS_TAG_ENTITY, entity_id: leadId, name: trimmed })
|
||||
.onConflictDoNothing();
|
||||
|
||||
revalidatePath("/admin/pipeline");
|
||||
revalidatePath(`/admin/pipeline/${leadId}`);
|
||||
}
|
||||
|
||||
export async function removeLeadTag(leadId: string, value: string) {
|
||||
await requireAdmin();
|
||||
|
||||
await db
|
||||
.delete(tags)
|
||||
.where(
|
||||
and(
|
||||
eq(tags.entity_type, LEADS_TAG_ENTITY),
|
||||
eq(tags.entity_id, leadId),
|
||||
eq(tags.name, value)
|
||||
)
|
||||
);
|
||||
|
||||
revalidatePath("/admin/pipeline");
|
||||
revalidatePath(`/admin/pipeline/${leadId}`);
|
||||
}
|
||||
|
||||
export async function renameLeadTag(oldValue: string, newValue: string) {
|
||||
await requireAdmin();
|
||||
const next = newValue.trim();
|
||||
if (next.length === 0) throw new Error("Nuovo nome richiesto");
|
||||
if (next === oldValue) return;
|
||||
|
||||
await db
|
||||
.update(tags)
|
||||
.set({ name: next })
|
||||
.where(and(eq(tags.entity_type, LEADS_TAG_ENTITY), eq(tags.name, oldValue)));
|
||||
|
||||
revalidatePath("/admin/pipeline");
|
||||
}
|
||||
|
||||
// ── Transcript actions (Phase 20 — KB-02) ────────────────────────────────────
|
||||
|
||||
const addTranscriptSchema = z.object({
|
||||
lead_id: z.string().min(1),
|
||||
title: z.string().optional(),
|
||||
content: z.string().min(1, "Il testo del transcript è obbligatorio"),
|
||||
call_date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, "Data non valida (YYYY-MM-DD)"),
|
||||
});
|
||||
|
||||
export async function addTranscript(data: z.infer<typeof addTranscriptSchema>) {
|
||||
await requireAdmin();
|
||||
|
||||
const parsed = addTranscriptSchema.safeParse(data);
|
||||
if (!parsed.success) {
|
||||
return { success: false, error: parsed.error.issues[0].message };
|
||||
}
|
||||
|
||||
try {
|
||||
const [transcript] = await db
|
||||
.insert(clientTranscripts)
|
||||
.values({
|
||||
id: nanoid(),
|
||||
lead_id: parsed.data.lead_id,
|
||||
title: parsed.data.title || null,
|
||||
content: parsed.data.content,
|
||||
call_date: parsed.data.call_date,
|
||||
})
|
||||
.returning();
|
||||
|
||||
revalidatePath(`/admin/pipeline/${parsed.data.lead_id}`);
|
||||
return { success: true, transcript };
|
||||
} catch (error) {
|
||||
console.error("addTranscript error:", error);
|
||||
return { success: false, error: "Errore nel salvataggio del transcript" };
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteTranscript(transcriptId: string, leadId: string) {
|
||||
await requireAdmin();
|
||||
|
||||
if (!transcriptId) throw new Error("ID transcript richiesto");
|
||||
|
||||
try {
|
||||
await db
|
||||
.delete(clientTranscripts)
|
||||
.where(eq(clientTranscripts.id, transcriptId));
|
||||
|
||||
revalidatePath(`/admin/pipeline/${leadId}`);
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
console.error("deleteTranscript error:", error);
|
||||
return { success: false, error: "Errore nell'eliminazione del transcript" };
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user