Merge pull request #151 from Hestia-Homes/new-reporting

Reporting screen re-styled and pdf download implemented
This commit is contained in:
KhalimCK 2025-12-16 13:27:57 +08:00 committed by GitHub
commit ecf87b3768
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 703 additions and 159 deletions

View file

@ -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,
});
}

View file

@ -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;
}
}

View file

@ -81,13 +81,7 @@ export default async function Page(props: {
<>
<div className="flex justify-center">
<div className="grid grid-cols-11 w-full max-w-8xl">
<div className="col-span-3 flex-col">
<SummaryBox
scenarios={scenarios}
numProperties={properties.length}
/>
</div>
<div className="col-span-8 bg-white">
<div className="col-span-11 bg-white">
{properties.length === 0 ? (
<EmptyPropertyState />
) : (

View file

@ -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}

View file

@ -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.
</div>
)}
<button
onClick={() => {
window.open(
`/portfolio/${portfolioId}/reporting/pdf?scenarioId=${selectedScenarioId}`,
"_blank"
);
}}
className="rounded-md border px-3 py-2 text-sm font-medium hover:bg-gray-50"
>
Download PDF
</button>
{/* --- RETROFIT SECTION --- */}
<SectionDivider

View file

@ -2,174 +2,338 @@
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,
variant,
children,
}: {
gradient: string;
variant: "green" | "blue" | "purple";
children: React.ReactNode;
}) {
return (
<AnimatePresence initial={false}>
{open && metrics && (
<motion.div
key="drawer"
initial={{ height: 0, opacity: 0 }}
animate={{ height: "auto", opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ duration: 0.35, ease: "easeInOut" }}
className="overflow-hidden"
>
<div className="rounded-lg border border-gray-200 bg-white shadow-sm mt-4 p-6">
<h3 className="text-lg font-semibold text-brandblue mb-4">
Scenario Financial Summary
</h3>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 text-sm">
<Metric
label="Total Cost"
value={`£${formatNumber(metrics.totalCost)}`}
icon={Banknote}
color="text-brandblue"
bg="bg-brandblue/20"
/>
<Metric
label="Contingency"
value={`£${formatNumber(metrics.contingency)}`}
icon={ShieldAlert}
color="text-amber-600"
bg="bg-amber-200/40"
/>
<Metric
label="Funding"
value={`£${formatNumber(metrics.funding)}`}
icon={PiggyBank}
color="text-emerald-600"
bg="bg-emerald-200/40"
/>
<Metric
label="Net Cost"
value={`£${formatNumber(metrics.netCost)}`}
icon={Scale}
color="text-red-600"
bg="bg-red-200/40"
/>
<Metric
label="Cost per SAP point"
value={`£${formatNumber(metrics.costPerSap)}`}
icon={Gauge}
color="text-purple-600"
bg="bg-purple-200/40"
/>
<Metric
label="Cost per tonne CO₂"
value={`£${formatNumber(metrics.costPerCo2)}`}
icon={Factory}
color="text-slate-700"
bg="bg-slate-200/40"
/>
<Metric
label="Gross Cost per Unit"
value={`£${formatNumber(metrics.grossPerUnit)}`}
icon={Home}
color="text-sky-600"
bg="bg-sky-200/40"
/>
<Metric
label="Units Upgraded"
value={metrics.nUnits}
icon={Users}
color="text-brandblue"
bg="bg-brandblue/20"
/>
<Metric
label="Total Carbon Saved (tonnes) per year"
value={formatNumber(metrics.totalCarbonSaved)}
icon={Users}
color="text-brandblue"
bg="bg-brandblue/20"
/>
<Metric
label="Total Bills Saved (£) per year"
value={`£${formatNumber(metrics.totalBillsSaved)}`}
icon={Users}
color="text-brandblue"
bg="bg-brandblue/20"
/>
<Metric
label="Average Carbon Saved (tonnes) per unit per year"
value={formatNumber(metrics.averageCaribonSaved)}
icon={Users}
color="text-brandblue"
bg="bg-brandblue/20"
/>
<Metric
label="Average Bills Saved (£) per unit per year"
value={`£${formatNumber(metrics.averageBillsSaved)}`}
icon={Users}
color="text-brandblue"
bg="bg-brandblue/20"
/>
</div>
</div>
</motion.div>
<div
className={clsx(
"relative rounded-lg p-[2px] gradient-card",
gradient,
`gradient-${variant}`
)}
</AnimatePresence>
>
<div className="rounded-[7px] bg-white h-full">{children}</div>
</div>
);
}
/* ───────────────────────────────────────────── */
/* Single Metric Card */
/* ───────────────────────────────────────────── */
function Metric({
label,
value,
icon: Icon,
color,
bg,
gradient,
variant = "green",
}: {
label: string;
value: string | number;
icon: any;
icon: React.ComponentType<React.SVGProps<SVGSVGElement>>;
color: string;
bg: string;
gradient: string;
variant?: "green" | "blue" | "purple";
}) {
return (
<div
className="group flex flex-col rounded-lg border border-gray-200
bg-gradient-to-br from-white to-gray-50
p-3 shadow-sm hover:shadow-md
hover:border-brandblue/30 transition-all"
>
<div className="flex items-center gap-2 mb-1">
{/* coloured icon background */}
<div
className={`p-1.5 rounded-md ${bg} group-hover:opacity-80 transition`}
>
<Icon className={`h-4 w-4 ${color}`} />
</div>
<span className="text-[10px] uppercase tracking-wide font-semibold text-gray-500">
<GradientCard gradient={gradient} variant={variant}>
<div className="flex flex-col items-center justify-center p-4 h-full text-center">
<Icon className={clsx("h-6 w-6 mb-2", color)} />
<span className="text-3xl font-semibold text-gray-900">{value}</span>
<span className="mt-1 text-xs uppercase tracking-wide font-semibold text-gray-500">
{label}
</span>
</div>
</GradientCard>
);
}
<span
className="text-lg md:text-xl font-semibold text-gray-900
group-hover:text-brandblue transition truncate"
>
{value}
</span>
/* ───────────────────────────────────────────── */
/* Paired Metric Card (Reusable Everywhere) */
/* ───────────────────────────────────────────── */
function PairedMetric({
title,
icon: Icon,
primary,
secondary,
gradient,
iconClassName = "text-gray-700",
variant = "green",
}: {
title: string;
icon: React.ComponentType<React.SVGProps<SVGSVGElement>>;
primary: { label: string; value: string };
secondary: { label: string; value: string };
gradient: string;
iconClassName?: string;
variant?: "green" | "blue" | "purple";
}) {
return (
<GradientCard gradient={gradient} variant={variant}>
<div className="p-4 h-full">
<div className="flex items-center gap-2 mb-3">
<Icon className={clsx("h-5 w-5", iconClassName)} />
<span className="text-sm font-semibold text-gray-900">{title}</span>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<p className="text-xs text-gray-500">{primary.label}</p>
<p className="text-xl font-semibold text-gray-900">
{primary.value}
</p>
</div>
<div>
<p className="text-xs text-gray-500">{secondary.label}</p>
<p className="text-xl font-semibold text-gray-900">
{secondary.value}
</p>
</div>
</div>
</div>
</GradientCard>
);
}
/* ───────────────────────────────────────────── */
/* Section Header */
/* ───────────────────────────────────────────── */
function Section({
title,
subtitle,
icon: Icon,
gradient,
accentColor,
children,
}: {
title: string;
subtitle: string;
icon: React.ComponentType<React.SVGProps<SVGSVGElement>>;
gradient: string;
accentColor: string;
children: React.ReactNode;
}) {
return (
<div className="space-y-4">
<div className="flex items-start gap-3">
<div className={clsx("w-1 rounded-full self-stretch", gradient)} />
<div
className={clsx(
"rounded-lg p-2 bg-white shadow-sm border",
accentColor
)}
>
<Icon className="h-5 w-5" />
</div>
<div className="pt-0.5">
<h4 className="text-base font-semibold text-gray-900">{title}</h4>
<p className="text-xs text-gray-500">{subtitle}</p>
</div>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-3 print-grid-3">
{children}
</div>
</div>
);
}
/* ───────────────────────────────────────────── */
/* Main Drawer */
/* ───────────────────────────────────────────── */
export function ScenarioFinancialDrawer({
open,
metrics,
}: ScenarioFinancialDrawerProps) {
return (
<AnimatePresence initial={false}>
{open && metrics && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: "auto", opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ duration: 0.35, ease: "easeInOut" }}
className="overflow-hidden"
>
<div className="rounded-lg border border-gray-200 bg-white shadow-sm mt-4 p-6 space-y-6">
<h3 className="text-lg font-semibold text-brandblue">
Scenario Impact Summary
</h3>
{/* BENEFITS */}
<Section
title="Benefits"
subtitle="Impact for occupants and the environment"
icon={ArrowTrendingUpIcon}
gradient={gradients.green}
accentColor="border-green-200 text-green-700"
>
<PairedMetric
title="Carbon impact"
icon={BoltIcon}
iconClassName="text-green-700"
primary={{
label: "Total carbon saved (t/yr)",
value: formatNumber(metrics.totalCarbonSaved),
}}
secondary={{
label: "Average per unit (t/yr)",
value: formatNumber(metrics.averageCaribonSaved),
}}
gradient={gradients.green}
variant="green"
/>
<PairedMetric
title="Bill savings"
icon={FireIcon}
iconClassName="text-green-700"
primary={{
label: "Total bill savings (£/yr)",
value: `£${formatNumber(metrics.totalBillsSaved)}`,
}}
secondary={{
label: "Average per unit (£/yr)",
value: `£${formatNumber(metrics.averageBillsSaved)}`,
}}
gradient={gradients.green}
variant="green"
/>
<Metric
label="Homes upgraded"
value={metrics.nUnits}
icon={HomeIcon}
color="text-green-700"
gradient={gradients.green}
variant="green"
/>
</Section>
{/* COSTS */}
<Section
title="Costs"
subtitle="Investment required to deliver the works"
icon={ClipboardDocumentCheckIcon}
gradient={gradients.blue}
accentColor="border-brandblue text-brandblue"
>
<PairedMetric
title="Delivery costs"
icon={WrenchIcon}
iconClassName="text-blue-600"
primary={{
label: "Construction works",
value: `£${formatNumber(metrics.constructionCost)}`,
}}
secondary={{
label: "Project delivery",
value: `£${formatNumber(metrics.pcCost)}`,
}}
gradient={gradients.blue}
variant="blue"
/>
<Metric
label="Gross cost per unit"
value={`£${formatNumber(metrics.grossPerUnit)}`}
icon={HomeIcon}
color="text-blue-600"
gradient={gradients.blue}
variant="blue"
/>
<Metric
label="Contingency"
value={`£${formatNumber(metrics.contingency)}`}
icon={Gauge}
color="text-blue-600"
gradient={gradients.blue}
variant="blue"
/>
</Section>
{/* COST EFFECTIVENESS */}
<Section
title="Cost effectiveness"
subtitle="Value for money of the investment"
icon={ScaleIcon}
gradient={gradients.purple}
accentColor="border-purple-200 text-purple-700"
>
<PairedMetric
title="Efficiency metrics"
icon={ChartBarIcon}
iconClassName="text-purple-700"
primary={{
label: "£ per SAP point",
value: `£${formatNumber(metrics.costPerSap)}`,
}}
secondary={{
label: "£ per tonne CO₂",
value: `£${formatNumber(metrics.costPerCo2)}`,
}}
gradient={gradients.purple}
variant="purple"
/>
</Section>
</div>
</motion.div>
)}
</AnimatePresence>
);
}

