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,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>
|
||||
);
|
||||
}
|
||||
@@ -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'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'AI (Claude Opus) legge i transcript del soggetto selezionato e genera una bozza
|
||||
personalizzata. La generazione richiede 30–60 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>
|
||||
);
|
||||
}
|
||||
@@ -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'offerta. L'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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user