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 ─────────────────────────────────────────────────────────────── // ── CLIENT CRUD ───────────────────────────────────────────────────────────────
const emptyToNull = (v: unknown) => {
const s = typeof v === "string" ? v.trim() : "";
return s === "" ? null : s;
};
const clientSchema = z.object({ const clientSchema = z.object({
name: z.string().min(1, "Nome richiesto"), name: z.string().min(1, "Nome richiesto"),
brand_name: z.string().min(1, "Brand name richiesto"), brand_name: z.string().min(1, "Brand name richiesto"),
brief: z.string(), brief: z.string(),
email: z.string().optional().transform(emptyToNull),
phone: z.string().optional().transform(emptyToNull),
slug: z slug: z
.string() .string()
.regex(/^[a-z0-9-]{3,50}$/, "Slug non valido (es. mario-rossi, min 3 max 50 caratteri)") .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"), name: formData.get("name"),
brand_name: formData.get("brand_name"), brand_name: formData.get("brand_name"),
brief: formData.get("brief") ?? "", brief: formData.get("brief") ?? "",
email: formData.get("email") ?? "",
phone: formData.get("phone") ?? "",
slug: formData.get("slug") ?? "", slug: formData.get("slug") ?? "",
}); });
if (!parsed.success) throw new Error(parsed.error.issues[0].message); 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>
<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"> <div className="space-y-1.5">
<Label htmlFor="brief">Brief progetto</Label> <Label htmlFor="brief">Brief progetto</Label>
<Textarea <Textarea
+15
View File
@@ -33,6 +33,21 @@ export default async function ClientDetailPage({
<div> <div>
<h1 className="text-2xl font-bold text-[#1a1a1a]">{client.name}</h1> <h1 className="text-2xl font-bold text-[#1a1a1a]">{client.name}</h1>
<p className="text-sm text-[#71717a]">{client.brand_name}</p> <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 && ( {client.archived && (
<span className="inline-block mt-1 text-xs font-medium bg-[#f4f4f5] text-[#71717a] px-2 py-0.5 rounded-full"> <span className="inline-block mt-1 text-xs font-medium bg-[#f4f4f5] text-[#71717a] px-2 py-0.5 rounded-full">
Archiviato Archiviato
+44 -38
View File
@@ -44,62 +44,68 @@ const createClientSchema = z.object({
name: z.string().min(1, "Nome richiesto"), name: z.string().min(1, "Nome richiesto"),
brand_name: z.string().min(1, "Nome brand richiesto"), brand_name: z.string().min(1, "Nome brand richiesto"),
brief: z.string().min(1, "Brief richiesto"), brief: z.string().min(1, "Brief richiesto"),
email: z.string().optional(),
phone: z.string().optional(),
}); });
export async function createClient(formData: FormData) { // Shared core used by both the manual create form and the lead→client
await requireAdmin(); // conversion. Creates the client (+ slug + token), a default project (optionally
const raw = { // linked to the originating lead), and the two 50/50 payment stubs. Returns the
name: formData.get("name") as string, // new ids. Does NOT redirect — callers decide where to go next.
brand_name: formData.get("brand_name") as string, export async function createClientCore(input: {
brief: formData.get("brief") as string, 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 const [newClient] = await db
.insert(clients) .insert(clients)
.values({ .values({
name: parsed.data.name, name: input.name,
brand_name: parsed.data.brand_name, brand_name: input.brand_name,
brief: parsed.data.brief, brief: input.brief,
email: input.email?.trim() || null,
phone: input.phone?.trim() || null,
slug, 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 const [newProject] = await db
.insert(projects) .insert(projects)
.values({ .values({
client_id: newClient.id, client_id: newClient.id,
name: newClient.brand_name, name: newClient.brand_name,
created_from_lead_id: input.created_from_lead_id ?? null,
}) })
.returning({ id: projects.id }); .returning({ id: projects.id });
// Always create two payment stubs per project — Acconto 50% and Saldo 50%
await db.insert(payments).values([ await db.insert(payments).values([
{ { project_id: newProject.id, label: "Acconto 50%", amount: "0", status: "da_saldare" },
project_id: newProject.id, { project_id: newProject.id, label: "Saldo 50%", amount: "0", status: "da_saldare" },
label: "Acconto 50%",
amount: "0",
status: "da_saldare",
},
{
project_id: newProject.id,
label: "Saldo 50%",
amount: "0",
status: "da_saldare",
},
]); ]);
revalidatePath("/admin"); return { clientId: newClient.id, projectId: newProject.id };
redirect(`/admin/clients/${newClient.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/${clientId}`);
} }
+20
View File
@@ -45,6 +45,26 @@ export default function NewClientPage() {
required required
/> />
</div> </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"> <div className="space-y-1">
<Label htmlFor="brief">Brief del progetto</Label> <Label htmlFor="brief">Brief del progetto</Label>
<Textarea <Textarea
+9 -5
View File
@@ -1,18 +1,21 @@
import { notFound } from "next/navigation"; import { notFound } from "next/navigation";
import { getActivityLog, getUpcomingReminders, getTranscripts } from "@/lib/lead-service"; 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"; import { LeadDetail } from "@/components/admin/leads/LeadDetail";
export default async function LeadDetailPage({ params }: { params: Promise<{ id: string }> }) { export default async function LeadDetailPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params; const { id } = await params;
const [leads, options] = await Promise.all([ const [lead, options, convertedClientId] = await Promise.all([
getLeadsWithTags(), getLeadByIdWithTags(id),
getLeadFieldOptions(), getLeadFieldOptions(),
getClientIdFromLead(id),
]); ]);
const lead = leads.find((l) => l.id === id);
if (!lead) { if (!lead) {
notFound(); notFound();
} }
@@ -31,6 +34,7 @@ export default async function LeadDetailPage({ params }: { params: Promise<{ id:
tags={lead.tags} tags={lead.tags}
tagOptions={options.tags} tagOptions={options.tags}
transcripts={transcripts} transcripts={transcripts}
convertedClientId={convertedClientId}
/> />
); );
} }
+52 -1
View File
@@ -2,7 +2,7 @@
import { z } from "zod"; import { z } from "zod";
import { db } from "@/db"; 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 { nanoid } from "nanoid";
import { eq, and } from "drizzle-orm"; import { eq, and } from "drizzle-orm";
import { import {
@@ -12,10 +12,61 @@ import {
LEAD_STAGES, LEAD_STAGES,
} from "@/lib/lead-validators"; } from "@/lib/lead-validators";
import { revalidatePath } from "next/cache"; import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
import { createActivity, updateLeadStage } from "@/lib/lead-service"; import { createActivity, updateLeadStage } from "@/lib/lead-service";
import { createClientCore } from "@/app/admin/clients/new/actions";
import { getServerSession } from "next-auth"; import { getServerSession } from "next-auth";
import { authOptions } from "@/lib/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>) { export async function createLead(data: z.infer<typeof createLeadSchema>) {
const parsed = createLeadSchema.safeParse(data); const parsed = createLeadSchema.safeParse(data);
+37 -1
View File
@@ -7,7 +7,13 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { OptionMultiSelect } from "@/components/ui/option-multi-select"; import { OptionMultiSelect } from "@/components/ui/option-multi-select";
import { addLeadTag, removeLeadTag, renameLeadTag, deleteTranscript } from "@/app/admin/leads/actions"; import {
addLeadTag,
removeLeadTag,
renameLeadTag,
deleteTranscript,
convertLeadToClient,
} from "@/app/admin/leads/actions";
import { formatDistanceToNow, format } from "date-fns"; import { formatDistanceToNow, format } from "date-fns";
import { it } from "date-fns/locale"; import { it } from "date-fns/locale";
import Link from "next/link"; import Link from "next/link";
@@ -39,6 +45,7 @@ export function LeadDetail({
tags, tags,
tagOptions, tagOptions,
transcripts, transcripts,
convertedClientId,
}: { }: {
lead: Lead; lead: Lead;
activities: Activity[]; activities: Activity[];
@@ -46,13 +53,23 @@ export function LeadDetail({
tags: string[]; tags: string[];
tagOptions: string[]; tagOptions: string[];
transcripts: ClientTranscript[]; transcripts: ClientTranscript[];
convertedClientId?: string | null;
}) { }) {
const router = useRouter(); const router = useRouter();
const [, startTransition] = useTransition(); const [, startTransition] = useTransition();
const [converting, startConvert] = useTransition();
const [tagError, setTagError] = useState<string | null>(null); const [tagError, setTagError] = useState<string | null>(null);
const [expandedIds, setExpandedIds] = useState<Set<string>>(new Set()); const [expandedIds, setExpandedIds] = useState<Set<string>>(new Set());
const [deletingId, setDeletingId] = useState<string | null>(null); const [deletingId, setDeletingId] = useState<string | null>(null);
function handleConvert() {
const ok = window.confirm(
`Convertire "${lead.name}" in cliente?\n\nVerrà creato un cliente (con email, telefono e transcript del lead) e il lead verrà archiviato mantenendo lo stato "vinto".`
);
if (!ok) return;
startConvert(() => convertLeadToClient(lead.id));
}
function run(fn: () => Promise<unknown>) { function run(fn: () => Promise<unknown>) {
setTagError(null); setTagError(null);
startTransition(async () => { startTransition(async () => {
@@ -106,6 +123,25 @@ export function LeadDetail({
> >
Genera preventivo Genera preventivo
</Link> </Link>
{convertedClientId ? (
<Link
href={`/admin/clients/${convertedClientId}`}
className="inline-flex items-center gap-1.5 px-3 py-2 bg-[#1A463C]/10 text-[#1A463C] rounded-md text-sm font-semibold hover:bg-[#1A463C]/15 transition-colors"
>
Convertito Apri cliente
</Link>
) : (
lead.status === "won" && (
<button
type="button"
onClick={handleConvert}
disabled={converting}
className="inline-flex items-center gap-1.5 px-3 py-2 bg-[#1A463C] text-white rounded-md text-sm font-semibold hover:bg-[#1A463C]/90 transition-colors disabled:opacity-50"
>
{converting ? "Conversione..." : "Converti in cliente"}
</button>
)
)}
<EditLeadModal lead={lead} /> <EditLeadModal lead={lead} />
</div> </div>
</div> </div>
@@ -0,0 +1,8 @@
-- Additive: client contact fields (email/phone) + lead archive flag.
-- Reuse: lead.email/phone carry over to the client on conversion; email also
-- feeds the v2.3 Email & Accesso (OTP) milestone. leads.archived hides a
-- converted lead from the pipeline while keeping its "won" status.
-- No drops/truncates. Safe to re-run.
ALTER TABLE clients ADD COLUMN IF NOT EXISTS email text;
ALTER TABLE clients ADD COLUMN IF NOT EXISTS phone text;
ALTER TABLE leads ADD COLUMN IF NOT EXISTS archived boolean NOT NULL DEFAULT false;
+7
View File
@@ -21,6 +21,10 @@ export const clients = pgTable("clients", {
name: text("name").notNull(), name: text("name").notNull(),
brand_name: text("brand_name").notNull(), brand_name: text("brand_name").notNull(),
brief: text("brief").notNull(), brief: text("brief").notNull(),
// Contact fields (Phase v2.3) — carried over from a converted lead and used
// by the Email & Accesso (OTP) milestone. Admin-only, never exposed publicly.
email: text("email"),
phone: text("phone"),
// token is SEPARATE from id — rotatable secret for client link access // token is SEPARATE from id — rotatable secret for client link access
token: text("token") token: text("token")
.notNull() .notNull()
@@ -454,6 +458,9 @@ export const leads = pgTable("leads", {
next_action: text("next_action"), next_action: text("next_action"),
next_action_date: timestamp("next_action_date", { withTimezone: true }), next_action_date: timestamp("next_action_date", { withTimezone: true }),
notes: text("notes"), notes: text("notes"),
// Archive flag (v2.3) — a converted lead is hidden from the pipeline while
// keeping its "won" status, so the board doesn't fill up over time.
archived: boolean("archived").notNull().default(false),
created_at: timestamp("created_at", { withTimezone: true }) created_at: timestamp("created_at", { withTimezone: true })
.notNull() .notNull()
.defaultNow(), .defaultNow(),
+50
View File
@@ -892,6 +892,7 @@ export async function getLeadsWithTags(): Promise<LeadWithTags[]> {
next_action: leads.next_action, next_action: leads.next_action,
next_action_date: leads.next_action_date, next_action_date: leads.next_action_date,
notes: leads.notes, notes: leads.notes,
archived: leads.archived,
created_at: leads.created_at, created_at: leads.created_at,
updated_at: leads.updated_at, updated_at: leads.updated_at,
tag_name: tags.name, tag_name: tags.name,
@@ -904,6 +905,7 @@ export async function getLeadsWithTags(): Promise<LeadWithTags[]> {
eq(tags.entity_type, LEADS_TAG_ENTITY) eq(tags.entity_type, LEADS_TAG_ENTITY)
) )
) )
.where(eq(leads.archived, false)) // converted leads are archived → hidden from pipeline
.orderBy(desc(leads.updated_at), asc(tags.name)); .orderBy(desc(leads.updated_at), asc(tags.name));
const leadMap = new Map<string, LeadWithTags>(); const leadMap = new Map<string, LeadWithTags>();
@@ -915,6 +917,54 @@ export async function getLeadsWithTags(): Promise<LeadWithTags[]> {
return Array.from(leadMap.values()); return Array.from(leadMap.values());
} }
// Single lead by id WITH tags — does NOT filter archived (the detail page must
// still show a converted/archived lead).
export async function getLeadByIdWithTags(leadId: string): Promise<LeadWithTags | null> {
const rows = await db
.select({
id: leads.id,
name: leads.name,
email: leads.email,
phone: leads.phone,
company: leads.company,
status: leads.status,
last_contact_date: leads.last_contact_date,
next_action: leads.next_action,
next_action_date: leads.next_action_date,
notes: leads.notes,
archived: leads.archived,
created_at: leads.created_at,
updated_at: leads.updated_at,
tag_name: tags.name,
})
.from(leads)
.leftJoin(
tags,
and(eq(tags.entity_id, leads.id), eq(tags.entity_type, LEADS_TAG_ENTITY))
)
.where(eq(leads.id, leadId));
if (rows.length === 0) return null;
let result: LeadWithTags | null = null;
for (const row of rows) {
const { tag_name, ...leadFields } = row;
if (!result) result = { ...leadFields, tags: [] };
if (tag_name) result.tags.push(tag_name);
}
return result;
}
// If this lead was converted, returns the client id of the project created from
// it (projects.created_from_lead_id), else null.
export async function getClientIdFromLead(leadId: string): Promise<string | null> {
const [row] = await db
.select({ client_id: projects.client_id })
.from(projects)
.where(eq(projects.created_from_lead_id, leadId))
.limit(1);
return row?.client_id ?? null;
}
export type LeadFieldOptions = { status: string[]; tags: string[] }; export type LeadFieldOptions = { status: string[]; tags: string[] };
export async function getLeadFieldOptions(): Promise<LeadFieldOptions> { export async function getLeadFieldOptions(): Promise<LeadFieldOptions> {