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 ( +
+ {/* Tipo soggetto */} +
+ +
+ + +
+
+ + {/* Select lead o cliente */} + {subjectType === "lead" ? ( +
+ + +
+ ) : ( +
+ + +
+ )} + + {/* Select offerta */} +
+ + +
+ + {/* Errore */} + {error && ( +

+ {error} +

+ )} + + {/* Info generazione */} +
+ L'AI (Claude Opus) legge i transcript del soggetto selezionato e genera una bozza + personalizzata. La generazione richiede 30–60 secondi. +
+ + {/* Submit */} + +
+ ); +} 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 → + +
+ ) : ( +
+ + + + + + + + + + + + {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 ( + + + + + + + + + ); + })} + +
TitoloLead / ClienteOffertaStatoData +
+ + {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 */}