mirror of
https://github.com/Hestia-Homes/assessment-model.git
synced 2026-06-08 11:37:25 +00:00
Merge pull request #151 from Hestia-Homes/new-reporting
Reporting screen re-styled and pdf download implemented
This commit is contained in:
commit
ecf87b3768
8 changed files with 703 additions and 159 deletions
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 />
|
||||
) : (
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
199
src/app/portfolio/[slug]/(portfolio)/reporting/pdf/page.tsx
Normal file
199
src/app/portfolio/[slug]/(portfolio)/reporting/pdf/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue