mirror of
https://github.com/Hestia-Homes/assessment-model.git
synced 2026-06-30 12:55:02 +00:00
Merge pull request #168 from Hestia-Homes/main
Dev deployment - work since Nov
This commit is contained in:
commit
e2bb4ecfa4
125 changed files with 115048 additions and 4362 deletions
25
.github/workflows/nextjs-build.yml
vendored
Normal file
25
.github/workflows/nextjs-build.yml
vendored
Normal 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
1
.gitignore
vendored
|
|
@ -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
26
.vscode/settings.json
vendored
Normal 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
|
||||||
|
},
|
||||||
|
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
5870
package-lock.json
generated
File diff suppressed because it is too large
Load diff
12
package.json
12
package.json
|
|
@ -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 |
30
src/app/api/plan/[id]/delete/confirm/route.ts
Normal file
30
src/app/api/plan/[id]/delete/confirm/route.ts
Normal 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,
|
||||||
|
});
|
||||||
|
}
|
||||||
13
src/app/api/plan/[id]/delete/preview/route.ts
Normal file
13
src/app/api/plan/[id]/delete/preview/route.ts
Normal 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 });
|
||||||
|
}
|
||||||
|
|
@ -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"],
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
|
});
|
||||||
|
}
|
||||||
25
src/app/api/properties/route.ts
Normal file
25
src/app/api/properties/route.ts
Normal 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);
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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 =
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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";
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
4
src/app/db/migrations/0127_acoustic_sleepwalker.sql
Normal file
4
src/app/db/migrations/0127_acoustic_sleepwalker.sql
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
CREATE TABLE "whlg" (
|
||||||
|
"id" bigserial PRIMARY KEY NOT NULL,
|
||||||
|
"postcode" text NOT NULL
|
||||||
|
);
|
||||||
17
src/app/db/migrations/0128_thin_wiccan.sql
Normal file
17
src/app/db/migrations/0128_thin_wiccan.sql
Normal 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
|
||||||
|
);
|
||||||
4
src/app/db/migrations/0129_workable_medusa.sql
Normal file
4
src/app/db/migrations/0129_workable_medusa.sql
Normal 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;
|
||||||
3
src/app/db/migrations/0130_large_wong.sql
Normal file
3
src/app/db/migrations/0130_large_wong.sql
Normal 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;
|
||||||
1
src/app/db/migrations/0131_minor_lucky_pierre.sql
Normal file
1
src/app/db/migrations/0131_minor_lucky_pierre.sql
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
ALTER TYPE "public"."type" ADD VALUE 'double_glazing' BEFORE 'trickle_vent';
|
||||||
1
src/app/db/migrations/0132_cynical_ikaris.sql
Normal file
1
src/app/db/migrations/0132_cynical_ikaris.sql
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
ALTER TABLE "property_details_epc" ADD COLUMN "sap_05_overwritten" boolean DEFAULT false;
|
||||||
1
src/app/db/migrations/0133_calm_talkback.sql
Normal file
1
src/app/db/migrations/0133_calm_talkback.sql
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
ALTER TYPE "public"."type" ADD VALUE 'boiler_upgrade' BEFORE 'roomstat_programmer_trvs';
|
||||||
10
src/app/db/migrations/0134_lying_lester.sql
Normal file
10
src/app/db/migrations/0134_lying_lester.sql
Normal 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;
|
||||||
6
src/app/db/migrations/0135_lovely_spectrum.sql
Normal file
6
src/app/db/migrations/0135_lovely_spectrum.sql
Normal 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;
|
||||||
1
src/app/db/migrations/0136_boring_charles_xavier.sql
Normal file
1
src/app/db/migrations/0136_boring_charles_xavier.sql
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
CREATE INDEX "recommendation_property_id_idx" ON "recommendation" USING btree ("property_id");
|
||||||
1
src/app/db/migrations/0137_shallow_speedball.sql
Normal file
1
src/app/db/migrations/0137_shallow_speedball.sql
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
CREATE INDEX "recommendation_materials_recommendation_id_idx" ON "recommendation_materials" USING btree ("recommendation_id");
|
||||||
1
src/app/db/migrations/0138_neat_havok.sql
Normal file
1
src/app/db/migrations/0138_neat_havok.sql
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
CREATE UNIQUE INDEX "uq_property_portfolio_uprn" ON "property" USING btree ("portfolio_id","uprn") WHERE "property"."uprn" IS NOT NULL;
|
||||||
1
src/app/db/migrations/0139_skinny_legion.sql
Normal file
1
src/app/db/migrations/0139_skinny_legion.sql
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
CREATE UNIQUE INDEX "uq_epc_store_uprn" ON "epc_store" USING btree ("uprn");
|
||||||
2
src/app/db/migrations/0140_keen_dreaming_celestial.sql
Normal file
2
src/app/db/migrations/0140_keen_dreaming_celestial.sql
Normal 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");
|
||||||
2
src/app/db/migrations/0141_amazing_harry_osborn.sql
Normal file
2
src/app/db/migrations/0141_amazing_harry_osborn.sql
Normal 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");
|
||||||
1
src/app/db/migrations/0142_serious_whirlwind.sql
Normal file
1
src/app/db/migrations/0142_serious_whirlwind.sql
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
CREATE INDEX "idx_recommendation_active_defaults" ON "recommendation" USING btree ("id") WHERE "recommendation"."default" = true AND "recommendation"."already_installed" = false;
|
||||||
1
src/app/db/migrations/0143_magenta_magus.sql
Normal file
1
src/app/db/migrations/0143_magenta_magus.sql
Normal 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);
|
||||||
2
src/app/db/migrations/0144_lovely_moira_mactaggert.sql
Normal file
2
src/app/db/migrations/0144_lovely_moira_mactaggert.sql
Normal 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;
|
||||||
19
src/app/db/migrations/0145_brave_power_pack.sql
Normal file
19
src/app/db/migrations/0145_brave_power_pack.sql
Normal 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;
|
||||||
3
src/app/db/migrations/0146_tiny_george_stacy.sql
Normal file
3
src/app/db/migrations/0146_tiny_george_stacy.sql
Normal 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;
|
||||||
2
src/app/db/migrations/0147_confused_killer_shrike.sql
Normal file
2
src/app/db/migrations/0147_confused_killer_shrike.sql
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
ALTER TABLE "property_installed_measures" DISABLE ROW LEVEL SECURITY;--> statement-breakpoint
|
||||||
|
DROP TABLE "property_installed_measures" CASCADE;--> statement-breakpoint
|
||||||
9
src/app/db/migrations/0148_first_gamma_corps.sql
Normal file
9
src/app/db/migrations/0148_first_gamma_corps.sql
Normal 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;
|
||||||
12
src/app/db/migrations/2026_01_06_recommendation_cover.sql
Normal file
12
src/app/db/migrations/2026_01_06_recommendation_cover.sql
Normal 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;
|
||||||
4587
src/app/db/migrations/meta/0127_snapshot.json
Normal file
4587
src/app/db/migrations/meta/0127_snapshot.json
Normal file
File diff suppressed because it is too large
Load diff
4690
src/app/db/migrations/meta/0128_snapshot.json
Normal file
4690
src/app/db/migrations/meta/0128_snapshot.json
Normal file
File diff suppressed because it is too large
Load diff
4688
src/app/db/migrations/meta/0129_snapshot.json
Normal file
4688
src/app/db/migrations/meta/0129_snapshot.json
Normal file
File diff suppressed because it is too large
Load diff
4688
src/app/db/migrations/meta/0130_snapshot.json
Normal file
4688
src/app/db/migrations/meta/0130_snapshot.json
Normal file
File diff suppressed because it is too large
Load diff
4689
src/app/db/migrations/meta/0131_snapshot.json
Normal file
4689
src/app/db/migrations/meta/0131_snapshot.json
Normal file
File diff suppressed because it is too large
Load diff
4696
src/app/db/migrations/meta/0132_snapshot.json
Normal file
4696
src/app/db/migrations/meta/0132_snapshot.json
Normal file
File diff suppressed because it is too large
Load diff
4697
src/app/db/migrations/meta/0133_snapshot.json
Normal file
4697
src/app/db/migrations/meta/0133_snapshot.json
Normal file
File diff suppressed because it is too large
Load diff
4758
src/app/db/migrations/meta/0134_snapshot.json
Normal file
4758
src/app/db/migrations/meta/0134_snapshot.json
Normal file
File diff suppressed because it is too large
Load diff
4795
src/app/db/migrations/meta/0135_snapshot.json
Normal file
4795
src/app/db/migrations/meta/0135_snapshot.json
Normal file
File diff suppressed because it is too large
Load diff
4811
src/app/db/migrations/meta/0136_snapshot.json
Normal file
4811
src/app/db/migrations/meta/0136_snapshot.json
Normal file
File diff suppressed because it is too large
Load diff
4827
src/app/db/migrations/meta/0137_snapshot.json
Normal file
4827
src/app/db/migrations/meta/0137_snapshot.json
Normal file
File diff suppressed because it is too large
Load diff
4850
src/app/db/migrations/meta/0138_snapshot.json
Normal file
4850
src/app/db/migrations/meta/0138_snapshot.json
Normal file
File diff suppressed because it is too large
Load diff
4866
src/app/db/migrations/meta/0139_snapshot.json
Normal file
4866
src/app/db/migrations/meta/0139_snapshot.json
Normal file
File diff suppressed because it is too large
Load diff
4904
src/app/db/migrations/meta/0140_snapshot.json
Normal file
4904
src/app/db/migrations/meta/0140_snapshot.json
Normal file
File diff suppressed because it is too large
Load diff
4942
src/app/db/migrations/meta/0141_snapshot.json
Normal file
4942
src/app/db/migrations/meta/0141_snapshot.json
Normal file
File diff suppressed because it is too large
Load diff
4958
src/app/db/migrations/meta/0142_snapshot.json
Normal file
4958
src/app/db/migrations/meta/0142_snapshot.json
Normal file
File diff suppressed because it is too large
Load diff
4991
src/app/db/migrations/meta/0143_snapshot.json
Normal file
4991
src/app/db/migrations/meta/0143_snapshot.json
Normal file
File diff suppressed because it is too large
Load diff
5034
src/app/db/migrations/meta/0144_snapshot.json
Normal file
5034
src/app/db/migrations/meta/0144_snapshot.json
Normal file
File diff suppressed because it is too large
Load diff
5215
src/app/db/migrations/meta/0145_snapshot.json
Normal file
5215
src/app/db/migrations/meta/0145_snapshot.json
Normal file
File diff suppressed because it is too large
Load diff
5234
src/app/db/migrations/meta/0146_snapshot.json
Normal file
5234
src/app/db/migrations/meta/0146_snapshot.json
Normal file
File diff suppressed because it is too large
Load diff
5188
src/app/db/migrations/meta/0147_snapshot.json
Normal file
5188
src/app/db/migrations/meta/0147_snapshot.json
Normal file
File diff suppressed because it is too large
Load diff
5243
src/app/db/migrations/meta/0148_snapshot.json
Normal file
5243
src/app/db/migrations/meta/0148_snapshot.json
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -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
49
src/app/db/schema/epc.ts
Normal 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)]
|
||||||
|
);
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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(),
|
||||||
|
|
|
||||||
|
|
@ -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 |
|
|
@ -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%); }
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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: "I’ve added my property, but I can’t 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 property’s 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: "I’ve completed the remote assessment. What’s 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 can’t 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 can’t find the answer you’re 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;
|
||||||
|
|
|
||||||
|
|
@ -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 Domna’s portfolio intelligence platform that turns housing stock data into clear, costed retrofit and investment plans.",
|
||||||
};
|
};
|
||||||
|
|
||||||
const getSession = cache(async () => {
|
const getSession = cache(async () => {
|
||||||
|
|
|
||||||
|
|
@ -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
|
We’ll 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>
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 "New Property" 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>
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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 '1900–1929'
|
||||||
|
WHEN CAST(year_built AS int) BETWEEN 1930 AND 1949 THEN '1930–1949'
|
||||||
|
WHEN CAST(year_built AS int) BETWEEN 1950 AND 1975 THEN '1950–1975'
|
||||||
|
WHEN CAST(year_built AS int) BETWEEN 1976 AND 1999 THEN '1976–1999'
|
||||||
|
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
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,66 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect } from "react";
|
||||||
|
|
||||||
|
export function AutoPrint() {
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
|
||||||
|
function waitForImages() {
|
||||||
|
const images = Array.from(document.images);
|
||||||
|
|
||||||
|
return Promise.all(
|
||||||
|
images.map((img) => {
|
||||||
|
if (img.complete) return Promise.resolve();
|
||||||
|
|
||||||
|
return new Promise<void>((resolve) => {
|
||||||
|
img.onload = () => resolve();
|
||||||
|
img.onerror = () => resolve(); // never block print
|
||||||
|
});
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function waitForFontsSafe() {
|
||||||
|
try {
|
||||||
|
// Some browsers expose document.fonts but break on .ready
|
||||||
|
if (
|
||||||
|
"fonts" in document &&
|
||||||
|
document.fonts &&
|
||||||
|
typeof document.fonts.ready?.then === "function"
|
||||||
|
) {
|
||||||
|
return document.fonts.ready;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Ignore font readiness completely if browser misbehaves
|
||||||
|
}
|
||||||
|
|
||||||
|
return Promise.resolve();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function printWhenReady() {
|
||||||
|
await Promise.all([waitForImages(), waitForFontsSafe()]);
|
||||||
|
|
||||||
|
if (cancelled) return;
|
||||||
|
|
||||||
|
// Ensure layout is flushed
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
// Close tab AFTER print dialog completes
|
||||||
|
window.onafterprint = () => {
|
||||||
|
window.close();
|
||||||
|
};
|
||||||
|
|
||||||
|
window.print();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
printWhenReady();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
window.onafterprint = null;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
199
src/app/portfolio/[slug]/(portfolio)/reporting/pdf/page.tsx
Normal file
199
src/app/portfolio/[slug]/(portfolio)/reporting/pdf/page.tsx
Normal file
|
|
@ -0,0 +1,199 @@
|
||||||
|
import { SectionDivider } from "../SectionDivider";
|
||||||
|
import { ScenarioFinancialDrawer } from "../ScenarioFinancialDrawer";
|
||||||
|
import { DashboardSummaryCards } from "../DashboardSummaryCards";
|
||||||
|
import { EpcQualityCards } from "../EpcQualityCards";
|
||||||
|
|
||||||
|
import { loadBaselineMetrics } from "@/app/portfolio/[slug]/(portfolio)/reporting/databaseFunctions";
|
||||||
|
|
||||||
|
import type { BaselineMetrics } from "../types";
|
||||||
|
import Image from "next/image";
|
||||||
|
import { AutoPrint } from "./AutoPrint";
|
||||||
|
|
||||||
|
/* ---------------------------------------------
|
||||||
|
Base URL helper (Vercel + local)
|
||||||
|
--------------------------------------------- */
|
||||||
|
|
||||||
|
function getBaseUrl() {
|
||||||
|
if (process.env.VERCEL_BRANCH_URL) {
|
||||||
|
return `https://${process.env.VERCEL_BRANCH_URL}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (process.env.VERCEL_URL) {
|
||||||
|
return `https://${process.env.VERCEL_URL}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return "http://localhost:3000";
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------------------------------------------
|
||||||
|
Server-side fetch for scenario metrics
|
||||||
|
--------------------------------------------- */
|
||||||
|
|
||||||
|
async function fetchScenarioReport({
|
||||||
|
portfolioId,
|
||||||
|
scenarioId,
|
||||||
|
}: {
|
||||||
|
portfolioId: number;
|
||||||
|
scenarioId: number;
|
||||||
|
}) {
|
||||||
|
const res = await fetch(
|
||||||
|
`${getBaseUrl()}/api/portfolio/${portfolioId}/scenario/${scenarioId}/metrics`,
|
||||||
|
{ cache: "no-store" }
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error("Failed to load scenario report");
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------------------------------------------
|
||||||
|
Page
|
||||||
|
--------------------------------------------- */
|
||||||
|
|
||||||
|
export default async function ReportingPdfPage(props: {
|
||||||
|
params: Promise<{ slug: string }>;
|
||||||
|
searchParams: Promise<{ scenarioId?: string }>;
|
||||||
|
}) {
|
||||||
|
const params = await props.params;
|
||||||
|
const searchParams = await props.searchParams;
|
||||||
|
const scenarioId = Number(searchParams.scenarioId);
|
||||||
|
|
||||||
|
if (!scenarioId) {
|
||||||
|
return <div>No scenario selected</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const portfolioId = Number(params.slug);
|
||||||
|
|
||||||
|
/* ---------------------------------------------
|
||||||
|
Fetch baseline + scenario (parallel)
|
||||||
|
--------------------------------------------- */
|
||||||
|
|
||||||
|
const [baseline, scenarioData]: [BaselineMetrics, any] = await Promise.all([
|
||||||
|
loadBaselineMetrics(portfolioId),
|
||||||
|
fetchScenarioReport({ portfolioId, scenarioId }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
/* ---------------------------------------------
|
||||||
|
Scenario-only metrics for drawer
|
||||||
|
--------------------------------------------- */
|
||||||
|
|
||||||
|
const scenarioOverlay = scenarioData
|
||||||
|
? {
|
||||||
|
avgSap: {
|
||||||
|
baseline: baseline.averages.avg_sap ?? 0,
|
||||||
|
scenario: Number(scenarioData.avg_sap),
|
||||||
|
},
|
||||||
|
avgCarbon: {
|
||||||
|
baseline: Number(baseline.averages.avg_carbon ?? 0),
|
||||||
|
scenario: Number(scenarioData.avg_carbon),
|
||||||
|
|
||||||
|
baselineTotal: Number(baseline.totals.total_carbon ?? 0),
|
||||||
|
scenarioTotal: Number(scenarioData.total_carbon ?? 0),
|
||||||
|
},
|
||||||
|
avgBills: {
|
||||||
|
baseline: baseline.averages.avg_bills ?? 0,
|
||||||
|
scenario: scenarioData.avg_bills,
|
||||||
|
baselineTotal: baseline.totals.total_bills ?? 0,
|
||||||
|
scenarioTotal: scenarioData.total_bills,
|
||||||
|
},
|
||||||
|
valuation: { baseline: null, scenario: null },
|
||||||
|
scenarioEpcBands: scenarioData.scenario_epc_counts,
|
||||||
|
}
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const scenarioSpecific = {
|
||||||
|
constructionCost: scenarioData.construction_cost,
|
||||||
|
pcCost: scenarioData.pc_cost,
|
||||||
|
contingency: scenarioData.contingency,
|
||||||
|
funding: scenarioData.total_funding,
|
||||||
|
costPerSap:
|
||||||
|
scenarioData.construction_cost > 0
|
||||||
|
? scenarioData.gross_per_unit /
|
||||||
|
(scenarioData.avg_sap - (baseline.averages.avg_sap ?? 0))
|
||||||
|
: 0,
|
||||||
|
costPerCo2:
|
||||||
|
scenarioData.construction_cost > 0
|
||||||
|
? (scenarioData.construction_cost + scenarioData.pc_cost) /
|
||||||
|
scenarioData.total_carbon
|
||||||
|
: 0,
|
||||||
|
netCost: scenarioData.net_cost,
|
||||||
|
grossPerUnit: scenarioData.gross_per_unit,
|
||||||
|
nUnits: scenarioData.n_units_upgraded,
|
||||||
|
totalCarbonSaved:
|
||||||
|
(baseline.totals.total_carbon ?? 0) - scenarioData.total_carbon,
|
||||||
|
totalBillsSaved:
|
||||||
|
(baseline.totals.total_bills ?? 0) - scenarioData.total_bills,
|
||||||
|
averageCaribonSaved:
|
||||||
|
((baseline.totals.total_carbon ?? 0) - scenarioData.total_carbon) /
|
||||||
|
scenarioData.n_units_upgraded,
|
||||||
|
averageBillsSaved:
|
||||||
|
((baseline.totals.total_bills ?? 0) - scenarioData.total_bills) /
|
||||||
|
scenarioData.n_units_upgraded,
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="print-page">
|
||||||
|
<div className="print-root space-y-4">
|
||||||
|
<AutoPrint />
|
||||||
|
{/* ------------------------------------------------
|
||||||
|
Branded header
|
||||||
|
------------------------------------------------ */}
|
||||||
|
<header className="flex items-center gap-3 border-b pb-2">
|
||||||
|
<Image
|
||||||
|
src="/domna_logo_blue_transparent_background.png"
|
||||||
|
alt="Domna Logo"
|
||||||
|
width={140}
|
||||||
|
height={40}
|
||||||
|
/>
|
||||||
|
<div>
|
||||||
|
<h1 className="text-xl font-semibold">Retrofit Scenario Report</h1>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{/* ------------------------------------------------
|
||||||
|
Scenario Impact Summary
|
||||||
|
------------------------------------------------ */}
|
||||||
|
<SectionDivider
|
||||||
|
title="Scenario Impact Summary"
|
||||||
|
subtitle="Financial, carbon and energy outcomes"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ScenarioFinancialDrawer open={true} metrics={scenarioSpecific} />
|
||||||
|
|
||||||
|
{/* ------------------------------------------------
|
||||||
|
Portfolio Summary (baseline)
|
||||||
|
------------------------------------------------ */}
|
||||||
|
<div className="page-break" />
|
||||||
|
<SectionDivider
|
||||||
|
title="Portfolio Summary"
|
||||||
|
subtitle="Headline performance indicators"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<DashboardSummaryCards
|
||||||
|
total={baseline.total}
|
||||||
|
totals={baseline.totals}
|
||||||
|
averages={baseline.averages}
|
||||||
|
estimatedCounts={baseline.estimatedCounts}
|
||||||
|
scenarioOverlay={scenarioOverlay}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* ------------------------------------------------
|
||||||
|
EPC Quality (baseline)
|
||||||
|
------------------------------------------------ */}
|
||||||
|
<SectionDivider
|
||||||
|
title="EPC Quality"
|
||||||
|
subtitle="Condition, compliance and performance"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<EpcQualityCards
|
||||||
|
estimatedCounts={baseline.estimatedCounts}
|
||||||
|
total={baseline.total}
|
||||||
|
expiredEpcs={baseline.expiredEpcs}
|
||||||
|
likelyDowngrades={baseline.likelyDowngrades}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
83
src/app/portfolio/[slug]/(portfolio)/reporting/types.ts
Normal file
83
src/app/portfolio/[slug]/(portfolio)/reporting/types.ts
Normal 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;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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: {
|
||||||
|
|
|
||||||
|
|
@ -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 (
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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">
|
||||||
|
|
@ -27,7 +26,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);
|
||||||
|
|
|
||||||
|
|
@ -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
Loading…
Add table
Reference in a new issue