diff --git a/.planning/STATE.md b/.planning/STATE.md
index 22b91de..08ba06c 100644
--- a/.planning/STATE.md
+++ b/.planning/STATE.md
@@ -3,15 +3,15 @@ gsd_state_version: 1.0
milestone: v2.2
milestone_name: — Sales Loop
status: executing
-stopped_at: "Phase 19 completa (19-01 done). Pronta per /gsd-plan-phase 20"
-last_updated: "2026-06-19T18:05:00.000Z"
-last_activity: 2026-06-19 -- Phase 19 complete — LeadsKanbanBoard + LeadsViewToggle (PIPE-01/PIPE-02)
+stopped_at: "Phase 21+22 codice completo — pending: (1) migration 0010 a prod via SSH tunnel; (2) dati reali in profile.ts"
+last_updated: "2026-06-20T14:00:00.000Z"
+last_activity: 2026-06-20 -- Phase 21+22 eseguite insieme — agente AI Opus 4.8 + deck pubblico 20+ slide + entry point LeadDetail + sidebar globale
progress:
total_phases: 5
- completed_phases: 2
- total_plans: 4
- completed_plans: 4
- percent: 40
+ completed_phases: 4
+ total_plans: 7
+ completed_plans: 6
+ percent: 80
---
# Project State
@@ -26,10 +26,10 @@ See: .planning/PROJECT.md (updated 2026-06-13)
## Current Position
-Phase: 20 (R3) Knowledge Base Cliente — pending planning
-Plan: —
-Status: Ready to plan
-Last activity: 2026-06-19 -- Phase 19 complete (19-01 done — PIPE-01/PIPE-02 verified)
+Phase: 20 (R3) Knowledge Base Cliente — ready to execute
+Plan: 3 piani (20-01 migration, 20-02 data layer, 20-03 UI)
+Status: Ready to execute
+Last activity: 2026-06-19 -- Phase 20 planned (3 piani, verification passed — KB-01/KB-02)
Progress (v2.2): [████░░░░░░] 40% — 2/5 fasi complete
diff --git a/.planning/phases/21-agente-ai-generazione-preventivo/21-PLAN.md b/.planning/phases/21-agente-ai-generazione-preventivo/21-PLAN.md
new file mode 100644
index 0000000..6dcba06
--- /dev/null
+++ b/.planning/phases/21-agente-ai-generazione-preventivo/21-PLAN.md
@@ -0,0 +1,72 @@
+# Phase 21+22 — Agente AI Preventivo + Pagina Pubblica Deck
+
+**Executed:** 2026-06-20
+**Status:** Code complete — pending migration 0010 to prod
+**Model used:** `claude-opus-4-8`
+**Note:** Le fasi 21 (AI generation) e 22 (public page) sono state eseguite in un'unica sessione per coerenza architetturale.
+
+---
+
+## Scope eseguito
+
+### Fase 21 — Agente AI (AI-01 / AI-02)
+- `@anthropic-ai/sdk@0.105.0` installato
+- `ANTHROPIC_API_KEY` aggiunta a `.env.local`
+- `src/lib/proposal/schema.ts` — Zod schema `ProposalContent` (sezioni AI)
+- `src/lib/proposal/agent.ts` — `generateProposalContent()` → Claude Opus 4.8, JSON strutturato validato Zod
+- `src/lib/proposal/assemble.ts` — `assembleProposal()` fonde AI + offerta DB + config consulente
+- `src/lib/proposal/profile.ts` — config statica consulente (bio, fatti, testimonianze, legal) — **editare con dati reali**
+- `src/lib/proposal/queries.ts` — `listProposals`, `getProposalById`, `getProposalBySlug`
+- `src/app/admin/preventivi/actions.ts` — `generateProposalDraft`, `publishProposal`, `updateProposalTitle`, `deleteProposal`
+- `src/app/admin/preventivi/page.tsx` — lista preventivi admin
+- `src/app/admin/preventivi/genera/page.tsx` + `GeneraProposalForm.tsx` — builder con pre-fill `?lead_id=X`
+- `src/app/admin/preventivi/[id]/page.tsx` — review bozza + pubblica + elimina
+- `src/components/admin/AdminSidebar.tsx` — voce "Preventivi" + CTA globale lime "Genera preventivo"
+- `src/components/admin/leads/LeadDetail.tsx` — pulsante "Genera preventivo" → `/admin/preventivi/genera?lead_id=X`
+
+### Fase 22 — Pagina pubblica deck (PUB-01 / PUB-02)
+- `src/app/preventivo/[slug]/page.tsx` — server component pubblico, gestisce stati draft/published/accepted/rejected
+- `src/app/preventivo/[slug]/actions.ts` — `acceptProposal` + `rejectProposal` con guard immutabilità `accepted_at`
+- `src/components/public/proposal/ProposalDeck.tsx` — deck navigabile (frecce ←/→ + dot cliccabili + keyboard), light mode iamcavalli
+- Sezioni (20+): Cover, Vision, Index, ChapterDivider, Strategist, Facts, Testimonials, ProblemNode, SynthesisDiagram, SolutionNode, SolutionSynthesis, Scope, Deliverables, Timeline, Pricing, StagesRecap, ComparisonMatrix, NextSteps, Accept, Closing
+
+### Schema DB
+- `src/db/migrations/0010_proposals.sql` — `CREATE TABLE IF NOT EXISTS proposals (...)` — **applicare a prod via SSH prima del push**
+- `src/db/schema.ts` — tabella `proposals` + relations + `Proposal`/`NewProposal` types
+
+---
+
+## Flusso end-to-end
+
+```
+Lead (con transcript) → LeadDetail "Genera preventivo"
+→ /admin/preventivi/genera?lead_id=X
+→ seleziona offerta → "Genera con AI"
+→ server action: legge transcript + offerta → chiama Claude Opus 4.8 → Zod validate
+→ assembla AssembledProposal (AI + offerta + config) → salva in proposals (draft)
+→ redirect /admin/preventivi/[id] → review bozza → "Pubblica"
+→ pagina pubblica /preventivo/[slug] — deck 20+ slide
+→ cliente sceglie tier A/B/C → accetta → accepted_at IMMUTABILE
+```
+
+---
+
+## Pending post-push
+
+1. **Migration 0010 a prod** via `ssh -L 54321:localhost:54321 root@178.104.27.55` + script node
+2. **Dati reali in `src/lib/proposal/profile.ts`** — bio, credenziali, testimonianze, contatti, foto URL
+3. **`ANTHROPIC_API_KEY` in Coolify** (già in `.env.local` per dev)
+4. **Wave 5 (opzionale)** — badge CRM su accept/reject + email Resend
+
+---
+
+## Decisioni architetturali
+
+| Decisione | Scelta | Motivo |
+|-----------|--------|--------|
+| Output AI | JSON strutturato → template fisso | Coerenza visiva garantita, zero rischio HTML rotto |
+| Bio/testimonianze | File config `profile.ts` | Editing semplice, no UI v1 |
+| Entry point | LeadDetail + sidebar globale | GSD prescrive LeadDetail; sidebar è additive |
+| `content` | `jsonb` (non `text`) | Parser automatico postgres-js, type-safe |
+| Modello | `claude-opus-4-8` | Migliore qualità copywriting strategico |
+| Phase split | 21+22 eseguite insieme | Dipendenza diretta, nessun vantaggio a splitparle |
diff --git a/package-lock.json b/package-lock.json
index d22cc52..94a15bc 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -8,6 +8,7 @@
"name": "clienthub",
"version": "0.1.0",
"dependencies": {
+ "@anthropic-ai/sdk": "^0.105.0",
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
@@ -59,6 +60,27 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
+ "node_modules/@anthropic-ai/sdk": {
+ "version": "0.105.0",
+ "resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.105.0.tgz",
+ "integrity": "sha512-sDyu+aM9cE6uZE+HgRjjHRb+qqb87GHZOx+8bE0YlWetdL1YcVLxn8h9ltxGOflyChTe6PMEo50kMQV4cw0hfg==",
+ "license": "MIT",
+ "dependencies": {
+ "json-schema-to-ts": "^3.1.1",
+ "standardwebhooks": "^1.0.0"
+ },
+ "bin": {
+ "anthropic-ai-sdk": "bin/cli"
+ },
+ "peerDependencies": {
+ "zod": "^3.25.0 || ^4.0.0"
+ },
+ "peerDependenciesMeta": {
+ "zod": {
+ "optional": true
+ }
+ }
+ },
"node_modules/@babel/code-frame": {
"version": "7.29.0",
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz",
@@ -3802,6 +3824,12 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/@stablelib/base64": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/@stablelib/base64/-/base64-1.0.1.tgz",
+ "integrity": "sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ==",
+ "license": "MIT"
+ },
"node_modules/@standard-schema/utils": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz",
@@ -6365,6 +6393,12 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/fast-sha256": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/fast-sha256/-/fast-sha256-1.3.0.tgz",
+ "integrity": "sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ==",
+ "license": "Unlicense"
+ },
"node_modules/fastq": {
"version": "1.20.1",
"resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz",
@@ -7342,6 +7376,19 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/json-schema-to-ts": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/json-schema-to-ts/-/json-schema-to-ts-3.1.1.tgz",
+ "integrity": "sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g==",
+ "license": "MIT",
+ "dependencies": {
+ "@babel/runtime": "^7.18.3",
+ "ts-algebra": "^2.0.0"
+ },
+ "engines": {
+ "node": ">=16"
+ }
+ },
"node_modules/json-schema-traverse": {
"version": "0.4.1",
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz",
@@ -9038,6 +9085,16 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/standardwebhooks": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/standardwebhooks/-/standardwebhooks-1.0.0.tgz",
+ "integrity": "sha512-BbHGOQK9olHPMvQNHWul6MYlrRTAOKn03rOe4A8O3CLWhNf4YHBqq2HJKKC+sfqpxiBY52pNeesD6jIiLDz8jg==",
+ "license": "MIT",
+ "dependencies": {
+ "@stablelib/base64": "^1.0.0",
+ "fast-sha256": "^1.3.0"
+ }
+ },
"node_modules/stop-iteration-iterator": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz",
@@ -9329,6 +9386,12 @@
"node": ">=8.0"
}
},
+ "node_modules/ts-algebra": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/ts-algebra/-/ts-algebra-2.0.0.tgz",
+ "integrity": "sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw==",
+ "license": "MIT"
+ },
"node_modules/ts-api-utils": {
"version": "2.5.0",
"resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz",
diff --git a/package.json b/package.json
index ece017a..7e0a715 100644
--- a/package.json
+++ b/package.json
@@ -9,6 +9,7 @@
"lint": "eslint"
},
"dependencies": {
+ "@anthropic-ai/sdk": "^0.105.0",
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
diff --git a/src/app/admin/preventivi/[id]/page.tsx b/src/app/admin/preventivi/[id]/page.tsx
new file mode 100644
index 0000000..9814273
--- /dev/null
+++ b/src/app/admin/preventivi/[id]/page.tsx
@@ -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 (
+
+ {/* Breadcrumb */}
+
+
Preventivi
+
+
+ {/* Header */}
+
+
+
+ {proposal.title ?? "Senza titolo"}
+
+
+
+
+ Generato il {proposal.createdAt.toLocaleDateString("it-IT")} · {proposal.model ?? "AI"}
+
+
+
+
+
+ {/* Link pubblico */}
+ {(proposal.state === "published" || proposal.state === "accepted") && (
+
+
+ Apri deck
+
+ )}
+
+ {/* Pubblica */}
+ {isDraft && (
+
+ )}
+
+ {/* Elimina (solo draft) */}
+ {isDraft && (
+
+ )}
+
+
+
+ {/* Link pubblico (draft) */}
+ {isDraft && (
+
+ Bozza — non ancora visibile al cliente. Pubblica per generare il link pubblico.
+
+ )}
+ {!isDraft && (
+
+ {typeof window === "undefined" ? "" : window.location.origin}/preventivo/{proposal.slug}
+
+ )}
+
+ {/* Preview contenuto */}
+
+
Anteprima contenuto
+
+
+ {content.ai.vision.headline}
+ {content.ai.vision.body}
+
+
+
+
+ {content.ai.problems.map((p) => (
+
+
{p.id}. {p.title}
+
{p.subtitle}
+
+ ))}
+
+
+
+
+ {content.ai.scope.scopeTitle}
+ {content.ai.scope.scopeBody}
+
+
+
+
+ {content.offer.tiers.map((t) => (
+
+
{t.tierLetter}
+
{t.publicName}
+
+ {t.publicPrice ? `€${t.publicPrice}` : `€${t.servicesTotal} (calcolato)`}
+
+
+ ))}
+
+
+
+
+ );
+}
+
+function StateBadge({ state }: { state: string }) {
+ const map: Record = {
+ 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 (
+
+ {info.label}
+
+ );
+}
+
+function Section({ title, children }: { title: string; children: React.ReactNode }) {
+ return (
+
+
{title}
+ {children}
+
+ );
+}
diff --git a/src/app/admin/preventivi/actions.ts b/src/app/admin/preventivi/actions.ts
new file mode 100644
index 0000000..952931b
--- /dev/null
+++ b/src/app/admin/preventivi/actions.ts
@@ -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> = [];
+
+ 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,
+ 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");
+}
diff --git a/src/app/admin/preventivi/genera/GeneraProposalForm.tsx b/src/app/admin/preventivi/genera/GeneraProposalForm.tsx
new file mode 100644
index 0000000..a2497e5
--- /dev/null
+++ b/src/app/admin/preventivi/genera/GeneraProposalForm.tsx
@@ -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;
+ 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(null);
+
+ async function handleSubmit(e: React.FormEvent) {
+ 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 (
+
+ );
+}
diff --git a/src/app/admin/preventivi/genera/page.tsx b/src/app/admin/preventivi/genera/page.tsx
new file mode 100644
index 0000000..702c530
--- /dev/null
+++ b/src/app/admin/preventivi/genera/page.tsx
@@ -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 (
+
+
+
Genera preventivo
+
+ Scegli il lead o il cliente e l'offerta. L'AI legge i transcript e genera la bozza.
+
+
+
({ id: l.id, name: l.name, company: l.company }))}
+ clients={allClients}
+ offers={activeOffers}
+ action={generateProposalDraft}
+ preselectedLeadId={preselectedLeadId}
+ />
+
+ );
+}
diff --git a/src/app/admin/preventivi/page.tsx b/src/app/admin/preventivi/page.tsx
new file mode 100644
index 0000000..3b8d8f9
--- /dev/null
+++ b/src/app/admin/preventivi/page.tsx
@@ -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 = {
+ 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 (
+
+ {/* Header */}
+
+
+
Preventivi
+
+ Preventivi generati con AI e pubblicati ai clienti
+
+
+
+
+ Genera preventivo
+
+
+
+ {/* Lista */}
+ {proposals.length === 0 ? (
+
+
+
Nessun preventivo ancora.
+
+ Genera il primo preventivo →
+
+
+ ) : (
+
+
+
+
+ | Titolo |
+ Lead / Cliente |
+ Offerta |
+ Stato |
+ Data |
+ |
+
+
+
+ {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 (
+
+ |
+
+ {p.title ?? "Senza titolo"}
+
+ |
+ {subject} |
+ {p.offerName} |
+
+
+ {stateInfo.label}
+
+ |
+
+ {p.createdAt.toLocaleDateString("it-IT")}
+ |
+
+ {p.state === "published" || p.state === "accepted" ? (
+
+ Apri →
+
+ ) : null}
+ |
+
+ );
+ })}
+
+
+
+ )}
+
+ );
+}
diff --git a/src/app/preventivo/[slug]/actions.ts b/src/app/preventivo/[slug]/actions.ts
new file mode 100644
index 0000000..10d959d
--- /dev/null
+++ b/src/app/preventivo/[slug]/actions.ts
@@ -0,0 +1,61 @@
+"use server";
+
+import { db } from "@/db";
+import { proposals } from "@/db/schema";
+import { eq, and, isNull, isNotNull } from "drizzle-orm";
+import { revalidatePath } from "next/cache";
+
+// Guard immutabilità — accepted_at è IMMUTABILE una volta valorizzato
+async function getProposalForAction(slug: string) {
+ const [row] = await db
+ .select({ id: proposals.id, state: proposals.state, accepted_at: proposals.accepted_at })
+ .from(proposals)
+ .where(eq(proposals.slug, slug))
+ .limit(1);
+ return row ?? null;
+}
+
+export async function acceptProposal(
+ slug: string,
+ tier: string,
+ email?: string,
+ notes?: string
+) {
+ const proposal = await getProposalForAction(slug);
+ if (!proposal) throw new Error("Proposta non trovata");
+ if (proposal.accepted_at) throw new Error("Proposta già accettata");
+ if (proposal.state !== "published") throw new Error("Proposta non pubblicata");
+ if (!["A", "B", "C"].includes(tier)) throw new Error("Tier non valido");
+
+ await db
+ .update(proposals)
+ .set({
+ state: "accepted",
+ selected_tier: tier,
+ accepted_at: new Date(),
+ client_email: email?.trim() || null,
+ client_notes: notes?.trim() || null,
+ updated_at: new Date(),
+ })
+ .where(and(eq(proposals.slug, slug), isNull(proposals.accepted_at)));
+
+ revalidatePath(`/preventivo/${slug}`);
+}
+
+export async function rejectProposal(slug: string, notes?: string) {
+ const proposal = await getProposalForAction(slug);
+ if (!proposal) throw new Error("Proposta non trovata");
+ if (proposal.accepted_at) throw new Error("Proposta già accettata");
+ if (proposal.state !== "published") throw new Error("Proposta non pubblicata");
+
+ await db
+ .update(proposals)
+ .set({
+ state: "rejected",
+ client_notes: notes?.trim() || null,
+ updated_at: new Date(),
+ })
+ .where(and(eq(proposals.slug, slug), isNull(proposals.accepted_at)));
+
+ revalidatePath(`/preventivo/${slug}`);
+}
diff --git a/src/app/preventivo/[slug]/page.tsx b/src/app/preventivo/[slug]/page.tsx
new file mode 100644
index 0000000..2b427de
--- /dev/null
+++ b/src/app/preventivo/[slug]/page.tsx
@@ -0,0 +1,39 @@
+import { notFound } from "next/navigation";
+import { getProposalBySlug } from "@/lib/proposal/queries";
+import { ProposalDeck } from "@/components/public/proposal/ProposalDeck";
+import { acceptProposal, rejectProposal } from "./actions";
+
+export const revalidate = 0;
+
+export async function generateMetadata({ params }: { params: Promise<{ slug: string }> }) {
+ const { slug } = await params;
+ const proposal = await getProposalBySlug(slug);
+ if (!proposal) return { title: "Preventivo non trovato" };
+ return {
+ title: proposal.title ?? "Documento di Soluzione · iamcavalli",
+ description: `Proposta riservata — ${proposal.content.header.clientName} × ${proposal.content.consultant.name}`,
+ };
+}
+
+export default async function ProposalPage({ params }: { params: Promise<{ slug: string }> }) {
+ const { slug } = await params;
+ const proposal = await getProposalBySlug(slug);
+ if (!proposal) notFound();
+
+ // Proposta non ancora pubblicata
+ if (proposal.state === "draft") {
+ return (
+
+
Questa proposta non è ancora disponibile.
+
+ );
+ }
+
+ return (
+
+ );
+}
diff --git a/src/components/admin/AdminSidebar.tsx b/src/components/admin/AdminSidebar.tsx
index 3f46294..c8f8b64 100644
--- a/src/components/admin/AdminSidebar.tsx
+++ b/src/components/admin/AdminSidebar.tsx
@@ -12,16 +12,18 @@ import {
Settings,
LogOut,
Zap,
+ FileText,
} from "lucide-react";
const NAV_ITEMS = [
- { href: "/admin", label: "Dashboard", icon: LayoutDashboard, exact: true },
- { href: "/admin/clients", label: "Clienti", icon: Users },
- { href: "/admin/leads", label: "Lead", icon: Zap },
- { href: "/admin/projects", label: "Progetti", icon: FolderOpen },
- { href: "/admin/offers", label: "Offerte", icon: Tag },
- { href: "/admin/catalog", label: "Catalogo", icon: BookOpen },
- { href: "/admin/impostazioni", label: "Impostazioni", icon: Settings },
+ { href: "/admin", label: "Dashboard", icon: LayoutDashboard, exact: true },
+ { href: "/admin/clients", label: "Clienti", icon: Users },
+ { href: "/admin/leads", label: "Lead", icon: Zap },
+ { href: "/admin/projects", label: "Progetti", icon: FolderOpen },
+ { href: "/admin/preventivi", label: "Preventivi", icon: FileText },
+ { href: "/admin/offers", label: "Offerte", icon: Tag },
+ { href: "/admin/catalog", label: "Catalogo", icon: BookOpen },
+ { href: "/admin/impostazioni", label: "Impostazioni", icon: Settings },
];
export function AdminSidebar() {
@@ -37,6 +39,17 @@ export function AdminSidebar() {
iamcavalli
+ {/* CTA globale — genera preventivo */}
+
+
+
+ Genera preventivo
+
+
+
{/* Nav links */}