From 2702a01e23cc223b14f77d6e4cdc5534e32e669f Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Tue, 16 Dec 2025 02:36:31 +0000 Subject: [PATCH 1/2] redesigned the scenario dropdown --- .../scenario/[scenarioId]/metrics/route.ts | 14 +- src/app/portfolio/[slug]/(portfolio)/page.tsx | 8 +- .../reporting/ReportingClientArea.tsx | 10 +- .../reporting/ScenarioFinancialDrawer.tsx | 431 ++++++++++++------ 4 files changed, 304 insertions(+), 159 deletions(-) diff --git a/src/app/api/portfolio/[portfolioId]/scenario/[scenarioId]/metrics/route.ts b/src/app/api/portfolio/[portfolioId]/scenario/[scenarioId]/metrics/route.ts index 3573b3a..03a7c2c 100644 --- a/src/app/api/portfolio/[portfolioId]/scenario/[scenarioId]/metrics/route.ts +++ b/src/app/api/portfolio/[portfolioId]/scenario/[scenarioId]/metrics/route.ts @@ -92,10 +92,11 @@ export async function GET( const upgraded = upgradedResult.rows[0] as UpgradedAggregates; const n_units_upgraded = upgraded.n_units_upgraded; - const total_cost = upgraded.total_cost ?? 0; + const construction_cost = upgraded.total_cost ?? 0; const contingency = upgraded.contingency ?? 0; const total_funding = upgraded.total_funding ?? 0; - const net_cost = total_cost - total_funding; + const net_cost = construction_cost - total_funding; + const pc_cost = construction_cost * 0.28; // Placeholder for PC cost // // ---------------------------------------------------------- @@ -132,13 +133,16 @@ export async function GET( total_bills, n_units, scenario_epc_counts, - + pc_cost, // Upgrade metrics (only properties with work) n_units_upgraded, - total_cost, + construction_cost, contingency, total_funding, net_cost, - gross_per_unit: n_units_upgraded > 0 ? total_cost / n_units_upgraded : 0, + gross_per_unit: + n_units_upgraded > 0 + ? (construction_cost + pc_cost) / n_units_upgraded + : 0, }); } diff --git a/src/app/portfolio/[slug]/(portfolio)/page.tsx b/src/app/portfolio/[slug]/(portfolio)/page.tsx index d6722c6..4d5d754 100644 --- a/src/app/portfolio/[slug]/(portfolio)/page.tsx +++ b/src/app/portfolio/[slug]/(portfolio)/page.tsx @@ -81,13 +81,7 @@ export default async function Page(props: { <>
-
- -
-
+
{properties.length === 0 ? ( ) : ( diff --git a/src/app/portfolio/[slug]/(portfolio)/reporting/ReportingClientArea.tsx b/src/app/portfolio/[slug]/(portfolio)/reporting/ReportingClientArea.tsx index ce72dfa..cb78fd4 100644 --- a/src/app/portfolio/[slug]/(portfolio)/reporting/ReportingClientArea.tsx +++ b/src/app/portfolio/[slug]/(portfolio)/reporting/ReportingClientArea.tsx @@ -110,17 +110,19 @@ export function ReportingClientArea({ // ---------------------------------------- const scenarioSpecific = scenarioData ? { - totalCost: scenarioData.total_cost, + constructionCost: scenarioData.construction_cost, + pcCost: scenarioData.pc_cost, contingency: scenarioData.contingency, funding: scenarioData.total_funding, costPerSap: - scenarioData.total_cost > 0 + scenarioData.construction_cost > 0 ? scenarioData.gross_per_unit / (scenarioData.avg_sap - (baseline.averages.avg_sap ?? 0)) : 0, costPerCo2: - scenarioData.total_cost > 0 - ? scenarioData.total_cost / scenarioData.total_carbon + scenarioData.construction_cost > 0 + ? (scenarioData.construction_cost + scenarioData.pc_cost) / + scenarioData.total_carbon : 0, netCost: scenarioData.net_cost, grossPerUnit: scenarioData.gross_per_unit, diff --git a/src/app/portfolio/[slug]/(portfolio)/reporting/ScenarioFinancialDrawer.tsx b/src/app/portfolio/[slug]/(portfolio)/reporting/ScenarioFinancialDrawer.tsx index 435fa9d..89db037 100644 --- a/src/app/portfolio/[slug]/(portfolio)/reporting/ScenarioFinancialDrawer.tsx +++ b/src/app/portfolio/[slug]/(portfolio)/reporting/ScenarioFinancialDrawer.tsx @@ -2,174 +2,319 @@ import { motion, AnimatePresence } from "framer-motion"; import { formatNumber } from "@/app/utils"; +import clsx from "clsx"; -// Premium Icons +/* Heroicons (outline) */ import { - Banknote, - ShieldAlert, - PiggyBank, - Scale, - Gauge, - Factory, - Home, - Users, -} from "lucide-react"; + ArrowTrendingUpIcon, + ClipboardDocumentCheckIcon, + ScaleIcon, + HomeIcon, + BoltIcon, + FireIcon, + ChartBarIcon, + WrenchIcon, +} from "@heroicons/react/24/outline"; -export function ScenarioFinancialDrawer({ - open, - metrics, -}: { +/* Lucide */ +import { Gauge } from "lucide-react"; + +/* ───────────────────────────────────────────── */ +/* Types */ +/* ───────────────────────────────────────────── */ + +interface ScenarioFinancialDrawerProps { open: boolean; metrics: any | null; +} + +/* ───────────────────────────────────────────── */ +/* Gradient Tokens */ +/* ───────────────────────────────────────────── */ + +const gradients = { + green: "bg-gradient-to-r from-green-700 via-green-400 to-green-700", + blue: "bg-gradient-to-r from-brandblue via-sky-400 to-brandblue", + purple: "bg-gradient-to-r from-purple-700 via-purple-400 to-purple-700", +}; + +/* ───────────────────────────────────────────── */ +/* Gradient Card Shell */ +/* ───────────────────────────────────────────── */ + +function GradientCard({ + gradient, + children, +}: { + gradient: string; + children: React.ReactNode; }) { return ( - - {open && metrics && ( - -
-

- Scenario Financial Summary -

- -
- - - - - - - - - - - - -
-
-
- )} -
+
+
{children}
+
); } +/* ───────────────────────────────────────────── */ +/* Single Metric Card */ +/* ───────────────────────────────────────────── */ + function Metric({ label, value, icon: Icon, color, - bg, + gradient, }: { label: string; value: string | number; - icon: any; + icon: React.ComponentType>; color: string; - bg: string; + gradient: string; }) { return ( -
-
- {/* coloured icon background */} -
- -
- - + +
+ + {value} + {label}
+
+ ); +} - - {value} - +/* ───────────────────────────────────────────── */ +/* Paired Metric Card (Reusable Everywhere) */ +/* ───────────────────────────────────────────── */ + +function PairedMetric({ + title, + icon: Icon, + primary, + secondary, + gradient, + iconClassName = "text-gray-700", +}: { + title: string; + icon: React.ComponentType>; + primary: { label: string; value: string }; + secondary: { label: string; value: string }; + gradient: string; + iconClassName?: string; +}) { + return ( + +
+
+ + {title} +
+ +
+
+

{primary.label}

+

+ {primary.value} +

+
+ +
+

{secondary.label}

+

+ {secondary.value} +

+
+
+
+
+ ); +} + +/* ───────────────────────────────────────────── */ +/* Section Header */ +/* ───────────────────────────────────────────── */ + +function Section({ + title, + subtitle, + icon: Icon, + gradient, + accentColor, + children, +}: { + title: string; + subtitle: string; + icon: React.ComponentType>; + gradient: string; + accentColor: string; + children: React.ReactNode; +}) { + return ( +
+
+
+ +
+ +
+ +
+

{title}

+

{subtitle}

+
+
+ +
+ {children} +
); } + +/* ───────────────────────────────────────────── */ +/* Main Drawer */ +/* ───────────────────────────────────────────── */ + +export function ScenarioFinancialDrawer({ + open, + metrics, +}: ScenarioFinancialDrawerProps) { + return ( + + {open && metrics && ( + +
+

+ Scenario Impact Summary +

+ + {/* BENEFITS */} +
+ + + + + +
+ + {/* COSTS */} +
+ + + + + +
+ + {/* COST EFFECTIVENESS */} +
+ +
+
+
+ )} +
+ ); +} From 727728d40cc66887476ba86ff340771a4471ff80 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Tue, 16 Dec 2025 05:19:51 +0000 Subject: [PATCH 2/2] pdf download working --- src/app/globals.css | 104 +++++++++ .../reporting/DashboardSummaryCards.tsx | 2 +- .../reporting/ReportingClientArea.tsx | 11 + .../reporting/ScenarioFinancialDrawer.tsx | 27 ++- .../(portfolio)/reporting/pdf/AutoPrint.tsx | 66 ++++++ .../[slug]/(portfolio)/reporting/pdf/page.tsx | 199 ++++++++++++++++++ 6 files changed, 404 insertions(+), 5 deletions(-) create mode 100644 src/app/portfolio/[slug]/(portfolio)/reporting/pdf/AutoPrint.tsx create mode 100644 src/app/portfolio/[slug]/(portfolio)/reporting/pdf/page.tsx diff --git a/src/app/globals.css b/src/app/globals.css index 3b98d12..c00cf15 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -118,3 +118,107 @@ .animate-spin { animation: spin 1s linear infinite; } + +@media print { + body { + background: white; + } + + /* --------------------------------- + PAGE LAYOUT + --------------------------------- */ + + @page { + margin: 0; + } + + /* Outer page padding (NOT scaled) */ + .print-page { + padding: 20px 24px; + } + + /* Inner content (scaled slightly) */ + .print-root { + transform: scale(0.94); + transform-origin: top left; + width: 106.4%; /* 1 / 0.94 */ + } + + /* --------------------------------- + HEADER + --------------------------------- */ + + .print-header { + display: flex; + align-items: center; + gap: 16px; + border-bottom: 2px solid #0b3c5d; + padding-bottom: 12px; + margin-bottom: 20px; + } + + /* --------------------------------- + PAGE BREAKS + --------------------------------- */ + + .page-break { + break-before: page; + page-break-before: always; + } + + .avoid-break { + break-inside: avoid; + page-break-inside: avoid; + } + + /* --------------------------------- + GRID STABILITY + --------------------------------- */ + + .print-grid-3 { + display: grid !important; + grid-template-columns: repeat(3, minmax(0, 1fr)) !important; + gap: 14px !important; + } + + .print-grid-2 { + display: grid !important; + grid-template-columns: repeat(2, minmax(0, 1fr)) !important; + gap: 14px !important; + } + + /* --------------------------------- + CARD FIXES + --------------------------------- */ + + section, + .card, + .gradient-card { + break-inside: avoid; + page-break-inside: avoid; + } + + .gradient-card { + background: none !important; + border: 2px solid #e5e7eb; + } + + .gradient-card > div { + padding: 10px !important; + } + + /* Disable gradient text */ + .print-text-solid { + background: none !important; + -webkit-background-clip: initial !important; + background-clip: initial !important; + color: #0b3c5d !important; + } + + /* Hide UI chrome */ + button, + nav, + .no-print { + display: none !important; + } +} diff --git a/src/app/portfolio/[slug]/(portfolio)/reporting/DashboardSummaryCards.tsx b/src/app/portfolio/[slug]/(portfolio)/reporting/DashboardSummaryCards.tsx index 15c9a92..425b1ee 100644 --- a/src/app/portfolio/[slug]/(portfolio)/reporting/DashboardSummaryCards.tsx +++ b/src/app/portfolio/[slug]/(portfolio)/reporting/DashboardSummaryCards.tsx @@ -171,7 +171,7 @@ export function DashboardSummaryCards({ className={ c.isEpc ? `text-3xl font-semibold ${epcColors[averageCurrentEpc || "Unknown"]}` - : "text-3xl font-semibold bg-clip-text text-transparent bg-gradient-to-r from-brandblue to-midblue" + : "text-3xl font-semibold bg-clip-text text-transparent bg-gradient-to-r from-brandblue to-midblue print-text-solid" } > {c.key === "avgBills" ? `£${c.baseline}` : c.baseline} diff --git a/src/app/portfolio/[slug]/(portfolio)/reporting/ReportingClientArea.tsx b/src/app/portfolio/[slug]/(portfolio)/reporting/ReportingClientArea.tsx index cb78fd4..5fe5848 100644 --- a/src/app/portfolio/[slug]/(portfolio)/reporting/ReportingClientArea.tsx +++ b/src/app/portfolio/[slug]/(portfolio)/reporting/ReportingClientArea.tsx @@ -161,6 +161,17 @@ export function ReportingClientArea({ Could not load scenario data.
)} + {/* --- RETROFIT SECTION --- */} +
{children}
); @@ -66,15 +74,17 @@ function Metric({ icon: Icon, color, gradient, + variant = "green", }: { label: string; value: string | number; icon: React.ComponentType>; color: string; gradient: string; + variant?: "green" | "blue" | "purple"; }) { return ( - +
{value} @@ -97,6 +107,7 @@ function PairedMetric({ secondary, gradient, iconClassName = "text-gray-700", + variant = "green", }: { title: string; icon: React.ComponentType>; @@ -104,9 +115,10 @@ function PairedMetric({ secondary: { label: string; value: string }; gradient: string; iconClassName?: string; + variant?: "green" | "blue" | "purple"; }) { return ( - +
@@ -172,7 +184,7 @@ function Section({
-
+
{children}
@@ -223,6 +235,7 @@ export function ScenarioFinancialDrawer({ value: formatNumber(metrics.averageCaribonSaved), }} gradient={gradients.green} + variant="green" /> @@ -270,6 +285,7 @@ export function ScenarioFinancialDrawer({ value: `£${formatNumber(metrics.pcCost)}`, }} gradient={gradients.blue} + variant="blue" /> @@ -310,6 +328,7 @@ export function ScenarioFinancialDrawer({ value: `£${formatNumber(metrics.costPerCo2)}`, }} gradient={gradients.purple} + variant="purple" />
diff --git a/src/app/portfolio/[slug]/(portfolio)/reporting/pdf/AutoPrint.tsx b/src/app/portfolio/[slug]/(portfolio)/reporting/pdf/AutoPrint.tsx new file mode 100644 index 0000000..6364471 --- /dev/null +++ b/src/app/portfolio/[slug]/(portfolio)/reporting/pdf/AutoPrint.tsx @@ -0,0 +1,66 @@ +"use client"; + +import { useEffect } from "react"; + +export function AutoPrint() { + useEffect(() => { + let cancelled = false; + + function waitForImages() { + const images = Array.from(document.images); + + return Promise.all( + images.map((img) => { + if (img.complete) return Promise.resolve(); + + return new Promise((resolve) => { + img.onload = () => resolve(); + img.onerror = () => resolve(); // never block print + }); + }) + ); + } + + function waitForFontsSafe() { + try { + // Some browsers expose document.fonts but break on .ready + if ( + "fonts" in document && + document.fonts && + typeof document.fonts.ready?.then === "function" + ) { + return document.fonts.ready; + } + } catch { + // Ignore font readiness completely if browser misbehaves + } + + return Promise.resolve(); + } + + async function printWhenReady() { + await Promise.all([waitForImages(), waitForFontsSafe()]); + + if (cancelled) return; + + // Ensure layout is flushed + requestAnimationFrame(() => { + // Close tab AFTER print dialog completes + window.onafterprint = () => { + window.close(); + }; + + window.print(); + }); + } + + printWhenReady(); + + return () => { + cancelled = true; + window.onafterprint = null; + }; + }, []); + + return null; +} diff --git a/src/app/portfolio/[slug]/(portfolio)/reporting/pdf/page.tsx b/src/app/portfolio/[slug]/(portfolio)/reporting/pdf/page.tsx new file mode 100644 index 0000000..0e72466 --- /dev/null +++ b/src/app/portfolio/[slug]/(portfolio)/reporting/pdf/page.tsx @@ -0,0 +1,199 @@ +import { SectionDivider } from "../SectionDivider"; +import { ScenarioFinancialDrawer } from "../ScenarioFinancialDrawer"; +import { DashboardSummaryCards } from "../DashboardSummaryCards"; +import { EpcQualityCards } from "../EpcQualityCards"; + +import { loadBaselineMetrics } from "@/app/portfolio/[slug]/(portfolio)/reporting/databaseFunctions"; + +import type { BaselineMetrics } from "../types"; +import Image from "next/image"; +import { AutoPrint } from "./AutoPrint"; + +/* --------------------------------------------- + Base URL helper (Vercel + local) +--------------------------------------------- */ + +function getBaseUrl() { + if (process.env.VERCEL_BRANCH_URL) { + return `https://${process.env.VERCEL_BRANCH_URL}`; + } + + if (process.env.VERCEL_URL) { + return `https://${process.env.VERCEL_URL}`; + } + + return "http://localhost:3000"; +} + +/* --------------------------------------------- + Server-side fetch for scenario metrics +--------------------------------------------- */ + +async function fetchScenarioReport({ + portfolioId, + scenarioId, +}: { + portfolioId: number; + scenarioId: number; +}) { + const res = await fetch( + `${getBaseUrl()}/api/portfolio/${portfolioId}/scenario/${scenarioId}/metrics`, + { cache: "no-store" } + ); + + if (!res.ok) { + throw new Error("Failed to load scenario report"); + } + + return res.json(); +} + +/* --------------------------------------------- + Page +--------------------------------------------- */ + +export default async function ReportingPdfPage(props: { + params: Promise<{ slug: string }>; + searchParams: Promise<{ scenarioId?: string }>; +}) { + const params = await props.params; + const searchParams = await props.searchParams; + const scenarioId = Number(searchParams.scenarioId); + + if (!scenarioId) { + return
No scenario selected
; + } + + const portfolioId = Number(params.slug); + + /* --------------------------------------------- + Fetch baseline + scenario (parallel) + --------------------------------------------- */ + + const [baseline, scenarioData]: [BaselineMetrics, any] = await Promise.all([ + loadBaselineMetrics(portfolioId), + fetchScenarioReport({ portfolioId, scenarioId }), + ]); + + /* --------------------------------------------- + Scenario-only metrics for drawer + --------------------------------------------- */ + + const scenarioOverlay = scenarioData + ? { + avgSap: { + baseline: baseline.averages.avg_sap ?? 0, + scenario: Number(scenarioData.avg_sap), + }, + avgCarbon: { + baseline: Number(baseline.averages.avg_carbon ?? 0), + scenario: Number(scenarioData.avg_carbon), + + baselineTotal: Number(baseline.totals.total_carbon ?? 0), + scenarioTotal: Number(scenarioData.total_carbon ?? 0), + }, + avgBills: { + baseline: baseline.averages.avg_bills ?? 0, + scenario: scenarioData.avg_bills, + baselineTotal: baseline.totals.total_bills ?? 0, + scenarioTotal: scenarioData.total_bills, + }, + valuation: { baseline: null, scenario: null }, + scenarioEpcBands: scenarioData.scenario_epc_counts, + } + : null; + + const scenarioSpecific = { + constructionCost: scenarioData.construction_cost, + pcCost: scenarioData.pc_cost, + contingency: scenarioData.contingency, + funding: scenarioData.total_funding, + costPerSap: + scenarioData.construction_cost > 0 + ? scenarioData.gross_per_unit / + (scenarioData.avg_sap - (baseline.averages.avg_sap ?? 0)) + : 0, + costPerCo2: + scenarioData.construction_cost > 0 + ? (scenarioData.construction_cost + scenarioData.pc_cost) / + scenarioData.total_carbon + : 0, + netCost: scenarioData.net_cost, + grossPerUnit: scenarioData.gross_per_unit, + nUnits: scenarioData.n_units_upgraded, + totalCarbonSaved: + (baseline.totals.total_carbon ?? 0) - scenarioData.total_carbon, + totalBillsSaved: + (baseline.totals.total_bills ?? 0) - scenarioData.total_bills, + averageCaribonSaved: + ((baseline.totals.total_carbon ?? 0) - scenarioData.total_carbon) / + scenarioData.n_units_upgraded, + averageBillsSaved: + ((baseline.totals.total_bills ?? 0) - scenarioData.total_bills) / + scenarioData.n_units_upgraded, + }; + + return ( +
+
+ + {/* ------------------------------------------------ + Branded header + ------------------------------------------------ */} +
+ Domna Logo +
+

Retrofit Scenario Report

+
+
+ + {/* ------------------------------------------------ + Scenario Impact Summary + ------------------------------------------------ */} + + + + + {/* ------------------------------------------------ + Portfolio Summary (baseline) + ------------------------------------------------ */} +
+ + + + + {/* ------------------------------------------------ + EPC Quality (baseline) + ------------------------------------------------ */} + + + +
+
+ ); +}