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:
2026-06-20 14:37:05 +02:00
parent 86c86cd420
commit fdcc938252
41 changed files with 2818 additions and 19 deletions
+163
View File
@@ -0,0 +1,163 @@
import { notFound } from "next/navigation";
import Link from "next/link";
import { getProposalById } from "@/lib/proposal/queries";
import { publishProposal, deleteProposal } from "../actions";
import { ExternalLink, ArrowLeft, Trash2 } from "lucide-react";
export const revalidate = 0;
export default async function ProposalDetailPage({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { id } = await params;
const proposal = await getProposalById(id);
if (!proposal) notFound();
const { content } = proposal;
const isDraft = proposal.state === "draft";
return (
<div className="max-w-4xl mx-auto space-y-6">
{/* Breadcrumb */}
<Link
href="/admin/preventivi"
className="inline-flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground transition-colors"
>
<ArrowLeft size={14} /> Preventivi
</Link>
{/* Header */}
<div className="flex items-start justify-between gap-4">
<div>
<h1 className="text-2xl font-semibold text-foreground">
{proposal.title ?? "Senza titolo"}
</h1>
<div className="flex items-center gap-3 mt-2">
<StateBadge state={proposal.state} />
<span className="text-sm text-muted-foreground">
Generato il {proposal.createdAt.toLocaleDateString("it-IT")} · {proposal.model ?? "AI"}
</span>
</div>
</div>
<div className="flex items-center gap-2 shrink-0">
{/* Link pubblico */}
{(proposal.state === "published" || proposal.state === "accepted") && (
<a
href={`/preventivo/${proposal.slug}`}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-1.5 px-3 py-2 border border-border rounded-md text-sm hover:bg-muted transition-colors"
>
<ExternalLink size={14} />
Apri deck
</a>
)}
{/* Pubblica */}
{isDraft && (
<form action={publishProposal.bind(null, id)}>
<button
type="submit"
className="px-4 py-2 bg-primary text-primary-foreground rounded-md text-sm font-medium hover:bg-primary/90 transition-colors"
>
Pubblica
</button>
</form>
)}
{/* Elimina (solo draft) */}
{isDraft && (
<form action={deleteProposal.bind(null, id)}>
<button
type="submit"
className="p-2 text-muted-foreground hover:text-destructive hover:bg-destructive/10 rounded-md transition-colors"
title="Elimina bozza"
>
<Trash2 size={16} />
</button>
</form>
)}
</div>
</div>
{/* Link pubblico (draft) */}
{isDraft && (
<div className="text-xs text-muted-foreground bg-yellow-50 border border-yellow-200 rounded-md px-3 py-2">
Bozza non ancora visibile al cliente. Pubblica per generare il link pubblico.
</div>
)}
{!isDraft && (
<div className="text-xs bg-muted border border-border rounded-md px-3 py-2 font-mono">
{typeof window === "undefined" ? "" : window.location.origin}/preventivo/{proposal.slug}
</div>
)}
{/* Preview contenuto */}
<div className="space-y-4">
<h2 className="text-base font-semibold text-foreground">Anteprima contenuto</h2>
<Section title="Vision">
<p className="font-medium">{content.ai.vision.headline}</p>
<p className="text-muted-foreground text-sm mt-1">{content.ai.vision.body}</p>
</Section>
<Section title={`Problemi (${content.ai.problems.length})`}>
<div className="space-y-2">
{content.ai.problems.map((p) => (
<div key={p.id} className="border border-border rounded-md px-3 py-2">
<p className="font-medium text-sm">{p.id}. {p.title}</p>
<p className="text-xs text-muted-foreground mt-0.5">{p.subtitle}</p>
</div>
))}
</div>
</Section>
<Section title="Scope">
<p className="font-medium text-sm">{content.ai.scope.scopeTitle}</p>
<p className="text-sm text-muted-foreground mt-1">{content.ai.scope.scopeBody}</p>
</Section>
<Section title={`Tier offerta (${content.offer.tiers.length})`}>
<div className="grid grid-cols-3 gap-3">
{content.offer.tiers.map((t) => (
<div key={t.id} className="border border-border rounded-md p-3 text-sm">
<p className="font-semibold text-primary">{t.tierLetter}</p>
<p className="font-medium mt-0.5">{t.publicName}</p>
<p className="text-muted-foreground mt-1">
{t.publicPrice ? `${t.publicPrice}` : `${t.servicesTotal} (calcolato)`}
</p>
</div>
))}
</div>
</Section>
</div>
</div>
);
}
function StateBadge({ state }: { state: string }) {
const map: Record<string, { label: string; className: string }> = {
draft: { label: "Bozza", className: "bg-yellow-100 text-yellow-800" },
published: { label: "Pubblicato", className: "bg-blue-100 text-blue-800" },
accepted: { label: "Accettato", className: "bg-green-100 text-green-800" },
rejected: { label: "Rifiutato", className: "bg-red-100 text-red-800" },
};
const info = map[state] ?? { label: state, className: "bg-gray-100 text-gray-700" };
return (
<span className={`inline-flex items-center px-2 py-0.5 rounded text-xs font-medium ${info.className}`}>
{info.label}
</span>
);
}
function Section({ title, children }: { title: string; children: React.ReactNode }) {
return (
<div className="border border-border rounded-lg p-4 space-y-2">
<h3 className="text-sm font-semibold text-muted-foreground uppercase tracking-wide">{title}</h3>
{children}
</div>
);
}
+140
View File
@@ -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");
}
@@ -0,0 +1,159 @@
"use client";
import { useState, useTransition } from "react";
import { Loader2 } from "lucide-react";
import type { OfferListCard } from "@/lib/offer-queries";
type Props = {
leads: Array<{ id: string; name: string; company: string | null }>;
clients: Array<{ id: string; name: string; brand_name: string }>;
offers: OfferListCard[];
action: (formData: FormData) => Promise<void>;
preselectedLeadId?: string;
};
export function GeneraProposalForm({ leads, clients, offers, action, preselectedLeadId }: Props) {
const [subjectType, setSubjectType] = useState<"lead" | "client">(
preselectedLeadId ? "lead" : "lead"
);
const [isPending, startTransition] = useTransition();
const [error, setError] = useState<string | null>(null);
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
setError(null);
const form = e.currentTarget;
const formData = new FormData(form);
startTransition(async () => {
try {
await action(formData);
} catch (err) {
setError(err instanceof Error ? err.message : "Errore sconosciuto");
}
});
}
return (
<form onSubmit={handleSubmit} className="space-y-6 bg-white border border-border rounded-lg p-6">
{/* Tipo soggetto */}
<div className="space-y-2">
<label className="text-sm font-medium text-foreground">Soggetto</label>
<div className="flex gap-3">
<button
type="button"
onClick={() => setSubjectType("lead")}
className={`px-4 py-2 rounded-md text-sm border transition-colors ${
subjectType === "lead"
? "bg-primary text-primary-foreground border-primary"
: "border-border text-muted-foreground hover:border-primary/50"
}`}
>
Lead
</button>
<button
type="button"
onClick={() => setSubjectType("client")}
className={`px-4 py-2 rounded-md text-sm border transition-colors ${
subjectType === "client"
? "bg-primary text-primary-foreground border-primary"
: "border-border text-muted-foreground hover:border-primary/50"
}`}
>
Cliente
</button>
</div>
</div>
{/* Select lead o cliente */}
{subjectType === "lead" ? (
<div className="space-y-1">
<label htmlFor="lead_id" className="text-sm font-medium text-foreground">
Lead
</label>
<select
id="lead_id"
name="lead_id"
required
className="w-full border border-border rounded-md px-3 py-2 text-sm focus:outline-none focus:ring-1 focus:ring-primary"
>
<option value="">Seleziona un lead</option>
{leads.map((l) => (
<option key={l.id} value={l.id} selected={l.id === preselectedLeadId}>
{l.name}{l.company ? `${l.company}` : ""}
</option>
))}
</select>
</div>
) : (
<div className="space-y-1">
<label htmlFor="client_id" className="text-sm font-medium text-foreground">
Cliente
</label>
<select
id="client_id"
name="client_id"
required
className="w-full border border-border rounded-md px-3 py-2 text-sm focus:outline-none focus:ring-1 focus:ring-primary"
>
<option value="">Seleziona un cliente</option>
{clients.map((c) => (
<option key={c.id} value={c.id}>
{c.brand_name} ({c.name})
</option>
))}
</select>
</div>
)}
{/* Select offerta */}
<div className="space-y-1">
<label htmlFor="offer_macro_id" className="text-sm font-medium text-foreground">
Offerta
</label>
<select
id="offer_macro_id"
name="offer_macro_id"
required
className="w-full border border-border rounded-md px-3 py-2 text-sm focus:outline-none focus:ring-1 focus:ring-primary"
>
<option value="">Seleziona un&apos;offerta</option>
{offers.map((o) => (
<option key={o.id} value={o.id}>
{o.internal_name}{o.description ? `${o.description}` : ""}
</option>
))}
</select>
</div>
{/* Errore */}
{error && (
<p className="text-sm text-destructive bg-destructive/10 border border-destructive/20 rounded-md px-3 py-2">
{error}
</p>
)}
{/* Info generazione */}
<div className="text-xs text-muted-foreground bg-muted rounded-md px-3 py-2">
L&apos;AI (Claude Opus) legge i transcript del soggetto selezionato e genera una bozza
personalizzata. La generazione richiede 3060 secondi.
</div>
{/* Submit */}
<button
type="submit"
disabled={isPending}
className="w-full flex items-center justify-center gap-2 px-4 py-2.5 bg-primary text-primary-foreground rounded-md text-sm font-medium hover:bg-primary/90 disabled:opacity-60 transition-colors"
>
{isPending ? (
<>
<Loader2 size={16} className="animate-spin" />
Generazione in corso
</>
) : (
"Genera con AI →"
)}
</button>
</form>
);
}
+45
View File
@@ -0,0 +1,45 @@
import { getAllLeads } from "@/lib/lead-service";
import { getOfferListCards } from "@/lib/offer-queries";
import { db } from "@/db";
import { clients } from "@/db/schema";
import { asc } from "drizzle-orm";
import { generateProposalDraft } from "../actions";
import { GeneraProposalForm } from "./GeneraProposalForm";
export const revalidate = 0;
export default async function GeneraPage({
searchParams,
}: {
searchParams: Promise<{ lead_id?: string }>;
}) {
const { lead_id: preselectedLeadId } = await searchParams;
const [leads, offerCards, allClients] = await Promise.all([
getAllLeads(200),
getOfferListCards(),
db.select({ id: clients.id, name: clients.name, brand_name: clients.brand_name })
.from(clients)
.orderBy(asc(clients.name)),
]);
const activeOffers = offerCards.filter((o) => !o.is_archived);
return (
<div className="max-w-2xl mx-auto space-y-6">
<div>
<h1 className="text-2xl font-semibold text-foreground">Genera preventivo</h1>
<p className="text-sm text-muted-foreground mt-1">
Scegli il lead o il cliente e l&apos;offerta. L&apos;AI legge i transcript e genera la bozza.
</p>
</div>
<GeneraProposalForm
leads={leads.map((l) => ({ id: l.id, name: l.name, company: l.company }))}
clients={allClients}
offers={activeOffers}
action={generateProposalDraft}
preselectedLeadId={preselectedLeadId}
/>
</div>
);
}
+103
View File
@@ -0,0 +1,103 @@
import Link from "next/link";
import { listProposals } from "@/lib/proposal/queries";
import { FileText, Plus } from "lucide-react";
export const revalidate = 0;
const STATE_LABELS: Record<string, { label: string; className: string }> = {
draft: { label: "Bozza", className: "bg-yellow-100 text-yellow-800" },
published: { label: "Pubblicato", className: "bg-blue-100 text-blue-800" },
accepted: { label: "Accettato", className: "bg-green-100 text-green-800" },
rejected: { label: "Rifiutato", className: "bg-red-100 text-red-800" },
};
export default async function PreventiviPage() {
const proposals = await listProposals();
return (
<div className="max-w-5xl mx-auto space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-semibold text-foreground">Preventivi</h1>
<p className="text-sm text-muted-foreground mt-1">
Preventivi generati con AI e pubblicati ai clienti
</p>
</div>
<Link
href="/admin/preventivi/genera"
className="flex items-center gap-2 px-4 py-2 bg-primary text-primary-foreground rounded-md text-sm font-medium hover:bg-primary/90 transition-colors"
>
<Plus size={16} />
Genera preventivo
</Link>
</div>
{/* Lista */}
{proposals.length === 0 ? (
<div className="text-center py-20 border border-dashed border-border rounded-lg">
<FileText size={40} className="mx-auto text-muted-foreground mb-4" />
<p className="text-muted-foreground text-sm">Nessun preventivo ancora.</p>
<Link
href="/admin/preventivi/genera"
className="mt-4 inline-flex items-center gap-2 text-sm text-primary hover:underline"
>
Genera il primo preventivo
</Link>
</div>
) : (
<div className="border border-border rounded-lg overflow-hidden">
<table className="w-full text-sm">
<thead className="bg-muted border-b border-border">
<tr>
<th className="px-4 py-3 text-left font-medium text-muted-foreground">Titolo</th>
<th className="px-4 py-3 text-left font-medium text-muted-foreground">Lead / Cliente</th>
<th className="px-4 py-3 text-left font-medium text-muted-foreground">Offerta</th>
<th className="px-4 py-3 text-left font-medium text-muted-foreground">Stato</th>
<th className="px-4 py-3 text-left font-medium text-muted-foreground">Data</th>
<th className="px-4 py-3" />
</tr>
</thead>
<tbody>
{proposals.map((p) => {
const stateInfo = STATE_LABELS[p.state] ?? { label: p.state, className: "bg-gray-100 text-gray-700" };
const subject = p.clientName ?? p.leadName ?? "—";
return (
<tr key={p.id} className="border-b border-border last:border-0 hover:bg-muted/50 transition-colors">
<td className="px-4 py-3 font-medium">
<Link href={`/admin/preventivi/${p.id}`} className="hover:text-primary transition-colors">
{p.title ?? "Senza titolo"}
</Link>
</td>
<td className="px-4 py-3 text-muted-foreground">{subject}</td>
<td className="px-4 py-3 text-muted-foreground">{p.offerName}</td>
<td className="px-4 py-3">
<span className={`inline-flex items-center px-2 py-0.5 rounded text-xs font-medium ${stateInfo.className}`}>
{stateInfo.label}
</span>
</td>
<td className="px-4 py-3 text-muted-foreground">
{p.createdAt.toLocaleDateString("it-IT")}
</td>
<td className="px-4 py-3 text-right">
{p.state === "published" || p.state === "accepted" ? (
<a
href={`/preventivo/${p.slug}`}
target="_blank"
rel="noopener noreferrer"
className="text-xs text-primary hover:underline"
>
Apri
</a>
) : null}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
</div>
);
}