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:
@@ -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'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. */}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user