View file

@ -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<void>((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;
}

View file

@ -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 <div>No scenario selected</div>;
}
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 (
<div className="print-page">
<div className="print-root space-y-4">
<AutoPrint />
{/* ------------------------------------------------
Branded header
------------------------------------------------ */}
<header className="flex items-center gap-3 border-b pb-2">
<Image
src="/domna_logo_blue_transparent_background.png"
alt="Domna Logo"
width={140}
height={40}
/>
<div>
<h1 className="text-xl font-semibold">Retrofit Scenario Report</h1>
</div>
</header>
{/* ------------------------------------------------
Scenario Impact Summary
------------------------------------------------ */}
<SectionDivider
title="Scenario Impact Summary"
subtitle="Financial, carbon and energy outcomes"
/>
<ScenarioFinancialDrawer open={true} metrics={scenarioSpecific} />
{/* ------------------------------------------------
Portfolio Summary (baseline)
------------------------------------------------ */}
<div className="page-break" />
<SectionDivider
title="Portfolio Summary"
subtitle="Headline performance indicators"
/>
<DashboardSummaryCards
total={baseline.total}
totals={baseline.totals}
averages={baseline.averages}
estimatedCounts={baseline.estimatedCounts}
scenarioOverlay={scenarioOverlay}
/>
{/* ------------------------------------------------
EPC Quality (baseline)
------------------------------------------------ */}
<SectionDivider
title="EPC Quality"
subtitle="Condition, compliance and performance"
/>
<EpcQualityCards
estimatedCounts={baseline.estimatedCounts}
total={baseline.total}
expiredEpcs={baseline.expiredEpcs}
likelyDowngrades={baseline.likelyDowngrades}
/>
</div>
</div>
);
}