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
+60 -1
View File
@@ -13,8 +13,9 @@ import {
services,
phases,
tasks,
PROJECT_OFFER_STATUSES,
} from "@/db/schema";
import { eq, asc } from "drizzle-orm";
import { eq, asc, and } from "drizzle-orm";
import { z } from "zod";
import { nanoid } from "nanoid";
@@ -292,4 +293,62 @@ export async function updateProjectOfferTotal(
.set({ accepted_total: amount.toFixed(2) })
.where(eq(project_offers.id, projectOfferId));
revalidatePath(`/admin/projects/${projectId}`);
}
// ── CICLO DI VITA DELL'OFFERTA ASSEGNATA (v2.4 Phase 13) ─────────────────────
const lifecycleSchema = z.object({
status: z.enum(PROJECT_OFFER_STATUSES),
// stringa "YYYY-MM-DD" dall'input date, oppure null per "continuativo"
end_date: z
.string()
.regex(/^\d{4}-\d{2}-\d{2}$/, "Data non valida")
.nullable()
.optional(),
});
export type LifecycleResult = { ok: true } | { ok: false; error: string };
/**
* Cambia stato e/o data di fine di un'offerta assegnata.
*
* Conta soprattutto per i retainer: prima della 0016 un abbonamento non poteva
* finire, e il forecast lo sommava a ogni mese in eterno (vedi
* getRevenueForecast12Months in src/lib/forecast-queries.ts).
*/
export async function setProjectOfferLifecycle(
projectOfferId: string,
projectId: string,
input: { status: string; end_date?: string | null }
): Promise<LifecycleResult> {
await requireAdmin();
const parsed = lifecycleSchema.safeParse(input);
if (!parsed.success) return { ok: false, error: parsed.error.issues[0].message };
const { status } = parsed.data;
let end_date: Date | null =
parsed.data.end_date ? new Date(`${parsed.data.end_date}T12:00:00Z`) : null;
// Un'offerta cessata ha per definizione una fine: senza end_date resterebbe
// "chiusa" ma priva della data che serve a delimitarla nei conteggi futuri.
if (status === "cessato" && !end_date) end_date = new Date();
// Riattivare azzera la data di fine, altrimenti l'offerta tornerebbe attiva
// ma già scaduta — stato incoerente e invisibile nel forecast.
if (status === "attivo" && parsed.data.end_date === undefined) end_date = null;
// project_id nella WHERE: un projectOfferId arbitrario non può toccare la
// riga di un altro progetto.
const updated = await db
.update(project_offers)
.set({ status, end_date })
.where(and(eq(project_offers.id, projectOfferId), eq(project_offers.project_id, projectId)))
.returning({ id: project_offers.id });
if (updated.length === 0) return { ok: false, error: "Offerta non trovata" };
revalidatePath(`/admin/projects/${projectId}`);
revalidatePath("/admin");
return { ok: true };
}
+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"
+8 -2
View File
@@ -22,8 +22,14 @@ interface ClientDashboardProps {
}
export function ClientDashboard({ view, token, comments, embedded = false }: ClientDashboardProps) {
// Determine payment display mode based on active offers
const retainerOffer = view.activeOffers?.find((o) => o.offer_type === "retainer");
// Determine payment display mode based on active offers.
// Solo i retainer ATTIVI cambiano la modalità: uno sospeso continuerebbe
// altrimenti a intestare i pagamenti "Totale Pagamento Mensile" e a
// sovrascrivere l'importo con un canone che al momento non si paga.
// (Le offerte cessate sono già escluse a monte, in client-view.ts.)
const retainerOffer = view.activeOffers?.find(
(o) => o.offer_type === "retainer" && o.status === "attivo"
);
const hasRetainer = !!retainerOffer;
const paymentLabel = hasRetainer ? "Totale Pagamento Mensile" : "Totale Investimento";
-213
View File
@@ -1,213 +0,0 @@
"use client";
import { useState, useTransition, useRef, useEffect } from "react";
import { useRouter } from "next/navigation";
import type { ClientView } from "@/lib/client-view";
import type { Comment } from "@/db/schema";
type Entity = { id: string; type: "general" | "task" | "deliverable"; label: string };
function buildEntityList(clientId: string, phases: ClientView["phases"]): Entity[] {
const entities: Entity[] = [
{ id: clientId, type: "general", label: "Messaggio generale" },
];
for (const phase of phases) {
for (const task of phase.tasks) {
entities.push({
id: task.id,
type: "task",
label: `${phase.title}${task.title}`,
});
for (const d of task.deliverables) {
entities.push({
id: d.id,
type: "deliverable",
label: `${task.title}${d.title}`,
});
}
}
}
return entities;
}
function buildLabelMap(entities: Entity[]): Map<string, string> {
const map = new Map<string, string>();
for (const e of entities) map.set(e.id, e.label);
return map;
}
function formatTime(ts: Date | string): string {
const d = typeof ts === "string" ? new Date(ts) : ts;
return d.toLocaleString("it-IT", {
day: "2-digit",
month: "short",
hour: "2-digit",
minute: "2-digit",
});
}
export function ChatSection({
clientId,
phases,
token,
comments,
}: {
clientId: string;
phases: ClientView["phases"];
token: string;
comments: Comment[];
}) {
const entities = buildEntityList(clientId, phases);
const labelMap = buildLabelMap(entities);
const [body, setBody] = useState("");
const [selectedEntityId, setSelectedEntityId] = useState(clientId);
const [error, setError] = useState<string | null>(null);
const [, startTransition] = useTransition();
const router = useRouter();
const bottomRef = useRef<HTMLDivElement>(null);
// Sort all comments chronologically
const sorted = [...comments].sort(
(a, b) => new Date(a.created_at).getTime() - new Date(b.created_at).getTime()
);
useEffect(() => {
bottomRef.current?.scrollIntoView({ behavior: "smooth" });
}, [comments.length]);
function handleSend(e: React.FormEvent) {
e.preventDefault();
const trimmed = body.trim();
if (!trimmed) return;
const selectedEntity = entities.find((en) => en.id === selectedEntityId);
if (!selectedEntity) return;
setError(null);
startTransition(async () => {
try {
const res = await fetch("/api/client/comment", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
token,
entity_type: selectedEntity.type,
entity_id: selectedEntity.id,
body: trimmed,
}),
});
if (!res.ok) {
const data = await res.json();
setError(data.error ?? "Errore nell'invio");
return;
}
setBody("");
router.refresh();
} catch {
setError("Errore di rete");
}
});
}
return (
<div className="flex flex-col rounded-xl border border-[#e5e7eb] overflow-hidden bg-white">
{/* Chat feed */}
<div className="flex-1 overflow-y-auto p-4 space-y-3 max-h-[480px] min-h-[200px]">
{sorted.length === 0 && (
<p className="text-sm text-[#71717a] italic text-center py-10">
Nessun messaggio ancora. Scrivi qui sotto per iniziare.
</p>
)}
{sorted.map((c) => {
const isClient = c.author === "client";
const entityLabel = labelMap.get(c.entity_id);
const showTag = c.entity_id !== clientId && entityLabel;
return (
<div
key={c.id}
className={`flex flex-col gap-1 ${isClient ? "items-end" : "items-start"}`}
>
{/* Author + tag */}
<div className={`flex items-center gap-2 ${isClient ? "flex-row-reverse" : ""}`}>
<span
className={`text-[10px] font-bold uppercase tracking-wide px-2 py-0.5 rounded-full ${
isClient
? "bg-[#DEF168] text-[#1A463C]"
: "bg-[#1A463C] text-white"
}`}
>
{isClient ? "Tu" : "iamcavalli"}
</span>
{showTag && (
<span className="text-[10px] text-[#71717a] bg-[#f4f4f5] px-2 py-0.5 rounded-full truncate max-w-[180px]">
{entityLabel}
</span>
)}
</div>
{/* Bubble */}
<div
className={`max-w-[75%] rounded-2xl px-4 py-2.5 text-sm leading-relaxed ${
isClient
? "bg-[#1A463C] text-white rounded-tr-sm"
: "bg-[#f4f4f5] text-[#1a1a1a] rounded-tl-sm"
}`}
>
{c.body}
</div>
{/* Timestamp */}
<span className="text-[10px] text-[#71717a]">{formatTime(c.created_at)}</span>
</div>
);
})}
<div ref={bottomRef} />
</div>
{/* Divider */}
<div className="border-t border-[#e5e7eb]" />
{/* Input area */}
<form onSubmit={handleSend} className="p-3 space-y-2 bg-[#fafafa]">
{/* Entity selector */}
<select
value={selectedEntityId}
onChange={(e) => setSelectedEntityId(e.target.value)}
className="w-full text-xs border border-[#e5e7eb] rounded-lg px-3 py-1.5 bg-white text-[#1a1a1a] focus:outline-none focus:ring-2 focus:ring-[#1A463C]/30"
>
{entities.map((en) => (
<option key={en.id} value={en.id}>
{en.label}
</option>
))}
</select>
<div className="flex gap-2">
<textarea
value={body}
onChange={(e) => setBody(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
handleSend(e as unknown as React.FormEvent);
}
}}
placeholder="Scrivi un messaggio… (Invio per inviare)"
rows={2}
className="flex-1 text-sm border border-[#e5e7eb] rounded-lg px-3 py-2 bg-white resize-none focus:outline-none focus:ring-2 focus:ring-[#1A463C]/30"
/>
<button
type="submit"
disabled={!body.trim()}
className="self-end px-4 py-2 rounded-lg bg-[#1A463C] text-white text-sm font-semibold disabled:opacity-40 hover:bg-[#1A463C]/90 transition-colors"
>
Invia
</button>
</div>
{error && <p className="text-xs text-red-600">{error}</p>}
</form>
</div>
);
}
-67
View File
@@ -1,67 +0,0 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea";
type Props = {
token: string;
entityType: "task" | "deliverable";
entityId: string;
};
export function CommentForm({ token, entityType, entityId }: Props) {
const router = useRouter();
const [body, setBody] = useState("");
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
if (!body.trim()) return;
setLoading(true);
setError(null);
try {
const res = await fetch("/api/client/comment", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ token, entity_type: entityType, entity_id: entityId, body }),
});
if (!res.ok) {
const data = await res.json();
setError(data.error ?? "Errore durante l'invio");
return;
}
setBody("");
router.refresh(); // Re-fetch Server Component to show new comment
} catch {
setError("Errore di rete");
} finally {
setLoading(false);
}
}
return (
<form onSubmit={handleSubmit} className="mt-3 flex gap-2 items-end">
<Textarea
value={body}
onChange={(e) => setBody(e.target.value)}
placeholder="Lascia un commento..."
rows={2}
className="text-sm resize-none flex-1"
/>
<div className="flex flex-col justify-end">
<Button
type="submit"
size="sm"
disabled={loading || !body.trim()}
className="text-xs"
>
{loading ? "Invio..." : "Invia"}
</Button>
</div>
{error && <p className="text-xs text-red-500 mt-1">{error}</p>}
</form>
);
}
-31
View File
@@ -1,31 +0,0 @@
import type { Comment } from "@/db/schema";
type Props = { comments: Comment[] };
export function CommentList({ comments }: Props) {
if (comments.length === 0) return null;
return (
<div className="mt-3 space-y-2">
{comments.map((c) => (
<div
key={c.id}
className={`flex gap-2 ${c.author === "admin" ? "flex-row-reverse" : ""}`}
>
<div
className={`rounded-lg px-3 py-2 text-xs max-w-xs ${
c.author === "admin"
? "bg-gray-900 text-white"
: "bg-gray-100 text-gray-800"
}`}
>
<p className="font-medium mb-0.5 opacity-60">
{c.author === "admin" ? "iamcavalli" : "Tu"}
</p>
<p>{c.body}</p>
</div>
</div>
))}
</div>
);
}
+35 -3
View File
@@ -15,9 +15,21 @@ interface ActiveOffer {
offer_type: string; // una_tantum | retainer
cumulative_price: string; // sum of service prices
accepted_total: string | null;
status: string; // attivo | sospeso — le cessate non arrivano qui
start_date: string; // ISO
end_date: string | null; // ISO — null = continuativo
services: IncludedService[];
}
/** "15 giugno 2026" — le date nel portale sono in italiano esteso. */
function formatDate(iso: string): string {
return new Date(iso).toLocaleDateString("it-IT", {
day: "numeric",
month: "long",
year: "numeric",
});
}
interface OffersSectionProps {
offers: ActiveOffer[];
}
@@ -27,6 +39,8 @@ function OfferCard({ offer }: { offer: ActiveOffer }) {
const price = parseFloat(offer.cumulative_price);
const hasPrice = price > 0;
const hasServices = offer.services.length > 0;
const isRetainer = offer.offer_type === "retainer";
const isSuspended = offer.status === "sospeso";
return (
<div className="bg-card rounded-xl border border-border-light shadow-card overflow-hidden">
@@ -34,7 +48,23 @@ function OfferCard({ offer }: { offer: ActiveOffer }) {
<div className="p-5">
<div className="border-l-2 border-primary pl-3">
{/* Heading: macro public name */}
<p className="text-sm font-bold text-foreground">{offer.offer_name}</p>
<div className="flex flex-wrap items-center gap-2">
<p className="text-sm font-bold text-foreground">{offer.offer_name}</p>
{isSuspended && (
<span className="inline-block rounded-full border border-amber-100 bg-amber-50 px-2 py-0.5 text-[11px] font-medium text-amber-600 dark:border-amber-900 dark:bg-amber-950/40 dark:text-amber-300">
In pausa
</span>
)}
</div>
{/* Periodo di attività — solo per i servizi ricorrenti: per una una
tantum "attivo dal" non aggiunge nulla. */}
{isRetainer && (
<p className="mt-1 text-[11px] text-muted-foreground">
Attivo dal {formatDate(offer.start_date)}
{offer.end_date && ` · fino al ${formatDate(offer.end_date)}`}
</p>
)}
<div className="mt-2 space-y-1.5">
{/* "Valore incluso" — hidden when 0 */}
@@ -45,10 +75,12 @@ function OfferCard({ offer }: { offer: ActiveOffer }) {
</div>
)}
{/* "Prezzo finale" */}
{/* Prezzo: per un ricorrente è il canone, non un totale una tantum */}
{offer.accepted_total && (
<div className="flex items-center justify-between text-xs">
<span className="font-semibold text-foreground">Prezzo finale</span>
<span className="font-semibold text-foreground">
{isRetainer ? "Canone mensile" : "Prezzo finale"}
</span>
<span className="text-sm font-bold text-primary">
{parseFloat(offer.accepted_total).toFixed(2)}
</span>
@@ -0,0 +1,38 @@
-- Additive: ciclo di vita delle offerte assegnate a un progetto (v2.4 Phase 13).
--
-- project_offers aveva solo start_date. Per i retainer il forecast somma
-- accepted_total a OGNI mese da start_date in poi, senza limite (vedi il ramo
-- retainer in src/lib/forecast-queries.ts): un abbonamento disdetto continuava
-- quindi a gonfiare il forecast a 12 mesi in eterno, e il portale continuava a
-- mostrarlo attivo al cliente.
--
-- status → attivo | sospeso | cessato. Default 'attivo' così TUTTE le righe
-- esistenti conservano esattamente il comportamento di oggi.
-- end_date → nullable. NULL = continuativo, che è la semantica implicita attuale.
--
-- Nessun DROP, nessun TRUNCATE, nessuna colonna rimossa o modificata.
-- Applicare a prod via SSH+docker exec PRIMA di pushare il codice dipendente.
-- Idempotente: safe to re-run.
ALTER TABLE project_offers
ADD COLUMN IF NOT EXISTS status text NOT NULL DEFAULT 'attivo';
ALTER TABLE project_offers
ADD COLUMN IF NOT EXISTS end_date timestamptz;
-- Vincolo sui valori ammessi. NOT VALID: non riverifica le righe esistenti
-- (tutte 'attivo' per il default, quindi già conformi) ed evita un lock lungo.
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'project_offers_status_check'
) THEN
ALTER TABLE project_offers
ADD CONSTRAINT project_offers_status_check
CHECK (status IN ('attivo', 'sospeso', 'cessato')) NOT VALID;
END IF;
END $$;
-- Il forecast filtra per stato su ogni riga non archiviata: indice utile appena
-- le righe crescono.
CREATE INDEX IF NOT EXISTS project_offers_status_idx ON project_offers (status);
+10
View File
@@ -431,9 +431,19 @@ export const project_offers = pgTable("project_offers", {
start_date: timestamp("start_date", { withTimezone: true }).notNull().defaultNow(),
// Offer-level accepted total — separate from projects.accepted_total (quote builder total)
accepted_total: numeric("accepted_total", { precision: 10, scale: 2 }),
// Ciclo di vita (v2.4 Phase 13): attivo | sospeso | cessato.
// Rilevante soprattutto per i retainer, che senza uno stato restavano nel
// forecast per sempre. CHECK sui valori in 0016_retainer_lifecycle.sql.
status: text("status").notNull().default("attivo"),
// NULL = continuativo (semantica implicita prima della 0016).
end_date: timestamp("end_date", { withTimezone: true }),
created_at: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
});
/** Stati del ciclo di vita di un'offerta assegnata a un progetto. */
export const PROJECT_OFFER_STATUSES = ["attivo", "sospeso", "cessato"] as const;
export type ProjectOfferStatus = (typeof PROJECT_OFFER_STATUSES)[number];
// ============ OFFER PHASES (hierarchical breakdown of offer micros) ============
export const offer_phases = pgTable("offer_phases", {
id: text("id")
+22 -3
View File
@@ -25,7 +25,7 @@ import {
clientTranscripts,
client_emails,
} from "@/db/schema";
import { eq, inArray, asc, desc, isNull, sql, and } from "drizzle-orm";
import { eq, ne, inArray, asc, desc, isNull, sql, and } from "drizzle-orm";
import { getPool } from "@/lib/taxonomy";
import { LEAD_STAGES } from "@/lib/lead-validators";
import type {
@@ -238,6 +238,9 @@ export type ProjectOfferWithMicro = {
macro_offer_type: string;
start_date: Date;
accepted_total: string | null;
// Ciclo di vita (v2.4 Phase 13): attivo | sospeso | cessato.
status: string;
end_date: Date | null;
created_at: Date;
};
@@ -684,6 +687,8 @@ export async function getProjectFullDetail(id: string): Promise<ProjectFullDetai
macro_offer_type: offer_macros.offer_type,
start_date: project_offers.start_date,
accepted_total: project_offers.accepted_total,
status: project_offers.status,
end_date: project_offers.end_date,
created_at: project_offers.created_at,
})
.from(project_offers)
@@ -759,8 +764,13 @@ export async function getProjectFullDetail(id: string): Promise<ProjectFullDetai
return true;
});
// Le offerte cessate non entrano nel totale: serve come default del piano
// pagamenti, e un abbonamento chiuso non è denaro da incassare.
const offersAcceptedTotal = (projectOffersRows as ProjectOfferWithMicro[]).reduce(
(sum, o) => sum + (o.accepted_total ? parseFloat(String(o.accepted_total)) : 0),
(sum, o) =>
o.status === "cessato"
? sum
: sum + (o.accepted_total ? parseFloat(String(o.accepted_total)) : 0),
0
);
@@ -863,6 +873,7 @@ export type ClientActiveOfferSummary = {
category: string | null; // Entry | Signature | Retainer (offer_macros.category)
offer_type: string; // una_tantum | retainer (fallback for category)
accepted_total: string | null;
status: string; // attivo | sospeso — le cessate non compaiono qui
};
export async function getClientActiveOffers(clientId: string): Promise<ClientActiveOfferSummary[]> {
@@ -887,11 +898,18 @@ export async function getClientActiveOffers(clientId: string): Promise<ClientAct
category: offer_macros.category,
offer_type: offer_macros.offer_type,
accepted_total: project_offers.accepted_total,
status: project_offers.status,
})
.from(project_offers)
.innerJoin(offer_micros, eq(project_offers.micro_id, offer_micros.id))
.innerJoin(offer_macros, eq(offer_micros.macro_id, offer_macros.id))
.where(inArray(project_offers.project_id, projectIds))
// La sezione si chiama "Offerte Attive": le cessate non ci vanno.
.where(
and(
inArray(project_offers.project_id, projectIds),
ne(project_offers.status, "cessato")
)
)
.orderBy(asc(project_offers.created_at));
return rows.map((r) => ({
@@ -904,6 +922,7 @@ export async function getClientActiveOffers(clientId: string): Promise<ClientAct
category: r.category,
offer_type: r.offer_type,
accepted_total: r.accepted_total ? String(r.accepted_total) : null,
status: r.status,
}));
}
+23 -3
View File
@@ -1,4 +1,4 @@
import { eq, inArray, asc, desc, sql } from "drizzle-orm";
import { eq, ne, and, inArray, asc, desc } from "drizzle-orm";
import { db } from "@/db";
import { clients, projects, phases, tasks, deliverables, payments, documents, notes, comments, project_offers, offer_micros, offer_micro_services, offer_services, offer_macros, offer_tier_services, services, clientTranscripts } from "@/db/schema";
@@ -59,6 +59,9 @@ export interface ClientView {
offer_type: string; // una_tantum | retainer
cumulative_price: string;
accepted_total: string | null;
status: string; // attivo | sospeso — le cessate non arrivano al cliente
start_date: string; // ISO
end_date: string | null; // ISO — null = continuativo
services: Array<{ name: string; description: string | null }>;
}>;
transcripts: Array<{
@@ -132,6 +135,9 @@ export interface ProjectView {
offer_type: string; // una_tantum | retainer
cumulative_price: string; // sum of assigned services.unit_price (new) or offer_services.price (legacy)
accepted_total: string | null;
status: string; // attivo | sospeso — le cessate non arrivano al cliente
start_date: string; // ISO
end_date: string | null; // ISO — null = continuativo
services: Array<{ name: string; description: string | null }>; // included services list
}>;
transcripts: Array<{
@@ -331,7 +337,10 @@ export async function getProjectView(projectId: string): Promise<ProjectView | n
.where(eq(notes.project_id, projectId))
.orderBy(asc(notes.created_at));
// Fetch active offers for this project (client-safe fields only — never internal_name)
// Fetch active offers for this project (client-safe fields only — never internal_name).
// Le offerte cessate sono escluse: un abbonamento chiuso non è un servizio
// attivo e il cliente non deve vederselo ancora addosso. Le sospese restano,
// marcate come tali — sapere che è in pausa è diverso dal vederlo sparire.
const projectOfferRows = await db
.select({
id: project_offers.id,
@@ -339,12 +348,20 @@ export async function getProjectView(projectId: string): Promise<ProjectView | n
offer_name: offer_macros.public_name, // macro-level public name — NEVER internal_name
offer_type: offer_macros.offer_type, // una_tantum | retainer
accepted_total: project_offers.accepted_total,
status: project_offers.status, // attivo | sospeso
start_date: project_offers.start_date,
end_date: project_offers.end_date,
micro_id: project_offers.micro_id,
})
.from(project_offers)
.innerJoin(offer_micros, eq(project_offers.micro_id, offer_micros.id))
.innerJoin(offer_macros, eq(offer_micros.macro_id, offer_macros.id))
.where(eq(project_offers.project_id, projectId));
.where(
and(
eq(project_offers.project_id, projectId),
ne(project_offers.status, "cessato")
)
);
// Cumulative price + service list per micro.
// Primary: offer_tier_services → services (Phase 12 catalog). Fallback: legacy offer_micro_services → offer_services.
@@ -420,6 +437,9 @@ export async function getProjectView(projectId: string): Promise<ProjectView | n
offer_type: o.offer_type,
cumulative_price,
accepted_total: o.accepted_total ? String(o.accepted_total) : null,
status: o.status,
start_date: o.start_date.toISOString(),
end_date: o.end_date ? o.end_date.toISOString() : null,
services: servicesList,
};
});
+28 -5
View File
@@ -1,6 +1,6 @@
import { db } from "@/db";
import { project_offers, offer_micros, offer_macros, projects } from "@/db/schema";
import { eq, sql, desc } from "drizzle-orm";
import { eq, ne, and, sql, desc } from "drizzle-orm";
export type ForecastMonth = {
year: number;
@@ -21,6 +21,11 @@ export type OffersSoldBreakdown = {
};
// Quante offerte sono state vendute (assegnate a un progetto), per offerta + tier.
//
// NON filtrare per project_offers.status: questo è un dato storico di vendita.
// Escludere le offerte cessate riscriverebbe il passato ogni volta che un
// cliente disdice, e "quante ne ho vendute" smetterebbe di essere una risposta
// stabile. Il ciclo di vita riguarda il forecast, non lo storico.
export async function getOffersSoldBreakdown(): Promise<OffersSoldBreakdown> {
const rows = await db
.select({
@@ -49,9 +54,15 @@ export async function getRevenueForecast12Months(): Promise<ForecastMonth[]> {
// Active offers only (non-archived projects). offer_type drives the math:
// - retainer: accepted_total è il CANONE MENSILE → ogni mese coperto riceve l'intero importo
// - una_tantum: accepted_total è il prezzo TOTALE → spalmato su duration_months
//
// Le offerte cessate escono dalla query: un abbonamento chiuso non è ricavo
// futuro. Prima della 0016 non esisteva modo di chiuderne uno, e un retainer
// disdetto restava nel forecast per sempre.
const offers = await db
.select({
start_date: project_offers.start_date,
end_date: project_offers.end_date,
status: project_offers.status,
duration_months: offer_micros.duration_months,
accepted_total: project_offers.accepted_total,
offer_type: offer_macros.offer_type,
@@ -60,7 +71,7 @@ export async function getRevenueForecast12Months(): Promise<ForecastMonth[]> {
.innerJoin(offer_micros, eq(project_offers.micro_id, offer_micros.id))
.innerJoin(offer_macros, eq(offer_micros.macro_id, offer_macros.id))
.innerJoin(projects, eq(project_offers.project_id, projects.id))
.where(eq(projects.archived, false));
.where(and(eq(projects.archived, false), ne(project_offers.status, "cessato")));
// Build 12-month bucket array starting from current month
const now = new Date();
@@ -83,11 +94,23 @@ export async function getRevenueForecast12Months(): Promise<ForecastMonth[]> {
const startMonthKey = start.getFullYear() * 12 + start.getMonth();
if (offer.offer_type === "retainer") {
// Canone ricorrente: contribuisce ad OGNI mese dell'orizzonte da start in poi,
// senza limite di duration_months (un retainer è continuativo).
// Un retainer sospeso non produce ricavo prevedibile: senza una data di
// ripresa non si può sapere quando ricomincia, quindi ai fini del forecast
// vale come chiuso. Scelta di prodotto, non conseguenza tecnica.
if (offer.status !== "attivo") continue;
// Canone ricorrente: contribuisce ad OGNI mese dell'orizzonte da start in
// poi, senza limite di duration_months (un retainer è continuativo) —
// ma ora si ferma a end_date se valorizzata. NULL = continuativo.
const endMonthKey = offer.end_date
? offer.end_date.getFullYear() * 12 + offer.end_date.getMonth()
: null;
for (const b of buckets) {
const bucketMonthKey = b.year * 12 + (b.month - 1);
if (bucketMonthKey >= startMonthKey) b.total += total;
if (bucketMonthKey < startMonthKey) continue;
if (endMonthKey !== null && bucketMonthKey > endMonthKey) continue;
b.total += total;
}
} else {
// Una tantum: prezzo totale ripartito su duration_months a partire da start.