Merge pull request #224 from Hestia-Homes/main

Khalim's fancy column changes
This commit is contained in:
Jun-te Kim 2026-04-16 18:26:49 +01:00 committed by GitHub
commit 277141e755
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
76 changed files with 33213 additions and 3240 deletions

View file

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

381
package-lock.json generated
View file

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

View file

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

View file

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

View file

@ -0,0 +1,39 @@
import { NextResponse } from "next/server";
import { db } from "@/app/db/db";
import { plan } from "@/app/db/schema/recommendations";
import { eq } from "drizzle-orm";
export async function POST(
_req: Request,
context: { params: Promise<{ id: string }> }
) {
const { id } = await context.params;
const planId = Number(id);
if (Number.isNaN(planId)) {
return NextResponse.json({ error: "Invalid plan id" }, { status: 400 });
}
const target = await db.query.plan.findFirst({
where: eq(plan.id, BigInt(planId)),
columns: { propertyId: true },
});
if (!target) {
return NextResponse.json({ error: "Plan not found" }, { status: 404 });
}
await db.transaction(async (tx) => {
await tx
.update(plan)
.set({ isDefault: false })
.where(eq(plan.propertyId, target.propertyId));
await tx
.update(plan)
.set({ isDefault: true })
.where(eq(plan.id, BigInt(planId)));
});
return NextResponse.json({ success: true });
}

View file

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

View file

