feat(21-22): agente AI generazione preventivo + pagina pubblica deck
- Migration 0010: tabella proposals (id, slug, lead_id, client_id, offer_macro_id, content jsonb, state, selected_tier, accepted_at) applicata a prod via SSH tunnel - @anthropic-ai/sdk@0.105.0 installato; ANTHROPIC_API_KEY in .env.local - src/lib/proposal/: schema Zod ProposalContent, agente Claude Opus 4.8, assemble (AI + offerta DB + config consulente), queries, profile.ts - Admin: /admin/preventivi lista + /genera (pre-fill ?lead_id=X) + /[id] review - Sidebar: voce Preventivi + CTA globale lime "Genera preventivo" - LeadDetail: pulsante "Genera preventivo" → /admin/preventivi/genera?lead_id=X - Pagina pubblica /preventivo/[slug]: deck 20+ slide light-mode iamcavalli, navigazione frecce + dot + keyboard, accept/reject con guard immutabilità - STATE.md aggiornato (80%), 21-PLAN.md scritto nel formato GSD Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,140 @@
|
||||
"use server";
|
||||
|
||||
import { db } from "@/db";
|
||||
import { proposals, leads, clients } from "@/db/schema";
|
||||
import { nanoid } from "nanoid";
|
||||
import { eq, and, isNull } from "drizzle-orm";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { redirect } from "next/navigation";
|
||||
import { getServerSession } from "next-auth";
|
||||
import { authOptions } from "@/lib/auth";
|
||||
import { generateProposalContent } from "@/lib/proposal/agent";
|
||||
import { assembleProposal } from "@/lib/proposal/assemble";
|
||||
import { getOfferEditorData } from "@/lib/offer-queries";
|
||||
import { getTranscripts } from "@/lib/lead-service";
|
||||
|
||||
async function requireAdmin() {
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session) throw new Error("Non autorizzato");
|
||||
}
|
||||
|
||||
// Genera una bozza di preventivo con AI e salva nel DB
|
||||
export async function generateProposalDraft(formData: FormData) {
|
||||
await requireAdmin();
|
||||
|
||||
const leadId = formData.get("lead_id") as string | null;
|
||||
const clientId = formData.get("client_id") as string | null;
|
||||
const offerMacroId = formData.get("offer_macro_id") as string;
|
||||
|
||||
if (!offerMacroId) throw new Error("offer_macro_id richiesto");
|
||||
if (!leadId && !clientId) throw new Error("lead_id o client_id richiesto");
|
||||
|
||||
// Carica dati necessari
|
||||
const offer = await getOfferEditorData(offerMacroId);
|
||||
if (!offer) throw new Error("Offerta non trovata");
|
||||
|
||||
let lead = undefined;
|
||||
let client = undefined;
|
||||
let transcripts: Awaited<ReturnType<typeof getTranscripts>> = [];
|
||||
|
||||
if (leadId) {
|
||||
const [leadRow] = await db
|
||||
.select({ id: leads.id, name: leads.name, email: leads.email, company: leads.company, notes: leads.notes })
|
||||
.from(leads)
|
||||
.where(eq(leads.id, leadId))
|
||||
.limit(1);
|
||||
lead = leadRow;
|
||||
transcripts = await getTranscripts(leadId);
|
||||
} else if (clientId) {
|
||||
const [clientRow] = await db
|
||||
.select({ id: clients.id, name: clients.name, brand_name: clients.brand_name, brief: clients.brief })
|
||||
.from(clients)
|
||||
.where(eq(clients.id, clientId))
|
||||
.limit(1);
|
||||
client = clientRow;
|
||||
// Trascrizioni del cliente (via client_id)
|
||||
const { clientTranscripts } = await import("@/db/schema");
|
||||
const { eq: drizzleEq, desc } = await import("drizzle-orm");
|
||||
transcripts = await db
|
||||
.select()
|
||||
.from(clientTranscripts)
|
||||
.where(drizzleEq(clientTranscripts.client_id, clientId))
|
||||
.orderBy(desc(clientTranscripts.call_date));
|
||||
}
|
||||
|
||||
const MODEL = "claude-opus-4-8";
|
||||
|
||||
// Chiama agente AI
|
||||
const { content: aiContent } = await generateProposalContent({
|
||||
lead,
|
||||
client,
|
||||
transcripts,
|
||||
offer,
|
||||
});
|
||||
|
||||
// Assembla contenuto completo
|
||||
const assembled = assembleProposal({
|
||||
lead,
|
||||
client,
|
||||
offer,
|
||||
aiContent,
|
||||
model: MODEL,
|
||||
});
|
||||
|
||||
// Salva nel DB
|
||||
const id = nanoid();
|
||||
const slug = nanoid();
|
||||
const title = `${assembled.header.clientName} × ${assembled.header.offerTitle}`;
|
||||
|
||||
await db.insert(proposals).values({
|
||||
id,
|
||||
slug,
|
||||
lead_id: leadId ?? null,
|
||||
client_id: clientId ?? null,
|
||||
offer_macro_id: offerMacroId,
|
||||
title,
|
||||
content: assembled as unknown as Record<string, unknown>,
|
||||
model: MODEL,
|
||||
state: "draft",
|
||||
});
|
||||
|
||||
revalidatePath("/admin/preventivi");
|
||||
redirect(`/admin/preventivi/${id}`);
|
||||
}
|
||||
|
||||
// Pubblica la proposta (draft → published)
|
||||
export async function publishProposal(id: string) {
|
||||
await requireAdmin();
|
||||
|
||||
await db
|
||||
.update(proposals)
|
||||
.set({ state: "published", updated_at: new Date() })
|
||||
.where(and(eq(proposals.id, id), eq(proposals.state, "draft")));
|
||||
|
||||
revalidatePath(`/admin/preventivi/${id}`);
|
||||
revalidatePath("/admin/preventivi");
|
||||
}
|
||||
|
||||
// Aggiorna il titolo della proposta
|
||||
export async function updateProposalTitle(id: string, title: string) {
|
||||
await requireAdmin();
|
||||
|
||||
await db
|
||||
.update(proposals)
|
||||
.set({ title: title.trim(), updated_at: new Date() })
|
||||
.where(eq(proposals.id, id));
|
||||
|
||||
revalidatePath(`/admin/preventivi/${id}`);
|
||||
}
|
||||
|
||||
// Elimina una proposta (solo draft)
|
||||
export async function deleteProposal(id: string) {
|
||||
await requireAdmin();
|
||||
|
||||
await db
|
||||
.delete(proposals)
|
||||
.where(and(eq(proposals.id, id), eq(proposals.state, "draft")));
|
||||
|
||||
revalidatePath("/admin/preventivi");
|
||||
redirect("/admin/preventivi");
|
||||
}
|
||||
Reference in New Issue
Block a user