Merge pull request #168 from Hestia-Homes/main

Dev deployment - work since Nov
This commit is contained in:
KhalimCK 2026-01-26 14:19:47 +00:00 committed by GitHub
commit e2bb4ecfa4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
125 changed files with 115048 additions and 4362 deletions

25
.github/workflows/nextjs-build.yml vendored Normal file
View file

@ -0,0 +1,25 @@
name: Next.js Build Check
on:
push:
branches:
- "**" # all branches
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Checkout repo
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
- name: Install dependencies
run: npm ci
- name: Build Next.js app
run: npm run build

1
.gitignore vendored
View file

@ -27,6 +27,7 @@ yarn-error.log*
# local env files # local env files
.env*.local .env*.local
env.local
cypress.env.json cypress.env.json
.env*.development .env*.development

26
.vscode/settings.json vendored Normal file
View file

@ -0,0 +1,26 @@
{
// Hot reload setting that needs to be in user settings
// "jupyter.runStartupCommands": [
// "%load_ext autoreload", "%autoreload 2"
// ]
// --- VIM SETTINGS ---
// "vim.useSystemClipboard": true,
"vim.enableNeovim": false,
// Allow VSCode native keybindings to override Vim when needed
"vim.handleKeys": {
"<C-p>": false,
"<C-P>": false,
"<C-S-p>": false,
"<C-c>": false,
"<C-v>": false,
"<C-S-v>": false,
"<C-S-e>": false,
"<C-b>": false,
"<C-j>": false,
"<C-S-c>": false,
"<C-k>": false
},
}

View file

@ -130,4 +130,6 @@ In our terraform stack, we have a module called `s3_presignable_bucket` which co
We will generate a pre-signed url and then make a post request to that endpoint to store that data to s3. Part of that process is the creation of an AWS IAM role which contains We will generate a pre-signed url and then make a post request to that endpoint to store that data to s3. Part of that process is the creation of an AWS IAM role which contains
the permission set to access the bucket, `rerofit-plan-inputs-<stage>`. The name of this IAM role is `s3_presign_role_<stage>` and for our NextJS application, as it's hosted outside of AWS (for the moment), we need to generate a set of access credentials to give the application access to this bucket. The access key and secret key are automatically generated and stored in AWS secrets manager under `dev/presign_frontend/access_key` and `dev/presign_frontend/secret_key` and need to be set in the environment for the pre-sign api to store csv data to aws. the permission set to access the bucket, `rerofit-plan-inputs-<stage>`. The name of this IAM role is `s3_presign_role_<stage>` and for our NextJS application, as it's hosted outside of AWS (for the moment), we need to generate a set of access credentials to give the application access to this bucket. The access key and secret key are automatically generated and stored in AWS secrets manager under `dev/presign_frontend/access_key` and `dev/presign_frontend/secret_key` and need to be set in the environment for the pre-sign api to store csv data to aws.
# Quick wins:
- [] Frequently asked questions page

View file

@ -18,5 +18,5 @@ export default {
ssl: isProduction ssl: isProduction
? true // strict SSL for prod ? true // strict SSL for prod
: { rejectUnauthorized: false }, // allow self-signed in dev/preview : { rejectUnauthorized: false }, // allow self-signed in dev/preview
} },
} satisfies Config; } satisfies Config;

