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>
This commit is contained in:
2026-08-01 18:18:28 +02:00
parent d57b0f3e04
commit 5177a3700a
16 changed files with 616 additions and 545 deletions
+156 -1
View File
@@ -7,6 +7,7 @@ import {
assignOfferToProject,
removeProjectOffer,
updateProjectOfferTotal,
setProjectOfferLifecycle,
} from "@/app/admin/projects/project-actions";
import type { ProjectOfferWithMicro } from "@/lib/admin-queries";
@@ -49,6 +50,42 @@ function meaningfulPublicName(m: { public_name: string; tier_letter: string | nu
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 (
@@ -70,6 +107,10 @@ export function OffersTab({ projectId, projectOffers, availableMicros }: OffersT
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(() => {
@@ -133,11 +174,30 @@ export function OffersTab({ projectId, projectOffers, availableMicros }: OffersT
});
}
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>
) : (
@@ -147,6 +207,7 @@ export function OffersTab({ projectId, projectOffers, availableMicros }: OffersT
public_name: offer.micro_public_name,
tier_letter: offer.tier_letter,
});
const isRetainer = offer.macro_offer_type === "retainer";
return (
<div
key={offer.id}
@@ -161,10 +222,13 @@ export function OffersTab({ projectId, projectOffers, availableMicros }: OffersT
</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]">Totale accettato </label>
<label className="text-xs text-[#71717a]">
{isRetainer ? "Canone mensile €" : "Totale accettato €"}
</label>
<input
type="number"
step="0.01"
@@ -178,6 +242,97 @@ export function OffersTab({ projectId, projectOffers, availableMicros }: OffersT
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"