feat(10-redo-C): CRM leads module — schema, services, UI (Phase 10 redo)

Stage C of staged Phase 10 redo. Restores dangling phase10-wip work:
- schema.ts: leads expansion + activities/reminders tables + relations
- lead-service.ts / lead-validators.ts service layer
- /admin/leads list + detail + actions, LeadTable/LeadDetail/LeadForm
- LogActivityModal, SendQuoteModal, FollowUpWidget on dashboard
- migration 0005 (applied to prod DB in Stage B, along with
  previously-missing 0001/0003/0004 — root cause of all post-deploy
  500s: prod DB had no migrations applied since 0000)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-06-11 20:58:14 +02:00
parent 5aa6614e41
commit 008a43469d
18 changed files with 1972 additions and 3 deletions
+16
View File
@@ -0,0 +1,16 @@
import { notFound } from "next/navigation";
import { getLeadById, getActivityLog, getUpcomingReminders } from "@/lib/lead-service";
import { LeadDetail } from "@/components/admin/leads/LeadDetail";
export default async function LeadDetailPage({ params }: { params: { id: string } }) {
const lead = await getLeadById(params.id);
if (!lead) {
notFound();
}
const activities = await getActivityLog(params.id);
const reminders = await getUpcomingReminders(params.id);
return <LeadDetail lead={lead} activities={activities} reminders={reminders} />;
}
+161
View File
@@ -0,0 +1,161 @@
"use server";
import { z } from "zod";
import { db } from "@/db";
import { leads, quotes } from "@/db/schema";
import { eq } from "drizzle-orm";
import { createLeadSchema, updateLeadSchema, createActivitySchema } from "@/lib/lead-validators";
import { revalidatePath } from "next/cache";
import { createActivity, updateLeadStage } from "@/lib/lead-service";
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/leads");
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/leads");
revalidatePath(`/admin/leads/${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/leads");
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/leads/${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(),
generate_new: z.boolean(),
});
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 {
if (parsed.data.generate_new) {
// Redirect handled on client; this is a no-op
return { success: true };
}
// 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/leads/${leadId}`);
return { success: true };
} catch (error) {
console.error("assignQuoteToLead error:", error);
return { success: false, error: "Errore nell'assegnazione" };
}
}
+25
View File
@@ -0,0 +1,25 @@
import { Suspense } from "react";
import { getAllLeads } from "@/lib/lead-service";
import { LeadTable } from "@/components/admin/leads/LeadTable";
import { CreateLeadModal } from "@/components/admin/leads/LeadForm";
async function LeadsList() {
const leads = await getAllLeads();
return (
<div className="space-y-6">
<div className="flex justify-between items-center">
<h1 className="text-3xl font-bold">Lead Pipeline</h1>
<CreateLeadModal />
</div>
<Suspense fallback={<div className="animate-pulse">Loading...</div>}>
<LeadTable leads={leads} />
</Suspense>
</div>
);
}
export default function LeadsPage() {
return <LeadsList />;
}
+9
View File
@@ -1,4 +1,6 @@
import { Suspense } from "react";
import { getDashboardStats } from "@/lib/dashboard-queries";
import { FollowUpWidget } from "@/components/admin/dashboard/FollowUpWidget";
import { Users, FolderOpen, Euro, Clock } from "lucide-react";
export const revalidate = 0;
@@ -59,6 +61,13 @@ export default async function AdminDashboard() {
<div className="max-w-5xl">
<h1 className="text-2xl font-bold text-gray-900 mb-6">Dashboard</h1>
{/* Follow-up Widget */}
<div className="mb-8">
<Suspense fallback={<div className="animate-pulse h-40 bg-gray-200 rounded-lg" />}>
<FollowUpWidget />
</Suspense>
</div>
{/* KPI cards */}
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4 mb-8">
<KpiCard