5870
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -3,7 +3,7 @@
"version": "0.1.0", "version": "0.1.0",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "next dev", "dev": "/workspaces/assessment-model/node_modules/.bin/next dev",
"build": "next build", "build": "next build",
"start": "next start", "start": "next start",
"lint": "next lint", "lint": "next lint",
@ -14,10 +14,11 @@
"create_user": "tsx src/app/db/create_user.ts" "create_user": "tsx src/app/db/create_user.ts"
}, },
"dependencies": { "dependencies": {
"@aws-sdk/client-s3": "^3.971.0",
"@aws-sdk/client-sqs": "^3.864.0", "@aws-sdk/client-sqs": "^3.864.0",
"@aws-sdk/s3-request-presigner": "^3.927.0", "@aws-sdk/s3-request-presigner": "^3.927.0",
"@headlessui/react": "^2.2.7", "@headlessui/react": "^2.2.7",
"@heroicons/react": "^2.0.18", "@heroicons/react": "^2.2.0",
"@hookform/resolvers": "^3.9.1", "@hookform/resolvers": "^3.9.1",
"@hubspot/api-client": "^13.4.0", "@hubspot/api-client": "^13.4.0",
"@radix-ui/react-accordion": "^1.2.12", "@radix-ui/react-accordion": "^1.2.12",
@ -40,23 +41,22 @@
"@tanstack/react-table": "^8.9.3", "@tanstack/react-table": "^8.9.3",
"@tremor/react": "^3.18.7", "@tremor/react": "^3.18.7",
"@types/node": "20.2.3", "@types/node": "20.2.3",
"@types/react": "18.3.1",
"@types/react-dom": "18.3.1", "@types/react-dom": "18.3.1",
"@vercel/speed-insights": "^1.2.0", "@vercel/speed-insights": "^1.2.0",
"autoprefixer": "10.4.14", "autoprefixer": "10.4.14",
"aws-sdk": "^2.1415.0", "aws-sdk": "^2.1415.0",
"class-variance-authority": "^0.6.1", "class-variance-authority": "^0.6.1",
"client-s3": "github:aws-sdk/client-s3",
"clsx": "^1.2.1", "clsx": "^1.2.1",
"drizzle-orm": "^0.44.5", "drizzle-orm": "^0.44.5",
"esbuild": "^0.25.8", "esbuild": "^0.25.8",
"eslint-config-next": "13.4.3", "eslint-config-next": "13.4.3",
"framer-motion": "^12.23.24", "framer-motion": "^12.23.24",
"lucide-react": "^0.233.0", "lucide-react": "^0.233.0",
"next": "^15.4.2", "next": "^15.5.7",
"next-auth": "^4.22.1", "next-auth": "^4.22.1",
"next-axiom": "^1.9.2", "next-axiom": "^1.9.2",
"next-themes": "^0.3.0", "next-themes": "^0.3.0",
"nodemailer": "^7.0.11",
"pg": "^8.11.1", "pg": "^8.11.1",
"postcss": "^8.5.6", "postcss": "^8.5.6",
"react": "18.3.1", "react": "18.3.1",
@ -75,8 +75,10 @@
"devDependencies": { "devDependencies": {
"@tailwindcss/forms": "^0.5.10", "@tailwindcss/forms": "^0.5.10",
"@testing-library/cypress": "^10.0.3", "@testing-library/cypress": "^10.0.3",
"@types/node": "^24.10.1",
"@types/nodemailer": "^7.0.2", "@types/nodemailer": "^7.0.2",
"@types/pg": "^8.10.2", "@types/pg": "^8.10.2",
"@types/react": "^19.2.7",
"cypress": "^14.5.3", "cypress": "^14.5.3",
"cypress-social-logins": "^1.14.1", "cypress-social-logins": "^1.14.1",
"dotenv": "^16.3.1", "dotenv": "^16.3.1",

Binary file not shown.

Before

Width:  |  Height:  |  Size: 106 KiB

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 88 KiB

After

Width:  |  Height:  |  Size: 13 KiB

View file

@ -0,0 +1,30 @@
import { NextResponse } from "next/server";
import { deletePlan } from "@/lib/services/propertyDeletion";
export async function POST(
req: Request,
context: { params: Promise<{ id: string }> }
) {
const { id } = await context.params;
const planId = Number(id);
if (Number.isNaN(planId)) {
return NextResponse.json({ error: "Invalid plan id" }, { status: 400 });
}
const { confirm } = await req.json();
if (confirm !== true) {
return NextResponse.json(
{ error: "Explicit confirmation required" },
{ status: 400 }
);
}
await deletePlan(planId);
return NextResponse.json({
success: true,
dryRun: true,
});
}

View file

@ -0,0 +1,13 @@
import { NextResponse } from "next/server";
import { previewPlanDeletion } from "@/lib/services/propertyDeletion";
export async function POST(
_req: Request,
context: { params: Promise<{ id: string }> }
) {
const { id } = await context.params; // 👈 THIS IS THE FIX
const planId = Number(id);
const preview = await previewPlanDeletion(planId);
return NextResponse.json({ preview });
}

View file

@ -26,6 +26,7 @@ const PresignedUrlBodySchema = z
file_type: z.enum(["csv", "xlsx"]).optional(), // Specify the file type file_type: z.enum(["csv", "xlsx"]).optional(), // Specify the file type
file_format: z.enum(["domna_asset_list"]).optional().nullable(), // Specify the file format file_format: z.enum(["domna_asset_list"]).optional().nullable(), // Specify the file format
sheet_name: z.string().optional().nullable(), // Specify the sheet name if applicable sheet_name: z.string().optional().nullable(), // Specify the sheet name if applicable
enforce_fabric_first: z.boolean().optional().default(false),
}) })
.refine((data) => data.goal !== "Increasing EPC" || !!data.goal_value, { .refine((data) => data.goal !== "Increasing EPC" || !!data.goal_value, {
path: ["goal_value"], path: ["goal_value"],

View file

@ -0,0 +1,123 @@
import { db } from "@/app/db/db";
import { sql } from "drizzle-orm";
import { NextRequest, NextResponse } from "next/server";
type MeasureAggregateRow = {
measure_type: string | null;
type: string | null;
includes_battery: boolean | null;
homes_count: number;
total_cost: number | null;
average_cost: number | null;
};
export async function GET(
request: NextRequest,
props: { params: Promise<{ portfolioId: string; scenarioId: string }> }
) {
const { portfolioId, scenarioId } = await props.params;
const pid = BigInt(portfolioId);
const sid = BigInt(scenarioId);
// TEMP: Remove batteries as underspecified
// const result = await db.execute(sql`
// WITH latest_plans AS (
// SELECT DISTINCT ON (property_id)
// *
// FROM plan
// WHERE portfolio_id = ${pid}
// AND scenario_id = ${sid}
// ORDER BY property_id, created_at DESC
// ),
// recommendation_flags AS (
// SELECT
// r.id AS recommendation_id,
// r.measure_type AS measure_type,
// r.property_id AS property_id,
// r.estimated_cost AS estimated_cost,
// BOOL_OR(m.includes_battery) AS includes_battery
// FROM latest_plans lp
// JOIN plan_recommendations pr
// ON pr.plan_id = lp.id
// JOIN recommendation r
// ON r.id = pr.recommendation_id
// LEFT JOIN recommendation_materials rm
// ON rm.recommendation_id = r.id
// LEFT JOIN material m
// ON m.id = rm.material_id
// AND m.is_active = true
// WHERE r.default = true
// AND r.already_installed = false
// GROUP BY
// r.id,
// r.measure_type,
// r.property_id,
// r.estimated_cost
// )
// SELECT
// measure_type,
// COALESCE(includes_battery, false) AS includes_battery,
// COUNT(DISTINCT property_id)::int AS homes_count,
// SUM(estimated_cost)::float AS total_cost,
// AVG(estimated_cost)::float AS average_cost
// FROM recommendation_flags
// GROUP BY
// measure_type,
// includes_battery
// ORDER BY total_cost DESC;
// `);
const result = await db.execute(sql`
SELECT
r.measure_type,
r.type,
COUNT(DISTINCT r.property_id)::int AS homes_count,
SUM(r.estimated_cost)::float AS total_cost,
AVG(r.estimated_cost)::float AS average_cost
FROM recommendation r
WHERE r.default = true
AND r.already_installed = false
AND EXISTS (
SELECT 1
FROM (
SELECT DISTINCT ON (p.property_id)
p.id
FROM plan p
WHERE p.portfolio_id = ${pid}
AND p.scenario_id = ${sid}
ORDER BY p.property_id, p.created_at DESC
) lp
JOIN plan_recommendations pr
ON pr.plan_id = lp.id
WHERE pr.recommendation_id = r.id
)
GROUP BY
r.measure_type,
r.type
ORDER BY total_cost DESC;
`);
const measures = (result.rows as MeasureAggregateRow[]).map((row) => ({
measureType: row.measure_type ?? "unknown",
type: row.type ?? "unknown",
homesCount: row.homes_count,
totalCost: Number(row.total_cost ?? 0),
averageCost: Number(row.average_cost ?? 0),
// includesBattery: row.includes_battery ?? false,
}));
return NextResponse.json({
portfolioId: Number(portfolioId),
scenarioId: Number(scenarioId),
measures,
});
}

View file

@ -0,0 +1,181 @@
import { db } from "@/app/db/db";
import { sql } from "drizzle-orm";
import { NextRequest, NextResponse } from "next/server";
import { sapToEpc } from "@/app/utils";
type BaselineAggregates = {
n_units: number;
avg_sap: number | null;
avg_carbon: number | null;
avg_bills: number | null;
total_carbon: number | null;
total_bills: number | null;
total_sap_uplift: number | null;
sap_points_array: (number | null)[];
};
type UpgradedAggregates = {
n_units_upgraded: number;
total_cost: number | null;
contingency: number | null;
total_funding: number | null;
};
export async function GET(
request: NextRequest,
props: { params: Promise<{ portfolioId: string; scenarioId: string }> }
) {
const { portfolioId, scenarioId } = await props.params;
const pid = BigInt(portfolioId);
const sid = BigInt(scenarioId);
//
// ----------------------------------------------------------
// QUERY 1 — Baseline metrics for *all* properties
// ----------------------------------------------------------
//
const baselineResult = await db.execute(sql`
WITH latest_plans AS (
SELECT DISTINCT ON (property_id)
*
FROM plan
WHERE portfolio_id = ${pid}
AND scenario_id = ${sid}
ORDER BY property_id, created_at DESC
)
SELECT
COUNT(*)::int AS n_units,
AVG(lp.post_sap_points)::float AS avg_sap,
AVG(lp.post_co2_emissions)::float AS avg_carbon,
AVG(lp.post_energy_bill)::float AS avg_bills,
SUM(lp.post_co2_emissions)::float AS total_carbon,
SUM(lp.post_energy_bill)::float AS total_bills,
SUM(
CASE
WHEN lp.cost_of_works > 0.01
AND p.current_sap_points IS NOT NULL
AND lp.post_sap_points IS NOT NULL
THEN lp.post_sap_points - p.current_sap_points
ELSE 0
END
)::float AS total_sap_uplift,
ARRAY_AGG(lp.post_sap_points) AS sap_points_array
FROM latest_plans lp
JOIN property p
ON p.id = lp.property_id;
`);
const baseline = baselineResult.rows[0] as BaselineAggregates | undefined;
if (!baseline || baseline.n_units === 0) {
return NextResponse.json(
{ error: "No plans found for this scenario" },
{ status: 404 }
);
}
const {
n_units,
avg_sap,
avg_carbon,
avg_bills,
total_carbon,
total_bills,
sap_points_array,
} = baseline;
//
// ----------------------------------------------------------
// QUERY 2 — Upgrade metrics for properties receiving work
// ----------------------------------------------------------
//
const upgradedResult = await db.execute(sql`
WITH latest_plans AS (
SELECT DISTINCT ON (property_id)
*
FROM plan
WHERE portfolio_id = ${pid}
AND scenario_id = ${sid}
ORDER BY property_id, created_at DESC
)
SELECT
COUNT(*)::int AS n_units_upgraded,
SUM(cost_of_works)::float AS total_cost,
SUM(contingency_cost)::float AS contingency,
SUM(
COALESCE(fp.project_funding, 0)
+ COALESCE(fp.total_uplift, 0)
)::float AS total_funding
FROM latest_plans lp
LEFT JOIN funding_package fp
ON fp.plan_id = lp.id
WHERE lp.cost_of_works > 0.01;
`);
const upgraded = upgradedResult.rows[0] as UpgradedAggregates;
const n_units_upgraded = upgraded.n_units_upgraded;
const construction_cost = upgraded.total_cost ?? 0;
const contingency = upgraded.contingency ?? 0;
const total_funding = upgraded.total_funding ?? 0;
const net_cost = construction_cost - total_funding;
const pc_cost = construction_cost * 0.3; // Placeholder for PC cost
const total_sap_uplift = baseline.total_sap_uplift ?? 0;
//
// ----------------------------------------------------------
// EPC band distribution (all properties)
// ----------------------------------------------------------
//
const scenario_epc_counts: Record<string, number> = {
A: 0,
B: 0,
C: 0,
D: 0,
E: 0,
F: 0,
G: 0,
Unknown: 0,
};
for (const sap of sap_points_array) {
const band = sapToEpc(sap);
scenario_epc_counts[band] += 1;
}
//
// ----------------------------------------------------------
// RESPONSE
// ----------------------------------------------------------
//
return NextResponse.json({
// Baseline metrics (all units)
avg_sap: avg_sap !== null ? Number(avg_sap).toFixed(1) : null,
avg_carbon,
avg_bills,
total_carbon,
total_bills,
n_units,
scenario_epc_counts,
pc_cost,
// Upgrade metrics (only properties with work)
n_units_upgraded,
construction_cost,
contingency,
total_funding,
net_cost,
gross_per_unit:
n_units_upgraded > 0
? (construction_cost + pc_cost) / n_units_upgraded
: 0,
total_sap_uplift,
});
}

View file

@ -0,0 +1,25 @@
import { NextRequest, NextResponse } from "next/server";
import { getProperties } from "@/app/portfolio/[slug]/utils";
import { PropertyFilter } from "@/app/utils/propertyFilters";
export async function POST(req: NextRequest) {
const body = await req.json();
const portfolioId = body.portfolioId;
const filters: PropertyFilter[] = body.filters ?? [];
if (!portfolioId) {
return NextResponse.json(
{ error: "Missing portfolioId" },
{ status: 400 }
);
}
console.log("filters", filters);
const properties = await getProperties(
portfolioId,
1000,
0,
filters
);
return NextResponse.json(properties);
}

View file

@ -108,9 +108,9 @@ export default function RecommendationCard({
) as Recommendation; ) as Recommendation;
// A recommendation type could have no default recommendation, so we need to check if it exists // A recommendation type could have no default recommendation, so we need to check if it exists
const alreadyInstalled = defaultComponent const alreadyInstalled = recommendationData.some(
? defaultComponent.alreadyInstalled (rec) => rec.alreadyInstalled
: false; );
const [cardComponent, setCardComponent] = const [cardComponent, setCardComponent] =
useState<Recommendation>(defaultComponent); useState<Recommendation>(defaultComponent);
@ -131,8 +131,8 @@ export default function RecommendationCard({
const cardClassName = alreadyInstalled const cardClassName = alreadyInstalled
? alreadyInstalledStyling ? alreadyInstalledStyling
: cardComponent : cardComponent
? selectionStyling ? selectionStyling
: noSelectionStyling; : noSelectionStyling;
const optionTextClassName = alreadyInstalled const optionTextClassName = alreadyInstalled
? "text-brandgold" ? "text-brandgold"
@ -141,8 +141,8 @@ export default function RecommendationCard({
const optionsText = alreadyInstalled const optionsText = alreadyInstalled
? "Already installed" ? "Already installed"
: cardComponent : cardComponent
? "Click for more options" ? "Click for more options"
: "Click to select"; : "Click to select";
const openModal = () => { const openModal = () => {
// If the card is already installed, we don't want to open the modal // If the card is already installed, we don't want to open the modal

View file

@ -19,12 +19,14 @@ import {
SecondaryEnergyEfficiencyImpactCard, SecondaryEnergyEfficiencyImpactCard,
} from "./EnergyEfficiencyImpactCard"; } from "./EnergyEfficiencyImpactCard";
import { FundingPackageWithMeasures } from "@/app/db/schema/funding"; import { FundingPackageWithMeasures } from "@/app/db/schema/funding";
import { InstalledMeasureSummary } from "@/app/portfolio/[slug]/building-passport/[propertyId]/utils";
interface RecommendationContainerProps { interface RecommendationContainerProps {
recommendations: Recommendation[]; recommendations: Recommendation[];
propertyMeta: PropertyMeta; propertyMeta: PropertyMeta;
planMeta: Plan; planMeta: Plan;
funding: FundingPackageWithMeasures[]; funding: FundingPackageWithMeasures[];
installedMeasures: InstalledMeasureSummary[];
} }
const typeToCategoryMap: { [key in RecommendationType]?: RecommendationType } = const typeToCategoryMap: { [key in RecommendationType]?: RecommendationType } =
@ -57,7 +59,13 @@ export default function RecommendationContainer({
propertyMeta, propertyMeta,
planMeta, planMeta,
funding, funding,
installedMeasures,
}: RecommendationContainerProps) { }: RecommendationContainerProps) {
// Get the unique types of installed measures for easy lookup
const installedMeasureTypeSet = new Set(
installedMeasures.map((m) => m.measureType)
);
const categorizedRecommendations = recommendations.reduce( const categorizedRecommendations = recommendations.reduce(
(acc, curr) => { (acc, curr) => {
const typeKey = curr.type as RecommendationType; const typeKey = curr.type as RecommendationType;
@ -66,11 +74,28 @@ export default function RecommendationContainer({
if (!acc[category]) { if (!acc[category]) {
acc[category] = []; acc[category] = [];
} }
acc[category].push(curr);
const alreadyInstalled =
curr.measureType != null &&
installedMeasureTypeSet.has(curr.measureType);
acc[category].push({
...curr,
alreadyInstalled: alreadyInstalled,
sapPoints: alreadyInstalled ? 0 : curr.sapPoints,
estimatedCost: alreadyInstalled ? 0 : curr.estimatedCost,
co2EquivalentSavings: alreadyInstalled ? 0 : curr.co2EquivalentSavings,
energyCostSavings: alreadyInstalled ? 0 : curr.energyCostSavings,
kwhSavings: alreadyInstalled ? 0 : curr.kwhSavings,
labourDays: alreadyInstalled ? 0 : curr.labourDays,
});
return acc; return acc;
}, },
{} as Record<RecommendationType, (typeof recommendations)[0][]> {} as Record<
RecommendationType,
(Recommendation & { alreadyInstalled: boolean })[]
>
); );
const defaultWallsRecommendations = const defaultWallsRecommendations =

View file

@ -70,11 +70,6 @@ export function Toolbar({
const [openModal, setOpenModal] = useState(false); const [openModal, setOpenModal] = useState(false);
const [showToast, setShowToast] = useState(false); const [showToast, setShowToast] = useState(false);
console.log(propertyId, "PropertyID")
console.log(portfolioId, "porfolio id")
console.log(propertyMeta, "property meta")
console.log(decentHomes, "decent homes")
function handleClickSettings() { function handleClickSettings() {
console.log("Settings were clicked, implement me"); console.log("Settings were clicked, implement me");
} }
@ -129,8 +124,6 @@ export function Toolbar({
</NavigationMenuLink> </NavigationMenuLink>
); );
return ( return (
<> <>
<div className="flex items-center justify-between w-full"> <div className="flex items-center justify-between w-full">
@ -168,7 +161,7 @@ export function Toolbar({
onClick={() => setOpenModal(true)} onClick={() => setOpenModal(true)}
className="bg-brandblue text-white hover:bg-branddarkblue flex items-center" className="bg-brandblue text-white hover:bg-branddarkblue flex items-center"
> >
Book a Survey Book an On Site Assessment
</Button> </Button>
</div> </div>
</div> </div>

View file

@ -1,5 +1,5 @@
import { Dialog, Transition } from "@headlessui/react"; import { Dialog, Transition } from "@headlessui/react";
import { Fragment, useState } from "react"; import React, { Fragment, useState } from "react";
import PoundIconSvg from "./PoundIconSvg"; import PoundIconSvg from "./PoundIconSvg";
import LightBulbSvg from "./LightBulbSvg"; import LightBulbSvg from "./LightBulbSvg";
import CarbonIcon from "./CarbonIcon"; import CarbonIcon from "./CarbonIcon";
@ -21,12 +21,12 @@ const selectedIconClasses =
const deSelectedIconClasses = const deSelectedIconClasses =
"bg-gray-200 w-1/5 rounded-md h-1/5 cursor-pointer"; "bg-gray-200 w-1/5 rounded-md h-1/5 cursor-pointer";
interface iconComponentsType { interface IconComponentsType {
[key: string]: JSX.Element; [key: string]: React.ReactElement;
} }
const Icon: React.FC<IconProps> = ({ name, selected, onSelect }: IconProps) => { const Icon: React.FC<IconProps> = ({ name, selected, onSelect }: IconProps) => {
const iconComponents: iconComponentsType = { const iconComponents: IconComponentsType = {
"Valuation Improvement": <PoundIconSvg fill="white" />, "Valuation Improvement": <PoundIconSvg fill="white" />,
"Energy Savings": <LightBulbSvg fill="white" />, "Energy Savings": <LightBulbSvg fill="white" />,
// add more mappings here if needed // add more mappings here if needed

View file

@ -38,10 +38,10 @@ export function Toolbar({ portfolioId, scenarios }: ToolbarProps) {
href: `/portfolio/${portfolioId}`, href: `/portfolio/${portfolioId}`,
}, },
{ {
label: "Retrofit Summary", label: "Reporting",
icon: ChartBarIcon, icon: ChartBarIcon,
match: (p: string) => p.startsWith(`/portfolio/${portfolioId}/summary`), match: (p: string) => p === `/portfolio/${portfolioId}/reporting`,
href: `/portfolio/${portfolioId}/summary`, href: `/portfolio/${portfolioId}/reporting`,
}, },
{ {
label: "Decent Homes", label: "Decent Homes",

View file

@ -2,6 +2,7 @@
import { ReactNode, forwardRef } from "react"; import { ReactNode, forwardRef } from "react";
import clsx from "clsx"; import clsx from "clsx";
import type { ComponentPropsWithoutRef } from "react";
const baseStyles = { const baseStyles = {
solid: solid:
@ -39,27 +40,23 @@ const variantStyles: VariantStylesType = {
}; };
type ButtonInput = { type ButtonInput = {
variant: "solid" | "outline"; variant?: "solid" | "outline";
color: "cyan" | "white" | "gray"; color: "cyan" | "white" | "gray";
href: string; href?: string;
} & JSX.IntrinsicElements["button"]; } & ComponentPropsWithoutRef<"button">;
interface Props {
children?: ReactNode;
}
export type Ref = HTMLButtonElement; export type Ref = HTMLButtonElement;
// write me a forwardRef to use the Button component
const Button = forwardRef<Ref, ButtonInput>( const Button = forwardRef<Ref, ButtonInput>(
({ variant = "solid", color, className, href, ...props }, ref) => { ({ variant = "solid", color, className, ...props }, ref) => {
className = clsx( const classes = clsx(
baseStyles[variant], baseStyles[variant],
variantStyles[variant][color], variantStyles[variant][color],
className className,
); );
return <button ref={ref} className={className} {...props} />; return <button ref={ref} className={classes} {...props} />;
} },
); );
Button.displayName = "Button"; Button.displayName = "Button";

View file

@ -52,7 +52,7 @@ export default function EmailSignInButton({
type="email" type="email"
value={email} value={email}
onChange={handleEmailChange} onChange={handleEmailChange}
placeholder="Enter your email" placeholder="Enter email"
required required
className="flex-1 h-10 rounded-lg border-gray-300" className="flex-1 h-10 rounded-lg border-gray-300"
/> />

View file

@ -0,0 +1,4 @@
CREATE TABLE "whlg" (
"id" bigserial PRIMARY KEY NOT NULL,
"postcode" text NOT NULL
);

View file

@ -0,0 +1,17 @@
CREATE TABLE "epc_store" (
"id" serial PRIMARY KEY NOT NULL,
"uprn" bigint,
"epc_api_created_at" timestamp DEFAULT now() NOT NULL,
"epc_api" jsonb NOT NULL,
"epc_page_created_at" timestamp DEFAULT now() NOT NULL,
"epc_page" text NOT NULL,
"epc_page_rrn" text NOT NULL
);
--> statement-breakpoint
CREATE TABLE "property_installed_measures" (
"id" bigserial PRIMARY KEY NOT NULL,
"uprn" bigint NOT NULL,
"measure_type" "type" NOT NULL,
"created_at" timestamp DEFAULT now() NOT NULL,
"installed_at" timestamp DEFAULT now() NOT NULL
);

View file

@ -0,0 +1,4 @@
ALTER TABLE "epc_store" ALTER COLUMN "epc_api_created_at" DROP DEFAULT;--> statement-breakpoint
ALTER TABLE "epc_store" ALTER COLUMN "epc_api_created_at" DROP NOT NULL;--> statement-breakpoint
ALTER TABLE "epc_store" ALTER COLUMN "epc_page_created_at" DROP DEFAULT;--> statement-breakpoint
ALTER TABLE "epc_store" ALTER COLUMN "epc_page_created_at" DROP NOT NULL;

View file

@ -0,0 +1,3 @@
ALTER TABLE "epc_store" ALTER COLUMN "epc_api" DROP NOT NULL;--> statement-breakpoint
ALTER TABLE "epc_store" ALTER COLUMN "epc_page" DROP NOT NULL;--> statement-breakpoint
ALTER TABLE "epc_store" ALTER COLUMN "epc_page_rrn" DROP NOT NULL;

View file

@ -0,0 +1 @@
ALTER TYPE "public"."type" ADD VALUE 'double_glazing' BEFORE 'trickle_vent';

View file

@ -0,0 +1 @@
ALTER TABLE "property_details_epc" ADD COLUMN "sap_05_overwritten" boolean DEFAULT false;

View file

@ -0,0 +1 @@
ALTER TYPE "public"."type" ADD VALUE 'boiler_upgrade' BEFORE 'roomstat_programmer_trvs';

View file

@ -0,0 +1,10 @@
ALTER TABLE "plan" ADD COLUMN "post_sap_points" real;--> statement-breakpoint
ALTER TABLE "plan" ADD COLUMN "post_epc_rating" "epc";--> statement-breakpoint
ALTER TABLE "plan" ADD COLUMN "post_co2_emissions" real;--> statement-breakpoint
ALTER TABLE "plan" ADD COLUMN "co2_savings" real;--> statement-breakpoint
ALTER TABLE "plan" ADD COLUMN "post_energy_bill" real;--> statement-breakpoint
ALTER TABLE "plan" ADD COLUMN "energy_bill_savings" real;--> statement-breakpoint
ALTER TABLE "plan" ADD COLUMN "post_energy_consumption" real;--> statement-breakpoint
ALTER TABLE "plan" ADD COLUMN "energy_consumption_savings" real;--> statement-breakpoint
ALTER TABLE "plan" ADD COLUMN "valuation_post_retrofit" real;--> statement-breakpoint
ALTER TABLE "plan" ADD COLUMN "valuation_increase" real;

View file

@ -0,0 +1,6 @@
ALTER TABLE "property_details_epc" ADD COLUMN "lodgement_date" timestamp;--> statement-breakpoint
ALTER TABLE "property_details_epc" ADD COLUMN "is_expired" boolean;--> statement-breakpoint
ALTER TABLE "property_details_epc" ADD COLUMN "sap_05_score" real;--> statement-breakpoint
ALTER TABLE "property_details_epc" ADD COLUMN "sap_05_epc_rating" "epc";--> statement-breakpoint
ALTER TABLE "plan" ADD COLUMN "cost_of_works" real;--> statement-breakpoint
ALTER TABLE "plan" ADD COLUMN "contingency_cost" real;

View file

@ -0,0 +1 @@
CREATE INDEX "recommendation_property_id_idx" ON "recommendation" USING btree ("property_id");

View file

@ -0,0 +1 @@
CREATE INDEX "recommendation_materials_recommendation_id_idx" ON "recommendation_materials" USING btree ("recommendation_id");

View file

@ -0,0 +1 @@
CREATE UNIQUE INDEX "uq_property_portfolio_uprn" ON "property" USING btree ("portfolio_id","uprn") WHERE "property"."uprn" IS NOT NULL;

View file

@ -0,0 +1 @@
CREATE UNIQUE INDEX "uq_epc_store_uprn" ON "epc_store" USING btree ("uprn");

View file

@ -0,0 +1,2 @@
CREATE UNIQUE INDEX "uq_property_details_epc_property_portfolio" ON "property_details_epc" USING btree ("property_id","portfolio_id");--> statement-breakpoint
CREATE UNIQUE INDEX "uq_property_details_spatial_uprn" ON "property_details_spatial" USING btree ("uprn");

View file

@ -0,0 +1,2 @@
CREATE INDEX CONCURRENTLY "idx_plan_portfolio_scenario" ON "plan" USING btree ("portfolio_id","scenario_id");--> statement-breakpoint
CREATE INDEX CONCURRENTLY "idx_plan_recommendations_plan_id" ON "plan_recommendations" USING btree ("plan_id");

View file

@ -0,0 +1 @@
CREATE INDEX "idx_recommendation_active_defaults" ON "recommendation" USING btree ("id") WHERE "recommendation"."default" = true AND "recommendation"."already_installed" = false;

View file

@ -0,0 +1 @@
CREATE INDEX "idx_plan_latest_per_property" ON "plan" USING btree ("portfolio_id","scenario_id","property_id","created_at" DESC NULLS LAST);

View file

@ -0,0 +1,2 @@
CREATE INDEX CONCURRENTLY "idx_plan_recommendations_plan_rec" ON "plan_recommendations" USING btree ("plan_id","recommendation_id");--> statement-breakpoint
CREATE INDEX CONCURRENTLY "idx_recommendation_active_id_property" ON "recommendation" USING btree ("id","property_id") WHERE "recommendation"."default" = true AND "recommendation"."already_installed" = false;

View file

@ -0,0 +1,19 @@
CREATE TYPE "public"."measure_type" AS ENUM('air_source_heat_pump', 'boiler_upgrade', 'high_heat_retention_storage_heaters', 'secondary_heating', 'roomstat_programmer_trvs', 'time_temperature_zone_control', 'cylinder_thermostat', 'cavity_wall_insulation', 'extension_cavity_wall_insulation', 'external_wall_insulation', 'internal_wall_insulation', 'loft_insulation', 'flat_roof_insulation', 'room_roof_insulation', 'solid_floor_insulation', 'suspended_floor_insulation', 'double_glazing', 'secondary_glazing', 'draught_proofing', 'mechanical_ventilation', 'low_energy_lighting', 'solar_pv', 'hot_water_tank_insulation', 'sealing_open_fireplace');--> statement-breakpoint
CREATE TABLE "installed_measure" (
"id" bigserial PRIMARY KEY NOT NULL,
"uprn" text NOT NULL,
"measure_type" "measure_type" NOT NULL,
"installed_at" timestamp DEFAULT now(),
"sap_points" real,
"carbon_savings" real,
"kwh_savings" real,
"bill_savings" real,
"heat_demand_savings" real,
"source" text,
"is_active" boolean DEFAULT true NOT NULL
);
--> statement-breakpoint
CREATE INDEX "idx_installed_measure_uprn" ON "installed_measure" USING btree ("uprn");--> statement-breakpoint
CREATE INDEX "idx_installed_measure_uprn_active" ON "installed_measure" USING btree ("uprn") WHERE "installed_measure"."is_active" = true;--> statement-breakpoint
CREATE INDEX "idx_installed_measure_measure_type" ON "installed_measure" USING btree ("measure_type");--> statement-breakpoint
CREATE INDEX "idx_installed_measure_uprn_measure" ON "installed_measure" USING btree ("uprn","measure_type") WHERE "installed_measure"."is_active" = true;

View file

@ -0,0 +1,3 @@
ALTER TABLE "property" ADD COLUMN "installed_measures_sap_point_adjustment" real;--> statement-breakpoint
ALTER TABLE "property" ADD COLUMN "is_sap_points_adjusted_for_installed_measures" boolean DEFAULT false;--> statement-breakpoint
ALTER TABLE "property" ADD COLUMN "original_sap_points" real;

View file

@ -0,0 +1,2 @@
ALTER TABLE "property_installed_measures" DISABLE ROW LEVEL SECURITY;--> statement-breakpoint
DROP TABLE "property_installed_measures" CASCADE;--> statement-breakpoint

View file

@ -0,0 +1,9 @@
ALTER TABLE "property_details_epc" ADD COLUMN "original_co2_emissions" real;--> statement-breakpoint
ALTER TABLE "property_details_epc" ADD COLUMN "original_primary_energy_consumption" real;--> statement-breakpoint
ALTER TABLE "property_details_epc" ADD COLUMN "original_current_energy_demand" real;--> statement-breakpoint
ALTER TABLE "property_details_epc" ADD COLUMN "original_current_energy_demand_heating_hotwater" real;--> statement-breakpoint
ALTER TABLE "property_details_epc" ADD COLUMN "installed_measures_co2_adjustment" real;--> statement-breakpoint
ALTER TABLE "property_details_epc" ADD COLUMN "installed_measures_energy_demand_adjustment" real;--> statement-breakpoint
ALTER TABLE "property_details_epc" ADD COLUMN "installed_measures_total_energy_bill_adjustment" real;--> statement-breakpoint
ALTER TABLE "property_details_epc" ADD COLUMN "installed_measures_heat_demand_adjustment" real;--> statement-breakpoint
ALTER TABLE "property_details_epc" ADD COLUMN "is_epc_adjusted_for_installed_measures" boolean DEFAULT false;

View file

@ -0,0 +1,12 @@
-- migrations/2026_01_06_recommendation_cover.sql
CREATE INDEX CONCURRENTLY idx_recommendation_active_cover
ON recommendation (
id,
property_id,
measure_type,
type
)
INCLUDE (estimated_cost)
WHERE default = true
AND already_installed = false;

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -897,6 +897,153 @@
"when": 1763119819059, "when": 1763119819059,
"tag": "0127_acoustic_sleepwalker", "tag": "0127_acoustic_sleepwalker",
"breakpoints": true "breakpoints": true
},
{
"idx": 128,
"version": "7",
"when": 1764072359053,
"tag": "0128_thin_wiccan",
"breakpoints": true
},
{
"idx": 129,
"version": "7",
"when": 1764077820379,
"tag": "0129_workable_medusa",
"breakpoints": true
},
{
"idx": 130,
"version": "7",
"when": 1764077914945,
"tag": "0130_large_wong",
"breakpoints": true
},
{
"idx": 131,
"version": "7",
"when": 1764191284790,
"tag": "0131_minor_lucky_pierre",
"breakpoints": true
},
{
"idx": 132,
"version": "7",
"when": 1764401779805,
"tag": "0132_cynical_ikaris",
"breakpoints": true
},
{
"idx": 133,
"version": "7",
"when": 1764403077454,
"tag": "0133_calm_talkback",
"breakpoints": true
},
{
"idx": 134,
"version": "7",
"when": 1764886152458,
"tag": "0134_lying_lester",
"breakpoints": true
},
{
"idx": 135,
"version": "7",
"when": 1765214013546,
"tag": "0135_lovely_spectrum",
"breakpoints": true
},
{
"idx": 136,
"version": "7",
"when": 1765397663012,
"tag": "0136_boring_charles_xavier",
"breakpoints": true
},
{
"idx": 137,
"version": "7",
"when": 1765400667595,
"tag": "0137_shallow_speedball",
"breakpoints": true
},
{
"idx": 138,
"version": "7",
"when": 1766319125106,
"tag": "0138_neat_havok",
"breakpoints": true
},
{
"idx": 139,
"version": "7",
"when": 1766321608808,
"tag": "0139_skinny_legion",
"breakpoints": true
},
{
"idx": 140,
"version": "7",
"when": 1766389269465,
"tag": "0140_keen_dreaming_celestial",
"breakpoints": true
},
{
"idx": 141,
"version": "7",
"when": 1767619224711,
"tag": "0141_amazing_harry_osborn",
"breakpoints": true
},
{
"idx": 142,
"version": "7",
"when": 1767692283901,
"tag": "0142_serious_whirlwind",
"breakpoints": true
},
{
"idx": 143,
"version": "7",
"when": 1767693565375,
"tag": "0143_magenta_magus",
"breakpoints": true
},
{
"idx": 144,
"version": "7",
"when": 1767704869539,
"tag": "0144_lovely_moira_mactaggert",
"breakpoints": true
},
{
"idx": 145,
"version": "7",
"when": 1767810791075,
"tag": "0145_brave_power_pack",
"breakpoints": true
},
{
"idx": 146,
"version": "7",
"when": 1767812381964,
"tag": "0146_tiny_george_stacy",
"breakpoints": true
},
{
"idx": 147,
"version": "7",
"when": 1767814056667,
"tag": "0147_confused_killer_shrike",
"breakpoints": true
},
{
"idx": 148,
"version": "7",
"when": 1767823836420,
"tag": "0148_first_gamma_corps",
"breakpoints": true
} }
] ]
} }

49
src/app/db/schema/epc.ts Normal file
View file

@ -0,0 +1,49 @@
import {
pgTable,
serial,
text,
jsonb,
timestamp,
bigint,
uniqueIndex,
} from "drizzle-orm/pg-core";
// This table stores postcode search results from the OS Places API
// for re-use and caching purposes. The data is stored in jsonb format, to
// allow for fast queries and flexibility with the API response structure.
export interface OSPlacesHeader {
totalresults?: number;
offset?: number;
maxresults?: number;
[k: string]: any;
}
export interface EpcApiResponse {
header?: OSPlacesHeader;
rows?: Record<string, any>;
}
export const epcStore = pgTable(
"epc_store",
{
id: serial("id").primaryKey(),
uprn: bigint("uprn", { mode: "bigint" }),
// Timestamp for when the EPC API entry was first stored
epcApiCreatedAt: timestamp("epc_api_created_at"),
// EPC API response for the UPRN
epcApi: jsonb("epc_api").$type<EpcApiResponse>(),
// Timestamp for when the EPC page was stored
epcPageCreatedAt: timestamp("epc_page_created_at"),
// HTML content of the EPC page
epcPage: text("epc_page"),
// RRN of the EPC page
epcPageRrn: text("epc_page_rrn"),
},
(table) => [uniqueIndex("uq_epc_store_uprn").on(table.uprn)]
);

View file

@ -43,6 +43,7 @@ export const MaterialType: [string, ...string[]] = [
// Windows // Windows
"windows_glazing", "windows_glazing",
"secondary_glazing", "secondary_glazing",
"double_glazing",
// vents // vents
"trickle_vent", "trickle_vent",
"door_undercut", "door_undercut",
@ -54,6 +55,8 @@ export const MaterialType: [string, ...string[]] = [
// heating systems // heating systems
"high_heat_retention_storage_heaters", "high_heat_retention_storage_heaters",
"air_source_heat_pump", "air_source_heat_pump",
// Upgrade an exiting heating system
"boiler_upgrade",
// heating controls // heating controls
"roomstat_programmer_trvs", "roomstat_programmer_trvs",
"time_temperature_zone_control", "time_temperature_zone_control",

View file

@ -9,9 +9,12 @@ import {
boolean, boolean,
smallint, smallint,
bigint, bigint,
uniqueIndex,
} from "drizzle-orm/pg-core"; } from "drizzle-orm/pg-core";
import { portfolio, PortfolioStatus } from "./portfolio"; import { portfolio, PortfolioStatus } from "./portfolio";
import { InferModel } from "drizzle-orm"; import { InferModel } from "drizzle-orm";
import { materialTypeEnum } from "./materials";
import { sql } from "drizzle-orm";
// This is a placeholder for the property schema // This is a placeholder for the property schema
export interface PropertyMeta { export interface PropertyMeta {
@ -90,35 +93,63 @@ export const propertyCreationStatusEnum = pgEnum(
export const epcEnum = pgEnum("epc", Epc); export const epcEnum = pgEnum("epc", Epc);
export const propertyStatusEnum = pgEnum("status", PortfolioStatus); export const propertyStatusEnum = pgEnum("status", PortfolioStatus);
export const property = pgTable("property", { export const property = pgTable(
id: bigserial("id", { mode: "bigint" }).primaryKey(), "property",
portfolioId: bigint("portfolio_id", { mode: "bigint" }) {
.notNull() id: bigserial("id", { mode: "bigint" }).primaryKey(),
.references(() => portfolio.id),
creationStatus: propertyCreationStatusEnum("creation_status").notNull(), portfolioId: bigint("portfolio_id", { mode: "bigint" })
uprn: bigint("uprn", { mode: "bigint" }), .notNull()
landlordPropertyId: text("landlord_property_id"), // Optional ID used by landlords .references(() => portfolio.id),
buildingReferenceNumber: bigint("building_reference_number", {
mode: "bigint", creationStatus: propertyCreationStatusEnum("creation_status").notNull(),
}), uprn: bigint("uprn", { mode: "bigint" }),
status: propertyStatusEnum("status"),
address: text("address"), landlordPropertyId: text("landlord_property_id"),
postcode: text("postcode"), buildingReferenceNumber: bigint("building_reference_number", {
hasPreConditionReport: boolean("has_pre_condition_report"), mode: "bigint",
hasRecommendations: boolean("has_recommendations"), }),
createdAt: timestamp("created_at").notNull().defaultNow(),
updatedAt: timestamp("updated_at").notNull().defaultNow(), status: propertyStatusEnum("status"),
propertyType: text("property_type"), address: text("address"),
builtForm: text("built_form"), postcode: text("postcode"),
localAuthority: text("local_authority"),
constituency: text("constituency"), hasPreConditionReport: boolean("has_pre_condition_report"),
numberOfRooms: integer("number_of_rooms"), hasRecommendations: boolean("has_recommendations"),
yearBuilt: text("year_built"),
tenure: text("tenure"), createdAt: timestamp("created_at").notNull().defaultNow(),
currentEpcRating: epcEnum("current_epc_rating"), updatedAt: timestamp("updated_at").notNull().defaultNow(),
currentSapPoints: real("current_sap_points"),
currentValuation: real("current_valuation"), propertyType: text("property_type"),
}); builtForm: text("built_form"),
localAuthority: text("local_authority"),
constituency: text("constituency"),
numberOfRooms: integer("number_of_rooms"),
yearBuilt: text("year_built"),
tenure: text("tenure"),
currentEpcRating: epcEnum("current_epc_rating"),
currentSapPoints: real("current_sap_points"),
currentValuation: real("current_valuation"),
// When we have already installed measures, we will adjust the SAP points to reflect this. We keep a record of
// 1) The number of points we've adjusted by
// 2) a flag to indicate whether the SAP points have been adjusted, for easily filtering
installedMeasuresSapPointAdjustment: real(
"installed_measures_sap_point_adjustment"
),
isSapPointsAdjustedForInstalledMeasures: boolean(
"is_sap_points_adjusted_for_installed_measures"
).default(false),
originalSapPoints: real("original_sap_points"),
},
(table) => [
uniqueIndex("uq_property_portfolio_uprn")
.on(table.portfolioId, table.uprn)
.where(sql`${table.uprn} IS NOT NULL`),
]
);
export const FeatureRating: [string, ...string[]] = [ export const FeatureRating: [string, ...string[]] = [
"Very good", "Very good",
@ -130,74 +161,128 @@ export const FeatureRating: [string, ...string[]] = [
export const FeatureRatingNumeric: [number, ...number[]] = [5, 4, 3, 2, 1]; export const FeatureRatingNumeric: [number, ...number[]] = [5, 4, 3, 2, 1];
export const propertyDetailsEpc = pgTable("property_details_epc", { export const propertyDetailsEpc = pgTable(
id: bigserial("id", { mode: "bigint" }).primaryKey(), "property_details_epc",
propertyId: bigint("property_id", { mode: "bigint" }) {
.notNull() id: bigserial("id", { mode: "bigint" }).primaryKey(),
.references(() => property.id), propertyId: bigint("property_id", { mode: "bigint" })
portfolioId: bigint("portfolio_id", { mode: "bigint" }) .notNull()
.notNull() .references(() => property.id),
.references(() => portfolio.id), portfolioId: bigint("portfolio_id", { mode: "bigint" })
fullAddress: text("full_address"), .notNull()
totalFloorArea: real("total_floor_area"), .references(() => portfolio.id),
walls: text("walls"), fullAddress: text("full_address"),
wallsRating: smallint("walls_rating"), // Date the EPC was lodged
roof: text("roof"), lodgementDate: timestamp("lodgement_date"),
roofRating: smallint("roof_rating"), isExpired: boolean("is_expired"),
floor: text("floor"), totalFloorArea: real("total_floor_area"),
floorRating: smallint("floor_rating"), walls: text("walls"),
windows: text("windows"), wallsRating: smallint("walls_rating"),
windowsRating: smallint("windows_rating"), roof: text("roof"),
heating: text("heating"), roofRating: smallint("roof_rating"),
heatingRating: smallint("heating_rating"), floor: text("floor"),
heatingControls: text("heating_controls"), floorRating: smallint("floor_rating"),
heatingControlsRating: smallint("heating_controls_rating"), windows: text("windows"),
hotWater: text("hot_water"), windowsRating: smallint("windows_rating"),
hotWaterRating: smallint("hot_water_rating"), heating: text("heating"),
lighting: text("lighting"), heatingRating: smallint("heating_rating"),
lightingRating: smallint("lighting_rating"), heatingControls: text("heating_controls"),
mainfuel: text("mainfuel"), heatingControlsRating: smallint("heating_controls_rating"),
ventilation: text("ventilation"), hotWater: text("hot_water"),
solarPv: real("solar_pv"), hotWaterRating: smallint("hot_water_rating"),
solarHotWater: boolean("solar_hot_water"), lighting: text("lighting"),
windTurbine: smallint("wind_turbine"), lightingRating: smallint("lighting_rating"),
floorHeight: real("floor_height"), mainfuel: text("mainfuel"),
numberHeatedRooms: integer("number_heated_rooms"), ventilation: text("ventilation"),
heatLossCorridor: boolean("heat_loss_corridor"), solarPv: real("solar_pv"),
unheatedCorridorLength: real("unheated_corridor_length"), solarHotWater: boolean("solar_hot_water"),
numberOpenFireplaces: integer("number_of_open_fireplaces"), windTurbine: smallint("wind_turbine"),
numberExtensions: integer("number_of_extensions"), floorHeight: real("floor_height"),
numberStoreys: integer("number_of_storeys"), numberHeatedRooms: integer("number_heated_rooms"),
mainsGas: boolean("mains_gas"), heatLossCorridor: boolean("heat_loss_corridor"),
energyTariff: text("energy_tariff"), unheatedCorridorLength: real("unheated_corridor_length"),
primaryEnergyConsumption: real("primary_energy_consumption"), numberOpenFireplaces: integer("number_of_open_fireplaces"),
co2Emissions: real("co2_emissions"), numberExtensions: integer("number_of_extensions"),
currentEnergyDemand: real("current_energy_demand"), numberStoreys: integer("number_of_storeys"),
currentEnergyDemandHeatingHotwater: real( mainsGas: boolean("mains_gas"),
"current_energy_demand_heating_hotwater" energyTariff: text("energy_tariff"),
), // This is heat demand
estimated: boolean("estimated").default(false), primaryEnergyConsumption: real("primary_energy_consumption"),
// Include current estimates for energy bills, across the different types of energy co2Emissions: real("co2_emissions"),
// These predictions are based on the EPC predicted consumptions + current energy prices // Bad naming but currentEnergyDemand is the current kwh consumption - needs to be renamed
heatingEnergyCostCurrent: real("heating_cost_current"), currentEnergyDemand: real("current_energy_demand"),
hotWaterEnergyCostCurrent: real("hot_water_cost_current"), currentEnergyDemandHeatingHotwater: real(
lightingEnergyCostCurrent: real("lighting_cost_current"), "current_energy_demand_heating_hotwater"
appliancesEnergyCostCurrent: real("appliances_cost_current"), ),
gasStandingCharge: real("gas_standing_charge"), estimated: boolean("estimated").default(false),
electricityStandingCharge: real("electricity_standing_charge"), // We indicate if the property has an overwritten SAP 05 EPC. I.e. there is a valid EPC, however it's a SAP 05
}); // EPC which isn't particularly useful. This value is defaulted to False
sap05Overwritten: boolean("sap_05_overwritten").default(false),
// When we've overwritten a SAP 05 EPC, we store the SAP 05 score and rating here for reference
sap05Score: real("sap_05_score"),
sap05EpcRating: epcEnum("sap_05_epc_rating"),
// Include current estimates for energy bills, across the different types of energy
// These predictions are based on the EPC predicted consumptions + current energy prices
heatingEnergyCostCurrent: real("heating_cost_current"),
hotWaterEnergyCostCurrent: real("hot_water_cost_current"),
lightingEnergyCostCurrent: real("lighting_cost_current"),
appliancesEnergyCostCurrent: real("appliances_cost_current"),
gasStandingCharge: real("gas_standing_charge"),
electricityStandingCharge: real("electricity_standing_charge"),
export const propertyDetailsSpatial = pgTable("property_details_spatial", { // When we have already installed measures, we will adjust the carbon, bills, kwh, heat demandto reflect this. We keep a record of
id: bigserial("id", { mode: "bigint" }).primaryKey(), // 1) The adjustments
uprn: bigint("uprn", { mode: "bigint" }), // 2) original values
xCoordinate: real("x_coordinate"), // 3) a flag to indicate whether the values have been adjusted, for easily filtering
yCoordinate: real("y_coordinate"),
latitude: real("latitude"), // original values - we don't need bills because we don't actually adjust any of the originals we just subtract adjustments from current values
longitude: real("longitude"), originalCo2Emissions: real("original_co2_emissions"),
conservationStatus: boolean("conservation_status"), originalPrimaryEnergyConsumption: real(
isListedBuilding: boolean("is_listed_building"), "original_primary_energy_consumption"
isHeritageBuilding: boolean("is_heritage_building"), ),
}); originalCurrentEnergyDemand: real("original_current_energy_demand"),
originalCurrentEnergyDemandHeatingHotwater: real(
"original_current_energy_demand_heating_hotwater"
),
// adjustment quantities
installedMeasuresCo2Adjustment: real("installed_measures_co2_adjustment"),
installedMeasuresEnergyDemandAdjustment: real(
"installed_measures_energy_demand_adjustment"
),
installedMeasuresTotalEnergyBillAdjustment: real(
"installed_measures_total_energy_bill_adjustment"
),
installedMeasuresHeatDemandAdjustment: real(
"installed_measures_heat_demand_adjustment"
),
isEpcAdjustedForInstalledMeasures: boolean(
"is_epc_adjusted_for_installed_measures"
).default(false),
},
(table) => [
uniqueIndex("uq_property_details_epc_property_portfolio").on(
table.propertyId,
table.portfolioId
),
]
);
export const propertyDetailsSpatial = pgTable(
"property_details_spatial",
{
id: bigserial("id", { mode: "bigint" }).primaryKey(),
uprn: bigint("uprn", { mode: "bigint" }),
xCoordinate: real("x_coordinate"),
yCoordinate: real("y_coordinate"),
latitude: real("latitude"),
longitude: real("longitude"),
conservationStatus: boolean("conservation_status"),
isListedBuilding: boolean("is_listed_building"),
isHeritageBuilding: boolean("is_heritage_building"),
},
(table) => [uniqueIndex("uq_property_details_spatial_uprn").on(table.uprn)]
);
export const propertyDetailsMeter = pgTable("property_details_meter", { export const propertyDetailsMeter = pgTable("property_details_meter", {
id: bigserial("id", { mode: "bigint" }).primaryKey(), id: bigserial("id", { mode: "bigint" }).primaryKey(),

View file

@ -1,4 +1,4 @@
import { property } from "./property"; import { property, epcEnum } from "./property";
import { goalEnum, portfolio } from "./portfolio"; import { goalEnum, portfolio } from "./portfolio";
import { import {
bigserial, bigserial,
@ -10,59 +10,129 @@ import {
bigint, bigint,
pgEnum, pgEnum,
integer, integer,
index,
} from "drizzle-orm/pg-core"; } from "drizzle-orm/pg-core";
import { Material, material } from "./materials"; import { Material, material } from "./materials";
import { InferModel } from "drizzle-orm"; import { InferModel, sql } from "drizzle-orm";
import { z } from "zod"; import { z } from "zod";
export const recommendation = pgTable("recommendation", { // For recommendations, measure types was initially defined as a string but we should convert this to an enum in the future
id: bigserial("id", { mode: "bigint" }).primaryKey(),
propertyId: bigint("property_id", { mode: "bigint" }) export const measureTypeEnum = pgEnum("measure_type", [
.notNull() // Heating systems
.references(() => property.id), "air_source_heat_pump",
createdAt: timestamp("created_at").notNull().defaultNow(), "boiler_upgrade",
type: text("type").notNull(), "high_heat_retention_storage_heaters",
measureType: text("measure_type"), "secondary_heating",
description: text("description").notNull(),
estimatedCost: real("estimated_cost"), // Heating controls
constingencyCost: real("contingency_cost"), "roomstat_programmer_trvs",
// default will indicate whether a mtaterial is currently being used in a recommendation and we will use this boolean to switch "time_temperature_zone_control",
// between materials in the UI and switch off all materials entirely "cylinder_thermostat",
default: boolean("default").notNull(),
startingUValue: real("starting_u_value"), // Insulation
newUValue: real("new_u_value"), "cavity_wall_insulation",
sapPoints: real("sap_points"), "extension_cavity_wall_insulation",
heatDemand: real("heat_demand"), "external_wall_insulation",
kwhSavings: real("kwh_savings"), "internal_wall_insulation",
co2EquivalentSavings: real("co2_equivalent_savings"), "loft_insulation",
energySavings: real("energy_savings"), "flat_roof_insulation",
energyCostSavings: real("energy_cost_savings"), "room_roof_insulation",
propertyValuationIncrease: real("property_valuation_increase"), "solid_floor_insulation",
rentalYieldIncrease: real("rental_yield_increase"), "suspended_floor_insulation",
totalWorkHours: real("total_work_hours"),
labourDays: real("labour_days"), // Windows & doors
alreadyInstalled: boolean("already_installed").default(false), "double_glazing",
}); "secondary_glazing",
"draught_proofing",
// Ventilation
"mechanical_ventilation",
// Lighting
"low_energy_lighting",
// Renewables
"solar_pv",
// Other fabric / hot water
"hot_water_tank_insulation",
"sealing_open_fireplace",
]);
export const recommendation = pgTable(
"recommendation",
{
id: bigserial("id", { mode: "bigint" }).primaryKey(),
propertyId: bigint("property_id", { mode: "bigint" })
.notNull()
.references(() => property.id),
createdAt: timestamp("created_at").notNull().defaultNow(),
type: text("type").notNull(),
measureType: text("measure_type"),
description: text("description").notNull(),
estimatedCost: real("estimated_cost"),
constingencyCost: real("contingency_cost"),
// default will indicate whether a mtaterial is currently being used in a recommendation and we will use this boolean to switch
// between materials in the UI and switch off all materials entirely
default: boolean("default").notNull(),
startingUValue: real("starting_u_value"),
newUValue: real("new_u_value"),
sapPoints: real("sap_points"),
heatDemand: real("heat_demand"),
kwhSavings: real("kwh_savings"),
co2EquivalentSavings: real("co2_equivalent_savings"),
energySavings: real("energy_savings"),
energyCostSavings: real("energy_cost_savings"),
propertyValuationIncrease: real("property_valuation_increase"),
rentalYieldIncrease: real("rental_yield_increase"),
totalWorkHours: real("total_work_hours"),
labourDays: real("labour_days"),
alreadyInstalled: boolean("already_installed").default(false),
},
(table) => [
index("recommendation_property_id_idx").on(table.propertyId),
index("idx_recommendation_active_defaults")
.on(table.id)
.where(
sql`${table.default} = true AND ${table.alreadyInstalled} = false`
),
index("idx_recommendation_active_id_property")
.on(table.id, table.propertyId)
.where(
sql`${table.default} = true AND ${table.alreadyInstalled} = false`
),
]
);
export const unitQuantity: [string, ...string[]] = ["m2", "part", "kwp"]; export const unitQuantity: [string, ...string[]] = ["m2", "part", "kwp"];
export const unitQuantityEnum = pgEnum("unit_quantity", unitQuantity); export const unitQuantityEnum = pgEnum("unit_quantity", unitQuantity);
export const recommendationMaterials = pgTable("recommendation_materials", { export const recommendationMaterials = pgTable(
id: bigserial("id", { mode: "bigint" }).primaryKey(), "recommendation_materials",
recommendationId: bigint("recommendation_id", { {
mode: "bigint", id: bigserial("id", { mode: "bigint" }).primaryKey(),
}) recommendationId: bigint("recommendation_id", {
.notNull() mode: "bigint",
.references(() => recommendation.id), })
materialId: bigint("material_id", { mode: "bigint" }) .notNull()
.notNull() .references(() => recommendation.id),
.references(() => material.id), materialId: bigint("material_id", { mode: "bigint" })
createdAt: timestamp("created_at").notNull().defaultNow(), .notNull()
depth: real("depth"), .references(() => material.id),
quantity: real("quantity"), createdAt: timestamp("created_at").notNull().defaultNow(),
quantityUnit: unitQuantityEnum("quantity_unit"), depth: real("depth"),
estimatedCost: real("estimated_cost").notNull(), quantity: real("quantity"),
}); quantityUnit: unitQuantityEnum("quantity_unit"),
estimatedCost: real("estimated_cost").notNull(),
},
(table) => [
index("recommendation_materials_recommendation_id_idx").on(
table.recommendationId
),
]
);
// We create a plan type, for common plan types that we produce for clients // We create a plan type, for common plan types that we produce for clients
export const PlanType: [string, ...string[]] = [ export const PlanType: [string, ...string[]] = [
@ -80,37 +150,108 @@ export type PlanTypeEnum =
| "extraction_eco"; | "extraction_eco";
export const planTypeEnum = pgEnum("plan_type", PlanType); export const planTypeEnum = pgEnum("plan_type", PlanType);
export const plan = pgTable("plan", { export const plan = pgTable(
id: bigserial("id", { mode: "bigint" }).primaryKey(), "plan",
name: text("name"), {
portfolioId: bigint("portfolio_id", { mode: "bigint" }) id: bigserial("id", { mode: "bigint" }).primaryKey(),
.notNull() name: text("name"),
.references(() => portfolio.id),
propertyId: bigint("property_id", { mode: "bigint" })
.notNull()
.references(() => property.id),
scenarioId: bigint("scenario_id", { mode: "bigint" }).references(
() => scenario.id
),
createdAt: timestamp("created_at").notNull().defaultNow(),
isDefault: boolean("is_default").notNull(),
valuationIncreaseLowerBound: real("valuation_increase_lower_bound"),
valuationIncreaseUpperBound: real("valuation_increase_upper_bound"),
valuationIncreaseAverage: real("valuation_increase_average"),
planType: planTypeEnum("plan_type"), // This may be null for custom plans, outside of our common plan types
});
export const planRecommendations = pgTable("plan_recommendations", { portfolioId: bigint("portfolio_id", { mode: "bigint" })
id: bigserial("id", { mode: "bigint" }).primaryKey(), .notNull()
planId: bigint("plan_id", { mode: "bigint" }) .references(() => portfolio.id),
.notNull()
.references(() => plan.id), propertyId: bigint("property_id", { mode: "bigint" })
recommendationId: bigint("recommendation_id", { .notNull()
mode: "bigint", .references(() => property.id),
})
.notNull() scenarioId: bigint("scenario_id", { mode: "bigint" }).references(
.references(() => recommendation.id), () => scenario.id
}); ),
createdAt: timestamp("created_at").notNull().defaultNow(),
isDefault: boolean("is_default").notNull(),
// ─────────────────────────────────────────────────────────
// Valuation metrics (existing)
// ─────────────────────────────────────────────────────────
valuationIncreaseLowerBound: real("valuation_increase_lower_bound"),
valuationIncreaseUpperBound: real("valuation_increase_upper_bound"),
valuationIncreaseAverage: real("valuation_increase_average"),
// ─────────────────────────────────────────────────────────
// NEW — SAP / EPC
// ─────────────────────────────────────────────────────────
postSapPoints: real("post_sap_points"),
postEpcRating: epcEnum("post_epc_rating"),
// ─────────────────────────────────────────────────────────
// NEW — Carbon emissions (tonnes CO₂e/yr)
// ─────────────────────────────────────────────────────────
postCo2Emissions: real("post_co2_emissions"),
co2Savings: real("co2_savings"), // baseline - post
// ─────────────────────────────────────────────────────────
// NEW — Energy bills
// ─────────────────────────────────────────────────────────
postEnergyBill: real("post_energy_bill"),
energyBillSavings: real("energy_bill_savings"),
// ─────────────────────────────────────────────────────────
// NEW — Energy consumption (kWh/year)
// ─────────────────────────────────────────────────────────
postEnergyConsumption: real("post_energy_consumption"),
energyConsumptionSavings: real("energy_consumption_savings"),
// ─────────────────────────────────────────────────────────
// NEW — Valuation
// ─────────────────────────────────────────────────────────
valuationPostRetrofit: real("valuation_post_retrofit"),
valuationIncrease: real("valuation_increase"),
// Plan costing data
costOfWorks: real("cost_of_works"),
contingencyCost: real("contingency_cost"),
// ─────────────────────────────────────────────────────────
// Plan type stays as-is
// ─────────────────────────────────────────────────────────
planType: planTypeEnum("plan_type"),
},
(table) => [
index("idx_plan_portfolio_scenario").on(
table.portfolioId,
table.scenarioId
),
index("idx_plan_latest_per_property").on(
table.portfolioId,
table.scenarioId,
table.propertyId,
table.createdAt.desc()
),
]
);
export const planRecommendations = pgTable(
"plan_recommendations",
{
id: bigserial("id", { mode: "bigint" }).primaryKey(),
planId: bigint("plan_id", { mode: "bigint" })
.notNull()
.references(() => plan.id),
recommendationId: bigint("recommendation_id", {
mode: "bigint",
})
.notNull()
.references(() => recommendation.id),
},
(table) => [
index("idx_plan_recommendations_plan_id").on(table.planId),
index("idx_plan_recommendations_plan_rec").on(
table.planId,
table.recommendationId
),
]
);
export const HousingType: [string, ...string[]] = ["Private", "Social"]; export const HousingType: [string, ...string[]] = ["Private", "Social"];
@ -169,6 +310,44 @@ export const scenario = pgTable("scenario", {
valuationReturnOnInvestment: text("valuation_return_on_investment"), valuationReturnOnInvestment: text("valuation_return_on_investment"),
}); });
export const installedMeasure = pgTable(
"installed_measure",
{
id: bigserial("id", { mode: "bigint" }).primaryKey(),
uprn: bigint("uprn", { mode: "bigint" }).notNull(),
measureType: measureTypeEnum("measure_type").notNull(),
installedAt: timestamp("installed_at").defaultNow(),
// Impacts
sapPoints: real("sap_points"),
carbonSavings: real("carbon_savings"), // tonnes CO₂e / yr
kwhSavings: real("kwh_savings"), // kWh / yr
billSavings: real("bill_savings"), // £ / yr
heatDemandSavings: real("heat_demand_savings"),
//
source: text("source"), // e.g. "EPC", "Survey", "Installer"
// Soft delete / supersession
isActive: boolean("is_active").notNull().default(true),
},
(table) => [
index("idx_installed_measure_uprn").on(table.uprn),
index("idx_installed_measure_uprn_active")
.on(table.uprn)
.where(sql`${table.isActive} = true`),
index("idx_installed_measure_measure_type").on(table.measureType),
index("idx_installed_measure_uprn_measure")
.on(table.uprn, table.measureType)
.where(sql`${table.isActive} = true`),
]
);
export type Plan = InferModel<typeof plan, "select">; export type Plan = InferModel<typeof plan, "select">;
export type Recommendation = InferModel<typeof recommendation, "select">; export type Recommendation = InferModel<typeof recommendation, "select">;
export type PlanRecommendations = InferModel< export type PlanRecommendations = InferModel<

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

After

Width:  |  Height:  |  Size: 2 KiB

View file

@ -118,3 +118,112 @@
.animate-spin { .animate-spin {
animation: spin 1s linear infinite; 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;
}
}
@keyframes loading {
0% { transform: translateX(-100%); }
100% { transform: translateX(300%); }
}

View file

@ -1,10 +1,135 @@
const HelpPage = () => { "use client";
import { useState } from "react";
const FAQItem = ({
question,
answer,
}: {
question: string;
answer: string;
}) => {
const [open, setOpen] = useState(false);
return ( return (
<div> <div className="border-b border-zinc-300 py-4">
<h1>Help Page</h1> <button
<p> All the help you could ever need </p> onClick={() => setOpen(!open)}
className="w-full flex justify-between items-center text-left"
>
<span className="font-medium text-lg">{question}</span>
<span className="text-zinc-500">{open ? "" : "+"}</span>
</button>
<div
className={`transition-all overflow-hidden ${
open ? "max-h-[500px] mt-2" : "max-h-0"
}`}
>
<p className="text-zinc-600 whitespace-pre-line">
{answer}
</p>
</div>
</div> </div>
); );
}; };
const HelpPage = () => {
const faqs = [
{
question: "Is Ara the same as an on-site PAS 2035 assessment?",
answer: `No. Ara is a remote pre-assessment designed to give fast, indicative recommendations.
A PAS 2035 assessment requires an onsite inspection and provides guaranteed upgrade pathways, full Retrofit Assessment reports, and validated specifications.`,
},
{
question: "How do I add a property to the app?",
answer:
"Click “New Property” at the top of your portfolio screen, enter the property address, and the system will generate your report automatically.",
},
{
question: "Ive added my property, but I cant see the results what should I do?",
answer: `Try refreshing the page or logging out and back in.
If the issue continues, contact us and we can add the property manually for you.`,
},
{
question: "How do I view my property results?",
answer: `To view your results, simply click on the property address in your portfolio.
This will open the full summary, including:
Current EPC position
Property data and assumptions
Estimated costs
Retrofit plan`,
},
{
question: "What do the recommendations mean?",
answer: `The system suggests the quickest and most cost-effective measures to reach EPC C based on your propertys current data.
These are estimates only exact requirements can only be confirmed through a full PAS 2035 on-site assessment.`,
},
{
question: "Can I change the recommended measures in Ara?",
answer: `Yes — you can adjust the recommended measures within the app to explore different upgrade options.
When you change a measure (for example, swapping insulation for solar PV), the app will automatically update:
Energy-efficiency impact
SAP point gains
Estimated costs
This allows you to compare different routes to EPC C.`,
},
{
question: "Will Ara tell me if I can get funding?",
answer: `Ara provides indicative funding guidance. However, funding eligibility depends on current scheme criteria and availability, which change regularly. A full eligibility check is included as part of a PAS 2035 assessment.`,
},
{
question: "Ive completed the remote assessment. Whats next?",
answer: `If you want to proceed with improvements, the next step is a PAS 2035 on-site Retrofit Assessment, which includes:
Property fabric inspection
Condition survey
RdSAP
Ventilation & occupancy assessment
A guaranteed improvement plan`,
},
{
question: "What if I cant get the app to work?",
answer: `If you have any technical issues, email us at enquiries@domna.homes and we can:
Add the property for you
Send the results manually
Arrange a call with a team member`,
},
];
return (
<div className="max-w-2xl mx-auto mt-12 px-4">
<h1 className="text-3xl font-bold mb-6">Help & FAQ</h1>
<p className="text-zinc-600 mb-8">
Answers to common questions below.
</p>
{faqs.map((f, i) => (
<FAQItem key={i} question={f.question} answer={f.answer} />
))}
<div className="py-4">
<h2 className="text-xl font-semibold mb-2">Still need help?</h2>
<p className="text-zinc-600 mb-4">
If you cant find the answer youre looking for, our team is happy to help.
</p>
<a
href="mailto:enquiries@domna.homes"
className="text-blue-600 font-medium hover:underline"
>
Contact us at enquiries@domna.homes
</a>
</div>
</div>
);
};
export default HelpPage; export default HelpPage;

View file

@ -16,8 +16,8 @@ const inter = Inter({
}); });
export const metadata = { export const metadata = {
title: "", title: "Ara",
description: "Start your retrofit journey today", description: "Ara is Domnas portfolio intelligence platform that turns housing stock data into clear, costed retrofit and investment plans.",
}; };
const getSession = cache(async () => { const getSession = cache(async () => {

View file

@ -1,7 +1,6 @@
import { getServerSession } from "next-auth/next"; import { getServerSession } from "next-auth/next";
import { AuthOptions } from "./api/auth/[...nextauth]/authOptions"; import { AuthOptions } from "./api/auth/[...nextauth]/authOptions";
import GoogleSignInButton from "./components/signin/GoogleSignInButton"; import GoogleSignInButton from "./components/signin/GoogleSignInButton";
import MicrosoftSignInButton from "./components/signin/MicrosoftSignInButton";
import EmailSignInButton from "./components/signin/EmailSignInButton"; import EmailSignInButton from "./components/signin/EmailSignInButton";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import Image from "next/image"; import Image from "next/image";
@ -40,10 +39,10 @@ export default async function Home(props: {
width={300} width={300}
/> />
<h1 className="text-4xl font-medium text-brandblue mb-8 text-center"> <h1 className="text-4xl font-medium text-brandblue mb-8 text-center">
Sign in to your account Your portfolios, managed easily.
</h1> </h1>
<div className="text-brandmidblue text-lg mb-4"> <div className="text-brandmidblue text-lg mb-4">
Start managing your portfolios Well email you a login link no password required.
</div> </div>
<div className="mb-2 min-w-[19rem]"> <div className="mb-2 min-w-[19rem]">
@ -51,10 +50,7 @@ export default async function Home(props: {
<EmailSignInButton error={error} /> <EmailSignInButton error={error} />
</div> </div>
<div className="text-md"> Sign in with a Social Account</div> <div className="text-md"> Sign in with a Social Account</div>
<div className="mb-2"> <GoogleSignInButton />
<MicrosoftSignInButton />
</div>
<GoogleSignInButton />
</div> </div>
</section> </section>
</div> </div>

View file

@ -5,7 +5,7 @@ import * as React from "react";
export default async function PortfolioLayout(props: { export default async function PortfolioLayout(props: {
children: React.ReactNode; children: React.ReactNode;
params: Promise<{ slug: string; propertyId: string }>; params: Promise<{ slug: string }>;
}) { }) {
const params = await props.params; const params = await props.params;

View file

@ -1,27 +1,14 @@
import { HomeIcon } from "@heroicons/react/24/outline";
import { getPortfolio, getPortfolioPerformance, getProperties } from "../utils"; import { getPortfolio, getPortfolioPerformance, getProperties } from "../utils";
import DataTable from "@/app/portfolio/[slug]/components/propertyTable"; import DataTable from "@/app/portfolio/[slug]/components/dataTable";
import { columns } from "@/app/portfolio/[slug]/components/propertyTableColumns";
import { PropertyWithRelations } from "@/app/db/schema/property"; import { PropertyWithRelations } from "@/app/db/schema/property";
import PropertyTable from "../components/PropertyTable";
import SummaryBox from "@/app/components/portfolio/SummaryBox"; import SummaryBox from "@/app/components/portfolio/SummaryBox";
import { Component } from "lucide-react";
// We enfore caching of data for 60 seconds // We enfore caching of data for 60 seconds
export const revalidate = 60; export const revalidate = 60;
function EmptyPropertyState() {
return (
<div className="flex justify-center h-1/2">
<div className="bg-white rounded-lg w-full">
<p className="text-center text-gray-400 pt-6">
Hover over &quot;New Property&quot; to start adding properties to your
Portfolio
<HomeIcon className="h-20 w-20 mx-auto mt-4 text-gray-200" />
</p>
</div>
</div>
);
}
export default async function Page(props: { export default async function Page(props: {
params: Promise<{ slug: string }>; params: Promise<{ slug: string }>;
searchParams: Promise<{ searchParams: Promise<{
@ -71,31 +58,9 @@ export default async function Page(props: {
]; ];
} }
const properties: PropertyWithRelations[] = await getProperties(
portfolioId,
1000,
0
);
return ( return (
<> <>
<div className="flex justify-center"> <PropertyTable portfolioId={portfolioId}/>
<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">
{properties.length === 0 ? (
<EmptyPropertyState />
) : (
<DataTable data={properties} columns={columns} />
)}
</div>
</div>
</div>
</> </>
); );
} }

View file

@ -0,0 +1,140 @@
"use client";
import { useState, useMemo } from "react";
import {
Card,
CardHeader,
CardTitle,
CardContent,
} from "@/app/shadcn_components/ui/card";
import { BarChart } from "@tremor/react";
import type { CustomTooltipProps } from "@tremor/react";
import {
Select,
SelectTrigger,
SelectContent,
SelectItem,
SelectValue,
} from "@/app/shadcn_components/ui/select";
import type { EpcBandCount, AgeBandCount, PropertyTypeCount } from "./types";
export function BreakdownChart({
epcBands,
ageBands,
propertyTypes,
scenarioEpcBands,
}: {
epcBands: EpcBandCount[];
ageBands: AgeBandCount[];
propertyTypes: PropertyTypeCount[];
scenarioEpcBands?: Record<string, number>;
}) {
const [selected, setSelected] = useState("epc");
const friendlyKeys = {
actual: "Actual EPCs",
estimated: "Estimated EPCs",
scenario: "Scenario result",
};
const chartData = useMemo(() => {
if (selected !== "epc") {
return selected === "age"
? ageBands.map((d) => ({ label: d.age_band, Count: d.count }))
: propertyTypes.map((d) => ({
label: d.type ?? "Unknown",
Count: d.count,
}));
}
const rows: any[] = [];
for (const d of epcBands) {
const epc = d.epc ?? "Unknown";
const scenarioValue = scenarioEpcBands?.[epc] ?? 0;
// Baseline (stacked)
rows.push({
label: `${epc}`,
[friendlyKeys.actual]: d.actual ?? 0,
[friendlyKeys.estimated]: d.estimated ?? 0,
[friendlyKeys.scenario]: 0,
});
// Scenario (single bar)
rows.push({
label: `${epc} (scenario)`,
[friendlyKeys.actual]: 0,
[friendlyKeys.estimated]: 0,
[friendlyKeys.scenario]: scenarioValue,
});
}
return rows;
}, [selected, epcBands, ageBands, propertyTypes, scenarioEpcBands]);
const categories =
selected === "epc"
? [friendlyKeys.actual, friendlyKeys.estimated, friendlyKeys.scenario]
: ["Count"];
const colors =
selected === "epc" ? ["#14163d", "#3943b7", "emerald"] : ["#2d348f"];
return (
<Card className="border border-gray-100 bg-white">
<CardHeader className="flex flex-col space-y-1 items-start">
<div className="flex w-full justify-between items-center">
<CardTitle className="text-md text-brandblue">
Property Breakdown
</CardTitle>
<Select value={selected} onValueChange={setSelected}>
<SelectTrigger className="w-40">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="epc">EPC Band</SelectItem>
<SelectItem value="age">Age Band</SelectItem>
<SelectItem value="type">Property Type</SelectItem>
</SelectContent>
</Select>
</div>
</CardHeader>
<CardContent>
<BarChart
data={chartData}
index="label"
categories={categories}
colors={colors}
valueFormatter={(v) => v.toString()}
stack={selected === "epc"}
customTooltip={MyTooltip}
className="h-64"
showGridLines={false}
/>
</CardContent>
</Card>
);
}
function MyTooltip({ payload }: CustomTooltipProps) {
if (!payload || payload.length === 0) return null;
return (
<div className="rounded-md bg-white shadow-lg border border-gray-200 px-3 py-2 text-xs text-gray-700">
{payload.map((p) => (
<div
key={p.dataKey}
className="flex justify-between gap-4 items-center"
>
<span>{p.dataKey}:</span>
<span className="font-medium">{p.value}</span>
</div>
))}
</div>
);
}

View file

@ -0,0 +1,256 @@
"use client";
import {
Card,
CardHeader,
CardTitle,
CardContent,
CardFooter,
} from "@/app/shadcn_components/ui/card";
import { motion } from "framer-motion";
import { Home, Zap, Leaf, LineChart, FileQuestionIcon } from "lucide-react";
import { formatNumber } from "@/app/utils";
import type {
AverageMetrics,
EstimatedCounts,
TotalMetrics,
ScenarioOverlayMetrics,
} from "./types";
import type { MetricKey } from "./types";
import { sapToEpc } from "@/app/utils";
const cardStyles = {
totalHomes: { icon: Home, color: "text-purple-600" },
avgSap: { icon: LineChart, color: "text-blue-600" },
avgCarbon: { icon: Leaf, color: "text-emerald-600" },
avgBills: { icon: Zap, color: "text-amber-600" },
missingEpc: { icon: FileQuestionIcon, color: "text-red-600" },
} as Record<MetricKey, { icon: React.ComponentType<any>; color: string }>;
const epcColors: Record<string, string> = {
A: "text-epc_a",
B: "text-epc_b",
C: "text-epc_c",
D: "text-epc_d",
E: "text-epc_e",
F: "text-epc_f",
G: "text-epc_g",
Unknown: "text-gray-400",
};
function hasOverlay(
overlay: ScenarioOverlayMetrics | undefined
): overlay is ScenarioOverlayMetrics {
return overlay !== undefined;
}
export function DashboardSummaryCards({
total,
totals,
averages,
estimatedCounts,
scenarioOverlay,
}: {
total: number;
totals: TotalMetrics;
averages: AverageMetrics;
estimatedCounts: EstimatedCounts;
scenarioOverlay?: ScenarioOverlayMetrics | null;
}) {
const missingEpcCount = estimatedCounts.estimated;
const missingEpcPercent = total > 0 ? (missingEpcCount / total) * 100 : 0;
const averageCurrentEpc = sapToEpc(averages.avg_sap || 0);
const overlay = scenarioOverlay ?? undefined;
const hasScenario = hasOverlay(overlay);
function deltaLabel(baseline: number, scenario: number) {
const b = Number(baseline);
const s = Number(scenario);
const diff = s - b;
if (!isFinite(diff) || diff === 0) return null;
const sign = diff > 0 ? "▲" : "▼";
const color = diff > 0 ? "text-red-600" : "text-emerald-600";
return (
<span className={`text-sm font-medium ${color}`}>
{sign} {Math.abs(diff).toFixed(2)}
</span>
);
}
const cards = [
{
key: "totalHomes",
title: "Number of Homes",
baseline: total,
scenario: null,
baselineTotal: undefined,
scenarioTotal: undefined,
units: "",
subtitle: "Total properties in this portfolio.",
},
{
key: "avgSap",
title: "Average EPC Rating",
baseline: `${averageCurrentEpc} (${Math.round(averages.avg_sap ?? 0)} pts)`,
scenario:
overlay?.avgSap &&
`${sapToEpc(overlay.avgSap.scenario)} (${overlay.avgSap.scenario} pts)`,
baselineTotal: undefined,
scenarioTotal: undefined,
subtitle: "Current SAP rating across all properties.",
isEpc: true,
},
{
key: "avgCarbon",
title: "Carbon Emissions",
baseline: formatNumber(averages.avg_carbon ?? 0),
scenario: overlay?.avgCarbon && formatNumber(overlay.avgCarbon.scenario),
units: "tCO₂e /home",
baselineTotal: totals.total_carbon ?? 0,
scenarioTotal: overlay?.avgCarbon?.scenarioTotal,
subtitle: "Average annual CO₂ output per home.",
delta:
hasScenario && overlay?.avgCarbon
? deltaLabel(overlay.avgCarbon.baseline, overlay.avgCarbon.scenario)
: null,
},
{
key: "avgBills",
title: "Energy Bills",
baseline: formatNumber(averages.avg_bills ?? 0),
scenario: overlay?.avgBills && formatNumber(overlay.avgBills.scenario),
units: "/ home",
baselineTotal: totals.total_bills ?? 0,
scenarioTotal: overlay?.avgBills?.scenarioTotal,
subtitle: "Estimated annual energy bills.",
delta:
hasScenario && overlay?.avgBills
? deltaLabel(overlay.avgBills.baseline, overlay.avgBills.scenario)
: null,
},
];
return (
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
{cards.map((c) => {
const Icon = cardStyles[c.key as MetricKey].icon;
const color = cardStyles[c.key as MetricKey].color;
return (
<Card
key={c.key}
className="relative h-full flex flex-col border border-gray-100 bg-gradient-to-br from-white to-brandlightblue/10 hover:shadow-lg transition-all duration-300"
>
<CardHeader className="flex flex-row items-center gap-2 pb-1">
<motion.div whileHover={{ scale: 1.05 }}>
<Icon className={`h-5 w-5 ${color}`} />
</motion.div>
<CardTitle className="text-md font-medium text-gray-700">
{c.title}
</CardTitle>
</CardHeader>
<CardContent className="flex flex-1 flex-col gap-2">
{/* BASELINE + SCENARIO ROW */}
<div
className={`flex ${
hasScenario ? "justify-between" : "justify-start"
} items-start`}
>
{/* BASELINE COLUMN */}
<div className="flex flex-col">
<span className="text-xs text-gray-500">Baseline</span>
<div className="flex items-baseline gap-2">
<span
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 print-text-solid"
}
>
{c.key === "avgBills" ? `£${c.baseline}` : c.baseline}
</span>
{/* units next to baseline average */}
{c.units && (
<span className="text-sm text-gray-500">{c.units}</span>
)}
</div>
{/* Baseline total */}
{c.baselineTotal !== undefined && (
<span className="text-md text-gray-600">
Total:{" "}
{c.key === "avgBills"
? `£${formatNumber(c.baselineTotal)}`
: `${formatNumber(c.baselineTotal)} tCO₂e`}
</span>
)}
</div>
{/* SCENARIO COLUMN */}
{hasScenario && c.scenario && (
<div className="flex flex-col text-right">
<span className="text-xs text-gray-500">Scenario</span>
{/* average + delta + units row */}
<div className="flex items-baseline justify-end gap-2">
<span
className={
c.isEpc
? `text-2xl font-semibold ${
epcColors[
sapToEpc(
overlay?.avgSap?.scenario ??
(averages.avg_sap || 0)
) || "Unknown"
]
}`
: "text-2xl font-semibold text-brandblue"
}
>
{c.key === "avgBills" ? `£${c.scenario}` : c.scenario}
</span>
{c.delta && <span>{c.delta}</span>}
</div>
{/* Scenario total */}
{c.scenarioTotal !== undefined && (
<span className="text-md text-gray-600">
Total:{" "}
{c.key === "avgBills"
? `£${formatNumber(c.scenarioTotal)}`
: `${formatNumber(c.scenarioTotal)} tCO₂e`}
</span>
)}
</div>
)}
</div>
{/* Missing EPC bar */}
{c.key === "missingEpc" && (
<div className="w-full bg-gray-200 rounded-full h-2 mt-2">
<div
className="h-2 rounded-full bg-red-500"
style={{ width: `${missingEpcPercent}%` }}
/>
</div>
)}
</CardContent>
<CardFooter>
<p className="text-xs text-gray-500">{c.subtitle}</p>
</CardFooter>
</Card>
);
})}
</div>
);
}

View file

@ -0,0 +1,116 @@
"use client";
import {
Card,
CardHeader,
CardTitle,
CardContent,
CardFooter,
} from "@/app/shadcn_components/ui/card";
import { motion } from "framer-motion";
import { FileQuestion, AlertTriangle, TrendingDown } from "lucide-react";
import type { EstimatedCounts } from "./types";
export function EpcQualityCards({
estimatedCounts,
total,
expiredEpcs,
likelyDowngrades,
}: {
estimatedCounts: EstimatedCounts;
total: number;
expiredEpcs: number;
likelyDowngrades: number;
}) {
// Missing EPCs (estimated = true)
const missing = estimatedCounts.estimated;
const pctMissing = total > 0 ? (missing / total) * 100 : 0;
// Expired EPCs
const pctExpired = total > 0 ? (expiredEpcs / total) * 100 : 0;
// Likely downgrades
const pctDowngrades = total > 0 ? (likelyDowngrades / total) * 100 : 0;
const cards = [
{
key: "missing",
title: "Homes Without an EPC",
icon: FileQuestion,
color: "text-red-600",
value: missing,
subtitle: `${pctMissing.toFixed(1)}% missing EPC records`,
barColor: "bg-red-500",
barWidth: pctMissing,
gradient: "bg-gradient-to-br from-white to-red-50/20",
},
{
key: "expired",
title: "Expired EPCs",
icon: AlertTriangle,
color: "text-amber-600",
value: expiredEpcs,
subtitle: `${pctExpired.toFixed(1)}% of homes have expired EPCs`,
barColor: "bg-amber-500",
barWidth: pctExpired,
gradient: "bg-gradient-to-br from-white to-amber-50/20",
},
{
key: "downgrades",
title: "Likely EPC Downgrades",
icon: TrendingDown,
color: "text-brandblue",
value: likelyDowngrades,
subtitle: `${pctDowngrades.toFixed(1)}% likely EPC score reductions`,
barColor: "bg-brandblue",
barWidth: pctDowngrades,
gradient: "bg-gradient-to-br from-white to-blue-50/20",
},
];
return (
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4 mt-4">
{cards.map((c) => {
const Icon = c.icon;
return (
<Card
key={c.key}
className={`relative h-full flex flex-col border border-gray-100 ${c.gradient} hover:shadow-md hover:-translate-y-0.5 transition-all`}
>
{/* Header */}
<CardHeader className="flex flex-row items-center gap-2 pb-1">
<div className="p-1.5 rounded-md bg-gray-100">
<motion.div whileHover={{ scale: 1.1 }} className="p-1">
<Icon className={`h-4 w-4 ${c.color}`} />
</motion.div>
</div>
<CardTitle className="text-md font-medium text-gray-600">
{c.title}
</CardTitle>
</CardHeader>
{/* Content */}
<CardContent className="flex flex-col pb-2">
<div className="text-2xl font-semibold text-brandblue">
{c.value}
</div>
{/* Mini bar */}
<div className="w-full mt-3 bg-gray-200 rounded-full h-2">
<div
className={`h-2 rounded-full ${c.barColor}`}
style={{ width: `${c.barWidth}%` }}
/>
</div>
</CardContent>
{/* Footer */}
<CardFooter className="pt-0 pb-4">
<p className="text-xs text-gray-500">{c.subtitle}</p>
</CardFooter>
</Card>
);
})}
</div>
);
}

View file

@ -0,0 +1,121 @@
"use client";
import {
Card,
CardHeader,
CardTitle,
CardContent,
CardFooter,
} from "@/app/shadcn_components/ui/card";
import { motion } from "framer-motion";
import {
AlertTriangle,
FileQuestion,
ClipboardList,
ClipboardX,
PoundSterling,
Home,
} from "lucide-react";
type PlaceholderCard = {
key: string;
title: string;
subtitle: string;
icon: any;
color: string;
};
export const CONDITION_PLACEHOLDERS = [
{
key: "awwabs",
title: "Awaabs Law Warnings",
subtitle: "Severe hazards related to damp & mould.",
icon: AlertTriangle,
color: "text-red-600",
},
{
key: "cat12",
title: "Category 1 & 2 Hazards",
subtitle: "Safety risks identified under HHSRS.",
icon: AlertTriangle,
color: "text-orange-500",
},
{
key: "noStockSurvey",
title: "Missing Stock Condition Survey",
subtitle: "Properties without a structural/condition survey.",
icon: ClipboardX,
color: "text-brandblue",
},
{
key: "noDecentHomes",
title: "Missing Decent Homes Survey",
subtitle: "Properties lacking a Decent Homes standard review.",
icon: ClipboardList,
color: "text-brandblue",
},
];
export const FINANCIAL_PLACEHOLDERS = [
{
key: "rent",
title: "Rent",
subtitle: "Historic or current rent information.",
icon: PoundSterling,
color: "text-brandbrown",
},
{
key: "valuation",
title: "Valuation",
subtitle: "Property valuation data.",
icon: Home,
color: "text-midblue",
},
];
export function PlaceholderMetricCards({
items,
}: {
items: PlaceholderCard[];
}) {
return (
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
{items.map((c) => {
const Icon = c.icon;
return (
<Card
key={c.key}
className="relative h-full flex flex-col border border-gray-100 bg-gradient-to-br from-white to-gray-50 hover:shadow-lg hover:-translate-y-0.5 transition-all duration-300"
>
<CardHeader className="flex flex-row items-center gap-2 pb-1">
<div className="p-1.5 rounded-md bg-gray-100">
<motion.div
whileHover={{ scale: 1.1 }}
whileTap={{ scale: 0.95 }}
className="p-1.5 rounded-md bg-gray-50"
>
<Icon className={`h-4 w-4 ${c.color}`} />
</motion.div>
</div>
<CardTitle className="text-md font-medium text-gray-600 mb-2">
{c.title}
</CardTitle>
</CardHeader>
<CardContent className="flex flex-1 flex-col pb-2 mb-2">
<div className="text-lg font-semibold text-gray-400 italic">
Data not provided
</div>
</CardContent>
<CardFooter className="pt-0 pb-4">
<p className="text-xs text-gray-500">{c.subtitle}</p>
</CardFooter>
</Card>
);
})}
</div>
);
}

View file

@ -0,0 +1,301 @@
"use client";
import { useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { ScenarioSelectorWrapper } from "./scenarioSelectorWrapper";
import { DashboardSummaryCards } from "./DashboardSummaryCards";
import { BreakdownChart } from "./BreakdownChart";
import { EpcQualityCards } from "./EpcQualityCards";
import { ScenarioFinancialDrawer } from "./ScenarioFinancialDrawer";
import { ScenarioMeasuresModal } from "./ScenarioMeasuresModal";
import { SectionDivider } from "@/app/portfolio/[slug]/(portfolio)/reporting/SectionDivider";
import {
PlaceholderMetricCards,
CONDITION_PLACEHOLDERS,
FINANCIAL_PLACEHOLDERS,
} from "@/app/portfolio/[slug]/(portfolio)/reporting/PlaceholderMetricCards";
import type {
BaselineMetrics,
PropertyTypeCount,
ScenarioSummary,
} from "./types";
interface ReportingClientAreaProps {
baseline: BaselineMetrics;
propertyTypes: PropertyTypeCount[];
scenarios: ScenarioSummary[];
portfolioId: number;
}
// ----------------------------------------
// Fetcher for scenario API route
// ----------------------------------------
async function fetchScenarioReport({
portfolioId,
scenarioId,
}: {
portfolioId: number;
scenarioId: number;
}) {
const res = await fetch(
`/api/portfolio/${portfolioId}/scenario/${scenarioId}/metrics`
);
if (!res.ok) {
console.error("Failed to fetch scenario report:", await res.text());
throw new Error("Failed to load scenario report");
}
return res.json();
}
async function fetchScenarioMeasures({
portfolioId,
scenarioId,
}: {
portfolioId: number;
scenarioId: number;
}) {
const res = await fetch(
`/api/portfolio/${portfolioId}/scenario/${scenarioId}/measures`
);
if (!res.ok) {
throw new Error("Failed to load measures");
}
return res.json();
}
export function ReportingClientArea({
baseline,
propertyTypes,
scenarios,
portfolioId,
}: ReportingClientAreaProps) {
const [selectedScenarioId, setSelectedScenarioId] = useState<number | null>(
null
);
const [measuresOpen, setMeasuresOpen] = useState<boolean>(false);
const drawerOpen = Boolean(selectedScenarioId);
// ----------------------------------------
// React Query: fetch scenario metrics
// ----------------------------------------
const {
data: scenarioData,
isLoading,
isError,
} = useQuery({
queryKey: ["scenario-report", portfolioId, selectedScenarioId],
queryFn: () =>
fetchScenarioReport({
portfolioId,
scenarioId: selectedScenarioId!,
}),
enabled: !!selectedScenarioId, // only run when scenario selected
});
const {
data: measuresData,
isLoading: measuresLoading,
isError: measuresError,
} = useQuery({
queryKey: ["scenario-measures", portfolioId, selectedScenarioId],
queryFn: () =>
fetchScenarioMeasures({
portfolioId,
scenarioId: selectedScenarioId!,
}),
enabled: measuresOpen && !!selectedScenarioId,
});
const scenarioLoading = isLoading && !!selectedScenarioId;
// ----------------------------------------
// Build overlay for Dashboard Summary cards
// ----------------------------------------
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;
// ----------------------------------------
// Scenario specific metrics that appear in the drawer (from API) and cannot be overlayed on baseline
// ----------------------------------------
const scenarioSpecific = scenarioData
? {
constructionCost: scenarioData.construction_cost,
pcCost: scenarioData.pc_cost,
contingency: scenarioData.contingency,
funding: scenarioData.total_funding,
costPerSap:
scenarioData.total_sap_uplift && scenarioData.total_sap_uplift > 0
? (scenarioData.construction_cost + scenarioData.pc_cost) /
scenarioData.total_sap_uplift
: 0,
costPerCo2:
scenarioData.construction_cost > 0
? (scenarioData.construction_cost + scenarioData.pc_cost) /
((baseline.totals.total_carbon ?? 0) - 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,
}
: null;
// Baseline stays baseline
const activeMetrics = baseline;
return (
<>
<div className="flex items-center justify-between gap-4">
{/* LEFT: Scenario selector */}
<ScenarioSelectorWrapper
scenarios={scenarios}
portfolioId={portfolioId}
selectedScenarioId={selectedScenarioId}
setSelectedScenarioId={setSelectedScenarioId}
/>
{/* RIGHT: Actions (only when scenario selected) */}
{selectedScenarioId && (
<div className="flex items-center gap-2">
<button
onClick={() => setMeasuresOpen(true)}
disabled={scenarioLoading}
className={`
rounded-md px-3 py-2 text-sm font-medium transition
${
scenarioLoading
? "bg-gray-200 text-gray-400 cursor-not-allowed"
: "bg-brandblue text-white hover:bg-hoverblue"
}
`}
>
{scenarioLoading ? "Loading…" : "Show measures"}
</button>
<button
onClick={() => {
window.open(
`/portfolio/${portfolioId}/reporting/pdf?scenarioId=${selectedScenarioId}`,
"_blank"
);
}}
disabled={scenarioLoading}
className={`
rounded-md border px-3 py-2 text-sm font-medium transition
${
scenarioLoading
? "border-gray-200 text-gray-400 cursor-not-allowed"
: "hover:bg-gray-50"
}
`}
>
Download PDF
</button>
</div>
)}
</div>
{/* LOADING + ERROR STATES */}
{isLoading && selectedScenarioId && (
<div className="text-sm text-gray-500 mt-2">Loading scenario</div>
)}
{isError && (
<div className="text-sm text-red-500 mt-2">
Could not load scenario data.
</div>
)}
{/* --- RETROFIT SECTION --- */}
<SectionDivider
title="Retrofit Summary"
subtitle="High-level insights on performance, energy, and EPC quality."
/>
<ScenarioFinancialDrawer open={drawerOpen} metrics={scenarioSpecific} />
<div className="grid grid-cols-1 lg:grid-cols-[60%_40%] gap-6 p-2">
<DashboardSummaryCards
total={activeMetrics.total}
totals={activeMetrics.totals}
averages={activeMetrics.averages}
estimatedCounts={activeMetrics.estimatedCounts}
scenarioOverlay={scenarioOverlay}
/>
<BreakdownChart
epcBands={activeMetrics.epcBands}
ageBands={activeMetrics.ageBands}
propertyTypes={propertyTypes}
scenarioEpcBands={scenarioOverlay?.scenarioEpcBands}
/>
<div className="lg:col-span-2">
<EpcQualityCards
estimatedCounts={activeMetrics.estimatedCounts}
total={activeMetrics.total}
expiredEpcs={activeMetrics.expiredEpcs}
likelyDowngrades={activeMetrics.likelyDowngrades}
/>
</div>
</div>
{/* --- CONDITION SECTION --- */}
<SectionDivider
title="Condition"
subtitle="Awaabs Law, decent homes and compliance"
/>
<PlaceholderMetricCards items={CONDITION_PLACEHOLDERS} />
{/* --- FINANCIAL SECTION --- */}
<SectionDivider
title="Financial Overview"
subtitle="Total bills, cost exposure, and potential funding pathways."
/>
<PlaceholderMetricCards items={FINANCIAL_PLACEHOLDERS} />
<ScenarioMeasuresModal
isOpen={measuresOpen}
onClose={() => setMeasuresOpen(false)}
isLoading={measuresLoading}
data={measuresData ?? null}
error={measuresError}
/>
</>
);
}

View file

@ -0,0 +1,339 @@
"use client";
import { motion, AnimatePresence } from "framer-motion";
import { formatNumber } from "@/app/utils";
import clsx from "clsx";
/* Heroicons (outline) */
import {
ArrowTrendingUpIcon,
ClipboardDocumentCheckIcon,
ScaleIcon,
HomeIcon,
BoltIcon,
FireIcon,
ChartBarIcon,
WrenchIcon,
} from "@heroicons/react/24/outline";
/* 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 (
<div
className={clsx(
"relative rounded-lg p-[2px] gradient-card",
gradient,
`gradient-${variant}`
)}
>
<div className="rounded-[7px] bg-white h-full">{children}</div>
</div>
);
}
/* ───────────────────────────────────────────── */
/* Single Metric Card */
/* ───────────────────────────────────────────── */
function Metric({
label,
value,
icon: Icon,
color,
gradient,
variant = "green",
}: {
label: string;
value: string | number;
icon: React.ComponentType<React.SVGProps<SVGSVGElement>>;
color: string;
gradient: string;
variant?: "green" | "blue" | "purple";
}) {
return (
<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>
);
}
/* ───────────────────────────────────────────── */
/* 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,320 @@
"use client";
import {
Dialog,
DialogBackdrop,
DialogPanel,
DialogTitle,
Transition,
} from "@headlessui/react";
import { Fragment, useMemo } from "react";
/* ------------------------------------------------
Types
------------------------------------------------ */
interface ScenarioMeasuresModalProps {
isOpen: boolean;
onClose: () => void;
isLoading: boolean;
data: any | null;
error: unknown;
}
type ScenarioMeasure = {
measureType: string;
homesCount: number;
totalCost: number;
averageCost: number;
};
export type MeasureCategory =
| "Wall insulation"
| "Roof insulation"
| "Floor insulation"
| "Ventilation & airtightness"
| "Windows & glazing"
| "Solar"
| "Heating"
| "Heating controls"
| "Lighting"
| "Scaffolding & enabling works"
| "Other";
/* ------------------------------------------------
Category mapping
------------------------------------------------ */
export const MEASURE_CATEGORY_MAP: Record<string, MeasureCategory> = {
internal_wall_insulation: "Wall insulation",
external_wall_insulation: "Wall insulation",
cavity_wall_insulation: "Wall insulation",
cavity_wall_extraction: "Wall insulation",
loft_insulation: "Roof insulation",
flat_roof_insulation: "Roof insulation",
room_roof_insulation: "Roof insulation",
suspended_floor_insulation: "Floor insulation",
solid_floor_insulation: "Floor insulation",
exposed_floor_insulation: "Floor insulation",
mechanical_ventilation: "Ventilation & airtightness",
trickle_vent: "Ventilation & airtightness",
door_undercut: "Ventilation & airtightness",
sealing_fireplace: "Ventilation & airtightness",
windows_glazing: "Windows & glazing",
double_glazing: "Windows & glazing",
secondary_glazing: "Windows & glazing",
solar_pv: "Solar",
solar_battery: "Solar",
air_source_heat_pump: "Heating",
boiler_upgrade: "Heating",
high_heat_retention_storage_heaters: "Heating",
roomstat_programmer_trvs: "Heating controls",
time_temperature_zone_control: "Heating controls",
low_energy_lighting_installation: "Lighting",
scaffolding: "Scaffolding & enabling works",
};
function getMeasureCategory(measureType: string): MeasureCategory {
return MEASURE_CATEGORY_MAP[measureType] ?? "Other";
}
/* ------------------------------------------------
Helpers
------------------------------------------------ */
function toTitleCase(value: string) {
return value
.replaceAll("_", " ")
.toLowerCase()
.replace(/\b\w/g, (char) => char.toUpperCase());
}
type GroupedMeasures = {
category: MeasureCategory;
rows: ScenarioMeasure[];
homesTotal: number;
costTotal: number;
};
function groupMeasuresByCategory(
measures: ScenarioMeasure[]
): GroupedMeasures[] {
const map = new Map<MeasureCategory, ScenarioMeasure[]>();
for (const m of measures) {
const category = getMeasureCategory(m.measureType);
if (!map.has(category)) map.set(category, []);
map.get(category)!.push(m);
}
return Array.from(map.entries()).map(([category, rows]) => ({
category,
rows,
homesTotal: rows.reduce((s, r) => s + r.homesCount, 0),
costTotal: rows.reduce((s, r) => s + r.totalCost, 0),
}));
}
/* ------------------------------------------------
CSV download
------------------------------------------------ */
function downloadMeasuresCsv(groups: GroupedMeasures[]) {
const lines: string[] = [];
lines.push("Category,Measure,Homes,Total cost (£),Average cost (£)");
for (const group of groups) {
for (const m of group.rows) {
lines.push(
[
group.category,
toTitleCase(m.measureType),
m.homesCount,
m.totalCost.toFixed(0),
m.averageCost.toFixed(2),
].join(",")
);
}
// Subtotal
lines.push(
[
group.category,
"Subtotal",
group.homesTotal,
group.costTotal.toFixed(0),
"",
].join(",")
);
}
const blob = new Blob([lines.join("\n")], {
type: "text/csv;charset=utf-8;",
});
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = "scenario-measures.csv";
link.click();
}
/* ------------------------------------------------
Component
------------------------------------------------ */
export function ScenarioMeasuresModal({
isOpen,
onClose,
isLoading,
data,
error,
}: ScenarioMeasuresModalProps) {
const measures: ScenarioMeasure[] = data?.measures ?? [];
const grouped = useMemo(() => groupMeasuresByCategory(measures), [measures]);
return (
<Transition show={isOpen} as={Fragment}>
<Dialog
as="div"
className="fixed inset-0 z-50 overflow-y-auto"
onClose={onClose}
>
<div className="min-h-screen px-4 text-center">
{isLoading && (
<DialogBackdrop className="fixed inset-0 bg-black/30" />
)}
<span className="inline-block h-screen align-middle" />
<DialogPanel className="inline-block w-full max-w-5xl p-6 my-8 text-left align-middle bg-white shadow-xl rounded-2xl">
<DialogTitle className="text-lg font-semibold text-gray-900">
Scenario measures
</DialogTitle>
{/* Actions */}
<div className="mt-4 flex items-center justify-between">
<p className="text-sm text-gray-500">
{measures.length} measures
</p>
<button
onClick={() => downloadMeasuresCsv(grouped)}
disabled={!measures.length}
className="rounded-md border px-3 py-2 text-sm font-medium hover:bg-gray-50 disabled:opacity-50"
>
Download CSV
</button>
</div>
{/* Content */}
<div className="mt-4">
{isLoading && (
<div className="text-sm text-gray-500">Loading measures</div>
)}
{Boolean(error) && (
<div className="text-sm text-red-600">
Failed to load measures.
</div>
)}
{!isLoading && grouped.length > 0 && (
<div className="overflow-x-auto rounded-xl border border-gray-200">
<table className="w-full text-sm">
<thead className="bg-gray-50">
<tr>
<th className="px-4 py-2 text-left font-medium text-gray-700">
Measure
</th>
<th className="px-4 py-2 text-right font-medium text-gray-700">
Homes
</th>
<th className="px-4 py-2 text-right font-medium text-gray-700">
Total cost
</th>
<th className="px-4 py-2 text-right font-medium text-gray-700">
Avg. cost
</th>
</tr>
</thead>
<tbody>
{grouped.map((group) => (
<Fragment key={group.category}>
{/* Category header */}
<tr className="bg-gray-100 border-t border-brandmidblue border-b">
<td
colSpan={4}
className="px-4 py-3 text-sm font-semibold text-gray-700 tracking-wide"
>
{group.category}
</td>
</tr>
{/* Rows */}
{group.rows.map((row) => (
<tr key={row.measureType} className="border-t">
<td className="px-4 py-2">
{toTitleCase(row.measureType)}
</td>
<td className="px-4 py-2 text-right">
{row.homesCount.toLocaleString()}
</td>
<td className="px-4 py-2 text-right">
£{row.totalCost.toLocaleString()}
</td>
<td className="px-4 py-2 text-right text-gray-600">
£
{row.averageCost.toLocaleString(undefined, {
maximumFractionDigits: 0,
})}
</td>
</tr>
))}
{/* Subtotal */}
<tr className="border-t bg-gray-50">
<td className="px-4 py-2 font-medium text-gray-700">
Subtotal
</td>
<td className="px-4 py-2 text-right font-medium">
{group.homesTotal.toLocaleString()}
</td>
<td className="px-4 py-2 text-right font-medium">
£{group.costTotal.toLocaleString()}
</td>
<td />
</tr>
</Fragment>
))}
</tbody>
</table>
</div>
)}
</div>
<div className="mt-6 flex justify-end">
<button
onClick={onClose}
className="rounded-md border px-4 py-2 text-sm font-medium hover:bg-gray-50"
>
Close
</button>
</div>
</DialogPanel>
</div>
</Dialog>
</Transition>
);
}

View file

@ -0,0 +1,32 @@
"use client";
import { motion } from "framer-motion";
export function SectionDivider({
title,
subtitle,
}: {
title: string;
subtitle?: string;
}) {
return (
<div className="w-full mb-4">
{/* Title */}
<div className="flex flex-col">
<h2 className="text-xl font-semibold text-brandblue tracking-tight">
{title}
</h2>
{subtitle && <p className="text-sm text-gray-500 mt-0.5">{subtitle}</p>}
</div>
{/* Animated gradient line */}
<motion.div
initial={{ width: 0 }}
whileInView={{ width: "100%" }}
transition={{ duration: 0.6, ease: "easeOut" }}
className="h-1 mt-2 rounded-full bg-gradient-to-r from-brandblue via-midblue to-brandlightblue"
/>
</div>
);
}

View file

@ -0,0 +1,241 @@
import { db } from "@/app/db/db";
import { sql, eq } from "drizzle-orm";
import { scenario } from "@/app/db/schema/recommendations";
import type {
AverageMetrics,
AgeBandCount,
EpcBandCount,
EstimatedCounts,
PropertyTypeCount,
BaselineMetrics,
TotalMetrics,
} from "./types";
export async function getPortfolioCounts(portfolioId: number): Promise<number> {
const result = await db.execute<{ total: number }>(sql`
SELECT COUNT(*)::int AS total
FROM property
WHERE portfolio_id = ${portfolioId};
`);
return result.rows[0].total;
}
export async function getAverages(
portfolioId: number
): Promise<AverageMetrics> {
const result = await db.execute<AverageMetrics>(sql`
SELECT
AVG(p.current_sap_points)::float AS avg_sap,
AVG(e.co2_emissions)::float AS avg_carbon,
AVG(
e.heating_cost_current +
e.hot_water_cost_current +
e.lighting_cost_current +
e.appliances_cost_current +
e.gas_standing_charge +
e.electricity_standing_charge -
COALESCE(e.installed_measures_total_energy_bill_adjustment, 0)
)::float AS avg_bills,
AVG(e.primary_energy_consumption)::float AS avg_energy_consumption
FROM property p
LEFT JOIN property_details_epc e ON e.property_id = p.id
WHERE p.portfolio_id = ${portfolioId};
`);
return result.rows[0];
}
export async function getTotals(portfolioId: number): Promise<TotalMetrics> {
const result = await db.execute<TotalMetrics>(sql`
SELECT
SUM(e.co2_emissions)::float AS total_carbon,
SUM(
e.heating_cost_current +
e.hot_water_cost_current +
e.lighting_cost_current +
e.appliances_cost_current +
e.gas_standing_charge +
e.electricity_standing_charge -
COALESCE(e.installed_measures_total_energy_bill_adjustment, 0)
)::float AS total_bills
FROM property p
LEFT JOIN property_details_epc e ON e.property_id = p.id
WHERE p.portfolio_id = ${portfolioId};
`);
return result.rows[0];
}
export async function getCountByAgeBand(
portfolioId: number
): Promise<AgeBandCount[]> {
const result = await db.execute<AgeBandCount>(sql`
SELECT
CASE
WHEN year_built ~ '^[0-9]+$' THEN
CASE
WHEN CAST(year_built AS int) < 1900 THEN '<1900'
WHEN CAST(year_built AS int) BETWEEN 1900 AND 1929 THEN '19001929'
WHEN CAST(year_built AS int) BETWEEN 1930 AND 1949 THEN '19301949'
WHEN CAST(year_built AS int) BETWEEN 1950 AND 1975 THEN '19501975'
WHEN CAST(year_built AS int) BETWEEN 1976 AND 1999 THEN '19761999'
ELSE '2000+'
END
ELSE 'Unknown'
END AS age_band,
COUNT(*)::int AS count
FROM property
WHERE portfolio_id = ${portfolioId}
GROUP BY age_band
ORDER BY age_band;
`);
return result.rows;
}
export async function getCountByEpcBand(
portfolioId: number
): Promise<EpcBandCount[]> {
const result = await db.execute<EpcBandCount>(sql`
SELECT *
FROM (
SELECT
COALESCE(p.current_epc_rating::text, 'Unknown') AS epc,
SUM(CASE WHEN e.estimated = false THEN 1 ELSE 0 END)::int AS actual,
SUM(CASE WHEN e.estimated = true THEN 1 ELSE 0 END)::int AS estimated
FROM property p
LEFT JOIN property_details_epc e
ON e.property_id = p.id
WHERE p.portfolio_id = ${portfolioId}
GROUP BY epc
) q
ORDER BY
CASE
WHEN q.epc = 'A' THEN 1
WHEN q.epc = 'B' THEN 2
WHEN q.epc = 'C' THEN 3
WHEN q.epc = 'D' THEN 4
WHEN q.epc = 'E' THEN 5
WHEN q.epc = 'F' THEN 6
WHEN q.epc = 'G' THEN 7
ELSE 8 -- 'Unknown'
END;
`);
return result.rows;
}
export async function getEstimatedCounts(
portfolioId: number
): Promise<EstimatedCounts> {
const result = await db.execute<EstimatedCounts>(sql`
SELECT
SUM(CASE WHEN e.estimated = true THEN 1 ELSE 0 END)::int AS estimated,
SUM(CASE WHEN e.estimated = false THEN 1 ELSE 0 END)::int AS actual
FROM property_details_epc e
WHERE e.portfolio_id = ${portfolioId};
`);
return result.rows[0];
}
export async function getCountByPropertyType(
portfolioId: number
): Promise<PropertyTypeCount[]> {
const result = await db.execute<PropertyTypeCount>(sql`
SELECT property_type AS type, COUNT(*)::int AS count
FROM property
WHERE portfolio_id = ${portfolioId}
GROUP BY property_type
ORDER BY count DESC;
`);
return result.rows;
}
export async function getExpiredEpcCount(portfolioId: number): Promise<number> {
const result = await db.execute<{ expired: number }>(sql`
SELECT
SUM(
CASE
WHEN is_expired = true AND estimated = false
THEN 1
ELSE 0
END
)::int AS expired
FROM property_details_epc
WHERE portfolio_id = ${portfolioId};
`);
return result.rows[0].expired;
}
export async function getLikelyDowngrades(
portfolioId: number
): Promise<number> {
const result = await db.execute<{ downgrades: number }>(sql`
SELECT
COUNT(*)::int AS downgrades
FROM property p
JOIN property_details_epc e
ON e.property_id = p.id
WHERE p.portfolio_id = ${portfolioId}
AND e.sap_05_overwritten = true
AND p.current_sap_points IS NOT NULL
AND e.sap_05_score IS NOT NULL
AND p.current_sap_points < e.sap_05_score;
`);
return result.rows[0].downgrades;
}
export async function loadBaselineMetrics(
portfolioId: number
): Promise<BaselineMetrics> {
const [
total,
averages,
totals,
ageBands,
epcBands,
estimatedCounts,
expiredEpcs,
likelyDowngrades,
] = await Promise.all([
getPortfolioCounts(portfolioId),
getAverages(portfolioId),
getTotals(portfolioId),
getCountByAgeBand(portfolioId),
getCountByEpcBand(portfolioId),
getEstimatedCounts(portfolioId),
getExpiredEpcCount(portfolioId),
getLikelyDowngrades(portfolioId),
]);
return {
total,
averages,
totals,
ageBands,
epcBands,
estimatedCounts,
expiredEpcs,
likelyDowngrades,
};
}
export async function getScenarios(portfolioId: number) {
const rows = await db
.select()
.from(scenario)
.where(eq(scenario.portfolioId, BigInt(portfolioId)));
// Normalise response (only return what we need right now)
return rows.map((s) => ({
id: Number(s.id),
name: s.name ?? `Scenario ${s.id}`,
// we will compute the performance metrics in another function
}));
}

View file

@ -1,14 +1,37 @@
import {
loadBaselineMetrics,
getCountByPropertyType,
getScenarios,
} from "@/app/portfolio/[slug]/(portfolio)/reporting/databaseFunctions";
import { ReportingClientArea } from "./ReportingClientArea";
export default async function ReportingPage(props: { export default async function ReportingPage(props: {
params: Promise<{ slug: string }>; params: Promise<{ slug: string }>;
}) { }) {
const params = await props.params; const params = await props.params;
const portfolioId = params.slug; const portfolioId = params.slug;
const [baseline, propertyTypes] = await Promise.all([
loadBaselineMetrics(Number(portfolioId)),
getCountByPropertyType(Number(portfolioId)),
]);
const scenarios = await getScenarios(Number(portfolioId));
return ( return (
<> <div className="max-w-8xl mx-auto px-6 pb-10 space-y-4 pt-4">
<div className="flex justify-center"> <div className="mb-6">
<div>Reporting Page for portfolio: {portfolioId}</div> <header className="text-3xl font-semibold text-brandblue">
Portfolio Overview
</header>
<div className="h-px bg-gray-200 mt-2" />
</div> </div>
</>
<ReportingClientArea
baseline={baseline}
propertyTypes={propertyTypes}
scenarios={scenarios}
portfolioId={Number(portfolioId)}
/>
</div>
); );
} }

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

View file

@ -0,0 +1,55 @@
"use client";
import {
Select,
SelectTrigger,
SelectContent,
SelectItem,
SelectValue,
} from "@/app/shadcn_components/ui/select";
import type { FC } from "react";
export interface ScenarioOption {
id: number;
name: string;
}
interface ScenarioSelectorProps {
scenarios: ScenarioOption[];
selected: number | null;
onChange: (id: number | null) => void;
}
export const ScenarioSelector: FC<ScenarioSelectorProps> = ({
scenarios,
selected,
onChange,
}) => {
return (
<div className="flex items-center gap-3">
<span className="text-sm text-gray-600">Scenario:</span>
<Select
value={selected ? String(selected) : "none"}
onValueChange={(val) => {
if (val === "none") onChange(null);
else onChange(Number(val));
}}
>
<SelectTrigger className="w-56 bg-white border-gray-200 shadow-sm">
<SelectValue placeholder="Select scenario" />
</SelectTrigger>
<SelectContent>
<SelectItem value="none">No scenario (baseline only)</SelectItem>
{scenarios.map((s) => (
<SelectItem key={s.id} value={String(s.id)}>
{s.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
);
};

View file

@ -0,0 +1,49 @@
"use client";
import { useState, useMemo } from "react";
import { ScenarioSelector } from "./scenarioSelector";
export function ScenarioSelectorWrapper({
scenarios,
portfolioId,
selectedScenarioId,
setSelectedScenarioId,
}: {
scenarios: { id: number; name: string }[];
portfolioId: number;
selectedScenarioId: number | null;
setSelectedScenarioId: (id: number | null) => void;
}) {
// The ID we will eventually pass into React Query
// const activeContextId = useMemo(
// () => selectedScenarioId ?? portfolioId,
// [selectedScenarioId, portfolioId]
// );
const [selectedScenarioName, setSelectedScenarioName] = useState<
string | null
>(null);
function handleSelect(id: number | null) {
setSelectedScenarioId(id);
const scenario = scenarios.find((s) => s.id === id);
setSelectedScenarioName(scenario ? scenario.name : null);
}
return (
<div className="flex items-center gap-4">
<ScenarioSelector
scenarios={scenarios}
selected={selectedScenarioId}
onChange={handleSelect}
/>
{selectedScenarioId !== null ? (
<div className="text-xs text-gray-500">
Scenario selected: {selectedScenarioName}
</div>
) : (
<div className="text-xs text-gray-400">Using portfolio baseline</div>
)}
</div>
);
}

View file

@ -0,0 +1,83 @@
// src/server/reports/types.ts
export type AverageMetrics = {
avg_sap: number | null;
avg_carbon: number | null;
avg_bills: number | null;
avg_energy_consumption: number | null;
};
export type TotalMetrics = {
total_carbon: number | null;
total_bills: number | null;
};
export type AgeBandCount = {
age_band: string;
count: number;
};
export type EpcBandCount = {
epc: string;
actual: number;
estimated: number;
};
export type EstimatedCounts = {
estimated: number;
actual: number;
};
export type PropertyTypeCount = {
type: string | null;
count: number;
};
export interface BaselineMetrics {
total: number;
averages: AverageMetrics;
totals: TotalMetrics;
ageBands: AgeBandCount[];
epcBands: EpcBandCount[];
estimatedCounts: EstimatedCounts;
expiredEpcs: number;
likelyDowngrades: number;
}
export type MetricKey =
| "totalHomes"
| "avgSap"
| "avgCarbon"
| "avgBills"
| "missingEpc";
export interface ScenarioSummary {
id: number;
name: string;
}
export interface ScenarioOverlayMetrics {
avgSap?: {
baseline: number;
scenario: number;
};
avgCarbon?: {
baseline: number;
scenario: number;
baselineTotal: number;
scenarioTotal: number;
};
avgBills?: {
baseline: number;
scenario: number;
baselineTotal: number;
scenarioTotal: number;
};
valuation?: {
baseline: number | null;
scenario: number | null;
};
}

View file

@ -281,18 +281,28 @@ export default function PortfolioSettings({
setIsDeleteModalOpen(true); setIsDeleteModalOpen(true);
} }
function handleDeleteConfirmation() { async function handleDeleteConfirmation() {
if (deleteConfirmationByName === portfolioSettingsData.name) { if (deleteConfirmationByName !== portfolioSettingsData.name) {
mutateDelete({ console.warn("Delete confirmation name does not match");
return;
}
try {
console.log("[DELETE] starting delete mutation");
await mutateDelete({
userId, userId,
portfolioId, portfolioId,
}); });
console.log("succesfully called the mututate function");
console.log("[DELETE] mutation completed successfully");
// Refresh table / page data
router.refresh();
} catch (err) {
console.error("[DELETE] mutation failed", err);
} }
} }
// RENAMING FUNCTIONS
// Change NAME functionality - changing state // Change NAME functionality - changing state
function handlePortfolioNameChange(e: React.ChangeEvent<HTMLInputElement>) { function handlePortfolioNameChange(e: React.ChangeEvent<HTMLInputElement>) {
@ -460,7 +470,7 @@ export default function PortfolioSettings({
</TableBody> </TableBody>
</Table> </Table>
</div> </div>
<UsersPermissionsCard portfolioId={portfolioId}/> <UsersPermissionsCard portfolioId={portfolioId} />
<div className="rounded-md border border-red-500 mt-2"> <div className="rounded-md border border-red-500 mt-2">
<Table> <Table>
<TableHead className="text-lg text-brandblue">Danger Zone:</TableHead> <TableHead className="text-lg text-brandblue">Danger Zone:</TableHead>

View file

@ -1,6 +1,6 @@
import { ProjectProposal, DashboardSummary } from "./ProjectProposal"; import { ProjectProposal, DashboardSummary } from "./ProjectProposal";
import { getPlansWithTotals } from "./utils"; import { getPlansWithTotals } from "./utils";
import DataTable from "@/app/portfolio/[slug]/components/propertyTable"; import DataTable from "@/app/portfolio/[slug]/components/dataTable";
import { planColumns } from "./ProposalColumns"; import { planColumns } from "./ProposalColumns";
export default async function ProjectProposalPage(props: { export default async function ProjectProposalPage(props: {

View file

@ -1,3 +1,5 @@
import type { ReactNode } from "react";
import { import {
getPropertyMeta, getPropertyMeta,
getDocument, getDocument,
@ -309,7 +311,7 @@ function ReplacementsContent({
// sort within each group // sort within each group
Object.values(groups).forEach((comps) => Object.values(groups).forEach((comps) =>
comps.sort((a, b) => a.expiry.getTime() - b.expiry.getTime()) comps.sort((a, b) => a.expiry.getTime() - b.expiry.getTime()),
); );
const groupOrder: (keyof typeof groups)[] = [ const groupOrder: (keyof typeof groups)[] = [
@ -320,7 +322,7 @@ function ReplacementsContent({
]; ];
// urgency → card highlight color + icon // urgency → card highlight color + icon
const cardStyles: Record<string, { border: string; icon: JSX.Element }> = { const cardStyles: Record<string, { border: string; icon: ReactNode }> = {
Overdue: { Overdue: {
border: "border-l-4 border-red-600", border: "border-l-4 border-red-600",
icon: <AlertTriangle className="w-4 h-4 text-red-600" />, icon: <AlertTriangle className="w-4 h-4 text-red-600" />,
@ -385,7 +387,7 @@ function ReplacementsContent({
))} ))}
</div> </div>
</div> </div>
) : null ) : null,
)} )}
</CardContent> </CardContent>
<div className="absolute bottom-0 left-0 right-0 h-6 bg-gradient-to-t from-gray-100 to-transparent pointer-events-none" /> <div className="absolute bottom-0 left-0 right-0 h-6 bg-gradient-to-t from-gray-100 to-transparent pointer-events-none" />
@ -434,7 +436,7 @@ function DecentHomesSummary({
day: "numeric", day: "numeric",
month: "long", month: "long",
year: "numeric", year: "numeric",
} },
); );
const criteriaGroups: Record< const criteriaGroups: Record<
@ -581,15 +583,15 @@ export default async function DecentHomesPage(props: {
!decentPropertyMeta.s3JsonUri !decentPropertyMeta.s3JsonUri
) { ) {
throw new Error( throw new Error(
`Decent Homes data is missing for uprn ${propertyMeta.uprn}` `Decent Homes data is missing for uprn ${propertyMeta.uprn}`,
); );
} }
const decentHomesMeta = await getEnergyAssessmentFromS3( const decentHomesMeta = await getEnergyAssessmentFromS3(
decentPropertyMeta.s3JsonUri decentPropertyMeta.s3JsonUri,
); );
const decentHomes = await getEnergyAssessmentFromS3( const decentHomes = await getEnergyAssessmentFromS3(
decentHomesSummary.s3JsonUri decentHomesSummary.s3JsonUri,
); );
return ( return (

View file

@ -23,8 +23,8 @@ export const MenuButton: React.FC<Props> = ({ onView, onDelete }) => {
<DropdownMenuContent align="end" className="w-32"> <DropdownMenuContent align="end" className="w-32">
<DropdownMenuItem onClick={onView}>View</DropdownMenuItem> <DropdownMenuItem onClick={onView}>View</DropdownMenuItem>
<DropdownMenuItem <DropdownMenuItem
onClick={onDelete}
className="text-red-600 focus:text-red-600" className="text-red-600 focus:text-red-600"
onClick={onDelete}
> >
Delete Delete
</DropdownMenuItem> </DropdownMenuItem>

View file

@ -4,7 +4,6 @@ import BackToPortfolioButton from "@/app/components/building-passport/BackToPort
import { ExclamationCircleIcon } from "@heroicons/react/24/outline"; import { ExclamationCircleIcon } from "@heroicons/react/24/outline";
// import "@tremor/react/dist/esm/tremor.css"; // import "@tremor/react/dist/esm/tremor.css";
function EstimatedDataNotification() { function EstimatedDataNotification() {
return ( return (
<div className="flex items-center text-brandmidblue mt-4"> <div className="flex items-center text-brandmidblue mt-4">
@ -28,7 +27,6 @@ export default async function DashboardLayout(props: {
const propertyId = params.propertyId ?? ""; const propertyId = params.propertyId ?? "";
const portfolioId = params.slug ?? ""; const portfolioId = params.slug ?? "";
// The layout is a server component by default so we can fetch meta data here // The layout is a server component by default so we can fetch meta data here
const propertyMeta = await getPropertyMeta(params.propertyId); const propertyMeta = await getPropertyMeta(params.propertyId);
// We check if we have an uploaded condition report and if so, we show the condition tab. Otherwise, we // We check if we have an uploaded condition report and if so, we show the condition tab. Otherwise, we

View file

@ -0,0 +1,232 @@
"use client";
import { useState } from "react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { TrashIcon } from "@heroicons/react/24/outline";
import { useRouter } from "next/navigation";
import EpcCard from "@/app/components/building-passport/EpcCard";
import GoToPlanButton from "@/app/components/building-passport/GoToPlanButton";
import { Card, CardContent, CardHeader } from "@/app/shadcn_components/ui/card";
import {
Dialog,
DialogContent,
DialogHeader as ModalHeader,
DialogTitle,
DialogFooter,
} from "@/app/shadcn_components/ui/dialog";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/app/shadcn_components/ui/table";
import { Button } from "@/app/shadcn_components/ui/button";
import { formatNumber } from "@/app/utils";
/* ----------------------------------------
Types
----------------------------------------- */
type DeletionPreviewRow = {
table: string;
count: number;
};
/* ----------------------------------------
Fetchers
----------------------------------------- */
async function fetchPlanDeletionPreview(
planId: string
): Promise<DeletionPreviewRow[]> {
const res = await fetch(`/api/plan/${planId}/delete/preview`, {
method: "POST",
headers: { "Content-Type": "application/json" },
});
if (!res.ok) throw new Error("Failed to load deletion preview");
const json = await res.json();
return json.preview;
}
async function confirmPlanDeletion(planId: string): Promise<void> {
const res = await fetch(`/api/plan/${planId}/delete/confirm`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ confirm: true }),
});
if (!res.ok) {
const msg = await res.text().catch(() => "");
throw new Error(msg || "Failed to delete plan");
}
}
/* ----------------------------------------
Component
----------------------------------------- */
export default function PlanCard({
expectedEpcRating,
createdAt,
totalEstimatedCost,
totalSapPoints,
planName,
planId,
}: {
expectedEpcRating: string;
createdAt: Date;
totalEstimatedCost: number;
totalSapPoints: number;
planName: string | null;
planId: string;
}) {
const [open, setOpen] = useState(false);
const queryClient = useQueryClient();
const router = useRouter();
/* -------- Preview query -------- */
const {
data: preview = [],
isLoading,
isError,
} = useQuery({
queryKey: ["planDeletionPreview", planId],
queryFn: () => fetchPlanDeletionPreview(planId),
enabled: open, // only fetch when modal opens
});
/* -------- Delete mutation -------- */
const deleteMutation = useMutation({
mutationFn: () => confirmPlanDeletion(planId),
onSuccess: () => {
setOpen(false);
router.refresh();
},
});
return (
<>
<Card className="relative flex items-start">
{/* Delete button */}
<button
type="button"
onClick={() => setOpen(true)}
className="
absolute top-3 right-3
rounded-md p-1.5
text-gray-400
hover:text-red-600 hover:bg-red-50
focus:outline-none focus:ring-2 focus:ring-red-400/40
transition
"
aria-label="Delete plan"
title="Delete plan"
>
<TrashIcon className="h-4 w-4" />
</button>
{/* EPC */}
<div className="flex-none w-1/5">
<EpcCard epcRating={expectedEpcRating} fullMargin expected />
</div>
{/* Content */}
<div className="flex-grow pl-4 flex flex-col justify-between">
<CardHeader className="flex justify-end items-start">
{planName && (
<div className="text-lg font-bold mb-2 text-gray-900">
{planName}
</div>
)}
</CardHeader>
<CardContent>
<div className="flex justify-between mb-2">
<span>Total cost:</span>
<span>£{formatNumber(totalEstimatedCost)}</span>
</div>
<div className="flex justify-between">
<span>Total SAP points:</span>
<span>
{Math.round((totalSapPoints + Number.EPSILON) * 100) / 100}
</span>
</div>
</CardContent>
</div>
{/* Right column */}
<div className="flex flex-col justify-end mr-2 self-stretch w-1/5">
<div className="flex flex-col items-end gap-2">
<GoToPlanButton planId={planId} />
</div>
</div>
</Card>
{/* ----------------------------------------
Delete preview modal
----------------------------------------- */}
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent className="max-w-lg">
<ModalHeader>
<DialogTitle className="text-red-600">Delete plan</DialogTitle>
</ModalHeader>
{isLoading ? (
<p className="text-sm text-gray-500">Loading deletion preview</p>
) : isError ? (
<p className="text-sm text-red-600">
Failed to load deletion preview
</p>
) : (
<div className="rounded-md border border-gray-200">
<Table>
<TableHeader>
<TableRow>
<TableHead>Table</TableHead>
<TableHead className="text-right">Rows deleted</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{preview.map((row) => (
<TableRow key={row.table}>
<TableCell className="font-mono text-sm">
{row.table}
</TableCell>
<TableCell className="text-right font-semibold">
{row.count}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
)}
<DialogFooter className="gap-2">
<Button
variant="outline"
onClick={() => setOpen(false)}
disabled={deleteMutation.isPending}
>
Cancel
</Button>
<Button
variant="destructive"
onClick={() => deleteMutation.mutate()}
disabled={deleteMutation.isPending}
>
{deleteMutation.isPending ? "Deleting…" : "Delete plan"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
}

Some files were not shown because too many files have changed in this diff Show more