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
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
);
}
+/* ───────────────────────────────────────────── */
+/* 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 (
+
);
}
+
+/* ───────────────────────────────────────────── */
+/* 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
+ ------------------------------------------------ */}
+
+
+ {/* ------------------------------------------------
+ Scenario Impact Summary
+ ------------------------------------------------ */}
+
+
+
+
+ {/* ------------------------------------------------
+ Portfolio Summary (baseline)
+ ------------------------------------------------ */}
+
+
+
+
+
+ {/* ------------------------------------------------
+ EPC Quality (baseline)
+ ------------------------------------------------ */}
+
+
+
+
+
+ );
+}