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/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)/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/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 ce72dfa..5fe5848 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, @@ -159,6 +161,17 @@ export function ReportingClientArea({ Could not load scenario data.
)} + {/* --- RETROFIT SECTION --- */} - {open && metrics && ( - -
-

- Scenario Financial Summary -

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