diff --git a/src/components/client-dashboard.tsx b/src/components/client-dashboard.tsx index 541bf14..c7703f2 100644 --- a/src/components/client-dashboard.tsx +++ b/src/components/client-dashboard.tsx @@ -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) - {/* Barra progresso globale */} -
-
-
-

Avanzamento Progetto

-

{view.global_progress_pct}%

+ {/* Barra progresso globale — nascosta per retainer (fasi non tracciate) */} + {!hasRetainer && ( +
+
+
+

Avanzamento Progetto

+

{view.global_progress_pct}%

+
+
- -
-
+ + )} {/* Layout principale */}
@@ -108,11 +111,30 @@ export function ClientDashboard({ view, token, comments }: ClientDashboardProps) {/* ── Contenuto principale ── */}
- } - phases={view.phases} - token={token} - /> + {hasRetainer ? ( +
+

+ Fasi del Progetto +

+
+ + + +
+

Servizio in abbonamento ricorrente

+

+ Non ci sono fasi di progetto da tracciare. Le fasi compaiono qui quando viene avviata un'offerta a progetto. +

+
+
+
+ ) : ( + } + phases={view.phases} + token={token} + /> + )} {/* Chat "Messaggi & Revisioni" moved to slide-in panel (FAB bottom-right) */}
diff --git a/src/components/client/OffersSection.tsx b/src/components/client/OffersSection.tsx index c0085b7..474a974 100644 --- a/src/components/client/OffersSection.tsx +++ b/src/components/client/OffersSection.tsx @@ -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 ( +
+ {/* Main info rows */} +
+ {/* Heading: macro public name */} +

{offer.offer_name}

+ +
+ {/* "Valore incluso" — hidden when 0 */} + {hasPrice && ( +
+ Valore incluso + + €{price.toFixed(2)} + +
+ )} + + {/* "Prezzo finale" */} + {offer.accepted_total && ( +
+ Prezzo finale + + €{parseFloat(offer.accepted_total).toFixed(2)} + +
+ )} +
+
+ + {/* Accordion "Cosa è compreso" — only when there are services */} + {hasServices && ( +
+ + + {open && ( +
    + {offer.services.map((svc, i) => ( +
  • + {svc.name} + {svc.description && ( + {svc.description} + )} +
  • + ))} +
+ )} +
+ )} +
+ ); +} + export function OffersSection({ offers }: OffersSectionProps) { if (offers.length === 0) return null; return (
{offers.map((offer) => ( -
- {/* Show macro public_name as heading — never tier letter, never internal_name */} -

{offer.offer_name}

-
-
- Valore incluso - - €{parseFloat(offer.cumulative_price).toFixed(2)} - -
- {offer.accepted_total && ( -
- Prezzo finale - - €{parseFloat(offer.accepted_total).toFixed(2)} - -
- )} -
-
+ ))}
); diff --git a/src/lib/client-view.ts b/src/lib/client-view.ts index f4ba19e..d91b580 100644 --- a/src/lib/client-view.ts +++ b/src/lib/client-view.ts @@ -1,6 +1,6 @@ import { eq, inArray, asc, desc, sql } from "drizzle-orm"; 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. @@ -59,6 +59,7 @@ export interface ClientView { offer_type: string; // una_tantum | retainer cumulative_price: string; accepted_total: string | null; + services: Array<{ name: string; description: string | null }>; }>; transcripts: Array<{ id: string; @@ -129,8 +130,9 @@ export interface ProjectView { public_name: string; // offer_micros.public_name — NEVER internal_name offer_name: string; // offer_macros.public_name — macro-level name shown to client 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; + services: Array<{ name: string; description: string | null }>; // included services list }>; transcripts: Array<{ id: string; @@ -308,28 +310,83 @@ export async function getProjectView(projectId: string): Promise 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>(); + 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({ micro_id: offer_micro_services.micro_id, - cumulative_price: sql`coalesce(sum(${offer_services.price}::numeric), 0)`, + name: offer_services.name, + price: offer_services.price, }) .from(offer_micro_services) .innerJoin(offer_services, eq(offer_micro_services.service_id, offer_services.id)) - .where(inArray(offer_micro_services.micro_id, microIds)) - .groupBy(offer_micro_services.micro_id); + .where(inArray(offer_micro_services.micro_id, microsNeedingLegacy)); - const cumulMap = new Map(cumulativePriceRows.map((r) => [r.micro_id, r.cumulative_price])); + const legacyServicesMap = new Map>(); + 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) => ({ - id: o.id, - public_name: o.public_name, - offer_name: o.offer_name, - offer_type: o.offer_type, - cumulative_price: cumulMap.get(o.micro_id) ?? "0", - accepted_total: o.accepted_total ? String(o.accepted_total) : null, - })); + const activeOffers = projectOfferRows.map((o) => { + const newStyleServices = tierServicesMap.get(o.micro_id); + const legacyServices = legacyServicesMap.get(o.micro_id); + + let cumulative_price: string; + let servicesList: Array<{ name: string; description: string | 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) const transcriptsRows = await db