"use client"; import { useMemo, useState, useTransition } from "react"; import { useRouter } from "next/navigation"; import { Check, Trash2 } from "lucide-react"; import { assignOfferToProject, removeProjectOffer, updateProjectOfferTotal, setProjectOfferLifecycle, } from "@/app/admin/projects/project-actions"; import type { ProjectOfferWithMicro } from "@/lib/admin-queries"; type AvailableMicro = { id: string; internal_name: string; public_name: string; duration_months: number; tier_letter: string | null; public_price: string | null; macro_id: string; macro_internal_name: string; macro_category: string | null; macro_offer_type: string; }; interface OffersTabProps { projectId: string; projectOffers: ProjectOfferWithMicro[]; availableMicros: AvailableMicro[]; } function typeLabel(offerType: string): string { return offerType === "retainer" ? "Ricorrente" : "Una tantum"; } function formatEuro(raw: string | null): string | null { if (raw == null || raw === "") return null; const n = parseFloat(raw); if (!Number.isFinite(n)) return null; return `€${n.toLocaleString("it-IT", { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`; } // public_name is only meaningful when it differs from the tier letter (it // defaults to the letter when unset). function meaningfulPublicName(m: { public_name: string; tier_letter: string | null }): string | null { const p = m.public_name?.trim(); if (!p) return null; if (m.tier_letter && p.toUpperCase() === m.tier_letter.toUpperCase()) return null; return p; } // Ciclo di vita (v2.4 Phase 13). Scritto a token semantici + coppie dark: // secondo il design system corrente, anche se la pagina attorno è ancora a // palette raw: al redesign di /admin/projects/[id] questa parte non si tocca. const LIFECYCLE_STYLES: Record = { attivo: "bg-emerald-50 text-emerald-600 border-emerald-100 dark:bg-emerald-950/40 dark:text-emerald-300 dark:border-emerald-900", sospeso: "bg-amber-50 text-amber-600 border-amber-100 dark:bg-amber-950/40 dark:text-amber-300 dark:border-amber-900", cessato: "bg-muted text-muted-foreground border-border", }; const LIFECYCLE_LABELS: Record = { attivo: "Attivo", sospeso: "Sospeso", cessato: "Cessato", }; function LifecycleBadge({ status }: { status: string }) { return ( {LIFECYCLE_LABELS[status] ?? status} ); } /** Date → "YYYY-MM-DD" per il value di un . */ function toDateInput(d: Date | null): string { if (!d) return ""; return new Date(d).toISOString().slice(0, 10); } function TypeBadge({ offerType }: { offerType: string }) { const retainer = offerType === "retainer"; return ( {typeLabel(offerType)} ); } export function OffersTab({ projectId, projectOffers, availableMicros }: OffersTabProps) { const [isPending, startTransition] = useTransition(); const router = useRouter(); const [macroId, setMacroId] = useState(""); const [tierId, setTierId] = useState(""); const [acceptedTotal, setAcceptedTotal] = useState(""); const [importPhases, setImportPhases] = useState(true); // id dell'offerta per cui "Cessa" attende conferma (due passaggi, come // l'eliminazione in ClientActions). const [ceaseArmed, setCeaseArmed] = useState(null); const [lifecycleError, setLifecycleError] = useState(null); // Group tiers by offer (macro), preserving query order. const offers = useMemo(() => { const map = new Map< string, { macro_id: string; name: string; category: string | null; offer_type: string; tiers: AvailableMicro[] } >(); for (const m of availableMicros) { let entry = map.get(m.macro_id); if (!entry) { entry = { macro_id: m.macro_id, name: m.macro_internal_name, category: m.macro_category, offer_type: m.macro_offer_type, tiers: [], }; map.set(m.macro_id, entry); } entry.tiers.push(m); } return Array.from(map.values()); }, [availableMicros]); const selectedOffer = offers.find((o) => o.macro_id === macroId) ?? null; function selectMacro(id: string) { setMacroId(id); setTierId(""); } function handleAssign() { if (!tierId) return; const fd = new FormData(); fd.set("project_id", projectId); fd.set("micro_id", tierId); if (acceptedTotal.trim()) fd.set("accepted_total", acceptedTotal.trim()); if (importPhases) fd.set("import_phases", "on"); startTransition(async () => { await assignOfferToProject(fd); setMacroId(""); setTierId(""); setAcceptedTotal(""); setImportPhases(true); router.refresh(); }); } function handleRemove(offerId: string) { if (!window.confirm("Rimuovere questa offerta dal progetto?")) return; startTransition(async () => { await removeProjectOffer(offerId, projectId); router.refresh(); }); } function handleTotalUpdate(offerId: string, value: string) { startTransition(async () => { await updateProjectOfferTotal(offerId, projectId, value); router.refresh(); }); } function handleLifecycle( offerId: string, input: { status: string; end_date?: string | null } ) { setLifecycleError(null); startTransition(async () => { const res = await setProjectOfferLifecycle(offerId, projectId, input); if (!res.ok) { setLifecycleError(res.error); return; } setCeaseArmed(null); router.refresh(); }); } return (
{/* Active assignments */}

