feat: lead → client conversion + client contact fields (email/phone)

Blocco A+B (milestone v2.3). Migration 0011 is additive (ADD COLUMN only):
clients.email, clients.phone, leads.archived.

- createClientCore() extracted from createClient and reused by conversion
- clients.email/phone added to create + edit forms and shown on client header
  (never exposed on the public /client/[token] page)
- convertLeadToClient(): reuses core, carries over lead transcripts
  (client_transcripts.client_id), links project.created_from_lead_id,
  archives the lead while keeping its "won" status; idempotent
- "Converti in cliente" button on won leads; "Convertito → Apri cliente"
  once converted (clientId resolved via created_from_lead_id)
- archived leads hidden from list/kanban; detail page fetches by id incl. archived

Migration must be applied to the prod DB BEFORE this is deployed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-22 08:18:41 +02:00
parent e1b3e8c3d5
commit f5f90cd643
11 changed files with 273 additions and 44 deletions
+9
View File
@@ -50,10 +50,17 @@ async function resolveEntity(id: string): Promise<{ projectId: string | null; pa
// ── CLIENT CRUD ───────────────────────────────────────────────────────────────
const emptyToNull = (v: unknown) => {
const s = typeof v === "string" ? v.trim() : "";
return s === "" ? null : s;
};
const clientSchema = z.object({
name: z.string().min(1, "Nome richiesto"),
brand_name: z.string().min(1, "Brand name richiesto"),
brief: z.string(),
email: z.string().optional().transform(emptyToNull),
phone: z.string().optional().transform(emptyToNull),
slug: z
.string()
.regex(/^[a-z0-9-]{3,50}$/, "Slug non valido (es. mario-rossi, min 3 max 50 caratteri)")
@@ -68,6 +75,8 @@ export async function updateClient(clientId: string, formData: FormData) {
name: formData.get("name"),
brand_name: formData.get("brand_name"),
brief: formData.get("brief") ?? "",
email: formData.get("email") ?? "",
phone: formData.get("phone") ?? "",
slug: formData.get("slug") ?? "",
});
if (!parsed.success) throw new Error(parsed.error.issues[0].message);
+23
View File
@@ -62,6 +62,29 @@ export default async function EditClientPage({
/>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div className="space-y-1.5">
<Label htmlFor="email">Email</Label>
<Input
id="email"
name="email"
type="email"
defaultValue={client.email ?? ""}
placeholder="marco@rossistudio.it"
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="phone">Telefono</Label>
<Input
id="phone"
name="phone"
type="tel"
defaultValue={client.phone ?? ""}
placeholder="+39 333 1234567"
/>
</div>
</div>
<div className="space-y-1.5">
<Label htmlFor="brief">Brief progetto</Label>
<Textarea
+15
View File
@@ -33,6 +33,21 @@ export default async function ClientDetailPage({
<div>
<h1 className="text-2xl font-bold text-[#1a1a1a]">{client.name}</h1>
<p className="text-sm text-[#71717a]">{client.brand_name}</p>
{(client.email || client.phone) && (
<div className="mt-1 flex flex-wrap items-center gap-x-3 gap-y-0.5 text-xs text-[#71717a]">
{client.email && (
<a href={`mailto:${client.email}`} className="hover:text-[#1A463C]">
{client.email}
</a>
)}
{client.email && client.phone && <span className="text-[#d4d4d8]">·</span>}
{client.phone && (
<a href={`tel:${client.phone}`} className="hover:text-[#1A463C]">
{client.phone}
</a>
)}
</div>
)}
{client.archived && (
<span className="inline-block mt-1 text-xs font-medium bg-[#f4f4f5] text-[#71717a] px-2 py-0.5 rounded-full">
Archiviato
+43 -37
View File
@@ -44,62 +44,68 @@ const createClientSchema = z.object({
name: z.string().min(1, "Nome richiesto"),
brand_name: z.string().min(1, "Nome brand richiesto"),
brief: z.string().min(1, "Brief richiesto"),
email: z.string().optional(),
phone: z.string().optional(),
});
export async function createClient(formData: FormData) {
await requireAdmin();
const raw = {
name: formData.get("name") as string,
brand_name: formData.get("brand_name") as string,
brief: formData.get("brief") as string,
};
// Shared core used by both the manual create form and the lead→client
// conversion. Creates the client (+ slug + token), a default project (optionally
// linked to the originating lead), and the two 50/50 payment stubs. Returns the
// new ids. Does NOT redirect — callers decide where to go next.
export async function createClientCore(input: {
name: string;
brand_name: string;
brief: string;
email?: string | null;
phone?: string | null;
created_from_lead_id?: string | null;
}): Promise<{ clientId: string; projectId: string }> {
const slug = await uniqueSlug(toSlug(input.name));
const parsed = createClientSchema.safeParse(raw);
if (!parsed.success) {
throw new Error(
parsed.error.issues.map((i) => i.message).join(", ")
);
}
// Auto-generate slug from name (e.g. "Mario Rossi" → "mario-rossi")
const slug = await uniqueSlug(toSlug(parsed.data.name));
// Insert client — token and id are auto-generated by $defaultFn(() => nanoid())
const [newClient] = await db
.insert(clients)
.values({
name: parsed.data.name,
brand_name: parsed.data.brand_name,
brief: parsed.data.brief,
name: input.name,
brand_name: input.brand_name,
brief: input.brief,
email: input.email?.trim() || null,
phone: input.phone?.trim() || null,
slug,
})
.returning({ id: clients.id, token: clients.token, brand_name: clients.brand_name });
.returning({ id: clients.id, brand_name: clients.brand_name });
// Create a default project for the client — all work items are project-scoped
const [newProject] = await db
.insert(projects)
.values({
client_id: newClient.id,
name: newClient.brand_name,
created_from_lead_id: input.created_from_lead_id ?? null,
})
.returning({ id: projects.id });
// Always create two payment stubs per project — Acconto 50% and Saldo 50%
await db.insert(payments).values([
{
project_id: newProject.id,
label: "Acconto 50%",
amount: "0",
status: "da_saldare",
},
{
project_id: newProject.id,
label: "Saldo 50%",
amount: "0",
status: "da_saldare",
},
{ project_id: newProject.id, label: "Acconto 50%", amount: "0", status: "da_saldare" },
{ project_id: newProject.id, label: "Saldo 50%", amount: "0", status: "da_saldare" },
]);
return { clientId: newClient.id, projectId: newProject.id };
}
export async function createClient(formData: FormData) {
await requireAdmin();
const parsed = createClientSchema.safeParse({
name: formData.get("name") as string,
brand_name: formData.get("brand_name") as string,
brief: formData.get("brief") as string,
email: (formData.get("email") as string) ?? "",
phone: (formData.get("phone") as string) ?? "",
});
if (!parsed.success) {
throw new Error(parsed.error.issues.map((i) => i.message).join(", "));
}
const { clientId } = await createClientCore(parsed.data);
revalidatePath("/admin");
redirect(`/admin/clients/${newClient.id}`);
redirect(`/admin/clients/${clientId}`);
}
+20
View File
@@ -45,6 +45,26 @@ export default function NewClientPage() {
required
/>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div className="space-y-1">
<Label htmlFor="email">Email</Label>
<Input
id="email"
name="email"
type="email"
placeholder="es. marco@rossistudio.it"
/>
</div>
<div className="space-y-1">
<Label htmlFor="phone">Telefono</Label>
<Input
id="phone"
name="phone"
type="tel"
placeholder="es. +39 333 1234567"
/>
</div>
</div>
<div className="space-y-1">
<Label htmlFor="brief">Brief del progetto</Label>
<Textarea
+9 -5
View File
@@ -1,18 +1,21 @@
import { notFound } from "next/navigation";
import { getActivityLog, getUpcomingReminders, getTranscripts } from "@/lib/lead-service";
import { getLeadsWithTags, getLeadFieldOptions } from "@/lib/admin-queries";
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 [leads, options] = await Promise.all([
getLeadsWithTags(),
const [lead, options, convertedClientId] = await Promise.all([
getLeadByIdWithTags(id),
getLeadFieldOptions(),
getClientIdFromLead(id),
]);
const lead = leads.find((l) => l.id === id);
if (!lead) {
notFound();
}
@@ -31,6 +34,7 @@ export default async function LeadDetailPage({ params }: { params: Promise<{ id:
tags={lead.tags}
tagOptions={options.tags}
transcripts={transcripts}
convertedClientId={convertedClientId}
/>
);
}
+52 -1
View File
@@ -2,7 +2,7 @@
import { z } from "zod";
import { db } from "@/db";
import { leads, quotes, tags, clientTranscripts } from "@/db/schema";
import { leads, quotes, tags, clientTranscripts, projects } from "@/db/schema";
import { nanoid } from "nanoid";
import { eq, and } from "drizzle-orm";
import {
@@ -12,10 +12,61 @@ import {
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/leads");
revalidatePath("/admin");
redirect(`/admin/clients/${clientId}`);
}
export async function createLead(data: z.infer<typeof createLeadSchema>) {
const parsed = createLeadSchema.safeParse(data);