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:
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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" },
|
||||
]);
|
||||
|
||||
revalidatePath("/admin");
|
||||
redirect(`/admin/clients/${newClient.id}`);
|
||||
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/${clientId}`);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -7,7 +7,13 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
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 { it } from "date-fns/locale";
|
||||
import Link from "next/link";
|
||||
@@ -39,6 +45,7 @@ export function LeadDetail({
|
||||
tags,
|
||||
tagOptions,
|
||||
transcripts,
|
||||
convertedClientId,
|
||||
}: {
|
||||
lead: Lead;
|
||||
activities: Activity[];
|
||||
@@ -46,13 +53,23 @@ export function LeadDetail({
|
||||
tags: string[];
|
||||
tagOptions: string[];
|
||||
transcripts: ClientTranscript[];
|
||||
convertedClientId?: string | null;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const [, startTransition] = useTransition();
|
||||
const [converting, startConvert] = useTransition();
|
||||
const [tagError, setTagError] = useState<string | null>(null);
|
||||
const [expandedIds, setExpandedIds] = useState<Set<string>>(new Set());
|
||||
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>) {
|
||||
setTagError(null);
|
||||
startTransition(async () => {
|
||||
@@ -106,6 +123,25 @@ export function LeadDetail({
|
||||
>
|
||||
Genera preventivo
|
||||
</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} />
|
||||
</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;
|
||||
@@ -21,6 +21,10 @@ export const clients = pgTable("clients", {
|
||||
name: text("name").notNull(),
|
||||
brand_name: text("brand_name").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: text("token")
|
||||
.notNull()
|
||||
@@ -454,6 +458,9 @@ export const leads = pgTable("leads", {
|
||||
next_action: text("next_action"),
|
||||
next_action_date: timestamp("next_action_date", { withTimezone: true }),
|
||||
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 })
|
||||
.notNull()
|
||||
.defaultNow(),
|
||||
|
||||
@@ -892,6 +892,7 @@ export async function getLeadsWithTags(): Promise<LeadWithTags[]> {
|
||||
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,
|
||||
@@ -904,6 +905,7 @@ export async function getLeadsWithTags(): Promise<LeadWithTags[]> {
|
||||
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));
|
||||
|
||||
const leadMap = new Map<string, LeadWithTags>();
|
||||
@@ -915,6 +917,54 @@ export async function getLeadsWithTags(): Promise<LeadWithTags[]> {
|
||||
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 async function getLeadFieldOptions(): Promise<LeadFieldOptions> {
|
||||
|
||||
Reference in New Issue
Block a user