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 ───────────────────────────────────────────────────────────────
|
// ── 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);
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,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}`);
|
||||||
}
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -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(),
|
||||||
|
|||||||
@@ -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> {
|
||||||
|
|||||||
Reference in New Issue
Block a user