5177a3700a
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>
464 lines
19 KiB
TypeScript
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'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"
|
|
>
|
|
Sì, 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'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'offerta
|
|
<span className="block text-[#71717a]">
|
|
Crea le fasi raggruppando i servizi per «Fase»; 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>
|
|
);
|
|
}
|