From 745f8a78c0c0bace9de5e0fc24144196ea664b1b Mon Sep 17 00:00:00 2001 From: Simone Cavalli Date: Sat, 30 May 2026 16:43:14 +0200 Subject: [PATCH] feat(05-04): OffersSection component + client dashboard wiring + /admin/forecast page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Create src/components/client/OffersSection.tsx — displays public_name, cumulative_price, accepted_total (no internal_name) - Extend client-dashboard.tsx — import OffersSection, add Offerte Attive sidebar block conditional on view.activeOffers - Extend client/[token]/page.tsx adapter — map activeOffers from ProjectView to ClientView - Create src/app/admin/forecast/page.tsx — RSC with 12-month revenue table using getRevenueForecast12Months(), bar chart column, totals row, empty-state message --- src/app/admin/forecast/page.tsx | 75 +++++++++++++++++++++++++ src/app/client/[token]/page.tsx | 1 + src/components/client-dashboard.tsx | 7 +++ src/components/client/OffersSection.tsx | 40 +++++++++++++ 4 files changed, 123 insertions(+) create mode 100644 src/app/admin/forecast/page.tsx create mode 100644 src/components/client/OffersSection.tsx diff --git a/src/app/admin/forecast/page.tsx b/src/app/admin/forecast/page.tsx new file mode 100644 index 0000000..d974191 --- /dev/null +++ b/src/app/admin/forecast/page.tsx @@ -0,0 +1,75 @@ +import { getRevenueForecast12Months } from "@/lib/forecast-queries"; + +export const revalidate = 0; + +export default async function ForecastPage() { + const forecast = await getRevenueForecast12Months(); + const totalForecast = forecast.reduce((sum, m) => sum + m.total, 0); + + return ( +
+

Revenue Forecast

+

+ Proiezione 12 mesi basata sulle offerte attive, i loro accepted_total e le durate in mesi. + Ogni offerta distribuisce il suo totale equamente per i mesi di durata a partire dalla data di inizio. +

+ +
+ + + + + + + + + + {forecast.map((month) => { + const maxTotal = Math.max(...forecast.map((m) => m.total), 1); + const pct = Math.round((month.total / maxTotal) * 100); + return ( + + + + + + ); + })} + + + + + + + +
+ Mese + + Fatturato previsto + + Barra +
{month.label} + {month.total > 0 ? `€${month.total.toFixed(2)}` : } + + {month.total > 0 && ( +
+
+
+ )} +
Totale 12 mesi + €{totalForecast.toFixed(2)} + +
+
+ + {forecast.every((m) => m.total === 0) && ( +

+ Nessuna offerta con accepted_total trovata. Assegna micro-offerte ai progetti e imposta il totale accettato. +

+ )} +
+ ); +} diff --git a/src/app/client/[token]/page.tsx b/src/app/client/[token]/page.tsx index 6bd483c..5db6a06 100644 --- a/src/app/client/[token]/page.tsx +++ b/src/app/client/[token]/page.tsx @@ -66,6 +66,7 @@ function projectViewToClientView( created_at: n.created_at instanceof Date ? n.created_at.toISOString() : String(n.created_at), })), global_progress_pct: view.global_progress_pct, + activeOffers: view.activeOffers, }; } diff --git a/src/components/client-dashboard.tsx b/src/components/client-dashboard.tsx index 51846a0..b6a8220 100644 --- a/src/components/client-dashboard.tsx +++ b/src/components/client-dashboard.tsx @@ -7,6 +7,7 @@ import { DocumentsSection } from './documents-section'; import { NotesSection } from './notes-section'; import { PhaseViewToggle } from './client/kanban/PhaseViewToggle'; import { ChatSection } from './client/ChatSection'; +import { OffersSection } from './client/OffersSection'; interface ClientDashboardProps { view: ClientView; @@ -54,6 +55,12 @@ export function ClientDashboard({ view, token, comments }: ClientDashboardProps)

Pagamenti

+ {view.activeOffers && view.activeOffers.length > 0 && ( +
+

Offerte Attive

+ +
+ )}

Documenti & File

diff --git a/src/components/client/OffersSection.tsx b/src/components/client/OffersSection.tsx new file mode 100644 index 0000000..db5f9e2 --- /dev/null +++ b/src/components/client/OffersSection.tsx @@ -0,0 +1,40 @@ +interface ActiveOffer { + id: string; + public_name: string; // micro offer public name only (security: see T-05-10) + cumulative_price: string; // sum of service prices + accepted_total: string | null; +} + +interface OffersSectionProps { + offers: ActiveOffer[]; +} + +export function OffersSection({ offers }: OffersSectionProps) { + if (offers.length === 0) return null; + + return ( +
+ {offers.map((offer) => ( +
+

{offer.public_name}

+
+
+ Valore incluso + + €{parseFloat(offer.cumulative_price).toFixed(2)} + +
+ {offer.accepted_total && ( +
+ Prezzo finale + + €{parseFloat(offer.accepted_total).toFixed(2)} + +
+ )} +
+
+ ))} +
+ ); +}