Merge pull request #219 from Hestia-Homes/feature/solar-map

Feature/solar map
This commit is contained in:
KhalimCK 2026-04-14 16:40:31 +01:00 committed by GitHub
commit 02282cbb90
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
58 changed files with 7666 additions and 3226 deletions

View file

@ -6,10 +6,14 @@ const nextConfig = {
protocol: "https",
hostname: "lh3.googleusercontent.com",
},
{
protocol: "https",
hostname: "images.unsplash.com",
},
],
},
allowedDevOrigins: ['local-origin.dev', '*.local-origin.dev'],
reactStrictMode: true,
reactStrictMode: false,
};
// use next-axiom for full stack monitoring

381
package-lock.json generated
View file

@ -32,6 +32,7 @@
"@radix-ui/react-tabs": "^1.1.13",
"@radix-ui/react-toast": "^1.2.2",
"@radix-ui/react-tooltip": "^1.0.7",
"@react-google-maps/api": "^2.20.8",
"@remixicon/react": "^4.2.0",
"@tanstack/match-sorter-utils": "^8.19.4",
"@tanstack/react-query": "^4.29.12",
@ -48,6 +49,7 @@
"esbuild": "^0.25.8",
"eslint-config-next": "13.4.3",
"framer-motion": "^12.23.24",
"geotiff": "^3.0.5",
"lucide-react": "^0.233.0",
"next": "^15.5.7",
"next-auth": "^4.22.1",
@ -60,6 +62,7 @@
"react-confetti": "^6.4.0",
"react-dom": "18.3.1",
"react-hook-form": "^7.53.2",
"recharts": "^3.8.1",
"tailwind-merge": "^1.13.2",
"tailwindcss": "^3.4.3",
"tailwindcss-animate": "^1.0.6",
@ -2300,6 +2303,22 @@
"integrity": "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==",
"license": "MIT"
},
"node_modules/@googlemaps/js-api-loader": {
"version": "1.16.8",
"resolved": "https://registry.npmjs.org/@googlemaps/js-api-loader/-/js-api-loader-1.16.8.tgz",
"integrity": "sha512-CROqqwfKotdO6EBjZO/gQGVTbeDps5V7Mt9+8+5Q+jTg5CRMi3Ii/L9PmV3USROrt2uWxtGzJHORmByxyo9pSQ==",
"license": "Apache-2.0"
},
"node_modules/@googlemaps/markerclusterer": {
"version": "2.5.3",
"resolved": "https://registry.npmjs.org/@googlemaps/markerclusterer/-/markerclusterer-2.5.3.tgz",
"integrity": "sha512-x7lX0R5yYOoiNectr10wLgCBasNcXFHiADIBdmn7jQllF2B5ENQw5XtZK+hIw4xnV0Df0xhN4LN98XqA5jaiOw==",
"license": "Apache-2.0",
"dependencies": {
"fast-deep-equal": "^3.1.3",
"supercluster": "^8.0.1"
}
},
"node_modules/@hapi/address": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/@hapi/address/-/address-5.1.1.tgz",
@ -3209,6 +3228,12 @@
"url": "https://github.com/sponsors/panva"
}
},
"node_modules/@petamoriken/float16": {
"version": "3.9.3",
"resolved": "https://registry.npmjs.org/@petamoriken/float16/-/float16-3.9.3.tgz",
"integrity": "sha512-8awtpHXCx/bNpFt4mt2xdkgtgVvKqty8VbjHI/WWWQuEw+KLzFot3f4+LkQY9YmOtq7A5GdOnqoIC8Pdygjk2g==",
"license": "MIT"
},
"node_modules/@puppeteer/browsers": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/@puppeteer/browsers/-/browsers-2.3.0.tgz",
@ -4460,6 +4485,36 @@
"node": ">=6"
}
},
"node_modules/@react-google-maps/api": {
"version": "2.20.8",
"resolved": "https://registry.npmjs.org/@react-google-maps/api/-/api-2.20.8.tgz",
"integrity": "sha512-wtLYFtCGXK3qbIz1H5to3JxbosPnKsvjDKhqGylXUb859EskhzR7OpuNt0LqdLarXUtZCJTKzPn3BNaekNIahg==",
"license": "MIT",
"dependencies": {
"@googlemaps/js-api-loader": "1.16.8",
"@googlemaps/markerclusterer": "2.5.3",
"@react-google-maps/infobox": "2.20.0",
"@react-google-maps/marker-clusterer": "2.20.0",
"@types/google.maps": "3.58.1",
"invariant": "2.2.4"
},
"peerDependencies": {
"react": "^16.8 || ^17 || ^18 || ^19",
"react-dom": "^16.8 || ^17 || ^18 || ^19"
}
},
"node_modules/@react-google-maps/infobox": {
"version": "2.20.0",
"resolved": "https://registry.npmjs.org/@react-google-maps/infobox/-/infobox-2.20.0.tgz",
"integrity": "sha512-03PJHjohhaVLkX6+NHhlr8CIlvUxWaXhryqDjyaZ8iIqqix/nV8GFdz9O3m5OsjtxtNho09F/15j14yV0nuyLQ==",
"license": "MIT"
},
"node_modules/@react-google-maps/marker-clusterer": {
"version": "2.20.0",
"resolved": "https://registry.npmjs.org/@react-google-maps/marker-clusterer/-/marker-clusterer-2.20.0.tgz",
"integrity": "sha512-tieX9Va5w1yP88vMgfH1pHTacDQ9TgDTjox3tLlisKDXRQWdjw+QeVVghhf5XqqIxXHgPdcGwBvKY6UP+SIvLw==",
"license": "MIT"
},
"node_modules/@react-stately/flags": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/@react-stately/flags/-/flags-3.1.2.tgz",
@ -4490,6 +4545,42 @@
"react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1"
}
},
"node_modules/@reduxjs/toolkit": {
"version": "2.11.2",
"resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.11.2.tgz",
"integrity": "sha512-Kd6kAHTA6/nUpp8mySPqj3en3dm0tdMIgbttnQ1xFMVpufoj+ADi8pXLBsd4xzTRHQa7t/Jv8W5UnCuW4kuWMQ==",
"license": "MIT",
"dependencies": {
"@standard-schema/spec": "^1.0.0",
"@standard-schema/utils": "^0.3.0",
"immer": "^11.0.0",
"redux": "^5.0.1",
"redux-thunk": "^3.1.0",
"reselect": "^5.1.0"
},
"peerDependencies": {
"react": "^16.9.0 || ^17.0.0 || ^18 || ^19",
"react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0"
},
"peerDependenciesMeta": {
"react": {
"optional": true
},
"react-redux": {
"optional": true
}
}
},
"node_modules/@reduxjs/toolkit/node_modules/immer": {
"version": "11.1.4",
"resolved": "https://registry.npmjs.org/immer/-/immer-11.1.4.tgz",
"integrity": "sha512-XREFCPo6ksxVzP4E0ekD5aMdf8WMwmdNaz6vuvxgI40UaEiu6q3p8X52aU6GdyvLY3XXX/8R7JOTXStz/nBbRw==",
"license": "MIT",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/immer"
}
},
"node_modules/@remixicon/react": {
"version": "4.8.0",
"resolved": "https://registry.npmjs.org/@remixicon/react/-/react-4.8.0.tgz",
@ -5247,7 +5338,12 @@
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
"integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
"dev": true,
"license": "MIT"
},
"node_modules/@standard-schema/utils": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz",
"integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==",
"license": "MIT"
},
"node_modules/@swc/helpers": {
@ -5524,6 +5620,50 @@
"react-dom": ">=16.8.0"
}
},
"node_modules/@tremor/react/node_modules/clsx": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
"integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/@tremor/react/node_modules/eventemitter3": {
"version": "4.0.7",
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz",
"integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==",
"license": "MIT"
},
"node_modules/@tremor/react/node_modules/react-is": {
"version": "18.3.1",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz",
"integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==",
"license": "MIT"
},
"node_modules/@tremor/react/node_modules/recharts": {
"version": "2.15.4",
"resolved": "https://registry.npmjs.org/recharts/-/recharts-2.15.4.tgz",
"integrity": "sha512-UT/q6fwS3c1dHbXv2uFgYJ9BMFHu3fwnd7AYZaEQhXuYQ4hgsxLvsUXzGdKeZrW5xopzDCvuA2N41WJ88I7zIw==",
"license": "MIT",
"dependencies": {
"clsx": "^2.0.0",
"eventemitter3": "^4.0.1",
"lodash": "^4.17.21",
"react-is": "^18.3.1",
"react-smooth": "^4.0.4",
"recharts-scale": "^0.4.4",
"tiny-invariant": "^1.3.1",
"victory-vendor": "^36.6.8"
},
"engines": {
"node": ">=14"
},
"peerDependencies": {
"react": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/@tremor/react/node_modules/tailwind-merge": {
"version": "2.6.0",
"resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-2.6.0.tgz",
@ -5534,6 +5674,28 @@
"url": "https://github.com/sponsors/dcastil"
}
},
"node_modules/@tremor/react/node_modules/victory-vendor": {
"version": "36.9.2",
"resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-36.9.2.tgz",
"integrity": "sha512-PnpQQMuxlwYdocC8fIJqVXvkeViHYzotI+NJrCuav0ZYFoq912ZHBk3mCeuj+5/VpodOjPe1z0Fk2ihgzlXqjQ==",
"license": "MIT AND ISC",
"dependencies": {
"@types/d3-array": "^3.0.3",
"@types/d3-ease": "^3.0.0",
"@types/d3-interpolate": "^3.0.1",
"@types/d3-scale": "^4.0.2",
"@types/d3-shape": "^3.1.0",
"@types/d3-time": "^3.0.0",
"@types/d3-timer": "^3.0.0",
"d3-array": "^3.1.6",
"d3-ease": "^3.0.1",
"d3-interpolate": "^3.0.1",
"d3-scale": "^4.0.2",
"d3-shape": "^3.1.0",
"d3-time": "^3.0.0",
"d3-timer": "^3.0.1"
}
},
"node_modules/@tybys/wasm-util": {
"version": "0.10.1",
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz",
@ -5614,6 +5776,12 @@
"integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==",
"license": "MIT"
},
"node_modules/@types/google.maps": {
"version": "3.58.1",
"resolved": "https://registry.npmjs.org/@types/google.maps/-/google.maps-3.58.1.tgz",
"integrity": "sha512-X9QTSvGJ0nCfMzYOnaVs/k6/4L+7F5uCS+4iUmkLEls6J9S/Phv+m/i3mDeyc49ZBgwab3EFO1HEoBY7k98EGQ==",
"license": "MIT"
},
"node_modules/@types/json5": {
"version": "0.0.29",
"resolved": "https://registry.npmjs.org/@types/json5/-/json5-0.0.29.tgz",
@ -5700,6 +5868,12 @@
"dev": true,
"license": "MIT"
},
"node_modules/@types/use-sync-external-store": {
"version": "0.0.6",
"resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz",
"integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==",
"license": "MIT"
},
"node_modules/@types/yauzl": {
"version": "2.10.3",
"resolved": "https://registry.npmjs.org/@types/yauzl/-/yauzl-2.10.3.tgz",
@ -8379,6 +8553,16 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/es-toolkit": {
"version": "1.45.1",
"resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.45.1.tgz",
"integrity": "sha512-/jhoOj/Fx+A+IIyDNOvO3TItGmlMKhtX8ISAHKE90c4b/k1tqaqEZ+uUqfpU8DMnW5cgNJv606zS55jGvza0Xw==",
"license": "MIT",
"workspaces": [
"docs",
"benchmarks"
]
},
"node_modules/es6-promise": {
"version": "4.2.8",
"resolved": "https://registry.npmjs.org/es6-promise/-/es6-promise-4.2.8.tgz",
@ -8952,9 +9136,9 @@
"license": "MIT"
},
"node_modules/eventemitter3": {
"version": "4.0.7",
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz",
"integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==",
"version": "5.0.4",
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz",
"integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==",
"license": "MIT"
},
"node_modules/events": {
@ -9437,6 +9621,25 @@
"node": ">= 0.4"
}
},
"node_modules/geotiff": {
"version": "3.0.5",
"resolved": "https://registry.npmjs.org/geotiff/-/geotiff-3.0.5.tgz",
"integrity": "sha512-OWcL9S9+yDZ6iAlXMt32T1iwUApJM8UiD47xbm6ZP1h33d10fqkPs14EG/ttT5EnefpZSx3G15iDFC5FxUNUwA==",
"license": "MIT",
"dependencies": {
"@petamoriken/float16": "^3.9.3",
"lerc": "^3.0.0",
"pako": "^2.0.4",
"parse-headers": "^2.0.2",
"quick-lru": "^6.1.1",
"web-worker": "^1.5.0",
"xml-utils": "^1.10.2",
"zstddec": "^0.2.0"
},
"engines": {
"node": ">=10.19"
}
},
"node_modules/get-caller-file": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
@ -9880,6 +10083,16 @@
"node": ">= 4"
}
},
"node_modules/immer": {
"version": "10.2.0",
"resolved": "https://registry.npmjs.org/immer/-/immer-10.2.0.tgz",
"integrity": "sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==",
"license": "MIT",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/immer"
}
},
"node_modules/import-fresh": {
"version": "3.3.1",
"resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz",
@ -9965,6 +10178,15 @@
"node": ">=12"
}
},
"node_modules/invariant": {
"version": "2.2.4",
"resolved": "https://registry.npmjs.org/invariant/-/invariant-2.2.4.tgz",
"integrity": "sha512-phJfQVBuaJM5raOpJjSfkiD6BpbCE4Ns//LaXl6wGYtUBY83nWS6Rf9tXm2e8VaK60JEjYldbPif/A2B1C2gNA==",
"license": "MIT",
"dependencies": {
"loose-envify": "^1.0.0"
}
},
"node_modules/ip-address": {
"version": "10.1.0",
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz",
@ -10657,6 +10879,12 @@
"node": ">=4.0"
}
},
"node_modules/kdbush": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/kdbush/-/kdbush-4.0.2.tgz",
"integrity": "sha512-WbCVYJ27Sz8zi9Q7Q0xHC+05iwkm3Znipc2XTlrnJbsHMYktW4hPhXUE8Ys1engBrvffoSCqbil1JQAa7clRpA==",
"license": "ISC"
},
"node_modules/keyv": {
"version": "4.5.4",
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
@ -10694,6 +10922,12 @@
"node": "> 0.8"
}
},
"node_modules/lerc": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/lerc/-/lerc-3.0.0.tgz",
"integrity": "sha512-Rm4J/WaHhRa93nCN2mwWDZFoRVF18G1f47C+kvQWyHGEZxFpTUi73p7lMVSAndyxGt6lJ2/CFbOcf9ra5p8aww==",
"license": "Apache-2.0"
},
"node_modules/levn": {
"version": "0.4.1",
"resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz",
@ -11629,6 +11863,12 @@
"node": ">= 14"
}
},
"node_modules/pako": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/pako/-/pako-2.1.0.tgz",
"integrity": "sha512-w+eufiZ1WuJYgPXbV/PO3NCMEc3xqylkKHzp8bxp1uW4qaSNQUkwmLLEc3kKsfz8lpV1F8Ht3U1Cm+9Srog2ug==",
"license": "(MIT AND Zlib)"
},
"node_modules/parent-module": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
@ -11641,6 +11881,12 @@
"node": ">=6"
}
},
"node_modules/parse-headers": {
"version": "2.0.6",
"resolved": "https://registry.npmjs.org/parse-headers/-/parse-headers-2.0.6.tgz",
"integrity": "sha512-Tz11t3uKztEW5FEVZnj1ox8GKblWn+PvHY9TmJV5Mll2uHEwRdR/5Li1OlXoECjLYkApdhWy44ocONwXLiKO5A==",
"license": "MIT"
},
"node_modules/parse-json": {
"version": "5.2.0",
"resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz",
@ -12352,6 +12598,18 @@
],
"license": "MIT"
},
"node_modules/quick-lru": {
"version": "6.1.2",
"resolved": "https://registry.npmjs.org/quick-lru/-/quick-lru-6.1.2.tgz",
"integrity": "sha512-AAFUA5O1d83pIHEhJwWCq/RQcRukCkn/NSm2QsTEMle5f2hP0ChI2+3Xb051PZCkLryI/Ir1MVKviT2FIloaTQ==",
"license": "MIT",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/react": {
"version": "18.3.1",
"resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
@ -12426,9 +12684,31 @@
"version": "17.0.2",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz",
"integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==",
"dev": true,
"license": "MIT"
},
"node_modules/react-redux": {
"version": "9.2.0",
"resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz",
"integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==",
"license": "MIT",
"dependencies": {
"@types/use-sync-external-store": "^0.0.6",
"use-sync-external-store": "^1.4.0"
},
"peerDependencies": {
"@types/react": "^18.2.25 || ^19",
"react": "^18.0 || ^19",
"redux": "^5.0.0"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"redux": {
"optional": true
}
}
},
"node_modules/react-remove-scroll": {
"version": "2.7.2",
"resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.7.2.tgz",
@ -12561,26 +12841,33 @@
}
},
"node_modules/recharts": {
"version": "2.15.4",
"resolved": "https://registry.npmjs.org/recharts/-/recharts-2.15.4.tgz",
"integrity": "sha512-UT/q6fwS3c1dHbXv2uFgYJ9BMFHu3fwnd7AYZaEQhXuYQ4hgsxLvsUXzGdKeZrW5xopzDCvuA2N41WJ88I7zIw==",
"version": "3.8.1",
"resolved": "https://registry.npmjs.org/recharts/-/recharts-3.8.1.tgz",
"integrity": "sha512-mwzmO1s9sFL0TduUpwndxCUNoXsBw3u3E/0+A+cLcrSfQitSG62L32N69GhqUrrT5qKcAE3pCGVINC6pqkBBQg==",
"license": "MIT",
"workspaces": [
"www"
],
"dependencies": {
"clsx": "^2.0.0",
"eventemitter3": "^4.0.1",
"lodash": "^4.17.21",
"react-is": "^18.3.1",
"react-smooth": "^4.0.4",
"recharts-scale": "^0.4.4",
"tiny-invariant": "^1.3.1",
"victory-vendor": "^36.6.8"
"@reduxjs/toolkit": "^1.9.0 || 2.x.x",
"clsx": "^2.1.1",
"decimal.js-light": "^2.5.1",
"es-toolkit": "^1.39.3",
"eventemitter3": "^5.0.1",
"immer": "^10.1.1",
"react-redux": "8.x.x || 9.x.x",
"reselect": "5.1.1",
"tiny-invariant": "^1.3.3",
"use-sync-external-store": "^1.2.2",
"victory-vendor": "^37.0.2"
},
"engines": {
"node": ">=14"
"node": ">=18"
},
"peerDependencies": {
"react": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react-is": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/recharts-scale": {
@ -12601,12 +12888,21 @@
"node": ">=6"
}
},
"node_modules/recharts/node_modules/react-is": {
"version": "18.3.1",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz",
"integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==",
"node_modules/redux": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz",
"integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==",
"license": "MIT"
},
"node_modules/redux-thunk": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-3.1.0.tgz",
"integrity": "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==",
"license": "MIT",
"peerDependencies": {
"redux": "^5.0.0"
}
},
"node_modules/reflect.getprototypeof": {
"version": "1.0.10",
"resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz",
@ -12675,6 +12971,12 @@
"node": ">=0.10.0"
}
},
"node_modules/reselect": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/reselect/-/reselect-5.1.1.tgz",
"integrity": "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==",
"license": "MIT"
},
"node_modules/resolve": {
"version": "1.22.11",
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz",
@ -13609,6 +13911,15 @@
"node": ">= 6"
}
},
"node_modules/supercluster": {
"version": "8.0.1",
"resolved": "https://registry.npmjs.org/supercluster/-/supercluster-8.0.1.tgz",
"integrity": "sha512-IiOea5kJ9iqzD2t7QJq/cREyLHTtSmUT6gQsweojg9WH2sYJqZK9SswTu6jrscO6D1G5v5vYZ9ru/eq85lXeZQ==",
"license": "ISC",
"dependencies": {
"kdbush": "^4.0.2"
}
},
"node_modules/supports-color": {
"version": "8.1.1",
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz",
@ -14905,9 +15216,9 @@
}
},
"node_modules/victory-vendor": {
"version": "36.9.2",
"resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-36.9.2.tgz",
"integrity": "sha512-PnpQQMuxlwYdocC8fIJqVXvkeViHYzotI+NJrCuav0ZYFoq912ZHBk3mCeuj+5/VpodOjPe1z0Fk2ihgzlXqjQ==",
"version": "37.3.6",
"resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-37.3.6.tgz",
"integrity": "sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==",
"license": "MIT AND ISC",
"dependencies": {
"@types/d3-array": "^3.0.3",
@ -14946,6 +15257,12 @@
"node": ">=20.0.0"
}
},
"node_modules/web-worker": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/web-worker/-/web-worker-1.5.0.tgz",
"integrity": "sha512-RiMReJrTAiA+mBjGONMnjVDP2u3p9R1vkcGz6gDIrOMT3oGuYwX2WRMYI9ipkphSuE5XKEhydbhNEJh4NY9mlw==",
"license": "Apache-2.0"
},
"node_modules/webidl-conversions": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz",
@ -15168,6 +15485,12 @@
"node": ">=0.8"
}
},
"node_modules/xml-utils": {
"version": "1.10.2",
"resolved": "https://registry.npmjs.org/xml-utils/-/xml-utils-1.10.2.tgz",
"integrity": "sha512-RqM+2o1RYs6T8+3DzDSoTRAUfrvaejbVHcp3+thnAtDKo8LskR+HomLajEy5UjTz24rpka7AxVBRR3g2wTUkJA==",
"license": "CC0-1.0"
},
"node_modules/xml2js": {
"version": "0.6.2",
"resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.6.2.tgz",
@ -15275,6 +15598,12 @@
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}
},
"node_modules/zstddec": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/zstddec/-/zstddec-0.2.0.tgz",
"integrity": "sha512-oyPnDa1X5c13+Y7mA/FDMNJrn4S8UNBe0KCqtDmor40Re7ALrPN6npFwyYVRRh+PqozZQdeg23QtbcamZnG5rA==",
"license": "MIT AND BSD-3-Clause"
}
}
}

View file

@ -38,6 +38,7 @@
"@radix-ui/react-tabs": "^1.1.13",
"@radix-ui/react-toast": "^1.2.2",
"@radix-ui/react-tooltip": "^1.0.7",
"@react-google-maps/api": "^2.20.8",
"@remixicon/react": "^4.2.0",
"@tanstack/match-sorter-utils": "^8.19.4",
"@tanstack/react-query": "^4.29.12",
@ -54,6 +55,7 @@
"esbuild": "^0.25.8",
"eslint-config-next": "13.4.3",
"framer-motion": "^12.23.24",
"geotiff": "^3.0.5",
"lucide-react": "^0.233.0",
"next": "^15.5.7",
"next-auth": "^4.22.1",
@ -66,6 +68,7 @@
"react-confetti": "^6.4.0",
"react-dom": "18.3.1",
"react-hook-form": "^7.53.2",
"recharts": "^3.8.1",
"tailwind-merge": "^1.13.2",
"tailwindcss": "^3.4.3",
"tailwindcss-animate": "^1.0.6",

View file

@ -0,0 +1,235 @@
"use server";
import { db } from "@/app/db/db";
import {
recommendation,
planRecommendations,
plan,
} from "@/app/db/schema/recommendations";
import { eq, inArray, and } from "drizzle-orm";
import { revalidatePath } from "next/cache";
// Maps specific recommendation types to their parent category.
// Mirrors the categorisation in RecommendationContainer.
const typeToCategoryMap: Record<string, string> = {
internal_wall_insulation: "wall_insulation",
external_wall_insulation: "wall_insulation",
cavity_wall_insulation: "wall_insulation",
loft_insulation: "roof_insulation",
room_roof_insulation: "roof_insulation",
flat_roof_insulation: "roof_insulation",
suspended_floor_insulation: "floor_insulation",
solid_floor_insulation: "floor_insulation",
exposed_floor_insulation: "floor_insulation",
};
function getCategoryTypes(type: string): string[] {
const category = typeToCategoryMap[type] ?? type;
const types = Object.entries(typeToCategoryMap)
.filter(([, cat]) => cat === category)
.map(([t]) => t);
if (!types.includes(category)) types.push(category);
return types;
}
// Per-measure contingency rates (fractional, e.g. 0.26 = 26%)
const CONTINGENCIES: Record<string, number> = {
cavity_wall_insulation: 0.1,
internal_wall_insulation: 0.26,
external_wall_insulation: 0.26,
loft_insulation: 0.1,
solar_pv: 0.15,
air_source_heat_pump: 0.25,
flat_roof_insulation: 0.26,
suspended_floor_insulation: 0.2,
solid_floor_insulation: 0.26,
low_energy_lighting: 0.26,
high_heat_retention_storage_heaters: 0.1,
windows_glazing: 0.15,
boiler_upgrade: 0.26,
time_and_temperature_zone_control: 0.1,
roomstat_programmer_trvs: 0.1,
room_roof_insulation: 0.26,
heater_removal: 0.1,
sealing_open_fireplace: 0.1,
mechanical_ventilation: 0.26,
sloping_ceiling_insulation: 0.26,
};
// Local SAP → EPC letter mapping (mirrors sapToEpc in @/app/utils)
function sapToEpcLetter(sapPoints: number): string {
if (sapPoints >= 92) return "A";
if (sapPoints >= 81) return "B";
if (sapPoints >= 69) return "C";
if (sapPoints >= 55) return "D";
if (sapPoints >= 39) return "E";
if (sapPoints >= 21) return "F";
return "G";
}
/**
* Sets a recommendation as the default for its category within a plan.
* Clears the default flag from all other recommendations in the same category,
* then sets it on the selected recommendation.
*/
export async function setDefaultRecommendation(
planId: string,
selectedRecId: string,
slug: string,
propertyId: string,
options?: { skipRevalidate?: boolean },
) {
const planRecs = await db
.select({ recId: planRecommendations.recommendationId })
.from(planRecommendations)
.where(eq(planRecommendations.planId, BigInt(planId)));
const recIds = planRecs.map((r) => r.recId);
if (recIds.length === 0) return;
const [selectedRec] = await db
.select({ type: recommendation.type })
.from(recommendation)
.where(eq(recommendation.id, BigInt(selectedRecId)));
if (!selectedRec) return;
const categoryTypes = getCategoryTypes(selectedRec.type);
await db.transaction(async (tx) => {
await tx
.update(recommendation)
.set({ default: false })
.where(
and(
inArray(recommendation.id, recIds),
inArray(recommendation.type, categoryTypes),
),
);
await tx
.update(recommendation)
.set({ default: true })
.where(eq(recommendation.id, BigInt(selectedRecId)));
});
if (!options?.skipRevalidate) {
revalidatePath(
`/portfolio/${slug}/building-passport/${propertyId}/plans/${planId}`,
);
}
}
/**
* Clears the default flag from every recommendation in a category for this plan.
* Used to remove a measure entirely from the plan.
*/
export async function clearCategoryDefault(
planId: string,
categoryType: string,
slug: string,
propertyId: string,
options?: { skipRevalidate?: boolean },
) {
const planRecs = await db
.select({ recId: planRecommendations.recommendationId })
.from(planRecommendations)
.where(eq(planRecommendations.planId, BigInt(planId)));
const recIds = planRecs.map((r) => r.recId);
if (recIds.length === 0) return;
const categoryTypes = getCategoryTypes(categoryType);
await db
.update(recommendation)
.set({ default: false })
.where(
and(
inArray(recommendation.id, recIds),
inArray(recommendation.type, categoryTypes),
),
);
if (!options?.skipRevalidate) {
revalidatePath(
`/portfolio/${slug}/building-passport/${propertyId}/plans/${planId}`,
);
}
}
/**
* Recalculates and persists plan-level metrics based on the current set of
* default recommendations. Contingency is calculated per-measure using
* measure-specific rates.
*/
export async function updatePlanMetrics(
planId: string,
currentSapPoints: number,
slug: string,
propertyId: string,
) {
const planRecs = await db
.select({ recId: planRecommendations.recommendationId })
.from(planRecommendations)
.where(eq(planRecommendations.planId, BigInt(planId)));
const recIds = planRecs.map((r) => r.recId);
const defaultRecs =
recIds.length > 0
? await db
.select()
.from(recommendation)
.where(
and(
inArray(recommendation.id, recIds),
eq(recommendation.default, true),
),
)
: [];
const costOfWorks = defaultRecs.reduce(
(s, r) => s + (r.estimatedCost ?? 0),
0,
);
const contingencyCost = defaultRecs.reduce((s, r) => {
const rate = CONTINGENCIES[r.type] ?? 0.125;
return s + (r.estimatedCost ?? 0) * rate;
}, 0);
const co2Savings = defaultRecs.reduce(
(s, r) => s + (r.co2EquivalentSavings ?? 0),
0,
);
const energyBillSavings = defaultRecs.reduce(
(s, r) => s + (r.energyCostSavings ?? 0),
0,
);
const sapPointsGain = defaultRecs.reduce(
(s, r) => s + (r.sapPoints ?? 0),
0,
);
const postSapPoints = currentSapPoints + sapPointsGain;
const postEpcRating = sapToEpcLetter(postSapPoints) as
| "A"
| "B"
| "C"
| "D"
| "E"
| "F"
| "G";
await db
.update(plan)
.set({
costOfWorks,
contingencyCost,
co2Savings,
energyBillSavings,
postSapPoints,
postEpcRating,
})
.where(eq(plan.id, BigInt(planId)));
revalidatePath(
`/portfolio/${slug}/building-passport/${propertyId}/plans/${planId}`,
);
}

View file

@ -0,0 +1,39 @@
import { NextResponse } from "next/server";
import { db } from "@/app/db/db";
import { plan } from "@/app/db/schema/recommendations";
import { eq } from "drizzle-orm";
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 target = await db.query.plan.findFirst({
where: eq(plan.id, BigInt(planId)),
columns: { propertyId: true },
});
if (!target) {
return NextResponse.json({ error: "Plan not found" }, { status: 404 });
}
await db.transaction(async (tx) => {
await tx
.update(plan)
.set({ isDefault: false })
.where(eq(plan.propertyId, target.propertyId));
await tx
.update(plan)
.set({ isDefault: true })
.where(eq(plan.id, BigInt(planId)));
});
return NextResponse.json({ success: true });
}

View file

@ -1,12 +1,16 @@
import { NextRequest, NextResponse } from "next/server";
import { getProperties } from "@/app/portfolio/[slug]/utils";
import { PropertyFilter } from "@/app/utils/propertyFilters";
import { getProperties, getPropertiesCount } from "@/app/portfolio/[slug]/utils";
import { FilterGroups } from "@/app/utils/propertyFilters";
const DEFAULT_LIMIT = 1000;
export async function POST(req: NextRequest) {
const body = await req.json();
const portfolioId = body.portfolioId;
const filters: PropertyFilter[] = body.filters ?? [];
const filterGroups: FilterGroups = body.filters ?? [];
const limit: number = body.limit ?? DEFAULT_LIMIT;
const offset: number = body.offset ?? 0;
if (!portfolioId) {
return NextResponse.json(
@ -14,12 +18,12 @@ export async function POST(req: NextRequest) {
{ status: 400 }
);
}
console.log("filters", filters);
const properties = await getProperties(
portfolioId,
1000,
0,
filters
);
return NextResponse.json(properties);
}
// Only compute the total count on the first page — it's expensive and doesn't change
const [data, total] = await Promise.all([
getProperties(portfolioId, limit, offset, filterGroups),
offset === 0 ? getPropertiesCount(portfolioId, filterGroups) : Promise.resolve(null),
]);
return NextResponse.json({ data, total });
}

View file

@ -0,0 +1,110 @@
"use client";
import { formatNumber } from "@/app/utils";
import {
Drawer,
DrawerContent,
DrawerHeader,
DrawerTitle,
} from "@/app/shadcn_components/ui/drawer";
import { CheckIcon } from "@heroicons/react/24/outline";
import type { AugmentedRec } from "./recommendation-types";
interface AlternativesDrawerProps {
isOpen: boolean;
onClose: () => void;
categoryLabel: string;
recs: AugmentedRec[];
selected: AugmentedRec | null;
displaySelected: AugmentedRec | null;
onSelect: (rec: AugmentedRec) => void;
isPending: boolean;
}
export default function AlternativesDrawer({
isOpen,
onClose,
categoryLabel,
recs,
displaySelected,
onSelect,
isPending,
}: AlternativesDrawerProps) {
const alternatives = recs.filter((r) => !r.alreadyInstalled);
return (
<Drawer open={isOpen} onOpenChange={(open) => !open && onClose()}>
<DrawerContent className="max-h-[80vh]">
<DrawerHeader className="px-6 pt-4 pb-3 border-b border-gray-100">
<DrawerTitle className="font-manrope text-xl font-bold text-brandblue">
{categoryLabel}
</DrawerTitle>
<p className="text-sm text-gray-400 mt-0.5">
{alternatives.length} option
{alternatives.length !== 1 ? "s" : ""} available select to update
the plan
</p>
</DrawerHeader>
<div className="overflow-y-auto px-6 py-4 space-y-2 pb-10">
{alternatives.map((rec) => {
const isSelected = displaySelected?.id === rec.id;
return (
<button
key={String(rec.id)}
disabled={isPending}
onClick={() => {
onSelect(rec);
onClose();
}}
className={`w-full text-left p-5 rounded-xl border transition-colors ${
isSelected
? "border-brandblue bg-brandblue/5"
: "border-gray-100 bg-white hover:bg-gray-50"
} disabled:opacity-50`}
>
<div className="flex items-start justify-between gap-4">
<div className="flex items-start gap-3 flex-1 min-w-0">
<div
className={`mt-0.5 w-5 h-5 rounded-full border-2 flex items-center justify-center flex-shrink-0 transition-colors ${
isSelected
? "border-brandblue bg-brandblue"
: "border-gray-300"
}`}
>
{isSelected && (
<CheckIcon className="w-3 h-3 text-white" />
)}
</div>
<div className="min-w-0">
<p className="font-semibold text-brandblue text-sm leading-snug">
{rec.description}
</p>
<div className="flex items-center gap-3 mt-1.5">
{rec.sapPoints !== null && rec.sapPoints > 0 && (
<span className="text-xs text-gray-400 font-medium">
SAP +{rec.sapPoints.toFixed(1)}
</span>
)}
{rec.energyCostSavings !== null &&
rec.energyCostSavings > 0 && (
<span className="text-xs text-gray-400 font-medium">
£{formatNumber(rec.energyCostSavings)}/yr saved
</span>
)}
</div>
</div>
</div>
<div className="text-right flex-shrink-0">
<p className="font-manrope font-bold text-brandblue">
£{formatNumber(rec.estimatedCost ?? 0)}
</p>
</div>
</div>
</button>
);
})}
</div>
</DrawerContent>
</Drawer>
);
}

View file

@ -1,232 +1,408 @@
"use client";
import {
Recommendation,
RecommendationType,
} from "@/app/db/schema/recommendations";
import { Dispatch, SetStateAction, useState } from "react";
import { useState } from "react";
import { RecommendationType } from "@/app/db/schema/recommendations";
import { formatNumber } from "@/app/utils";
import { RecommendationMetricMap } from "@/types/recommendations";
import RecommendationModal from "./RecommendationModal";
import {
ChevronDownIcon,
BuildingOfficeIcon,
HomeModernIcon,
Square3Stack3DIcon,
WindowIcon,
SunIcon,
FireIcon,
BeakerIcon,
ArrowPathIcon,
AdjustmentsHorizontalIcon,
LightBulbIcon,
WrenchScrewdriverIcon,
ArrowsRightLeftIcon,
} from "@heroicons/react/24/outline";
import AlternativesDrawer from "./AlternativesDrawer";
import type { AugmentedRec } from "./recommendation-types";
const selectionStyling =
"shadow active:shadow active:bg-brandmidblue w-full border rounded p-4 cursor-pointer text-gray-900 bg-gray-100 hover:bg-hoverblue hover:text-gray-100 transition-colors rounded-md flex flex-col justify-start";
const noSelectionStyling =
"shadow active:shadow active:bg-brandmidblue w-full border rounded p-4 cursor-pointer text-gray-300 bg-white hover:bg-hoverblue hover:text-gray-100 transition-colors rounded-md flex flex-col justify-start";
const alreadyInstalledStyling =
"shadow active:shadow w-full border rounded p-4 cursor-pointer text-gray-900 bg-gray-100transition-colors rounded-md flex flex-col justify-start";
export type { AugmentedRec } from "./recommendation-types";
const TitleMap = {
const TitleMap: Record<string, string> = {
mechanical_ventilation: "Mechanical Ventilation",
trickle_vents: "Trickle Vents",
sealing_open_fireplace: "Sealing Open Fireplace",
low_energy_lighting: "Low Energy Lighting",
// Walls
internal_wall_insulation: "Internal Wall Insulation",
external_wall_insulation: "External Wall Insulation",
cavity_wall_insulation: "Cavity Wall Insulation",
extension_cavity_wall_insulation: "Extension Cavity Wall Insulation",
// Roof
loft_insulation: "Loft Insulation",
room_roof_insulation: "Room Roof Insulation",
flat_roof_insulation: "Flat Roof Insulation",
sloping_ceiling_insulation: "Sloping Ceiling Insulation",
// Floor
solid_floor_insulation: "Solid Floor Insulation",
suspended_floor_insulation: "Suspended Floor Insulation",
exposed_floor_insulation: "Exposed Floor Insulation",
// Windows
windows_glazing: "Window Glazing",
mixed_glazing: "Mixed - Secondary and Double Glazing",
// Solar pv
solar_pv: "Solar Photovoltaic Panels System",
// Heating
heating: "Heating Systems",
mixed_glazing: "Mixed Glazing",
solar_pv: "Solar Photovoltaic Panels",
heating: "Heating System",
heating_control: "Heating Controls",
secondary_heating: "Secondary Heating System",
// Hot water tank
secondary_heating: "Secondary Heating",
hot_water_tank_insulation: "Hot Water Tank Insulation",
// Default options when no recommendation is selected
wall_insulation: "Wall Insulation",
floor_insulation: "Floor Insulation",
roof_insulation: "Roof Insulation",
// Cylinder thermostat
cylinder_thermostat: "Cylinder Thermostat",
// Draught proofing
draught_proofing: "Draught Proofing",
};
type RecommendationCardProps = {
componentType: RecommendationType;
recommendationData: Recommendation[];
setCostMap: Dispatch<SetStateAction<RecommendationMetricMap>>;
costMap: RecommendationMetricMap;
setTotalEstimatedCost: Dispatch<SetStateAction<number>>;
sapMap: RecommendationMetricMap;
setSapMap: Dispatch<SetStateAction<RecommendationMetricMap>>;
setTotalSapPoints: Dispatch<SetStateAction<number>>;
currentSapPoints: number;
setExpectedEpcRating: Dispatch<SetStateAction<string>>;
setTotalLabourDays: Dispatch<SetStateAction<number>>;
labourDaysMap: RecommendationMetricMap;
setLabourDaysMap: Dispatch<SetStateAction<RecommendationMetricMap>>;
setCo2SavingsMap: Dispatch<SetStateAction<RecommendationMetricMap>>;
co2SavingsMap: RecommendationMetricMap;
setTotalCo2Savings: Dispatch<SetStateAction<number>>;
setEnergyCostSavingsMap: Dispatch<SetStateAction<RecommendationMetricMap>>;
energyCostSavingsMap: RecommendationMetricMap;
setTotalEnergyCostSavings: Dispatch<SetStateAction<number>>;
setKwhSavingsMap: Dispatch<SetStateAction<RecommendationMetricMap>>;
kwhSavingsMap: RecommendationMetricMap;
setTotalKwhSavings: Dispatch<SetStateAction<number>>;
// All categories use a consistent cool blue accent
function getCategoryAccentColor(_category: string): string {
return "#2563eb";
}
function CategoryIcon({ category, color }: { category: string; color: string }) {
const cls = "w-6 h-6";
const style = { color };
switch (category) {
case "wall_insulation":
case "internal_wall_insulation":
case "external_wall_insulation":
case "cavity_wall_insulation":
case "extension_cavity_wall_insulation":
return <BuildingOfficeIcon className={cls} style={style} />;
case "roof_insulation":
case "loft_insulation":
case "room_roof_insulation":
case "flat_roof_insulation":
case "sloping_ceiling_insulation":
return <HomeModernIcon className={cls} style={style} />;
case "floor_insulation":
case "suspended_floor_insulation":
case "solid_floor_insulation":
case "exposed_floor_insulation":
return <Square3Stack3DIcon className={cls} style={style} />;
case "windows_glazing":
case "mixed_glazing":
return <WindowIcon className={cls} style={style} />;
case "solar_pv":
return <SunIcon className={cls} style={style} />;
case "heating":
case "secondary_heating":
case "sealing_open_fireplace":
return <FireIcon className={cls} style={style} />;
case "hot_water_tank_insulation":
return <BeakerIcon className={cls} style={style} />;
case "mechanical_ventilation":
case "trickle_vents":
return <ArrowPathIcon className={cls} style={style} />;
case "heating_control":
case "cylinder_thermostat":
return <AdjustmentsHorizontalIcon className={cls} style={style} />;
case "low_energy_lighting":
return <LightBulbIcon className={cls} style={style} />;
default:
return <WrenchScrewdriverIcon className={cls} style={style} />;
}
}
type Props = {
category: RecommendationType;
recs: AugmentedRec[];
selected: AugmentedRec | null;
pendingSelection: AugmentedRec | null;
hasPendingChange: boolean;
isIncluded: boolean;
isPending: boolean;
onSelect: (rec: AugmentedRec) => void;
onRemove: () => void;
};
export default function RecommendationCard({
componentType,
recommendationData,
setCostMap,
costMap,
setTotalEstimatedCost,
sapMap,
setSapMap,
setTotalSapPoints,
currentSapPoints,
setExpectedEpcRating,
setTotalLabourDays,
labourDaysMap,
setLabourDaysMap,
setCo2SavingsMap,
co2SavingsMap,
setTotalCo2Savings,
setEnergyCostSavingsMap,
energyCostSavingsMap,
setTotalEnergyCostSavings,
setKwhSavingsMap,
kwhSavingsMap,
setTotalKwhSavings,
}: RecommendationCardProps) {
const defaultComponent = recommendationData.find(
(rec: Recommendation) => rec.default,
) as Recommendation;
category,
recs,
selected,
pendingSelection,
hasPendingChange,
isIncluded,
isPending,
onSelect,
onRemove,
}: Props) {
const [isOpen, setIsOpen] = useState(false);
const [isDrawerOpen, setIsDrawerOpen] = useState(false);
// A recommendation type could have no default recommendation, so we need to check if it exists
const alreadyInstalled = recommendationData.some(
(rec) => rec.alreadyInstalled,
const alreadyInstalled = recs.some((r) => r.alreadyInstalled);
const title = TitleMap[category] ?? category.replace(/_/g, " ");
const isInteractive = !alreadyInstalled;
const accentColor = getCategoryAccentColor(category);
// Display pending selection in header chips if there's a pending change
const displayRec = hasPendingChange ? pendingSelection : selected;
// Display pending selection in the expanded grid too
const displaySelected = hasPendingChange ? pendingSelection : selected;
const alternatives = recs.filter(
(r) => !r.alreadyInstalled && r.id !== displaySelected?.id,
);
const [cardComponent, setCardComponent] =
useState<Recommendation>(defaultComponent);
const [modalIsOpen, setModalIsOpen] = useState(false);
const getTitle = () => {
if (!cardComponent) {
return TitleMap[componentType];
}
const recommendationType = cardComponent.type as RecommendationType;
return TitleMap[recommendationType];
};
// Determine the className based on alreadyInstalled or cardComponent existence
const cardClassName = alreadyInstalled
? alreadyInstalledStyling
: cardComponent
? selectionStyling
: noSelectionStyling;
const optionTextClassName = alreadyInstalled
? "text-brandgold"
: "text-brandbrown hover:text-blue-300";
const optionsText = alreadyInstalled
? "Already installed"
: cardComponent
? "Click for more options"
: "Click to select";
const openModal = () => {
// If the card is already installed, we don't want to open the modal
if (alreadyInstalled) {
return;
}
setModalIsOpen(true);
};
// If the measure is already installed, we change the font colour to gold
const titleClassName = alreadyInstalled
? "text-brandgold font-bold mb-4 text-lg"
: "font-bold mb-4 text-lg";
const hasMore = recs.filter((r) => !r.alreadyInstalled).length > 3;
return (
<div className={cardClassName} onClick={openModal}>
<h2 className={titleClassName}>{getTitle()}</h2>
<div className="mb-3">
{cardComponent ? (
cardComponent.description
) : (
<div className="text-red-500">No measure selected</div>
<div
className={`rounded-xl overflow-hidden bg-white shadow-[0_8px_30px_rgb(0,0,0,0.06)] hover:shadow-[0_12px_40px_rgb(0,0,0,0.10)] transition-all border ${
hasPendingChange ? "border-amber-300" : "border-gray-100"
} ${!isIncluded && !alreadyInstalled ? "opacity-60" : ""}`}
>
<div className="p-8">
{/* ── Card header ─────────────────────────────────────────── */}
<div className="flex flex-col md:flex-row justify-between items-start md:items-center gap-6 mb-6">
{/* Left: circular icon + title block */}
<div className="flex items-center gap-5 flex-1 min-w-0">
{/* Circular icon */}
<div
className={`w-14 h-14 rounded-full flex items-center justify-center shrink-0 ${alreadyInstalled ? "bg-amber-50" : ""}`}
style={
!alreadyInstalled
? { backgroundColor: `${accentColor}18` }
: undefined
}
>
{alreadyInstalled ? (
<CategoryIcon category={category} color="#d97706" />
) : (
<CategoryIcon category={category} color={accentColor} />
)}
</div>
<div className="min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<h4 className="text-xl font-bold font-manrope text-brandblue leading-snug">
{title}
</h4>
{!isIncluded && !alreadyInstalled && (
<span className="text-[10px] px-2 py-0.5 rounded-full bg-gray-100 text-gray-400 font-bold uppercase tracking-wide">
Not included
</span>
)}
</div>
{displayRec && !alreadyInstalled && (
<p className="text-sm text-gray-500 font-semibold mt-0.5 truncate">
{displayRec.description}
</p>
)}
{!displayRec && !alreadyInstalled && (
<p className="text-sm text-gray-400 mt-0.5">
No measure selected
</p>
)}
{alreadyInstalled && (
<span className="mt-1 inline-block px-2.5 py-0.5 rounded-full text-xs font-bold bg-amber-100 text-amber-700">
Already Installed
</span>
)}
</div>
</div>
{/* Right: stat chips + status indicators */}
<div className="flex items-center gap-3 shrink-0 flex-wrap justify-end">
{hasPendingChange && !isPending && (
<span className="text-[10px] font-bold text-amber-600 flex items-center gap-1">
<span className="w-1.5 h-1.5 rounded-full bg-amber-400 animate-pulse inline-block" />
Pending
</span>
)}
{isPending && (
<span className="text-xs font-bold text-gray-400 flex items-center gap-1">
<ArrowPathIcon className="w-4 h-4 animate-spin" />
Saving
</span>
)}
{displayRec?.sapPoints != null && displayRec.sapPoints > 0 && (
<div className="px-3 py-2 bg-gray-50 rounded-lg text-center border border-gray-100 min-w-[72px]">
<p className="text-[9px] font-bold uppercase tracking-widest mb-0.5 text-brandmidblue">
SAP Boost
</p>
<p className="text-sm font-black text-brandblue font-manrope">
+{displayRec.sapPoints.toFixed(1)}
</p>
</div>
)}
{displayRec?.energyCostSavings != null &&
displayRec.energyCostSavings > 0 && (
<div className="px-3 py-2 bg-gray-50 rounded-lg text-center border border-gray-100 min-w-[72px]">
<p className="text-[9px] font-bold text-brandmidblue uppercase tracking-widest mb-0.5">
Savings
</p>
<p className="text-sm font-black text-brandblue font-manrope">
£{formatNumber(displayRec.energyCostSavings)}/yr
</p>
</div>
)}
{displayRec?.estimatedCost != null && displayRec.estimatedCost > 0 && (
<div className="px-3 py-2 bg-gray-50 rounded-lg text-center border border-gray-100 min-w-[72px]">
<p className="text-[9px] font-bold text-brandmidblue uppercase tracking-widest mb-0.5">
Cost
</p>
<p className="text-sm font-black text-brandblue font-manrope">
£{formatNumber(displayRec.estimatedCost)}
</p>
</div>
)}
</div>
</div>
{/* ── Collapsed CTA ───────────────────────────────────────── */}
{isInteractive && !isOpen && (
<button
onClick={() => setIsOpen(true)}
className="w-full py-3 rounded-xl bg-gray-50 border border-gray-100 text-brandblue font-bold text-sm hover:bg-gray-100 transition-colors flex items-center justify-center gap-2"
>
<ArrowsRightLeftIcon className="w-4 h-4" style={{ color: accentColor }} />
{isIncluded
? "View alternative options considered"
: "Add to plan / view options"}
</button>
)}
{/* ── Expanded alternatives panel ─────────────────────────── */}
{isInteractive && isOpen && (
<div className="space-y-4">
<div className="flex items-center gap-3">
<span className="text-[10px] font-black uppercase tracking-widest text-gray-400 whitespace-nowrap">
Detailed Alternatives
</span>
<div className="h-px flex-grow bg-gray-100" />
</div>
{/* Grid: chosen card + up to 2 alternatives */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
{displaySelected && (
<div
className="p-5 rounded-xl"
style={{
border: `1px solid ${accentColor}30`,
backgroundColor: `${accentColor}08`,
}}
>
<div className="flex justify-between items-start mb-3">
<p className="font-bold text-sm text-brandblue leading-tight flex-1 mr-2">
{displaySelected.description}
</p>
<span
className="text-[10px] px-2 py-0.5 rounded font-bold uppercase whitespace-nowrap shrink-0 text-white"
style={{ backgroundColor: accentColor }}
>
{hasPendingChange ? "Pending" : "Chosen"}
</span>
</div>
{(displaySelected.sapPoints != null && displaySelected.sapPoints > 0) ||
(displaySelected.energyCostSavings != null && displaySelected.energyCostSavings > 0) ? (
<div className="flex flex-wrap gap-1.5 mb-3">
{displaySelected.sapPoints != null && displaySelected.sapPoints > 0 && (
<span className="text-xs font-bold px-2.5 py-1 rounded-full bg-green-100 text-green-700">
+{displaySelected.sapPoints.toFixed(1)} SAP
</span>
)}
{displaySelected.energyCostSavings != null && displaySelected.energyCostSavings > 0 && (
<span className="text-xs font-bold px-2.5 py-1 rounded-full bg-amber-100 text-amber-700">
£{formatNumber(displaySelected.energyCostSavings)}/yr saved
</span>
)}
</div>
) : null}
<p className="font-black text-sm text-brandblue">
£{formatNumber(displaySelected.estimatedCost ?? 0)}
</p>
</div>
)}
{alternatives.slice(0, displaySelected ? 2 : 3).map((alt) => (
<div
key={String(alt.id)}
className="p-5 rounded-xl border border-gray-100 bg-gray-50/50 flex flex-col justify-between"
>
<div>
<p className="font-bold text-sm text-brandblue leading-tight mb-3">
{alt.description}
</p>
{(alt.sapPoints != null && alt.sapPoints > 0) ||
(alt.energyCostSavings != null && alt.energyCostSavings > 0) ? (
<div className="flex flex-wrap gap-1.5 mb-3">
{alt.sapPoints != null && alt.sapPoints > 0 && (
<span className="text-xs font-bold px-2.5 py-1 rounded-full bg-green-100 text-green-700">
+{alt.sapPoints.toFixed(1)} SAP
</span>
)}
{alt.energyCostSavings != null && alt.energyCostSavings > 0 && (
<span className="text-xs font-bold px-2.5 py-1 rounded-full bg-amber-100 text-amber-700">
£{formatNumber(alt.energyCostSavings)}/yr saved
</span>
)}
</div>
) : null}
</div>
<div className="flex items-center justify-between mt-2">
<p className="font-black text-sm text-brandblue">
£{formatNumber(alt.estimatedCost ?? 0)}
</p>
<button
disabled={isPending}
onClick={() => onSelect(alt)}
className="px-3 py-1.5 text-xs font-bold rounded-lg bg-white hover:bg-gray-50 transition-colors disabled:opacity-40 border border-gray-200"
style={{ color: accentColor }}
>
Select
</button>
</div>
</div>
))}
</div>
{hasMore && (
<button
onClick={() => setIsDrawerOpen(true)}
className="w-full py-3 rounded-xl bg-gray-50 text-brandblue font-bold text-xs uppercase tracking-widest hover:bg-gray-100 transition-colors border border-gray-100"
>
Compare all {recs.filter((r) => !r.alreadyInstalled).length}{" "}
options
</button>
)}
{/* Footer actions */}
<div className="flex flex-col gap-2 pt-1">
{(selected || pendingSelection) && (
<button
disabled={isPending}
onClick={onRemove}
className="w-full py-3 rounded-xl border border-red-100 text-red-500 font-bold text-xs uppercase tracking-widest hover:bg-red-50 transition-colors disabled:opacity-40"
>
Remove this measure from plan
</button>
)}
<button
onClick={() => setIsOpen(false)}
className="w-full py-3 rounded-xl bg-gray-50 text-gray-500 font-bold text-xs uppercase tracking-widest hover:bg-gray-100 transition-colors flex items-center justify-center gap-2"
>
<ChevronDownIcon className="w-4 h-4 rotate-180" />
Collapse alternative options
</button>
</div>
</div>
)}
</div>
<div className={optionTextClassName}>{optionsText}</div>
{cardComponent ? (
<table className="w-full text-left">
<tbody>
<tr>
<td className="font-medium">Estimated Cost:</td>
<td className="font-bold">
{cardComponent
? "£" + formatNumber(cardComponent?.estimatedCost || 0)
: ""}
</td>
</tr>
{cardComponent.sapPoints != null && (
<tr>
<td className="font-medium">SAP Points:</td>
<td className="font-bold">
{cardComponent.sapPoints < 0.1 &&
cardComponent.type !== "mechanical_ventilation"
? "Negligible"
: cardComponent.sapPoints}
</td>
</tr>
)}
</tbody>
</table>
) : (
""
)}
<RecommendationModal
title={componentType}
isOpen={modalIsOpen}
setIsOpen={setModalIsOpen}
recommendationData={recommendationData}
setCardComponent={setCardComponent}
setCostMap={setCostMap}
costMap={costMap}
setTotalEstimatedCost={setTotalEstimatedCost}
sapMap={sapMap}
setSapMap={setSapMap}
setTotalSapPoints={setTotalSapPoints}
currentSapPoints={currentSapPoints}
setExpectedEpcRating={setExpectedEpcRating}
// Labour
setTotalLabourDays={setTotalLabourDays}
labourDaysMap={labourDaysMap}
setLabourDaysMap={setLabourDaysMap}
// Co2
setCo2SavingsMap={setCo2SavingsMap}
co2SavingsMap={co2SavingsMap}
setTotalCo2Savings={setTotalCo2Savings}
// Energy Cost
setEnergyCostSavingsMap={setEnergyCostSavingsMap}
energyCostSavingsMap={energyCostSavingsMap}
setTotalEnergyCostSavings={setTotalEnergyCostSavings}
// kWh Savings
setKwhSavingsMap={setKwhSavingsMap}
kwhSavingsMap={kwhSavingsMap}
setTotalKwhSavings={setTotalKwhSavings}
<AlternativesDrawer
isOpen={isDrawerOpen}
onClose={() => setIsDrawerOpen(false)}
categoryLabel={title}
recs={recs}
selected={selected}
displaySelected={pendingSelection ?? selected}
onSelect={(rec) => {
onSelect(rec);
setIsDrawerOpen(false);
}}
isPending={isPending}
/>
</div>
);

View file

@ -1,441 +1,270 @@
"use client";
import { useState, useTransition, useMemo } from "react";
import { useRouter } from "next/navigation";
import {
Recommendation,
RecommendationType,
Plan,
} from "@/app/db/schema/recommendations";
import RecommendationCard from "./RecommendationCard";
import WorksPackageCard from "./WorksPackageCard";
import ValuationImpactComponent from "./ValuationImpactComponent";
import { Separator } from "@/app/shadcn_components/ui/separator";
import { PropertyMeta } from "@/app/db/schema/property";
import { sapToEpc } from "@/app/utils";
import { useState } from "react";
import { sumRecommendationMetricMap } from "@/app/portfolio/[slug]/building-passport/[propertyId]/plans/utils";
import { RecommendationMetricMap } from "@/types/recommendations";
import {
EnergyEfficiencyImpactCard,
SecondaryEnergyEfficiencyImpactCard,
} from "./EnergyEfficiencyImpactCard";
import { FundingPackageWithMeasures } from "@/app/db/schema/funding";
import { InstalledMeasureSummary } from "@/app/portfolio/[slug]/building-passport/[propertyId]/utils";
import {
setDefaultRecommendation,
clearCategoryDefault,
updatePlanMetrics,
} from "@/app/actions/recommendations";
import RecommendationCard, { AugmentedRec } from "./RecommendationCard";
import StickyImpactBar from "./StickyImpactBar";
import { sapToEpc } from "@/app/utils";
import { WrenchScrewdriverIcon } from "@heroicons/react/24/outline";
interface RecommendationContainerProps {
recommendations: Recommendation[];
propertyMeta: PropertyMeta;
planMeta: Plan;
funding: FundingPackageWithMeasures[];
installedMeasures: InstalledMeasureSummary[];
}
export type { AugmentedRec };
const typeToCategoryMap: { [key in RecommendationType]?: RecommendationType } =
{
internal_wall_insulation: "wall_insulation",
external_wall_insulation: "wall_insulation",
cavity_wall_insulation: "wall_insulation",
extension_cavity_wall_insulation: "extension_cavity_wall_insulation",
loft_insulation: "roof_insulation",
room_roof_insulation: "roof_insulation",
flat_roof_insulation: "roof_insulation",
suspended_floor_insulation: "floor_insulation",
solid_floor_insulation: "floor_insulation",
exposed_floor_insulation: "floor_insulation",
windows_glazing: "windows_glazing",
heating: "heating",
};
const emptyImpactState = {
estimatedCost: 0,
sapPoints: 0,
labourDays: 0,
co2EquivalentSavings: 0,
energyCostSavings: 0,
kwhSavings: 0,
const typeToCategoryMap: Partial<
Record<RecommendationType, RecommendationType>
> = {
internal_wall_insulation: "wall_insulation",
external_wall_insulation: "wall_insulation",
cavity_wall_insulation: "wall_insulation",
extension_cavity_wall_insulation: "extension_cavity_wall_insulation",
loft_insulation: "roof_insulation",
room_roof_insulation: "roof_insulation",
flat_roof_insulation: "roof_insulation",
suspended_floor_insulation: "floor_insulation",
solid_floor_insulation: "floor_insulation",
exposed_floor_insulation: "floor_insulation",
windows_glazing: "windows_glazing",
heating: "heating",
};
interface Props {
recommendations: Recommendation[];
installedMeasures: InstalledMeasureSummary[];
planId: string;
slug: string;
propertyId: string;
currentSapPoints: number;
savedCostOfWorks: number;
savedContingencyCost: number;
}
export default function RecommendationContainer({
recommendations,
propertyMeta,
planMeta,
funding,
installedMeasures,
}: RecommendationContainerProps) {
// Get the unique types of installed measures for easy lookup
const installedMeasureTypeSet = new Set(
installedMeasures.map((m) => m.measureType)
);
const categorizedRecommendations = recommendations.reduce(
(acc, curr) => {
const typeKey = curr.type as RecommendationType;
const category = typeToCategoryMap[typeKey] ?? typeKey;
if (!acc[category]) {
acc[category] = [];
}
planId,
slug,
propertyId,
currentSapPoints,
savedCostOfWorks,
savedContingencyCost,
}: Props) {
const installedTypeSet = new Set(installedMeasures.map((m) => m.measureType));
const categorized = recommendations.reduce(
(acc, rec) => {
const category = (typeToCategoryMap[rec.type as RecommendationType] ??
rec.type) as RecommendationType;
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,
});
rec.measureType != null && installedTypeSet.has(rec.measureType);
const augmented: AugmentedRec = {
...rec,
alreadyInstalled,
sapPoints: alreadyInstalled ? 0 : rec.sapPoints,
estimatedCost: alreadyInstalled ? 0 : rec.estimatedCost,
co2EquivalentSavings: alreadyInstalled ? 0 : rec.co2EquivalentSavings,
energyCostSavings: alreadyInstalled ? 0 : rec.energyCostSavings,
kwhSavings: alreadyInstalled ? 0 : rec.kwhSavings,
labourDays: alreadyInstalled ? 0 : rec.labourDays,
};
if (!acc[category]) acc[category] = [];
acc[category].push(augmented);
return acc;
},
{} as Record<
RecommendationType,
(Recommendation & { alreadyInstalled: boolean })[]
>
{} as Record<RecommendationType, AugmentedRec[]>,
);
const defaultWallsRecommendations =
categorizedRecommendations.wall_insulation?.find(
(rec: Recommendation) => rec.default
) || emptyImpactState;
const defaultFloorRecommendations =
categorizedRecommendations.floor_insulation?.find(
(rec: Recommendation) => rec.default
) || emptyImpactState;
const defaultRoofRecommendations =
categorizedRecommendations.roof_insulation?.find(
(rec: Recommendation) => rec.default
) || emptyImpactState;
const defaultVentiliationRecommendations =
categorizedRecommendations.mechanical_ventilation?.find(
(rec: Recommendation) => rec.default
) || emptyImpactState;
const defaultFireplaceRecommendations =
categorizedRecommendations.sealing_open_fireplace?.find(
(rec: Recommendation) => rec.default
) || emptyImpactState;
const defaultLightingRecommendations =
categorizedRecommendations.low_energy_lighting?.find(
(rec: Recommendation) => rec.default
) || emptyImpactState;
const defaultWindowsRecommendations =
categorizedRecommendations.windows_glazing?.find(
(rec: Recommendation) => rec.default
) || emptyImpactState;
const defaultSolarRecommendations =
categorizedRecommendations.solar_pv?.find(
(rec: Recommendation) => rec.default
) || emptyImpactState;
const defaultHeatingRecommendations =
categorizedRecommendations.heating?.find(
(rec: Recommendation) => rec.default
) || emptyImpactState;
const defaultHeatingControlRecommendations =
categorizedRecommendations.heating_control?.find(
(rec: Recommendation) => rec.default
) || emptyImpactState;
const defaultHotWaterTankRecommendations =
categorizedRecommendations.hot_water_tank_insulation?.find(
(rec: Recommendation) => rec.default
) || emptyImpactState;
const defaultSecondaryHeatingRecommendations =
categorizedRecommendations.secondary_heating?.find(
(rec: Recommendation) => rec.default
) || emptyImpactState;
const defaultCylinderThermostatRecommendations =
categorizedRecommendations.cylinder_thermostat?.find(
(rec: Recommendation) => rec.default
) || emptyImpactState;
const defaultTrickleVentsRecommendations =
categorizedRecommendations.trickle_vents?.find(
(rec: Recommendation) => rec.default
) || emptyImpactState;
const defaultMixedGlazingRecommendations =
categorizedRecommendations.mixed_glazing?.find(
(rec: Recommendation) => rec.default
) || emptyImpactState;
const defaultDraughtProofingRecommendations =
categorizedRecommendations.draught_proofing?.find(
(rec: Recommendation) => rec.default
) || emptyImpactState;
const [costMap, setCostMap] = useState<RecommendationMetricMap>({
wall_insulation: defaultWallsRecommendations.estimatedCost || 0,
floor_insulation: defaultFloorRecommendations.estimatedCost || 0,
roof_insulation: defaultRoofRecommendations.estimatedCost || 0,
mechanical_ventilation:
defaultVentiliationRecommendations.estimatedCost || 0,
sealing_open_fireplace: defaultFireplaceRecommendations.estimatedCost || 0,
low_energy_lighting: defaultLightingRecommendations.estimatedCost || 0,
windows_glazing: defaultWindowsRecommendations.estimatedCost || 0,
solar_pv: defaultSolarRecommendations.estimatedCost || 0,
heating: defaultHeatingRecommendations.estimatedCost || 0,
hot_water_tank_insulation:
defaultHotWaterTankRecommendations.estimatedCost || 0,
heating_control: defaultHeatingControlRecommendations.estimatedCost || 0,
secondary_heating:
defaultSecondaryHeatingRecommendations.estimatedCost || 0,
cylinder_thermostat:
defaultCylinderThermostatRecommendations.estimatedCost || 0,
trickle_vents: defaultTrickleVentsRecommendations.estimatedCost || 0,
mixed_glazing: defaultMixedGlazingRecommendations.estimatedCost || 0,
draught_proofing: defaultDraughtProofingRecommendations.estimatedCost || 0,
});
const [sapMap, setSapMap] = useState<RecommendationMetricMap>({
wall_insulation: defaultWallsRecommendations.sapPoints || 0,
floor_insulation: defaultFloorRecommendations.sapPoints || 0,
roof_insulation: defaultRoofRecommendations.sapPoints || 0,
mechanical_ventilation: defaultVentiliationRecommendations.sapPoints || 0,
sealing_open_fireplace: defaultFireplaceRecommendations.sapPoints || 0,
low_energy_lighting: defaultLightingRecommendations.sapPoints || 0,
windows_glazing: defaultWindowsRecommendations.sapPoints || 0,
solar_pv: defaultSolarRecommendations.sapPoints || 0,
heating: defaultHeatingRecommendations.sapPoints || 0,
hot_water_tank_insulation:
defaultHotWaterTankRecommendations.sapPoints || 0,
heating_control: defaultHeatingControlRecommendations.sapPoints || 0,
secondary_heating: defaultSecondaryHeatingRecommendations.sapPoints || 0,
cylinder_thermostat:
defaultCylinderThermostatRecommendations.sapPoints || 0,
trickle_vents: defaultTrickleVentsRecommendations.sapPoints || 0,
mixed_glazing: defaultMixedGlazingRecommendations.sapPoints || 0,
draught_proofing: defaultDraughtProofingRecommendations.sapPoints || 0,
});
const [labourDaysMap, setLabourDaysMap] = useState<RecommendationMetricMap>({
wall_insulation: defaultWallsRecommendations.labourDays || 0,
floor_insulation: defaultFloorRecommendations.labourDays || 0,
roof_insulation: defaultRoofRecommendations.labourDays || 0,
mechanical_ventilation: defaultVentiliationRecommendations.labourDays || 0,
sealing_open_fireplace: defaultFireplaceRecommendations.labourDays || 0,
low_energy_lighting: defaultLightingRecommendations.labourDays || 0,
windows_glazing: defaultWindowsRecommendations.labourDays || 0,
solar_pv: defaultSolarRecommendations.labourDays || 0,
heating: defaultHeatingRecommendations.labourDays || 0,
hot_water_tank_insulation:
defaultHotWaterTankRecommendations.labourDays || 0,
heating_control: defaultHeatingControlRecommendations.labourDays || 0,
secondary_heating: defaultSecondaryHeatingRecommendations.labourDays || 0,
cylinder_thermostat:
defaultCylinderThermostatRecommendations.labourDays || 0,
trickle_vents: defaultTrickleVentsRecommendations.labourDays || 0,
mixed_glazing: defaultMixedGlazingRecommendations.labourDays || 0,
draught_proofing: defaultDraughtProofingRecommendations.labourDays || 0,
});
const [co2SavingsMap, setCo2SavingsMap] = useState<RecommendationMetricMap>({
wall_insulation: defaultWallsRecommendations.co2EquivalentSavings || 0,
floor_insulation: defaultFloorRecommendations.co2EquivalentSavings || 0,
roof_insulation: defaultRoofRecommendations.co2EquivalentSavings || 0,
mechanical_ventilation:
defaultVentiliationRecommendations.co2EquivalentSavings || 0,
sealing_open_fireplace:
defaultFireplaceRecommendations.co2EquivalentSavings || 0,
low_energy_lighting:
defaultLightingRecommendations.co2EquivalentSavings || 0,
windows_glazing: defaultWindowsRecommendations.co2EquivalentSavings || 0,
solar_pv: defaultSolarRecommendations.co2EquivalentSavings || 0,
heating: defaultHeatingRecommendations.co2EquivalentSavings || 0,
hot_water_tank_insulation:
defaultHotWaterTankRecommendations.co2EquivalentSavings || 0,
heating_control:
defaultHeatingControlRecommendations.co2EquivalentSavings || 0,
secondary_heating:
defaultSecondaryHeatingRecommendations.co2EquivalentSavings || 0,
cylinder_thermostat:
defaultCylinderThermostatRecommendations.co2EquivalentSavings || 0,
trickle_vents: defaultTrickleVentsRecommendations.co2EquivalentSavings || 0,
mixed_glazing: defaultMixedGlazingRecommendations.co2EquivalentSavings || 0,
draught_proofing:
defaultDraughtProofingRecommendations.co2EquivalentSavings || 0,
});
const [energyCostSavingsMap, setEnergyCostSavingsMap] =
useState<RecommendationMetricMap>({
wall_insulation: defaultWallsRecommendations?.energyCostSavings || 0,
floor_insulation: defaultFloorRecommendations.energyCostSavings || 0,
roof_insulation: defaultRoofRecommendations.energyCostSavings || 0,
mechanical_ventilation:
defaultVentiliationRecommendations.energyCostSavings || 0,
sealing_open_fireplace:
defaultFireplaceRecommendations.energyCostSavings || 0,
low_energy_lighting:
defaultLightingRecommendations.energyCostSavings || 0,
windows_glazing: defaultWindowsRecommendations.energyCostSavings || 0,
solar_pv: defaultSolarRecommendations.energyCostSavings || 0,
heating: defaultHeatingRecommendations.energyCostSavings || 0,
hot_water_tank_insulation:
defaultHotWaterTankRecommendations.energyCostSavings || 0,
heating_control:
defaultHeatingControlRecommendations.energyCostSavings || 0,
secondary_heating:
defaultSecondaryHeatingRecommendations.energyCostSavings || 0,
cylinder_thermostat:
defaultCylinderThermostatRecommendations.energyCostSavings || 0,
trickle_vents: defaultTrickleVentsRecommendations.energyCostSavings || 0,
mixed_glazing: defaultMixedGlazingRecommendations.energyCostSavings || 0,
draught_proofing:
defaultDraughtProofingRecommendations.energyCostSavings || 0,
});
const [kwhSavingsMap, setKwhSavingsMap] = useState<RecommendationMetricMap>({
wall_insulation: defaultWallsRecommendations.kwhSavings || 0,
floor_insulation: defaultFloorRecommendations.kwhSavings || 0,
roof_insulation: defaultRoofRecommendations.kwhSavings || 0,
mechanical_ventilation: defaultVentiliationRecommendations.kwhSavings || 0,
sealing_open_fireplace: defaultFireplaceRecommendations.kwhSavings || 0,
low_energy_lighting: defaultLightingRecommendations.kwhSavings || 0,
windows_glazing: defaultWindowsRecommendations.kwhSavings || 0,
solar_pv: defaultSolarRecommendations.kwhSavings || 0,
heating: defaultHeatingRecommendations.kwhSavings || 0,
hot_water_tank_insulation:
defaultHotWaterTankRecommendations.kwhSavings || 0,
heating_control: defaultHeatingControlRecommendations.kwhSavings || 0,
secondary_heating: defaultSecondaryHeatingRecommendations.kwhSavings || 0,
cylinder_thermostat:
defaultCylinderThermostatRecommendations.kwhSavings || 0,
trickle_vents: defaultTrickleVentsRecommendations.kwhSavings || 0,
mixed_glazing: defaultMixedGlazingRecommendations.kwhSavings || 0,
draught_proofing: defaultDraughtProofingRecommendations.kwhSavings || 0,
});
const [totalEstimatedCost, setTotalEstimatedCost] = useState(
sumRecommendationMetricMap(costMap)
const [selections, setSelections] = useState<
Record<string, AugmentedRec | null>
>(() =>
Object.fromEntries(
Object.entries(categorized).map(([cat, recs]) => [
cat,
recs.find((r) => r.default) ?? null,
]),
),
);
const [totalSapPoints, setTotalSapPoints] = useState(
sumRecommendationMetricMap(sapMap)
// Pending selections — changes not yet saved to the database
const [pendingSelections, setPendingSelections] = useState<
Record<string, AugmentedRec | null>
>({});
const router = useRouter();
const [isPending, startTransition] = useTransition();
// Sort: included first, then not-included, then already-installed
const sortedCategories = Object.entries(categorized).sort(
([catA, recsA], [catB, recsB]) => {
const score = (cat: string, recs: AugmentedRec[]) => {
if (recs.some((r) => r.alreadyInstalled)) return 2;
if (selections[cat]) return 0;
return 1;
};
return score(catA, recsA) - score(catB, recsB);
},
);
const [totalLabourDays, setTotalLabourDays] = useState(
sumRecommendationMetricMap(labourDaysMap)
);
const [totalCo2Savings, setTotalCo2Savings] = useState(
sumRecommendationMetricMap(co2SavingsMap)
);
const [totalEnergyCostSavings, setTotalEnergyCostSavings] = useState(
sumRecommendationMetricMap(energyCostSavingsMap)
);
const [totalKwhSavings, setTotalKwhSavings] = useState(
sumRecommendationMetricMap(kwhSavingsMap)
);
// for the moment, we shouldn't have more than one funding package and so we flag if we have more than one
if (funding.length > 1) {
console.warn("Multiple funding packages found, using the first one.");
// Only stages a pending change — does NOT call server actions
function handleSelect(category: string, rec: AugmentedRec) {
setPendingSelections((prev) => ({ ...prev, [category]: rec }));
}
// Sum up project funding and uplift
const [totalFunding, setTotalFunding] = useState(
funding[0]
? (funding[0].projectFunding ?? 0) + (funding[0].totalUplift ?? 0)
: 0
);
// Stages a removal as a pending change — does NOT call server actions
function handleRemove(category: string) {
setPendingSelections((prev) => ({ ...prev, [category]: null }));
}
const currentEpcRating = propertyMeta.currentEpcRating;
const currentSapPoints = propertyMeta.currentSapPoints;
// Discard all pending changes
function handleDiscardAll() {
setPendingSelections({});
}
const expectedSapPoints = Math.min(currentSapPoints + totalSapPoints, 100);
const [expectedEpcRating, setExpectedEpcRating] = useState(
sapToEpc(expectedSapPoints)
);
// Save all pending changes to the database
function handleSaveAll() {
const snapshot = { ...pendingSelections };
setSelections((prev) => {
const merged = { ...prev };
for (const [cat, rec] of Object.entries(snapshot)) {
merged[cat] = rec;
}
return merged;
});
setPendingSelections({});
startTransition(async () => {
await Promise.all(
Object.entries(snapshot).map(([cat, rec]) => {
if (rec) {
return setDefaultRecommendation(
planId,
String(rec.id),
slug,
propertyId,
{ skipRevalidate: true },
);
} else {
const anyRec = categorized[cat as RecommendationType]?.[0];
if (!anyRec) return Promise.resolve();
return clearCategoryDefault(planId, anyRec.type, slug, propertyId, {
skipRevalidate: true,
});
}
}),
);
await updatePlanMetrics(planId, currentSapPoints, slug, propertyId);
router.refresh();
});
}
// Projected metrics: merge pending on top of committed, then sum
const projectedMetrics = useMemo(() => {
const effectiveRecs = Object.keys(categorized)
.map((cat) =>
cat in pendingSelections ? pendingSelections[cat] : selections[cat],
)
.filter((r): r is AugmentedRec => r != null);
const sapGain = effectiveRecs.reduce((s, r) => s + (r.sapPoints ?? 0), 0);
const projectedSap = currentSapPoints + sapGain;
const projectedCost = effectiveRecs.reduce(
(s, r) => s + (r.estimatedCost ?? 0),
0,
);
// Scale contingency proportionally from saved plan values
const contingencyRate = savedCostOfWorks > 0 ? savedContingencyCost / savedCostOfWorks : 0;
const projectedContingency = Math.round(projectedCost * contingencyRate);
return {
projectedSap,
projectedEpc: sapToEpc(projectedSap),
projectedCo2: effectiveRecs.reduce(
(s, r) => s + (r.co2EquivalentSavings ?? 0),
0,
),
projectedHeatDemand: effectiveRecs.reduce(
(s, r) => s + (r.heatDemand ?? 0),
0,
),
projectedCost,
projectedContingency,
};
}, [pendingSelections, selections, categorized, currentSapPoints, savedCostOfWorks, savedContingencyCost]);
// Current saved baseline derived from committed selections
const savedMetrics = useMemo(() => {
const committed = Object.values(selections).filter(
(r): r is AugmentedRec => r != null,
);
const sapGain = committed.reduce((s, r) => s + (r.sapPoints ?? 0), 0);
const savedSap = currentSapPoints + sapGain;
return { savedSap, savedEpc: sapToEpc(savedSap) };
}, [selections, currentSapPoints]);
const hasPendingChanges = Object.keys(pendingSelections).length > 0;
const pendingCount = Object.keys(pendingSelections).length;
if (Object.keys(categorized).length === 0) {
return (
<div className="bg-white rounded-2xl border border-dashed border-gray-200 p-16 flex flex-col items-center justify-center text-center gap-4">
<div className="w-14 h-14 rounded-2xl bg-gray-50 border border-gray-200 flex items-center justify-center">
<WrenchScrewdriverIcon className="w-7 h-7 text-gray-400" />
</div>
<p className="text-sm text-gray-400">
No recommendations for this plan.
</p>
</div>
);
}
return (
<>
<div className="mt-8 mb-4 flex-col grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 items-stretch">
<WorksPackageCard
totalEstimatedCost={totalEstimatedCost}
totalLabourDays={totalLabourDays}
totalFunding={totalFunding}
/>
<EnergyEfficiencyImpactCard
currentEpcRating={currentEpcRating}
expectedEpcRating={expectedEpcRating}
currentSapPoints={currentSapPoints}
expectedSapPoints={expectedSapPoints}
totalSapPoints={totalSapPoints}
/>
<SecondaryEnergyEfficiencyImpactCard
TotalCo2Savings={totalCo2Savings}
totalEnergyCostSavings={totalEnergyCostSavings}
totalKwhSavings={totalKwhSavings}
/>
<ValuationImpactComponent
currentValuation={propertyMeta.currentValuation}
valuationIncreaseLowerBound={planMeta.valuationIncreaseLowerBound}
valuationIncreaseUpperBound={planMeta.valuationIncreaseUpperBound}
funding={funding[0]}
/>
<div className="space-y-10 pb-24">
{sortedCategories.map(([category, recs]) => {
const isIncluded =
!!selections[category] && !recs.some((r) => r.alreadyInstalled);
return (
<RecommendationCard
key={category}
category={category as RecommendationType}
recs={recs}
selected={selections[category] ?? null}
pendingSelection={pendingSelections[category] ?? null}
hasPendingChange={category in pendingSelections}
isIncluded={isIncluded}
isPending={isPending}
onSelect={(rec) => handleSelect(category, rec)}
onRemove={() => handleRemove(category)}
/>
);
})}
</div>
<Separator className="mb-4 bg-brandbrown" />
<div className="flex-col grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 items-stretch">
{Object.entries(categorizedRecommendations).map(
([componentType, recommendationData], idx) => {
return (
<RecommendationCard
key={idx}
// entires means we loose the typing on the key
componentType={componentType as RecommendationType}
recommendationData={recommendationData}
// cost
setCostMap={setCostMap}
costMap={costMap}
setTotalEstimatedCost={setTotalEstimatedCost}
// Sap
setSapMap={setSapMap}
sapMap={sapMap}
setTotalSapPoints={setTotalSapPoints}
currentSapPoints={currentSapPoints}
setExpectedEpcRating={setExpectedEpcRating}
// Labour
setTotalLabourDays={setTotalLabourDays}
labourDaysMap={labourDaysMap}
setLabourDaysMap={setLabourDaysMap}
// Co2
setCo2SavingsMap={setCo2SavingsMap}
co2SavingsMap={co2SavingsMap}
setTotalCo2Savings={setTotalCo2Savings}
// Energy Cost
setEnergyCostSavingsMap={setEnergyCostSavingsMap}
energyCostSavingsMap={energyCostSavingsMap}
setTotalEnergyCostSavings={setTotalEnergyCostSavings}
// kwh Savings
setKwhSavingsMap={setKwhSavingsMap}
kwhSavingsMap={kwhSavingsMap}
setTotalKwhSavings={setTotalKwhSavings}
/>
);
}
)}
</div>
<StickyImpactBar
hasPendingChanges={hasPendingChanges}
pendingCount={pendingCount}
savedSap={savedMetrics.savedSap}
savedEpc={savedMetrics.savedEpc}
projectedSap={projectedMetrics.projectedSap}
projectedEpc={projectedMetrics.projectedEpc}
projectedCo2={projectedMetrics.projectedCo2}
projectedHeatDemand={projectedMetrics.projectedHeatDemand}
projectedCost={projectedMetrics.projectedCost}
projectedContingency={projectedMetrics.projectedContingency}
onSaveAll={handleSaveAll}
onDiscard={handleDiscardAll}
isPending={isPending}
/>
</>
);
}

View file

@ -0,0 +1,167 @@
"use client";
import { ArrowPathIcon, ArrowRightIcon } from "@heroicons/react/24/outline";
function getEpcHex(letter: string | null | undefined): string {
switch (letter?.toUpperCase()) {
case "A": return "#117d58";
case "B": return "#2da55c";
case "C": return "#8dbd40";
case "D": return "#f7cd14";
case "E": return "#f3a96a";
case "F": return "#ef8026";
case "G": return "#e41e3b";
default: return "#9ca3af";
}
}
interface StickyImpactBarProps {
hasPendingChanges: boolean;
pendingCount: number;
savedSap: number;
savedEpc: string;
projectedSap: number;
projectedEpc: string;
projectedCo2: number;
projectedHeatDemand: number;
projectedCost: number;
projectedContingency: number;
onSaveAll: () => void;
onDiscard: () => void;
isPending: boolean;
}
function formatNumber(n: number): string {
return n.toLocaleString("en-GB", { maximumFractionDigits: 0 });
}
export default function StickyImpactBar({
hasPendingChanges,
pendingCount,
savedSap,
savedEpc,
projectedSap,
projectedEpc,
projectedCo2,
projectedHeatDemand,
projectedCost,
projectedContingency,
onSaveAll,
onDiscard,
isPending,
}: StickyImpactBarProps) {
return (
<div
className={`fixed bottom-0 left-0 right-0 z-40 border-t border-gray-200 bg-white/95 backdrop-blur-md shadow-[0_-8px_30px_rgba(0,0,0,0.08)] transition-transform duration-300 ${
hasPendingChanges ? "translate-y-0" : "translate-y-full"
}`}
>
<div className="max-w-[1400px] mx-auto px-6 py-4 flex items-center gap-4 flex-wrap">
{/* Left: pending count badge */}
<span className="text-xs font-bold px-3 py-1.5 rounded-full bg-amber-100 text-amber-700 shrink-0">
{pendingCount} unsaved {pendingCount === 1 ? "change" : "changes"}
</span>
{/* Center: metric chips */}
<div className="flex items-center gap-3 flex-1 flex-wrap">
{/* SAP */}
<div className="px-3 py-2 bg-gray-50 rounded-lg border border-gray-100 flex items-center gap-2">
<div>
<p className="text-[9px] font-bold uppercase tracking-widest text-gray-400 mb-0.5">SAP</p>
<div className="flex items-center gap-1.5">
<span className="text-sm font-black text-gray-400 font-manrope">
{savedSap.toFixed(1)}
</span>
<ArrowRightIcon className="w-3 h-3 text-gray-300" />
<span className="text-sm font-black text-brandblue font-manrope">
{projectedSap.toFixed(1)}
</span>
</div>
</div>
</div>
{/* EPC */}
<div className="px-3 py-2 bg-gray-50 rounded-lg border border-gray-100 flex items-center gap-2">
<div>
<p className="text-[9px] font-bold uppercase tracking-widest text-gray-400 mb-0.5">EPC</p>
<div className="flex items-center gap-1.5">
<span
className="text-sm font-black font-manrope"
style={{ color: getEpcHex(savedEpc) }}
>
{savedEpc}
</span>
<ArrowRightIcon className="w-3 h-3 text-gray-300" />
<span
className="text-sm font-black font-manrope"
style={{ color: getEpcHex(projectedEpc) }}
>
{projectedEpc}
</span>
</div>
</div>
</div>
{/* CO₂ */}
<div className="px-3 py-2 bg-gray-50 rounded-lg border border-gray-100">
<p className="text-[9px] font-bold uppercase tracking-widest text-gray-400 mb-0.5">CO saved</p>
<p className="text-sm font-black text-brandblue font-manrope">
{projectedCo2.toFixed(1)} t/yr
</p>
</div>
{/* Heat demand — hidden when zero */}
{projectedHeatDemand > 0 && (
<div className="px-3 py-2 bg-gray-50 rounded-lg border border-gray-100">
<p className="text-[9px] font-bold uppercase tracking-widest text-gray-400 mb-0.5">Heat demand</p>
<p className="text-sm font-black text-brandblue font-manrope">
{projectedHeatDemand.toFixed(0)} kWh/yr
</p>
</div>
)}
{/* Cost — hidden when zero */}
{projectedCost > 0 && (
<div className="px-3 py-2 bg-gray-50 rounded-lg border border-gray-100">
<p className="text-[9px] font-bold uppercase tracking-widest text-gray-400 mb-0.5">Est. Cost</p>
<p className="text-sm font-black text-brandblue font-manrope">
£{formatNumber(projectedCost)}
</p>
</div>
)}
{/* Contingency — hidden when zero */}
{projectedContingency > 0 && (
<div className="px-3 py-2 bg-gray-50 rounded-lg border border-gray-100">
<p className="text-[9px] font-bold uppercase tracking-widest text-gray-400 mb-0.5">Contingency</p>
<p className="text-sm font-black text-brandbrown font-manrope">
~£{formatNumber(projectedContingency)}
</p>
</div>
)}
</div>
{/* Right: action buttons */}
<div className="flex items-center gap-2 shrink-0">
{isPending && (
<ArrowPathIcon className="w-4 h-4 animate-spin text-gray-400" />
)}
<button
disabled={isPending}
onClick={onDiscard}
className="px-4 py-2 rounded-lg border border-gray-200 text-gray-600 font-bold text-sm hover:bg-gray-50 transition-colors disabled:opacity-40"
>
Discard
</button>
<button
disabled={isPending}
onClick={onSaveAll}
className="px-4 py-2 rounded-lg bg-brandblue text-white font-bold text-sm hover:bg-blue-700 transition-colors disabled:opacity-40"
>
Save all changes
</button>
</div>
</div>
</div>
);
}

View file

@ -1,24 +1,17 @@
"use client";
import { useState } from "react";
import { useState, useTransition } from "react";
import { useRouter, usePathname } from "next/navigation";
import {
Cog6ToothIcon,
NewspaperIcon,
HomeModernIcon,
WrenchScrewdriverIcon,
SunIcon,
CircleStackIcon,
HeartIcon,
CalendarDaysIcon,
ArrowPathIcon,
ClipboardDocumentCheckIcon,
} from "@heroicons/react/24/outline";
import {
NavigationMenu,
NavigationMenuItem,
NavigationMenuList,
NavigationMenuLink,
} from "@/app/shadcn_components/ui/navigation-menu";
import { Button } from "@/app/shadcn_components/ui/button";
import { cva } from "class-variance-authority";
import { cn } from "@/lib/utils";
import { getUploadedFile } from "@/app/db/surveyDB/schema/surveyDB";
import BookSurveyModal from "@/app/portfolio/[slug]/components/BookSurveyModal";
import SuccessToast from "@/app/portfolio/[slug]/components/SuccessToast";
@ -31,36 +24,6 @@ interface ToolbarProps {
decentHomes: getUploadedFile;
}
const navigationMenuTriggerStyle = cva(
[
"bg-gray-50",
"cursor-pointer",
"group",
"inline-flex",
"h-10",
"w-max",
"items-center",
"justify-center",
"rounded-md",
"bg-background",
"px-4",
"py-2",
"text-sm",
"font-medium",
"transition-colors",
"hover:bg-gray-200",
"hover:text-accent-foreground",
"focus:bg-accent",
"focus:text-accent-foreground",
"focus:outline-none",
"disabled:pointer-events-none",
"disabled:opacity-50",
"data-[active]:bg-accent/50",
"data-[state=open]:bg-gray-200",
"text-gray-900",
].join(" ")
);
export function Toolbar({
propertyId,
portfolioId,
@ -69,104 +32,78 @@ export function Toolbar({
}: ToolbarProps) {
const [openModal, setOpenModal] = useState(false);
const [showToast, setShowToast] = useState(false);
const [loadingTab, setLoadingTab] = useState<string | null>(null);
const [isPending, startTransition] = useTransition();
const router = useRouter();
const pathname = usePathname();
function handleClickSettings() {
console.log("Settings were clicked, implement me");
}
const baseUrl = `/portfolio/${portfolioId}/building-passport/${propertyId}`;
const preAssessmentReportButton = (
<NavigationMenuLink
className={navigationMenuTriggerStyle() + " ml-3 mr-2"}
href={`/portfolio/${portfolioId}/building-passport/${propertyId}/assessment`}
>
<NewspaperIcon className="h-4 w-4 mr-2" />
Data
</NavigationMenuLink>
);
const tabs = [
{ href: baseUrl, label: "Overview", icon: HomeModernIcon, exact: true },
{ href: `${baseUrl}/assessment`, label: "Property Details", icon: NewspaperIcon },
...(Object.keys(decentHomes).length > 0 && decentHomes.uprn
? [{ href: `${baseUrl}/decent-homes`, label: "Decent Homes", icon: HeartIcon }]
: []),
{ href: `${baseUrl}/plans`, label: "Retrofit Plans", icon: WrenchScrewdriverIcon },
{ href: `${baseUrl}/documents`, label: "Documents", icon: CircleStackIcon },
] as { href: string; label: string; icon: React.ElementType; exact?: boolean }[];
const documentsButton = (
<NavigationMenuLink
className={navigationMenuTriggerStyle() + " ml-3 mr-2"}
href={`/portfolio/${portfolioId}/building-passport/${propertyId}/documents`}
>
<CircleStackIcon className="h-4 w-4 mr-2" />
Documents
</NavigationMenuLink>
);
const solarAnalysisButton = (
<NavigationMenuLink
className={navigationMenuTriggerStyle() + " ml-3 mr-2"}
href={`/portfolio/${portfolioId}/building-passport/${propertyId}/solar-analysis`}
>
<SunIcon className="h-4 w-4 mr-2" />
Solar
</NavigationMenuLink>
);
const recommendationsButton = (
<NavigationMenuLink
className={navigationMenuTriggerStyle() + " ml-3 mr-2"}
href={`/portfolio/${portfolioId}/building-passport/${propertyId}/plans`}
>
<WrenchScrewdriverIcon className="h-4 w-4 mr-2" />
Retrofit Plans
</NavigationMenuLink>
);
const decentHomesButton = (
<NavigationMenuLink
className={navigationMenuTriggerStyle() + " ml-3 mr-2"}
href={`/portfolio/${portfolioId}/building-passport/${propertyId}/decent-homes`}
>
<HeartIcon className="h-4 w-4 mr-2" />
Decent Homes
</NavigationMenuLink>
);
const handleNav = (href: string) => {
if (pathname === href) return;
setLoadingTab(href);
startTransition(() => {
router.push(href);
});
};
return (
<>
<div className="flex items-center justify-between w-full">
{/* Left side: navigation */}
<NavigationMenu>
<NavigationMenuLink
className={navigationMenuTriggerStyle() + " ml-3 mr-2"}
href={`/portfolio/${portfolioId}/building-passport/${propertyId}`}
>
<HomeModernIcon className="h-4 w-4 mr-2" />
Summary
</NavigationMenuLink>
<div className="flex items-center justify-between w-full px-3 py-1.5">
{/* Tabs */}
<div className="flex items-center gap-0.5">
{tabs.map((tab) => {
const isActive = tab.exact
? pathname === tab.href
: pathname.startsWith(tab.href);
const isLoading = loadingTab === tab.href && isPending;
const Icon = tab.icon;
<NavigationMenuList>
{preAssessmentReportButton}
{Object.keys(decentHomes).length > 0 &&
decentHomes.uprn &&
decentHomesButton}
{solarAnalysisButton}
{recommendationsButton}
{documentsButton}
<NavigationMenuItem
className={navigationMenuTriggerStyle() + " ml-3 mr-2"}
onClick={handleClickSettings}
>
<Cog6ToothIcon className="h-4 w-4 mr-2" />
Settings
</NavigationMenuItem>
</NavigationMenuList>
</NavigationMenu>
{/* ✅ Right side: Book a Survey button */}
<div className="mr-3">
<Button
onClick={() => setOpenModal(true)}
className="bg-brandblue text-white hover:bg-branddarkblue flex items-center"
>
Book an On Site Assessment
</Button>
return (
<button
key={tab.href}
onClick={() => handleNav(tab.href)}
className={cn(
"relative flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium transition-all duration-200 cursor-pointer",
isActive
? "bg-white text-gray-900 shadow-sm"
: "text-gray-500 hover:text-gray-800 hover:bg-white/70",
)}
>
{isLoading ? (
<ArrowPathIcon className="h-4 w-4 animate-spin text-indigo-500" />
) : (
<Icon className="h-4 w-4" />
)}
{tab.label}
{isActive && (
<span className="absolute bottom-0 left-2 right-2 h-0.5 rounded-full bg-blue-500" />
)}
</button>
);
})}
</div>
{/* Book Assessment button */}
<button
onClick={() => setOpenModal(true)}
className="flex items-center gap-2 px-4 py-2 rounded-xl bg-gradient-to-br from-gray-900 to-gray-700 text-white text-sm font-semibold shadow-md hover:scale-[1.03] transition-transform active:scale-95 cursor-pointer"
>
<ClipboardDocumentCheckIcon className="h-4 w-4" />
Book Assessment
</button>
</div>
{/* ✅ Modal */}
{openModal && (
<BookSurveyModal
open={openModal}
@ -178,7 +115,6 @@ export function Toolbar({
/>
)}
{/* ✅ Toast */}
<SuccessToast
show={showToast}
showConfetti={showToast}

View file

@ -10,7 +10,6 @@ import { PiggyBank } from "lucide-react";
import { Dialog, Transition } from "@headlessui/react";
import { Fragment } from "react";
import { FundingPackageMeasure } from "@/app/db/schema/funding";
import { set } from "cypress/types/lodash";
type FundingSummaryProps = {
scheme: string | null;

View file

@ -0,0 +1,3 @@
import { Recommendation } from "@/app/db/schema/recommendations";
export type AugmentedRec = Recommendation & { alreadyInstalled: boolean };

View file

@ -5,6 +5,7 @@ import {
ChartBarIcon,
HomeModernIcon,
BuildingOffice2Icon,
ArrowPathIcon,
} from "@heroicons/react/24/outline";
import {
NavigationMenu,
@ -16,7 +17,7 @@ import AddNewDropDown from "./AddNew";
import YourProjectsDropdown from "./YourProjectsDropdown";
import UploadCsvModal from "@/app/portfolio/[slug]/components/UploadCsvModal";
import { ScenarioSelect } from "@/app/db/schema/recommendations";
import { useState } from "react";
import { useState, useTransition } from "react";
import { useRouter, usePathname } from "next/navigation";
import { cn } from "@/lib/utils";
@ -29,6 +30,16 @@ export function Toolbar({ portfolioId, scenarios }: ToolbarProps) {
const router = useRouter();
const pathname = usePathname();
const [modalIsOpen, setModalIsOpen] = useState(false);
const [loadingHref, setLoadingHref] = useState<string | null>(null);
const [isPending, startTransition] = useTransition();
const handleNav = (href: string) => {
if (pathname === href) return;
setLoadingHref(href);
startTransition(() => {
router.push(href);
});
};
const navItems = [
{
@ -63,30 +74,35 @@ export function Toolbar({ portfolioId, scenarios }: ToolbarProps) {
return (
<>
<NavigationMenu className="relative">
<NavigationMenu className="relative px-4">
<NavigationMenuList className="flex-wrap">
{navItems.map(({ label, icon: Icon, href, match }) => {
const isActive = match(pathname);
const isLoading = loadingHref === href && isPending;
return (
<NavigationMenuItem key={label}>
<button
onClick={() => router.push(href)}
onClick={() => handleNav(href)}
className={cn(
"relative flex items-center rounded-md text-sm font-medium p-[3px]",
"relative flex items-center rounded-md text-xs font-medium p-[3px]",
isActive &&
"bg-gradient-to-r from-brandblue via-brandbrown to-brandblue"
)}
>
<div
className={cn(
"flex items-center rounded-md px-4 py-2",
"flex items-center rounded-md px-3 py-1.5",
isActive
? "bg-white text-brandblue shadow-sm"
: "bg-gray-50 text-gray-800 hover:bg-midblue hover:text-gray-100"
)}
>
<Icon className="h-4 w-4 mr-2" />
{isLoading ? (
<ArrowPathIcon className="h-4 w-4 mr-2 animate-spin" />
) : (
<Icon className="h-4 w-4 mr-2" />
)}
{label}
</div>
</button>
@ -101,26 +117,31 @@ export function Toolbar({ portfolioId, scenarios }: ToolbarProps) {
/>
{SettingsItems.map(({ label, icon: Icon, href, match }) => {
const isActive = match(pathname);
const isLoading = loadingHref === href && isPending;
return (
<NavigationMenuItem key={label}>
<button
onClick={() => router.push(href)}
onClick={() => handleNav(href)}
className={cn(
"relative flex items-center rounded-md text-sm font-medium p-[3px]",
"relative flex items-center rounded-md text-xs font-medium p-[3px]",
isActive &&
"bg-gradient-to-r from-brandblue via-brandbrown to-brandblue"
)}
>
<div
className={cn(
"flex items-center rounded-md px-4 py-2",
"flex items-center rounded-md px-3 py-1.5",
isActive
? "bg-white text-brandblue shadow-sm"
: "bg-gray-50 text-gray-800 hover:bg-midblue hover:text-gray-100"
)}
>
<Icon className="h-4 w-4 mr-2" />
{isLoading ? (
<ArrowPathIcon className="h-4 w-4 mr-2 animate-spin" />
) : (
<Icon className="h-4 w-4 mr-2" />
)}
{label}
</div>
</button>

View file

@ -368,6 +368,19 @@ export interface PropertyWithRelations extends Record<string, unknown> {
fundingScheme: string | null;
totalRecommendationSapPoints: number | null;
totalRecommendationCost: number | null;
// New fields
landlordPropertyId: string | null;
originalSapPoints: number | null;
epcLodgementDate: string | null;
epcIsExpired: boolean | null;
// Optional columns (hidden by default)
propertyType: string | null;
builtForm: string | null;
tenure: string | null;
yearBuilt: string | null;
totalFloorArea: number | null;
co2Emissions: number | null;
mainfuel: string | null;
}
export type NonIntrusiveSurveyNotes = InferModel<

View file

@ -1,264 +0,0 @@
"use client";
import { useState } from "react";
import { SelectFolder } from "../components/due-considerations/SelectFolder";
import { Button } from "../shadcn_components/ui/button";
import { useSession } from "next-auth/react";
import { useMutation } from "@tanstack/react-query";
import { Input } from "../shadcn_components/ui/input";
const Spinner = () => {
return (
<div className="w-16 h-16 border-t-4 border-brandgold border-solid rounded-full animate-spin"></div>
);
};
function generateDueConsiderationsS3Folder(userId: string) {
const timestamp = new Date().toISOString().replace(/[:.-]/g, "");
const key = `${userId}/${timestamp}/`;
return key;
}
async function postDueConsiderations(
userId: string,
folderKey: string,
schemeName: string
) {
// Triggers the due considerations process
const body = JSON.stringify({
userId: userId,
folderKey: folderKey,
scheme: schemeName,
});
try {
const response = await fetch(`/api/due-considerations`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: body,
});
if (!response.ok) {
throw new Error("Network response was not ok");
}
// Handle the response as needed
const data = await response.json();
return data;
} catch (error) {
console.error(error);
// Handle the error appropriately
}
}
const useUploadFiles = ({
dueConsiderationsFiles,
userId,
schemeName,
setDownloadUrl,
}: {
dueConsiderationsFiles: File[];
userId: string;
schemeName: string;
setDownloadUrl: React.Dispatch<React.SetStateAction<string>>;
}) => {
const { mutate: mutateUploadFiles, isLoading: isUploadLoading } = useMutation(
uploadFilesToS3,
{
onSuccess: async () => {
console.log("Trigger the due considerations process");
console.log("Folder key: ", folderKey);
const data = await postDueConsiderations(userId, folderKey, schemeName);
setDownloadUrl(data.download_url);
},
onError: (error) => {
console.error(error);
},
}
);
const { mutate, isLoading: isGeneratingUrlLoading } = useMutation(
generatePresignedUrls,
{
onSuccess: (data) => {
try {
const response = mutateUploadFiles({
presignedUrls: data.urls,
files: dueConsiderationsFiles,
});
return response;
} catch (error) {
console.error(error);
}
},
onError: (error) => {
console.error(error);
},
}
);
const [folderKey, setFolderKey] = useState("");
const handleUpload = (newFolderKey: string) => {
setFolderKey(newFolderKey);
mutate({ folderKey: newFolderKey, files: dueConsiderationsFiles });
};
return {
handleUpload,
isGeneratingUrlLoading,
isUploadLoading,
};
};
async function generatePresignedUrls({
folderKey,
files,
}: {
folderKey: string;
files: File[];
}) {
const body = JSON.stringify({
files: files.map((file) => ({
fileKey: folderKey + file.name,
contentType: file.type,
})),
});
const presignedResponse = await fetch("/api/upload/due-considerations", {
method: "POST",
body: body,
});
if (!presignedResponse.ok) {
throw new Error("Network response was not ok");
}
const presignedUrls = await presignedResponse.json();
return presignedUrls;
}
async function uploadFilesToS3({
presignedUrls,
files,
}: {
presignedUrls: string[];
files: File[];
}) {
await Promise.all(
files.map((file, index) => {
return fetch(presignedUrls[index], {
method: "PUT",
headers: {
"Content-Type": file.type,
},
body: file,
});
})
);
}
export default function DueConsiderationsHome() {
const [dueConsiderationsFiles, setDueConsiderationFile] = useState<File[]>(
[]
);
const [buttonDisabled, setButtonDisabled] = useState(true);
const [uploadMessage, setUploadMessage] = useState("");
const [schemeName, setSchemeName] = useState("");
const [downloadUrl, setDownloadUrl] = useState("");
const session = useSession();
const userId = String(session.data?.user.dbId);
const { handleUpload, isGeneratingUrlLoading, isUploadLoading } =
useUploadFiles({
dueConsiderationsFiles,
userId,
schemeName,
setDownloadUrl,
});
const initiateUpload = () => {
setDownloadUrl("");
const newFolderKey = generateDueConsiderationsS3Folder(userId);
handleUpload(newFolderKey);
};
function handleOnChange(e: React.ChangeEvent<HTMLInputElement>) {
if (e.target.files && e.target.files.length === 3) {
const filesArray = Array.from(e.target.files);
const extensions = filesArray.map((file) =>
file.name.split(".").pop()?.toLowerCase()
);
if (
extensions.includes("xml") &&
extensions.includes("pdf") &&
extensions.includes("docx")
) {
setDueConsiderationFile(filesArray);
setButtonDisabled(false);
setUploadMessage("");
} else {
setUploadMessage("Please select a .xml, .pdf, and .docx file.");
setButtonDisabled(true);
}
} else {
setUploadMessage("Please select exactly 3 files.");
setButtonDisabled(true);
}
}
return (
<div className="flex flex-col items-center mt-20 tracking-wider leading-loose">
<div className="text-center">
<div className="mb-4">Select a folder containing:</div>
<ul className="list-disc list-inside text-left ml-8 mb-8">
<li>A full SAP xml</li>
<li>EPR pdf</li>
<li>Condition report word document</li>
</ul>
<div className="mb-4">
Make sure these documents all relate to the same property
</div>
<div className="mb-5">
<SelectFolder handleOnChange={handleOnChange} />
</div>
<Input
type="text"
placeholder="Choose an optional scheme name"
onChange={(e) => setSchemeName(e.target.value)}
/>
<div className="mb-4"></div>
<div className="flex justify-between w-full">
<div>{uploadMessage}</div>
<Button
disabled={
buttonDisabled || isGeneratingUrlLoading || isUploadLoading
}
className="bg-brandblue hover:bg-hoverblue"
onClick={initiateUpload}
>
{isGeneratingUrlLoading || isUploadLoading
? "Loading..."
: "Upload"}
</Button>
</div>
<div className="flex items-center justify-center">
{isGeneratingUrlLoading || isUploadLoading ? (
<Spinner />
) : downloadUrl ? (
<a href={downloadUrl} className="text-blue-500 hover:underline">
Download Due Considerations
</a>
) : null}
</div>
</div>
</div>
);
}

View file

@ -1,284 +0,0 @@
"use client";
import { useState } from "react";
import { SelectFolder } from "../components/due-considerations/SelectFolder";
import { Button } from "../shadcn_components/ui/button";
import { useSession } from "next-auth/react";
import { useMutation } from "@tanstack/react-query";
const Spinner = () => {
return (
<div className="w-16 h-16 border-t-4 border-brandgold border-solid rounded-full animate-spin"></div>
);
};
function generateEcoSpreadsheetS3Folder(userId: string) {
const timestamp = new Date().toISOString().replace(/[:.-]/g, "");
const key = `${userId}/${timestamp}/`;
return key;
}
async function postEcoSpreadsheet(userId: string, folderKey: string) {
// Triggers the eco spreadsheet process
const body = JSON.stringify({
userId: userId,
folderKey: folderKey,
});
try {
const response = await fetch(`/api/eco-spreadsheet`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: body,
});
if (!response.ok) {
throw new Error("Network response was not ok");
}
// Handle the response as needed
const data = await response.json();
return data;
} catch (error) {
console.error(error);
// Handle the error appropriately
}
}
const useUploadFiles = ({
files,
userId,
setDownloadUrl,
}: {
files: File[];
userId: string;
setDownloadUrl: React.Dispatch<React.SetStateAction<string>>;
}) => {
const { mutate: mutateUploadFiles, isLoading: isUploadLoading } = useMutation(
uploadFilesToS3,
{
onSuccess: async () => {
console.log("Trigger the eco spreadsheet process");
console.log("Folder key: ", folderKey);
const data = await postEcoSpreadsheet(userId, folderKey);
setDownloadUrl(data.download_url);
},
onError: (error) => {
console.error(error);
},
}
);
const { mutate, isLoading: isGeneratingUrlLoading } = useMutation(
generatePresignedUrls,
{
onSuccess: (data) => {
try {
console.log("Trying to generate presigned urls");
const response = mutateUploadFiles({
presignedUrls: data.urls,
files: files,
});
return response;
} catch (error) {
console.error(error);
}
},
onError: (error) => {
console.error(error);
},
}
);
const [folderKey, setFolderKey] = useState("");
const handleUpload = (newFolderKey: string) => {
setFolderKey(newFolderKey);
mutate({ folderKey: newFolderKey, files: files });
};
return {
handleUpload,
isGeneratingUrlLoading,
isUploadLoading,
};
};
async function generatePresignedUrls({
folderKey,
files,
}: {
folderKey: string;
files: File[];
}) {
const body = JSON.stringify({
files: files.map((file) => ({
fileKey: folderKey + file.name,
contentType: file.type,
})),
});
const presignedResponse = await fetch("/api/upload/eco-spreadsheet", {
method: "POST",
body: body,
});
if (!presignedResponse.ok) {
throw new Error("Network response was not ok");
}
const presignedUrls = await presignedResponse.json();
return presignedUrls;
}
async function uploadFilesToS3({
presignedUrls,
files,
}: {
presignedUrls: string[];
files: File[];
}) {
await Promise.all(
files.map((file, index) => {
return fetch(presignedUrls[index], {
method: "PUT",
headers: {
"Content-Type": file.type,
},
body: file,
});
})
);
}
export default function EcoSpreadsheetHome() {
const [files, setFiles] = useState<File[]>([]);
const [buttonDisabled, setButtonDisabled] = useState(true);
const [uploadMessage, setUploadMessage] = useState("");
const [downloadUrl, setDownloadUrl] = useState("");
const session = useSession();
const userId = String(session.data?.user.dbId);
const { handleUpload, isGeneratingUrlLoading, isUploadLoading } =
useUploadFiles({
files,
userId,
setDownloadUrl,
});
const initiateUpload = () => {
setDownloadUrl("");
const newFolderKey = generateEcoSpreadsheetS3Folder(userId);
handleUpload(newFolderKey);
};
function handleOnChange(e: React.ChangeEvent<HTMLInputElement>) {
if (e.target.files && e.target.files.length === 3) {
const filesArray = Array.from(e.target.files);
const extensions = filesArray.map((file) =>
file.name.split(".").pop()?.toLowerCase()
);
const names = filesArray.map((file) => file.name.toLowerCase());
if (
extensions.includes("xml") &&
extensions.includes("pdf") &&
names.some((name) => name.includes("epr")) &&
names.some((name) => name.includes("ventilation"))
) {
setFiles(filesArray);
setButtonDisabled(false);
setUploadMessage("");
} else {
setUploadMessage(
"Please select the xml, the epr and the ventilation and condition report"
);
setButtonDisabled(true);
}
} else {
setUploadMessage("Please select exactly 3 files.");
setButtonDisabled(true);
}
}
function handleOnChangeExcel(e: React.ChangeEvent<HTMLInputElement>) {
if (e.target.files && e.target.files.length === 1) {
const ExcelfilesArray = Array.from(e.target.files);
const extensions = ExcelfilesArray.map((file) =>
file.name.split(".").pop()?.toLowerCase()
);
if (extensions.includes("xlsx")) {
// Append the excel onto the existing files
setFiles((prevFiles) => [...prevFiles, ...ExcelfilesArray]);
setUploadMessage("");
} else {
setUploadMessage("Please select the existing ECO spreadsheet excel");
setButtonDisabled(true);
}
} else {
setUploadMessage("Please select exactly one Excel file");
setButtonDisabled(true);
}
}
return (
<div className="flex flex-col items-center mt-20 tracking-wider leading-loose">
<div className="text-center">
<div className="mb-4">Please select the following files:</div>
<ul className="list-disc list-inside text-left ml-8 mb-8">
<li>An xml</li>
<li>EPR pdf</li>
<li>Ventilation and Condition pdf</li>
</ul>
<div className="mb-4">
Make sure these documents all relate to the same property
</div>
<div className="mb-5">
<SelectFolder handleOnChange={handleOnChange} />
</div>
<div className="text-red-500 mb-2">{uploadMessage}</div>
<div className="mb-4 text-left">
Additionally, you can upload an optional ECO Excel spreadsheet
<br />
if you wish to add new records to an already populated spreadsheet
</div>
<div className="mb-5">
<SelectFolder handleOnChange={handleOnChangeExcel} accept={".xlsx"} />
</div>
<div className="mb-4"></div>
<div className="flex justify-between w-full">
<Button
disabled={
buttonDisabled || isGeneratingUrlLoading || isUploadLoading
}
className="bg-brandblue hover:bg-hoverblue"
onClick={initiateUpload}
>
{isGeneratingUrlLoading || isUploadLoading
? "Loading..."
: "Upload"}
</Button>
</div>
<div className="flex items-center justify-center">
{isGeneratingUrlLoading || isUploadLoading ? (
<Spinner />
) : downloadUrl ? (
<a href={downloadUrl} className="text-blue-500 hover:underline">
Download Eco Spreadsheet
</a>
) : null}
</div>
</div>
</div>
);
}

View file

@ -5,7 +5,7 @@ import { ReactQueryProvider } from "./ReactQueryProvider";
import { AuthOptions } from "@/app/api/auth/[...nextauth]/authOptions";
import { getServerSession } from "next-auth/next";
import { cache } from "react";
import { Inter } from "next/font/google";
import { Inter, Manrope } from "next/font/google";
import { Toaster } from "@/app/shadcn_components/ui/toaster";
import { SpeedInsights } from "@vercel/speed-insights/next";
import type { Metadata } from "next";
@ -16,6 +16,12 @@ const inter = Inter({
variable: "--font-inter",
});
const manrope = Manrope({
subsets: ["latin"],
variable: "--font-manrope",
weight: ["400", "500", "700", "800"],
});
export const metadata = {
title: "Ara",
description: "Ara is Domnas portfolio intelligence platform that turns housing stock data into clear, costed retrofit and investment plans.",
@ -61,7 +67,7 @@ export default async function RootLayout({
const userImage = session?.user?.image;
return (
<html lang="en" className={inter.className}>
<html lang="en" className={`${inter.className} ${manrope.variable}`}>
<body className="min-h-screen flex flex-col">
<Provider>
<ReactQueryProvider>

View file

@ -22,18 +22,20 @@ export default async function PortfolioLayout(props: {
return (
<section>
<div className="flex justify-center">
<h1 className="text-3xl text-gray-700 font-bold mt-3 mb-4">
<h1 className="text-2xl text-gray-700 font-bold mt-1 mb-1">
{portfolioName}
</h1>
</div>
<div className="flex justify-center">
<div className="grid grid-cols-8 w-full max-w-8xl">
<div className="col-span-12 justify-center bg-gray-50 py-2 relative">
<div className="col-span-12 justify-center bg-gray-50 py-1 px-4 relative">
<Toolbar portfolioId={portfolioId} scenarios={scenarios} />
</div>
<div className="col-span-12">
{children}
</div>
</div>
</div>
{children}
</section>
);
}

View file

@ -1,12 +1,5 @@
import { getPortfolio, getPortfolioPerformance, getProperties } from "../utils";
import DataTable from "@/app/portfolio/[slug]/components/dataTable";
import { PropertyWithRelations } from "@/app/db/schema/property";
import PropertyTable from "../components/PropertyTable";
import SummaryBox from "@/app/components/portfolio/SummaryBox";
import { Component } from "lucide-react";
// We enfore caching of data for 60 seconds
export const revalidate = 60;
export default async function Page(props: {
@ -16,51 +9,11 @@ export default async function Page(props: {
}>;
}) {
const params = await props.params;
// This page is served from the server so we can make calls to the database
const portfolioId = params.slug;
let portfolioPerformance = await getPortfolioPerformance(portfolioId);
let scenarios;
if (portfolioPerformance.length > 0) {
scenarios = portfolioPerformance.map((performance) => ({
id: BigInt(performance.id),
name: performance.name || "Default Scenario",
budget: performance.budget,
totalCost: performance.cost,
funding: performance.funding,
contingency: performance.contingency,
co2EquivalentSavings: performance.co2EquivalentSavings,
propertyValuationIncrease: performance.propertyValuationIncrease,
energySavings: performance.energySavings,
energyCostSavings: performance.energyCostSavings,
labourDays: performance.labourDays,
isDefault: performance.isDefault,
}));
} else {
const portfolio = await getPortfolio(portfolioId);
scenarios = [
{
id: BigInt(0),
name: "Default",
budget: portfolio.budget,
totalCost: portfolio.cost,
funding: 0,
contingency: 0,
co2EquivalentSavings: portfolio.co2EquivalentSavings,
propertyValuationIncrease: portfolio.propertyValuationIncrease,
energySavings: portfolio.energySavings,
energyCostSavings: portfolio.energyCostSavings,
labourDays: portfolio.labourDays,
isDefault: true,
},
];
}
return (
<>
<PropertyTable portfolioId={portfolioId}/>
<>
<PropertyTable portfolioId={portfolioId} />
</>
);
}
}

View file

@ -20,6 +20,12 @@ import {
import type { EpcBandCount, AgeBandCount, PropertyTypeCount } from "./types";
const friendlyKeys = {
actual: "Actual EPCs",
estimated: "Estimated EPCs",
scenario: "Scenario result",
};
export function BreakdownChart({
epcBands,
ageBands,
@ -33,12 +39,6 @@ export function BreakdownChart({
}) {
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"
@ -73,7 +73,7 @@ export function BreakdownChart({
}
return rows;
}, [selected, epcBands, ageBands, propertyTypes, scenarioEpcBands]);
}, [selected, epcBands, ageBands, propertyTypes, scenarioEpcBands, friendlyKeys.actual, friendlyKeys.estimated, friendlyKeys.scenario]);
const categories =
selected === "epc"

View file

@ -179,7 +179,7 @@ export function ScenarioMeasuresModal({
data,
error,
}: ScenarioMeasuresModalProps) {
const measures: ScenarioMeasure[] = data?.measures ?? [];
const measures = useMemo<ScenarioMeasure[]>(() => data?.measures ?? [], [data?.measures]);
const grouped = useMemo(() => groupMeasuresByCategory(measures), [measures]);

View file

@ -454,7 +454,7 @@ export default function CompletionTrendsChart({
dataKey={isStacked ? "_total" : cat}
position="top"
style={{ fontSize: 10, fill: "#6b7280", fontWeight: 500 }}
formatter={(v: number) => (v === 0 ? "" : v)}
formatter={(v: unknown) => (v === 0 ? "" : String(v))}
/>
)}
</Bar>
@ -466,13 +466,16 @@ export default function CompletionTrendsChart({
{isStacked && (
<RechartsLegend
wrapperStyle={{ paddingTop: "12px", fontSize: "12px", color: "#6b7280" }}
iconType="square"
iconSize={10}
payload={categories.map((cat, i) => ({
value: cat,
type: "square" as const,
color: colors[i],
}))}
content={() => (
<ul style={{ display: "flex", flexWrap: "wrap", gap: "8px", padding: 0, margin: 0, listStyle: "none" }}>
{categories.map((cat, i) => (
<li key={cat} style={{ display: "flex", alignItems: "center", gap: "4px" }}>
<span style={{ display: "inline-block", width: 10, height: 10, backgroundColor: colors[i], flexShrink: 0 }} />
<span>{cat}</span>
</li>
))}
</ul>
)}
/>
)}
</Card>

View file

@ -0,0 +1,55 @@
"use client";
import { QuestionMarkCircleIcon } from "@heroicons/react/24/outline";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/app/shadcn_components/ui/tooltip";
export function HeritageTooltip() {
return (
<TooltipProvider delayDuration={200}>
<Tooltip>
<TooltipTrigger asChild>
<button
className="inline-flex items-center justify-center w-4 h-4 rounded-full text-gray-400 hover:text-brandblue transition-colors focus:outline-none"
aria-label="Heritage and planning restrictions explanation"
>
<QuestionMarkCircleIcon className="w-4 h-4" />
</button>
</TooltipTrigger>
<TooltipContent
side="right"
className="max-w-xs p-0 overflow-hidden border-gray-200 shadow-xl"
sideOffset={8}
>
<div className="px-4 pt-3 pb-2 bg-gray-50 border-b border-gray-100">
<p className="text-xs font-bold text-gray-700 uppercase tracking-widest">Planning Restrictions</p>
<p className="text-[11px] text-gray-400 mt-0.5">Conservation, listed &amp; heritage properties</p>
</div>
<div className="px-4 py-3 space-y-2.5">
<p className="text-[11px] text-gray-500 leading-snug">
Properties in a <span className="font-semibold text-gray-700">conservation area</span> or with{" "}
<span className="font-semibold text-gray-700">listed</span> or{" "}
<span className="font-semibold text-gray-700">heritage</span> status may have restrictions on
certain improvement measures, including:
</p>
<ul className="text-[11px] text-gray-500 leading-snug space-y-1 pl-3">
<li className="flex items-start gap-1.5"><span className="mt-0.5 shrink-0 text-gray-400"></span>Solar panel installation</li>
<li className="flex items-start gap-1.5"><span className="mt-0.5 shrink-0 text-gray-400"></span>External wall insulation</li>
<li className="flex items-start gap-1.5"><span className="mt-0.5 shrink-0 text-gray-400"></span>Alterations to windows, doors, or roof materials</li>
</ul>
</div>
<div className="px-4 py-2.5 bg-gray-50 border-t border-gray-100">
<p className="text-[11px] text-gray-400 leading-snug">
Always consult your <span className="font-semibold text-gray-600">local planning authority</span> to
confirm which measures are permitted before commissioning any works.
</p>
</div>
</TooltipContent>
</Tooltip>
</TooltipProvider>
);
}

View file

@ -0,0 +1,67 @@
"use client";
import { QuestionMarkCircleIcon } from "@heroicons/react/24/outline";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/app/shadcn_components/ui/tooltip";
const EPC_BANDS = [
{ band: "A", range: "92100", color: "#117d58", desc: "Exceptional, near-zero energy bills, usually new-builds or eco-homes." },
{ band: "B", range: "8191", color: "#2da55c", desc: "Very efficient, often featuring solar panels, high-grade insulation, and modern heating." },
{ band: "C", range: "6980", color: "#8dbd40", desc: "Good, above-average efficiency; common target for retrofitting existing homes." },
{ band: "D", range: "5568", color: "#f7cd14", desc: "Average, the typical rating for many homes in the UK." },
{ band: "E", range: "3954", color: "#f3a96a", desc: "Below average, likely requires better insulation and boiler upgrades." },
{ band: "F", range: "2138", color: "#ef8026", desc: "Poor, high energy costs and lower energy performance." },
{ band: "G", range: "120", color: "#e41e3b", desc: "Very poor, least efficient, high energy costs." },
];
export function EpcInfoTooltip() {
return (
<TooltipProvider delayDuration={200}>
<Tooltip>
<TooltipTrigger asChild>
<button
className="inline-flex items-center justify-center w-4 h-4 rounded-full text-gray-400 hover:text-brandblue transition-colors focus:outline-none"
aria-label="EPC and SAP score explanation"
>
<QuestionMarkCircleIcon className="w-4 h-4" />
</button>
</TooltipTrigger>
<TooltipContent
side="right"
className="max-w-xs p-0 overflow-hidden border-gray-200 shadow-xl"
sideOffset={8}
>
<div className="px-4 pt-3 pb-2 bg-gray-50 border-b border-gray-100">
<p className="text-xs font-bold text-gray-700 uppercase tracking-widest">EPC Rating Bands</p>
<p className="text-[11px] text-gray-400 mt-0.5">Based on the SAP score (1100)</p>
</div>
<div className="divide-y divide-gray-50">
{EPC_BANDS.map(({ band, range, color, desc }) => (
<div key={band} className="flex items-start gap-3 px-4 py-2.5">
<span
className="shrink-0 w-6 h-6 rounded-md flex items-center justify-center text-white text-xs font-black leading-none mt-0.5"
style={{ backgroundColor: color }}
>
{band}
</span>
<div className="min-w-0">
<p className="text-[11px] font-bold text-gray-700">{range}</p>
<p className="text-[11px] text-gray-400 leading-snug mt-0.5">{desc}</p>
</div>
</div>
))}
</div>
<div className="px-4 py-2.5 bg-gray-50 border-t border-gray-100">
<p className="text-[11px] text-gray-400 leading-snug">
<span className="font-semibold text-gray-600">SAP score</span> Standard Assessment Procedure. A government-approved method for rating the energy performance of homes on a scale of 1 to 100.
</p>
</div>
</TooltipContent>
</Tooltip>
</TooltipProvider>
);
}

View file

@ -1,189 +1,643 @@
import EpcCard from "@/app/components/building-passport/EpcCard";
import FeatureTable from "@/app/components/building-passport/FeatureTable";
import {
PropertyDetailsEpc,
PropertyDetailsSpatial,
PropertyMeta,
} from "@/app/db/schema/property";
BoltIcon,
SunIcon,
Squares2X2Icon,
SparklesIcon,
} from "@heroicons/react/24/outline";
import { Card, CardContent, CardHeader, CardTitle } from "@/app/shadcn_components/ui/card";
import { Separator } from "@/app/shadcn_components/ui/separator";
import { formatDateTime } from "@/app/utils";
import {
generalColumns,
nonInstrusiveColumns,
retrofitColumns,
} from "@/app/components/building-passport/FeatureTableColumns";
import {
formatGeneralFeatures,
formatHeatDemandFeatures,
formatRetrofitFeatures,
getConditionReport,
getPropertyMeta,
getSpatialData,
formatGeneralFeatures,
getNonIntrusiveSurvey,
getDocument,
getEnergyAssessmentFromS3,
getPropertyMeta,
getConditionReport,
getSpatialData,
} from "../utils";
import { getSolarData, getSolarScenarioData } from "../solar-analysis/utils";
import PropertyMapWrapper from "../solar-analysis/PropertyMapWrapper";
import SolarSimulationWrapper from "../solar-analysis/SolarSimulationWrapper";
import { EpcInfoTooltip } from "./EpcInfoTooltip";
interface PropertyDetailsCardProps {
conditionReportData: PropertyDetailsEpc;
propertyMeta: PropertyMeta;
propertyDetailsSpatial: PropertyDetailsSpatial;
// ── Helpers ────────────────────────────────────────────────────────────────────
function getEpcHex(letter: string | null | undefined): string {
switch (letter?.toUpperCase()) {
case "A": return "#117d58";
case "B": return "#2da55c";
case "C": return "#8dbd40";
case "D": return "#f7cd14";
case "E": return "#f3a96a";
case "F": return "#ef8026";
case "G": return "#e41e3b";
default: return "#9ca3af";
}
}
const rowTitleStyle = "text-brandblue align-top pb-3";
const rowValueStyle = "text-brandblue text-end pr-8 pt-1 align-top pb-3";
function getEpcDescription(letter: string | null | undefined): string {
switch (letter?.toUpperCase()) {
case "A":
case "B": return "This property is performing at or above modern energy standards.";
case "C": return "This property meets modern energy performance benchmarks.";
case "D": return "This property is performing slightly below modern energy standards.";
case "E": return "This property is performing below modern energy standards.";
case "F":
case "G": return "This property is performing significantly below modern energy standards.";
default: return "Energy performance data is not yet available for this property.";
}
}
function PropertyDetailsCard({
conditionReportData,
propertyMeta,
propertyDetailsSpatial,
}: PropertyDetailsCardProps) {
const propertyText = [propertyMeta.builtForm, propertyMeta.propertyType]
.filter(Boolean)
.join(" ");
function getDirectionLabel(az: number): { label: string; short: string } {
const norm = ((az % 360) + 360) % 360;
if (norm >= 337.5 || norm < 22.5) return { short: "N", label: "North" };
if (norm < 67.5) return { short: "NE", label: "North-East" };
if (norm < 112.5) return { short: "E", label: "East" };
if (norm < 157.5) return { short: "SE", label: "South-East" };
if (norm < 202.5) return { short: "S", label: "South" };
if (norm < 247.5) return { short: "SW", label: "South-West" };
if (norm < 292.5) return { short: "W", label: "West" };
return { short: "NW", label: "North-West" };
}
function getRatingClasses(rating: string): string {
switch (rating) {
case "Very good": return "bg-green-600 text-white";
case "Good": return "bg-green-100 text-green-800";
case "Poor": return "bg-surface-container text-on-surface-variant";
case "Very poor": return "bg-error-container text-error";
default: return "bg-gray-100 text-gray-400";
}
}
function formatDate(date: Date): string {
return new Date(date).toLocaleDateString("en-GB", {
weekday: "long",
year: "numeric",
month: "long",
day: "numeric",
});
}
const SEGMENT_THEMES: Record<string, {
gradient: string;
border: string;
badge: string;
label: string;
dot: string;
}> = {
S: { gradient: "from-amber-50 to-orange-50/60", border: "border-amber-200/80", badge: "bg-amber-100 text-amber-800 border-amber-300", label: "text-amber-900", dot: "bg-amber-400" },
SE: { gradient: "from-orange-50 to-amber-50/60", border: "border-orange-200/80", badge: "bg-orange-100 text-orange-800 border-orange-300", label: "text-orange-900", dot: "bg-orange-400" },
SW: { gradient: "from-orange-50 to-amber-50/60", border: "border-orange-200/80", badge: "bg-orange-100 text-orange-800 border-orange-300", label: "text-orange-900", dot: "bg-orange-400" },
E: { gradient: "from-sky-50 to-blue-50/40", border: "border-sky-200/80", badge: "bg-sky-100 text-sky-800 border-sky-300", label: "text-sky-900", dot: "bg-sky-400" },
W: { gradient: "from-sky-50 to-blue-50/40", border: "border-sky-200/80", badge: "bg-sky-100 text-sky-800 border-sky-300", label: "text-sky-900", dot: "bg-sky-400" },
N: { gradient: "from-slate-50 to-gray-50/40", border: "border-slate-200/80", badge: "bg-slate-100 text-slate-700 border-slate-300", label: "text-slate-800", dot: "bg-slate-400" },
NE: { gradient: "from-slate-50 to-gray-50/40", border: "border-slate-200/80", badge: "bg-slate-100 text-slate-700 border-slate-300", label: "text-slate-800", dot: "bg-slate-400" },
NW: { gradient: "from-slate-50 to-gray-50/40", border: "border-slate-200/80", badge: "bg-slate-100 text-slate-700 border-slate-300", label: "text-slate-800", dot: "bg-slate-400" },
};
// ── Sub-components ─────────────────────────────────────────────────────────────
function RoofSegmentCard({
index,
azimuthDegrees,
pitchDegrees,
areaMeters2,
groundAreaMeters2,
sunshineQuantiles,
planeHeightAtCenterMeters,
}: {
index: number;
azimuthDegrees: number;
pitchDegrees: number;
areaMeters2: number;
groundAreaMeters2: number;
sunshineQuantiles: number[];
planeHeightAtCenterMeters: number;
}) {
const dir = getDirectionLabel(azimuthDegrees);
const theme = SEGMENT_THEMES[dir.short] ?? SEGMENT_THEMES["N"];
const medianSunshine = sunshineQuantiles?.[4] ?? null;
const peakSunshine = sunshineQuantiles?.[8] ?? null;
return (
<div className="w-full flex flex-col items-center p-4 shadow rounded-md justify-start bg-gray-100">
<div className="grid grid-cols-2 gap-8 text-m w-full h-full text-sm">
<div className="border-r">
<table className="w-full ">
<tbody>
<tr>
<td className={rowTitleStyle}>Year built:</td>
<td className={rowValueStyle}>{propertyMeta.yearBuilt}</td>
</tr>
<tr>
<td className={rowTitleStyle}>Property Type:</td>
<td className={rowValueStyle}>{propertyText}</td>
</tr>
<tr>
<td className={rowTitleStyle}>Total floor area:</td>
<td className={rowValueStyle}>
{`${conditionReportData.totalFloorArea} m`}
<sup>2</sup>
</td>
</tr>
<tr>
<td className={rowTitleStyle}>In conservation area:</td>
<td className={rowValueStyle}>
{propertyDetailsSpatial.conservationStatus ? "Yes" : "No"}
</td>
</tr>
<tr>
<td className={rowTitleStyle}>Is listed:</td>
<td className={rowValueStyle}>
{propertyDetailsSpatial.isListedBuilding ? "Yes" : "No"}
</td>
</tr>
<tr>
<td className={rowTitleStyle}>Is heritage:</td>
<td className={rowValueStyle}>
{propertyDetailsSpatial.isHeritageBuilding ? "Yes" : "No"}
</td>
</tr>
</tbody>
</table>
<div className={`rounded-2xl border bg-gradient-to-br ${theme.gradient} ${theme.border} shadow-sm overflow-hidden`}>
<div className="px-5 pt-5 pb-4">
<div className="flex items-start justify-between mb-3">
<div>
<p className="text-[10px] font-bold uppercase tracking-widest text-gray-400 mb-1">
Segment {index + 1}
</p>
<h3 className={`text-lg font-bold leading-tight ${theme.label}`}>
{dir.label}
</h3>
</div>
<div className={`flex items-center gap-1 text-xs font-bold px-2.5 py-1 rounded-full border ${theme.badge}`}>
<span className={`w-1.5 h-1.5 rounded-full ${theme.dot}`} />
{dir.short}
</div>
</div>
<table className="w-full">
<tbody>
<tr>
<td className={rowTitleStyle}>Local Authority:</td>
<td className={rowValueStyle}>{propertyMeta.localAuthority}</td>
</tr>
<tr>
<td className={rowTitleStyle}>Constituency:</td>
<td className={rowValueStyle}>{propertyMeta.constituency}</td>
</tr>
<tr>
<td className={rowTitleStyle}>Tenure</td>
<td className={rowValueStyle}>{propertyMeta.tenure}</td>
</tr>
<tr>
<td className={rowTitleStyle}>Number of Habitable Rooms:</td>
<td className={rowValueStyle}>
{propertyMeta.numberOfRooms || "unkown"}
</td>
</tr>
</tbody>
</table>
{medianSunshine !== null && (
<div className="mt-3">
<div className="flex items-center justify-between text-[10px] text-gray-400 mb-1">
<span>Median sunshine</span>
<span className="font-semibold text-gray-600">{Math.round(medianSunshine)} hrs/yr</span>
</div>
<div className="h-1.5 bg-white/60 rounded-full overflow-hidden">
<div
className={`h-full rounded-full ${theme.dot} opacity-70`}
style={{ width: `${Math.min(100, (medianSunshine / 1600) * 100)}%` }}
/>
</div>
</div>
)}
</div>
<Separator className="opacity-30" />
<dl className="grid grid-cols-2 gap-px bg-black/5 text-sm">
{[
{ label: "Roof area", value: `${areaMeters2.toFixed(1)}` },
{ label: "Ground area", value: `${groundAreaMeters2.toFixed(1)}` },
{ label: "Pitch", value: `${pitchDegrees.toFixed(1)}°` },
{ label: "Azimuth", value: `${azimuthDegrees.toFixed(1)}°` },
{ label: "Height", value: `${planeHeightAtCenterMeters.toFixed(1)} m` },
peakSunshine !== null
? { label: "Peak (P90)", value: `${Math.round(peakSunshine)} hrs` }
: { label: "", value: "" },
].map(({ label, value }, i) =>
label ? (
<div key={i} className="bg-white/40 px-4 py-3 hover:bg-white/60 transition-colors">
<dt className="text-[10px] uppercase tracking-wide text-gray-400 font-medium mb-0.5">{label}</dt>
<dd className="font-semibold text-gray-700 tabular-nums">{value}</dd>
</div>
) : (
<div key={i} className="bg-white/20" />
)
)}
</dl>
</div>
);
}
const formatDate = (dateString: Date) => {
const date = new Date(dateString);
return date.toLocaleDateString("en-GB", {
weekday: "long", // "Monday" through "Sunday"
year: "numeric", // "2024"
month: "long", // "January" through "December"
day: "numeric", // "1", "2", ..., "31"
});
};
// ── Page ───────────────────────────────────────────────────────────────────────
export default async function PreAssessmentReport(props: {
params: Promise<{ slug: string; propertyId: string }>;
}) {
const params = await props.params;
const propertyMeta = await getPropertyMeta(params.propertyId);
const conditionReportData = await getConditionReport(params.propertyId);
const propertyDetailsSpatial = await getSpatialData(propertyMeta.uprn);
const generalFeatures = formatGeneralFeatures(
conditionReportData,
propertyMeta.propertyType
);
const conditionReport = await getConditionReport(params.propertyId);
const spatial = await getSpatialData(Number(propertyMeta.uprn));
const nonIntrusiveSurvey = await getNonIntrusiveSurvey(propertyMeta.uprn);
const retrofitFeatures = formatRetrofitFeatures(conditionReportData);
const heatingDemand = formatHeatDemandFeatures(conditionReportData);
// If total floor area is missing, we have a problem
if (conditionReportData.totalFloorArea == null) {
if (conditionReport.totalFloorArea == null) {
console.error("Total floor area is missing");
return null;
}
return (
<div className="leading-loose tracking-wider">
<div className="text-gray-700 text-sm mt-4">
Last updated: {formatDateTime(propertyMeta.updatedAt)}
</div>
<div className="flex flex-col items-stretch mb-4">
<div className="flex flex-row justify-start mt-4 space-x-4">
<EpcCard
epcRating={propertyMeta.currentEpcRating}
fullMargin={false}
sap={String(propertyMeta.currentSapPoints)}
/>
const retrofitFeatures = formatRetrofitFeatures(conditionReport);
const fundamentalDetails = [
{ feature: "Property type", description: propertyMeta.propertyType ?? "Unknown" },
{ feature: "Built form", description: propertyMeta.builtForm ?? "Unknown" },
{ feature: "Floor area", description: conditionReport.totalFloorArea != null ? `${Math.round(conditionReport.totalFloorArea)}` : "Unknown" },
{ feature: "Age", description: propertyMeta.yearBuilt != null ? String(propertyMeta.yearBuilt) : "Unknown" },
];
const generalFeatures = [
...fundamentalDetails,
...formatGeneralFeatures(conditionReport, propertyMeta.propertyType)
.filter((f) => !["Mains gas", "Year built", "Property type", "Floor area", "Habitable rooms"].includes(f.feature))
.slice(0, 4),
];
<PropertyDetailsCard
conditionReportData={conditionReportData}
propertyMeta={propertyMeta}
propertyDetailsSpatial={propertyDetailsSpatial}
const rawSolar = await getSolarData(Number(propertyMeta.uprn));
const solarData = rawSolar ?? null;
const epcLetter = propertyMeta.currentEpcRating ?? null;
const sapScore = propertyMeta.currentSapPoints ?? 0;
const epcHex = getEpcHex(epcLetter);
// Solar derived values
const sp = solarData?.googleApiResponse?.solarPotential ?? null;
const solarScenarioData = solarData ? await getSolarScenarioData(String(solarData.id)) : null;
const maxAnnualKwh = sp
? Math.round(sp.solarPanelConfigs[sp.solarPanelConfigs.length - 1].yearlyEnergyDcKwh)
: 0;
const roofSegmentStats: any[] = sp?.roofSegmentStats ?? [];
const lat = solarData?.googleApiResponse?.center?.latitude ?? spatial?.latitude ?? 0;
const lng = solarData?.googleApiResponse?.center?.longitude ?? spatial?.longitude ?? 0;
const imageryQuality = solarData?.googleApiResponse?.imageryQuality ?? null;
const imageryDate = solarData?.googleApiResponse?.imageryDate ?? null;
const imageryDateStr = imageryDate
? `${imageryDate.year}-${String(imageryDate.month).padStart(2, "0")}-${String(imageryDate.day).padStart(2, "0")}`
: null;
const qualityColors: Record<string, string> = {
HIGH: "bg-emerald-50 text-emerald-700 border-emerald-200",
MEDIUM: "bg-amber-50 text-amber-700 border-amber-200",
LOW: "bg-gray-50 text-gray-600 border-gray-200",
};
const qualityBadge = imageryQuality ? (qualityColors[imageryQuality] ?? qualityColors.LOW) : "";
const qualityText = imageryQuality === "HIGH" ? "High quality"
: imageryQuality === "MEDIUM" ? "Medium quality"
: "Base quality";
return (
<div className="max-w-7xl mx-auto py-10 space-y-8">
{/* ── Page Header ─────────────────────────────────────────────────────── */}
<header className="flex items-end justify-between">
<div>
<p className="font-manrope text-xs font-bold text-brandmidblue uppercase tracking-widest mb-2">
Structural Analysis
</p>
<h1 className="font-manrope font-extrabold text-4xl text-brandblue tracking-tighter">
Property Details
</h1>
</div>
<p className="text-sm text-gray-400 font-medium">
Last updated: {formatDateTime(propertyMeta.updatedAt)}
</p>
</header>
{/* ── Row 1: EPC hero + energy metrics + general features ──────────── */}
<div className="grid grid-cols-12 gap-6 items-stretch">
{/* EPC Hero — matches overview page style */}
<section className="col-span-12 lg:col-span-5 bg-white rounded-2xl p-10 flex flex-col justify-between shadow-sm border border-gray-100 relative overflow-hidden">
<div
className="absolute top-0 right-0 w-72 h-72 rounded-full blur-3xl -mr-20 -mt-20 opacity-10"
style={{ backgroundColor: epcHex }}
/>
<div className="relative z-10">
<div className="flex items-center gap-2 mb-6">
<p className="font-manrope text-xs font-bold text-brandmidblue uppercase tracking-widest">
Current Efficiency State
</p>
<EpcInfoTooltip />
</div>
<div className="flex items-baseline gap-4 mb-4">
<span
className="text-[110px] font-black font-manrope leading-none tracking-tighter"
style={{ color: epcHex }}
>
{epcLetter ?? "—"}
</span>
<span className="text-4xl font-bold font-manrope text-gray-400">
/ {sapScore || ""}
</span>
</div>
<p className="text-gray-500 font-medium text-sm max-w-xs leading-relaxed">
{getEpcDescription(epcLetter)}
</p>
</div>
<div className="mt-10 space-y-3">
<div className="relative h-2.5 w-full bg-gray-100 rounded-full overflow-hidden">
<div
className="absolute inset-y-0 left-0 rounded-full"
style={{
width: `${Math.min(100, Math.max(2, sapScore))}%`,
background: "linear-gradient(to right, #e41e3b, #ef8026, #f7cd14, #8dbd40, #117d58)",
}}
/>
</div>
<div className="flex justify-between text-[10px] font-bold text-gray-400 uppercase tracking-wider">
<span>Very Inefficient</span>
<span>Very Efficient</span>
</div>
</div>
</section>
{/* Right column: 3 metric cards + general features grid */}
<div className="col-span-12 lg:col-span-7 flex flex-col gap-6">
<div className="grid grid-cols-3 gap-6">
<div className="bg-white border border-gray-100 p-7 rounded-2xl flex flex-col justify-between shadow-sm">
<p className="text-[10px] font-bold text-gray-400 uppercase tracking-widest mb-4">
Energy Demand
</p>
<div>
<p className="text-3xl font-black font-manrope text-brandblue">
{conditionReport.currentEnergyDemand != null
? Number(conditionReport.currentEnergyDemand).toFixed(0)
: "—"}
</p>
<p className="text-sm font-medium text-gray-400 mt-1">kWh / year</p>
</div>
</div>
<div className="bg-white border border-gray-100 p-7 rounded-2xl flex flex-col justify-between shadow-sm">
<p className="text-[10px] font-bold text-gray-400 uppercase tracking-widest mb-4">
CO Emissions
</p>
<div>
<p className="text-3xl font-black font-manrope text-brandblue">
{conditionReport.co2Emissions ?? "—"}
</p>
<p className="text-sm font-medium text-gray-400 mt-1">tonnes / year</p>
</div>
</div>
<div className="bg-white border border-gray-100 p-7 rounded-2xl flex flex-col justify-between shadow-sm">
<p className="text-[10px] font-bold text-gray-400 uppercase tracking-widest mb-4">
Primary Energy
</p>
<div>
<p className="text-3xl font-black font-manrope text-brandblue">
{conditionReport.primaryEnergyConsumption ?? "—"}
</p>
<p className="text-sm font-medium text-gray-400 mt-1">kWh / m² / year</p>
</div>
</div>
</div>
{/* General features grid — fills remaining height */}
{generalFeatures.length > 0 && (
<div className="flex-1 bg-white rounded-2xl p-6 shadow-sm border border-gray-100 flex flex-col">
<p className="font-manrope text-xs font-bold text-brandmidblue uppercase tracking-widest mb-4">
General Features
</p>
<div className="grid grid-cols-2 gap-3 flex-1">
{generalFeatures.map((f) => {
const desc = String(f.description ?? "");
const isUnknown = desc === "Unknown" || desc === "";
return (
<div
key={f.feature}
className="bg-gray-50 rounded-xl px-4 py-3 flex flex-col justify-between"
>
<p className="text-[10px] font-bold text-gray-400 uppercase tracking-widest mb-1">
{f.feature}
</p>
<p className={`text-sm font-semibold ${isUnknown ? "text-gray-300 italic" : "text-brandblue"}`}>
{isUnknown ? "Unknown" : desc}
</p>
</div>
);
})}
</div>
</div>
)}
</div>
</div>
{/* ── Row 2: Fabric table (full width) ─────────────────────────────────── */}
<section className="bg-white rounded-2xl p-10 shadow-sm border border-gray-100">
<h2 className="font-manrope font-bold text-xl text-brandblue mb-8">
Existing Infrastructure Details
</h2>
<div className="overflow-x-auto">
<table className="w-full text-left border-collapse">
<thead>
<tr className="text-[10px] font-bold text-gray-400 uppercase tracking-widest border-b border-gray-100">
<th className="pb-4 pr-4 w-40">Feature</th>
<th className="pb-4 px-4">Description</th>
<th className="pb-4 pl-4 text-right w-28">Rating</th>
</tr>
</thead>
<tbody className="text-sm">
{retrofitFeatures.map((f) => (
<tr key={f.feature} className="hover:bg-gray-50 transition-colors">
<td className="py-4 pr-4 font-bold text-brandblue whitespace-nowrap">
{f.feature}
</td>
<td className="py-4 px-4 text-gray-500 font-medium">{f.description}</td>
<td className="py-4 pl-4 text-right whitespace-nowrap">
<span className={`px-3 py-1 rounded-full font-bold text-[10px] uppercase tracking-wider ${getRatingClasses(f.rating)}`}>
{f.rating}
</span>
</td>
</tr>
))}
</tbody>
</table>
</div>
</section>
{/* ── Row 3: Non-Intrusive Survey ──────────────────────────────────────── */}
{nonIntrusiveSurvey && (
<div>
<div className="flex py-8 text-lg">Non-Intrusive Survey</div>
<div className="flex mb-2 text-sm text-gray-500">
Conducted by: {nonIntrusiveSurvey.surveyor} on{" "}
{formatDate(nonIntrusiveSurvey.surveyDate)}
<section className="bg-white rounded-2xl p-10 shadow-sm border border-gray-100">
<div className="flex flex-wrap items-end justify-between gap-4 mb-8">
<h2 className="font-manrope font-bold text-xl text-brandblue">
Non-Intrusive Survey
</h2>
<p className="text-sm text-gray-400">
Conducted by{" "}
<span className="font-semibold text-gray-600">{nonIntrusiveSurvey.surveyor}</span>
{" "}on {formatDate(nonIntrusiveSurvey.surveyDate)}
</p>
</div>
<table className="w-full text-left border-collapse">
<thead>
<tr className="text-[10px] font-bold text-gray-400 uppercase tracking-widest border-b border-gray-100">
<th className="pb-4 pr-4 w-1/4">Feature</th>
<th className="pb-4 pl-4">Recorded Observation</th>
</tr>
</thead>
<tbody className="text-sm">
{nonIntrusiveSurvey.notes.map((note: { title: string; note: string }, i: number) => (
<tr key={i} className="hover:bg-gray-50 transition-colors">
<td className="py-4 pr-4 font-bold text-brandblue">{note.title}</td>
<td className="py-4 pl-4 text-gray-500">{note.note}</td>
</tr>
))}
</tbody>
</table>
</section>
)}
{/* ── Solar Section ────────────────────────────────────────────────────── */}
{/* Solar section header — always shown if we have coords */}
{(solarData || spatial?.latitude) && (
<div className="flex items-center gap-4 pt-2">
<div className="w-8 h-8 rounded-xl bg-amber-50 border border-amber-200/60 flex items-center justify-center shrink-0">
<SunIcon className="w-4 h-4 text-amber-600" />
</div>
<div>
<p className="font-manrope text-xs font-bold text-amber-600 uppercase tracking-widest">
Solar Potential Analysis
</p>
</div>
<FeatureTable
data={nonIntrusiveSurvey.notes}
columns={nonInstrusiveColumns}
/>
</div>
)}
<div className="flex py-8 text-lg">General Features</div>
<FeatureTable data={generalFeatures} columns={generalColumns} />
<div className="flex py-8 text-lg">Existing Property Features</div>
<FeatureTable data={retrofitFeatures} columns={retrofitColumns} />
<div className="flex py-8 text-lg">Heating Demand</div>
<FeatureTable data={heatingDemand} columns={generalColumns} />
{solarData && sp ? (
<>
{/* Row 4: Solar info + Rooftop Summary side by side, then map below */}
<div className="grid grid-cols-12 gap-6">
{/* Left: scenario context + imagery info + rooftop summary */}
<div className="col-span-12 lg:col-span-5 flex flex-col gap-4">
{/* Scenario / imagery context card */}
<div className="bg-amber-50 border border-amber-200/60 rounded-2xl px-5 py-4 flex flex-col gap-3">
<p className="text-sm font-medium text-amber-800 leading-snug">
{solarScenarioData?.scenrioType === "building"
? "Figures represent the building as a whole."
: "Figures represent this individual unit."}
</p>
<div className="flex flex-wrap items-center gap-2">
{imageryQuality && (
<span className={`text-xs font-semibold px-2.5 py-1 rounded-full border ${qualityBadge}`}>
{qualityText}
</span>
)}
{imageryDateStr && (
<span className="text-xs text-amber-700/70 font-medium">
Imagery: {imageryDateStr}
</span>
)}
</div>
</div>
{/* Rooftop Summary */}
<Card className="flex-1 shadow-sm border-gray-200/80">
<CardHeader className="pb-1 pt-6 px-6">
<CardTitle className="text-base font-bold text-brandblue font-manrope">
Rooftop Summary
</CardTitle>
<p className="text-xs text-gray-400 mt-0.5">
Key metrics extracted from aerial imagery analysis.
</p>
</CardHeader>
<CardContent className="px-6 pb-6 pt-2">
<div className="divide-y divide-gray-100 text-sm">
{[
{
icon: <BoltIcon className="w-3.5 h-3.5" />,
label: "Max annual output",
value: `${maxAnnualKwh.toLocaleString()} kWh`,
},
{
icon: <Squares2X2Icon className="w-3.5 h-3.5" />,
label: "Max panel count",
value: `${sp.maxArrayPanelsCount} panels`,
},
{
icon: <Squares2X2Icon className="w-3.5 h-3.5" />,
label: "Max array area",
value: `${sp.maxArrayAreaMeters2.toFixed(0)}`,
},
{
icon: <SparklesIcon className="w-3.5 h-3.5" />,
label: "Roof faces identified",
value: `${roofSegmentStats.length}`,
},
{
icon: <SunIcon className="w-3.5 h-3.5" />,
label: "Max sunshine hours",
value: `${Math.round(sp.maxSunshineHoursPerYear).toLocaleString()} hrs/yr`,
},
{
icon: <SparklesIcon className="w-3.5 h-3.5" />,
label: "Carbon offset factor",
value: `${Math.round(sp.carbonOffsetFactorKgPerMwh)} kg/MWh`,
},
{
icon: <Squares2X2Icon className="w-3.5 h-3.5" />,
label: "Panel dimensions",
value: `${sp.panelWidthMeters} m × ${sp.panelHeightMeters} m`,
},
{
icon: <BoltIcon className="w-3.5 h-3.5" />,
label: "Panel capacity",
value: `${sp.panelCapacityWatts} W`,
},
].map(({ icon, label, value }, i) => (
<div key={i} className="flex items-center justify-between py-3 group">
<div className="flex items-center gap-2.5">
<span className="shrink-0 w-6 h-6 rounded-md bg-brandblue/5 flex items-center justify-center text-brandblue/60 group-hover:bg-brandblue/10 transition-colors">
{icon}
</span>
<span className="text-gray-500">{label}</span>
</div>
<span className="font-semibold text-gray-800 tabular-nums">{value}</span>
</div>
))}
</div>
</CardContent>
</Card>
</div>
{/* Right: Map */}
<div className="col-span-12 lg:col-span-7 rounded-2xl overflow-hidden border border-gray-200 shadow-sm min-h-[500px]">
<PropertyMapWrapper latitude={lat} longitude={lng} />
</div>
</div>
{/* Row 5: Roof Profile */}
{roofSegmentStats.length > 0 && (
<section>
<div className="mb-6">
<h2 className="font-manrope font-bold text-xl text-brandblue mb-1">
Roof Profile
</h2>
<p className="text-sm text-gray-400 leading-relaxed">
{roofSegmentStats.length} roof face{roofSegmentStats.length !== 1 ? "s" : ""} identified.
South-facing segments with low pitch typically yield the highest solar output.
</p>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
{roofSegmentStats.map((seg: any, i: number) => (
<RoofSegmentCard
key={i}
index={i}
azimuthDegrees={seg.azimuthDegrees}
pitchDegrees={seg.pitchDegrees}
areaMeters2={seg.stats.areaMeters2}
groundAreaMeters2={seg.stats.groundAreaMeters2}
sunshineQuantiles={seg.stats.sunshineQuantiles}
planeHeightAtCenterMeters={seg.planeHeightAtCenterMeters}
/>
))}
</div>
</section>
)}
{/* Row 6: Solar Simulation */}
<section className="bg-white rounded-2xl p-10 shadow-sm border border-gray-100">
<h2 className="font-manrope font-bold text-xl text-brandblue mb-2">
Solar Configurations
</h2>
<p className="text-sm text-gray-400 mb-8 leading-relaxed">
All array sizes modelled for this property. The efficiency curve reveals
how output scales with system size useful for understanding which roof
faces are doing the most work.
</p>
<SolarSimulationWrapper
solarPanelConfigs={sp.solarPanelConfigs}
panelCapacityWatts={sp.panelCapacityWatts}
panelLifetimeYears={sp.panelLifetimeYears}
panelWidthMeters={sp.panelWidthMeters}
panelHeightMeters={sp.panelHeightMeters}
/>
</section>
</>
) : (spatial?.latitude && spatial?.longitude) ? (
/* No solar data — show map with annotation */
<div className="grid grid-cols-12 gap-6">
<div className="col-span-12 lg:col-span-7 rounded-2xl overflow-hidden border border-gray-200 shadow-sm relative" style={{ height: "400px" }}>
<PropertyMapWrapper latitude={spatial.latitude} longitude={spatial.longitude} />
<div className="absolute inset-0 flex items-end justify-start p-5 pointer-events-none">
<div className="bg-white/90 backdrop-blur-sm border border-amber-200 rounded-xl px-4 py-3 shadow-sm max-w-xs">
<p className="text-xs font-bold text-amber-700 uppercase tracking-widest mb-1">Solar data unavailable</p>
<p className="text-xs text-gray-500 leading-snug">
Solar potential analysis has not been completed for this property. This may be due to insufficient aerial imagery coverage or the property type may not be suitable for solar assessment.
</p>
</div>
</div>
</div>
<div className="col-span-12 lg:col-span-5 bg-white rounded-2xl p-8 shadow-sm border border-gray-100 flex flex-col justify-center">
<div className="w-10 h-10 rounded-2xl bg-amber-50 border border-amber-200 flex items-center justify-center mb-4">
<SunIcon className="w-5 h-5 text-amber-500" />
</div>
<h3 className="font-manrope font-bold text-lg text-brandblue mb-2">Solar Analysis Not Available</h3>
<p className="text-sm text-gray-500 leading-relaxed">
We were unable to retrieve solar potential data for this address. This can happen when aerial imagery quality is insufficient, the property is in a densely shaded area, or a solar survey has not yet been commissioned.
</p>
</div>
</div>
) : null}
</div>
);
}

View file

@ -0,0 +1,598 @@
"use client";
import type { ReactNode } from "react";
import {
CheckCircleIcon,
XCircleIcon,
MinusCircleIcon,
HomeIcon,
WrenchScrewdriverIcon,
SparklesIcon,
FireIcon,
ExclamationTriangleIcon,
ClockIcon,
} from "@heroicons/react/24/outline";
import {
CheckCircleIcon as CheckCircleSolid,
XCircleIcon as XCircleSolid,
} from "@heroicons/react/24/solid";
import { AlertTriangle, Hourglass } from "lucide-react";
// ── Constants ─────────────────────────────────────────────────────────────────
const DISPLAY_NAMES: Record<string, string> = {
damp_and_mould_growth: "Damp and Mould Growth",
excess_cold: "Excess Cold",
excess_heat: "Excess Heat",
asbestos_and_mm_fibres: "Asbestos and MM Fibres",
biocides: "Biocides",
carbon_monoxide: "Carbon Monoxide",
lead: "Lead",
radiation: "Radiation",
uncombusted_fuel_gas: "Uncombusted Fuel Gas",
volatile_organic_compounds: "Volatile Organic Compounds",
crowding_and_space: "Crowding and Space",
entry_by_intruders: "Entry by Intruders",
lighting: "Lighting",
noise: "Noise",
domestic_hygiene_pests_and_refuse: "Domestic Hygiene, Pests, and Refuse",
food_safety: "Food Safety",
personal_hygiene_sanitation_and_drainage: "Personal Hygiene, Sanitation, and Drainage",
water_supply: "Water Supply",
falls_associated_with_baths: "Falls Associated with Baths",
falls_on_level_surfaces: "Falls on Level Surfaces",
falls_on_stairs_and_steps: "Falls on Stairs and Steps",
falls_between_levels: "Falls Between Levels",
electrical_hazards: "Electrical Hazards",
fire: "Fire",
flames_hot_surfaces_and_materials: "Flames, Hot Surfaces, and Materials",
collision_and_entrapment: "Collision and Entrapment",
explosions: "Explosions",
ergonomics: "Ergonomics",
structural_collapse_and_falling_elements: "Structural Collapse and Falling Elements",
wall_structure: "Wall Structure",
lintels: "Lintels",
wall_finish: "Wall Finish",
roof_structure: "Roof Structure",
roof_finish: "Roof Finish",
chimneys: "Chimneys",
windows: "Windows",
external_doors: "External Doors",
heating_other: "Other Heating Systems",
electrical_systems: "Electrical Systems",
kitchen: "Kitchen",
bathroom: "Bathroom",
kitchen_less_than_20_years_old: "Kitchen Less Than 20 Years Old",
kitchen_adequate_space_and_layout: "Kitchen Has Adequate Space and Layout",
bathroom_less_than_30_years_old: "Bathroom Less Than 30 Years Old",
bathroom_wc_appropriately_located: "Bathroom/WC Appropriately Located",
adequate_external_noise_insulation: "Adequate External Noise Insulation",
efficient_heating_system_type: "Efficient Heating System Type",
efficient_heating_distribution: "Efficient Heating Distribution",
loft_insulation_sufficient: "Loft Insulation Sufficient",
wall_insulation_sufficient: "Wall Insulation Sufficient",
};
const SUB_ITEMS_TEXT: Record<string, string> = {
"Wall Structure in External Area": "Wall Structure Renewal",
"Lintels in External Area": "Lintel Renewal",
"Wall Finish 1 in External Area": "Wall Finish Renewal",
"Brickwork Pointing in External Area": "Brickwork Pointing Renewal",
"Roof Structure 1 in External Area": "Roof Structure Renewal",
"Fascia / Soffit / Bargeboard in External Area": "Fascia / Soffit / Bargeboard Renewal",
"Gutters in External Area": "Gutter Renewal",
"Downpipes in External Area": "Downpipe Renewal",
"Roof Covering 1 in External Area": "Roof Covering Replacement",
"Chimneys in External Area": "Chimney Renewal",
"Windows in Property": "Window Replacement",
"Windows 1 in External Area": "Window Replacement",
"Type and Location of Front Door in Property": "Front Door Replacement",
"Back and Side Doors 1 in External Area": "Door Replacement",
"Back and Side Doors 2 in External Area": "Door Replacement",
"Type of Water Heating in Property": "Water Heating System Replacement",
"Electrics Required in Property": "Electrical System Renewal",
"Adequacy of Kitchen and Type in Property": "Kitchen Renewal",
"Adequacy of Bathroom Location in Property": "Bathroom Renewal",
kitchen_less_than_20_years_old: "Kitchen Replacement",
kitchen_adequate_space_and_layout: "Kitchen Layout Upgrade",
bathroom_less_than_30_years_old: "Bathroom Replacement",
bathroom_wc_appropriately_located: "Bathroom/WC Layout Upgrade",
adequate_external_noise_insulation: "Noise Insulation Upgrade",
efficient_heating_system_type: "Heating System Upgrade",
efficient_heating_distribution: "Heating Distribution Upgrade",
loft_insulation_sufficient: "Loft Insulation Upgrade",
wall_insulation_sufficient: "Wall Insulation Upgrade",
};
// ── Types ─────────────────────────────────────────────────────────────────────
type MetaItem = {
criteria: string;
sub_variable: string;
result: string;
expiry_date?: string | null;
install_date?: string | null;
};
type ReplacementEntry = {
label: string;
expiry: Date;
install: Date;
remaining: string;
overdue: boolean;
};
// ── Helpers ───────────────────────────────────────────────────────────────────
function getStatusIcon(status: string) {
if (status === "pass") return <CheckCircleSolid className="w-4 h-4 text-emerald-500 shrink-0" />;
if (status === "fail") return <XCircleSolid className="w-4 h-4 text-red-500 shrink-0" />;
return <MinusCircleIcon className="w-4 h-4 text-gray-400 shrink-0" />;
}
function getStatusDot(status: string) {
const colorMap: Record<string, string> = {
pass: "bg-emerald-500",
fail: "bg-red-500",
no_data: "bg-gray-300",
};
return <span className={`w-2 h-2 rounded-full shrink-0 ${colorMap[status] ?? "bg-gray-300"}`} />;
}
function getOverallStyles(status: string): { bg: string; text: string; border: string } {
if (status === "pass") return { bg: "bg-emerald-50", text: "text-emerald-800", border: "border-emerald-200" };
if (status === "fail") return { bg: "bg-red-50", text: "text-red-800", border: "border-red-200" };
return { bg: "bg-gray-50", text: "text-gray-700", border: "border-gray-200" };
}
function getOverallLabel(status: string): string {
if (status === "pass") return "Decent Homes: Pass";
if (status === "fail") return "Decent Homes: Fail";
return "Information Missing";
}
function getCriterionStatusBadge(status: string) {
if (status === "pass") {
return (
<span className="inline-flex items-center gap-1 text-xs font-semibold px-2 py-0.5 rounded-full bg-emerald-50 border border-emerald-200 text-emerald-700">
<CheckCircleSolid className="w-3 h-3" />
Pass
</span>
);
}
if (status === "fail") {
return (
<span className="inline-flex items-center gap-1 text-xs font-semibold px-2 py-0.5 rounded-full bg-red-50 border border-red-200 text-red-700">
<XCircleSolid className="w-3 h-3" />
Fail
</span>
);
}
return (
<span className="inline-flex items-center gap-1 text-xs font-semibold px-2 py-0.5 rounded-full bg-gray-50 border border-gray-200 text-gray-500">
<MinusCircleIcon className="w-3 h-3" />
No Data
</span>
);
}
function parseReplacements(items: MetaItem[]): ReplacementEntry[] {
const today = new Date();
return items
.filter((r) => r.expiry_date && r.install_date)
.map((r) => {
const expiry = new Date(r.expiry_date!);
const install = new Date(r.install_date!);
const diffMs = expiry.getTime() - today.getTime();
const diffMonths = diffMs / (1000 * 60 * 60 * 24 * 30);
const years = Math.floor(Math.abs(diffMonths) / 12);
const months = Math.floor(Math.abs(diffMonths) % 12);
const overdue = diffMs < 0;
const remaining = overdue
? `Expired ${years > 0 ? `${years}y ` : ""}${months}m ago`
: `${years > 0 ? `${years}y ` : ""}${months}m remaining`;
return {
label: SUB_ITEMS_TEXT[r.sub_variable] ?? r.sub_variable,
expiry,
install,
remaining,
overdue,
};
})
.sort((a, b) => a.expiry.getTime() - b.expiry.getTime());
}
function groupReplacements(entries: ReplacementEntry[]) {
const groups: Record<string, ReplacementEntry[]> = {
Overdue: [],
"06 months": [],
"612 months": [],
">12 months": [],
};
for (const e of entries) {
const diffMs = e.expiry.getTime() - Date.now();
const diffMonths = diffMs / (1000 * 60 * 60 * 24 * 30);
if (e.overdue) groups.Overdue.push(e);
else if (diffMonths <= 6) groups["06 months"].push(e);
else if (diffMonths <= 12) groups["612 months"].push(e);
else groups[">12 months"].push(e);
}
return groups;
}
// ── Sub-components ────────────────────────────────────────────────────────────
function CriterionCard({
letter,
label,
description,
icon,
status,
items,
}: {
letter: string;
label: string;
description: string;
icon: ReactNode;
status: string;
items: { sub_variable: string; result: string }[];
}) {
const sorted = [...items].sort((a, b) => {
const order = { fail: 0, no_data: 1, pass: 2 };
return (order[a.result as keyof typeof order] ?? 1) - (order[b.result as keyof typeof order] ?? 1);
});
const failCount = items.filter((i) => i.result === "fail").length;
const passCount = items.filter((i) => i.result === "pass").length;
const notAssessedCount = items.length - passCount - failCount;
return (
<div className="bg-white rounded-2xl border border-gray-100 shadow-sm overflow-hidden flex flex-col">
{/* Card header */}
<div className="p-5 pb-4 flex items-start justify-between gap-3">
<div className="flex items-center gap-3">
<div className="w-9 h-9 rounded-xl bg-brandblue flex items-center justify-center shrink-0">
<span className="text-white">{icon}</span>
</div>
<div>
<p className="text-[10px] font-bold text-brandmidblue uppercase tracking-widest">
Criterion {letter}
</p>
<p className="font-manrope font-bold text-sm text-brandblue leading-tight">{label}</p>
</div>
</div>
{getCriterionStatusBadge(status)}
</div>
{/* Description */}
<p className="text-xs text-gray-400 px-5 pb-3 leading-relaxed">{description}</p>
{/* Stats row */}
<div className="flex items-center gap-3 px-5 pb-3">
<span className="inline-flex items-center gap-1 text-[10px] font-semibold text-emerald-700">
<CheckCircleSolid className="w-3 h-3" />
{passCount} pass
</span>
<span className="text-gray-200">·</span>
<span className="inline-flex items-center gap-1 text-[10px] font-semibold text-red-600">
<XCircleSolid className="w-3 h-3" />
{failCount} fail
</span>
{notAssessedCount > 0 && (
<>
<span className="text-gray-200">·</span>
<span className="inline-flex items-center gap-1 text-[10px] font-semibold text-gray-400">
<MinusCircleIcon className="w-3 h-3" />
{notAssessedCount} not assessed
</span>
</>
)}
</div>
{/* Scrollable item list */}
<div className="relative flex-1 border-t border-gray-100">
<div className="max-h-[320px] overflow-y-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden divide-y divide-gray-100">
{sorted.map((item, idx) => (
<div
key={idx}
className="flex items-center justify-between px-5 py-2.5 hover:bg-gray-50 transition-colors"
>
<span className="text-xs text-gray-700 pr-3">
{DISPLAY_NAMES[item.sub_variable] ?? item.sub_variable}
</span>
{getStatusIcon(item.result)}
</div>
))}
</div>
{/* Gradient fade to indicate more items below */}
<div className="pointer-events-none absolute bottom-0 inset-x-0 h-8 bg-gradient-to-t from-white to-transparent z-10" />
</div>
</div>
);
}
function ReplacementsSection({ items }: { items: MetaItem[] }) {
const entries = parseReplacements(items);
const groups = groupReplacements(entries);
const urgencyConfig: Record<string, {
border: string;
headerBg: string;
badge: string;
icon: ReactNode;
label: string;
}> = {
Overdue: {
border: "border-red-200",
headerBg: "bg-red-50",
badge: "bg-red-100 text-red-800 border-red-200",
icon: <AlertTriangle className="w-3.5 h-3.5 text-red-600" />,
label: "Overdue",
},
"06 months": {
border: "border-orange-200",
headerBg: "bg-orange-50",
badge: "bg-orange-100 text-orange-800 border-orange-200",
icon: <ClockIcon className="w-3.5 h-3.5 text-orange-600" />,
label: "06 months",
},
"612 months": {
border: "border-yellow-200",
headerBg: "bg-yellow-50",
badge: "bg-yellow-100 text-yellow-800 border-yellow-200",
icon: <Hourglass className="w-3.5 h-3.5 text-yellow-600" />,
label: "612 months",
},
">12 months": {
border: "border-emerald-200",
headerBg: "bg-emerald-50",
badge: "bg-emerald-100 text-emerald-800 border-emerald-200",
icon: <CheckCircleIcon className="w-3.5 h-3.5 text-emerald-600" />,
label: ">12 months",
},
};
const order = ["Overdue", "06 months", "612 months", ">12 months"] as const;
return (
<div className="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-4 gap-4">
{order.map((urgency) => {
const cfg = urgencyConfig[urgency];
const list = groups[urgency];
return (
<div
key={urgency}
className={`rounded-2xl border ${cfg.border} overflow-hidden flex flex-col bg-white shadow-sm`}
>
{/* Column header */}
<div className={`${cfg.headerBg} px-4 py-3 flex items-center justify-between border-b ${cfg.border} shrink-0`}>
<div className="flex items-center gap-1.5">
{cfg.icon}
<span className="text-xs font-bold text-gray-700">{cfg.label}</span>
</div>
<span className={`text-xs font-bold px-2 py-0.5 rounded-full border ${cfg.badge}`}>
{list.length}
</span>
</div>
{/* Scrollable body */}
<div className="relative flex-1">
<div className="max-h-[400px] overflow-y-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden divide-y divide-gray-100">
{list.length === 0 ? (
<p className="text-xs text-gray-400 text-center py-6">None</p>
) : (
list.map((entry, idx) => (
<div key={idx} className="px-4 py-3">
<p className="text-xs font-semibold text-gray-800 mb-1">{entry.label}</p>
<p className={`text-[10px] font-medium ${entry.overdue ? "text-red-600" : "text-gray-500"}`}>
{entry.remaining}
</p>
<div className="flex justify-between mt-1">
<p className="text-[10px] text-gray-400">
Installed: {entry.install.toLocaleDateString("en-GB")}
</p>
<p className={`text-[10px] ${entry.overdue ? "text-red-500" : "text-gray-400"}`}>
Expires: {entry.expiry.toLocaleDateString("en-GB")}
</p>
</div>
</div>
))
)}
</div>
{list.length > 0 && (
<div className="pointer-events-none absolute bottom-0 inset-x-0 h-8 bg-gradient-to-t from-white to-transparent z-10" />
)}
</div>
</div>
);
})}
</div>
);
}
// ── Data ──────────────────────────────────────────────────────────────────────
const CRITERIA: {
key: "A" | "B" | "C" | "D";
letter: string;
label: string;
description: string;
icon: ReactNode;
statusKey: string;
}[] = [
{
key: "A",
letter: "A",
label: "Statutory Standard",
description: "Meets current statutory minimum standard for housing",
icon: <HomeIcon className="w-4 h-4" />,
statusKey: "criterion_a",
},
{
key: "B",
letter: "B",
label: "State of Repair",
description: "The home is in a reasonable state of repair",
icon: <WrenchScrewdriverIcon className="w-4 h-4" />,
statusKey: "criterion_b",
},
{
key: "C",
letter: "C",
label: "Modern Facilities",
description: "Has reasonable modern facilities and services",
icon: <SparklesIcon className="w-4 h-4" />,
statusKey: "criterion_c",
},
{
key: "D",
letter: "D",
label: "Thermal Comfort",
description: "Provides a reasonable degree of thermal comfort",
icon: <FireIcon className="w-4 h-4" />,
statusKey: "criterion_d",
},
];
// ── Main export ───────────────────────────────────────────────────────────────
export default function DecentHomesSummary({
decentHomes,
decentHomesMeta,
}: {
decentHomes: {
uprn: number;
creation_date: string;
criterion_a: string;
criterion_b: string;
criterion_c: string;
criterion_d: string;
decent_homes: string;
};
decentHomesMeta: MetaItem[];
}) {
const criteriaGroups: Record<string, { sub_variable: string; result: string }[]> = {
A: [], B: [], C: [], D: [],
};
for (const item of decentHomesMeta) {
if (criteriaGroups[item.criteria]) {
criteriaGroups[item.criteria].push({
sub_variable: item.sub_variable,
result: item.result,
});
}
}
const overdueCount = decentHomesMeta.filter((r) => {
if (!r.expiry_date) return false;
return new Date(r.expiry_date).getTime() < Date.now();
}).length;
const overallStatus = decentHomes.decent_homes;
const overall = getOverallStyles(overallStatus);
const overallLabel = getOverallLabel(overallStatus);
const lastUpdated = new Date(decentHomes.creation_date).toLocaleDateString("en-GB", {
day: "numeric",
month: "long",
year: "numeric",
});
const criterionStatus: Record<string, string> = {
A: decentHomes.criterion_a,
B: decentHomes.criterion_b,
C: decentHomes.criterion_c,
D: decentHomes.criterion_d,
};
return (
<div className="max-w-7xl mx-auto py-10 space-y-10">
{/* ── Page header ──────────────────────────────────────────────────── */}
<header>
<p className="font-manrope text-xs font-bold text-brandmidblue uppercase tracking-widest mb-2">
Housing Standards
</p>
<h1 className="font-manrope font-extrabold text-4xl text-brandblue tracking-tight">
Decent Homes Assessment
</h1>
</header>
{/* ── Hero card ────────────────────────────────────────────────────── */}
<div className={`rounded-2xl border ${overall.border} ${overall.bg} p-8 flex flex-wrap items-center justify-between gap-6`}>
<div className="flex items-center gap-4">
{overallStatus === "pass" ? (
<CheckCircleSolid className="w-12 h-12 text-emerald-500 shrink-0" />
) : overallStatus === "fail" ? (
<XCircleSolid className="w-12 h-12 text-red-500 shrink-0" />
) : (
<ExclamationTriangleIcon className="w-12 h-12 text-gray-400 shrink-0" />
)}
<div>
<p className="font-manrope font-extrabold text-2xl text-gray-900 tracking-tight">
{overallLabel}
</p>
<p className="text-sm text-gray-500 mt-0.5">
Decent Homes Standard assessment · Last updated {lastUpdated}
</p>
</div>
</div>
{/* Criteria summary pills */}
<div className="flex gap-3 flex-wrap">
{CRITERIA.map((c) => {
const s = criterionStatus[c.letter];
return (
<div
key={c.key}
className="flex items-center gap-2 text-xs font-semibold text-gray-600 bg-white border border-gray-200 rounded-full px-3 py-1.5 shadow-sm"
>
{getStatusDot(s)}
<span>Criterion {c.letter}</span>
<span className="text-gray-400 font-normal">{c.label}</span>
</div>
);
})}
</div>
</div>
{/* ── Criteria cards ───────────────────────────────────────────────── */}
<section>
<div className="flex items-center gap-3 mb-6">
<p className="font-manrope text-xs font-bold text-brandmidblue uppercase tracking-widest">
Assessment Criteria
</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
{CRITERIA.map((c) => (
<CriterionCard
key={c.key}
letter={c.letter}
label={c.label}
description={c.description}
icon={c.icon}
status={criterionStatus[c.letter]}
items={criteriaGroups[c.letter]}
/>
))}
</div>
</section>
{/* ── Replacements section ─────────────────────────────────────────── */}
<section>
<div className="flex items-center gap-3 mb-6">
<p className="font-manrope text-xs font-bold text-brandmidblue uppercase tracking-widest">
Component Replacements
</p>
{overdueCount > 0 && (
<span className="bg-red-100 text-red-700 border border-red-200 text-[10px] font-bold px-2 py-0.5 rounded-full">
{overdueCount} overdue
</span>
)}
</div>
<ReplacementsSection items={decentHomesMeta} />
</section>
</div>
);
}

View file

@ -1,564 +1,9 @@
import type { ReactNode } from "react";
import {
getPropertyMeta,
getDocument,
getEnergyAssessmentFromS3,
} from "../utils";
import {
Card,
CardHeader,
CardTitle,
CardContent,
} from "@/app/shadcn_components/ui/card";
import { Badge } from "@/app/shadcn_components/ui/badge";
import {
Tabs,
TabsList,
TabsTrigger,
TabsContent,
} from "@/app/shadcn_components/ui/tabs";
import {
Wrench,
AlertTriangle,
Clock,
Hourglass,
CheckCircle,
} from "lucide-react";
const DISPLAY_NAMES: Record<string, string> = {
// Criterion A - HHSRS hazards
damp_and_mould_growth: "Damp and Mould Growth",
excess_cold: "Excess Cold",
excess_heat: "Excess Heat",
asbestos_and_mm_fibres: "Asbestos and MM Fibres",
biocides: "Biocides",
carbon_monoxide: "Carbon Monoxide",
lead: "Lead",
radiation: "Radiation",
uncombusted_fuel_gas: "Uncombusted Fuel Gas",
volatile_organic_compounds: "Volatile Organic Compounds",
crowding_and_space: "Crowding and Space",
entry_by_intruders: "Entry by Intruders",
lighting: "Lighting",
noise: "Noise",
domestic_hygiene_pests_and_refuse: "Domestic Hygiene, Pests, and Refuse",
food_safety: "Food Safety",
personal_hygiene_sanitation_and_drainage:
"Personal Hygiene, Sanitation, and Drainage",
water_supply: "Water Supply",
falls_associated_with_baths: "Falls Associated with Baths",
falls_on_level_surfaces: "Falls on Level Surfaces",
falls_on_stairs_and_steps: "Falls on Stairs and Steps",
falls_between_levels: "Falls Between Levels",
electrical_hazards: "Electrical Hazards",
fire: "Fire",
flames_hot_surfaces_and_materials: "Flames, Hot Surfaces, and Materials",
collision_and_entrapment: "Collision and Entrapment",
explosions: "Explosions",
ergonomics: "Ergonomics",
structural_collapse_and_falling_elements:
"Structural Collapse and Falling Elements",
// Criterion B - Key building components
wall_structure: "Wall Structure",
lintels: "Lintels",
wall_finish: "Wall Finish",
roof_structure: "Roof Structure",
roof_finish: "Roof Finish",
chimneys: "Chimneys",
windows: "Windows",
external_doors: "External Doors",
heating_other: "Other Heating Systems",
electrical_systems: "Electrical Systems",
kitchen: "Kitchen",
bathroom: "Bathroom",
// Criterion C - Modern facilities
kitchen_less_than_20_years_old: "Kitchen Less Than 20 Years Old",
kitchen_adequate_space_and_layout: "Kitchen Has Adequate Space and Layout",
bathroom_less_than_30_years_old: "Bathroom Less Than 30 Years Old",
bathroom_wc_appropriately_located: "Bathroom/WC Appropriately Located",
adequate_external_noise_insulation: "Adequate External Noise Insulation",
// Criterion D - Thermal comfort
efficient_heating_system_type: "Efficient Heating System Type",
efficient_heating_distribution: "Efficient Heating Distribution",
loft_insulation_sufficient: "Loft Insulation Sufficient",
wall_insulation_sufficient: "Wall Insulation Sufficient",
};
const SUB_ITEMS_TEXT: Record<string, string> = {
// Criterion A - Hazards (keep as-is, not replacements)
damp_and_mould_growth: "Damp and Mould Growth",
excess_cold: "Excess Cold",
excess_heat: "Excess Heat",
asbestos_and_mm_fibres: "Asbestos and MM Fibres",
biocides: "Biocides",
carbon_monoxide: "Carbon Monoxide",
lead: "Lead",
radiation: "Radiation",
uncombusted_fuel_gas: "Uncombusted Fuel Gas",
volatile_organic_compounds: "Volatile Organic Compounds",
crowding_and_space: "Crowding and Space",
entry_by_intruders: "Entry by Intruders",
lighting: "Lighting",
noise: "Noise",
domestic_hygiene_pests_and_refuse: "Domestic Hygiene, Pests, and Refuse",
food_safety: "Food Safety",
personal_hygiene_sanitation_and_drainage:
"Personal Hygiene, Sanitation, and Drainage",
water_supply: "Water Supply",
falls_associated_with_baths: "Falls Associated with Baths",
falls_on_level_surfaces: "Falls on Level Surfaces",
falls_on_stairs_and_steps: "Falls on Stairs and Steps",
falls_between_levels: "Falls Between Levels",
electrical_hazards: "Electrical Hazards",
fire: "Fire",
flames_hot_surfaces_and_materials: "Flames, Hot Surfaces, and Materials",
collision_and_entrapment: "Collision and Entrapment",
explosions: "Explosions",
ergonomics: "Ergonomics",
structural_collapse_and_falling_elements:
"Structural Collapse and Falling Elements",
// Criterion B - Key components
"Wall Structure in External Area": "Wall Structure Renewal",
"Lintels in External Area": "Lintel Renewal",
"Wall Finish 1 in External Area": "Wall Finish Renewal",
"Brickwork Pointing in External Area": "Brickwork Pointing Renewal",
"Roof Structure 1 in External Area": "Roof Structure Renewal",
"Fascia / Soffit / Bargeboard in External Area":
"Fascia / Soffit / Bargeboard Renewal",
"Gutters in External Area": "Gutter Renewal",
"Downpipes in External Area": "Downpipe Renewal",
"Roof Covering 1 in External Area": "Roof Covering Replacement",
"Chimneys in External Area": "Chimney Renewal",
"Windows in Property": "Window Replacement",
"Windows 1 in External Area": "Window Replacement",
"Type and Location of Front Door in Property": "Front Door Replacement",
"Back and Side Doors 1 in External Area": "Door Replacement",
"Back and Side Doors 2 in External Area": "Door Replacement",
"Type of Water Heating in Property": "Water Heating System Replacement",
"Electrics Required in Property": "Electrical System Renewal",
"Adequacy of Kitchen and Type in Property": "Kitchen Renewal",
"Adequacy of Bathroom Location in Property": "Bathroom Renewal",
// Criterion C - Modern facilities
kitchen_less_than_20_years_old: "Kitchen Replacement",
kitchen_adequate_space_and_layout: "Kitchen Layout Upgrade",
bathroom_less_than_30_years_old: "Bathroom Replacement",
bathroom_wc_appropriately_located: "Bathroom/WC Layout Upgrade",
adequate_external_noise_insulation: "Noise Insulation Upgrade",
// Criterion D - Thermal comfort
efficient_heating_system_type: "Heating System Upgrade",
efficient_heating_distribution: "Heating Distribution Upgrade",
loft_insulation_sufficient: "Loft Insulation Upgrade",
wall_insulation_sufficient: "Wall Insulation Upgrade",
};
const LABEL_MAP: Record<string, string> = {
pass: "Pass",
fail: "Fail",
no_data: "Not Assessed",
};
const OVERALL_LABEL_MAP: Record<string, string> = {
pass: "Pass",
fail: "Fail",
no_data: "Information Missing",
};
const OVERALL_LABEL_COLORS: Record<string, string> = {
pass: "bg-green-600 hover:bg-green-700",
fail: "bg-red-700 hover:bg-red-800",
no_data: "bg-gray-500 hover:bg-gray-600",
};
// status badge
function StatusBadge({ status }: { status: string }) {
const colors =
status === "pass"
? "bg-green-600 hover:bg-green-700"
: status === "fail"
? "bg-red-700 hover:bg-red-800"
: "bg-gray-500 hover:bg-gray-600";
return (
<Badge className={`${colors} text-white text-xs px-2 py-1`}>
{LABEL_MAP[status]}
</Badge>
);
}
// urgency badge
function UrgencyBadge({ label }: { label: string }) {
const colorMap: Record<string, string> = {
Overdue: "bg-red-700",
"06 months": "bg-orange-500",
"612 months": "bg-yellow-500",
">12 months": "bg-green-600",
};
return (
<Badge className={`${colorMap[label]} text-white text-xs px-2 py-1`}>
{label}
</Badge>
);
}
function CriterionContent({
title,
items,
}: {
title: string;
items: { sub_variable: string; result: string }[];
}) {
const sortedItems = [...items].sort((a, b) => {
const order = { fail: 0, no_data: 1, pass: 2 };
return (
order[a.result as keyof typeof order] -
order[b.result as keyof typeof order]
);
});
return (
<Card className="h-96 flex flex-col relative overflow-hidden">
<CardHeader>
<CardTitle className="text-brandbrown">{title}</CardTitle>
</CardHeader>
<CardContent className="flex-1 overflow-y-scroll pr-2 scrollbar-thin scrollbar-thumb-gray-400 scrollbar-track-gray-100">
<ul className="space-y-2 pb-6">
{sortedItems.map((item, idx) => (
<li
key={idx}
className="flex justify-between items-center border-b last:border-0 pb-1"
>
<span className="text-gray-700">
{DISPLAY_NAMES[item.sub_variable] ?? item.sub_variable}
</span>
<StatusBadge status={item.result} />
</li>
))}
</ul>
</CardContent>
<div className="absolute bottom-0 left-0 right-0 h-6 bg-gradient-to-t from-gray-100 to-transparent pointer-events-none" />
</Card>
);
}
function ReplacementsContent({
items,
}: {
items: {
sub_variable: string;
expiry_date: string | null;
install_date: string | null;
}[];
}) {
const today = new Date();
const groups: Record<
string,
{
sub_variable: string;
expiry: Date;
install: Date;
remaining: string;
overdue: boolean;
}[]
> = {
Overdue: [],
"06 months": [],
"612 months": [],
">12 months": [],
};
items.forEach((item) => {
if (!item.expiry_date || !item.install_date) return;
const expiry = new Date(item.expiry_date);
const install = new Date(item.install_date);
const diffMs = expiry.getTime() - today.getTime();
const diffMonths = diffMs / (1000 * 60 * 60 * 24 * 30);
const years = Math.floor(Math.abs(diffMonths) / 12);
const months = Math.floor(Math.abs(diffMonths) % 12);
let remaining = "";
let overdue = false;
if (diffMs < 0) {
overdue = true;
remaining = `Expired ${years > 0 ? `${years}y ` : ""}${months}m ago`;
} else {
remaining = `${years > 0 ? `${years}y ` : ""}${months}m remaining`;
}
const entry = {
sub_variable: SUB_ITEMS_TEXT[item.sub_variable] ?? item.sub_variable,
expiry,
install,
remaining,
overdue,
};
if (diffMs < 0) groups.Overdue.push(entry);
else if (diffMonths <= 6) groups["06 months"].push(entry);
else if (diffMonths <= 12) groups["612 months"].push(entry);
else groups[">12 months"].push(entry);
});
// sort within each group
Object.values(groups).forEach((comps) =>
comps.sort((a, b) => a.expiry.getTime() - b.expiry.getTime()),
);
const groupOrder: (keyof typeof groups)[] = [
"Overdue",
"06 months",
"612 months",
">12 months",
];
// urgency → card highlight color + icon
const cardStyles: Record<string, { border: string; icon: ReactNode }> = {
Overdue: {
border: "border-l-4 border-red-600",
icon: <AlertTriangle className="w-4 h-4 text-red-600" />,
},
"06 months": {
border: "border-l-4 border-orange-500",
icon: <Clock className="w-4 h-4 text-orange-500" />,
},
"612 months": {
border: "border-l-4 border-yellow-500",
icon: <Hourglass className="w-4 h-4 text-yellow-500" />,
},
">12 months": {
border: "border-l-4 border-green-600",
icon: <CheckCircle className="w-4 h-4 text-green-600" />,
},
};
return (
<Card className="h-[32rem] flex flex-col relative overflow-hidden">
<CardHeader>
<CardTitle className="text-lg font-medium text-brandbrown">
Upcoming Replacements
</CardTitle>
</CardHeader>
<CardContent className="flex-1 overflow-y-scroll pr-2 scrollbar-thin scrollbar-thumb-gray-400 scrollbar-track-gray-100">
{groupOrder.map((urgency) =>
groups[urgency].length > 0 ? (
<div key={urgency} className="mb-6">
{/* group header */}
<div className="flex items-center space-x-2 mb-3">
<UrgencyBadge label={urgency} />
<span className="text-gray-700 font-medium">
{groups[urgency].length}{" "}
{groups[urgency].length > 1 ? "items" : "item"}
</span>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
{groups[urgency].map((comp, idx) => (
<div
key={idx}
className={`px-4 py-2 bg-gray-50 rounded-md border hover:bg-gray-100 transition ${cardStyles[urgency].border}`}
>
<div className="flex items-center justify-between mb-2">
<span className="font-medium text-gray-800 flex items-center gap-2">
{cardStyles[urgency].icon}
{comp.sub_variable}
</span>
</div>
<div className="text-sm text-gray-600 flex justify-between">
<span>{comp.remaining}</span>
<div className="flex flex-col text-right">
<span className={comp.overdue ? "text-red-600" : ""}>
{`Installed: ${comp.install.toLocaleDateString("en-GB")}`}
</span>
<span className={comp.overdue ? "text-red-600" : ""}>
{`Expired: ${comp.expiry.toLocaleDateString("en-GB")}`}
</span>
</div>
</div>
</div>
))}
</div>
</div>
) : null,
)}
</CardContent>
<div className="absolute bottom-0 left-0 right-0 h-6 bg-gradient-to-t from-gray-100 to-transparent pointer-events-none" />
</Card>
);
}
function StatusCircle({ status }: { status: string }) {
const colorMap: Record<string, string> = {
pass: "bg-green-600",
fail: "bg-red-700",
no_data: "bg-gray-500",
};
return <div className={`w-4 h-4 rounded-full ${colorMap[status]}`} />;
}
function DecentHomesSummary({
decentHomes,
decentHomesMeta,
}: {
decentHomes: {
uprn: number;
creation_date: string;
criterion_a: string;
criterion_b: string;
criterion_c: string;
criterion_d: string;
decent_homes: string;
};
decentHomesMeta: {
criteria: string;
sub_variable: string;
result: string;
expiry_date?: string | null;
install_date?: string | null;
}[];
}) {
// There are three possible overall outcomes: "pass", "fail", "no_data"
// overall is "pass" if all criteria are "pass"
// overall is "fail" if any criteria are "fail"
// overall is "no_data" if all criteria are "no_data" or some are "no_data" and others are "pass"
const overallPass = decentHomes.decent_homes;
const lastUpdated = new Date(decentHomes.creation_date).toLocaleDateString(
"en-GB",
{
day: "numeric",
month: "long",
year: "numeric",
},
);
const criteriaGroups: Record<
string,
{ sub_variable: string; result: string }[]
> = {
A: [],
B: [],
C: [],
D: [],
};
const replacements: {
sub_variable: string;
expiry_date: string | null;
install_date: string | null;
}[] = [];
decentHomesMeta.forEach((item) => {
if (criteriaGroups[item.criteria]) {
criteriaGroups[item.criteria].push({
sub_variable: item.sub_variable,
result: item.result,
});
}
if (item.expiry_date) {
replacements.push({
sub_variable: item.sub_variable,
expiry_date: item.expiry_date,
install_date: item.install_date ?? null,
});
}
});
const soonCount = replacements.filter((r) => {
if (!r.expiry_date) return false;
const expiry = new Date(r.expiry_date);
return expiry.getTime() < Date.now(); // strictly overdue
}).length;
return (
<div className="flex flex-col items-center mt-10 space-y-6">
<Card className="w-full max-w-xl">
<CardHeader>
<CardTitle className="text-center text-2xl font-semibold">
Decent Homes Assessment
</CardTitle>
</CardHeader>
<CardContent className="flex flex-col items-center space-y-1">
<Badge
className={`px-4 py-2 text-lg ${OVERALL_LABEL_COLORS[overallPass]} text-white`}
>
{OVERALL_LABEL_MAP[overallPass]}
</Badge>
<p className="text-sm text-gray-500">Last updated: {lastUpdated}</p>
</CardContent>
</Card>
<Tabs defaultValue="A" className="w-full max-w-4xl">
<TabsList className="grid grid-cols-5 w-full">
<TabsTrigger value="A">
<div className="mr-4">Criterion A</div>
<StatusCircle status={decentHomes.criterion_a} />
</TabsTrigger>
<TabsTrigger value="B">
<div className="mr-4">Criterion B</div>
<StatusCircle status={decentHomes.criterion_b} />
</TabsTrigger>
<TabsTrigger value="C">
<div className="mr-4">Criterion C</div>
<StatusCircle status={decentHomes.criterion_c} />
</TabsTrigger>
<TabsTrigger value="D">
<div className="mr-4">Criterion D</div>
<StatusCircle status={decentHomes.criterion_d} />
</TabsTrigger>
<TabsTrigger
value="replacements"
className="relative flex items-center space-x-2 text-orange-700 font-medium
data-[state=active]:bg-brandbrown data-[state=active]:rounded-md data-[state=active]:text-gray-100"
>
<Wrench className="w-4 h-4" />
<span>Replacements</span>
{soonCount > 0 && (
<span className="absolute -top-1 -right-2 bg-red-600 text-white text-xs rounded-full w-5 h-5 flex items-center justify-center">
{soonCount}
</span>
)}
</TabsTrigger>
</TabsList>
<TabsContent value="A" className="mt-4">
<CriterionContent
title="Criterion A: The home meets the current statutory minimum standard for housing"
items={criteriaGroups["A"]}
/>
</TabsContent>
<TabsContent value="B" className="mt-4">
<CriterionContent
title="Criterion B: The home is in a reasonable state of repair"
items={criteriaGroups["B"]}
/>
</TabsContent>
<TabsContent value="C" className="mt-4">
<CriterionContent
title="Criterion C: The home has reasonable modern facilities and services"
items={criteriaGroups["C"]}
/>
</TabsContent>
<TabsContent value="D" className="mt-4">
<CriterionContent
title="Criterion D: The home provides a reasonable degree of thermal comfort"
items={criteriaGroups["D"]}
/>
</TabsContent>
<TabsContent value="replacements" className="mt-4">
<ReplacementsContent items={replacements} />
</TabsContent>
</Tabs>
</div>
);
}
import DecentHomesSummary from "./DecentHomesSummary";
export default async function DecentHomesPage(props: {
params: Promise<{ slug: string; propertyId: string }>;

View file

@ -0,0 +1,330 @@
"use client";
import { useState } from "react";
import {
Camera,
FileText,
Zap,
ClipboardCheck,
Download,
Loader2,
LayoutGrid,
List,
} from "lucide-react";
export type RawFileType =
| "photo_pack"
| "site_note"
| "rd_sap_site_note"
| "pas_2023_ventilation"
| "pas_2023_condition"
| "pas_significance"
| "par_photo_pack"
| "pas_2023_property"
| "pas_2023_occupancy"
| "ecmk_site_note"
| "ecmk_rd_sap_site_note"
| "unknown";
type Document = {
id: string;
s3FileKey: string;
s3FileBucket: string;
docType: RawFileType;
s3UploadTimestamp: string;
};
type GroupConfig = {
label: string;
types: RawFileType[];
icon: React.ComponentType<{ className?: string }>;
};
const GROUPS: GroupConfig[] = [
{
label: "Photos",
types: ["photo_pack", "par_photo_pack"],
icon: Camera,
},
{
label: "Energy Performance",
types: ["site_note", "ecmk_site_note", "rd_sap_site_note", "ecmk_rd_sap_site_note"],
icon: Zap,
},
{
label: "PAS Condition & Other",
types: ["pas_2023_condition", "pas_2023_ventilation", "pas_2023_occupancy", "pas_2023_property", "pas_significance"],
icon: ClipboardCheck,
},
];
function getGroupForType(docType: RawFileType): GroupConfig | undefined {
return GROUPS.find((g) => g.types.includes(docType));
}
const DOC_TYPE_LABELS: Record<RawFileType, string> = {
photo_pack: "Photo Pack",
par_photo_pack: "Photo Pack",
site_note: "Site Note",
ecmk_site_note: "Site Note",
rd_sap_site_note: "RdSAP Report",
ecmk_rd_sap_site_note: "RdSAP Report",
pas_2023_condition: "Condition Report",
pas_2023_ventilation: "Ventilation Report",
pas_2023_occupancy: "Occupancy Report",
pas_2023_property: "Property Report",
pas_significance: "Significance Report",
unknown: "Document",
};
function getDisplayLabel(docType: RawFileType): string {
return DOC_TYPE_LABELS[docType] ?? "Document";
}
function extractFilename(s3Key: string): string {
const parts = s3Key.split("/");
return parts[parts.length - 1] ?? s3Key;
}
function formatDate(iso: string): string {
const d = new Date(iso);
return d.toLocaleDateString("en-GB", {
day: "2-digit",
month: "short",
year: "numeric",
});
}
async function fetchPresignedUrl(key: string, bucket: string): Promise<string> {
const res = await fetch("/api/sign-document-url", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ key, bucket }),
});
if (!res.ok) throw new Error("Failed to get download URL");
const data = await res.json();
return data.url;
}
function DocumentCard({
doc,
viewMode,
}: {
doc: Document;
viewMode: "grid" | "list";
}) {
const [loading, setLoading] = useState(false);
const group = getGroupForType(doc.docType);
const Icon = group?.icon ?? FileText;
const filename = extractFilename(doc.s3FileKey);
const label = getDisplayLabel(doc.docType);
const date = formatDate(doc.s3UploadTimestamp);
async function handleDownload() {
setLoading(true);
try {
const url = await fetchPresignedUrl(doc.s3FileKey, doc.s3FileBucket);
window.open(url, "_blank");
} catch {
// silently fail — could add a toast here
} finally {
setLoading(false);
}
}
if (viewMode === "list") {
return (
<div className="group flex items-center gap-4 bg-white rounded-xl px-5 py-4 shadow-sm hover:shadow-md transition-shadow">
<div className="w-10 h-10 rounded-xl bg-[#eff6fc] flex items-center justify-center text-[#3943b7] flex-shrink-0">
<Icon className="w-5 h-5" />
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-semibold text-[#15173e] truncate">
{filename}
</p>
<p className="text-xs text-slate-400 mt-0.5">
{label} · {date}
</p>
</div>
<button
onClick={handleDownload}
disabled={loading}
className="w-9 h-9 rounded-full bg-[#15173e] text-white flex items-center justify-center hover:opacity-80 active:scale-95 transition-all disabled:opacity-50"
>
{loading ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
<Download className="w-4 h-4" />
)}
</button>
</div>
);
}
return (
<div className="group bg-white p-6 rounded-2xl shadow-sm hover:-translate-y-1 hover:shadow-md transition-all duration-300">
<div className="flex justify-between items-start mb-5">
<div className="w-11 h-11 rounded-xl bg-[#eff6fc] flex items-center justify-center text-[#3943b7]">
<Icon className="w-5 h-5" />
</div>
</div>
<p className="text-xs font-bold uppercase tracking-widest text-slate-400 mb-1">
{label}
</p>
<h3 className="text-sm font-semibold text-[#15173e] mb-1 leading-snug break-all line-clamp-2">
{filename}
</h3>
<div className="flex items-center justify-between pt-4 mt-4 border-t border-slate-100">
<span className="text-xs text-slate-400">{date}</span>
<button
onClick={handleDownload}
disabled={loading}
className="w-9 h-9 rounded-full bg-[#15173e] text-white flex items-center justify-center group-hover:scale-110 active:scale-95 transition-transform disabled:opacity-50"
>
{loading ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
<Download className="w-4 h-4" />
)}
</button>
</div>
</div>
);
}
export function DocumentsClient({ documents }: { documents: Document[] }) {
const [activeFilter, setActiveFilter] = useState("All");
const [viewMode, setViewMode] = useState<"grid" | "list">("grid");
// Only show groups that have at least one document
const presentGroups = GROUPS.filter((g) =>
documents.some((d) => g.types.includes(d.docType))
);
const filteredDocs =
activeFilter === "All"
? documents
: documents.filter((d) => {
const group = getGroupForType(d.docType);
return group?.label === activeFilter;
});
// For grouped display: group the filtered docs
const groupsToShow =
activeFilter === "All"
? presentGroups
: presentGroups.filter((g) => g.label === activeFilter);
return (
<div>
{/* Header */}
<div className="mb-8">
<h1 className="text-3xl font-extrabold text-[#15173e] tracking-tight">
Documents
</h1>
</div>
{/* Controls */}
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4 mb-8">
{/* Filter pills */}
<div className="flex flex-wrap gap-2">
<button
onClick={() => setActiveFilter("All")}
className={`px-4 py-2 rounded-full text-sm font-semibold transition-colors ${
activeFilter === "All"
? "bg-[#15173e] text-white shadow"
: "bg-white text-slate-600 hover:bg-slate-100"
}`}
>
All Documents
</button>
{presentGroups.map((g) => (
<button
key={g.label}
onClick={() => setActiveFilter(g.label)}
className={`px-4 py-2 rounded-full text-sm font-medium transition-colors ${
activeFilter === g.label
? "bg-[#15173e] text-white shadow"
: "bg-white text-slate-600 hover:bg-slate-100"
}`}
>
{g.label}
</button>
))}
</div>
{/* View toggle */}
<div className="bg-slate-100 rounded-xl p-1 flex self-start sm:self-auto">
<button
onClick={() => setViewMode("grid")}
className={`px-4 py-2 rounded-lg text-xs font-bold flex items-center gap-1.5 transition-all ${
viewMode === "grid"
? "bg-white text-[#15173e] shadow-sm"
: "text-slate-400"
}`}
>
<LayoutGrid className="w-3.5 h-3.5" />
Grid
</button>
<button
onClick={() => setViewMode("list")}
className={`px-4 py-2 rounded-lg text-xs font-bold flex items-center gap-1.5 transition-all ${
viewMode === "list"
? "bg-white text-[#15173e] shadow-sm"
: "text-slate-400"
}`}
>
<List className="w-3.5 h-3.5" />
List
</button>
</div>
</div>
{/* Empty state */}
{filteredDocs.length === 0 && (
<div className="py-16 text-center text-slate-400 text-sm">
No documents found.
</div>
)}
{/* Document groups */}
{filteredDocs.length > 0 && (
<div className="space-y-10">
{groupsToShow.map((group) => {
const groupDocs = filteredDocs.filter((d) =>
group.types.includes(d.docType)
);
if (groupDocs.length === 0) return null;
return (
<div key={group.label}>
{/* Section header */}
<div className="flex items-center gap-4 mb-4">
<h2 className="text-xs font-black uppercase tracking-widest text-slate-400 whitespace-nowrap">
{group.label}
</h2>
<div className="h-px flex-1 bg-slate-200" />
</div>
{/* Cards */}
{viewMode === "grid" ? (
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-5">
{groupDocs.map((doc) => (
<DocumentCard key={doc.id} doc={doc} viewMode="grid" />
))}
</div>
) : (
<div className="flex flex-col gap-2">
{groupDocs.map((doc) => (
<DocumentCard key={doc.id} doc={doc} viewMode="list" />
))}
</div>
)}
</div>
);
})}
</div>
)}
</div>
);
}

View file

@ -1,84 +1,43 @@
import { getPropertyMeta } from "@/app/portfolio/[slug]/building-passport/[propertyId]/utils";
import { and, eq } from "drizzle-orm";
import { DocumentsTable } from "./DocumentsTable";
import { GenericDocumentsTable } from "./GenericDocumentsTable";
import { surveyDB } from "@/app/db/surveyDB/connection";
import { eq } from "drizzle-orm";
import { db } from "@/app/db/db";
import { filesFromSurveyor } from "@/app/db/schema/files_from_surveyor";
import type { FilesFromSurveyor } from "@/app/db/schema/files_from_surveyor";
import { uploadedFiles } from "@/app/db/surveyDB/schema/surveyDB";
import { type getUploadedFiles } from "@/app/db/surveyDB/schema/surveyDB";
async function getDocuments(uprn: number): Promise<getUploadedFiles> {
const result = surveyDB.query.uploadedFiles.findMany({
where: eq(uploadedFiles.uprn, String(uprn)),
});
return result;
}
async function getSurveyorDocuments(
portfolioId: string,
propertyId: string
): Promise<FilesFromSurveyor[]> {
const files = await db
.select()
.from(filesFromSurveyor)
.where(
and(
eq(filesFromSurveyor.portfolioId, BigInt(portfolioId)),
eq(filesFromSurveyor.propertyId, BigInt(propertyId))
)
);
return files;
}
import { uploadedFiles } from "@/app/db/schema/uploaded_files";
import { DocumentsClient, type RawFileType } from "./DocumentsClient";
export default async function DocumentsPage(props: {
params: Promise<{ slug: string; propertyId: string }>;
}) {
const params = await props.params;
// Get the property UPRN
const propertyId = params.propertyId;
const { propertyId } = params;
if (!propertyId || propertyId === "0") {
throw Error("Invalid propertyId");
}
const propertyMeta = await getPropertyMeta(propertyId);
const uploadedFiles = await getDocuments(propertyMeta.uprn);
// We also fetch surveyor documents, which is a temp solution
const surveyorDocuments = await getSurveyorDocuments(params.slug, propertyId);
const rows = await db
.select({
id: uploadedFiles.id,
s3FileKey: uploadedFiles.s3FileKey,
s3FileBucket: uploadedFiles.s3FileBucket,
s3UploadTimestamp: uploadedFiles.s3UploadTimestamp,
fileType: uploadedFiles.fileType,
})
.from(uploadedFiles)
.where(eq(uploadedFiles.uprn, BigInt(propertyMeta.uprn)));
const documents = rows.map((row) => ({
id: String(row.id),
s3FileKey: row.s3FileKey,
s3FileBucket: row.s3FileBucket,
docType: (row.fileType ?? "unknown") as RawFileType,
s3UploadTimestamp: row.s3UploadTimestamp.toISOString(),
}));
return (
<>
<div className="mt-6">
<div className="flex items-center justify-between py-4 px-6 bg-brandblue text-white font-semibold text-lg rounded-md">
Core Survey Documents
</div>
<div className="py-4">
<DocumentsTable
uprn={propertyMeta.uprn.toString()}
uploadedFilesData={uploadedFiles}
/>
</div>
<div className="py-4"></div>
<div className="flex items-center justify-between py-4 px-6 bg-brandblue text-white font-semibold text-lg rounded-md">
Surveyor Uploaded Documents
</div>
<div className="py-4">
<GenericDocumentsTable
uprn={propertyMeta.uprn.toString()}
files={surveyorDocuments}
/>
</div>
<div className="py-4"></div>
<div className="flex items-center justify-between py-4 px-6 bg-brandblue text-white font-semibold text-lg rounded-md">
Coordination
</div>
</div>
</>
<div className="mt-6 px-1">
<DocumentsClient documents={documents} />
</div>
);
}

View file

@ -57,7 +57,7 @@ export default async function DashboardLayout(props: {
</h1>
<p className="text-xl text-gray-700">{propertyMeta.postcode}</p>
</div>
<div className="col-span-12 justify-center bg-gray-50 py-2 rounded-md">
<div className="col-span-12 justify-center bg-white/70 backdrop-blur-md border border-gray-100 shadow-sm py-1.5 rounded-xl">
<Toolbar
propertyId={propertyId}
portfolioId={portfolioId}

View file

@ -1,70 +1,360 @@
import EpcCard from "@/app/components/building-passport/EpcCard";
import { formatDateTime } from "@/app/utils";
import {
HomeIcon,
BoltIcon,
CloudIcon,
BanknotesIcon,
WrenchScrewdriverIcon,
SparklesIcon,
BuildingOfficeIcon,
CalendarIcon,
HomeModernIcon,
ClockIcon,
UserGroupIcon,
} from "@heroicons/react/24/solid";
import { getPropertyMeta } from "./utils";
ShieldCheckIcon,
} from "@heroicons/react/24/outline";
import { CheckCircleIcon, XCircleIcon } from "@heroicons/react/24/solid";
import {
getPropertyMeta,
getConditionReport,
getSpatialData,
getInstalledMeasuresByUprn,
} from "./utils";
import { HeritageTooltip } from "./HeritageTooltip";
export const revalidate = 1;
export default async function BuildingPassportHome(
props: {
params: Promise<{ slug: string; propertyId: string }>;
// ── Helpers ───────────────────────────────────────────────────────────────────
function formatGbp(value: number | null | undefined): string {
if (value == null) return "—";
return `£${Math.round(value).toLocaleString("en-GB")}`;
}
/** Map EPC letter to its hex color from the project's palette */
function getEpcHex(letter: string | null | undefined): string {
switch (letter?.toUpperCase()) {
case "A": return "#117d58";
case "B": return "#2da55c";
case "C": return "#8dbd40";
case "D": return "#f7cd14";
case "E": return "#f3a96a";
case "F": return "#ef8026";
case "G": return "#e41e3b";
default: return "#9ca3af";
}
) {
}
function getEpcDescription(letter: string | null | undefined): string {
switch (letter?.toUpperCase()) {
case "A":
case "B": return "This property is performing at or above modern energy standards.";
case "C": return "This property meets modern energy performance benchmarks.";
case "D": return "This property is performing slightly below modern energy standards.";
case "E": return "This property is performing below modern energy standards.";
case "F":
case "G": return "This property is performing significantly below modern energy standards.";
default: return "Energy performance data is not yet available for this property.";
}
}
// ── Sub-components ────────────────────────────────────────────────────────────
function SectionHeading({ icon, label }: { icon: React.ReactNode; label: string }) {
return (
<div className="flex items-center gap-3 mb-5">
<span className="text-brandblue">{icon}</span>
<h2 className="font-manrope font-bold text-sm text-brandblue uppercase tracking-widest">{label}</h2>
</div>
);
}
function YesNoBadge({ value }: { value: boolean }) {
return value ? (
<span className="inline-flex items-center gap-1 text-xs font-semibold text-emerald-700 bg-emerald-50 border border-emerald-200 px-2 py-0.5 rounded-full">
<CheckCircleIcon className="w-3 h-3" />
Yes
</span>
) : (
<span className="inline-flex items-center gap-1 text-xs font-semibold text-gray-500 bg-gray-50 border border-gray-200 px-2 py-0.5 rounded-full">
<XCircleIcon className="w-3 h-3" />
No
</span>
);
}
function DetailRow({ label, value }: { label: string; value: React.ReactNode }) {
return (
<div className="flex items-center justify-between py-3 border-b border-gray-100 last:border-0 gap-4">
<span className="text-sm text-gray-500 shrink-0">{label}</span>
<span className="text-sm font-semibold text-brandblue text-right">{value}</span>
</div>
);
}
// ── Page ──────────────────────────────────────────────────────────────────────
export default async function BuildingPassportHome(props: {
params: Promise<{ slug: string; propertyId: string }>;
}) {
const params = await props.params;
// This is a server component and because we make the exact same request in the layout,
// the response is cached so we just gain access to the data
const propertyMeta = await getPropertyMeta(params.propertyId);
const conditionReport = await getConditionReport(params.propertyId);
const spatial = await getSpatialData(propertyMeta.uprn);
const installedMeasures = await getInstalledMeasuresByUprn(Number(propertyMeta.uprn));
const annualEnergyCost =
(conditionReport.heatingEnergyCostCurrent ?? 0) +
(conditionReport.hotWaterEnergyCostCurrent ?? 0) +
(conditionReport.lightingEnergyCostCurrent ?? 0);
const epcLetter = propertyMeta.currentEpcRating ?? null;
const sapScore = propertyMeta.currentSapPoints ?? 0;
const epcHex = getEpcHex(epcLetter);
return (
<div className="flex flex-col items-center mt-4">
<div className="flex justify-center mt-4 space-x-2">
<EpcCard
epcRating={propertyMeta.currentEpcRating}
fullMargin={false}
kwh={propertyMeta.detailsEpc.currentEnergyDemand}
carbon={propertyMeta.detailsEpc.co2Emissions}
/>
<div className="flex flex-col p-8 bg-white shadow rounded-md max-w-2xl mx-auto justify-start text-gray-700">
<div className="text-2xl font-bold mb-4">Your property</div>
<div className="flex items-center space-x-2 mb-2">
<CalendarIcon className="h-5 w-5 text-gray-400" />
<div className="text-gray-500">Building Passport Created At:</div>
<div>{formatDateTime(propertyMeta.createdAt)}</div>
<div className="max-w-7xl mx-auto px-6 py-10 space-y-6">
{/* ── Row 1: EPC Hero + Energy Stats ──────────────────────────────── */}
<div className="grid grid-cols-12 gap-6">
{/* EPC Hero */}
<section className="col-span-12 lg:col-span-5 bg-white rounded-2xl p-10 flex flex-col justify-between shadow-sm border border-gray-100 relative overflow-hidden">
<div
className="absolute top-0 right-0 w-72 h-72 rounded-full blur-3xl -mr-20 -mt-20 opacity-10"
style={{ backgroundColor: epcHex }}
/>
<div className="relative z-10">
<p className="font-manrope text-xs font-bold text-brandmidblue uppercase tracking-widest mb-6">
Current Efficiency State
</p>
<div className="flex items-baseline gap-4 mb-4">
<span
className="text-[110px] font-black font-manrope leading-none tracking-tighter"
style={{ color: epcHex }}
>
{epcLetter ?? "—"}
</span>
<span className="text-4xl font-bold font-manrope text-gray-400">
/ {sapScore || ""}
</span>
</div>
<p className="text-gray-500 font-medium text-sm max-w-xs leading-relaxed">
{getEpcDescription(epcLetter)}
</p>
</div>
<div className="flex items-center space-x-2 mb-2">
<HomeIcon className="h-5 w-5 text-gray-400" />
<div className="text-gray-500">Property Type:</div>
<div className="text-gray-700">{propertyMeta.propertyType}</div>
<div className="mt-10 space-y-3">
<div className="relative h-2.5 w-full bg-gray-100 rounded-full overflow-hidden">
<div
className="absolute inset-y-0 left-0 rounded-full"
style={{
width: `${Math.min(100, Math.max(2, sapScore))}%`,
background: "linear-gradient(to right, #e41e3b, #ef8026, #f7cd14, #8dbd40, #117d58)",
}}
/>
</div>
<div className="flex justify-between text-[10px] font-bold text-gray-400 uppercase tracking-wider">
<span>Very Inefficient</span>
<span>Very Efficient</span>
</div>
</div>
<div className="flex items-center space-x-2 mb-2">
<BuildingOfficeIcon className="h-5 w-5 text-gray-400" />
<div className="text-gray-500">Built Form:</div>
<div className="text-gray-700">{propertyMeta.builtForm}</div>
</section>
{/* Energy Stats + Heritage Status */}
<div className="col-span-12 lg:col-span-7 flex flex-col gap-4">
{/* 3 stat cards */}
<div className="grid grid-cols-3 gap-4">
{/* Stat: Energy Demand */}
<div className="bg-gray-50 rounded-2xl p-7 flex flex-col justify-between">
<BoltIcon className="w-6 h-6 text-brandmidblue mb-4" />
<div>
<p className="text-xs font-bold text-gray-400 uppercase tracking-wider mb-1">Energy Demand</p>
<p className="font-manrope text-2xl font-black text-brandblue">
{conditionReport.currentEnergyDemand != null
? Math.round(conditionReport.currentEnergyDemand).toLocaleString("en-GB")
: "—"}
<span className="text-sm font-bold text-gray-400 ml-1.5">kWh/yr</span>
</p>
</div>
</div>
{/* Stat: CO₂ Emissions */}
<div className="bg-gray-50 rounded-2xl p-7 flex flex-col justify-between">
<CloudIcon className="w-6 h-6 text-brandmidblue mb-4" />
<div>
<p className="text-xs font-bold text-gray-400 uppercase tracking-wider mb-1">CO Emissions</p>
<p className="font-manrope text-2xl font-black text-brandblue">
{conditionReport.co2Emissions != null
? conditionReport.co2Emissions.toFixed(1)
: "—"}
<span className="text-sm font-bold text-gray-400 ml-1.5">t CO/yr</span>
</p>
</div>
</div>
{/* Stat: Annual Bills */}
<div className="bg-gray-50 rounded-2xl p-7 flex flex-col justify-between">
<BanknotesIcon className="w-6 h-6 text-brandmidblue mb-4" />
<div>
<p className="text-xs font-bold text-gray-400 uppercase tracking-wider mb-1">Est. Annual Bills</p>
<p className="font-manrope text-2xl font-black text-brandblue">
{annualEnergyCost > 0 ? `£${Math.round(annualEnergyCost).toLocaleString("en-GB")}` : "—"}
</p>
</div>
</div>
</div>
<div className="flex items-center space-x-2 mb-2">
<ClockIcon className="h-5 w-5 text-gray-400" />
<div className="text-gray-500">Year Built:</div>
<div className="text-gray-700">{propertyMeta.yearBuilt}</div>
</div>
<div className="flex items-center space-x-2 mb-2">
<UserGroupIcon className="h-5 w-5 text-gray-400" />
<div className="text-gray-500">Tenure:</div>
<div className="text-gray-700">{propertyMeta.tenure}</div>
</div>
<div className="flex items-center space-x-2 mb-2">
<HomeModernIcon className="h-5 w-5 text-gray-400" />
<div className="text-gray-500">Number of Habitable Rooms:</div>
<div className="text-gray-700">{propertyMeta.numberOfRooms}</div>
{/* Heritage Status — fills remaining height to match EPC card */}
<div className="flex-1 bg-white rounded-2xl p-8 shadow-sm border border-gray-100 flex flex-col justify-between">
<div>
<div className="flex items-center gap-3 mb-6">
<span className="w-8 h-8 rounded-xl bg-brandblue/8 flex items-center justify-center shrink-0">
<ShieldCheckIcon className="w-4 h-4 text-brandblue" />
</span>
<p className="font-manrope text-xs font-bold text-brandmidblue uppercase tracking-widest">Heritage & Planning Status</p>
<HeritageTooltip />
</div>
<div className="grid grid-cols-3 gap-4">
<div className="flex flex-col gap-3">
<p className="text-xs font-bold text-gray-400 uppercase tracking-wider">Conservation Area</p>
<YesNoBadge value={!!spatial.conservationStatus} />
<p className="text-xs text-gray-400 leading-relaxed">
{spatial.conservationStatus
? "This property falls within a designated conservation area."
: "No conservation area restrictions apply to this property."}
</p>
</div>
<div className="flex flex-col gap-3">
<p className="text-xs font-bold text-gray-400 uppercase tracking-wider">Listed Building</p>
<YesNoBadge value={!!spatial.isListedBuilding} />
<p className="text-xs text-gray-400 leading-relaxed">
{spatial.isListedBuilding
? "This property is a listed building with statutory protections."
: "This property does not have listed building status."}
</p>
</div>
<div className="flex flex-col gap-3">
<p className="text-xs font-bold text-gray-400 uppercase tracking-wider">Heritage Building</p>
<YesNoBadge value={!!spatial.isHeritageBuilding} />
<p className="text-xs text-gray-400 leading-relaxed">
{spatial.isHeritageBuilding
? "This property is recognised as a heritage asset."
: "No heritage asset designation applies to this property."}
</p>
</div>
</div>
</div>
</div>
</div>
</div>
{/* ── Row 2: Property Details Grid ────────────────────────────────── */}
<div>
<SectionHeading
icon={<BuildingOfficeIcon className="w-4 h-4" />}
label="Property Details"
/>
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
{/* Building */}
<div className="bg-white rounded-2xl p-7 shadow-sm border border-gray-100">
<p className="font-manrope text-xs font-bold text-brandmidblue uppercase tracking-widest mb-4">Building</p>
<DetailRow label="Year built" value={propertyMeta.yearBuilt ?? "—"} />
<DetailRow
label="Type"
value={[propertyMeta.builtForm, propertyMeta.propertyType].filter(Boolean).join(", ") || "—"}
/>
<DetailRow
label="Floor area"
value={
conditionReport.totalFloorArea != null
? <>{Math.round(conditionReport.totalFloorArea)} m²</>
: "—"
}
/>
<DetailRow label="Storeys" value={conditionReport.numberStoreys ?? "—"} />
<DetailRow label="Habitable rooms" value={propertyMeta.numberOfRooms ?? "—"} />
<DetailRow
label="Mains gas"
value={
conditionReport.mainsGas != null
? <YesNoBadge value={conditionReport.mainsGas} />
: "—"
}
/>
</div>
{/* Location & Status */}
<div className="bg-white rounded-2xl p-7 shadow-sm border border-gray-100">
<p className="font-manrope text-xs font-bold text-brandmidblue uppercase tracking-widest mb-4">Location & Status</p>
<DetailRow label="Local authority" value={propertyMeta.localAuthority ?? "—"} />
<DetailRow label="Constituency" value={propertyMeta.constituency ?? "—"} />
<DetailRow label="Tenure" value={propertyMeta.tenure ?? "—"} />
</div>
{/* Annual Energy Costs */}
<div className="bg-white rounded-2xl p-7 shadow-sm border border-gray-100">
<p className="font-manrope text-xs font-bold text-brandmidblue uppercase tracking-widest mb-4">Annual Energy Costs</p>
<DetailRow label="Heating" value={formatGbp(conditionReport.heatingEnergyCostCurrent)} />
<DetailRow label="Hot water" value={formatGbp(conditionReport.hotWaterEnergyCostCurrent)} />
<DetailRow label="Lighting" value={formatGbp(conditionReport.lightingEnergyCostCurrent)} />
<DetailRow label="Appliances" value={formatGbp(conditionReport.appliancesEnergyCostCurrent)} />
{annualEnergyCost > 0 && (
<div className="mt-3 pt-3 border-t border-gray-200 flex justify-between">
<span className="text-sm font-bold text-gray-600">Total (excl. appliances)</span>
<span className="text-sm font-bold text-brandblue tabular-nums">
£{Math.round(annualEnergyCost).toLocaleString("en-GB")}
</span>
</div>
)}
</div>
</div>
</div>
{/* ── Row 3: Installed Measures ────────────────────────────────────── */}
{installedMeasures.length > 0 && (
<div>
<SectionHeading
icon={<WrenchScrewdriverIcon className="w-4 h-4" />}
label="Installed Measures"
/>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
{installedMeasures.map((measure, i) => (
<div
key={i}
className="bg-white rounded-2xl border border-gray-100 p-6 shadow-sm flex flex-col gap-3"
>
<div className="flex items-center gap-3">
<span className="w-8 h-8 rounded-xl bg-brandblue/8 flex items-center justify-center shrink-0">
<SparklesIcon className="w-4 h-4 text-brandblue" />
</span>
<span className="text-sm font-bold text-brandblue">{measure.measureType}</span>
</div>
{measure.installedAt && (
<p className="text-xs text-gray-400">
Installed {new Date(measure.installedAt).toLocaleDateString("en-GB", { month: "short", year: "numeric" })}
</p>
)}
<div className="grid grid-cols-2 gap-2 mt-1">
{measure.kwhSavings != null && (
<div>
<p className="text-[10px] uppercase tracking-wide text-gray-400 font-medium">kWh saved</p>
<p className="text-sm font-semibold text-brandblue tabular-nums">
{Math.round(measure.kwhSavings).toLocaleString()}/yr
</p>
</div>
)}
{measure.billSavings != null && (
<div>
<p className="text-[10px] uppercase tracking-wide text-gray-400 font-medium">Bill saving</p>
<p className="text-sm font-semibold text-brandblue tabular-nums">
£{Math.round(measure.billSavings).toLocaleString()}/yr
</p>
</div>
)}
</div>
</div>
))}
</div>
</div>
)}
</div>
);
}
}

View file

@ -2,14 +2,16 @@
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 { EllipsisVerticalIcon, ArrowRightIcon } from "@heroicons/react/24/outline";
import { useRouter, usePathname } from "next/navigation";
import { formatNumber } from "@/app/utils";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/app/shadcn_components/ui/dropdown-menu";
import {
Dialog,
DialogContent,
@ -17,7 +19,6 @@ import {
DialogTitle,
DialogFooter,
} from "@/app/shadcn_components/ui/dialog";
import {
Table,
TableBody,
@ -26,33 +27,17 @@ import {
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;
};
type DeletionPreviewRow = { table: string; count: number };
/* ----------------------------------------
Fetchers
----------------------------------------- */
async function fetchPlanDeletionPreview(
planId: string
): Promise<DeletionPreviewRow[]> {
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;
return (await res.json()).preview;
}
async function confirmPlanDeletion(planId: string): Promise<void> {
@ -61,36 +46,35 @@ async function confirmPlanDeletion(planId: string): Promise<void> {
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");
}
if (!res.ok) throw new Error("Failed to delete plan");
}
/* ----------------------------------------
Component
----------------------------------------- */
export default function PlanCard({
expectedEpcRating,
currentEpcRating,
createdAt,
totalEstimatedCost,
totalSapPoints,
planName,
planId,
isDefault,
}: {
expectedEpcRating: string;
currentEpcRating: string;
createdAt: Date;
totalEstimatedCost: number;
totalSapPoints: number;
planName: string | null;
planId: string;
isDefault: boolean;
}) {
const [open, setOpen] = useState(false);
const [setDefaultOpen, setSetDefaultOpen] = useState(false);
const [settingDefault, setSettingDefault] = useState(false);
const queryClient = useQueryClient();
const router = useRouter();
const pathname = usePathname();
/* -------- Preview query -------- */
const {
data: preview = [],
isLoading,
@ -98,79 +82,124 @@ export default function PlanCard({
} = useQuery({
queryKey: ["planDeletionPreview", planId],
queryFn: () => fetchPlanDeletionPreview(planId),
enabled: open, // only fetch when modal opens
enabled: open,
});
/* -------- Delete mutation -------- */
const deleteMutation = useMutation({
mutationFn: () => confirmPlanDeletion(planId),
onSuccess: () => {
setOpen(false);
queryClient.invalidateQueries({ queryKey: ["plans"] });
router.refresh();
},
});
async function handleSetDefault() {
setSettingDefault(true);
setSetDefaultOpen(false);
try {
await fetch(`/api/plan/${planId}/set-default`, { method: "POST" });
router.refresh();
} finally {
setSettingDefault(false);
}
}
const sapImprovement = Math.round((totalSapPoints + Number.EPSILON) * 100) / 100;
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>
<div className="bg-gray-50 rounded-2xl p-8 flex flex-col justify-between hover:shadow-md transition-shadow border border-gray-100 min-h-[220px]">
<div>
{/* Title row */}
<div className="flex justify-between items-start mb-6">
<h3 className="font-manrope font-extrabold text-2xl text-brandblue tracking-tight pr-4">
{planName ?? "Unnamed Plan"}
</h3>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type="button"
className="flex-shrink-0 rounded-lg p-1.5 text-gray-400 hover:text-gray-700 hover:bg-gray-200 focus:outline-none transition"
aria-label="Plan options"
>
<EllipsisVerticalIcon className="h-5 w-5" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
className="cursor-pointer"
onSelect={() => setSetDefaultOpen(true)}
disabled={settingDefault}
>
{settingDefault ? "Setting…" : "Set as Default"}
</DropdownMenuItem>
<DropdownMenuItem
className="text-red-600 focus:text-red-600 focus:bg-red-50 cursor-pointer"
onSelect={() => setOpen(true)}
>
Delete Plan
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
{/* 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>
{/* Stats */}
<div className="space-y-3">
<div className="flex justify-between items-center text-sm">
<span className="text-gray-400 font-medium">Expected EPC</span>
<span className="font-bold text-brandblue">{expectedEpcRating}</span>
</div>
<div className="flex justify-between">
<span>Total SAP points:</span>
<span>
{Math.round((totalSapPoints + Number.EPSILON) * 100) / 100}
</span>
<div className="flex justify-between items-center text-sm">
<span className="text-gray-400 font-medium">Investment</span>
<span className="font-bold text-brandblue">£{formatNumber(totalEstimatedCost)}</span>
</div>
<div className="flex justify-between items-center text-sm">
<span className="text-gray-400 font-medium">SAP Gain</span>
<span className="font-bold text-brandblue">+{sapImprovement} pts</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
----------------------------------------- */}
{/* CTA */}
<button
type="button"
onClick={() => router.push(`${pathname}/${planId}`)}
className="mt-8 w-full text-brandmidblue font-manrope font-bold text-sm text-left flex items-center gap-2 hover:gap-3 transition-all group"
>
View Details
<ArrowRightIcon className="w-4 h-4 group-hover:translate-x-1 transition-transform" />
</button>
</div>
{/* Set default confirmation modal */}
<Dialog open={setDefaultOpen} onOpenChange={setSetDefaultOpen}>
<DialogContent className="max-w-md">
<ModalHeader>
<DialogTitle className="font-manrope font-extrabold text-brandblue text-xl">
Change default plan?
</DialogTitle>
</ModalHeader>
<p className="text-sm text-gray-500 leading-relaxed">
<span className="font-semibold text-brandblue">{planName ?? "This plan"}</span> will
become the recommended strategy shown at the top of the page. You can change it again
at any time.
</p>
<DialogFooter className="gap-2 pt-2">
<Button variant="outline" onClick={() => setSetDefaultOpen(false)}>
Cancel
</Button>
<Button
className="bg-brandblue hover:bg-hoverblue text-white"
onClick={handleSetDefault}
>
Yes, set as default
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Delete preview modal */}
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent className="max-w-lg">
<ModalHeader>
@ -180,9 +209,7 @@ export default function PlanCard({
{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>
<p className="text-sm text-red-600">Failed to load deletion preview</p>
) : (
<div className="rounded-md border border-gray-200">
<Table>
@ -195,12 +222,8 @@ export default function PlanCard({
<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>
<TableCell className="font-mono text-sm">{row.table}</TableCell>
<TableCell className="text-right font-semibold">{row.count}</TableCell>
</TableRow>
))}
</TableBody>
@ -209,14 +232,9 @@ export default function PlanCard({
)}
<DialogFooter className="gap-2">
<Button
variant="outline"
onClick={() => setOpen(false)}
disabled={deleteMutation.isPending}
>
<Button variant="outline" onClick={() => setOpen(false)} disabled={deleteMutation.isPending}>
Cancel
</Button>
<Button
variant="destructive"
onClick={() => deleteMutation.mutate()}

View file

@ -0,0 +1,479 @@
"use client";
import { useState } from "react";
import { useMutation, useQuery } from "@tanstack/react-query";
import { useRouter, usePathname } from "next/navigation";
import {
ArrowRightIcon,
EllipsisVerticalIcon,
} from "@heroicons/react/24/outline";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/app/shadcn_components/ui/dropdown-menu";
import {
Dialog,
DialogContent,
DialogHeader,
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";
type DeletionPreviewRow = { table: string; count: number };
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");
return (await res.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) throw new Error("Failed to delete plan");
}
function getEpcHex(letter: string | null | undefined): string {
switch (letter?.toUpperCase()) {
case "A":
return "#117d58";
case "B":
return "#2da55c";
case "C":
return "#8dbd40";
case "D":
return "#f7cd14";
case "E":
return "#f3a96a";
case "F":
return "#ef8026";
case "G":
return "#e41e3b";
default:
return "#9ca3af";
}
}
function epcToPosition(rating: string): number {
const map: Record<string, number> = {
G: 0,
F: 1 / 6,
E: 2 / 6,
D: 3 / 6,
C: 4 / 6,
B: 5 / 6,
A: 1,
};
return map[rating?.toUpperCase()] ?? 0;
}
export default function PlanHeroCard({
planId,
planName,
currentEpcRating,
expectedEpcRating,
totalEstimatedCost,
totalSapPoints,
co2Savings,
energyBillSavings,
valuationIncreaseLowerBound,
valuationIncreaseUpperBound,
createdAt,
}: {
planId: string;
planName: string | null;
currentEpcRating: string;
expectedEpcRating: string;
totalEstimatedCost: number;
totalSapPoints: number;
co2Savings: number | null;
energyBillSavings: number | null;
valuationIncreaseLowerBound: number | null;
valuationIncreaseUpperBound: number | null;
createdAt: Date;
}) {
const [deleteOpen, setDeleteOpen] = useState(false);
const router = useRouter();
const pathname = usePathname();
const sapImprovement =
Math.round((totalSapPoints + Number.EPSILON) * 100) / 100;
const createdDate = new Date(createdAt).toLocaleDateString("en-GB", {
day: "numeric",
month: "short",
year: "numeric",
});
const currentHex = getEpcHex(currentEpcRating);
const expectedHex = getEpcHex(expectedEpcRating);
const currentPos = epcToPosition(currentEpcRating);
const expectedPos = epcToPosition(expectedEpcRating);
const carsEquivalent =
co2Savings != null ? Math.round(co2Savings / 1.47) : null;
const {
data: preview = [],
isLoading: previewLoading,
isError: previewError,
} = useQuery({
queryKey: ["planDeletionPreview", planId],
queryFn: () => fetchPlanDeletionPreview(planId),
enabled: deleteOpen,
});
const deleteMutation = useMutation({
mutationFn: () => confirmPlanDeletion(planId),
onSuccess: () => {
setDeleteOpen(false);
router.refresh();
},
});
return (
<>
<div className="grid grid-cols-1 lg:grid-cols-12 gap-6">
{/* ── Featured hero card ─────────────────────────────── */}
<div className="lg:col-span-8 bg-white rounded-2xl shadow-sm border border-gray-100 p-8 flex flex-col md:flex-row gap-8">
{/* Left: content */}
<div className="flex-1 flex flex-col gap-7">
{/* Header */}
<div className="flex items-start justify-between gap-4">
<div>
<span className="inline-flex items-center gap-1.5 bg-brandblue text-brandgold px-3 py-1 rounded-full text-[10px] font-bold uppercase tracking-wider mb-3">
<svg
className="w-2.5 h-2.5"
viewBox="0 0 24 24"
fill="currentColor"
>
<path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z" />
</svg>
Recommended Strategy
</span>
<h2 className="font-manrope font-extrabold text-3xl text-brandblue tracking-tight">
{planName ?? "Unnamed Plan"}
</h2>
<p className="text-sm text-gray-400 font-medium mt-1">
Created: {createdDate}
</p>
</div>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type="button"
className="rounded-lg p-1.5 text-gray-400 hover:text-gray-700 hover:bg-gray-100 focus:outline-none transition"
aria-label="Plan options"
>
<EllipsisVerticalIcon className="h-5 w-5" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
className="text-red-600 focus:text-red-600 focus:bg-red-50 cursor-pointer"
onSelect={() => setDeleteOpen(true)}
>
Delete Plan
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
{/* EPC progress */}
<div className="space-y-4">
<div className="flex justify-between text-[10px] font-bold uppercase tracking-widest text-gray-400">
<span>Current Rating: {currentEpcRating}</span>
<span>Target Rating: {expectedEpcRating}</span>
</div>
{/* Bar + pin */}
<div className="relative py-2.5">
<div className="relative h-3 w-full bg-gray-100 rounded-full">
<div
className="absolute inset-y-0 left-0 rounded-full"
style={{
width: `${expectedPos * 100}%`,
background: `linear-gradient(to right, ${currentHex}, ${expectedHex})`,
}}
/>
</div>
{/* Current position pin */}
<div
className="absolute top-1/2 w-5 h-5 rounded-full border-4 bg-white shadow-md z-10"
style={{
left: `${currentPos * 100}%`,
transform: "translateX(-50%) translateY(-50%)",
borderColor: currentHex,
}}
/>
</div>
{/* SAP gain + cost */}
<div className="flex items-end justify-between pt-1">
<div>
<span className="font-manrope font-black text-2xl text-brandblue tabular-nums">
+{sapImprovement}
</span>
<span className="text-sm font-medium text-gray-400 ml-1.5">
SAP Gain
</span>
</div>
<div className="text-right">
<p className="text-xs text-gray-400 font-medium mb-0.5">
Estimated Investment
</p>
<span className="font-manrope font-black text-2xl text-brandblue tabular-nums">
£{formatNumber(totalEstimatedCost)}
</span>
</div>
</div>
</div>
{/* CTA button */}
<div className="flex justify-end mt-auto">
<button
type="button"
onClick={() => router.push(`${pathname}/${planId}`)}
className="py-2.5 px-6 rounded-xl bg-brandblue text-white font-manrope font-bold text-sm hover:bg-hoverblue transition-colors active:scale-95 flex items-center gap-2"
>
View Plan
<ArrowRightIcon className="w-4 h-4" />
</button>
</div>
</div>
{/* Right: image */}
<div className="w-full md:w-64 h-56 md:h-auto rounded-xl overflow-hidden flex-shrink-0">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src="https://lh3.googleusercontent.com/aida-public/AB6AXuD3ciUtcps6C-Cimp8GUpI-SmEjXEAyrwoPfhhVGV2-q_4KV1pYKqP1zGAVF7mN4NYLVsKIRW4qSphWvqwSOTbnEtT_ogwJ_jz1bSFbUG34gG_dVpBLjMGuo2yFlgVWDZDPFFbm2j5HQtRcXQemDnyQ6uHb8oxqhSu8_duOWSUGMcWPZAR_pPUUOoGd9g5hCjf00Amhs6GE61LlTM0U7B0hrKB58570DMDK_5mwabLnEF0W2sFV6ADwcIZpt_ZJuE0dMp3YzvEPjiBz"
alt="Modern architectural house exterior"
className="w-full h-full object-cover"
/>
</div>
</div>
{/* ── Impact card ─────────────────────────────── */}
<div className="lg:col-span-4 bg-gradient-to-br from-brandblue to-[#0a2a4a] text-white rounded-2xl p-8 flex flex-col justify-between shadow-sm border border-white/10">
<div className="space-y-6">
<h3 className="font-manrope font-bold text-xl">Impact</h3>
{/* CO2 */}
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-full bg-white/15 flex items-center justify-center flex-shrink-0">
<svg
className="w-5 h-5 text-white"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2}
>
<path d="M12 2a7 7 0 017 7c0 5-7 13-7 13S5 14 5 9a7 7 0 017-7z" />
<circle cx="12" cy="9" r="2.5" />
</svg>
</div>
<div className="flex-1">
<p className="text-xs text-white/60 font-medium">
Annual CO Reduction
</p>
<div className="flex items-center gap-2">
<p className="font-manrope font-bold text-lg">
{co2Savings != null
? `${co2Savings.toFixed(1)} Tonnes`
: "—"}
</p>
{carsEquivalent != null && carsEquivalent > 0 && (
<div className="relative group/co2tooltip">
<button
type="button"
className="w-4 h-4 rounded-full bg-white/25 flex items-center justify-center text-white hover:bg-white/40 transition-colors cursor-help flex-shrink-0"
tabIndex={-1}
aria-label="Carbon equivalent info"
>
<span className="text-[9px] font-black leading-none">
!
</span>
</button>
<div className="pointer-events-none absolute left-1/2 -translate-x-1/2 bottom-full mb-3 w-64 opacity-0 group-hover/co2tooltip:opacity-100 transition-opacity z-30">
<div className="bg-white rounded-2xl shadow-2xl p-4 border border-gray-100">
<div className="flex items-center gap-2 mb-2">
<div className="w-7 h-7 rounded-full bg-brandblue/8 flex items-center justify-center flex-shrink-0">
<svg
className="w-3.5 h-3.5 text-brandblue"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2}
>
<path d="M5 17H3a1 1 0 01-1-1v-4l2.5-5h11L18 12v4a1 1 0 01-1 1h-2" />
<circle cx="7.5" cy="17.5" r="1.5" />
<circle cx="16.5" cy="17.5" r="1.5" />
<path d="M5 12h14" />
</svg>
</div>
<p className="text-xs font-bold text-brandblue">
Equivalent Impact
</p>
</div>
<p className="text-xs text-gray-500 leading-relaxed">
Like taking{" "}
<span className="font-bold text-brandblue">
~{carsEquivalent} car
{carsEquivalent !== 1 ? "s" : ""}
</span>{" "}
off the road for a year based on the avg. UK car
emitting 1.47 t CO/yr.
</p>
</div>
{/* Caret */}
<div className="flex justify-center">
<div className="w-3 h-1.5 overflow-hidden">
<div className="w-3 h-3 bg-white border border-gray-100 rotate-45 -translate-y-1.5 shadow-sm" />
</div>
</div>
</div>
</div>
)}
</div>
</div>
</div>
{/* Bill savings */}
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-full bg-white/15 flex items-center justify-center flex-shrink-0">
<svg
className="w-5 h-5 text-white"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2}
>
<rect x="2" y="5" width="20" height="14" rx="2" />
<path d="M2 10h20" />
</svg>
</div>
<div>
<p className="text-xs text-white/60 font-medium">
Bill Savings (Est.)
</p>
<p className="font-manrope font-bold text-lg">
{energyBillSavings != null
? `£${formatNumber(energyBillSavings)} / yr`
: "—"}
</p>
</div>
</div>
{/* Valuation uplift */}
{(valuationIncreaseLowerBound != null ||
valuationIncreaseUpperBound != null) && (
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-full bg-white/15 flex items-center justify-center flex-shrink-0">
<svg
className="w-5 h-5 text-white"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2}
>
<path d="M3 9l9-7 9 7v11a2 2 0 01-2 2H5a2 2 0 01-2-2z" />
<path d="M9 22V12h6v10" />
<path d="M12 7v5m0 0l-2-2m2 2l2-2" />
</svg>
</div>
<div>
<p className="text-xs text-white/60 font-medium">
Valuation Uplift (Est.)
</p>
<p className="font-manrope font-bold text-lg">
{valuationIncreaseLowerBound != null &&
valuationIncreaseUpperBound != null
? `${formatNumber(valuationIncreaseLowerBound * 100)}% ${formatNumber(valuationIncreaseUpperBound * 100)}%`
: valuationIncreaseLowerBound != null
? `${formatNumber(valuationIncreaseLowerBound * 100)}%+`
: `Up to ${formatNumber(valuationIncreaseUpperBound! * 100)}%`}
</p>
</div>
</div>
)}
</div>
</div>
</div>
{/* Delete modal */}
<Dialog open={deleteOpen} onOpenChange={setDeleteOpen}>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle className="text-red-600">Delete plan</DialogTitle>
</DialogHeader>
{previewLoading ? (
<p className="text-sm text-gray-500">Loading deletion preview</p>
) : previewError ? (
<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={() => setDeleteOpen(false)}
disabled={deleteMutation.isPending}
>
Cancel
</Button>
<Button
variant="destructive"
onClick={() => deleteMutation.mutate()}
disabled={deleteMutation.isPending}
>
{deleteMutation.isPending ? "Deleting…" : "Delete plan"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
}

View file

@ -0,0 +1,102 @@
function Skeleton({ className }: { className?: string }) {
return (
<div className={`animate-pulse bg-gray-100 rounded-lg ${className ?? ""}`} />
);
}
export default function PlanDetailLoading() {
return (
<div className="max-w-[1400px] mx-auto py-10 space-y-12">
{/* Header skeleton */}
<div className="space-y-3">
<Skeleton className="h-12 w-2/3 rounded-xl" />
<Skeleton className="h-5 w-1/2" />
</div>
{/* Executive Summary Bento skeleton */}
<section className="grid grid-cols-1 md:grid-cols-4 lg:grid-cols-6 gap-6">
{/* SAP Improvement card */}
<div className="md:col-span-2 lg:col-span-2 bg-white p-7 rounded-2xl border border-gray-100 shadow-sm flex flex-col gap-5">
<Skeleton className="h-3 w-28" />
<div className="flex items-end gap-3">
<div className="flex flex-col items-center gap-1.5">
<Skeleton className="h-3 w-12" />
<Skeleton className="h-12 w-10 rounded-lg" />
</div>
<Skeleton className="h-5 w-5 mb-2 rounded" />
<div className="flex flex-col items-center gap-1.5">
<Skeleton className="h-3 w-12" />
<Skeleton className="h-16 w-14 rounded-lg" />
</div>
</div>
<div className="space-y-1.5">
<Skeleton className="h-3 w-full rounded-full" />
<Skeleton className="h-3 w-full" />
</div>
<Skeleton className="h-4 w-40" />
</div>
{/* 4 stat tiles */}
<div className="md:col-span-2 lg:col-span-4 grid grid-cols-2 gap-4">
{[0, 1, 2, 3].map((i) => (
<div key={i} className="bg-blue-50 p-5 rounded-xl flex flex-col justify-between gap-3 border border-blue-100">
<Skeleton className="h-8 w-8 rounded-lg" />
<Skeleton className="h-8 w-24 rounded-lg" />
<Skeleton className="h-3 w-32" />
</div>
))}
</div>
</section>
{/* Financial + Recommendations skeleton */}
<div className="grid grid-cols-1 lg:grid-cols-12 gap-12 items-start">
{/* Left: Financial Overview */}
<div className="lg:col-span-4 space-y-6">
<Skeleton className="h-3 w-40" />
<div className="bg-brandblue/10 p-8 rounded-2xl space-y-6">
<div className="space-y-2">
<Skeleton className="h-3 w-28 bg-gray-200" />
<Skeleton className="h-12 w-40 rounded-lg bg-gray-200" />
</div>
<div className="pt-6 border-t border-gray-200 flex justify-between">
<div className="space-y-2">
<Skeleton className="h-3 w-20 bg-gray-200" />
<Skeleton className="h-6 w-24 rounded-lg bg-gray-200" />
</div>
</div>
</div>
<div className="bg-brandlightblue rounded-xl border border-blue-100 p-5 space-y-2">
<Skeleton className="h-3 w-36" />
<Skeleton className="h-3 w-full" />
<Skeleton className="h-3 w-4/5" />
<Skeleton className="h-3 w-3/5" />
</div>
</div>
{/* Right: Recommendations */}
<div className="lg:col-span-8 space-y-8">
<Skeleton className="h-3 w-44" />
{[0, 1, 2, 3].map((i) => (
<div key={i} className="bg-white rounded-xl border border-gray-100 shadow-sm p-8 space-y-4">
<div className="flex items-center gap-5">
<Skeleton className="h-14 w-14 rounded-full" />
<div className="flex-1 space-y-2">
<Skeleton className="h-5 w-48 rounded-lg" />
<Skeleton className="h-3 w-64" />
</div>
<div className="flex gap-2">
<Skeleton className="h-12 w-20 rounded-lg" />
<Skeleton className="h-12 w-20 rounded-lg" />
</div>
</div>
<Skeleton className="h-12 w-full rounded-xl" />
</div>
))}
</div>
</div>
</div>
);
}

View file

@ -3,29 +3,412 @@ import {
getPropertyMeta,
getRecommendations,
getPlanMeta,
getPlanFunding,
getInstalledMeasuresByUprn,
getScenario,
} from "../../utils";
import { sapToEpc, formatNumber } from "@/app/utils";
import {
CloudIcon,
BoltIcon,
CurrencyPoundIcon,
BuildingOfficeIcon,
InformationCircleIcon,
} from "@heroicons/react/24/outline";
export default async function Recommendations(props: {
function getEpcHex(letter: string | null | undefined): string {
switch (letter?.toUpperCase()) {
case "A":
return "#117d58";
case "B":
return "#2da55c";
case "C":
return "#8dbd40";
case "D":
return "#f7cd14";
case "E":
return "#f3a96a";
case "F":
return "#ef8026";
case "G":
return "#e41e3b";
default:
return "#9ca3af";
}
}
// G on left (worst), A on right (best) — correct EPC scale direction
const EPC_LETTERS = ["G", "F", "E", "D", "C", "B", "A"];
// Returns the horizontal centre position (%) of each EPC band in the 7-segment bar
function epcToBandCenter(letter: string | null | undefined): number {
if (!letter) return 0;
const map: Record<string, number> = {
G: 0,
F: 1,
E: 2,
D: 3,
C: 4,
B: 5,
A: 6,
};
const idx = map[letter.toUpperCase()] ?? 0;
return ((idx + 0.5) / 7) * 100;
}
export default async function PlanDetail(props: {
params: Promise<{ slug: string; propertyId: string; planId: string }>;
}) {
const params = await props.params;
const propertyMeta = await getPropertyMeta(params.propertyId);
const recommendations = await getRecommendations(params.planId);
const planMeta = await getPlanMeta(params.planId);
const funding = await getPlanFunding(params.planId);
const installedMeasures = await getInstalledMeasuresByUprn(propertyMeta.uprn);
const [recommendations, planMeta, installedMeasures, scenarioData] =
await Promise.all([
getRecommendations(params.planId),
getPlanMeta(params.planId),
getInstalledMeasuresByUprn(propertyMeta.uprn),
getScenario(params.planId),
]);
const currentEpc = propertyMeta.currentEpcRating;
const targetEpc =
planMeta.postEpcRating ??
(planMeta.postSapPoints ? sapToEpc(planMeta.postSapPoints) : null);
const valuationLabel = (() => {
if (planMeta.valuationIncreaseAverage)
return `+${planMeta.valuationIncreaseAverage.toFixed(1)}%`;
if (
planMeta.valuationIncreaseLowerBound &&
planMeta.valuationIncreaseUpperBound
)
return `+${planMeta.valuationIncreaseLowerBound.toFixed(0)}${planMeta.valuationIncreaseUpperBound.toFixed(0)}%`;
if (planMeta.valuationIncrease)
return `+${planMeta.valuationIncrease.toFixed(1)}%`;
return null;
})();
return (
<div className="leading-loose tracking-wider">
<RecommendationContainer
recommendations={recommendations}
propertyMeta={propertyMeta}
planMeta={planMeta}
funding={funding}
installedMeasures={installedMeasures}
/>
<div className="max-w-[1400px] mx-auto py-6 space-y-12">
{/* ── Header ─────────────────────────────────────────────────── */}
<div>
<h2 className="text-brandblue text-2xl md:text-5xl font-black font-manrope tracking-tighter text-brandblue mb-2">
Plan: {planMeta.name ?? "Retrofit Plan"}
</h2>
</div>
{/* ── Executive Summary Bento ──────────────────────────────────── */}
<section className="grid grid-cols-1 md:grid-cols-4 lg:grid-cols-6 gap-6">
{/* EPC Rating card */}
<div className="md:col-span-2 lg:col-span-2 bg-white p-7 rounded-2xl border border-gray-100 shadow-sm flex flex-col justify-between relative overflow-hidden group">
{/* Decorative corner accent */}
<div className="absolute top-0 right-0 w-32 h-32 bg-green-500/5 rounded-bl-full -mr-10 -mt-10 transition-transform duration-700 group-hover:scale-110" />
<div>
{/* Header label with decorative line */}
<div className="flex items-center gap-2 mb-7">
<span className="text-[10px] font-bold uppercase tracking-[0.2em] text-brandbrown whitespace-nowrap">
EPC UPGRADE
</span>
<div className="h-px flex-1 bg-brandbrown/20" />
</div>
{/* Current → Target letter badges */}
<div className="flex items-center justify-between mb-8">
<div className="flex flex-col">
<span className="text-[10px] font-bold text-gray-400 uppercase tracking-wider mb-1">
Current
</span>
<span
className="text-5xl font-black font-manrope leading-none"
style={{ color: getEpcHex(currentEpc) }}
>
{currentEpc ?? ""}
</span>
</div>
<svg
className="w-8 h-8 text-gray-300 shrink-0 animate-pulse"
fill="none"
stroke="currentColor"
strokeWidth={1.5}
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M17 8l4 4m0 0l-4 4m4-4H3"
/>
</svg>
<div className="flex flex-col items-end">
<span className="text-[10px] font-bold text-green-500 uppercase tracking-wider mb-1">
Target
</span>
<div className="relative">
<span
className="text-7xl font-black font-manrope leading-none"
style={{
color: getEpcHex(targetEpc),
filter: "drop-shadow(0 10px 15px rgba(34,197,94,0.3))",
}}
>
{targetEpc ?? ""}
</span>
</div>
</div>
</div>
</div>
<div>
{/* Gradient bar with current-position marker */}
<div className="relative h-2 w-full bg-gray-100 rounded-full overflow-hidden mb-5">
<div
className="absolute inset-0 opacity-90"
style={{
background:
"linear-gradient(90deg, #ba1a1a 0%, #c4a47c 50%, #22c55e 100%)",
}}
/>
{currentEpc && (
<div
className="absolute top-0 h-full w-1 bg-white shadow-sm z-10"
style={{ left: `${epcToBandCenter(currentEpc)}%` }}
/>
)}
</div>
{/* Bottom: SAP points summary */}
<div className="flex justify-between items-end">
<div className="space-y-0.5">
<p className="text-xs font-bold font-manrope text-brandblue tracking-tight">
Projected Rating Increase
</p>
<p className="text-[11px] text-gray-500 font-medium">
Improvement from{" "}
<span
className="font-bold"
style={{ color: getEpcHex(currentEpc) }}
>
{propertyMeta.currentSapPoints != null
? Math.round(propertyMeta.currentSapPoints)
: ""}
</span>{" "}
to{" "}
<span className="font-bold text-green-500">
{planMeta.postSapPoints?.toFixed(1) ?? ""}
</span>{" "}
SAP points
</p>
</div>
<svg
className="w-5 h-5 text-brandbrown shrink-0"
fill="none"
stroke="currentColor"
strokeWidth={2}
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M9 12.75L11.25 15 15 9.75M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
</div>
</div>
</div>
{/* 4 stat tiles — cool blue background, icon at top */}
<div className="md:col-span-2 lg:col-span-4 grid grid-cols-2 gap-4">
<div className="bg-blue-50 p-5 rounded-xl flex flex-col justify-between gap-3 border border-blue-100">
<CloudIcon className="w-8 h-8 text-brandmidblue" />
<p className="text-2xl font-bold font-manrope text-brandblue">
{planMeta.co2Savings != null
? `${planMeta.co2Savings.toFixed(1)}t`
: "—"}
</p>
<p className="text-[10px] text-gray-500 font-bold uppercase tracking-widest leading-tight">
CO Reduction /yr
</p>
</div>
<div className="bg-blue-50 p-5 rounded-xl flex flex-col justify-between gap-3 border border-blue-100">
<BoltIcon className="w-8 h-8 text-brandmidblue" />
<p className="text-2xl font-bold font-manrope text-brandblue">
{planMeta.energyConsumptionSavings != null
? `${formatNumber(planMeta.energyConsumptionSavings)} kWh`
: "—"}
</p>
<p className="text-[10px] text-gray-500 font-bold uppercase tracking-widest leading-tight">
Energy Savings /yr
</p>
</div>
<div className="bg-blue-50 p-5 rounded-xl flex flex-col justify-between gap-3 border border-blue-100">
<CurrencyPoundIcon className="w-8 h-8 text-brandmidblue" />
<p className="text-2xl font-bold font-manrope text-brandblue">
{planMeta.energyBillSavings != null
? `£${formatNumber(planMeta.energyBillSavings)}`
: "—"}
</p>
<p className="text-[10px] text-gray-500 font-bold uppercase tracking-widest leading-tight">
Bill Reduction /yr
</p>
</div>
<div className="bg-blue-50 p-5 rounded-xl flex flex-col justify-between gap-3 border border-blue-100">
<BuildingOfficeIcon className="w-8 h-8 text-brandmidblue" />
<p className="text-2xl font-bold font-manrope text-brandblue">
{valuationLabel ?? "—"}
</p>
<p className="text-[10px] text-gray-500 font-bold uppercase tracking-widest leading-tight">
Valuation Boost
</p>
</div>
</div>
</section>
{/* ── Financial + Recommendations ──────────────────────────────── */}
<div className="grid grid-cols-1 lg:grid-cols-12 gap-12 items-start">
{/* Left: Financial Overview — sticky so it stays visible while scrolling recs */}
<div className="lg:col-span-4 space-y-6 lg:sticky lg:top-8">
<h3 className="text-xs font-bold uppercase tracking-widest text-gray-500">
Financial Overview
</h3>
<div className="bg-brandblue text-white p-8 rounded-2xl shadow-xl shadow-brandblue/10">
<div className="mb-8">
<p className="text-white/60 text-sm font-medium mb-1 uppercase tracking-wider">
Total Investment
</p>
<p className="text-5xl font-black font-manrope">
{planMeta.costOfWorks != null
? `£${formatNumber(planMeta.costOfWorks)}`
: "—"}
</p>
</div>
<div className="pt-6 border-t border-white/10 flex justify-between items-center">
<div>
<p className="text-white/60 text-[10px] font-bold uppercase tracking-widest">
Contingency
</p>
<p className="text-xl font-bold font-manrope text-brandbrown">
{planMeta.contingencyCost != null &&
planMeta.contingencyCost > 0
? `£${formatNumber(planMeta.contingencyCost)}`
: "—"}
</p>
</div>
{/* Contingency tooltip */}
<div className="relative group cursor-help">
<InformationCircleIcon className="w-5 h-5 text-white/40 group-hover:text-white/80 transition-colors" />
<div className="absolute bottom-full right-0 mb-3 w-64 bg-gray-900 text-white text-xs rounded-xl p-3.5 shadow-2xl z-20 opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none">
<p className="leading-relaxed text-white/90">
A contingency buffer is added on top of estimated costs to
account for unexpected variations in materials or labour.
Rates vary per measure type.
</p>
<div className="absolute top-full right-4 border-4 border-transparent border-t-gray-900" />
</div>
</div>
</div>
</div>
<p className="text-sm text-gray-600 leading-relaxed">
Based on current market rates and property size. Final quotes may
vary following a technical survey. Contingency rates vary per
measure type.
</p>
{/* About These Estimates box */}
<div className="bg-brandlightblue rounded-xl border border-blue-100 p-5">
<div className="flex items-start gap-3">
<InformationCircleIcon className="w-4 h-4 text-brandblue mt-0.5 shrink-0" />
<div>
<p className="text-xs font-bold text-brandblue uppercase tracking-widest mb-2">
About These Estimates
</p>
<ul className="space-y-1.5">
<li className="text-xs text-gray-600 leading-relaxed flex items-start gap-1.5">
<span className="text-brandblue mt-0.5 shrink-0">·</span>
Costs modelled using current market rates and SAP 10.2
methodology.
</li>
<li className="text-xs text-gray-600 leading-relaxed flex items-start gap-1.5">
<span className="text-brandblue mt-0.5 shrink-0">·</span>
Annual savings are projected based on typical occupancy
patterns.
</li>
<li className="text-xs text-gray-600 leading-relaxed flex items-start gap-1.5">
<span className="text-brandblue mt-0.5 shrink-0">·</span>
Final quotes confirmed after a technical survey of the
property.
</li>
</ul>
</div>
</div>
</div>
{/* Scenario Configuration */}
{scenarioData && (
<div className="bg-white rounded-xl border border-gray-100 p-5 space-y-3">
<p className="text-xs font-bold text-brandblue uppercase tracking-widest">
Scenario Configuration
</p>
{scenarioData.name && (
<div className="flex justify-between text-sm">
<span className="text-gray-500">Scenario</span>
<span className="font-semibold text-gray-800">
{scenarioData.name}
</span>
</div>
)}
<div className="flex justify-between text-sm">
<span className="text-gray-500">Housing type</span>
<span className="font-semibold text-gray-800">
{scenarioData.housingType}
</span>
</div>
{scenarioData.budget != null && (
<div className="flex justify-between text-sm">
<span className="text-gray-500">Budget</span>
<span className="font-semibold text-gray-800">
£{formatNumber(scenarioData.budget)}
</span>
</div>
)}
{scenarioData.goal && (
<div className="flex justify-between text-sm">
<span className="text-gray-500">Goal</span>
<span className="font-semibold text-gray-800 text-right">
{scenarioData.goal}
{scenarioData.goalValue
? `${scenarioData.goalValue}`
: ""}
</span>
</div>
)}
</div>
)}
</div>
{/* Right: Recommended Upgrades — scrollable to avoid excessive page length */}
<div className="lg:col-span-8">
<h3 className="text-xs font-bold uppercase tracking-widest text-gray-500 mb-8 flex items-center gap-3">
Recommended Upgrades
<div className="h-px flex-grow bg-gray-100" />
</h3>
<div className="overflow-y-auto max-h-[780px] pr-2 -mr-2 pb-24">
<RecommendationContainer
recommendations={recommendations}
installedMeasures={installedMeasures}
planId={params.planId}
slug={params.slug}
propertyId={params.propertyId}
currentSapPoints={propertyMeta.currentSapPoints ?? 0}
savedCostOfWorks={planMeta.costOfWorks ?? 0}
savedContingencyCost={planMeta.contingencyCost ?? 0}
/>
</div>
</div>
</div>
</div>
);
}

View file

@ -1,47 +1,132 @@
import { getPlans, getPropertyMeta } from "../utils";
import { sapToEpc } from "@/app/utils";
import PlanCard from "./PlanCard";
import PlanHeroCard from "./PlanHeroCard";
import { WrenchScrewdriverIcon } from "@heroicons/react/24/outline";
export default async function RecommendationPlans(props: {
params: Promise<{ slug: string; propertyId: string }>;
}) {
const params = await props.params;
const propertyMeta = await getPropertyMeta(params.propertyId);
const plans = await getPlans(params.propertyId);
const [propertyMeta, plans] = await Promise.all([
getPropertyMeta(params.propertyId),
getPlans(params.propertyId),
]);
if (plans.length === 0) {
return (
<div className="max-w-7xl mx-auto py-10 space-y-8">
<header>
<p className="font-manrope text-xs font-bold text-brandmidblue uppercase tracking-widest mb-2">
Retrofit Strategy
</p>
</header>
<div className="bg-white rounded-2xl border border-dashed border-gray-200 p-16 flex flex-col items-center justify-center text-center gap-4">
<div className="w-14 h-14 rounded-2xl bg-gray-50 border border-gray-200 flex items-center justify-center">
<WrenchScrewdriverIcon className="w-7 h-7 text-gray-400" />
</div>
<div>
<p className="font-manrope font-bold text-brandblue text-lg">
No plans yet
</p>
<p className="text-sm text-gray-400 mt-1">
Retrofit plans will appear here once they have been generated.
</p>
</div>
</div>
</div>
);
}
function getPlanMetrics(plan: (typeof plans)[number]) {
const totalEstimatedCost = plan.costOfWorks ?? 0;
const totalSapPoints =
(plan.postSapPoints ?? propertyMeta.currentSapPoints) -
propertyMeta.currentSapPoints;
const expectedSapPoints = Math.min(
propertyMeta.currentSapPoints + totalSapPoints,
100,
);
const expectedEpcRating = sapToEpc(expectedSapPoints);
return { totalEstimatedCost, totalSapPoints, expectedEpcRating };
}
const defaultPlan = plans.find((p) => p.isDefault) ?? plans[0];
const otherPlans = plans.filter((p) => p.id !== defaultPlan.id);
const defaultMetrics = getPlanMetrics(defaultPlan);
return (
<div className="leading-loose tracking-wider">
<div className="flex py-8 text-lg">Retrofit Plans</div>
<div className="max-w-7xl mx-auto py-10 space-y-10">
{/* Page header */}
<header>
<p className="font-manrope text-xs font-bold text-brandmidblue uppercase tracking-widest mb-2">
Retrofit Strategy
</p>
<h1 className="font-manrope font-extrabold text-4xl text-brandblue tracking-tight">
Retrofit Plans
</h1>
</header>
<div className="max-w-3xl">
{plans.map((plan) => {
const totalEstimatedCost = plan.costOfWorks || 0;
{/* Hero — default plan + carbon impact card */}
<PlanHeroCard
planId={String(defaultPlan.id)}
planName={defaultPlan.name}
currentEpcRating={propertyMeta.currentEpcRating}
expectedEpcRating={defaultMetrics.expectedEpcRating}
totalEstimatedCost={defaultMetrics.totalEstimatedCost}
totalSapPoints={defaultMetrics.totalSapPoints}
co2Savings={defaultPlan.co2Savings ?? null}
energyBillSavings={defaultPlan.energyBillSavings ?? null}
valuationIncreaseLowerBound={
defaultPlan.valuationIncreaseLowerBound ?? null
}
valuationIncreaseUpperBound={
defaultPlan.valuationIncreaseUpperBound ?? null
}
createdAt={defaultPlan.createdAt}
/>
const totalSapPoints =
(plan.postSapPoints || propertyMeta.currentSapPoints) -
propertyMeta.currentSapPoints;
{/* Secondary plans grid */}
<section>
{otherPlans.length > 0 && (
<div className="flex items-center gap-3 mb-6">
<p className="font-manrope text-xs font-bold text-brandmidblue uppercase tracking-widest">
Other Plans
</p>
<span className="bg-gray-100 text-gray-500 text-[10px] font-bold px-2 py-0.5 rounded-full">
{otherPlans.length}
</span>
</div>
)}
const expectedSapPoints = Math.min(
propertyMeta.currentSapPoints + totalSapPoints,
100
);
const expectedEpcRating = sapToEpc(expectedSapPoints);
return (
<div key={plan.id} className="mb-4">
<PlanCard
expectedEpcRating={expectedEpcRating}
createdAt={plan.createdAt}
totalEstimatedCost={totalEstimatedCost}
totalSapPoints={totalSapPoints}
planName={plan.name}
planId={String(plan.id)}
/>
</div>
);
})}
</div>
<div className="relative">
{/* Gradient fade to indicate overflow */}
<div className="pointer-events-none absolute right-0 inset-y-0 w-20 bg-gradient-to-l from-white to-transparent z-10" />
<div className="flex gap-6 overflow-x-auto pb-4 scroll-smooth snap-x snap-mandatory [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
{otherPlans.map((plan) => {
const { totalEstimatedCost, totalSapPoints, expectedEpcRating } =
getPlanMetrics(plan);
return (
<div
key={String(plan.id)}
className="min-w-[300px] flex-shrink-0 snap-start"
>
<PlanCard
expectedEpcRating={expectedEpcRating}
currentEpcRating={propertyMeta.currentEpcRating}
createdAt={plan.createdAt}
totalEstimatedCost={totalEstimatedCost}
totalSapPoints={totalSapPoints}
planName={plan.name}
planId={String(plan.id)}
isDefault={false}
/>
</div>
);
})}
</div>
</div>
</section>
</div>
);
}

View file

@ -0,0 +1,67 @@
"use client";
import { useEffect, useRef } from "react";
interface PropertyMapProps {
latitude: number;
longitude: number;
}
function loadMapsScript(): Promise<void> {
return new Promise((resolve, reject) => {
if (typeof google !== "undefined" && google.maps) {
resolve();
return;
}
const existing = document.getElementById("google-maps-script");
if (existing) {
existing.addEventListener("load", () => resolve());
existing.addEventListener("error", reject);
return;
}
const apiKey = process.env.NEXT_PUBLIC_GOOGLE_MAPS_API_KEY ?? "";
const script = document.createElement("script");
script.id = "google-maps-script";
script.src = `https://maps.googleapis.com/maps/api/js?key=${apiKey}`;
script.async = true;
script.onload = () => resolve();
script.onerror = reject;
document.head.appendChild(script);
});
}
export default function PropertyMap({ latitude, longitude }: PropertyMapProps) {
const mapDivRef = useRef<HTMLDivElement>(null);
useEffect(() => {
loadMapsScript().then(() => {
if (!mapDivRef.current) return;
const position = { lat: latitude, lng: longitude };
const map = new google.maps.Map(mapDivRef.current, {
center: position,
zoom: 18,
mapTypeId: "hybrid",
tilt: 0,
mapTypeControl: false,
streetViewControl: false,
rotateControl: false,
fullscreenControl: false,
zoomControl: true,
scrollwheel: false,
});
new google.maps.Marker({
position,
map,
title: "Property location",
});
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return (
<div
ref={mapDivRef}
style={{ height: "100%", width: "100%", minHeight: "320px" }}
/>
);
}

View file

@ -0,0 +1,18 @@
"use client";
import dynamic from "next/dynamic";
import type { ComponentProps } from "react";
import type PropertyMap from "./PropertyMap";
const PropertyMapDynamic = dynamic(() => import("./PropertyMap"), {
ssr: false,
loading: () => (
<div className="w-full h-full min-h-[320px] bg-gray-100 animate-pulse rounded-lg" />
),
});
export default function PropertyMapWrapper(
props: ComponentProps<typeof PropertyMap>
) {
return <PropertyMapDynamic {...props} />;
}

View file

@ -0,0 +1,8 @@
"use client";
import type { ComponentProps } from "react";
import SolarPanelMap from "./SolarPanelMap";
export default function SolarMapWrapper(props: ComponentProps<typeof SolarPanelMap>) {
return <SolarPanelMap {...props} />;
}

View file

@ -0,0 +1,216 @@
"use client";
import { useMemo } from "react";
interface SolarPanel {
center: { latitude: number; longitude: number };
orientation: "LANDSCAPE" | "PORTRAIT";
segmentIndex: number;
yearlyEnergyDcKwh: number;
}
interface RoofSegmentStat {
segmentIndex: number;
azimuthDegrees: number;
center: { latitude: number; longitude: number };
stats: { groundAreaMeters2: number };
}
interface SolarPanelMapProps {
activePanels: SolarPanel[];
roofSegmentStats: RoofSegmentStat[];
panelWidthMeters: number;
panelHeightMeters: number;
buildingCenter: { latitude: number; longitude: number };
}
const SEGMENT_STYLES = [
{ fill: "#fef9c3", stroke: "#ca8a04", text: "#92400e" },
{ fill: "#dbeafe", stroke: "#2563eb", text: "#1e40af" },
{ fill: "#dcfce7", stroke: "#16a34a", text: "#166534" },
{ fill: "#fce7f3", stroke: "#db2777", text: "#9d174d" },
];
const PANEL_COLORS = ["#ca8a04", "#2563eb", "#16a34a", "#db2777"];
const SVG_W = 560;
const SVG_H = 440;
const PAD = 56;
function getCardinal(az: number): string {
const dirs = ["N", "NE", "E", "SE", "S", "SW", "W", "NW"];
return dirs[Math.round((((az % 360) + 360) % 360) / 45) % 8];
}
function CompassRose({ cx, cy, r }: { cx: number; cy: number; r: number }) {
return (
<g>
<circle cx={cx} cy={cy} r={r + 5} fill="white" stroke="#e5e7eb" strokeWidth={1} />
<polygon
points={`${cx},${cy - r} ${cx - r * 0.3},${cy + r * 0.1} ${cx + r * 0.3},${cy + r * 0.1}`}
fill="#1e3a5f"
/>
<polygon
points={`${cx},${cy + r} ${cx - r * 0.3},${cy - r * 0.1} ${cx + r * 0.3},${cy - r * 0.1}`}
fill="#d1d5db"
/>
<line x1={cx - r} y1={cy} x2={cx + r} y2={cy} stroke="#9ca3af" strokeWidth={1} />
<text x={cx} y={cy - r - 6} textAnchor="middle" fontSize={11} fontWeight="700" fill="#1e3a5f">N</text>
<text x={cx} y={cy + r + 14} textAnchor="middle" fontSize={10} fill="#9ca3af">S</text>
<text x={cx + r + 10} y={cy + 4} textAnchor="middle" fontSize={10} fill="#9ca3af">E</text>
<text x={cx - r - 10} y={cy + 4} textAnchor="middle" fontSize={10} fill="#9ca3af">W</text>
</g>
);
}
export default function SolarPanelMap({
activePanels,
roofSegmentStats,
panelWidthMeters,
panelHeightMeters,
buildingCenter,
}: SolarPanelMapProps) {
const mPerLng = 111320 * Math.cos((buildingCenter.latitude * Math.PI) / 180);
const mPerLat = 111320;
const cx = SVG_W / 2;
const cy = SVG_H / 2;
const scale = useMemo(() => {
const allM = [
...activePanels.map((p) => ({
x: (p.center.longitude - buildingCenter.longitude) * mPerLng,
y: (p.center.latitude - buildingCenter.latitude) * mPerLat,
})),
...roofSegmentStats.map((s) => ({
x: (s.center.longitude - buildingCenter.longitude) * mPerLng,
y: (s.center.latitude - buildingCenter.latitude) * mPerLat,
})),
];
if (allM.length === 0) return 20;
const maxR = Math.max(...allM.map((p) => Math.sqrt(p.x ** 2 + p.y ** 2)), 1);
const halfSize = Math.max(
...roofSegmentStats.map((s) => Math.sqrt(s.stats.groundAreaMeters2) / 2),
panelWidthMeters,
panelHeightMeters,
1
);
const drawR = Math.min(SVG_W, SVG_H) / 2 - PAD;
return drawR / (maxR + halfSize);
}, [activePanels, roofSegmentStats, buildingCenter, mPerLng, mPerLat, panelWidthMeters, panelHeightMeters]);
const segmentData = useMemo(
() =>
roofSegmentStats.map((seg, i) => ({
key: i,
px: cx + (seg.center.longitude - buildingCenter.longitude) * mPerLng * scale,
py: cy - (seg.center.latitude - buildingCenter.latitude) * mPerLat * scale,
side: Math.sqrt(seg.stats.groundAreaMeters2) * scale,
az: seg.azimuthDegrees,
direction: getCardinal(seg.azimuthDegrees),
style: SEGMENT_STYLES[i % SEGMENT_STYLES.length],
segmentIndex: seg.segmentIndex,
})),
[roofSegmentStats, buildingCenter, mPerLng, mPerLat, scale, cx, cy]
);
const panelData = useMemo(
() =>
activePanels.map((panel, i) => {
const seg = roofSegmentStats.find((s) => s.segmentIndex === panel.segmentIndex);
const az = seg?.azimuthDegrees ?? 180;
const isLandscape = panel.orientation === "LANDSCAPE";
const pw = (isLandscape ? panelHeightMeters : panelWidthMeters) * scale;
const ph = (isLandscape ? panelWidthMeters : panelHeightMeters) * scale;
return {
key: i,
px: cx + (panel.center.longitude - buildingCenter.longitude) * mPerLng * scale,
py: cy - (panel.center.latitude - buildingCenter.latitude) * mPerLat * scale,
pw,
ph,
az,
color: PANEL_COLORS[panel.segmentIndex % PANEL_COLORS.length],
};
}),
[activePanels, roofSegmentStats, buildingCenter, mPerLng, mPerLat, scale, cx, cy, panelWidthMeters, panelHeightMeters]
);
return (
<div className="rounded-lg overflow-hidden border border-gray-200">
<svg
viewBox={`0 0 ${SVG_W} ${SVG_H}`}
width="100%"
style={{ display: "block", background: "#f8fafc" }}
aria-label="Roof schematic with solar panels"
>
{/* Subtle grid */}
{Array.from({ length: 21 }, (_, i) => i - 10).map((i) => (
<g key={i}>
<line x1={0} y1={cy + i * 20} x2={SVG_W} y2={cy + i * 20} stroke="#e5e7eb" strokeWidth={0.4} />
<line x1={cx + i * 20} y1={0} x2={cx + i * 20} y2={SVG_H} stroke="#e5e7eb" strokeWidth={0.4} />
</g>
))}
{/* Roof segments */}
{segmentData.map((seg) => (
<g key={seg.key} transform={`rotate(${seg.az + 180}, ${seg.px}, ${seg.py})`}>
<rect
x={seg.px - seg.side / 2}
y={seg.py - seg.side / 2}
width={seg.side}
height={seg.side}
fill={seg.style.fill}
stroke={seg.style.stroke}
strokeWidth={2}
rx={3}
/>
{/* Label counter-rotates so it's always readable */}
<text
x={seg.px}
y={seg.py - seg.side / 2 + Math.max(14, seg.side * 0.18)}
textAnchor="middle"
fontSize={Math.min(13, Math.max(9, seg.side * 0.16))}
fontWeight="600"
fill={seg.style.text}
transform={`rotate(${-(seg.az + 180)}, ${seg.px}, ${seg.py})`}
>
{seg.direction}-facing
</text>
</g>
))}
{/* Solar panels */}
{panelData.map((p) => (
<g key={p.key} transform={`rotate(${p.az + 180}, ${p.px}, ${p.py})`}>
<rect
x={p.px - p.pw / 2}
y={p.py - p.ph / 2}
width={p.pw}
height={p.ph}
fill={p.color}
fillOpacity={0.9}
stroke="white"
strokeWidth={0.6}
rx={1}
/>
</g>
))}
{/* Compass rose — top right */}
<CompassRose cx={SVG_W - 52} cy={52} r={26} />
{/* Legend — bottom left */}
<g>
{segmentData.map((seg, i) => (
<g key={i} transform={`translate(12, ${SVG_H - 14 - (segmentData.length - 1 - i) * 20})`}>
<rect width={12} height={12} y={-11} fill={seg.style.fill} stroke={seg.style.stroke} strokeWidth={1.5} rx={2} />
<rect width={6} height={10} x={3} y={-10} fill={PANEL_COLORS[i % PANEL_COLORS.length]} fillOpacity={0.9} rx={1} />
<text x={18} y={0} fontSize={11} fill="#374151">
Segment {seg.segmentIndex + 1} {seg.direction}-facing
</text>
</g>
))}
</g>
</svg>
</div>
);
}

View file

@ -0,0 +1,213 @@
"use client";
import { useMemo } from "react";
import {
AreaChart,
Area,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
ResponsiveContainer,
} from "recharts";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/app/shadcn_components/ui/table";
import { Separator } from "@/app/shadcn_components/ui/separator";
interface SolarPanelConfig {
panelsCount: number;
yearlyEnergyDcKwh: number;
}
interface SolarConfigTableProps {
solarPanelConfigs: SolarPanelConfig[];
panelCapacityWatts: number;
panelLifetimeYears: number;
panelWidthMeters: number;
panelHeightMeters: number;
}
function ChartTooltip({
payload,
active,
label,
}: {
payload?: { name: string; value: number; color: string }[];
active?: boolean;
label?: string | number;
}) {
if (!active || !payload?.length) return null;
return (
<div className="bg-white border border-gray-200 rounded-lg shadow-lg px-3 py-2.5 text-sm min-w-[160px]">
<p className="font-semibold text-gray-700 mb-1.5 border-b border-gray-100 pb-1.5">
{label} panels
</p>
{payload.map((item, i) => (
<div key={i} className="flex items-center justify-between gap-3 py-0.5">
<div className="flex items-center gap-1.5">
<span
className="w-2.5 h-2.5 rounded-sm shrink-0"
style={{ backgroundColor: item.color }}
/>
<span className="text-gray-500 text-xs">{item.name}</span>
</div>
<span className="font-semibold text-gray-800 text-xs">
{Math.round(item.value).toLocaleString()} kWh
</span>
</div>
))}
</div>
);
}
export default function SolarConfigTable({
solarPanelConfigs,
panelCapacityWatts,
panelLifetimeYears,
panelWidthMeters,
panelHeightMeters,
}: SolarConfigTableProps) {
const chartData = useMemo(
() =>
solarPanelConfigs.map((cfg) => ({
panels: cfg.panelsCount,
"Annual output": cfg.yearlyEnergyDcKwh,
})),
[solarPanelConfigs]
);
return (
<div className="space-y-8">
{/* Efficiency curve */}
<div>
<p className="text-sm font-semibold text-brandblue mb-0.5">
Solar output vs. number of panels
</p>
<p className="text-xs text-gray-400 mb-5">
A curve that flattens early indicates diminishing returns as panels
are placed on less optimal roof faces.
</p>
<ResponsiveContainer width="100%" height={260}>
<AreaChart data={chartData} margin={{ top: 10, right: 16, left: 0, bottom: 24 }}>
<defs>
<linearGradient id="solarGradient" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="#3943b7" stopOpacity={0.18} />
<stop offset="95%" stopColor="#3943b7" stopOpacity={0.01} />
</linearGradient>
</defs>
<CartesianGrid strokeDasharray="3 3" stroke="#f0f0f0" vertical={false} />
<XAxis
dataKey="panels"
tick={{ fontSize: 10, fill: "#9ca3af" }}
axisLine={false}
tickLine={false}
label={{
value: "Number of panels",
position: "insideBottom",
offset: -12,
fontSize: 10,
fill: "#9ca3af",
}}
height={40}
/>
<YAxis
width={52}
tick={{ fontSize: 10, fill: "#9ca3af" }}
axisLine={false}
tickLine={false}
tickFormatter={(v) => `${(v / 1000).toFixed(0)}k`}
/>
<Tooltip content={<ChartTooltip />} cursor={{ stroke: "#3943b7", strokeWidth: 1, strokeDasharray: "4 2" }} />
<Area
type="monotone"
dataKey="Annual output"
stroke="#3943b7"
strokeWidth={2}
fill="url(#solarGradient)"
dot={false}
activeDot={{ r: 4, fill: "#3943b7", strokeWidth: 0 }}
/>
</AreaChart>
</ResponsiveContainer>
</div>
<Separator />
{/* Configurations table */}
<div>
<p className="text-sm font-semibold text-brandblue mb-0.5">
All modelled configurations
</p>
<p className="text-xs text-gray-400 mb-4">
Every array size considered, from the smallest viable installation to the
maximum possible for this property.
</p>
<div className="rounded-lg border border-gray-200 overflow-hidden">
<Table>
<TableHeader>
<TableRow className="bg-gray-50/80">
<TableHead className="font-semibold text-gray-500 text-xs uppercase tracking-wide">Panels</TableHead>
<TableHead className="font-semibold text-gray-500 text-xs uppercase tracking-wide text-right">Capacity</TableHead>
<TableHead className="font-semibold text-gray-500 text-xs uppercase tracking-wide text-right">Roof area</TableHead>
<TableHead className="font-semibold text-gray-500 text-xs uppercase tracking-wide text-right">Annual output</TableHead>
<TableHead className="font-semibold text-gray-500 text-xs uppercase tracking-wide text-right">
Lifetime output
<span className="ml-1 font-normal text-gray-400 normal-case">
({panelLifetimeYears} yr)
</span>
</TableHead>
<TableHead className="font-semibold text-gray-500 text-xs uppercase tracking-wide text-right">kWh / kWp</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{solarPanelConfigs.map((cfg, i) => {
const capacityKwp = (cfg.panelsCount * panelCapacityWatts) / 1000;
const areaM2 = cfg.panelsCount * panelWidthMeters * panelHeightMeters;
const annualKwh = Math.round(cfg.yearlyEnergyDcKwh);
const lifetimeKwh = Math.round(cfg.yearlyEnergyDcKwh * panelLifetimeYears);
const efficiency = Math.round(annualKwh / capacityKwp);
const isEven = i % 2 === 0;
return (
<TableRow
key={cfg.panelsCount}
className={isEven ? "bg-white" : "bg-gray-50/40"}
>
<TableCell className="font-semibold text-brandblue">
{cfg.panelsCount}
</TableCell>
<TableCell className="text-right text-gray-600 tabular-nums">
{capacityKwp.toFixed(1)}
<span className="ml-1 text-xs text-gray-400">kWp</span>
</TableCell>
<TableCell className="text-right text-gray-600 tabular-nums">
{areaM2.toFixed(1)}
<span className="ml-1 text-xs text-gray-400">m²</span>
</TableCell>
<TableCell className="text-right text-gray-600 tabular-nums">
{annualKwh.toLocaleString()}
<span className="ml-1 text-xs text-gray-400">kWh</span>
</TableCell>
<TableCell className="text-right text-gray-600 tabular-nums">
{lifetimeKwh.toLocaleString()}
<span className="ml-1 text-xs text-gray-400">kWh</span>
</TableCell>
<TableCell className="text-right text-gray-400 tabular-nums text-xs">
{efficiency.toLocaleString()}
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</div>
</div>
</div>
);
}

View file

@ -0,0 +1,21 @@
"use client";
import dynamic from "next/dynamic";
import type { ComponentProps } from "react";
import type SolarSimulation from "./SolarSimulation";
const SolarConfigDynamic = dynamic(() => import("./SolarSimulation"), {
ssr: false,
loading: () => (
<div className="space-y-4">
<div className="h-52 bg-gray-100 animate-pulse rounded-lg" />
<div className="h-64 bg-gray-100 animate-pulse rounded-lg" />
</div>
),
});
export default function SolarSimulationWrapper(
props: ComponentProps<typeof SolarSimulation>
) {
return <SolarConfigDynamic {...props} />;
}

View file

@ -1,224 +0,0 @@
import {
FlagIcon,
LightBulbIcon,
SunIcon,
InformationCircleIcon,
CloudIcon,
SparklesIcon,
BoltIcon,
CurrencyDollarIcon,
ArrowTrendingUpIcon,
HomeIcon,
} from "@heroicons/react/24/outline";
import { getPropertyMeta } from "../utils";
import { getSolarData, getSolarScenarioData } from "./utils";
import FeatureTable from "@/app/components/building-passport/FeatureTable";
import { roofSegmentsColumns } from "./roof-segments-table";
import { formatNumber } from "@/app/utils";
export default async function SolarAnalysisPage(
props: {
params: Promise<{ slug: string; propertyId: string }>;
}
) {
const params = await props.params;
const propertyMeta = await getPropertyMeta(params.propertyId);
const solarData = await getSolarData(Number(propertyMeta.uprn));
// If there's no solar data, we cannot display the page
if (!solarData) {
return (
<div className="flex items-center justify-center min-h-screen">
<div className="text-center text-gray-500">
<div className="text-2xl font-semibold mb-4">
No Solar Analysis Data Available
</div>
<p className="text-lg">Please check back later for updates.</p>
</div>
</div>
);
}
const solarScenarioData = await getSolarScenarioData(String(solarData.id));
const {
panelWidthMeters,
panelHeightMeters,
panelCapacityWatts,
panelLifetimeYears,
maxSunshineHoursPerYear,
carbonOffsetFactorKgPerMwh,
roofSegmentStats,
} = solarData.googleApiResponse.solarPotential;
const getDirectionFromAzimuth = (azimuth: number): string => {
if ((azimuth >= 330 && azimuth <= 360) || (azimuth >= 0 && azimuth < 30))
return "N";
if (azimuth >= 30 && azimuth < 60) return "NE";
if (azimuth >= 60 && azimuth < 120) return "E";
if (azimuth >= 120 && azimuth < 150) return "SE";
if (azimuth >= 150 && azimuth < 210) return "S";
if (azimuth >= 210 && azimuth < 240) return "SW";
if (azimuth >= 240 && azimuth < 300) return "W";
if (azimuth >= 300 && azimuth < 330) return "NW";
return "";
};
const transformedRoofSegmentStats = roofSegmentStats.map(
({ segmentIndex, stats, center, azimuthDegrees, ...rest }) => ({
...rest,
areaMeters2: stats.areaMeters2.toFixed(1),
groundAreaMeters2: stats.groundAreaMeters2.toFixed(1),
azimuthDegrees: azimuthDegrees.toFixed(1),
center: {
latitude: center.latitude.toFixed(6),
longitude: center.longitude.toFixed(6),
},
sunshineQuantiles: stats.sunshineQuantiles.join(", "), // Join sunshineQuantiles into a string
direction: getDirectionFromAzimuth(azimuthDegrees),
})
);
return (
<div className="leading-loose tracking-wider">
<div className="py-8 max-w-7xl mx-auto">
<div className="flex items-center text-gray-700 font-semibold mt-4 text-lg mb-4">
<InformationCircleIcon className="w-6 h-6 text-brandgold mr-2" />
{solarScenarioData.scenrioType === "building"
? "These figures are for the building as a whole"
: "These figures are for the individual property"}
</div>
<div className="bg-white shadow-md rounded-lg p-6 mb-8 w-full">
<h2 className="text-l font-semibold text-gray-800 mb-4">
Simulation Panel Configuration
</h2>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
<div className="flex items-center p-4 bg-blue-50 rounded-lg shadow w-full">
<InformationCircleIcon className="w-6 h-6 text-blue-500 mr-2" />
<p className="text-lg text-gray-700">
Dimensions: {panelWidthMeters}m x {panelHeightMeters}m
</p>
</div>
<div className="flex items-center p-4 bg-green-50 rounded-lg shadow w-full">
<LightBulbIcon className="w-6 h-6 text-green-500 mr-2" />
<p className="text-lg text-gray-700">
Wattage: {panelCapacityWatts}W
</p>
</div>
<div className="flex items-center p-4 bg-yellow-50 rounded-lg shadow w-full">
<SunIcon className="w-6 h-6 text-yellow-500 mr-2" />
<p className="text-lg text-gray-700">
Lifetime: {panelLifetimeYears} years
</p>
</div>
<div className="flex items-center p-4 bg-orange-50 rounded-lg shadow w-full">
<FlagIcon className="w-6 h-6 text-orange-500 mr-2" />
<p className="text-lg text-gray-700">
Region: {solarData.googleApiResponse.regionCode}
</p>
</div>
</div>
</div>
<div className="bg-white shadow-md rounded-lg p-6 mb-8 w-full">
<h2 className="text-l font-semibold text-gray-800 mb-4">
Weather and Environmental Data
</h2>
<div className="flex flex-col space-y-4">
<div className="flex items-center p-4 bg-indigo-50 rounded-lg shadow w-full">
<CloudIcon className="w-6 h-6 text-indigo-500 mr-2" />
<p className="text-lg text-gray-700">
Max Sunshine Hours per Year: {maxSunshineHoursPerYear} hours
</p>
</div>
<div className="flex items-center p-4 bg-teal-50 rounded-lg shadow w-full">
<SparklesIcon className="w-6 h-6 text-teal-500 mr-2" />
<p className="text-lg text-gray-700">
Carbon Offset Factor: {carbonOffsetFactorKgPerMwh} kg/MWh
</p>
</div>
</div>
</div>
<div className="bg-white shadow-md rounded-lg p-6 mb-8 w-full">
<h2 className="text-l font-semibold text-gray-800 mb-4">
Roof Segments Data
</h2>
<FeatureTable
data={transformedRoofSegmentStats}
columns={roofSegmentsColumns}
/>
</div>
<div className="bg-white shadow-md rounded-lg p-6 mb-8 w-full">
<div className="flex">
<div className="flex flex-col space-y-4 w-1/2">
<h2 className="text-l font-semibold text-gray-800 mb-4">
Solar PV Simulation
</h2>
<ul className="list-none pl-5 text-gray-700">
<li className="flex items-center">
<HomeIcon className="w-5 h-5 text-blue-500 mr-2" />
Number of panels: {solarScenarioData.numberPanels}
</li>
<li className="flex items-center">
<BoltIcon className="w-5 h-5 text-yellow-500 mr-2" />
Array output: {solarScenarioData.arrayKwhp} kWp
</li>
<li className="flex items-center">
<SunIcon className="w-5 h-5 text-orange-500 mr-2" />
Lifetime DC energy:{" "}
{Math.round(solarScenarioData.lifetimeDcKwh)} kWh
</li>
<li className="flex items-center">
<SunIcon className="w-5 h-5 text-orange-500 mr-2" />
Yearly DC energy: {Math.round(
solarScenarioData.yearlyDcKwh
)}{" "}
kWh
</li>
{solarScenarioData.lifetimeAcKwh !== null && (
<li className="flex items-center">
<SunIcon className="w-5 h-5 text-orange-500 mr-2" />
Lifetime AC energy:{" "}
{Math.round(solarScenarioData.lifetimeAcKwh)} kWh
</li>
)}
{solarScenarioData.yearlyAcKwh !== null && (
<li className="flex items-center">
<SunIcon className="w-5 h-5 text-orange-500 mr-2" />
Yearly AC energy:{" "}
{Math.round(solarScenarioData.yearlyAcKwh)} kWh
</li>
)}
<li className="flex items-center">
<CurrencyDollarIcon className="w-5 h-5 text-green-500 mr-2" />
Cost: £{formatNumber(solarScenarioData.cost)}
</li>
<li className="flex items-center">
<ArrowTrendingUpIcon className="w-5 h-5 text-purple-500 mr-2" />
Expected payback years:{" "}
{solarScenarioData.expectedPaybackYears}
</li>
<li className="flex items-center">
<HomeIcon className="w-5 h-5 text-blue-500 mr-2" />
Panelled roof area:{" "}
{solarScenarioData.panelledRoofArea.toFixed(1)} m²
</li>
</ul>
</div>
<div className="w-1/2 flex items-center justify-center bg-gray-500">
<div>Map not currently available</div>
{/* <img
src="/pfp_solar_image.png"
alt="Solar Image"
className="w-full h-full"
/> */}
</div>
</div>
</div>
</div>
</div>
);
}

View file

@ -1,134 +0,0 @@
"use client";
import React, { useState, useEffect } from "react";
import { useParams } from "next/navigation";
interface FileRecord {
id: number;
s3JsonUrl: string;
portfolioId: string;
propertyId: string;
presignedUrl?: string;
createdAt?: string;
}
const UploadPage: React.FC = () => {
const [isUploading, setIsUploading] = useState(false);
const [files, setFiles] = useState<FileRecord[]>([]);
const params = useParams();
const portfolioId = params?.slug as string;
const propertyId = params?.propertyId as string;
const fetchFiles = async () => {
const res = await fetch(
`/api/upload/retrofit-data?portfolioId=${portfolioId}&propertyId=${propertyId}`
);
if (res.ok) {
const data = await res.json();
setFiles(data.files);
}
};
useEffect(() => {
if (portfolioId && propertyId) {
fetchFiles();
}
}, [portfolioId, propertyId]);
const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
const files = e.target.files;
if (!files || files.length === 0) return;
const formData = new FormData();
formData.append("portfolioId", portfolioId);
formData.append("propertyId", propertyId);
Array.from(files).forEach((file) => formData.append("files", file));
try {
setIsUploading(true);
const res = await fetch("/api/upload/retrofit-data", {
method: "POST",
body: formData,
});
if (!res.ok) throw new Error("Upload failed");
await fetchFiles();
alert("✅ Files uploaded successfully!");
} catch (err) {
console.error(err);
alert("❌ Upload failed");
} finally {
setIsUploading(false);
}
};
return (
<div className="flex flex-col items-center justify-start min-h-screen gap-6 bg-gray-50 p-8">
<h1 className="text-2xl font-semibold">Upload Retrofit Data Files</h1>
<div className="text-gray-700 text-center">
<p>
<strong>Portfolio ID:</strong> {portfolioId}
</p>
<p>
<strong>Property ID:</strong> {propertyId}
</p>
</div>
<label
htmlFor="file-upload"
className={`cursor-pointer px-5 py-3 rounded-lg transition ${
isUploading
? "bg-gray-400 cursor-not-allowed"
: "bg-blue-600 hover:bg-blue-700 text-white"
}`}
>
{isUploading ? "Uploading..." : "Choose Files"}
</label>
<input
id="file-upload"
type="file"
multiple
onChange={handleFileChange}
disabled={isUploading}
className="hidden"
/>
<div className="w-full max-w-3xl mt-10">
<h2 className="text-lg font-medium mb-4">Uploaded Files</h2>
{files.length === 0 ? (
<p className="text-gray-500">No files uploaded yet.</p>
) : (
<table className="min-w-full border border-gray-200 bg-white rounded-lg shadow-sm">
<thead className="bg-gray-100">
<tr>
<th className="p-3 text-left">File URL</th>
<th className="p-3 text-left">Action</th>
</tr>
</thead>
<tbody>
{files.map((file) => (
<tr key={file.id} className="border-t">
<td className="p-3 text-blue-700 break-all">
{file.s3JsonUrl}
</td>
<td className="p-3">
<button
onClick={() => window.open(file.presignedUrl, "_blank")}
className="px-3 py-1 text-sm bg-green-600 text-white rounded hover:bg-green-700"
>
View
</button>
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
</div>
);
};
export default UploadPage;

View file

@ -5,6 +5,8 @@ import {
plan,
Plan,
installedMeasure,
scenario,
ScenarioSelect,
} from "@/app/db/schema/recommendations";
import { db } from "@/app/db/db";
import { surveyDB } from "@/app/db/surveyDB/connection";
@ -486,6 +488,18 @@ export function formatHeatDemandFeatures(
];
}
export async function getScenario(planId: string): Promise<ScenarioSelect | null> {
const planData = await db.query.plan.findFirst({
where: eq(plan.id, BigInt(planId)),
columns: { scenarioId: true },
});
if (!planData?.scenarioId) return null;
const data = await db.query.scenario.findFirst({
where: eq(scenario.id, planData.scenarioId),
});
return data ?? null;
}
export async function getInstalledMeasuresByUprn(
uprn: number
): Promise<InstalledMeasureSummary[]> {

View file

@ -1,185 +1,772 @@
"use client";
import { useState } from "react";
import React, { useState, useRef, useEffect } from "react";
import { X, Plus, ChevronDown, Check } from "lucide-react";
import { getEpcColorClass } from "@/app/utils";
import {
FilterGroups,
FilterGroup,
PropertyFilter,
FilterField,
FilterOperator,
DatePreset,
EnumOption,
PROPERTY_TYPE_OPTIONS,
BUILT_FORM_OPTIONS,
TENURE_OPTIONS,
YEAR_BUILT_OPTIONS,
MAINFUEL_OPTIONS,
} from "@/app/utils/propertyFilters";
export type PropertyFilterValues = {
address: string;
postcode: string;
current_epc_at_most: "" | "C" | "D" | "E" | "F" | "G";
expected_epc_at_least: "" | "A" | "B" | "C" | "D";
/* -----------------------------------------------------------------------
Constants
------------------------------------------------------------------------ */
const EPC_LETTERS = ["A", "B", "C", "D", "E", "F", "G"] as const;
type EpcLetter = (typeof EPC_LETTERS)[number];
const FIELD_OPTIONS: { value: FilterField; label: string }[] = [
{ value: "currentEpc", label: "Current EPC" },
{ value: "lodgedEpc", label: "Lodged EPC" },
{ value: "expectedEpc", label: "Expected EPC" },
{ value: "epcExpiryDate", label: "EPC Expiry Date" },
{ value: "propertyType", label: "Property Type" },
{ value: "builtForm", label: "Built Form" },
{ value: "tenure", label: "Tenure" },
{ value: "yearBuilt", label: "Year Built" },
{ value: "floorArea", label: "Floor Area (m²)" },
{ value: "co2Emissions", label: "CO₂ Emissions (kg/m²/yr)" },
{ value: "mainfuel", label: "Main Fuel" },
];
const EPC_OPERATOR_OPTIONS: { value: FilterOperator; label: string }[] = [
{ value: "epc_less_than", label: "is worse than" },
{ value: "equals", label: "equals" },
{ value: "epc_greater_than", label: "is better than" },
{ value: "epc_one_of", label: "is one of" },
];
const DATE_OPERATOR_OPTIONS: { value: FilterOperator; label: string }[] = [
{ value: "date_before", label: "is before" },
{ value: "date_after", label: "is after" },
{ value: "date_equals", label: "is on" },
{ value: "date_preset", label: "preset" },
];
const DATE_PRESET_OPTIONS: { value: DatePreset; label: string }[] = [
{ value: "expired", label: "Already expired" },
{ value: "expires_this_year", label: "Expiring this year" },
{ value: "expires_within_1_year", label: "Expiring within 1 year" },
{ value: "expires_within_2_years",label: "Expiring within 2 years" },
];
const ENUM_OPERATOR_OPTIONS: { value: FilterOperator; label: string }[] = [
{ value: "enum_one_of", label: "is one of" },
];
const NUM_OPERATOR_OPTIONS: { value: FilterOperator; label: string }[] = [
{ value: "num_gte", label: "≥ (at least)" },
{ value: "num_lte", label: "≤ (at most)" },
{ value: "num_equals", label: "= (equals)" },
];
const ENUM_FIELD_OPTIONS: Record<string, EnumOption[]> = {
propertyType: PROPERTY_TYPE_OPTIONS,
builtForm: BUILT_FORM_OPTIONS,
tenure: TENURE_OPTIONS,
yearBuilt: YEAR_BUILT_OPTIONS,
mainfuel: MAINFUEL_OPTIONS,
};
const EPC_ORDER = ["A", "B", "C", "D", "E", "F", "G"] as const;
/* -----------------------------------------------------------------------
Helpers
------------------------------------------------------------------------ */
function isEpcField(field: FilterField) {
return field === "currentEpc" || field === "lodgedEpc" || field === "expectedEpc";
}
const epcIndex = (epc: string) =>
EPC_ORDER.indexOf(epc as (typeof EPC_ORDER)[number]);
function isEnumField(field: FilterField): boolean {
return field in ENUM_FIELD_OPTIONS;
}
export default function PropertyFilters({
onApply,
function isNumericField(field: FilterField) {
return field === "floorArea" || field === "co2Emissions";
}
function operatorsForField(field: FilterField): { value: FilterOperator; label: string }[] {
if (isEpcField(field)) return EPC_OPERATOR_OPTIONS;
if (field === "epcExpiryDate") return DATE_OPERATOR_OPTIONS;
if (isEnumField(field)) return ENUM_OPERATOR_OPTIONS;
if (isNumericField(field)) return NUM_OPERATOR_OPTIONS;
return [];
}
function defaultOperatorForField(field: FilterField): FilterOperator {
return operatorsForField(field)[0]?.value ?? "equals";
}
function conditionLabel(condition: PropertyFilter): string {
const fieldLabel = FIELD_OPTIONS.find((f) => f.value === condition.field)?.label ?? condition.field;
if (isEpcField(condition.field)) {
const opLabel = EPC_OPERATOR_OPTIONS.find((o) => o.value === condition.operator)?.label ?? condition.operator;
const value =
condition.operator === "epc_one_of"
? condition.value.split(",").join(", ")
: condition.value;
return `${fieldLabel} ${opLabel} ${value}`;
}
if (condition.field === "epcExpiryDate") {
if (condition.operator === "date_preset") {
const presetLabel = DATE_PRESET_OPTIONS.find((p) => p.value === condition.value)?.label ?? condition.value;
return `${fieldLabel}: ${presetLabel}`;
}
const opLabel = DATE_OPERATOR_OPTIONS.find((o) => o.value === condition.operator)?.label ?? condition.operator;
return `${fieldLabel} ${opLabel} ${condition.value}`;
}
if (isEnumField(condition.field) && condition.operator === "enum_one_of") {
try {
const labels: string[] = JSON.parse(condition.value);
return `${fieldLabel} is one of: ${labels.join(", ")}`;
} catch {
return `${fieldLabel} is one of: ${condition.value}`;
}
}
if (isNumericField(condition.field)) {
const opLabel = NUM_OPERATOR_OPTIONS.find((o) => o.value === condition.operator)?.label ?? condition.operator;
return `${fieldLabel} ${opLabel} ${condition.value}`;
}
return `${fieldLabel} ${condition.operator} ${condition.value}`;
}
/* -----------------------------------------------------------------------
EPC Dropdown (single + multi)
------------------------------------------------------------------------ */
function EpcDropdown({
multi,
selected,
onChange,
}: {
onApply: (filters: PropertyFilterValues) => void;
multi: boolean;
selected: string[];
onChange: (letters: string[]) => void;
}) {
const [address, setAddress] = useState("");
const [postcode, setPostcode] = useState("");
const [currentEpc, setCurrentEpc] =
useState<PropertyFilterValues["current_epc_at_most"]>("");
const [expectedEpc, setExpectedEpc] =
useState<PropertyFilterValues["expected_epc_at_least"]>("");
const [open, setOpen] = useState(false);
const [dropdownStyle, setDropdownStyle] = useState<React.CSSProperties>({});
const ref = useRef<HTMLDivElement>(null);
const buttonRef = useRef<HTMLButtonElement>(null);
/* ----------------------------------------
Change handlers (no useEffect)
----------------------------------------- */
function handleCurrentEpcChange(
value: PropertyFilterValues["current_epc_at_most"],
) {
setCurrentEpc(value);
const dropdownRef = useRef<HTMLDivElement>(null);
if (value && expectedEpc && epcIndex(expectedEpc) >= epcIndex(value)) {
setExpectedEpc("");
useEffect(() => {
function handleOutside(e: MouseEvent) {
if (ref.current && !ref.current.contains(e.target as Node)) {
setOpen(false);
}
}
}
function handleExpectedEpcChange(
value: PropertyFilterValues["expected_epc_at_least"],
) {
setExpectedEpc(value);
if (value && currentEpc && epcIndex(value) >= epcIndex(currentEpc)) {
setCurrentEpc("");
function handleScroll(e: Event) {
if (dropdownRef.current && dropdownRef.current.contains(e.target as Node)) return;
setOpen(false);
}
if (open) {
document.addEventListener("mousedown", handleOutside);
window.addEventListener("scroll", handleScroll, true);
}
return () => {
document.removeEventListener("mousedown", handleOutside);
window.removeEventListener("scroll", handleScroll, true);
};
}, [open]);
function openDropdown() {
if (buttonRef.current) {
const rect = buttonRef.current.getBoundingClientRect();
setDropdownStyle({
position: "fixed",
top: rect.bottom + 4,
left: rect.left,
width: rect.width,
zIndex: 9999,
});
}
setOpen((o) => !o);
}
function apply() {
onApply({
address,
postcode,
current_epc_at_most: currentEpc,
expected_epc_at_least: expectedEpc,
});
}
function clear() {
setAddress("");
setPostcode("");
setCurrentEpc("");
setExpectedEpc("");
onApply({
address: "",
postcode: "",
current_epc_at_most: "",
expected_epc_at_least: "",
});
}
function handleKeyDown(e: React.KeyboardEvent) {
if (e.key === "Enter") {
e.preventDefault();
apply();
function toggle(letter: string) {
if (multi) {
onChange(
selected.includes(letter)
? selected.filter((l) => l !== letter)
: [...selected, letter]
);
} else {
onChange(selected[0] === letter ? [] : [letter]);
setOpen(false);
}
}
return (
<div className="border-b bg-white">
<div className="grid grid-cols-12 gap-4 p-4" onKeyDown={handleKeyDown}>
{/* Address */}
<div className="col-span-4">
<label className="block text-xs font-medium text-gray-600 mb-1">
Address
</label>
<input
className="w-full rounded-md border border-gray-300 px-3 py-2 text-sm
focus:outline-none focus:ring-2 focus:ring-black/10"
placeholder="Contains…"
value={address}
onChange={(e) => setAddress(e.target.value)}
/>
</div>
{/* Postcode */}
<div className="col-span-2">
<label className="block text-xs font-medium text-gray-600 mb-1">
Postcode
</label>
<input
className="w-full rounded-md border border-gray-300 px-3 py-2 text-sm
focus:outline-none focus:ring-2 focus:ring-black/10"
placeholder="e.g. E17"
value={postcode}
onChange={(e) => setPostcode(e.target.value)}
/>
</div>
{/* Current EPC */}
<div className="col-span-2">
<label className="block text-xs font-medium text-gray-600 mb-1">
Current EPC
</label>
<select
className="w-full rounded-md border border-gray-300 px-3 py-2 text-sm
bg-white focus:outline-none focus:ring-2 focus:ring-black/10"
value={currentEpc}
onChange={(e) => handleCurrentEpcChange(e.target.value as any)}
>
<option value="">Any</option>
{["C", "D", "E", "F", "G"].map((epc) => (
<option
key={epc}
value={epc}
disabled={
expectedEpc !== "" && epcIndex(epc) <= epcIndex(expectedEpc)
}
<div className="relative" ref={ref}>
<button
ref={buttonRef}
type="button"
onClick={openDropdown}
className="w-full flex items-center justify-between rounded-md border border-gray-300 px-2 py-1.5 text-sm bg-white hover:border-gray-400 focus:outline-none focus:ring-2 focus:ring-black/10 transition"
>
<span className="flex items-center gap-1 flex-wrap min-h-[1.25rem]">
{selected.length === 0 ? (
<span className="text-gray-400">Select rating</span>
) : (
selected.map((l) => (
<span
key={l}
className={`inline-flex items-center justify-center w-5 h-5 rounded-full text-[11px] font-bold text-white ${getEpcColorClass(l)}`}
>
{epc} or below
</option>
{l}
</span>
))
)}
</span>
<ChevronDown className={`h-4 w-4 text-gray-400 shrink-0 ml-1 transition-transform ${open ? "rotate-180" : ""}`} />
</button>
{open && (
<div ref={dropdownRef} style={dropdownStyle} className="rounded-md border border-gray-200 bg-white shadow-lg py-1">
{EPC_LETTERS.map((l) => {
const isSelected = selected.includes(l);
return (
<button
key={l}
type="button"
onClick={() => toggle(l)}
className={`w-full flex items-center gap-2.5 px-3 py-1.5 text-sm transition hover:bg-gray-50 ${isSelected ? "bg-gray-50" : ""}`}
>
<span
className={`inline-flex items-center justify-center w-6 h-6 rounded-full text-xs font-bold text-white shrink-0 ${getEpcColorClass(l)}`}
>
{l}
</span>
<span className="text-gray-700">Rating {l}</span>
{multi ? (
<span
className={`ml-auto w-4 h-4 rounded border flex items-center justify-center shrink-0 transition ${
isSelected ? "bg-black border-black" : "border-gray-300"
}`}
>
{isSelected && <Check className="h-2.5 w-2.5 text-white" />}
</span>
) : (
isSelected && <Check className="ml-auto h-3.5 w-3.5 text-gray-600" />
)}
</button>
);
})}
</div>
)}
</div>
);
}
/* -----------------------------------------------------------------------
Enum Multi-Select Dropdown
------------------------------------------------------------------------ */
function EnumMultiDropdown({
options,
selectedLabels,
onChange,
}: {
options: EnumOption[];
selectedLabels: string[];
onChange: (labels: string[]) => void;
}) {
const [open, setOpen] = useState(false);
const [dropdownStyle, setDropdownStyle] = useState<React.CSSProperties>({});
const ref = useRef<HTMLDivElement>(null);
const buttonRef = useRef<HTMLButtonElement>(null);
const dropdownRef = useRef<HTMLDivElement>(null);
useEffect(() => {
function handleOutside(e: MouseEvent) {
if (ref.current && !ref.current.contains(e.target as Node)) {
setOpen(false);
}
}
function handleScroll(e: Event) {
if (dropdownRef.current && dropdownRef.current.contains(e.target as Node)) return;
setOpen(false);
}
if (open) {
document.addEventListener("mousedown", handleOutside);
window.addEventListener("scroll", handleScroll, true);
}
return () => {
document.removeEventListener("mousedown", handleOutside);
window.removeEventListener("scroll", handleScroll, true);
};
}, [open]);
function openDropdown() {
if (buttonRef.current) {
const rect = buttonRef.current.getBoundingClientRect();
setDropdownStyle({
position: "fixed",
top: rect.bottom + 4,
left: rect.left,
width: Math.max(rect.width, 220),
zIndex: 9999,
maxHeight: 280,
overflowY: "auto",
});
}
setOpen((o) => !o);
}
function toggle(label: string) {
onChange(
selectedLabels.includes(label)
? selectedLabels.filter((l) => l !== label)
: [...selectedLabels, label]
);
}
return (
<div className="relative" ref={ref}>
<button
ref={buttonRef}
type="button"
onClick={openDropdown}
className="w-full flex items-center justify-between rounded-md border border-gray-300 px-2 py-1.5 text-sm bg-white hover:border-gray-400 focus:outline-none focus:ring-2 focus:ring-black/10 transition"
>
<span className="flex items-center gap-1 flex-wrap min-h-[1.25rem] text-left">
{selectedLabels.length === 0 ? (
<span className="text-gray-400">Select options</span>
) : (
<span className="text-gray-700 truncate">{selectedLabels.join(", ")}</span>
)}
</span>
<ChevronDown className={`h-4 w-4 text-gray-400 shrink-0 ml-1 transition-transform ${open ? "rotate-180" : ""}`} />
</button>
{open && (
<div ref={dropdownRef} style={dropdownStyle} className="rounded-md border border-gray-200 bg-white shadow-lg py-1">
{options.map((opt) => {
const isSelected = selectedLabels.includes(opt.label);
return (
<button
key={opt.label}
type="button"
onClick={() => toggle(opt.label)}
className={`w-full flex items-center gap-2.5 px-3 py-1.5 text-sm transition hover:bg-gray-50 text-left ${isSelected ? "bg-gray-50" : ""}`}
>
<span className="flex-1 text-gray-700 leading-tight">{opt.label}</span>
<span
className={`ml-auto w-4 h-4 rounded border flex items-center justify-center shrink-0 transition ${
isSelected ? "bg-black border-black" : "border-gray-300"
}`}
>
{isSelected && <Check className="h-2.5 w-2.5 text-white" />}
</span>
</button>
);
})}
</div>
)}
</div>
);
}
/* -----------------------------------------------------------------------
Number Filter Input
------------------------------------------------------------------------ */
function NumberFilterInput({
value,
onChange,
}: {
value: string;
onChange: (v: string) => void;
}) {
return (
<input
type="number"
className="w-full rounded-md border border-gray-300 px-2 py-1.5 text-sm bg-white focus:outline-none focus:ring-2 focus:ring-black/10"
placeholder="Enter value…"
value={value}
onChange={(e) => onChange(e.target.value)}
/>
);
}
/* -----------------------------------------------------------------------
Add Filter Form
------------------------------------------------------------------------ */
interface AddFilterFormProps {
targetGroupId: string | null; // null = new group
onConfirm: (groupId: string | null, condition: PropertyFilter) => void;
onCancel: () => void;
}
function AddFilterForm({ targetGroupId, onConfirm, onCancel }: AddFilterFormProps) {
const [field, setField] = useState<FilterField>("currentEpc");
const [operator, setOperator] = useState<FilterOperator>("epc_less_than");
const [epcSelected, setEpcSelected] = useState<string[]>([]);
const [dateValue, setDateValue] = useState("");
const [preset, setPreset] = useState<DatePreset>("expired");
const [enumSelected, setEnumSelected] = useState<string[]>([]);
const [numValue, setNumValue] = useState("");
function handleFieldChange(newField: FilterField) {
setField(newField);
setOperator(defaultOperatorForField(newField));
setEpcSelected([]);
setDateValue("");
setEnumSelected([]);
setNumValue("");
}
function buildValue(): string {
if (isEpcField(field)) {
return operator === "epc_one_of" ? epcSelected.join(",") : epcSelected[0] ?? "";
}
if (field === "epcExpiryDate") {
return operator === "date_preset" ? preset : dateValue;
}
if (isEnumField(field)) {
return enumSelected.length > 0 ? JSON.stringify(enumSelected) : "";
}
if (isNumericField(field)) {
return numValue;
}
return "";
}
function canConfirm(): boolean {
return buildValue().length > 0;
}
function handleConfirm() {
const value = buildValue();
if (!value) return;
const condition: PropertyFilter = {
id: crypto.randomUUID(),
field,
operator,
value,
};
onConfirm(targetGroupId, condition);
}
const selectClass =
"w-full rounded-md border border-gray-300 px-2 py-1.5 text-sm bg-white focus:outline-none focus:ring-2 focus:ring-black/10";
const enumOptions = isEnumField(field) ? ENUM_FIELD_OPTIONS[field] : [];
return (
<div className="flex flex-col gap-2 p-3 bg-gray-50 rounded-md border border-gray-200">
{/* Field */}
<div>
<label className="block text-xs font-medium text-gray-500 mb-0.5">Field</label>
<select
className={selectClass}
value={field}
onChange={(e) => handleFieldChange(e.target.value as FilterField)}
>
{FIELD_OPTIONS.map((f) => (
<option key={f.value} value={f.value}>{f.label}</option>
))}
</select>
</div>
{/* Operator */}
<div>
<label className="block text-xs font-medium text-gray-500 mb-0.5">Condition</label>
<select
className={selectClass}
value={operator}
onChange={(e) => setOperator(e.target.value as FilterOperator)}
>
{operatorsForField(field).map((o) => (
<option key={o.value} value={o.value}>{o.label}</option>
))}
</select>
</div>
{/* Value */}
<div>
<label className="block text-xs font-medium text-gray-500 mb-1">Value</label>
{isEpcField(field) && (
<EpcDropdown
multi={operator === "epc_one_of"}
selected={epcSelected}
onChange={setEpcSelected}
/>
)}
{field === "epcExpiryDate" && operator === "date_preset" && (
<select
className={selectClass}
value={preset}
onChange={(e) => setPreset(e.target.value as DatePreset)}
>
{DATE_PRESET_OPTIONS.map((p) => (
<option key={p.value} value={p.value}>{p.label}</option>
))}
</select>
</div>
)}
{/* Expected EPC */}
<div className="col-span-2">
<label className="block text-xs font-medium text-gray-600 mb-1">
Expected EPC
</label>
<select
className="w-full rounded-md border border-gray-300 px-3 py-2 text-sm
bg-white focus:outline-none focus:ring-2 focus:ring-black/10"
value={expectedEpc}
onChange={(e) => handleExpectedEpcChange(e.target.value as any)}
>
<option value="">Any</option>
{["A", "B", "C", "D"].map((epc) => (
<option
key={epc}
value={epc}
disabled={
expectedEpc !== "" && epcIndex(epc) <= epcIndex(expectedEpc)
}
>
{epc} or above
</option>
))}
</select>
</div>
{field === "epcExpiryDate" && operator !== "date_preset" && (
<input
type="date"
className="w-full rounded-md border border-gray-300 px-2 py-1.5 text-sm bg-white focus:outline-none focus:ring-2 focus:ring-black/10"
value={dateValue}
onChange={(e) => setDateValue(e.target.value)}
/>
)}
{/* Actions */}
<div className="col-span-2 flex items-end gap-2">
<button
onClick={apply}
className="h-10 w-full rounded-md bg-black text-sm font-medium text-white
hover:bg-black/90 transition"
>
Apply
</button>
<button
onClick={clear}
className="h-10 px-3 text-sm text-gray-500 hover:text-gray-700"
>
Clear
</button>
</div>
{isEnumField(field) && (
<EnumMultiDropdown
options={enumOptions}
selectedLabels={enumSelected}
onChange={setEnumSelected}
/>
)}
{isNumericField(field) && (
<NumberFilterInput value={numValue} onChange={setNumValue} />
)}
</div>
{/* Actions */}
<div className="flex gap-2 pt-1">
<button
type="button"
onClick={handleConfirm}
disabled={!canConfirm()}
className="flex-1 h-8 rounded-md bg-black text-xs font-medium text-white hover:bg-black/90 transition disabled:opacity-40 disabled:cursor-not-allowed"
>
Add
</button>
<button
type="button"
onClick={onCancel}
className="h-8 px-3 text-xs text-gray-500 hover:text-gray-700"
>
Cancel
</button>
</div>
</div>
);
}
/* -----------------------------------------------------------------------
Condition Row
------------------------------------------------------------------------ */
function ConditionRow({
condition,
onRemove,
}: {
condition: PropertyFilter;
onRemove: () => void;
}) {
return (
<div className="flex items-start gap-1 group">
<div className="flex-1 text-xs text-gray-700 bg-white border border-gray-200 rounded px-2 py-1.5 leading-tight break-words min-w-0">
{conditionLabel(condition)}
</div>
<button
type="button"
onClick={onRemove}
className="shrink-0 mt-0.5 p-0.5 rounded text-gray-400 hover:text-gray-700 hover:bg-gray-100 transition"
title="Remove condition"
>
<X className="h-3.5 w-3.5" />
</button>
</div>
);
}
/* -----------------------------------------------------------------------
Main Component
------------------------------------------------------------------------ */
export default function PropertyFilters({
filterGroups,
onChange,
}: {
filterGroups: FilterGroups;
onChange: (groups: FilterGroups) => void;
}) {
// Draft state — only applied when user clicks Apply
const [draft, setDraft] = useState<FilterGroups>(filterGroups);
// "or" = new OR group, "and:<groupId>" = AND condition into existing group, null = hidden
const [addMode, setAddMode] = useState<"or" | `and:${string}` | null>(null);
function openOrGroup() {
setAddMode("or");
}
function openAndCondition(groupId: string) {
setAddMode(`and:${groupId}`);
}
function handleConfirm(groupId: string | null, condition: PropertyFilter) {
setDraft((prev) => {
if (groupId === null) {
// New OR group
const newGroup: FilterGroup = {
id: crypto.randomUUID(),
conditions: [condition],
};
return [...prev, newGroup];
} else {
// Add AND-condition to existing group
return prev.map((g) =>
g.id === groupId
? { ...g, conditions: [...g.conditions, condition] }
: g
);
}
});
setAddMode(null);
}
function removeCondition(groupId: string, conditionId: string) {
setDraft((prev) => {
const updated = prev
.map((g) =>
g.id === groupId
? { ...g, conditions: g.conditions.filter((c) => c.id !== conditionId) }
: g
)
.filter((g) => g.conditions.length > 0);
return updated;
});
}
function apply() {
onChange(draft);
}
function clear() {
setDraft([]);
onChange([]);
}
const hasGroups = draft.length > 0;
return (
<div className="flex flex-col gap-3 p-3">
{/* Group list */}
{draft.map((group, groupIdx) => {
const isAndTarget = addMode === `and:${group.id}`;
return (
<div key={group.id}>
{/* OR divider between groups */}
{groupIdx > 0 && (
<div className="flex items-center gap-2 mb-2">
<div className="flex-1 border-t border-blue-200" />
<span className="text-[10px] font-bold text-blue-500 uppercase tracking-wide bg-blue-50 px-1.5 py-0.5 rounded border border-blue-200">
OR
</span>
<div className="flex-1 border-t border-blue-200" />
</div>
)}
<div className="flex flex-col gap-1.5 bg-white border border-gray-200 rounded-md p-2">
{group.conditions.map((condition, condIdx) => (
<div key={condition.id}>
{/* AND label between conditions in same group */}
{condIdx > 0 && (
<div className="flex items-center gap-2 my-1">
<div className="flex-1 border-t border-dashed border-gray-300" />
<span className="text-[10px] font-bold text-gray-500 uppercase tracking-wide bg-white px-1.5 py-0.5 rounded border border-gray-300">
AND
</span>
<div className="flex-1 border-t border-dashed border-gray-300" />
</div>
)}
<ConditionRow
condition={condition}
onRemove={() => removeCondition(group.id, condition.id)}
/>
</div>
))}
{/* AND form inline */}
{isAndTarget ? (
<AddFilterForm
targetGroupId={group.id}
onConfirm={handleConfirm}
onCancel={() => setAddMode(null)}
/>
) : (
<button
type="button"
onClick={() => openAndCondition(group.id)}
className="self-start mt-1 flex items-center gap-1 text-[11px] font-semibold text-gray-500 hover:text-gray-700 bg-white hover:bg-gray-50 border border-gray-300 rounded px-2 py-0.5 transition"
>
<Plus className="h-3 w-3" />
AND
</button>
)}
</div>
</div>
);
})}
{/* OR: add new group form or button */}
{addMode === "or" ? (
<AddFilterForm
targetGroupId={null}
onConfirm={handleConfirm}
onCancel={() => setAddMode(null)}
/>
) : (
<div className={hasGroups ? "flex items-center gap-2" : undefined}>
{hasGroups && (
<>
<div className="flex-1 border-t border-blue-200" />
<span className="text-[10px] font-bold text-blue-400 uppercase tracking-wide px-1">
or
</span>
<div className="flex-1 border-t border-blue-200" />
</>
)}
<button
type="button"
onClick={openOrGroup}
className={[
"flex items-center gap-1 text-sm font-semibold transition",
hasGroups
? "shrink-0 text-blue-500 hover:text-blue-700 bg-blue-50 border border-blue-200 rounded px-2 py-0.5 text-[11px] hover:border-blue-400"
: "w-full justify-center text-gray-600 hover:text-gray-900 border border-dashed border-gray-300 rounded-md px-3 py-2 hover:border-gray-400",
].join(" ")}
>
<Plus className="h-3.5 w-3.5" />
{hasGroups ? "OR" : "Add filter"}
</button>
</div>
)}
{/* Apply / Clear */}
<div className="flex gap-2 pt-1 border-t border-gray-100">
<button
type="button"
onClick={apply}
className="flex-1 h-9 rounded-md bg-black text-sm font-medium text-white hover:bg-black/90 transition"
>
Apply
</button>
<button
type="button"
onClick={clear}
className="h-9 px-3 text-sm text-gray-500 hover:text-gray-700"
>
Clear
</button>
</div>
</div>
);
}
// #Test git with khalimsdsadsaasdsasdfdsertrsadfsdsadfdssdfds

View file

@ -1,13 +1,45 @@
"use client";
import { useState, useMemo } from "react";
import { useRouter } from "next/navigation";
import { useState, useMemo, useRef, useEffect, useCallback } from "react";
import { useProperties } from "./useProperties";
import DataTable from "./dataTable";
import PropertyFilters, { PropertyFilterValues } from "./PropertyFilters";
import { PropertyFilter } from "@/app/utils/propertyFilters";
import PropertyFilters from "./PropertyFilters";
import { FilterGroups } from "@/app/utils/propertyFilters";
import {
FunnelIcon,
ChevronDownIcon,
XMarkIcon,
ArrowDownTrayIcon,
ViewColumnsIcon,
} from "@heroicons/react/24/outline";
import { HomeIcon } from "@heroicons/react/24/outline";
import { sapToEpc } from "@/app/utils";
import { columns } from "@/app/portfolio/[slug]/components/propertyTableColumns";
import { PropertyWithRelations } from "@/app/db/schema/property";
import {
TENURE_OPTIONS,
MAINFUEL_OPTIONS,
EnumOption,
} from "@/app/utils/propertyFilters";
import {
OPTIONAL_COLUMN_IDS,
OPTIONAL_COLUMN_LABELS,
} from "@/app/portfolio/[slug]/components/propertyTableColumns";
import {
VisibilityState,
Updater,
PaginationState,
} from "@tanstack/react-table";
import { Tooltip } from "./Tooltip";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/app/shadcn_components/ui/dropdown-menu";
import {
Dialog,
@ -20,46 +52,89 @@ import {
import { Button } from "@/app/shadcn_components/ui/button";
/* ----------------------------------------
Filter parsing
Export helpers
----------------------------------------- */
export function parsePropertyFilters(
filters: PropertyFilterValues
): PropertyFilter[] {
const parsed: PropertyFilter[] = [];
const EXPORT_LIMIT = 1000;
if (filters.address) {
parsed.push({
field: "address",
operator: "contains",
value: filters.address,
});
}
function resolveEnumLabel(
options: EnumOption[],
dbValue: string | null | undefined,
): string {
if (dbValue == null) return "";
const opt = options.find((o) => o.dbValues.includes(dbValue));
return opt?.label ?? dbValue;
}
if (filters.postcode) {
parsed.push({
field: "postcode",
operator: "starts_with",
value: filters.postcode,
});
}
function exportToCsv(data: PropertyWithRelations[]) {
const headers = [
"Address",
"Postcode",
"Property Ref",
"Current EPC",
"Lodged EPC",
"Expected EPC",
"EPC Expiry",
"EPC Expired",
"Plan Cost (£)",
"Property Type",
"Built Form",
"Tenure",
"Year Built",
"Floor Area (m²)",
"CO₂ Emissions (kg/m²/yr)",
"Main Fuel",
];
if (filters.current_epc_at_most) {
parsed.push({
field: "currentEpc",
operator: "epc_at_most",
value: filters.current_epc_at_most,
});
}
const rows = data.map((p) => {
const lodgedLetter = p.originalSapPoints
? (sapToEpc(p.originalSapPoints) ?? "")
: "";
const expectedSap =
(p.currentSapPoints ?? 0) + (p.totalRecommendationSapPoints ?? 0);
const expectedLetter = expectedSap > 0 ? (sapToEpc(expectedSap) ?? "") : "";
if (filters.expected_epc_at_least) {
parsed.push({
field: "expectedEpc",
operator: "epc_at_least",
value: filters.expected_epc_at_least,
});
}
let expiryStr = "";
if (p.epcLodgementDate) {
const expiry = new Date(p.epcLodgementDate);
expiry.setFullYear(expiry.getFullYear() + 10);
expiryStr = expiry.toLocaleDateString("en-GB");
}
return parsed;
return [
p.address ?? "",
p.postcode ?? "",
p.landlordPropertyId ?? "",
p.currentEpcRating ?? "",
lodgedLetter,
expectedLetter,
expiryStr,
p.epcIsExpired ? "Yes" : "No",
p.totalRecommendationCost ? p.totalRecommendationCost.toFixed(2) : "",
p.propertyType ?? "",
p.builtForm ?? "",
resolveEnumLabel(TENURE_OPTIONS, p.tenure),
p.yearBuilt ?? "",
p.totalFloorArea != null ? p.totalFloorArea.toFixed(1) : "",
p.co2Emissions != null ? p.co2Emissions.toFixed(1) : "",
resolveEnumLabel(MAINFUEL_OPTIONS, p.mainfuel),
];
});
const csv = [headers, ...rows]
.map((row) =>
row.map((cell) => `"${String(cell).replace(/"/g, '""')}"`).join(","),
)
.join("\n");
const blob = new Blob([csv], { type: "text/csv;charset=utf-8;" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = "properties.csv";
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}
/* ----------------------------------------
@ -71,7 +146,7 @@ function EmptyPropertyState() {
<div className="text-center text-gray-400">
<HomeIcon className="h-16 w-16 mx-auto mb-4 text-gray-200" />
<p>
Hover over <strong>New Property</strong> to start adding properties
Hover over <strong>&ldquo;New Property&rdquo;</strong> to start adding properties
to your portfolio.
</p>
</div>
@ -79,6 +154,145 @@ function EmptyPropertyState() {
);
}
/* ----------------------------------------
Loading overlay
----------------------------------------- */
function LoadingOverlay() {
return (
<div className="absolute inset-0 z-10 rounded-xl bg-white/60 flex items-center justify-center pointer-events-none">
<div className="flex flex-col items-center gap-2">
<div className="h-6 w-6 animate-spin rounded-full border-2 border-gray-200 border-t-gray-700" />
<span className="text-xs text-gray-500">Updating</span>
</div>
</div>
);
}
/* ----------------------------------------
Quick filter dropdown button
----------------------------------------- */
type QuickFilterKey = "address" | "postcode" | "propertyRef";
interface QuickFilterDropdownProps {
label: string;
placeholder: string;
committedValue: string;
isOpen: boolean;
onOpen: () => void;
onCommit: (value: string) => void;
onClear: () => void;
inputWidth?: string;
}
function QuickFilterDropdown({
label,
placeholder,
committedValue,
isOpen,
onOpen,
onCommit,
onClear,
inputWidth = "w-52",
}: QuickFilterDropdownProps) {
const containerRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
const [draft, setDraft] = useState(committedValue);
useEffect(() => {
if (isOpen) {
setDraft(committedValue);
setTimeout(() => inputRef.current?.focus(), 0);
}
}, [isOpen, committedValue]);
const commit = useCallback(() => {
onCommit(draft.trim());
}, [draft, onCommit]);
useEffect(() => {
if (!isOpen) return;
function handleMouseDown(e: MouseEvent) {
if (
containerRef.current &&
!containerRef.current.contains(e.target as Node)
) {
commit();
}
}
document.addEventListener("mousedown", handleMouseDown);
return () => document.removeEventListener("mousedown", handleMouseDown);
}, [isOpen, commit]);
const isActive = Boolean(committedValue);
return (
<div ref={containerRef} className="relative">
<button
onClick={onOpen}
className={[
"flex items-center gap-1.5 h-8 px-3 rounded-lg border text-xs font-semibold transition shrink-0",
isActive
? "border-brandblue bg-brandblue text-white"
: "border-slate-200 bg-white text-primary hover:bg-slate-50",
].join(" ")}
>
<span>{label}</span>
{isActive ? (
<>
<span className="opacity-75 max-w-[120px] truncate">
: {committedValue}
</span>
<span
role="button"
tabIndex={0}
onClick={(e) => {
e.stopPropagation();
onClear();
}}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.stopPropagation();
onClear();
}
}}
className="ml-0.5 rounded-full hover:opacity-75"
>
<XMarkIcon className="h-3.5 w-3.5" />
</span>
</>
) : (
<ChevronDownIcon className="h-3.5 w-3.5 opacity-50" />
)}
</button>
{isOpen && (
<div className="absolute left-0 top-full mt-1 z-30 bg-white border border-slate-200 rounded-lg shadow-md p-2 flex gap-1.5 items-center">
<input
ref={inputRef}
className={`h-8 rounded-lg border border-slate-200 px-2.5 text-xs focus:outline-none focus:ring-2 focus:ring-brandblue/20 focus:border-brandblue ${inputWidth}`}
placeholder={placeholder}
value={draft}
onChange={(e) => setDraft(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") commit();
if (e.key === "Escape") {
setDraft(committedValue);
onCommit(committedValue);
}
}}
/>
<button
onClick={commit}
className="h-8 px-2.5 rounded-lg bg-brandblue text-white text-xs font-semibold hover:opacity-90 transition whitespace-nowrap"
>
Apply
</button>
</div>
)}
</div>
);
}
/* ----------------------------------------
Main table
----------------------------------------- */
@ -87,29 +301,194 @@ export default function PropertyTable({
}: {
portfolioId: string;
}) {
const router = useRouter();
const [sidebarOpen, setSidebarOpen] = useState(false);
const [filters, setFilters] = useState<PropertyFilterValues>({
address: "",
postcode: "",
current_epc_at_most: "",
expected_epc_at_least: "",
});
const [committedAddress, setCommittedAddress] = useState("");
const [committedPostcode, setCommittedPostcode] = useState("");
const [committedPropertyRef, setCommittedPropertyRef] = useState("");
const [openFilter, setOpenFilter] = useState<QuickFilterKey | null>(null);
const [filterGroups, setFilterGroups] = useState<FilterGroups>([]);
const parsedFilters = useMemo(() => parsePropertyFilters(filters), [filters]);
const hasActiveFilters = parsedFilters.length > 0;
// Column visibility — lifted up from DataTable
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>(
() => {
const init: VisibilityState = {};
OPTIONAL_COLUMN_IDS.forEach((id) => {
init[id] = false;
});
return init;
},
);
function commitFilter(field: QuickFilterKey, value: string) {
if (field === "address") setCommittedAddress(value);
if (field === "postcode") setCommittedPostcode(value);
if (field === "propertyRef") setCommittedPropertyRef(value);
setOpenFilter(null);
}
function clearAll() {
setCommittedAddress("");
setCommittedPostcode("");
setCommittedPropertyRef("");
setOpenFilter(null);
setFilterGroups([]);
}
const allFilterGroups = useMemo((): FilterGroups => {
const quick: FilterGroups = [];
if (committedAddress)
quick.push({
id: "qa",
conditions: [
{
id: "qa-c",
field: "address",
operator: "contains",
value: committedAddress,
},
],
});
if (committedPostcode)
quick.push({
id: "qp",
conditions: [
{
id: "qp-c",
field: "postcode",
operator: "starts_with",
value: committedPostcode,
},
],
});
if (committedPropertyRef)
quick.push({
id: "qr",
conditions: [
{
id: "qr-c",
field: "propertyRef",
operator: "contains",
value: committedPropertyRef,
},
],
});
return [...quick, ...filterGroups];
}, [committedAddress, committedPostcode, committedPropertyRef, filterGroups]);
const hasActiveFilters = allFilterGroups.length > 0;
const {
data = [],
data: filteredResponse,
isLoading,
isFetching,
isError,
refetch,
} = useProperties({
portfolioId,
filters: parsedFilters,
filterGroups: allFilterGroups,
});
const queryData = useMemo(() => filteredResponse?.data ?? [], [filteredResponse?.data]);
const filteredTotal = filteredResponse?.total ?? 0;
// Second query for total (no filters) — React Query dedupes when filters are empty
const { data: allResponse } = useProperties({
portfolioId,
filterGroups: [],
});
const totalCount = allResponse?.total ?? 0;
const DISPLAY_LIMIT = 1000;
const LOAD_MORE_SIZE = 100;
// ── Extra rows (lazy-loaded pages beyond the initial 1 000) ──────────────
// Keyed to the current filter set so they auto-clear on filter change.
const filterKey = useMemo(
() => JSON.stringify(allFilterGroups),
[allFilterGroups],
);
const [extraState, setExtraState] = useState<{
filterKey: string;
rows: PropertyWithRelations[];
}>({ filterKey: "", rows: [] });
const extraRows = useMemo(
() => (extraState.filterKey === filterKey ? extraState.rows : []),
[extraState, filterKey],
);
// The full data visible to the table — initial batch + any lazy-loaded rows
const tableData = useMemo(
() => (extraRows.length > 0 ? [...queryData, ...extraRows] : queryData),
[queryData, extraRows],
);
// Controlled pagination (lifted from DataTable so we can detect the last page)
const [pagination, setPagination] = useState<PaginationState>({
pageIndex: 0,
pageSize: 7,
});
// Reset to page 1 whenever the filter changes
const prevFilterKeyRef = useRef(filterKey);
if (prevFilterKeyRef.current !== filterKey) {
prevFilterKeyRef.current = filterKey;
if (pagination.pageIndex !== 0)
setPagination((p) => ({ ...p, pageIndex: 0 }));
}
const [isFetchingMore, setIsFetchingMore] = useState(false);
const pageCount = Math.ceil(tableData.length / pagination.pageSize);
const isOnLastPage = pageCount > 0 && pagination.pageIndex >= pageCount - 1;
const hasMore = tableData.length < filteredTotal;
const isAtDisplayLimit =
queryData.length >= DISPLAY_LIMIT && filteredTotal > DISPLAY_LIMIT;
const loadMore = useCallback(async () => {
if (isFetchingMore || !hasMore || isLoading || isFetching) return;
setIsFetchingMore(true);
try {
const res = await fetch("/api/properties", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
portfolioId,
filters: allFilterGroups,
offset: tableData.length,
limit: LOAD_MORE_SIZE,
}),
});
if (!res.ok) return;
const json: { data: PropertyWithRelations[] } = await res.json();
setExtraState((prev) => ({
filterKey,
rows:
prev.filterKey === filterKey
? [...prev.rows, ...json.data]
: json.data,
}));
} catch {
// silently ignore — user can navigate away and back to retry
} finally {
setIsFetchingMore(false);
}
}, [
isFetchingMore,
hasMore,
isLoading,
isFetching,
portfolioId,
allFilterGroups,
tableData.length,
filterKey,
]);
useEffect(() => {
if (isOnLastPage && hasMore) loadMore();
}, [isOnLastPage, hasMore, loadMore]);
/* ----------------------------------------
Delete preview state
----------------------------------------- */
@ -117,71 +496,268 @@ export default function PropertyTable({
const [deletePreview, setDeletePreview] = useState<
{ table: string; count: number }[] | null
>(null);
const [previewLoading, setPreviewLoading] = useState(false);
const [previewError, setPreviewError] = useState<string | null>(null);
const [deleteLoading, setDeleteLoading] = useState(false);
const [previewLoading] = useState(false);
const [previewError] = useState<string | null>(null);
return (
<div className="flex justify-center">
<div className="grid grid-cols-11 w-full max-w-8xl">
<div className="col-span-11 bg-white rounded-md border">
<PropertyFilters onApply={setFilters} />
{isFetching && (
<div className="h-1 w-full bg-gray-100 overflow-hidden">
<div className="h-full w-1/3 bg-black animate-[loading_1.2s_infinite]" />
</div>
<div className="py-4">
{/* Action bar */}
<div className="flex items-center justify-between mb-3">
{/* Left: results count */}
<div className="flex items-center gap-3">
<span className="text-xs font-black uppercase tracking-wider text-primary">
Results:
</span>
{isLoading ? (
<span className="text-xs text-slate-400">Loading</span>
) : (
<span className="text-xs text-slate-500">
Showing{" "}
<span className="font-bold text-primary">
{tableData.length.toLocaleString()}
</span>{" "}
of{" "}
<span className="font-bold text-primary">
{totalCount.toLocaleString()}
</span>{" "}
properties
</span>
)}
{hasActiveFilters && !isFetching && (
<div className="px-4 py-2 text-xs text-gray-500 border-b">
Filters applied ({parsedFilters.length})
</div>
{hasActiveFilters && (
<button
onClick={clearAll}
className="text-xs text-slate-400 hover:text-primary underline"
>
Clear all
</button>
)}
</div>
{/* Right: action buttons */}
<div className="flex items-center gap-2">
{/* Filters toggle */}
<button
onClick={() => setSidebarOpen((o) => !o)}
className={[
"flex items-center gap-1.5 h-8 px-3 rounded-lg border text-xs font-semibold transition shrink-0",
sidebarOpen
? "bg-primary text-white border-primary"
: "border-slate-200 bg-white text-primary hover:bg-slate-50",
].join(" ")}
title={sidebarOpen ? "Hide filters" : "Show filters"}
>
<FunnelIcon className="h-3.5 w-3.5" />
Filters
</button>
{/* Edit Columns dropdown */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button className="flex items-center gap-1.5 h-8 px-3 rounded-lg border border-slate-200 bg-white text-xs font-semibold text-primary hover:bg-slate-50 transition">
<ViewColumnsIcon className="h-3.5 w-3.5" />
Edit Columns
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-48">
<DropdownMenuLabel className="text-xs">
Optional Columns
</DropdownMenuLabel>
<DropdownMenuSeparator />
{OPTIONAL_COLUMN_IDS.map((colId) => {
const isVisible = columnVisibility[colId] !== false;
return (
<DropdownMenuItem
key={colId}
className="flex items-center gap-2 cursor-pointer"
onSelect={(e) => {
e.preventDefault();
setColumnVisibility((prev) => ({
...prev,
[colId]: !isVisible,
}));
}}
>
<input
type="checkbox"
checked={isVisible}
readOnly
className="h-3.5 w-3.5 accent-black"
/>
<span className="text-xs">
{OPTIONAL_COLUMN_LABELS[colId]}
</span>
</DropdownMenuItem>
);
})}
</DropdownMenuContent>
</DropdownMenu>
{/* Export */}
{filteredTotal > EXPORT_LIMIT ? (
<Tooltip content={`Export is limited to ${EXPORT_LIMIT.toLocaleString()} properties. Refine your filters to enable it.`}>
<button
disabled
className="flex items-center gap-1.5 h-8 px-3 rounded-lg border border-slate-200 bg-slate-100 text-xs font-semibold text-slate-400 cursor-not-allowed opacity-60"
>
<ArrowDownTrayIcon className="h-3.5 w-3.5" />
Export
<span className="inline-flex items-center justify-center w-3.5 h-3.5 rounded-full bg-amber-400 text-white text-[9px] font-black leading-none">!</span>
</button>
</Tooltip>
) : (
<button
onClick={() => exportToCsv(tableData)}
disabled={isLoading || tableData.length === 0}
className="flex items-center gap-1.5 h-8 px-3 rounded-lg border border-slate-200 bg-slate-100 text-xs font-semibold text-primary hover:bg-slate-200 transition disabled:opacity-50 disabled:cursor-not-allowed"
>
<ArrowDownTrayIcon className="h-3.5 w-3.5" />
Export
</button>
)}
</div>
</div>
{/* Quick filters row */}
<div className="flex items-center gap-2 mb-3 flex-wrap">
<QuickFilterDropdown
label="Address"
placeholder="Contains…"
committedValue={committedAddress}
isOpen={openFilter === "address"}
onOpen={() =>
setOpenFilter(openFilter === "address" ? null : "address")
}
onCommit={(v) => commitFilter("address", v)}
onClear={() => {
setCommittedAddress("");
setOpenFilter(null);
}}
inputWidth="w-52"
/>
<QuickFilterDropdown
label="Postcode"
placeholder="e.g. E17"
committedValue={committedPostcode}
isOpen={openFilter === "postcode"}
onOpen={() =>
setOpenFilter(openFilter === "postcode" ? null : "postcode")
}
onCommit={(v) => commitFilter("postcode", v)}
onClear={() => {
setCommittedPostcode("");
setOpenFilter(null);
}}
inputWidth="w-32"
/>
<QuickFilterDropdown
label="Property Ref"
placeholder="Landlord ref…"
committedValue={committedPropertyRef}
isOpen={openFilter === "propertyRef"}
onOpen={() =>
setOpenFilter(openFilter === "propertyRef" ? null : "propertyRef")
}
onCommit={(v) => commitFilter("propertyRef", v)}
onClear={() => {
setCommittedPropertyRef("");
setOpenFilter(null);
}}
inputWidth="w-40"
/>
</div>
{/* Display-limit notice */}
{isAtDisplayLimit && hasMore && (
<div className="mb-3 flex items-center gap-2 rounded-lg border border-slate-200 bg-slate-50 px-3 py-2 text-xs text-slate-500">
<span>
Showing{" "}
<span className="font-semibold text-primary">
{tableData.length.toLocaleString()}
</span>{" "}
of{" "}
<span className="font-semibold text-primary">
{filteredTotal.toLocaleString()}
</span>{" "}
properties more load automatically as you navigate to the last page.
</span>
</div>
)}
{/* Body: sidebar + table */}
<div className="flex items-start">
{/* Collapsible filter sidebar */}
<div
className={[
"shrink-0 overflow-hidden transition-all duration-300 ease-in-out rounded-xl",
sidebarOpen
? "w-72 opacity-100 border border-slate-100 bg-slate-50"
: "w-0 opacity-0",
].join(" ")}
>
<div className="w-72">
<p className="px-4 pt-4 pb-0 text-[9px] font-black text-primary uppercase tracking-widest">
Curate Selection
</p>
<PropertyFilters
filterGroups={filterGroups}
onChange={setFilterGroups}
/>
</div>
</div>
{/* Table area */}
<div className="flex-1 min-w-0 bg-white rounded-xl border border-slate-100 shadow-sm relative">
{((isFetching && !isLoading) || isFetchingMore) && <LoadingOverlay />}
{isLoading ? (
<div className="p-6 text-gray-400">Loading properties</div>
<div className="p-6 text-slate-400 text-sm">
Loading properties
</div>
) : isError ? (
<div className="p-6 text-red-500">Failed to load properties.</div>
) : data.length === 0 && hasActiveFilters ? (
<div className="p-10 text-center text-gray-500">
<p>No properties match your filters.</p>
<div className="p-6 text-red-500 text-sm">
Failed to load properties.
</div>
) : queryData.length === 0 && hasActiveFilters ? (
<div className="p-10 text-center text-slate-500">
<p className="text-sm">No properties match your filters.</p>
<button
onClick={() =>
setFilters({
address: "",
postcode: "",
current_epc_at_most: "",
expected_epc_at_least: "",
})
}
className="mt-3 text-sm text-black underline"
onClick={clearAll}
className="mt-3 text-xs text-primary underline"
>
Clear filters
</button>
</div>
) : data.length === 0 ? (
) : queryData.length === 0 ? (
<EmptyPropertyState />
) : (
<DataTable
data={data}
data={tableData}
columns={columns}
onDeleteProperty={(id) => setDeletePropertyId(id)}
columnVisibility={columnVisibility}
onColumnVisibilityChange={
setColumnVisibility as (
updater: Updater<VisibilityState>,
) => void
}
pagination={pagination}
onPaginationChange={
setPagination as (updater: Updater<PaginationState>) => void
}
/>
)}
</div>
</div>
{/* ----------------------------------------
Delete preview modal
----------------------------------------- */}
{/* Delete preview modal */}
<Dialog
open={deletePropertyId !== null}
onOpenChange={(open) => {
if (!open) {
setDeletePropertyId(null);
setDeletePreview(null);
setPreviewError(null);
}
}}
>
@ -195,15 +771,6 @@ export default function PropertyTable({
</DialogDescription>
</DialogHeader>
{previewLoading && (
<div className="flex items-center gap-3 py-6">
<div className="h-5 w-5 animate-spin rounded-full border-2 border-muted border-t-foreground" />
<span className="text-sm text-muted-foreground">
Calculating deletion impact
</span>
</div>
)}
{previewError && (
<p className="text-sm text-red-600">{previewError}</p>
)}
@ -231,14 +798,6 @@ export default function PropertyTable({
>
Cancel
</Button>
{/* <Button
variant="destructive"
disabled={!deletePreview || previewLoading || deleteLoading}
onClick={handleConfirmDelete}
>
{deleteLoading ? "Deleting…" : "Confirm delete"}
</Button> */}
</DialogFooter>
</DialogContent>
</Dialog>

View file

@ -33,7 +33,7 @@ export default function SuccessToast({
}, timeoutMs);
return () => clearTimeout(timer);
}
}, [show, onClose]);
}, [show, onClose, showConfetti, timeoutMs]);
return (
<>

View file

@ -0,0 +1,54 @@
"use client";
import { useState, useRef, useEffect } from "react";
import { createPortal } from "react-dom";
interface TooltipProps {
content: React.ReactNode;
children: React.ReactElement;
}
export function Tooltip({ content, children }: TooltipProps) {
const [visible, setVisible] = useState(false);
const [style, setStyle] = useState<React.CSSProperties>({});
const triggerRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!visible || !triggerRef.current) return;
const rect = triggerRef.current.getBoundingClientRect();
setStyle({
position: "fixed",
top: rect.top - 8,
left: rect.left + rect.width / 2,
transform: "translate(-50%, -100%)",
zIndex: 9999,
});
}, [visible]);
return (
<>
<div
ref={triggerRef}
onMouseEnter={() => setVisible(true)}
onMouseLeave={() => setVisible(false)}
className="inline-flex"
>
{children}
</div>
{visible &&
createPortal(
<div style={style} className="pointer-events-none">
<div className="bg-gray-900 text-white text-xs rounded-lg px-3 py-2 max-w-[220px] text-center leading-snug shadow-lg">
{content}
</div>
{/* Arrow */}
<div className="flex justify-center">
<div className="w-0 h-0 border-l-4 border-r-4 border-t-4 border-l-transparent border-r-transparent border-t-gray-900" />
</div>
</div>,
document.body
)}
</>
);
}

View file

@ -5,6 +5,8 @@ import {
ColumnFiltersState,
SortingState,
PaginationState,
VisibilityState,
Updater,
flexRender,
getCoreRowModel,
getFilteredRowModel,
@ -23,6 +25,7 @@ import {
TableRow,
} from "@/app/shadcn_components/ui/table";
import { useState } from "react";
import { DataTablePagination } from "./propertyTablePagination";
import { rankItem } from "@tanstack/match-sorter-utils";
@ -36,26 +39,36 @@ const fuzzyFilter: FilterFn<any> = (row, columnId, value, addMeta) => {
return itemRank.passed;
};
const DEFAULT_PAGINATION: PaginationState = { pageIndex: 0, pageSize: 7 };
interface DataTableProps<TData> {
columns: ColumnDef<TData, any>[];
data: TData[];
onDeleteProperty?: (propertyId: number) => void;
columnVisibility?: VisibilityState;
onColumnVisibilityChange?: (updater: Updater<VisibilityState>) => void;
// Controlled pagination — when omitted the table manages its own pagination state
pagination?: PaginationState;
onPaginationChange?: (updater: Updater<PaginationState>) => void;
}
export default function DataTable<TData extends Record<string, any>>({
data,
columns,
onDeleteProperty,
columnVisibility = {},
onColumnVisibilityChange,
pagination: controlledPagination,
onPaginationChange: controlledOnPaginationChange,
}: DataTableProps<TData>) {
const [sorting, setSorting] = useState<SortingState>([]);
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);
const [globalFilter, setGlobalFilter] = useState("");
const [internalPagination, setInternalPagination] = useState<PaginationState>(DEFAULT_PAGINATION);
// ✅ REQUIRED pagination state (fixes TS error)
const [pagination, setPagination] = useState<PaginationState>({
pageIndex: 0,
pageSize: 7,
});
const isControlled = controlledPagination !== undefined;
const pagination = isControlled ? controlledPagination : internalPagination;
const onPaginationChange = isControlled ? controlledOnPaginationChange! : setInternalPagination;
const table = useReactTable({
data,
@ -66,10 +79,13 @@ export default function DataTable<TData extends Record<string, any>>({
getFilteredRowModel: getFilteredRowModel(),
getPaginationRowModel: getPaginationRowModel(),
autoResetPageIndex: false,
onSortingChange: setSorting,
onColumnFiltersChange: setColumnFilters,
onGlobalFilterChange: setGlobalFilter,
onPaginationChange: setPagination,
onPaginationChange,
onColumnVisibilityChange,
globalFilterFn: fuzzyFilter,
@ -78,6 +94,7 @@ export default function DataTable<TData extends Record<string, any>>({
columnFilters,
globalFilter,
pagination,
columnVisibility,
},
meta: {
@ -86,13 +103,13 @@ export default function DataTable<TData extends Record<string, any>>({
});
return (
<div className="rounded-md border">
<div className="rounded-xl">
<Table>
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => (
<TableHead key={header.id} className="h-14 py-2">
<TableHead key={header.id} className="h-12 py-2">
{header.isPlaceholder
? null
: flexRender(

View file

@ -11,35 +11,75 @@ import {
} from "@/app/shadcn_components/ui/dropdown-menu";
import { Button } from "@/app/shadcn_components/ui/button";
import { ArrowUpDown, MoreHorizontal } from "lucide-react";
import StatusBadge from "@/app/components/StatusBadge";
import { HomeIcon } from "@heroicons/react/20/solid";
import { FunnelIcon } from "@heroicons/react/24/outline";
import { formatNumber, getEpcColorClass, sapToEpc } from "@/app/utils";
import { cn } from "@/lib/utils";
import { PortfolioStatus } from "@/app/db/schema/portfolio";
import { PropertyWithRelations } from "@/app/db/schema/property";
import { X } from "lucide-react";
import {
EnumOption,
TENURE_OPTIONS,
MAINFUEL_OPTIONS,
} from "@/app/utils/propertyFilters";
interface DataTableColumnHeaderProps<
TData,
TValue,
> extends React.HTMLAttributes<HTMLDivElement> {
column: Column<TData, TValue>;
title: string;
/* -----------------------------------------------------------------------
Helpers
------------------------------------------------------------------------ */
function resolveEnumLabel(
options: EnumOption[],
dbValue: string | null | undefined
): string | null {
if (dbValue == null) return null;
const opt = options.find((o) => o.dbValues.includes(dbValue));
return opt?.label ?? dbValue;
}
function tenureBadgeClass(label: string): string {
if (label.toLowerCase().includes("owner")) return "bg-blue-50 text-blue-700";
if (label.toLowerCase().includes("private")) return "bg-violet-50 text-violet-700";
if (label.toLowerCase().includes("social")) return "bg-emerald-50 text-emerald-700";
return "bg-slate-100 text-slate-500";
}
function Pill({
children,
className = "bg-slate-100 text-slate-600",
}: {
children: React.ReactNode;
className?: string;
}) {
return (
<span
className={`inline-block px-2 py-0.5 rounded text-[9px] font-bold uppercase tracking-wide whitespace-nowrap ${className}`}
>
{children}
</span>
);
}
/* -----------------------------------------------------------------------
EPC letter bubble
------------------------------------------------------------------------ */
const EpcLetterBubble = ({ letter }: { letter: string }) => {
if (!letter) return <div className="w-5 h-5" />;
return (
<div
className={`inline-flex items-center justify-center w-6 h-6 rounded-full ${getEpcColorClass(
letter
)} text-white text-m font-bold shadow-outline-black`}
className={`inline-flex items-center justify-center w-5 h-5 rounded-full text-xs font-bold text-white ${getEpcColorClass(letter)}`}
>
{letter}
</div>
);
};
/* -----------------------------------------------------------------------
Column header with dropdown filter
------------------------------------------------------------------------ */
interface DataTableColumnHeaderProps<TData, TValue>
extends React.HTMLAttributes<HTMLDivElement> {
column: Column<TData, TValue>;
title: string;
}
export function DataTableFilterHeader<TData, TValue>({
column,
title,
@ -68,7 +108,6 @@ export function DataTableFilterHeader<TData, TValue>({
<FunnelIcon className="ml-2 h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="start"
className="max-h-80 overflow-y-auto min-w-[10rem]"
@ -107,144 +146,118 @@ export function DataTableFilterHeader<TData, TValue>({
);
}
export const columns: ColumnDef<PropertyWithRelations>[] = [
/* -----------------------------------------------------------------------
Column metadata
------------------------------------------------------------------------ */
export const OPTIONAL_COLUMN_IDS = [
"propertyType",
"builtForm",
"tenure",
"yearBuilt",
"totalFloorArea",
"co2Emissions",
"mainfuel",
] as const;
export type OptionalColumnId = (typeof OPTIONAL_COLUMN_IDS)[number];
const OPTIONAL_COLUMN_LABELS: Record<OptionalColumnId, string> = {
propertyType: "Property Type",
builtForm: "Built Form",
tenure: "Tenure",
yearBuilt: "Year Built",
totalFloorArea: "Floor Area (m²)",
co2Emissions: "CO₂ Emissions",
mainfuel: "Main Fuel",
};
export { OPTIONAL_COLUMN_LABELS };
/* -----------------------------------------------------------------------
Core columns
------------------------------------------------------------------------ */
const coreColumns: ColumnDef<PropertyWithRelations>[] = [
{
accessorKey: "address",
enableGlobalFilter: true,
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
>
Address
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
},
cell: ({ row }) => {
const address = String(row.getValue("address"));
const propertyId = row.original.id;
const portfolioId = row.original.portfolioId;
return (
<div className="flex items-center space-x-2">
<HomeIcon className="h-8 w-8 text-brandmidblue" />
<div className="flex flex-col">
<a
href={`${portfolioId}/building-passport/${propertyId}`}
className="font-medium underline text-gray-800 cursor-pointer"
>
{address}
</a>
</div>
</div>
);
},
},
{
accessorKey: "postcode",
enableGlobalFilter: true,
header: ({ column }) => (
<Button
variant="ghost"
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
>
Postcode
Address
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
),
cell: ({ row }) => (
<div className="text-gray-700 font-medium flex justify-center">
{row.original.postcode}
</div>
),
},
{
accessorKey: "status",
// header: () => <div className="flex justify-center">Status</div>,
header: ({ column }) => {
return (
<div className="flex justify-center">
<DataTableFilterHeader
column={column}
title="Status"
options={PortfolioStatus}
renderOption={(status) => (
<StatusBadge status={status} isProperty={false} />
)}
/>
</div>
);
},
cell: ({ row }) => {
const status = row.getValue("status") ?? "";
const address = String(row.getValue("address"));
const postcode = row.original.postcode;
const propertyId = row.original.id;
const portfolioId = row.original.portfolioId;
return (
<div className="flex justify-center">
{status && <StatusBadge status={String(status)} isProperty={true} />}
</div>
);
},
},
{
accessorKey: "fundingScheme",
header: ({ column }) => {
return (
<div className="flex justify-center">
<DataTableFilterHeader
column={column}
title="Funding Scheme"
options={["ECO4", "GBIS", "NONE"]}
renderOption={(status) => (
// handle status being null or undefined
<StatusBadge status={status ?? "none"} isProperty={false} />
)}
/>
</div>
);
},
cell: ({ row }) => {
// if the funding scheme is "none" we display nothing
const fundingScheme = row.getValue("fundingScheme") || "";
// Check if any plan has an ECO4 or GBIS funding package
return (
<div className="flex justify-center">
{fundingScheme && fundingScheme !== "none" && (
<StatusBadge
status={String(fundingScheme).toUpperCase()}
isProperty={true}
/>
<div className="flex flex-col gap-0.5">
<a
href={`${portfolioId}/building-passport/${propertyId}`}
className="text-xs font-bold text-primary hover:text-secondary transition-colors truncate"
>
{address}
</a>
{postcode && (
<span className="text-[10px] text-slate-400">{postcode}</span>
)}
</div>
);
},
},
{
accessorKey: "landlordPropertyId",
header: () => <div className="text-xs font-medium">Property Ref</div>,
cell: ({ row }) => (
<div className="text-gray-700 text-sm">
{row.original.landlordPropertyId ?? "—"}
</div>
),
},
{
accessorKey: "currentEpc",
header: () => <div className="flex justify-center">Current EPC Rating</div>,
header: () => (
<div className="flex justify-center text-xs">Current EPC</div>
),
cell: ({ row }) => (
<div className="text-gray-700 font-medium flex justify-center">
<EpcLetterBubble letter={row.original.currentEpcRating || ""} />
</div>
),
},
{
accessorKey: "originalSapPoints",
header: () => (
<div className="flex justify-center text-xs">Lodged EPC</div>
),
cell: ({ row }) => {
const originalSap = row.original.originalSapPoints;
const letter = originalSap ? sapToEpc(originalSap) : null;
return (
<div className="text-gray-700 font-medium flex justify-center">
{<EpcLetterBubble letter={row.original.currentEpcRating || ""} />}
<EpcLetterBubble letter={letter || ""} />
</div>
);
},
},
{
accessorKey: "targetEpc",
header: () => <div className="flex justify-center">Expected EPC</div>,
header: () => (
<div className="flex justify-center text-xs">Expected EPC</div>
),
cell: ({ row }) => {
const currentSapPoints = row.original.currentSapPoints || 0;
const expectedSapPoints = row.original.totalRecommendationSapPoints || 0;
// if currentSapPoints + expectedSapPoint is 0 expected EPC is ""
if (currentSapPoints + expectedSapPoints === 0) {
return (
<div className="text-gray-700 font-medium flex justify-center">
{<EpcLetterBubble letter={""} />}
<EpcLetterBubble letter="" />
</div>
);
}
@ -252,78 +265,195 @@ export const columns: ColumnDef<PropertyWithRelations>[] = [
return (
<div className="text-gray-700 font-medium flex justify-center">
{<EpcLetterBubble letter={expectedEpc || ""} />}
<EpcLetterBubble letter={expectedEpc || ""} />
</div>
);
},
},
{
accessorKey: "epcLodgementDate",
header: () => (
<div className="flex justify-center text-xs">EPC Expiry</div>
),
cell: ({ row }) => {
const dateStr = row.original.epcLodgementDate;
const expired = row.original.epcIsExpired;
if (!dateStr) {
return <div className="text-center text-slate-400 text-xs"></div>;
}
const lodgementDate = new Date(dateStr);
const expiryDate = new Date(lodgementDate);
expiryDate.setFullYear(expiryDate.getFullYear() + 10);
const formatted = expiryDate.toLocaleDateString("en-GB", {
month: "short",
year: "numeric",
});
if (expired) {
return (
<div className="flex flex-col gap-0.5">
<span className="text-[9px] font-bold text-red-600 bg-red-50 px-1.5 py-0.5 rounded w-max uppercase tracking-wide">
Expired
</span>
<span className="text-[10px] text-slate-400">{formatted}</span>
</div>
);
}
return (
<div className="flex flex-col gap-0.5">
<span className="text-xs font-semibold text-primary">{formatted}</span>
</div>
);
},
},
{
accessorKey: "cost",
header: () => <div className="flex justify-center">Cost</div>,
header: () => <div className="flex justify-end text-xs">Plan Cost</div>,
cell: ({ row }) => {
const cost = row.original.totalRecommendationCost;
const creationStatus = row.original.creationStatus;
if (creationStatus === "LOADING") {
return <div className="font-medium flex justify-center"></div>;
return <div className="font-medium flex justify-end" />;
}
const formatted = cost ? "£" + formatNumber(cost) : "";
return (
<div className="text-gray-700 font-medium flex justify-center">
{formatted}
<div className="text-right">
{cost ? (
<span className="text-xs font-bold text-primary">
£{formatNumber(cost)}
</span>
) : (
<span className="text-[10px] text-slate-300 italic">No cost</span>
)}
</div>
);
},
},
{
id: "actions",
cell: ({ row, table }) => {
cell: ({ row }) => {
const property = row.original;
const propertyId = property.id;
const portfolioId = property.portfolioId;
const creationStatus = property.creationStatus;
if (creationStatus === "LOADING") {
return (
<div className="font-mediutext-gray-800m text-gray-700 flex justify-center">
Loading...
</div>
<div className="text-gray-700 flex justify-center">Loading...</div>
);
}
return (
<>
<div className="flex justify-center">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="h-8 w-8 p-0">
<span className="sr-only">Open menu</span>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuLabel>Actions</DropdownMenuLabel>
<DropdownMenuItem
// onClick={() => navigator.clipboard.writeText(payment.id)}
className="text-gray-700 cursor-pointer"
>
<a href={`${portfolioId}/building-passport/${propertyId}`}>
Building Passport
</a>
</DropdownMenuItem>
<DropdownMenuItem className="text-gray-700 cursor-pointer">
Settings
</DropdownMenuItem>
<DropdownMenuSeparator />
</DropdownMenuContent>
</DropdownMenu>
</div>
</>
<div className="flex justify-center">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="h-8 w-8 p-0">
<span className="sr-only">Open menu</span>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuLabel>Actions</DropdownMenuLabel>
<DropdownMenuItem className="text-gray-700 cursor-pointer">
<a href={`${portfolioId}/building-passport/${propertyId}`}>
Building Passport
</a>
</DropdownMenuItem>
<DropdownMenuItem className="text-gray-700 cursor-pointer">
Settings
</DropdownMenuItem>
<DropdownMenuSeparator />
</DropdownMenuContent>
</DropdownMenu>
</div>
);
},
},
];
/* -----------------------------------------------------------------------
Optional columns
------------------------------------------------------------------------ */
const optionalColumns: ColumnDef<PropertyWithRelations>[] = [
{
id: "propertyType",
accessorKey: "propertyType",
header: () => <div className="text-xs">Property Type</div>,
cell: ({ row }) => {
const val = row.original.propertyType;
return val ? <Pill>{val}</Pill> : <span className="text-slate-300 text-xs"></span>;
},
},
{
id: "builtForm",
accessorKey: "builtForm",
header: () => <div className="text-xs">Built Form</div>,
cell: ({ row }) => {
const val = row.original.builtForm;
return val ? <Pill>{val}</Pill> : <span className="text-slate-300 text-xs"></span>;
},
},
{
id: "tenure",
accessorKey: "tenure",
header: () => <div className="text-xs">Tenure</div>,
cell: ({ row }) => {
const label = resolveEnumLabel(TENURE_OPTIONS, row.original.tenure);
if (!label) return <span className="text-slate-300 text-xs"></span>;
return <Pill className={tenureBadgeClass(label)}>{label}</Pill>;
},
},
{
id: "yearBuilt",
accessorKey: "yearBuilt",
header: () => <div className="text-xs">Year Built</div>,
cell: ({ row }) => (
<div className="text-sm text-gray-700">{row.original.yearBuilt ?? "—"}</div>
),
},
{
id: "totalFloorArea",
accessorKey: "totalFloorArea",
header: () => <div className="text-xs">Floor Area (m²)</div>,
cell: ({ row }) => {
const val = row.original.totalFloorArea;
return (
<div className="text-sm text-gray-700">
{val != null ? `${val.toFixed(1)}` : "—"}
</div>
);
},
},
{
id: "co2Emissions",
accessorKey: "co2Emissions",
header: () => <div className="text-xs">CO Emissions</div>,
cell: ({ row }) => {
const val = row.original.co2Emissions;
return (
<div className="text-sm text-gray-700">
{val != null ? `${val.toFixed(1)} kg/m²/yr` : "—"}
</div>
);
},
},
{
id: "mainfuel",
accessorKey: "mainfuel",
header: () => <div className="text-xs">Main Fuel</div>,
cell: ({ row }) => {
const label = resolveEnumLabel(MAINFUEL_OPTIONS, row.original.mainfuel);
return label ? <Pill>{label}</Pill> : <span className="text-slate-300 text-xs"></span>;
},
},
];
export const columns: ColumnDef<PropertyWithRelations>[] = [
...coreColumns,
...optionalColumns,
];

View file

@ -1,15 +1,20 @@
import { useQuery } from "@tanstack/react-query";
import { PropertyWithRelations } from "@/app/db/schema/property";
import { PropertyFilter } from "@/app/utils/propertyFilters";
import { FilterGroups } from "@/app/utils/propertyFilters";
interface Params {
portfolioId: string;
filters: PropertyFilter[];
filterGroups: FilterGroups;
}
export function useProperties({ portfolioId, filters }: Params) {
return useQuery<PropertyWithRelations[]>({
queryKey: ["properties", portfolioId, filters],
export interface PropertiesResponse {
data: PropertyWithRelations[];
total: number | null; // null when fetched with offset > 0 (count not recomputed)
}
export function useProperties({ portfolioId, filterGroups }: Params) {
return useQuery<PropertiesResponse>({
queryKey: ["properties", portfolioId, filterGroups],
queryFn: async () => {
const res = await fetch("/api/properties", {
method: "POST",
@ -18,7 +23,7 @@ export function useProperties({ portfolioId, filters }: Params) {
},
body: JSON.stringify({
portfolioId,
filters,
filters: filterGroups,
}),
});

View file

@ -19,9 +19,26 @@ import {
ScenarioSelect,
} from "@/app/db/schema/recommendations";
import { sql } from "drizzle-orm";
import { PropertyFilter } from "@/app/utils/propertyFilters";
import {
FilterGroups,
PropertyFilter,
PROPERTY_TYPE_OPTIONS,
BUILT_FORM_OPTIONS,
TENURE_OPTIONS,
YEAR_BUILT_OPTIONS,
MAINFUEL_OPTIONS,
EnumOption,
} from "@/app/utils/propertyFilters";
import { EPC_TO_SAP_MIN, EPC_TO_SAP_MAX } from "@/app/utils/epc";
const ENUM_FIELD_DB_OPTIONS: Record<string, EnumOption[]> = {
propertyType: PROPERTY_TYPE_OPTIONS,
builtForm: BUILT_FORM_OPTIONS,
tenure: TENURE_OPTIONS,
yearBuilt: YEAR_BUILT_OPTIONS,
mainfuel: MAINFUEL_OPTIONS,
};
export interface PortfolioSettingsType {
name: string;
budget: number | null;
@ -415,69 +432,257 @@ export async function getNonDefaultPortfolioScenarios(
return scenarios;
}
type EpcLetter = "A" | "B" | "C" | "D" | "E" | "F" | "G";
const EPC_ORDER: EpcLetter[] = ["A", "B", "C", "D", "E", "F", "G"];
function buildEpcSapCondition(
col: ReturnType<typeof sql>,
operator: PropertyFilter["operator"],
value: string
): ReturnType<typeof sql> | null {
const letter = value as EpcLetter;
if (operator === "epc_at_most") {
const maxSap = EPC_TO_SAP_MAX[letter as keyof typeof EPC_TO_SAP_MAX];
if (maxSap === undefined) return null;
return sql`${col} <= ${maxSap}`;
}
if (operator === "epc_at_least") {
const minSap = EPC_TO_SAP_MIN[letter as keyof typeof EPC_TO_SAP_MIN];
if (minSap === undefined) return null;
return sql`${col} >= ${minSap}`;
}
if (operator === "equals") {
const minSap = EPC_TO_SAP_MIN[letter as keyof typeof EPC_TO_SAP_MIN];
const maxSap = EPC_TO_SAP_MAX[letter as keyof typeof EPC_TO_SAP_MAX];
if (minSap === undefined || maxSap === undefined) return null;
return sql`${col} BETWEEN ${minSap} AND ${maxSap}`;
}
if (operator === "epc_less_than") {
// Worse than the given letter — SAP below the band's minimum
const minSap = EPC_TO_SAP_MIN[letter as keyof typeof EPC_TO_SAP_MIN];
if (minSap === undefined) return null;
return sql`${col} < ${minSap}`;
}
if (operator === "epc_greater_than") {
// Better than the given letter — SAP above the band's maximum
const maxSap = EPC_TO_SAP_MAX[letter as keyof typeof EPC_TO_SAP_MAX];
if (maxSap === undefined) return null;
return sql`${col} > ${maxSap}`;
}
if (operator === "epc_one_of") {
const letters = value.split(",").map((l) => l.trim()) as EpcLetter[];
const ranges = letters
.map((l) => {
const minSap = EPC_TO_SAP_MIN[l as keyof typeof EPC_TO_SAP_MIN];
const maxSap = EPC_TO_SAP_MAX[l as keyof typeof EPC_TO_SAP_MAX];
if (minSap === undefined || maxSap === undefined) return null;
return sql`(${col} BETWEEN ${minSap} AND ${maxSap})`;
})
.filter((x): x is ReturnType<typeof sql> => x !== null);
if (ranges.length === 0) return null;
return sql`(${sql.join(ranges, sql` OR `)})`;
}
return null;
}
function buildConditionSql(filter: PropertyFilter): ReturnType<typeof sql> | null {
switch (filter.field) {
case "address":
if (filter.operator === "contains") {
return sql`p.address ILIKE ${"%" + filter.value + "%"}`;
}
return null;
case "postcode":
if (filter.operator === "starts_with") {
return sql`p.postcode ILIKE ${filter.value + "%"}`;
}
return null;
case "propertyRef":
if (filter.operator === "contains") {
return sql`p.landlord_property_id ILIKE ${"%" + filter.value + "%"}`;
}
return null;
case "currentEpc":
return buildEpcSapCondition(sql`p.current_sap_points`, filter.operator, filter.value);
case "lodgedEpc":
return buildEpcSapCondition(sql`p.original_sap_points`, filter.operator, filter.value);
case "expectedEpc": {
if (filter.operator === "epc_at_least") {
const minSap = EPC_TO_SAP_MIN[filter.value as keyof typeof EPC_TO_SAP_MIN];
if (minSap === undefined) return null;
return sql`pl.post_sap_points IS NOT NULL AND pl.post_sap_points >= ${minSap}`;
}
return null;
}
case "epcExpiryDate": {
const expiryExpr = sql`(epc.lodgement_date + INTERVAL '10 years')`;
const guard = sql`epc.lodgement_date IS NOT NULL`;
if (filter.operator === "date_before") {
return sql`${guard} AND ${expiryExpr} < ${filter.value}::date`;
}
if (filter.operator === "date_after") {
return sql`${guard} AND ${expiryExpr} > ${filter.value}::date`;
}
if (filter.operator === "date_equals") {
return sql`${guard} AND DATE(${expiryExpr}) = ${filter.value}::date`;
}
if (filter.operator === "date_preset") {
switch (filter.value) {
case "expired":
return sql`epc.is_expired = true`;
case "expires_this_year":
return sql`${guard} AND EXTRACT(YEAR FROM ${expiryExpr}) = EXTRACT(YEAR FROM CURRENT_DATE)`;
case "expires_within_1_year":
return sql`${guard} AND ${expiryExpr} BETWEEN CURRENT_DATE AND (CURRENT_DATE + INTERVAL '1 year')`;
case "expires_within_2_years":
return sql`${guard} AND ${expiryExpr} BETWEEN CURRENT_DATE AND (CURRENT_DATE + INTERVAL '2 years')`;
default:
return null;
}
}
return null;
}
case "propertyType":
case "builtForm":
case "tenure":
case "yearBuilt":
case "mainfuel": {
if (filter.operator !== "enum_one_of") return null;
let selectedLabels: string[];
try {
selectedLabels = JSON.parse(filter.value);
} catch {
return null;
}
if (selectedLabels.length === 0) return null;
const options = ENUM_FIELD_DB_OPTIONS[filter.field];
const colMap: Record<string, ReturnType<typeof sql>> = {
propertyType: sql`p.property_type`,
builtForm: sql`p.built_form`,
tenure: sql`p.tenure`,
yearBuilt: sql`p.year_built`,
mainfuel: sql`epc.mainfuel`,
};
const col = colMap[filter.field];
// Flatten all dbValues for selected labels
const allDbValues: string[] = [];
let includeNull = false;
for (const label of selectedLabels) {
const opt = options.find((o) => o.label === label);
if (!opt) continue;
for (const v of opt.dbValues) {
if (v === "__null__") {
includeNull = true;
} else {
allDbValues.push(v);
}
}
}
const parts: ReturnType<typeof sql>[] = [];
if (includeNull) {
parts.push(sql`${col} IS NULL`);
}
if (allDbValues.length > 0) {
// Build IN clause with each value as a separate param
const placeholders = allDbValues.map((v) => sql`${v}`);
parts.push(sql`${col} IN (${sql.join(placeholders, sql`, `)})`);
}
if (parts.length === 0) return null;
if (parts.length === 1) return parts[0];
return sql`(${sql.join(parts, sql` OR `)})`;
}
case "floorArea": {
const n = parseFloat(filter.value);
if (isNaN(n)) return null;
if (filter.operator === "num_gte") return sql`epc.total_floor_area >= ${n}`;
if (filter.operator === "num_lte") return sql`epc.total_floor_area <= ${n}`;
if (filter.operator === "num_equals") return sql`epc.total_floor_area = ${n}`;
return null;
}
case "co2Emissions": {
const n = parseFloat(filter.value);
if (isNaN(n)) return null;
if (filter.operator === "num_gte") return sql`epc.co2_emissions >= ${n}`;
if (filter.operator === "num_lte") return sql`epc.co2_emissions <= ${n}`;
if (filter.operator === "num_equals") return sql`epc.co2_emissions = ${n}`;
return null;
}
}
return null;
}
function buildWhereClause(filterGroups: FilterGroups): ReturnType<typeof sql> {
const groupFragments: ReturnType<typeof sql>[] = [];
for (const group of filterGroups) {
const condFragments = group.conditions
.map(buildConditionSql)
.filter((x): x is ReturnType<typeof sql> => x !== null);
if (condFragments.length === 0) continue;
if (condFragments.length === 1) {
groupFragments.push(condFragments[0]);
} else {
groupFragments.push(sql`(${sql.join(condFragments, sql` AND `)})`);
}
}
return groupFragments.length > 0
? sql`AND (${sql.join(groupFragments, sql` OR `)})`
: sql``;
}
export async function getPropertiesCount(
portfolioId: string,
filterGroups: FilterGroups = []
): Promise<number> {
const combinedWhere = buildWhereClause(filterGroups);
const result = await db.execute<{ count: string }>(sql`
SELECT COUNT(DISTINCT p.id)::int AS count
FROM property p
LEFT JOIN property_details_epc epc ON epc.property_id = p.id
LEFT JOIN plan pl ON pl.property_id = p.id AND pl.is_default = true
WHERE p.portfolio_id = ${portfolioId}
${combinedWhere}
`);
return parseInt(result.rows[0]?.count ?? "0", 10);
}
export async function getProperties(
portfolioId: string,
limit: number = 1000,
offset: number = 0,
filters: PropertyFilter[] = []
filterGroups: FilterGroups = []
): Promise<PropertyWithRelations[]> {
// We need to perform the query like this because the nested query is not supported in the ORM right now
const whereClauses: any[] = [];
for (const filter of filters) {
switch (filter.field) {
case "address":
if (filter.operator === "contains") {
whereClauses.push(
sql`p.address ILIKE ${"%" + filter.value + "%"}`
);
}
break;
case "postcode":
if (filter.operator === "starts_with") {
whereClauses.push(
sql`p.postcode ILIKE ${filter.value + "%"}`
);
}
break;
case "currentEpc": {
console.log("EPC at most", filter.value)
const maxSap =
EPC_TO_SAP_MAX[filter.value as keyof typeof EPC_TO_SAP_MAX];
if (maxSap === undefined) break;
if (filter.operator === "epc_at_most") {
whereClauses.push(
sql`p.current_sap_points <= ${maxSap}`
);
}
break;
}
case "expectedEpc": {
const minSap =
EPC_TO_SAP_MIN[filter.value as keyof typeof EPC_TO_SAP_MIN];
if (minSap === undefined) break;
if (filter.operator === "epc_at_least") {
whereClauses.push(
sql`
pl.post_sap_points IS NOT NULL
AND pl.post_sap_points >= ${minSap}
`
);
}
break;
}
}
}
const combinedWhere =
whereClauses.length > 0
? sql`AND (${sql.join(whereClauses, sql` AND `)})`
: sql``;
const combinedWhere = buildWhereClause(filterGroups);
const result =
await db.execute<PropertyWithRelations>(sql<PropertyWithRelations>`
@ -494,7 +699,18 @@ export async function getProperties(
pl.id AS "planId",
fp.scheme AS "fundingScheme",
COALESCE(SUM(r.sap_points), 0) AS "totalRecommendationSapPoints",
COALESCE(SUM(r.estimated_cost), 0) AS "totalRecommendationCost"
COALESCE(SUM(r.estimated_cost), 0) AS "totalRecommendationCost",
p.landlord_property_id AS "landlordPropertyId",
p.original_sap_points AS "originalSapPoints",
p.property_type AS "propertyType",
p.built_form AS "builtForm",
p.tenure AS tenure,
p.year_built AS "yearBuilt",
epc.lodgement_date::text AS "epcLodgementDate",
epc.is_expired AS "epcIsExpired",
epc.total_floor_area AS "totalFloorArea",
epc.co2_emissions AS "co2Emissions",
epc.mainfuel AS mainfuel
FROM property p
LEFT JOIN property_targets t
ON t.property_id = p.id
@ -508,7 +724,9 @@ export async function getProperties(
LEFT JOIN recommendation r
ON r.id = pr.recommendation_id
AND r.default = true
and r.already_installed = false
AND r.already_installed = false
LEFT JOIN property_details_epc epc
ON epc.property_id = p.id
WHERE p.portfolio_id = ${portfolioId}
${combinedWhere}
GROUP BY
@ -522,7 +740,18 @@ export async function getProperties(
p.current_sap_points,
t.epc,
pl.id,
fp.scheme
fp.scheme,
p.landlord_property_id,
p.original_sap_points,
p.property_type,
p.built_form,
p.tenure,
p.year_built,
epc.lodgement_date,
epc.is_expired,
epc.total_floor_area,
epc.co2_emissions,
epc.mainfuel
LIMIT ${limit} OFFSET ${offset};
`);

View file

@ -67,6 +67,20 @@ export const getEpcColorClass = (letter: string) => {
}
};
/** Border + text colour classes for a transparent-background EPC badge */
export const getEpcAccentClasses = (letter: string) => {
switch (letter.toUpperCase()) {
case "A": return "border-epc_a text-epc_a";
case "B": return "border-epc_b text-epc_b";
case "C": return "border-epc_c text-epc_c";
case "D": return "border-epc_d text-epc_d";
case "E": return "border-epc_e text-epc_e";
case "F": return "border-epc_f text-epc_f";
case "G": return "border-epc_g text-epc_g";
default: return "border-slate-300 text-slate-400";
}
};
export const getRating = (rating: number | null): Rating => {
if (rating == null) {
return "N/A";

View file

@ -2,17 +2,113 @@ export type FilterField =
| "address"
| "postcode"
| "currentEpc"
| "expectedEpc";
| "expectedEpc"
| "lodgedEpc"
| "epcExpiryDate"
| "propertyRef"
| "propertyType"
| "builtForm"
| "tenure"
| "yearBuilt"
| "floorArea"
| "co2Emissions"
| "mainfuel";
export type FilterOperator =
| "contains"
| "starts_with"
| "equals"
| "epc_at_least"
| "epc_at_most";
| "epc_at_most"
| "epc_less_than"
| "epc_greater_than"
| "epc_one_of"
| "date_before"
| "date_after"
| "date_equals"
| "date_preset"
| "enum_one_of"
| "num_gte"
| "num_lte"
| "num_equals";
export type DatePreset =
| "expired"
| "expires_this_year"
| "expires_within_1_year"
| "expires_within_2_years";
export interface PropertyFilter {
id: string;
field: FilterField;
operator: FilterOperator;
value: string;
}
}
export interface FilterGroup {
id: string;
conditions: PropertyFilter[];
}
export type FilterGroups = FilterGroup[];
/* -----------------------------------------------------------------------
Enum option definitions for categorical filter fields
------------------------------------------------------------------------ */
export interface EnumOption {
/** User-facing display label */
label: string;
/** Actual DB values to match. Use ["__null__"] to match NULL. */
dbValues: string[];
}
export const PROPERTY_TYPE_OPTIONS: EnumOption[] = [
{ label: "House", dbValues: ["House"] },
{ label: "Flat", dbValues: ["Flat"] },
{ label: "Bungalow", dbValues: ["Bungalow"] },
{ label: "Maisonette", dbValues: ["Maisonette"] },
];
export const BUILT_FORM_OPTIONS: EnumOption[] = [
{ label: "Detached", dbValues: ["Detached"] },
{ label: "Semi-Detached", dbValues: ["Semi-Detached"] },
{ label: "End-Terrace", dbValues: ["End-Terrace"] },
{ label: "Mid-Terrace", dbValues: ["Mid-Terrace"] },
{ label: "Enclosed End-Terrace", dbValues: ["Enclosed End-Terrace"] },
{ label: "Enclosed Mid-Terrace", dbValues: ["Enclosed Mid-Terrace"] },
{ label: "Not Recorded", dbValues: ["Not Recorded"] },
];
export const TENURE_OPTIONS: EnumOption[] = [
{ label: "Owner-occupied", dbValues: ["Owner-occupied", "owner-occupied"] },
{ label: "Rented (Private)", dbValues: ["Rented (private)", "rental (private)", "rented (private)"] },
{ label: "Rented (Social)", dbValues: ["Rented (social)", "rental (social)"] },
{ label: "Not Defined", dbValues: ["Not defined - use in the case of a new dwelling for which the intended tenure in not known. It is not to be used for an existing dwelling"] },
{ label: "Unknown", dbValues: ["unknown"] },
{ label: "Not Recorded", dbValues: ["__null__"] },
];
export const YEAR_BUILT_OPTIONS: EnumOption[] = [
"1900","1930","1950","1967","1976","1983","1991","1996",
"2003","2007","2008","2009","2010","2011","2012","2013",
"2014","2015","2016","2017","2018","2019","2020","2021",
"2022","2023","2024","2025",
].map((y) => ({ label: y, dbValues: [y] }));
export const MAINFUEL_OPTIONS: EnumOption[] = [
{ label: "Mains Gas", dbValues: ["Gas mains gas", "Mains gas community", "Mains gas not community", "Mains gas this is for backwards compatibility only and should not be used"] },
{ label: "LPG", dbValues: ["Bottled lpg", "Lpg community", "Lpg not community", "Lpg this is for backwards compatibility only and should not be used"] },
{ label: "Oil", dbValues: ["Oil heating oil", "Oil community", "Oil not community", "Oil this is for backwards compatibility only and should not be used"] },
{ label: "Electricity", dbValues: ["Electricity electricity unspecified tariff", "Electricity community", "Electricity not community", "Electricity this is for backwards compatibility only and should not be used"] },
{ label: "Biomass", dbValues: ["Bulk wood pellets", "Wood chips", "Wood logs", "Biomass community", "Biomass this is for backwards compatibility only and should not be used"] },
{ label: "Coal", dbValues: ["Anthracite", "House coal not community", "House coal this is for backwards compatibility only and should not be used", "Smokeless coal", "Coal community"] },
{ label: "Dual Fuel (Mineral + Wood)", dbValues: ["Dual fuel mineral wood"] },
{ label: "Biogas", dbValues: ["Biogas not community"] },
{ label: "Biodiesel", dbValues: ["Heat from boilers using biodiesel from any biomass source community"] },
{ label: "B30d (Biodiesel blend)", dbValues: ["B30d community"] },
{ label: "B30k (Biodiesel blend)", dbValues: ["B30k not community"] },
{ label: "Community Heat Network", dbValues: ["From heat network data community"] },
{ label: "No Heating System", dbValues: ["To be used only when there is no heatinghotwater system", "To be used only when there is no heatinghotwater system or data is from a community network"] },
{ label: "Unknown / No Data", dbValues: ["UNKNOWN", "NO DATA!"] },
];

View file

@ -155,6 +155,7 @@ module.exports = {
},
fontFamily: {
sans: ["var(--font-sans)", ...fontFamily.sans],
manrope: ["var(--font-manrope)", ...fontFamily.sans],
},
keyframes: {
"accordion-down": {

View file

@ -1,8 +1,7 @@
{
"compilerOptions": {
"allowSyntheticDefaultImports": true,
"target": "es5",
// "target": "ESNext",
"target": "ESNext",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
@ -11,7 +10,8 @@
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "node",
"ignoreDeprecations": "5.0",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",