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) + ------------------------------------------------ */} + + + +
+
+ ); +}