@ -26,6 +26,7 @@ export async function GET(request: NextRequest, props: { params: Promise<{ prope
tenure: true,
currentEpcRating: true,
currentSapPoints: true,
originalSapPoints: true,
updatedAt: true,
currentValuation: true,
},

View file

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

View file

@ -0,0 +1,139 @@
import { sapToEpc } from "@/app/utils";
import { EpcInfoTooltip } from "./EpcInfoTooltip";
import { LodgedEpcTooltip } from "./LodgedEpcTooltip";
interface CurrentEfficiencyCardProps {
epcRating: string | null;
sapPoints: number;
originalSapPoints?: number | null;
}
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 EPC_STOPS = [
{ sap: 0, color: "#e41e3b" }, // G
{ sap: 21, color: "#ef8026" }, // F
{ sap: 39, color: "#f3a96a" }, // E
{ sap: 55, color: "#f7cd14" }, // D
{ sap: 69, color: "#8dbd40" }, // C
{ sap: 81, color: "#2da55c" }, // B
{ sap: 92, color: "#117d58" }, // A
];
function getEpcGradient(sapPoints: number, epcHex: string): string {
if (sapPoints <= 0) return epcHex;
const stops = EPC_STOPS.filter(s => s.sap <= sapPoints);
const gradientStops = stops.map(s => {
const pct = s.sap === 0 ? 0 : (s.sap / sapPoints) * 100;
return `${s.color} ${pct.toFixed(1)}%`;
});
gradientStops.push(`${epcHex} 100%`);
return `linear-gradient(to right, ${gradientStops.join(", ")})`;
}
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.";
}
}
export function CurrentEfficiencyCard({
epcRating,
sapPoints,
originalSapPoints,
}: CurrentEfficiencyCardProps) {
const epcHex = getEpcHex(epcRating);
const lodgedLetter = originalSapPoints ? sapToEpc(originalSapPoints) : null;
const showLodgedBadge = lodgedLetter !== null && lodgedLetter !== epcRating;
const lodgedHex = getEpcHex(lodgedLetter);
return (
<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 }}
/>
{showLodgedBadge && (
<div className="absolute top-3 right-3 z-20 rounded-xl border-2 border-brandbrown/60 bg-white/90 px-3 py-2.5 shadow-sm">
<div className="flex items-center gap-1 mb-1">
<span className="text-[9px] font-bold uppercase tracking-widest text-brandbrown">
Lodged EPC
</span>
<LodgedEpcTooltip />
</div>
<div className="flex items-baseline gap-1.5">
<span
className="text-2xl font-black font-manrope leading-none"
style={{ color: lodgedHex }}
>
{lodgedLetter}
</span>
{originalSapPoints != null && (
<span className="text-xs font-bold text-gray-400">
/ {Math.round(originalSapPoints)}
</span>
)}
</div>
</div>
)}
<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 }}
>
{epcRating ?? "—"}
</span>
<span className="text-4xl font-bold font-manrope text-gray-400">
/ {sapPoints || ""}
</span>
</div>
<p className="text-gray-500 font-medium text-sm max-w-xs leading-relaxed">
{getEpcDescription(epcRating)}
</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, sapPoints))}%`,
background: getEpcGradient(sapPoints, epcHex),
}}
/>
</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>
);
}

View file

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

View file

@ -0,0 +1,58 @@
"use client";
import { QuestionMarkCircleIcon } from "@heroicons/react/24/outline";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/app/shadcn_components/ui/tooltip";
export function LodgedEpcTooltip() {
return (
<TooltipProvider delayDuration={200}>
<Tooltip>
<TooltipTrigger asChild>
<button
className="inline-flex items-center justify-center w-3.5 h-3.5 rounded-full text-brandbrown/60 hover:text-brandbrown transition-colors focus:outline-none"
aria-label="Lodged EPC rating explanation"
>
<QuestionMarkCircleIcon className="w-3.5 h-3.5" />
</button>
</TooltipTrigger>
<TooltipContent
side="left"
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">Lodged EPC Rating</p>
<p className="text-[11px] text-gray-400 mt-0.5">From the official EPC register</p>
</div>
<div className="px-4 py-3 space-y-2.5">
<p className="text-[11px] text-gray-500 leading-snug">
This is the rating recorded on the{" "}
<span className="font-semibold text-gray-700">official EPC register</span> at the time of the last survey.
</p>
<p className="text-[11px] text-gray-500 leading-snug">
Your <span className="font-semibold text-gray-700">current rating</span> may differ because we re-model this property under{" "}
<span className="font-semibold text-gray-700">SAP 10</span>, which uses updated energy factors and can produce a different result than the original survey.
</p>
<p className="text-[11px] text-gray-500 leading-snug">
We also re-model when the EPC has{" "}
<span className="font-semibold text-gray-700">expired</span> or is{" "}
<span className="font-semibold text-gray-700">invalid</span>, or when you have told us the property has{" "}
<span className="font-semibold text-gray-700">changed</span> since the last survey.
</p>
</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">
SAP 10 is the latest version of the{" "}
<span className="font-semibold text-gray-600">Standard Assessment Procedure</span>, introduced to better reflect modern energy use and lower-carbon fuels.
</p>
</div>
</TooltipContent>
</Tooltip>
</TooltipProvider>
);
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1 @@
ALTER TABLE "hubspot_deal_data" ADD COLUMN "coordination_comments" text;

View file

@ -0,0 +1 @@
ALTER TABLE "property_details_epc" ADD COLUMN "environment_impact_current" real;

View file

@ -0,0 +1 @@
ALTER TYPE "public"."file_type" ADD VALUE 'ecmk_survey_xml';

View file

@ -0,0 +1 @@
ALTER TABLE "uploaded_files" ADD COLUMN "hubspot_deal_id" text;

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -1163,6 +1163,34 @@
"when": 1775577933185,
"tag": "0165_small_khan",
"breakpoints": true
},
{
"idx": 166,
"version": "7",
"when": 1775658752820,
"tag": "0166_melodic_crusher_hogan",
"breakpoints": true
},
{
"idx": 167,
"version": "7",
"when": 1775747945070,
"tag": "0167_wise_agent_zero",
"breakpoints": true
},
{
"idx": 168,
"version": "7",
"when": 1776246796721,
"tag": "0168_romantic_kronos",
"breakpoints": true
},
{
"idx": 169,
"version": "7",
"when": 1776351792028,
"tag": "0169_freezing_moonstone",
"breakpoints": true
}
]
}

View file

@ -43,11 +43,12 @@ export const hubspotDealData = pgTable("hubspot_deal_data", {
measuresLodgementDate: timestamp("measures_lodgement_date", { precision: 6, withTimezone: true }),
lodgementDate: timestamp("lodgement_date", { precision: 6, withTimezone: true }),
expectedCommencementDate: timestamp("expected_commencement_date", { precision: 6, withTimezone: true }),
coordination_comments: text("coordination_comments"),
surveyor: text("surveyor"),
damnpMouldAndRepairComments: text("damp_mould_and_repairs_comments"),
confirmedSurveyDate: timestamp("confirmed_survey_date", { precision: 6, withTimezone: true }),
confirmedSurveyTime: text("confirmed_survey_time"),
SurveyedDate: timestamp("surveyed_date", { precision: 6, withTimezone: true }),
surveyedDate: timestamp("surveyed_date", { precision: 6, withTimezone: true }),
createdAt: timestamp("created_at", { precision: 6, withTimezone: true })
.defaultNow()

View file

@ -34,6 +34,7 @@ export interface PropertyMeta {
tenure: string;
currentEpcRating: string;
currentSapPoints: number;
originalSapPoints: number | null;
updatedAt: string;
currentValuation: number | null;
detailsEpc: {
@ -269,6 +270,8 @@ export const propertyDetailsEpc = pgTable(
lodgedCo2Emissions: real("lodged_co2_emissions"),
lodgedHeatDemand: real("lodged_heat_demand"),
hasBeenRemodelled: boolean("has_been_remodelled").default(false),
// additional fields
environment_impact_current: real("environment_impact_current"),
},
(table) => [
uniqueIndex("uq_property_details_epc_property_portfolio").on(
@ -366,6 +369,19 @@ export interface PropertyWithRelations extends Record<string, unknown> {
fundingScheme: string | null;
totalRecommendationSapPoints: number | null;
totalRecommendationCost: number | null;
// New fields
landlordPropertyId: string | null;
originalSapPoints: number | null;
epcLodgementDate: string | null;
epcIsExpired: boolean | null;
// Optional columns (hidden by default)
propertyType: string | null;
builtForm: string | null;
tenure: string | null;
yearBuilt: string | null;
totalFloorArea: number | null;
co2Emissions: number | null;
mainfuel: string | null;
}
export type NonIntrusiveSurveyNotes = InferModel<

View file

@ -11,7 +11,8 @@ export const fileType = pgEnum("file_type", [
"pas_2023_property",
"pas_2023_occupancy",
"ecmk_site_note",
"ecmk_rd_sap_site_note"
"ecmk_rd_sap_site_note",
"ecmk_survey_xml"
]);
export const fileSource = pgEnum("file_source", [
@ -32,6 +33,7 @@ export const uploadedFiles = pgTable(
}).notNull(),
landlordPropertyId: text("landlord_property_id"),
uprn: bigint("uprn", { mode: "bigint" }),
hubsotDealId: text("hubspot_deal_id"),
hubspotListingId: bigint("hubspot_listing_id", { mode: "bigint" }),
fileType: fileType("file_type"),
source: fileSource("file_source")

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -110,9 +110,9 @@ const DESIGN_TYPE_ORDER = [
function getMondayOfWeek(date: Date): string {
const d = new Date(date);
const day = d.getDay();
d.setDate(d.getDate() - (day === 0 ? 6 : day - 1));
d.setHours(0, 0, 0, 0);
const day = d.getUTCDay();
d.setUTCDate(d.getUTCDate() - (day === 0 ? 6 : day - 1));
d.setUTCHours(0, 0, 0, 0);
return d.toISOString().split("T")[0];
}
@ -132,7 +132,7 @@ function fillWeekGaps(keys: string[]): string[] {
const end = new Date(sorted[sorted.length - 1]);
while (current <= end) {
result.push(current.toISOString().split("T")[0]);
current.setDate(current.getDate() + 7);
current.setUTCDate(current.getUTCDate() + 7);
}
return result;
}
@ -314,14 +314,28 @@ export default function CompletionTrendsChart({
const isDesign = metric === "design";
const isStacked = isCoordination || isAssessments || isLodgement || isDesign;
// External assessments with no date
// Assessments (retrofit or EPC) with no date
const undatedAssessments = isAssessments
? deals.filter((d) => {
const o = d.outcome ?? "";
return (o === "Surveyed" || o === "Surveyed - Pending Upload") && !d.surveyedDate;
return (
(o === "Surveyed" || o === "Surveyed - Pending Upload" || o === "EPC Completed") &&
!d.surveyedDate
);
})
: [];
// Dated assessments broken down by type — used for summary badges
const retrofitDeals = isAssessments
? deals.filter((d) => {
const o = d.outcome ?? "";
return (o === "Surveyed" || o === "Surveyed - Pending Upload") && !!d.surveyedDate;
})
: [];
const epcDeals = isAssessments
? deals.filter((d) => d.outcome === "EPC Completed" && !!d.surveyedDate)
: [];
// Build chart data
let chartData: Record<string, string | number>[];
let categories: string[];
@ -363,7 +377,37 @@ export default function CompletionTrendsChart({
<Title className="text-brandblue text-lg font-bold">
Trends Over Time
</Title>
{totalCompleted !== null && (
{isAssessments ? (
<div className="flex flex-wrap gap-2">
{([
{ label: "Retrofit Assessments", dealList: retrofitDeals },
{ label: "EPCs", dealList: epcDeals },
] as const).filter(({ dealList }) => dealList.length > 0).map(({ label, dealList }) => (
<button
key={label}
type="button"
onClick={() =>
onOpenTable?.(
`Completed — ${label}`,
dealList,
["dealname", "landlordPropertyId", "outcome", "surveyedDate"],
{
dealname: "Address",
landlordPropertyId: "Property Ref.",
outcome: "Work Type",
surveyedDate: "Survey Date",
},
)
}
className="inline-flex items-center gap-2 px-3 py-1.5 rounded-full bg-gradient-to-r from-brandmidblue/10 to-brandlightblue/50 border border-brandblue/20 shadow-sm hover:border-brandblue/40 hover:shadow-md transition-all cursor-pointer"
>
<span className="text-brandmidblue font-bold text-base leading-none">{dealList.length}</span>
<span className="text-xs text-brandblue font-medium">{label}</span>
<span className="text-brandmidblue text-xs leading-none"></span>
</button>
))}
</div>
) : totalCompleted !== null && (
<div className="inline-flex items-center gap-2 self-start px-3 py-1.5 rounded-full bg-gradient-to-r from-brandmidblue/10 to-brandlightblue/50 border border-brandblue/20 shadow-sm">
<span className="text-brandmidblue font-bold text-base leading-none" suppressHydrationWarning>{totalCompleted}</span>
<span className="text-xs text-brandblue font-medium">
@ -454,7 +498,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 +510,16 @@ export default function CompletionTrendsChart({
{isStacked && (
<RechartsLegend
wrapperStyle={{ paddingTop: "12px", fontSize: "12px", color: "#6b7280" }}
iconType="square"
iconSize={10}
payload={categories.map((cat, i) => ({
value: cat,
type: "square" as const,
color: colors[i],
}))}
content={() => (
<ul style={{ display: "flex", flexWrap: "wrap", gap: "8px", padding: 0, margin: 0, listStyle: "none" }}>
{categories.map((cat, i) => (
<li key={cat} style={{ display: "flex", alignItems: "center", gap: "4px" }}>
<span style={{ display: "inline-block", width: 10, height: 10, backgroundColor: colors[i], flexShrink: 0 }} />
<span>{cat}</span>
</li>
))}
</ul>
)}
/>
)}
</Card>

View file

@ -5,7 +5,7 @@ import { AlertCircle } from "lucide-react";
import { Card, CardContent } from "@/app/shadcn_components/ui/card";
import type { ClassifiedDeal } from "./types";
const SUCCESSFUL_OUTCOMES = new Set(["Surveyed", "Surveyed - Pending Upload"]);
const SUCCESSFUL_OUTCOMES = new Set(["Surveyed", "Surveyed - Pending Upload", "EPC Completed"]);
const COLUMNS: (keyof ClassifiedDeal)[] = [
"dealname",

View file

@ -53,7 +53,7 @@ function mapDbRowToHubspotDeal(row: DbDeal): HubspotDeal {
measuresLodgementDate: row.measuresLodgementDate,
fullLodgementDate: row.lodgementDate,
confirmedSurveyDate: row.confirmedSurveyDate,
surveyedDate: row.SurveyedDate,
surveyedDate: row.surveyedDate,
designType: row.dealType,
createdAt: row.createdAt,
updatedAt: row.updatedAt,

View file

@ -230,6 +230,7 @@ export type DocumentDrawerState = {
export const SURVEYOR_OUTCOMES = [
"Surveyed",
"Surveyed - Pending Upload",
"EPC Completed",
"Tenant Refusal",
"Other",
"Not Viable",

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,70 +1,295 @@
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";
import { CurrentEfficiencyCard } from "@/app/components/building-passport/CurrentEfficiencyCard";
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")}`;
}
// ── 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);
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="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 */}
<CurrentEfficiencyCard
epcRating={propertyMeta.currentEpcRating ?? null}
sapPoints={propertyMeta.currentSapPoints ?? 0}
originalSapPoints={propertyMeta.originalSapPoints}
/>
<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>
{/* 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">
<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>
<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>
</div>
<div className="flex items-center space-x-2 mb-2">
<ClockIcon className="h-5 w-5 text-gray-400" />
<div className="text-gray-500">Year Built:</div>
<div className="text-gray-700">{propertyMeta.yearBuilt}</div>
</div>
<div className="flex items-center space-x-2 mb-2">
<UserGroupIcon className="h-5 w-5 text-gray-400" />
<div className="text-gray-500">Tenure:</div>
<div className="text-gray-700">{propertyMeta.tenure}</div>
</div>
<div className="flex items-center space-x-2 mb-2">
<HomeModernIcon className="h-5 w-5 text-gray-400" />
<div className="text-gray-500">Number of Habitable Rooms:</div>
<div className="text-gray-700">{propertyMeta.numberOfRooms}</div>
{/* Heritage Status — fills remaining height to match EPC card */}
<div className="flex-1 bg-white rounded-2xl p-8 shadow-sm border border-gray-100 flex flex-col justify-between">
<div>
<div className="flex items-center gap-3 mb-6">
<span className="w-8 h-8 rounded-xl bg-brandblue/8 flex items-center justify-center shrink-0">
<ShieldCheckIcon className="w-4 h-4 text-brandblue" />
</span>
<p className="font-manrope text-xs font-bold text-brandmidblue uppercase tracking-widest">Heritage & Planning Status</p>
<HeritageTooltip />
</div>
<div className="grid grid-cols-3 gap-4">
<div className="flex flex-col gap-3">
<p className="text-xs font-bold text-gray-400 uppercase tracking-wider">Conservation Area</p>
<YesNoBadge value={!!spatial.conservationStatus} />
<p className="text-xs text-gray-400 leading-relaxed">
{spatial.conservationStatus
? "This property falls within a designated conservation area."
: "No conservation area restrictions apply to this property."}
</p>
</div>
<div className="flex flex-col gap-3">
<p className="text-xs font-bold text-gray-400 uppercase tracking-wider">Listed Building</p>
<YesNoBadge value={!!spatial.isListedBuilding} />
<p className="text-xs text-gray-400 leading-relaxed">
{spatial.isListedBuilding
? "This property is a listed building with statutory protections."
: "This property does not have listed building status."}
</p>
</div>
<div className="flex flex-col gap-3">
<p className="text-xs font-bold text-gray-400 uppercase tracking-wider">Heritage Building</p>
<YesNoBadge value={!!spatial.isHeritageBuilding} />
<p className="text-xs text-gray-400 leading-relaxed">
{spatial.isHeritageBuilding
? "This property is recognised as a heritage asset."
: "No heritage asset designation applies to this property."}
</p>
</div>
</div>
</div>
</div>
</div>
</div>
{/* ── Row 2: Property Details Grid ────────────────────────────────── */}
<div>
<SectionHeading
icon={<BuildingOfficeIcon className="w-4 h-4" />}
label="Property Details"
/>
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
{/* Building */}
<div className="bg-white rounded-2xl p-7 shadow-sm border border-gray-100">
<p className="font-manrope text-xs font-bold text-brandmidblue uppercase tracking-widest mb-4">Building</p>
<DetailRow label="Year built" value={propertyMeta.yearBuilt ?? "—"} />
<DetailRow
label="Type"
value={[propertyMeta.builtForm, propertyMeta.propertyType].filter(Boolean).join(", ") || "—"}
/>
<DetailRow
label="Floor area"
value={
conditionReport.totalFloorArea != null
? <>{Math.round(conditionReport.totalFloorArea)} m²</>
: "—"
}
/>
<DetailRow label="Storeys" value={conditionReport.numberStoreys ?? "—"} />
<DetailRow label="Habitable rooms" value={propertyMeta.numberOfRooms ?? "—"} />
<DetailRow
label="Mains gas"
value={
conditionReport.mainsGas != null
? <YesNoBadge value={conditionReport.mainsGas} />
: "—"
}
/>
</div>
{/* Location & Status */}
<div className="bg-white rounded-2xl p-7 shadow-sm border border-gray-100">
<p className="font-manrope text-xs font-bold text-brandmidblue uppercase tracking-widest mb-4">Location & Status</p>
<DetailRow label="Local authority" value={propertyMeta.localAuthority ?? "—"} />
<DetailRow label="Constituency" value={propertyMeta.constituency ?? "—"} />
<DetailRow label="Tenure" value={propertyMeta.tenure ?? "—"} />
</div>
{/* Annual Energy Costs */}
<div className="bg-white rounded-2xl p-7 shadow-sm border border-gray-100">
<p className="font-manrope text-xs font-bold text-brandmidblue uppercase tracking-widest mb-4">Annual Energy Costs</p>
<DetailRow label="Heating" value={formatGbp(conditionReport.heatingEnergyCostCurrent)} />
<DetailRow label="Hot water" value={formatGbp(conditionReport.hotWaterEnergyCostCurrent)} />
<DetailRow label="Lighting" value={formatGbp(conditionReport.lightingEnergyCostCurrent)} />
<DetailRow label="Appliances" value={formatGbp(conditionReport.appliancesEnergyCostCurrent)} />
{annualEnergyCost > 0 && (
<div className="mt-3 pt-3 border-t border-gray-200 flex justify-between">
<span className="text-sm font-bold text-gray-600">Total (excl. appliances)</span>
<span className="text-sm font-bold text-brandblue tabular-nums">
£{Math.round(annualEnergyCost).toLocaleString("en-GB")}
</span>
</div>
)}
</div>
</div>
</div>
{/* ── Row 3: Installed Measures ────────────────────────────────────── */}
{installedMeasures.length > 0 && (
<div>
<SectionHeading
icon={<WrenchScrewdriverIcon className="w-4 h-4" />}
label="Installed Measures"
/>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
{installedMeasures.map((measure, i) => (
<div
key={i}
className="bg-white rounded-2xl border border-gray-100 p-6 shadow-sm flex flex-col gap-3"
>
<div className="flex items-center gap-3">
<span className="w-8 h-8 rounded-xl bg-brandblue/8 flex items-center justify-center shrink-0">
<SparklesIcon className="w-4 h-4 text-brandblue" />
</span>
<span className="text-sm font-bold text-brandblue">{measure.measureType}</span>
</div>
{measure.installedAt && (
<p className="text-xs text-gray-400">
Installed {new Date(measure.installedAt).toLocaleDateString("en-GB", { month: "short", year: "numeric" })}
</p>
)}
<div className="grid grid-cols-2 gap-2 mt-1">
{measure.kwhSavings != null && (
<div>
<p className="text-[10px] uppercase tracking-wide text-gray-400 font-medium">kWh saved</p>
<p className="text-sm font-semibold text-brandblue tabular-nums">
{Math.round(measure.kwhSavings).toLocaleString()}/yr
</p>
</div>
)}
{measure.billSavings != null && (
<div>
<p className="text-[10px] uppercase tracking-wide text-gray-400 font-medium">Bill saving</p>
<p className="text-sm font-semibold text-brandblue tabular-nums">
£{Math.round(measure.billSavings).toLocaleString()}/yr
</p>
</div>
)}
</div>
</div>
))}
</div>
</div>
)}
</div>
);
}
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,51 @@
"use client";
import { QuestionMarkCircleIcon } from "@heroicons/react/24/outline";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/app/shadcn_components/ui/tooltip";
export function CurrentEpcTooltip() {
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="Current EPC rating 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">Current EPC Rating</p>
<p className="text-[11px] text-gray-400 mt-0.5">How we calculate this rating</p>
</div>
<div className="px-4 py-3 space-y-2.5">
<p className="text-[11px] text-gray-500 leading-snug">
We show a <span className="font-semibold text-gray-700">modelled rating</span> when:
</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>The lodged EPC is <span className="font-semibold text-gray-700 ml-1">expired or invalid</span></li>
<li className="flex items-start gap-1.5"><span className="mt-0.5 shrink-0 text-gray-400"></span>You have told us the property differs from the last survey</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">
This rating may differ from the <span className="font-semibold text-gray-600">lodged EPC</span> because we re-model under{" "}
<span className="font-semibold text-gray-600">SAP 10</span>.
</p>
</div>
</TooltipContent>
</Tooltip>
</TooltipProvider>
);
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -11,35 +11,76 @@ 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";
import { CurrentEpcTooltip } from "./CurrentEpcTooltip";
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 +109,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 +147,121 @@ 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-brandmidblue 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 items-center justify-center gap-1 text-xs">
Current EPC
<CurrentEpcTooltip />
</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 +269,195 @@ export const columns: ColumnDef<PropertyWithRelations>[] = [
return (
<div className="text-gray-700 font-medium flex justify-center">
{<EpcLetterBubble letter={expectedEpc || ""} />}
<EpcLetterBubble letter={expectedEpc || ""} />
</div>
);
},
},
{
accessorKey: "epcLodgementDate",
header: () => (
<div className="flex justify-center text-xs">EPC Expiry</div>
),
cell: ({ row }) => {
const dateStr = row.original.epcLodgementDate;
const expired = row.original.epcIsExpired;
if (!dateStr) {
return <div className="text-center text-slate-400 text-xs"></div>;
}
const lodgementDate = new Date(dateStr);
const expiryDate = new Date(lodgementDate);
expiryDate.setFullYear(expiryDate.getFullYear() + 10);
const formatted = expiryDate.toLocaleDateString("en-GB", {
month: "short",
year: "numeric",
});
if (expired) {
return (
<div className="flex flex-col gap-0.5">
<span className="text-[9px] font-bold text-red-600 bg-red-50 px-1.5 py-0.5 rounded w-max uppercase tracking-wide">
Expired
</span>
<span className="text-[10px] text-slate-400">{formatted}</span>
</div>
);
}
return (
<div className="flex flex-col gap-0.5">
<span className="text-xs font-semibold text-primary">{formatted}</span>
</div>
);
},
},
{
accessorKey: "cost",
header: () => <div className="flex justify-center">Cost</div>,
header: () => <div className="flex justify-end text-xs">Plan Cost</div>,
cell: ({ row }) => {
const cost = row.original.totalRecommendationCost;
const creationStatus = row.original.creationStatus;
if (creationStatus === "LOADING") {
return <div className="font-medium flex justify-center"></div>;
return <div className="font-medium flex justify-end" />;
}
const formatted = cost ? "£" + formatNumber(cost) : "";
return (
<div className="text-gray-700 font-medium flex justify-center">
{formatted}
<div className="text-right">
{cost ? (
<span className="text-xs font-bold text-primary">
£{formatNumber(cost)}
</span>
) : (
<span className="text-[10px] text-slate-300 italic">No cost</span>
)}
</div>
);
},
},
{
id: "actions",
cell: ({ row, table }) => {
cell: ({ row }) => {
const property = row.original;
const propertyId = property.id;
const portfolioId = property.portfolioId;
const creationStatus = property.creationStatus;
if (creationStatus === "LOADING") {
return (
<div className="font-mediutext-gray-800m text-gray-700 flex justify-center">
Loading...
</div>
<div className="text-gray-700 flex justify-center">Loading...</div>
);
}
return (
<>
<div className="flex justify-center">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="h-8 w-8 p-0">
<span className="sr-only">Open menu</span>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuLabel>Actions</DropdownMenuLabel>
<DropdownMenuItem
// onClick={() => navigator.clipboard.writeText(payment.id)}
className="text-gray-700 cursor-pointer"
>
<a href={`${portfolioId}/building-passport/${propertyId}`}>
Building Passport
</a>
</DropdownMenuItem>
<DropdownMenuItem className="text-gray-700 cursor-pointer">
Settings
</DropdownMenuItem>
<DropdownMenuSeparator />
</DropdownMenuContent>
</DropdownMenu>
</div>
</>
<div className="flex justify-center">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="h-8 w-8 p-0">
<span className="sr-only">Open menu</span>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuLabel>Actions</DropdownMenuLabel>
<DropdownMenuItem className="text-gray-700 cursor-pointer">
<a href={`${portfolioId}/building-passport/${propertyId}`}>
Building Passport
</a>
</DropdownMenuItem>
<DropdownMenuItem className="text-gray-700 cursor-pointer">
Settings
</DropdownMenuItem>
<DropdownMenuSeparator />
</DropdownMenuContent>
</DropdownMenu>
</div>
);
},
},
];
/* -----------------------------------------------------------------------
Optional columns
------------------------------------------------------------------------ */
const optionalColumns: ColumnDef<PropertyWithRelations>[] = [
{
id: "propertyType",
accessorKey: "propertyType",
header: () => <div className="text-xs">Property Type</div>,
cell: ({ row }) => {
const val = row.original.propertyType;
return val ? <Pill>{val}</Pill> : <span className="text-slate-300 text-xs"></span>;
},
},
{
id: "builtForm",
accessorKey: "builtForm",
header: () => <div className="text-xs">Built Form</div>,
cell: ({ row }) => {
const val = row.original.builtForm;
return val ? <Pill>{val}</Pill> : <span className="text-slate-300 text-xs"></span>;
},
},
{
id: "tenure",
accessorKey: "tenure",
header: () => <div className="text-xs">Tenure</div>,
cell: ({ row }) => {
const label = resolveEnumLabel(TENURE_OPTIONS, row.original.tenure);
if (!label) return <span className="text-slate-300 text-xs"></span>;
return <Pill className={tenureBadgeClass(label)}>{label}</Pill>;
},
},
{
id: "yearBuilt",
accessorKey: "yearBuilt",
header: () => <div className="text-xs">Year Built</div>,
cell: ({ row }) => (
<div className="text-sm text-gray-700">{row.original.yearBuilt ?? "—"}</div>
),
},
{
id: "totalFloorArea",
accessorKey: "totalFloorArea",
header: () => <div className="text-xs">Floor Area (m²)</div>,
cell: ({ row }) => {
const val = row.original.totalFloorArea;
return (
<div className="text-sm text-gray-700">
{val != null ? `${val.toFixed(1)}` : "—"}
</div>
);
},
},
{
id: "co2Emissions",
accessorKey: "co2Emissions",
header: () => <div className="text-xs">CO Emissions</div>,
cell: ({ row }) => {
const val = row.original.co2Emissions;
return (
<div className="text-sm text-gray-700">
{val != null ? `${val.toFixed(1)} kg/m²/yr` : "—"}
</div>
);
},
},
{
id: "mainfuel",
accessorKey: "mainfuel",
header: () => <div className="text-xs">Main Fuel</div>,
cell: ({ row }) => {
const label = resolveEnumLabel(MAINFUEL_OPTIONS, row.original.mainfuel);
return label ? <Pill>{label}</Pill> : <span className="text-slate-300 text-xs"></span>;
},
},
];
export const columns: ColumnDef<PropertyWithRelations>[] = [
...coreColumns,
...optionalColumns,
];

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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