feat(portale): barra full-width, card offerta senza accordion, valore override

Barra di avanzamento a tutta larghezza (via il max-w-[1200px]).

Card offerta: rimosso l'accordion "Cosa è compreso". La lista servizi non
arriva più al client — client-view.ts legge dai servizi i soli prezzi — e
OffersSection perde il suo unico useState, quindi anche il "use client".

"Valore incluso" diventa "Valore dell'offerta" e accetta un override manuale
(migration 0020, project_offers.offer_value_override). Prima era sempre la
somma dei prezzi di catalogo del tier: in produzione mostrava €20.250 su
offerte vendute a 7.000 e 5.500. NULL torna al calcolo, 0 nasconde la riga.
Si gestisce dal tab Offerte del progetto, che affianca la cifra calcolata
per far vedere cosa si sta sostituendo.

La somma calcolata vive ora in src/lib/offer-value.ts, letta sia dal portale
sia dall'admin: duplicarla avrebbe fatto divergere le due viste.

"Prezzo finale" diventa "Investimento finale" (il ricorrente resta "Canone
mensile").

Migration 0020 applicata a prod prima del push. Override tutti NULL, quindi
il comportamento resta invariato finché non se ne imposta uno.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-21 07:54:55 +02:00
parent 7e58031528
commit 44be190631
12 changed files with 270 additions and 133 deletions
+44
View File
@@ -7,6 +7,7 @@ import {
assignOfferToProject,
removeProjectOffer,
updateProjectOfferTotal,
updateOfferValueOverride,
setProjectOfferLifecycle,
} from "@/app/admin/projects/project-actions";
import type { ProjectOfferWithMicro } from "@/lib/admin-queries";
@@ -111,6 +112,7 @@ export function OffersTab({ projectId, projectOffers, availableMicros }: OffersT
// l'eliminazione in ClientActions).
const [ceaseArmed, setCeaseArmed] = useState<string | null>(null);
const [lifecycleError, setLifecycleError] = useState<string | null>(null);
const [overrideError, setOverrideError] = useState<string | null>(null);
// Group tiers by offer (macro), preserving query order.
const offers = useMemo(() => {
@@ -174,6 +176,18 @@ export function OffersTab({ projectId, projectOffers, availableMicros }: OffersT
});
}
function handleValueOverride(offerId: string, value: string) {
setOverrideError(null);
startTransition(async () => {
const res = await updateOfferValueOverride(offerId, projectId, value);
if (!res.ok) {
setOverrideError(res.error);
return;
}
router.refresh();
});
}
function handleLifecycle(
offerId: string,
input: { status: string; end_date?: string | null }
@@ -198,6 +212,9 @@ export function OffersTab({ projectId, projectOffers, availableMicros }: OffersT
{lifecycleError && (
<p className="mb-3 text-xs text-destructive">{lifecycleError}</p>
)}
{overrideError && (
<p className="mb-3 text-xs text-destructive">{overrideError}</p>
)}
{projectOffers.length === 0 ? (
<p className="text-sm text-[#71717a]">Nessuna offerta assegnata a questo progetto.</p>
) : (
@@ -243,6 +260,33 @@ export function OffersTab({ projectId, projectOffers, availableMicros }: OffersT
/>
</div>
{/* Override del "Valore dell'offerta" mostrato nel portale
(0020). Vuoto = il portale calcola la somma dei prezzi
dei servizi del tier; 0 = riga nascosta al cliente. */}
<div className="mt-2 flex flex-wrap items-center gap-x-2 gap-y-1">
<label className="text-xs text-[#71717a]">Valore dell&apos;offerta </label>
<input
type="number"
step="0.01"
min="0"
defaultValue={offer.offer_value_override ?? ""}
placeholder={offer.computed_offer_value}
disabled={isPending}
onBlur={(e) => {
const val = e.currentTarget.value.trim();
if (val !== (offer.offer_value_override ?? "")) {
handleValueOverride(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 disabled:opacity-50"
/>
<span className="text-[11px] text-[#71717a]">
{offer.offer_value_override == null
? `calcolato dai servizi (€${offer.computed_offer_value})`
: `sostituisce €${offer.computed_offer_value} · svuota per tornare al calcolo`}
</span>
</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. */}
+3 -1
View File
@@ -34,7 +34,9 @@ export function MilestoneStepper({
return (
<div className="bg-card border-b border-border-light px-6 py-3">
<div className="relative mx-auto max-w-[1200px]">
{/* Nessun max-width: la barra occupa tutta la larghezza della viewport,
a differenza del resto della dashboard che è incolonnato a 1400px. */}
<div className="relative w-full">
{/* Linea di connessione + riempimento dinamico (centrata sui cerchi) */}
<div className="absolute left-[5%] right-[5%] top-[14px] z-0 h-1 rounded-full bg-muted">
<div
+9 -51
View File
@@ -1,24 +1,13 @@
"use client";
import { useState } from "react";
import { ChevronDown } from "lucide-react";
interface IncludedService {
name: string;
description: string | null;
}
interface ActiveOffer {
id: string;
public_name: string; // micro offer public name — NOT shown to client (T-05-10)
offer_name: string; // macro public_name — shown as heading
offer_type: string; // una_tantum | retainer
cumulative_price: string; // sum of service prices
offer_value: string; // override admin, o somma calcolata dei servizi
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. */
@@ -35,10 +24,8 @@ interface OffersSectionProps {
}
function OfferCard({ offer }: { offer: ActiveOffer }) {
const [open, setOpen] = useState(false);
const price = parseFloat(offer.cumulative_price);
const hasPrice = price > 0;
const hasServices = offer.services.length > 0;
const value = parseFloat(offer.offer_value);
const hasValue = value > 0;
const isRetainer = offer.offer_type === "retainer";
const isSuspended = offer.status === "sospeso";
@@ -67,11 +54,12 @@ function OfferCard({ offer }: { offer: ActiveOffer }) {
)}
<div className="mt-2 space-y-1.5">
{/* "Valore incluso" — hidden when 0 */}
{hasPrice && (
{/* "Valore dell'offerta" — override admin o somma dei servizi.
Zero significa "non mostrarlo". */}
{hasValue && (
<div className="flex items-center justify-between text-xs">
<span className="text-muted-foreground">Valore incluso</span>
<span className="font-mono text-foreground">{price.toFixed(2)}</span>
<span className="text-muted-foreground">Valore dell&apos;offerta</span>
<span className="font-mono text-foreground">{value.toFixed(2)}</span>
</div>
)}
@@ -79,7 +67,7 @@ function OfferCard({ offer }: { offer: ActiveOffer }) {
{offer.accepted_total && (
<div className="flex items-center justify-between text-xs">
<span className="font-semibold text-foreground">
{isRetainer ? "Canone mensile" : "Prezzo finale"}
{isRetainer ? "Canone mensile" : "Investimento finale"}
</span>
<span className="text-sm font-bold text-primary">
{parseFloat(offer.accepted_total).toFixed(2)}
@@ -89,36 +77,6 @@ function OfferCard({ offer }: { offer: ActiveOffer }) {
</div>
</div>
</div>
{/* Accordion "Cosa è compreso" — only when there are services */}
{hasServices && (
<div className="border-t border-border-light">
<button
type="button"
onClick={() => setOpen((v) => !v)}
className="w-full flex items-center justify-between px-5 py-2.5 text-xs font-semibold text-foreground hover:bg-muted transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset"
aria-expanded={open}
>
<span>Cosa è compreso</span>
<ChevronDown
className={`w-3.5 h-3.5 text-muted-foreground transition-transform duration-200 ${open ? "rotate-180" : ""}`}
/>
</button>
{open && (
<ul className="px-5 pb-3 space-y-2">
{offer.services.map((svc, i) => (
<li key={i} className="flex flex-col gap-0.5">
<span className="text-xs font-medium text-foreground">{svc.name}</span>
{svc.description && (
<span className="text-[11px] text-muted-foreground leading-snug">{svc.description}</span>
)}
</li>
))}
</ul>
)}
</div>
)}
</div>
);
}