Offerte Attive

{lifecycleError && (

{lifecycleError}

)} {projectOffers.length === 0 ? (

Nessuna offerta assegnata a questo progetto.

) : (
{projectOffers.map((offer) => { const pub = meaningfulPublicName({ public_name: offer.micro_public_name, tier_letter: offer.tier_letter, }); const isRetainer = offer.macro_offer_type === "retainer"; return (

{offer.macro_internal_name}

{offer.tier_letter && ( Tier {offer.tier_letter} )} {isRetainer && }
{pub &&

Pubblico: {offer.micro_public_name}

}
{ const val = e.currentTarget.value.trim(); if (val !== (offer.accepted_total ?? "")) handleTotalUpdate(offer.id, val); }} className="w-28 border border-[#e5e7eb] rounded-md px-2 py-1 text-xs tabular-nums focus:outline-none focus:ring-2 focus:ring-[#1A463C]/15" />
{/* Ciclo di vita — solo per i ricorrenti: per una una tantum "sospendere" o "cessare" non significa nulla, la durata è già data da duration_months. */} {isRetainer && (
{ const val = e.currentTarget.value; handleLifecycle(offer.id, { status: offer.status, end_date: val === "" ? null : val, }); }} className="rounded-md border border-border bg-background px-2 py-1 text-xs text-foreground focus:border-ring focus:outline-none focus:ring-1 focus:ring-ring" /> {!offer.end_date && ( continuativo )}
{offer.status === "attivo" ? ( ) : ( )} {offer.status !== "cessato" && (ceaseArmed === offer.id ? ( <> Chiudere l'abbonamento? ) : ( ))}

Sospeso e cessato escono dal forecast dei prossimi 12 mesi. Lo storico delle offerte vendute non cambia.

)}
); })}
)}
{/* Assignment — step 1: offer, step 2: tier */}

Assegna Offerta

{offers.length === 0 ? (

Nessuna offerta disponibile. Crea prima un'offerta in{" "} Offerte.

) : (
{/* Step 1 — choose offer */}
{/* Step 2 — choose tier */} {selectedOffer && (
{selectedOffer.tiers.map((t) => { const active = tierId === t.id; const price = formatEuro(t.public_price); const pub = meaningfulPublicName(t); return ( ); })}
)} {/* Options + submit */} {selectedOffer && (
setAcceptedTotal(e.target.value)} type="number" step="0.01" min="0" placeholder="0.00" className="w-full border border-[#e5e7eb] rounded-md px-3 py-2 text-sm tabular-nums focus:outline-none focus:ring-2 focus:ring-[#1A463C]/15" />
)}
)}
); }