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:
2026-06-25 14:53:25 +02:00
parent 64030afef3
commit e10d1f70cb
3 changed files with 191 additions and 51 deletions
+36 -14
View File
@@ -1,6 +1,7 @@
import type { ClientView } from '@/lib/client-view';
import type { Comment } from '@/db/schema';
import { Progress } from '@/components/ui/progress';
import { RefreshCw } from 'lucide-react';
import { PhaseTimeline } from './phase-timeline';
import { PaymentStatus } from './payment-status';
import { DocumentsSection } from './documents-section';
@@ -45,16 +46,18 @@ export function ClientDashboard({ view, token, comments }: ClientDashboardProps)
</div>
</header>
{/* Barra progresso globale */}
<section className="bg-[#f9f9f9] border-b border-[#e5e5e5]">
<div className="max-w-6xl mx-auto px-4 sm:px-6 py-4">
<div className="flex items-center justify-between mb-2">
<p className="text-sm font-semibold text-[#1a1a1a]">Avanzamento Progetto</p>
<p className="text-sm font-bold text-[#1a1a1a]">{view.global_progress_pct}%</p>
{/* Barra progresso globale — nascosta per retainer (fasi non tracciate) */}
{!hasRetainer && (
<section className="bg-[#f9f9f9] border-b border-[#e5e5e5]">
<div className="max-w-6xl mx-auto px-4 sm:px-6 py-4">
<div className="flex items-center justify-between mb-2">
<p className="text-sm font-semibold text-[#1a1a1a]">Avanzamento Progetto</p>
<p className="text-sm font-bold text-[#1a1a1a]">{view.global_progress_pct}%</p>
</div>
<Progress value={view.global_progress_pct} className="h-2" />
</div>
<Progress value={view.global_progress_pct} className="h-2" />
</div>
</section>
</section>
)}
{/* Layout principale */}
<div className="max-w-6xl mx-auto px-4 sm:px-6 py-8">
@@ -108,11 +111,30 @@ export function ClientDashboard({ view, token, comments }: ClientDashboardProps)
{/* ── Contenuto principale ── */}
<main className="flex-1 min-w-0 order-1 lg:order-2 space-y-12">
<PhaseViewToggle
timelineView={<PhaseTimeline phases={view.phases} token={token} />}
phases={view.phases}
token={token}
/>
{hasRetainer ? (
<div>
<h2 className="text-xs font-bold text-[#71717a] uppercase tracking-wider mb-4">
Fasi del Progetto
</h2>
<div className="flex flex-col items-center justify-center gap-4 rounded-xl border border-dashed border-[#d4d4d4] bg-[#fafafa] px-8 py-12 text-center">
<span className="flex h-11 w-11 items-center justify-center rounded-full bg-[#f0f0f0]">
<RefreshCw className="h-5 w-5 text-[#71717a]" />
</span>
<div className="space-y-1.5">
<p className="text-sm font-semibold text-[#1a1a1a]">Servizio in abbonamento ricorrente</p>
<p className="text-xs text-[#71717a] max-w-xs leading-relaxed">
Non ci sono fasi di progetto da tracciare. Le fasi compaiono qui quando viene avviata un&apos;offerta a progetto.
</p>
</div>
</div>
</div>
) : (
<PhaseViewToggle
timelineView={<PhaseTimeline phases={view.phases} token={token} />}
phases={view.phases}
token={token}
/>
)}
{/* Chat "Messaggi & Revisioni" moved to slide-in panel (FAB bottom-right) */}
</main>
+82 -21
View File
@@ -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>
);