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
+211
View File
@@ -0,0 +1,211 @@
import { eq, and, isNull, desc, gte, lte, ilike, or } from "drizzle-orm";
import { db } from "@/db";
import { leads, activities, reminders, quotes } from "@/db/schema";
// Get all leads
export async function getAllLeads(limit: number = 100, offset: number = 0) {
return await db
.select()
.from(leads)
.orderBy(desc(leads.updated_at))
.limit(limit)
.offset(offset);
}
// Get lead by ID
export async function getLeadById(id: string) {
const [lead] = await db
.select()
.from(leads)
.where(eq(leads.id, id));
return lead;
}
// Get leads by stage
export async function getLeadsByStage(stage: string) {
return await db
.select()
.from(leads)
.where(eq(leads.status, stage))
.orderBy(desc(leads.last_contact_date));
}
// Get leads needing follow-up
export async function getLeadsNeedingFollowUp(daysAgo: number = 7) {
const cutoffDate = new Date();
cutoffDate.setDate(cutoffDate.getDate() - daysAgo);
return await db
.select()
.from(leads)
.where(
or(
isNull(leads.last_contact_date),
lte(leads.last_contact_date, cutoffDate)
)
)
.orderBy(leads.created_at);
}
// Get activity log
export async function getActivityLog(leadId: string) {
return await db
.select()
.from(activities)
.where(eq(activities.lead_id, leadId))
.orderBy(desc(activities.activity_date));
}
// Get upcoming reminders
export async function getUpcomingReminders(leadId: string) {
return await db
.select()
.from(reminders)
.where(and(eq(reminders.lead_id, leadId), isNull(reminders.completed_at)))
.orderBy(reminders.due_date);
}
// Get overdue reminders
export async function getOverdueReminders() {
const now = new Date();
return await db
.select()
.from(reminders)
.where(and(lte(reminders.due_date, now), isNull(reminders.completed_at)))
.orderBy(reminders.due_date);
}
// Create activity and auto-update last_contact_date
export async function createActivity(data: {
lead_id: string;
type: string;
activity_date: Date | string;
duration_minutes?: number;
notes: string;
}) {
const activityDate =
typeof data.activity_date === "string"
? new Date(data.activity_date)
: data.activity_date;
const [activity] = await db
.insert(activities)
.values({
lead_id: data.lead_id,
type: data.type,
activity_date: activityDate,
duration_minutes: data.duration_minutes,
notes: data.notes,
})
.returning();
// Auto-update lead.last_contact_date
await db
.update(leads)
.set({ last_contact_date: activityDate, updated_at: new Date() })
.where(eq(leads.id, data.lead_id));
return activity;
}
// Update lead stage
export async function updateLeadStage(leadId: string, stage: string) {
const [updated] = await db
.update(leads)
.set({ status: stage, updated_at: new Date() })
.where(eq(leads.id, leadId))
.returning();
return updated;
}
// Update lead
export async function updateLead(
leadId: string,
data: Partial<{
name: string;
email: string;
phone: string;
company: string;
notes: string;
next_action: string;
next_action_date: Date | string;
}>
) {
const updateData: Record<string, any> = { updated_at: new Date() };
if (data.name !== undefined) updateData.name = data.name;
if (data.email !== undefined) updateData.email = data.email;
if (data.phone !== undefined) updateData.phone = data.phone;
if (data.company !== undefined) updateData.company = data.company;
if (data.notes !== undefined) updateData.notes = data.notes;
if (data.next_action !== undefined) updateData.next_action = data.next_action;
if (data.next_action_date !== undefined) {
updateData.next_action_date =
typeof data.next_action_date === "string"
? new Date(data.next_action_date)
: data.next_action_date;
}
const [updated] = await db
.update(leads)
.set(updateData)
.where(eq(leads.id, leadId))
.returning();
return updated;
}
// Search leads
export async function searchLeads(query: string) {
return await db
.select()
.from(leads)
.where(
or(
ilike(leads.name, `%${query}%`),
ilike(leads.email, `%${query}%`),
ilike(leads.company, `%${query}%`)
)
)
.orderBy(desc(leads.updated_at));
}
// Create reminder
export async function createReminder(data: {
lead_id: string;
title: string;
description?: string;
due_date: Date | string;
}) {
const dueDate =
typeof data.due_date === "string" ? new Date(data.due_date) : data.due_date;
const [reminder] = await db
.insert(reminders)
.values({
lead_id: data.lead_id,
title: data.title,
description: data.description,
due_date: dueDate,
})
.returning();
return reminder;
}
// Complete reminder
export async function completeReminder(reminderId: string) {
const [updated] = await db
.update(reminders)
.set({ completed_at: new Date() })
.where(eq(reminders.id, reminderId))
.returning();
return updated;
}
// Get quotes by lead
export async function getQuotesByLeadId(leadId: string) {
return await db
.select()
.from(quotes)
.where(eq(quotes.lead_id, leadId))
.orderBy(desc(quotes.created_at));
}
+56
View File
@@ -0,0 +1,56 @@
import { z } from "zod";
// Pipeline stages
export const LEAD_STAGES = [
"contacted",
"qualified",
"proposal_sent",
"negotiating",
"won",
"lost",
] as const;
export const ACTIVITY_TYPES = ["call", "email", "meeting", "note"] as const;
// Lead creation
export const createLeadSchema = z.object({
name: z.string().min(1, "Nome richiesto").max(100),
email: z.string().email("Email valida").optional().or(z.literal("")),
phone: z.string().optional().or(z.literal("")),
company: z.string().optional().or(z.literal("")),
status: z.enum(LEAD_STAGES),
notes: z.string().optional().or(z.literal("")),
});
// Lead update
export const updateLeadSchema = createLeadSchema.partial();
// Activity logging
export const createActivitySchema = z.object({
lead_id: z.string().min(1, "Lead richiesto"),
type: z.enum(ACTIVITY_TYPES),
activity_date: z.date().or(z.string()),
duration_minutes: z.number().min(0).optional(),
notes: z.string().min(1, "Note richieste").max(1000),
});
// Reminder creation
export const createReminderSchema = z.object({
lead_id: z.string().min(1, "Lead richiesto"),
title: z.string().min(1, "Titolo richiesto").max(200),
description: z.string().optional(),
due_date: z.date().or(z.string()),
});
// Update lead stage
export const updateLeadStageSchema = z.object({
lead_id: z.string().min(1),
stage: z.enum(LEAD_STAGES),
});
// Type exports
export type CreateLeadInput = z.infer<typeof createLeadSchema>;
export type UpdateLeadInput = z.infer<typeof updateLeadSchema>;
export type CreateActivityInput = z.infer<typeof createActivitySchema>;
export type CreateReminderInput = z.infer<typeof createReminderSchema>;
export type UpdateLeadStageInput = z.infer<typeof updateLeadStageSchema>;