mirror of
https://github.com/Hestia-Homes/assessment-model.git
synced 2026-06-08 11:37:25 +00:00
Merge pull request #219 from Hestia-Homes/feature/solar-map
Feature/solar map
This commit is contained in:
commit
02282cbb90
58 changed files with 7666 additions and 3226 deletions
|
|
@ -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
381
package-lock.json
generated
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
235
src/app/actions/recommendations.ts
Normal file
235
src/app/actions/recommendations.ts
Normal 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}`,
|
||||
);
|
||||
}
|
||||
39
src/app/api/plan/[id]/set-default/route.ts
Normal file
39
src/app/api/plan/[id]/set-default/route.ts
Normal 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 });
|
||||
}
|
||||
|
|
@ -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 });
|
||||
}
|
||||
|
|
|
|||
110
src/app/components/building-passport/AlternativesDrawer.tsx
Normal file
110
src/app/components/building-passport/AlternativesDrawer.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
167
src/app/components/building-passport/StickyImpactBar.tsx
Normal file
167
src/app/components/building-passport/StickyImpactBar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,3 @@
|
|||
import { Recommendation } from "@/app/db/schema/recommendations";
|
||||
|
||||
export type AugmentedRec = Recommendation & { alreadyInstalled: boolean };
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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<
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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 Domna’s 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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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]);
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 & 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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: "92–100", color: "#117d58", desc: "Exceptional, near-zero energy bills, usually new-builds or eco-homes." },
|
||||
{ band: "B", range: "81–91", color: "#2da55c", desc: "Very efficient, often featuring solar panels, high-grade insulation, and modern heating." },
|
||||
{ band: "C", range: "69–80", color: "#8dbd40", desc: "Good, above-average efficiency; common target for retrofitting existing homes." },
|
||||
{ band: "D", range: "55–68", color: "#f7cd14", desc: "Average, the typical rating for many homes in the UK." },
|
||||
{ band: "E", range: "39–54", color: "#f3a96a", desc: "Below average, likely requires better insulation and boiler upgrades." },
|
||||
{ band: "F", range: "21–38", color: "#ef8026", desc: "Poor, high energy costs and lower energy performance." },
|
||||
{ band: "G", range: "1–20", 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 (1–100)</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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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)} m²` },
|
||||
{ label: "Ground area", value: `${groundAreaMeters2.toFixed(1)} m²` },
|
||||
{ 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)} m²` : "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)} m²`,
|
||||
},
|
||||
{
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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: [],
|
||||
"0–6 months": [],
|
||||
"6–12 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["0–6 months"].push(e);
|
||||
else if (diffMonths <= 12) groups["6–12 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",
|
||||
},
|
||||
"0–6 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: "0–6 months",
|
||||
},
|
||||
"6–12 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: "6–12 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", "0–6 months", "6–12 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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",
|
||||
"0–6 months": "bg-orange-500",
|
||||
"6–12 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: [],
|
||||
"0–6 months": [],
|
||||
"6–12 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["0–6 months"].push(entry);
|
||||
else if (diffMonths <= 12) groups["6–12 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",
|
||||
"0–6 months",
|
||||
"6–12 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" />,
|
||||
},
|
||||
"0–6 months": {
|
||||
border: "border-l-4 border-orange-500",
|
||||
icon: <Clock className="w-4 h-4 text-orange-500" />,
|
||||
},
|
||||
"6–12 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 }>;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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" }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -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} />;
|
||||
}
|
||||
|
|
@ -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} />;
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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} />;
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
@ -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[]> {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>“New Property”</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>
|
||||
|
|
|
|||
|
|
@ -33,7 +33,7 @@ export default function SuccessToast({
|
|||
}, timeoutMs);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [show, onClose]);
|
||||
}, [show, onClose, showConfetti, timeoutMs]);
|
||||
|
||||
return (
|
||||
<>
|
||||
|
|
|
|||
54
src/app/portfolio/[slug]/components/Tooltip.tsx
Normal file
54
src/app/portfolio/[slug]/components/Tooltip.tsx
Normal 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
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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)} m²` : "—"}
|
||||
</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,
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}),
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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};
|
||||
`);
|
||||
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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!"] },
|
||||
];
|
||||
|
|
|
|||
|
|
@ -155,6 +155,7 @@ module.exports = {
|
|||
},
|
||||
fontFamily: {
|
||||
sans: ["var(--font-sans)", ...fontFamily.sans],
|
||||
manrope: ["var(--font-manrope)", ...fontFamily.sans],
|
||||
},
|
||||
keyframes: {
|
||||
"accordion-down": {
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue