Files
clienthub/src/components/admin/tabs/OffersTab.tsx
T
simone 5177a3700a feat(offers): ciclo di vita dei servizi ricorrenti (v2.4 Phase 13)
Un retainer, una volta assegnato, non si poteva fermare: project_offers
aveva solo start_date e il forecast sommava il canone a ogni mese
dell'orizzonte da li in poi, per sempre. Un cliente che disdiceva
continuava a gonfiare il forecast a 12 mesi e a vedersi l'abbonamento
attivo nel portale.

- migration 0016 (gia applicata a prod): project_offers.status
  (attivo|sospeso|cessato, CHECK) + end_date. Additiva pura, default
  'attivo' cosi le righe esistenti conservano il comportamento di prima
- forecast: i retainer si fermano a end_date, sospesi e cessati escono.
  getOffersSoldBreakdown NON filtra per stato: e uno storico di vendita,
  escludere le cessate riscriverebbe il passato
- offersAcceptedTotal esclude le cessate (default del piano pagamenti)
- admin: comandi Sospendi/Riattiva/Cessa + data fine nella tab Offerte,
  solo per i ricorrenti. setProjectOfferLifecycle valida con Zod e filtra
  anche per project_id, cosi un id arbitrario non tocca altri progetti
- portale: "Attivo dal", "fino al", badge In pausa, "Canone mensile"
  invece di "Prezzo finale"; le cessate non arrivano al client
- fix: un retainer sospeso continuava a intestare i pagamenti "Totale
  Pagamento Mensile" e a sovrascrivere l'importo

Igiene nello stesso giro:
- STATUS.md riscritto: era fermo al 22 giugno e diceva che node/docker non
  sono disponibili sul server e che le migrazioni si applicano da locale
  con uno script postgres.js — il contrario della procedura reale
- rimossi ChatSection/CommentList/CommentForm, senza importatori (308 righe)
- overrides postcss>=8.5.18 e sharp>=0.35.0: 3 CVE high transitive di Next
  senza fix upstream. npm audit ora pulito, build verde

Verifica: forecast controllato sui dati veri in 5 scenari (baseline
invariata, end_date, sospeso, cessato, ripristino); portale verificato nei
4 stati; tab admin verificata con Playwright sul build di produzione
(i comandi non compaiono sulle una tantum). Dati di test ripuliti,
tabelle protette invariate 4/5/11/10.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 18:18:28 +02:00

464 lines
19 KiB
TypeScript

"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<string, string> = {
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<string, string> = {
attivo: "Attivo",
sospeso: "Sospeso",
cessato: "Cessato",
};
function LifecycleBadge({ status }: { status: string }) {
return (
<span
className={`inline-block rounded-full border px-2 py-0.5 text-[11px] font-medium ${
LIFECYCLE_STYLES[status] ?? LIFECYCLE_STYLES.cessato
}`}
>
{LIFECYCLE_LABELS[status] ?? status}
</span>
);
}
/** Date → "YYYY-MM-DD" per il value di un <input type="date">. */
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 (
<span
className={`inline-block rounded text-[11px] font-medium px-1.5 py-0.5 ${
retainer ? "bg-blue-50 text-blue-700" : "bg-[#f4f4f5] text-[#52525b]"
}`}
>
{typeLabel(offerType)}
</span>
);
}
export function OffersTab({ projectId, projectOffers, availableMicros }: OffersTabProps) {
const [isPending, startTransition] = useTransition();
const router = useRouter();
const [macroId, setMacroId] = useState<string>("");
const [tierId, setTierId] = useState<string>("");
const [acceptedTotal, setAcceptedTotal] = useState<string>("");
const [importPhases, setImportPhases] = useState(true);
// id dell'offerta per cui "Cessa" attende conferma (due passaggi, come
// l'eliminazione in ClientActions).
const [ceaseArmed, setCeaseArmed] = useState<string | null>(null);
const [lifecycleError, setLifecycleError] = useState<string | null>(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 (
<div className="space-y-8 max-w-2xl">
{/* Active assignments */}
<div>
<h3 className="text-sm font-semibold text-[#1a1a1a] mb-3">Offerte Attive</h3>
{lifecycleError && (
<p className="mb-3 text-xs text-destructive">{lifecycleError}</p>
)}
{projectOffers.length === 0 ? (
<p className="text-sm text-[#71717a]">Nessuna offerta assegnata a questo progetto.</p>
) : (
<div className="space-y-3">
{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 (
<div
key={offer.id}
className="bg-white rounded-xl border border-[#e5e7eb] p-4 flex items-start justify-between gap-4"
>
<div className="flex-1 min-w-0">
<div className="flex flex-wrap items-center gap-2">
<p className="text-sm font-medium text-[#1a1a1a]">{offer.macro_internal_name}</p>
{offer.tier_letter && (
<span className="inline-block rounded bg-[#1A463C]/10 text-[#1A463C] text-[11px] font-semibold px-1.5 py-0.5">
Tier {offer.tier_letter}
</span>
)}
<TypeBadge offerType={offer.macro_offer_type} />
{isRetainer && <LifecycleBadge status={offer.status} />}
</div>
{pub && <p className="text-xs text-[#71717a] mt-1">Pubblico: {offer.micro_public_name}</p>}
<div className="flex items-center gap-2 mt-2">
<label className="text-xs text-[#71717a]">
{isRetainer ? "Canone mensile €" : "Totale accettato €"}
</label>
<input
type="number"
step="0.01"
min="0"
defaultValue={offer.accepted_total ?? ""}
placeholder="0.00"
onBlur={(e) => {
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"
/>
</div>
{/* 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 && (
<div className="mt-3 rounded-lg border border-border bg-card p-3">
<div className="flex flex-wrap items-center gap-x-4 gap-y-2">
<div className="flex items-center gap-2">
<label className="text-xs text-muted-foreground">Attivo fino al</label>
<input
type="date"
defaultValue={toDateInput(offer.end_date)}
disabled={isPending}
onChange={(e) => {
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 && (
<span className="text-[11px] text-muted-foreground">continuativo</span>
)}
</div>
<div className="flex items-center gap-2">
{offer.status === "attivo" ? (
<button
type="button"
disabled={isPending}
onClick={() => handleLifecycle(offer.id, { status: "sospeso" })}
className="text-xs text-muted-foreground underline-offset-2 hover:text-foreground hover:underline disabled:opacity-50"
>
Sospendi
</button>
) : (
<button
type="button"
disabled={isPending}
onClick={() =>
handleLifecycle(offer.id, { status: "attivo", end_date: null })
}
className="text-xs text-muted-foreground underline-offset-2 hover:text-foreground hover:underline disabled:opacity-50"
>
Riattiva
</button>
)}
{offer.status !== "cessato" &&
(ceaseArmed === offer.id ? (
<>
<span className="text-[11px] text-muted-foreground">
Chiudere l&apos;abbonamento?
</span>
<button
type="button"
disabled={isPending}
onClick={() => handleLifecycle(offer.id, { status: "cessato" })}
className="text-xs font-semibold text-destructive underline-offset-2 hover:underline disabled:opacity-50"
>
, cessa
</button>
<button
type="button"
onClick={() => setCeaseArmed(null)}
className="text-xs text-muted-foreground hover:text-foreground"
>
Annulla
</button>
</>
) : (
<button
type="button"
disabled={isPending}
onClick={() => setCeaseArmed(offer.id)}
className="text-xs text-muted-foreground underline-offset-2 hover:text-destructive hover:underline disabled:opacity-50"
>
Cessa
</button>
))}
</div>
</div>
<p className="mt-2 text-[11px] leading-relaxed text-muted-foreground">
Sospeso e cessato escono dal forecast dei prossimi 12 mesi. Lo storico
delle offerte vendute non cambia.
</p>
</div>
)}
</div>
<button
type="button"
onClick={() => handleRemove(offer.id)}
disabled={isPending}
aria-label="Rimuovi offerta"
className="shrink-0 rounded-md p-1.5 text-[#a1a1aa] hover:bg-red-50 hover:text-red-500 transition-colors disabled:opacity-50"
>
<Trash2 className="h-4 w-4" />
</button>
</div>
);
})}
</div>
)}
</div>
{/* Assignment — step 1: offer, step 2: tier */}
<div>
<h3 className="text-sm font-semibold text-[#1a1a1a] mb-3">Assegna Offerta</h3>
{offers.length === 0 ? (
<p className="text-sm text-[#71717a]">
Nessuna offerta disponibile. Crea prima un&apos;offerta in{" "}
<a href="/admin/offers" className="text-[#1A463C] underline">Offerte</a>.
</p>
) : (
<div className="bg-white rounded-xl border border-[#e5e7eb] p-4 space-y-4">
{/* Step 1 — choose offer */}
<div>
<label className="text-xs text-[#71717a] block mb-1">Offerta</label>
<select
value={macroId}
onChange={(e) => selectMacro(e.target.value)}
className="w-full border border-[#e5e7eb] rounded-md px-3 py-2 text-sm bg-white focus:outline-none focus:ring-2 focus:ring-[#1A463C]/15"
>
<option value="">Seleziona offerta...</option>
{offers.map((o) => (
<option key={o.macro_id} value={o.macro_id}>
{o.name}
{o.category ? ` · ${o.category}` : ""} · {typeLabel(o.offer_type)}
</option>
))}
</select>
</div>
{/* Step 2 — choose tier */}
{selectedOffer && (
<div>
<label className="text-xs text-[#71717a] block mb-1.5">Tier</label>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-2">
{selectedOffer.tiers.map((t) => {
const active = tierId === t.id;
const price = formatEuro(t.public_price);
const pub = meaningfulPublicName(t);
return (
<button
key={t.id}
type="button"
onClick={() => setTierId(t.id)}
className={`text-left rounded-lg border p-3 transition-colors ${
active
? "border-[#1A463C] bg-[#1A463C]/5 ring-1 ring-[#1A463C]/20"
: "border-[#e5e7eb] hover:border-[#1A463C]/30 bg-white"
}`}
>
<div className="flex items-center justify-between">
<span className="text-sm font-semibold text-[#1a1a1a]">
{t.tier_letter ? `Tier ${t.tier_letter}` : t.public_name || "Tier"}
</span>
{active && <Check className="h-4 w-4 text-[#1A463C]" />}
</div>
{pub && <p className="text-xs text-[#71717a] mt-0.5 truncate">{pub}</p>}
<p className="text-sm text-[#1a1a1a] mt-1 tabular-nums">{price ?? "—"}</p>
</button>
);
})}
</div>
</div>
)}
{/* Options + submit */}
{selectedOffer && (
<div className="space-y-3 pt-1">
<div>
<label className="text-xs text-[#71717a] block mb-1">Totale accettato (opzionale)</label>
<input
value={acceptedTotal}
onChange={(e) => 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"
/>
</div>
<label className="flex items-start gap-2 text-xs text-[#1a1a1a] cursor-pointer">
<input
type="checkbox"
checked={importPhases}
onChange={(e) => setImportPhases(e.target.checked)}
className="mt-0.5 h-3.5 w-3.5 accent-[#1A463C]"
/>
<span>
Importa fasi e task dall&apos;offerta
<span className="block text-[#71717a]">
Crea le fasi raggruppando i servizi per &laquo;Fase&raquo;; ogni servizio diventa un task.
</span>
</span>
</label>
<button
type="button"
onClick={handleAssign}
disabled={isPending || !tierId}
className="bg-[#1A463C] text-white text-sm font-medium px-4 py-2 rounded-md hover:bg-[#1A463C]/90 transition-colors disabled:opacity-40"
>
{isPending ? "Assegnazione..." : "Assegna Offerta"}
</button>
</div>
)}
</div>
)}
</div>
</div>
);
}