diff --git a/src/app/admin/clients/[id]/actions.ts b/src/app/admin/clients/[id]/actions.ts
index a5f66b5..4802d67 100644
--- a/src/app/admin/clients/[id]/actions.ts
+++ b/src/app/admin/clients/[id]/actions.ts
@@ -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);
diff --git a/src/app/admin/clients/[id]/edit/page.tsx b/src/app/admin/clients/[id]/edit/page.tsx
index 0cda1bb..357fbc4 100644
--- a/src/app/admin/clients/[id]/edit/page.tsx
+++ b/src/app/admin/clients/[id]/edit/page.tsx
@@ -62,6 +62,29 @@ export default async function EditClientPage({
/>
+
+
+
diff --git a/src/db/migrations/0011_client_contact_lead_archived.sql b/src/db/migrations/0011_client_contact_lead_archived.sql
new file mode 100644
index 0000000..1606739
--- /dev/null
+++ b/src/db/migrations/0011_client_contact_lead_archived.sql
@@ -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;
diff --git a/src/db/schema.ts b/src/db/schema.ts
index 26f8f2b..f38c01b 100644
--- a/src/db/schema.ts
+++ b/src/db/schema.ts
@@ -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(),
diff --git a/src/lib/admin-queries.ts b/src/lib/admin-queries.ts
index 8053fed..e9b6703 100644
--- a/src/lib/admin-queries.ts
+++ b/src/lib/admin-queries.ts
@@ -892,6 +892,7 @@ export async function getLeadsWithTags(): Promise {
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 {
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();
@@ -915,6 +917,54 @@ export async function getLeadsWithTags(): Promise {
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 {
+ 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 {
+ 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 {