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,6 +1,7 @@
|
|||||||
import type { ClientView } from '@/lib/client-view';
|
import type { ClientView } from '@/lib/client-view';
|
||||||
import type { Comment } from '@/db/schema';
|
import type { Comment } from '@/db/schema';
|
||||||
import { Progress } from '@/components/ui/progress';
|
import { Progress } from '@/components/ui/progress';
|
||||||
|
import { RefreshCw } from 'lucide-react';
|
||||||
import { PhaseTimeline } from './phase-timeline';
|
import { PhaseTimeline } from './phase-timeline';
|
||||||
import { PaymentStatus } from './payment-status';
|
import { PaymentStatus } from './payment-status';
|
||||||
import { DocumentsSection } from './documents-section';
|
import { DocumentsSection } from './documents-section';
|
||||||
@@ -45,16 +46,18 @@ export function ClientDashboard({ view, token, comments }: ClientDashboardProps)
|
|||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
{/* Barra progresso globale */}
|
{/* Barra progresso globale — nascosta per retainer (fasi non tracciate) */}
|
||||||
<section className="bg-[#f9f9f9] border-b border-[#e5e5e5]">
|
{!hasRetainer && (
|
||||||
<div className="max-w-6xl mx-auto px-4 sm:px-6 py-4">
|
<section className="bg-[#f9f9f9] border-b border-[#e5e5e5]">
|
||||||
<div className="flex items-center justify-between mb-2">
|
<div className="max-w-6xl mx-auto px-4 sm:px-6 py-4">
|
||||||
<p className="text-sm font-semibold text-[#1a1a1a]">Avanzamento Progetto</p>
|
<div className="flex items-center justify-between mb-2">
|
||||||
<p className="text-sm font-bold text-[#1a1a1a]">{view.global_progress_pct}%</p>
|
<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>
|
</div>
|
||||||
<Progress value={view.global_progress_pct} className="h-2" />
|
</section>
|
||||||
</div>
|
)}
|
||||||
</section>
|
|
||||||
|
|
||||||
{/* Layout principale */}
|
{/* Layout principale */}
|
||||||
<div className="max-w-6xl mx-auto px-4 sm:px-6 py-8">
|
<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 ── */}
|
{/* ── Contenuto principale ── */}
|
||||||
<main className="flex-1 min-w-0 order-1 lg:order-2 space-y-12">
|
<main className="flex-1 min-w-0 order-1 lg:order-2 space-y-12">
|
||||||
<PhaseViewToggle
|
{hasRetainer ? (
|
||||||
timelineView={<PhaseTimeline phases={view.phases} token={token} />}
|
<div>
|
||||||
phases={view.phases}
|
<h2 className="text-xs font-bold text-[#71717a] uppercase tracking-wider mb-4">
|
||||||
token={token}
|
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'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) */}
|
{/* Chat "Messaggi & Revisioni" moved to slide-in panel (FAB bottom-right) */}
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
|
|||||||
@@ -1,42 +1,103 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { ChevronDown } from "lucide-react";
|
||||||
|
|
||||||
|
interface IncludedService {
|
||||||
|
name: string;
|
||||||
|
description: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
interface ActiveOffer {
|
interface ActiveOffer {
|
||||||
id: string;
|
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_name: string; // macro public_name — shown as heading
|
||||||
offer_type: string; // una_tantum | retainer
|
offer_type: string; // una_tantum | retainer
|
||||||
cumulative_price: string; // sum of service prices
|
cumulative_price: string; // sum of service prices
|
||||||
accepted_total: string | null;
|
accepted_total: string | null;
|
||||||
|
services: IncludedService[];
|
||||||
}
|
}
|
||||||
|
|
||||||
interface OffersSectionProps {
|
interface OffersSectionProps {
|
||||||
offers: ActiveOffer[];
|
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) {
|
export function OffersSection({ offers }: OffersSectionProps) {
|
||||||
if (offers.length === 0) return null;
|
if (offers.length === 0) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{offers.map((offer) => (
|
{offers.map((offer) => (
|
||||||
<div key={offer.id} className="bg-white rounded-lg border border-[#e5e5e5] p-4">
|
<OfferCard key={offer.id} offer={offer} />
|
||||||
{/* 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>
|
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
+73
-16
@@ -1,6 +1,6 @@
|
|||||||
import { eq, inArray, asc, desc, sql } from "drizzle-orm";
|
import { eq, inArray, asc, desc, sql } from "drizzle-orm";
|
||||||
import { db } from "@/db";
|
import { db } from "@/db";
|
||||||
import { clients, projects, phases, tasks, deliverables, payments, documents, notes, comments, project_offers, offer_micros, offer_micro_services, offer_services, offer_macros, clientTranscripts } from "@/db/schema";
|
import { clients, projects, phases, tasks, deliverables, payments, documents, notes, comments, project_offers, offer_micros, offer_micro_services, offer_services, offer_macros, offer_tier_services, services, clientTranscripts } from "@/db/schema";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* ClientView: Legacy shape used by ClientDashboard component.
|
* ClientView: Legacy shape used by ClientDashboard component.
|
||||||
@@ -59,6 +59,7 @@ export interface ClientView {
|
|||||||
offer_type: string; // una_tantum | retainer
|
offer_type: string; // una_tantum | retainer
|
||||||
cumulative_price: string;
|
cumulative_price: string;
|
||||||
accepted_total: string | null;
|
accepted_total: string | null;
|
||||||
|
services: Array<{ name: string; description: string | null }>;
|
||||||
}>;
|
}>;
|
||||||
transcripts: Array<{
|
transcripts: Array<{
|
||||||
id: string;
|
id: string;
|
||||||
@@ -129,8 +130,9 @@ export interface ProjectView {
|
|||||||
public_name: string; // offer_micros.public_name — NEVER internal_name
|
public_name: string; // offer_micros.public_name — NEVER internal_name
|
||||||
offer_name: string; // offer_macros.public_name — macro-level name shown to client
|
offer_name: string; // offer_macros.public_name — macro-level name shown to client
|
||||||
offer_type: string; // una_tantum | retainer
|
offer_type: string; // una_tantum | retainer
|
||||||
cumulative_price: string; // sum of assigned offer_services.price
|
cumulative_price: string; // sum of assigned services.unit_price (new) or offer_services.price (legacy)
|
||||||
accepted_total: string | null;
|
accepted_total: string | null;
|
||||||
|
services: Array<{ name: string; description: string | null }>; // included services list
|
||||||
}>;
|
}>;
|
||||||
transcripts: Array<{
|
transcripts: Array<{
|
||||||
id: string;
|
id: string;
|
||||||
@@ -308,28 +310,83 @@ export async function getProjectView(projectId: string): Promise<ProjectView | n
|
|||||||
.innerJoin(offer_macros, eq(offer_micros.macro_id, offer_macros.id))
|
.innerJoin(offer_macros, eq(offer_micros.macro_id, offer_macros.id))
|
||||||
.where(eq(project_offers.project_id, projectId));
|
.where(eq(project_offers.project_id, projectId));
|
||||||
|
|
||||||
// Cumulative price per micro (sum of assigned services)
|
// Cumulative price + service list per micro.
|
||||||
|
// Primary: offer_tier_services → services (Phase 12 catalog). Fallback: legacy offer_micro_services → offer_services.
|
||||||
const microIds = projectOfferRows.map((o) => o.micro_id);
|
const microIds = projectOfferRows.map((o) => o.micro_id);
|
||||||
const cumulativePriceRows = microIds.length === 0 ? [] : await db
|
|
||||||
|
// New-style: services from offer_tier_services joined to unified services catalog
|
||||||
|
const tierServiceRows = microIds.length === 0 ? [] : await db
|
||||||
|
.select({
|
||||||
|
tier_id: offer_tier_services.tier_id,
|
||||||
|
name: services.name,
|
||||||
|
description: services.description,
|
||||||
|
unit_price: services.unit_price,
|
||||||
|
})
|
||||||
|
.from(offer_tier_services)
|
||||||
|
.innerJoin(services, eq(offer_tier_services.service_id, services.id))
|
||||||
|
.where(inArray(offer_tier_services.tier_id, microIds));
|
||||||
|
|
||||||
|
// Group by tier_id for quick lookup
|
||||||
|
const tierServicesMap = new Map<string, Array<{ name: string; description: string | null; unit_price: string }>>();
|
||||||
|
for (const row of tierServiceRows) {
|
||||||
|
const existing = tierServicesMap.get(row.tier_id) ?? [];
|
||||||
|
existing.push({ name: row.name, description: row.description, unit_price: row.unit_price });
|
||||||
|
tierServicesMap.set(row.tier_id, existing);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine which micros have NO entries in the new table → need legacy fallback
|
||||||
|
const microsNeedingLegacy = microIds.filter((id) => !tierServicesMap.has(id));
|
||||||
|
|
||||||
|
// Legacy fallback: offer_micro_services → offer_services (for old offers with no tier_services rows)
|
||||||
|
const legacyPriceRows = microsNeedingLegacy.length === 0 ? [] : await db
|
||||||
.select({
|
.select({
|
||||||
micro_id: offer_micro_services.micro_id,
|
micro_id: offer_micro_services.micro_id,
|
||||||
cumulative_price: sql<string>`coalesce(sum(${offer_services.price}::numeric), 0)`,
|
name: offer_services.name,
|
||||||
|
price: offer_services.price,
|
||||||
})
|
})
|
||||||
.from(offer_micro_services)
|
.from(offer_micro_services)
|
||||||
.innerJoin(offer_services, eq(offer_micro_services.service_id, offer_services.id))
|
.innerJoin(offer_services, eq(offer_micro_services.service_id, offer_services.id))
|
||||||
.where(inArray(offer_micro_services.micro_id, microIds))
|
.where(inArray(offer_micro_services.micro_id, microsNeedingLegacy));
|
||||||
.groupBy(offer_micro_services.micro_id);
|
|
||||||
|
|
||||||
const cumulMap = new Map(cumulativePriceRows.map((r) => [r.micro_id, r.cumulative_price]));
|
const legacyServicesMap = new Map<string, Array<{ name: string; description: string | null; price: string }>>();
|
||||||
|
for (const row of legacyPriceRows) {
|
||||||
|
const existing = legacyServicesMap.get(row.micro_id) ?? [];
|
||||||
|
existing.push({ name: row.name, description: null, price: row.price });
|
||||||
|
legacyServicesMap.set(row.micro_id, existing);
|
||||||
|
}
|
||||||
|
|
||||||
const activeOffers = projectOfferRows.map((o) => ({
|
const activeOffers = projectOfferRows.map((o) => {
|
||||||
id: o.id,
|
const newStyleServices = tierServicesMap.get(o.micro_id);
|
||||||
public_name: o.public_name,
|
const legacyServices = legacyServicesMap.get(o.micro_id);
|
||||||
offer_name: o.offer_name,
|
|
||||||
offer_type: o.offer_type,
|
let cumulative_price: string;
|
||||||
cumulative_price: cumulMap.get(o.micro_id) ?? "0",
|
let servicesList: Array<{ name: string; description: string | null }>;
|
||||||
accepted_total: o.accepted_total ? String(o.accepted_total) : null,
|
|
||||||
}));
|
if (newStyleServices && newStyleServices.length > 0) {
|
||||||
|
// Use new catalog prices
|
||||||
|
const total = newStyleServices.reduce((sum, s) => sum + parseFloat(s.unit_price ?? "0"), 0);
|
||||||
|
cumulative_price = total.toFixed(2);
|
||||||
|
servicesList = newStyleServices.map((s) => ({ name: s.name, description: s.description }));
|
||||||
|
} else if (legacyServices && legacyServices.length > 0) {
|
||||||
|
// Legacy fallback
|
||||||
|
const total = legacyServices.reduce((sum, s) => sum + parseFloat(s.price ?? "0"), 0);
|
||||||
|
cumulative_price = total.toFixed(2);
|
||||||
|
servicesList = legacyServices.map((s) => ({ name: s.name, description: null }));
|
||||||
|
} else {
|
||||||
|
cumulative_price = "0";
|
||||||
|
servicesList = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: o.id,
|
||||||
|
public_name: o.public_name,
|
||||||
|
offer_name: o.offer_name,
|
||||||
|
offer_type: o.offer_type,
|
||||||
|
cumulative_price,
|
||||||
|
accepted_total: o.accepted_total ? String(o.accepted_total) : null,
|
||||||
|
services: servicesList,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
// Transcripts linked to the project's client (post lead→client conversion)
|
// Transcripts linked to the project's client (post lead→client conversion)
|
||||||
const transcriptsRows = await db
|
const transcriptsRows = await db
|
||||||
|
|||||||
Reference in New Issue
Block a user