fix: cumulative price from offer_tier_services, offers accordion, retainer phase placeholder
- client-view.ts: read cumulative_price + services list from offer_tier_services→services (Phase 12 catalog); legacy fallback to offer_micro_services→offer_services when no new-style rows exist. Extend activeOffers type with services[] in both ProjectView and ClientView. - OffersSection.tsx: extract OfferCard client component with useState accordion; hide "Valore incluso" row when price is 0; add "Cosa è compreso" chevron toggle listing included services (name + description when present). - client-dashboard.tsx: hide progress bar and PhaseViewToggle when hasRetainer; render polished empty-state placeholder with RefreshCw icon instead. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -1,42 +1,103 @@
|
||||
"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 (tier label) — NOT shown to client (T-05-10)
|
||||
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
|
||||
accepted_total: string | null;
|
||||
services: IncludedService[];
|
||||
}
|
||||
|
||||
interface OffersSectionProps {
|
||||
offers: ActiveOffer[];
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-lg border border-[#e5e5e5] overflow-hidden">
|
||||
{/* Main info rows */}
|
||||
<div className="p-4 space-y-2">
|
||||
{/* Heading: macro public name */}
|
||||
<p className="text-sm font-semibold text-[#1a1a1a]">{offer.offer_name}</p>
|
||||
|
||||
<div className="space-y-1">
|
||||
{/* "Valore incluso" — hidden when 0 */}
|
||||
{hasPrice && (
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs text-[#71717a]">Valore incluso</span>
|
||||
<span className="text-xs font-mono text-[#1a1a1a]">
|
||||
€{price.toFixed(2)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* "Prezzo finale" */}
|
||||
{offer.accepted_total && (
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs font-semibold text-[#1a1a1a]">Prezzo finale</span>
|
||||
<span className="text-sm font-bold text-[#1A463C]">
|
||||
€{parseFloat(offer.accepted_total).toFixed(2)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Accordion "Cosa è compreso" — only when there are services */}
|
||||
{hasServices && (
|
||||
<div className="border-t border-[#e5e5e5]">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
className="w-full flex items-center justify-between px-4 py-2.5 text-xs font-semibold text-[#1a1a1a] hover:bg-[#f9f9f9] transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#1A463C] focus-visible:ring-inset"
|
||||
aria-expanded={open}
|
||||
>
|
||||
<span>Cosa è compreso</span>
|
||||
<ChevronDown
|
||||
className={`w-3.5 h-3.5 text-[#71717a] transition-transform duration-200 ${open ? "rotate-180" : ""}`}
|
||||
/>
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<ul className="px-4 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-[#1a1a1a]">{svc.name}</span>
|
||||
{svc.description && (
|
||||
<span className="text-[11px] text-[#71717a] leading-snug">{svc.description}</span>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function OffersSection({ offers }: OffersSectionProps) {
|
||||
if (offers.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{offers.map((offer) => (
|
||||
<div key={offer.id} className="bg-white rounded-lg border border-[#e5e5e5] p-4">
|
||||
{/* Show macro public_name as heading — never tier letter, never internal_name */}
|
||||
<p className="text-sm font-semibold text-[#1a1a1a]">{offer.offer_name}</p>
|
||||
<div className="mt-2 space-y-1">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs text-[#71717a]">Valore incluso</span>
|
||||
<span className="text-xs font-mono text-[#1a1a1a]">
|
||||
€{parseFloat(offer.cumulative_price).toFixed(2)}
|
||||
</span>
|
||||
</div>
|
||||
{offer.accepted_total && (
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs font-semibold text-[#1a1a1a]">Prezzo finale</span>
|
||||
<span className="text-sm font-bold text-[#1A463C]">
|
||||
€{parseFloat(offer.accepted_total).toFixed(2)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<OfferCard key={offer.id} offer={offer} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user