diff --git a/next.config.js b/next.config.js index 6088217..5d5ce4a 100644 --- a/next.config.js +++ b/next.config.js @@ -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 diff --git a/package-lock.json b/package-lock.json index 115a8d5..7b251ec 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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" } } } diff --git a/package.json b/package.json index e5fabc6..e01c7ac 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/app/actions/recommendations.ts b/src/app/actions/recommendations.ts new file mode 100644 index 0000000..8f4167a --- /dev/null +++ b/src/app/actions/recommendations.ts @@ -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 = { + 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 = { + 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}`, + ); +} diff --git a/src/app/api/plan/[id]/set-default/route.ts b/src/app/api/plan/[id]/set-default/route.ts new file mode 100644 index 0000000..ee33489 --- /dev/null +++ b/src/app/api/plan/[id]/set-default/route.ts @@ -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 }); +} diff --git a/src/app/api/properties/route.ts b/src/app/api/properties/route.ts index c0fd513..bf0239a 100644 --- a/src/app/api/properties/route.ts +++ b/src/app/api/properties/route.ts @@ -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); -} \ No newline at end of file + + // 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 }); +} diff --git a/src/app/components/building-passport/AlternativesDrawer.tsx b/src/app/components/building-passport/AlternativesDrawer.tsx new file mode 100644 index 0000000..bfac9d1 --- /dev/null +++ b/src/app/components/building-passport/AlternativesDrawer.tsx @@ -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 ( + !open && onClose()}> + + + + {categoryLabel} + +

+ {alternatives.length} option + {alternatives.length !== 1 ? "s" : ""} available — select to update + the plan +

+
+ +
+ {alternatives.map((rec) => { + const isSelected = displaySelected?.id === rec.id; + return ( + + ); + })} +
+
+
+ ); +} diff --git a/src/app/components/building-passport/RecommendationCard.tsx b/src/app/components/building-passport/RecommendationCard.tsx index d03da7f..0dc61f4 100644 --- a/src/app/components/building-passport/RecommendationCard.tsx +++ b/src/app/components/building-passport/RecommendationCard.tsx @@ -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 = { 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>; - costMap: RecommendationMetricMap; - setTotalEstimatedCost: Dispatch>; - sapMap: RecommendationMetricMap; - setSapMap: Dispatch>; - setTotalSapPoints: Dispatch>; - currentSapPoints: number; - setExpectedEpcRating: Dispatch>; - setTotalLabourDays: Dispatch>; - labourDaysMap: RecommendationMetricMap; - setLabourDaysMap: Dispatch>; - setCo2SavingsMap: Dispatch>; - co2SavingsMap: RecommendationMetricMap; - setTotalCo2Savings: Dispatch>; - setEnergyCostSavingsMap: Dispatch>; - energyCostSavingsMap: RecommendationMetricMap; - setTotalEnergyCostSavings: Dispatch>; - setKwhSavingsMap: Dispatch>; - kwhSavingsMap: RecommendationMetricMap; - setTotalKwhSavings: Dispatch>; +// 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 ; + case "roof_insulation": + case "loft_insulation": + case "room_roof_insulation": + case "flat_roof_insulation": + case "sloping_ceiling_insulation": + return ; + case "floor_insulation": + case "suspended_floor_insulation": + case "solid_floor_insulation": + case "exposed_floor_insulation": + return ; + case "windows_glazing": + case "mixed_glazing": + return ; + case "solar_pv": + return ; + case "heating": + case "secondary_heating": + case "sealing_open_fireplace": + return ; + case "hot_water_tank_insulation": + return ; + case "mechanical_ventilation": + case "trickle_vents": + return ; + case "heating_control": + case "cylinder_thermostat": + return ; + case "low_energy_lighting": + return ; + default: + return ; + } +} + +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(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 ( -
-

{getTitle()}

-
- {cardComponent ? ( - cardComponent.description - ) : ( -
No measure selected
+
+
+ {/* ── Card header ─────────────────────────────────────────── */} +
+ + {/* Left: circular icon + title block */} +
+ {/* Circular icon */} +
+ {alreadyInstalled ? ( + + ) : ( + + )} +
+ +
+
+

+ {title} +

+ {!isIncluded && !alreadyInstalled && ( + + Not included + + )} +
+ {displayRec && !alreadyInstalled && ( +

+ {displayRec.description} +

+ )} + {!displayRec && !alreadyInstalled && ( +

+ No measure selected +

+ )} + {alreadyInstalled && ( + + Already Installed + + )} +
+
+ + {/* Right: stat chips + status indicators */} +
+ {hasPendingChange && !isPending && ( + + + Pending + + )} + {isPending && ( + + + Saving… + + )} + {displayRec?.sapPoints != null && displayRec.sapPoints > 0 && ( +
+

+ SAP Boost +

+

+ +{displayRec.sapPoints.toFixed(1)} +

+
+ )} + {displayRec?.energyCostSavings != null && + displayRec.energyCostSavings > 0 && ( +
+

+ Savings +

+

+ £{formatNumber(displayRec.energyCostSavings)}/yr +

+
+ )} + {displayRec?.estimatedCost != null && displayRec.estimatedCost > 0 && ( +
+

+ Cost +

+

+ £{formatNumber(displayRec.estimatedCost)} +

+
+ )} +
+
+ + {/* ── Collapsed CTA ───────────────────────────────────────── */} + {isInteractive && !isOpen && ( + + )} + + {/* ── Expanded alternatives panel ─────────────────────────── */} + {isInteractive && isOpen && ( +
+
+ + Detailed Alternatives + +
+
+ + {/* Grid: chosen card + up to 2 alternatives */} +
+ {displaySelected && ( +
+
+

+ {displaySelected.description} +

+ + {hasPendingChange ? "Pending" : "Chosen"} + +
+ {(displaySelected.sapPoints != null && displaySelected.sapPoints > 0) || + (displaySelected.energyCostSavings != null && displaySelected.energyCostSavings > 0) ? ( +
+ {displaySelected.sapPoints != null && displaySelected.sapPoints > 0 && ( + + +{displaySelected.sapPoints.toFixed(1)} SAP + + )} + {displaySelected.energyCostSavings != null && displaySelected.energyCostSavings > 0 && ( + + £{formatNumber(displaySelected.energyCostSavings)}/yr saved + + )} +
+ ) : null} +

+ £{formatNumber(displaySelected.estimatedCost ?? 0)} +

+
+ )} + + {alternatives.slice(0, displaySelected ? 2 : 3).map((alt) => ( +
+
+

+ {alt.description} +

+ {(alt.sapPoints != null && alt.sapPoints > 0) || + (alt.energyCostSavings != null && alt.energyCostSavings > 0) ? ( +
+ {alt.sapPoints != null && alt.sapPoints > 0 && ( + + +{alt.sapPoints.toFixed(1)} SAP + + )} + {alt.energyCostSavings != null && alt.energyCostSavings > 0 && ( + + £{formatNumber(alt.energyCostSavings)}/yr saved + + )} +
+ ) : null} +
+
+

+ £{formatNumber(alt.estimatedCost ?? 0)} +

+ +
+
+ ))} +
+ + {hasMore && ( + + )} + + {/* Footer actions */} +
+ {(selected || pendingSelection) && ( + + )} + +
+
)}
-
{optionsText}
- {cardComponent ? ( - - - - - - - {cardComponent.sapPoints != null && ( - - - - - )} - -
Estimated Cost: - {cardComponent - ? "£" + formatNumber(cardComponent?.estimatedCost || 0) - : ""} -
SAP Points: - {cardComponent.sapPoints < 0.1 && - cardComponent.type !== "mechanical_ventilation" - ? "Negligible" - : cardComponent.sapPoints} -
- ) : ( - "" - )} - setIsDrawerOpen(false)} + categoryLabel={title} + recs={recs} + selected={selected} + displaySelected={pendingSelection ?? selected} + onSelect={(rec) => { + onSelect(rec); + setIsDrawerOpen(false); + }} + isPending={isPending} />
); diff --git a/src/app/components/building-passport/RecommendationContainer.tsx b/src/app/components/building-passport/RecommendationContainer.tsx index e02e2c1..b524e65 100644 --- a/src/app/components/building-passport/RecommendationContainer.tsx +++ b/src/app/components/building-passport/RecommendationContainer.tsx @@ -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 +> = { + 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, ); - 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({ - 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({ - 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({ - 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({ - 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({ - 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({ - 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 + >(() => + 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 + >({}); + + 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 ( +
+
+ +
+

+ No recommendations for this plan. +

+
+ ); + } return ( <> -
- - - - - - - +
+ {sortedCategories.map(([category, recs]) => { + const isIncluded = + !!selections[category] && !recs.some((r) => r.alreadyInstalled); + return ( + handleSelect(category, rec)} + onRemove={() => handleRemove(category)} + /> + ); + })}
- - -
- {Object.entries(categorizedRecommendations).map( - ([componentType, recommendationData], idx) => { - return ( - - ); - } - )} -
+ ); } diff --git a/src/app/components/building-passport/StickyImpactBar.tsx b/src/app/components/building-passport/StickyImpactBar.tsx new file mode 100644 index 0000000..942f27c --- /dev/null +++ b/src/app/components/building-passport/StickyImpactBar.tsx @@ -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 ( +
+
+ {/* Left: pending count badge */} + + {pendingCount} unsaved {pendingCount === 1 ? "change" : "changes"} + + + {/* Center: metric chips */} +
+ {/* SAP */} +
+
+

SAP

+
+ + {savedSap.toFixed(1)} + + + + {projectedSap.toFixed(1)} + +
+
+
+ + {/* EPC */} +
+
+

EPC

+
+ + {savedEpc} + + + + {projectedEpc} + +
+
+
+ + {/* CO₂ */} +
+

CO₂ saved

+

+ {projectedCo2.toFixed(1)} t/yr +

+
+ + {/* Heat demand — hidden when zero */} + {projectedHeatDemand > 0 && ( +
+

Heat demand

+

+ {projectedHeatDemand.toFixed(0)} kWh/yr +

+
+ )} + + {/* Cost — hidden when zero */} + {projectedCost > 0 && ( +
+

Est. Cost

+

+ £{formatNumber(projectedCost)} +

+
+ )} + + {/* Contingency — hidden when zero */} + {projectedContingency > 0 && ( +
+

Contingency

+

+ ~£{formatNumber(projectedContingency)} +

+
+ )} +
+ + {/* Right: action buttons */} +
+ {isPending && ( + + )} + + +
+
+
+ ); +} diff --git a/src/app/components/building-passport/Toolbar.tsx b/src/app/components/building-passport/Toolbar.tsx index 66670d0..5dc2570 100644 --- a/src/app/components/building-passport/Toolbar.tsx +++ b/src/app/components/building-passport/Toolbar.tsx @@ -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(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 = ( - - - Data - - ); + 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 = ( - - - Documents - - ); - - const solarAnalysisButton = ( - - - Solar - - ); - - const recommendationsButton = ( - - - Retrofit Plans - - ); - - const decentHomesButton = ( - - - Decent Homes - - ); + const handleNav = (href: string) => { + if (pathname === href) return; + setLoadingTab(href); + startTransition(() => { + router.push(href); + }); + }; return ( <> -
- {/* Left side: navigation */} - - - - Summary - +
+ {/* Tabs */} +
+ {tabs.map((tab) => { + const isActive = tab.exact + ? pathname === tab.href + : pathname.startsWith(tab.href); + const isLoading = loadingTab === tab.href && isPending; + const Icon = tab.icon; - - {preAssessmentReportButton} - {Object.keys(decentHomes).length > 0 && - decentHomes.uprn && - decentHomesButton} - {solarAnalysisButton} - {recommendationsButton} - {documentsButton} - - - Settings - - - - - {/* ✅ Right side: Book a Survey button */} -
- + return ( + + ); + })}
+ + {/* Book Assessment button */} +
- {/* ✅ Modal */} {openModal && ( )} - {/* ✅ Toast */} (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 ( <> - + {navItems.map(({ label, icon: Icon, href, match }) => { const isActive = match(pathname); + const isLoading = loadingHref === href && isPending; return ( @@ -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 ( diff --git a/src/app/db/schema/property.ts b/src/app/db/schema/property.ts index 90bfea7..3d2dd56 100644 --- a/src/app/db/schema/property.ts +++ b/src/app/db/schema/property.ts @@ -368,6 +368,19 @@ export interface PropertyWithRelations extends Record { 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< diff --git a/src/app/due-considerations/page.tsx b/src/app/due-considerations/page.tsx deleted file mode 100644 index b4d8b31..0000000 --- a/src/app/due-considerations/page.tsx +++ /dev/null @@ -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 ( -
- ); -}; - -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>; -}) => { - 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( - [] - ); - 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) { - 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 ( -
-
-
Select a folder containing:
-
    -
  • A full SAP xml
  • -
  • EPR pdf
  • -
  • Condition report word document
  • -
-
- Make sure these documents all relate to the same property -
- -
- -
- - setSchemeName(e.target.value)} - /> - -
- -
-
{uploadMessage}
- -
-
- {isGeneratingUrlLoading || isUploadLoading ? ( - - ) : downloadUrl ? ( - - Download Due Considerations - - ) : null} -
-
-
- ); -} diff --git a/src/app/eco-spreadsheet/page.tsx b/src/app/eco-spreadsheet/page.tsx deleted file mode 100644 index 8d58b7d..0000000 --- a/src/app/eco-spreadsheet/page.tsx +++ /dev/null @@ -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 ( -
- ); -}; - -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>; -}) => { - 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([]); - 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) { - 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) { - 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 ( -
-
-
Please select the following files:
-
    -
  • An xml
  • -
  • EPR pdf
  • -
  • Ventilation and Condition pdf
  • -
- -
- Make sure these documents all relate to the same property -
- -
- -
- -
{uploadMessage}
- -
- Additionally, you can upload an optional ECO Excel spreadsheet -
- if you wish to add new records to an already populated spreadsheet -
- -
- -
- -
- -
- -
-
- {isGeneratingUrlLoading || isUploadLoading ? ( - - ) : downloadUrl ? ( - - Download Eco Spreadsheet - - ) : null} -
-
-
- ); -} diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 24c014e..6b1dd09 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -5,7 +5,7 @@ import { ReactQueryProvider } from "./ReactQueryProvider"; import { AuthOptions } from "@/app/api/auth/[...nextauth]/authOptions"; import { getServerSession } from "next-auth/next"; import { cache } from "react"; -import { Inter } from "next/font/google"; +import { Inter, Manrope } from "next/font/google"; import { Toaster } from "@/app/shadcn_components/ui/toaster"; import { SpeedInsights } from "@vercel/speed-insights/next"; import type { Metadata } from "next"; @@ -16,6 +16,12 @@ const inter = Inter({ variable: "--font-inter", }); +const manrope = Manrope({ + subsets: ["latin"], + variable: "--font-manrope", + weight: ["400", "500", "700", "800"], +}); + export const metadata = { title: "Ara", description: "Ara is Domna’s portfolio intelligence platform that turns housing stock data into clear, costed retrofit and investment plans.", @@ -61,7 +67,7 @@ export default async function RootLayout({ const userImage = session?.user?.image; return ( - + diff --git a/src/app/portfolio/[slug]/(portfolio)/layout.tsx b/src/app/portfolio/[slug]/(portfolio)/layout.tsx index f997ae6..ac7be23 100644 --- a/src/app/portfolio/[slug]/(portfolio)/layout.tsx +++ b/src/app/portfolio/[slug]/(portfolio)/layout.tsx @@ -22,18 +22,20 @@ export default async function PortfolioLayout(props: { return (
-

+

{portfolioName}

-
+
+
+ {children} +
- {children}
); } diff --git a/src/app/portfolio/[slug]/(portfolio)/page.tsx b/src/app/portfolio/[slug]/(portfolio)/page.tsx index 024928b..4f9b0cd 100644 --- a/src/app/portfolio/[slug]/(portfolio)/page.tsx +++ b/src/app/portfolio/[slug]/(portfolio)/page.tsx @@ -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 ( - <> - + <> + ); -} \ No newline at end of file +} diff --git a/src/app/portfolio/[slug]/(portfolio)/reporting/BreakdownChart.tsx b/src/app/portfolio/[slug]/(portfolio)/reporting/BreakdownChart.tsx index 173e3c7..0fb720e 100644 --- a/src/app/portfolio/[slug]/(portfolio)/reporting/BreakdownChart.tsx +++ b/src/app/portfolio/[slug]/(portfolio)/reporting/BreakdownChart.tsx @@ -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" diff --git a/src/app/portfolio/[slug]/(portfolio)/reporting/ScenarioMeasuresModal.tsx b/src/app/portfolio/[slug]/(portfolio)/reporting/ScenarioMeasuresModal.tsx index cb34694..43961b3 100644 --- a/src/app/portfolio/[slug]/(portfolio)/reporting/ScenarioMeasuresModal.tsx +++ b/src/app/portfolio/[slug]/(portfolio)/reporting/ScenarioMeasuresModal.tsx @@ -179,7 +179,7 @@ export function ScenarioMeasuresModal({ data, error, }: ScenarioMeasuresModalProps) { - const measures: ScenarioMeasure[] = data?.measures ?? []; + const measures = useMemo(() => data?.measures ?? [], [data?.measures]); const grouped = useMemo(() => groupMeasuresByCategory(measures), [measures]); diff --git a/src/app/portfolio/[slug]/(portfolio)/your-projects/live/CompletionTrendsChart.tsx b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/CompletionTrendsChart.tsx index d869e97..dfcd492 100644 --- a/src/app/portfolio/[slug]/(portfolio)/your-projects/live/CompletionTrendsChart.tsx +++ b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/CompletionTrendsChart.tsx @@ -454,7 +454,7 @@ export default function CompletionTrendsChart({ dataKey={isStacked ? "_total" : cat} position="top" style={{ fontSize: 10, fill: "#6b7280", fontWeight: 500 }} - formatter={(v: number) => (v === 0 ? "" : v)} + formatter={(v: unknown) => (v === 0 ? "" : String(v))} /> )} @@ -466,13 +466,16 @@ export default function CompletionTrendsChart({ {isStacked && ( ({ - value: cat, - type: "square" as const, - color: colors[i], - }))} + content={() => ( +
    + {categories.map((cat, i) => ( +
  • + + {cat} +
  • + ))} +
+ )} /> )} diff --git a/src/app/portfolio/[slug]/building-passport/[propertyId]/HeritageTooltip.tsx b/src/app/portfolio/[slug]/building-passport/[propertyId]/HeritageTooltip.tsx new file mode 100644 index 0000000..588a3b4 --- /dev/null +++ b/src/app/portfolio/[slug]/building-passport/[propertyId]/HeritageTooltip.tsx @@ -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 ( + + + + + + +
+

Planning Restrictions

+

Conservation, listed & heritage properties

+
+
+

+ Properties in a conservation area or with{" "} + listed or{" "} + heritage status may have restrictions on + certain improvement measures, including: +

+
    +
  • Solar panel installation
  • +
  • External wall insulation
  • +
  • Alterations to windows, doors, or roof materials
  • +
+
+
+

+ Always consult your local planning authority to + confirm which measures are permitted before commissioning any works. +

+
+
+
+
+ ); +} diff --git a/src/app/portfolio/[slug]/building-passport/[propertyId]/assessment/EpcInfoTooltip.tsx b/src/app/portfolio/[slug]/building-passport/[propertyId]/assessment/EpcInfoTooltip.tsx new file mode 100644 index 0000000..bdc7d17 --- /dev/null +++ b/src/app/portfolio/[slug]/building-passport/[propertyId]/assessment/EpcInfoTooltip.tsx @@ -0,0 +1,67 @@ +"use client"; + +import { QuestionMarkCircleIcon } from "@heroicons/react/24/outline"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@/app/shadcn_components/ui/tooltip"; + +const EPC_BANDS = [ + { band: "A", range: "92–100", color: "#117d58", desc: "Exceptional, near-zero energy bills, usually new-builds or eco-homes." }, + { band: "B", range: "81–91", color: "#2da55c", desc: "Very efficient, often featuring solar panels, high-grade insulation, and modern heating." }, + { band: "C", range: "69–80", color: "#8dbd40", desc: "Good, above-average efficiency; common target for retrofitting existing homes." }, + { band: "D", range: "55–68", color: "#f7cd14", desc: "Average, the typical rating for many homes in the UK." }, + { band: "E", range: "39–54", color: "#f3a96a", desc: "Below average, likely requires better insulation and boiler upgrades." }, + { band: "F", range: "21–38", color: "#ef8026", desc: "Poor, high energy costs and lower energy performance." }, + { band: "G", range: "1–20", color: "#e41e3b", desc: "Very poor, least efficient, high energy costs." }, +]; + +export function EpcInfoTooltip() { + return ( + + + + + + +
+

EPC Rating Bands

+

Based on the SAP score (1–100)

+
+
+ {EPC_BANDS.map(({ band, range, color, desc }) => ( +
+ + {band} + +
+

{range}

+

{desc}

+
+
+ ))} +
+
+

+ SAP score — Standard Assessment Procedure. A government-approved method for rating the energy performance of homes on a scale of 1 to 100. +

+
+
+
+
+ ); +} diff --git a/src/app/portfolio/[slug]/building-passport/[propertyId]/assessment/page.tsx b/src/app/portfolio/[slug]/building-passport/[propertyId]/assessment/page.tsx index 084954b..78d7b26 100644 --- a/src/app/portfolio/[slug]/building-passport/[propertyId]/assessment/page.tsx +++ b/src/app/portfolio/[slug]/building-passport/[propertyId]/assessment/page.tsx @@ -1,189 +1,643 @@ -import EpcCard from "@/app/components/building-passport/EpcCard"; -import FeatureTable from "@/app/components/building-passport/FeatureTable"; import { - PropertyDetailsEpc, - PropertyDetailsSpatial, - PropertyMeta, -} from "@/app/db/schema/property"; + BoltIcon, + SunIcon, + Squares2X2Icon, + SparklesIcon, +} from "@heroicons/react/24/outline"; +import { Card, CardContent, CardHeader, CardTitle } from "@/app/shadcn_components/ui/card"; +import { Separator } from "@/app/shadcn_components/ui/separator"; import { formatDateTime } from "@/app/utils"; import { - generalColumns, - nonInstrusiveColumns, - retrofitColumns, -} from "@/app/components/building-passport/FeatureTableColumns"; -import { - formatGeneralFeatures, - formatHeatDemandFeatures, formatRetrofitFeatures, - getConditionReport, - getPropertyMeta, - getSpatialData, + formatGeneralFeatures, getNonIntrusiveSurvey, - getDocument, - getEnergyAssessmentFromS3, + getPropertyMeta, + getConditionReport, + getSpatialData, } from "../utils"; +import { getSolarData, getSolarScenarioData } from "../solar-analysis/utils"; +import PropertyMapWrapper from "../solar-analysis/PropertyMapWrapper"; +import SolarSimulationWrapper from "../solar-analysis/SolarSimulationWrapper"; +import { EpcInfoTooltip } from "./EpcInfoTooltip"; -interface PropertyDetailsCardProps { - conditionReportData: PropertyDetailsEpc; - propertyMeta: PropertyMeta; - propertyDetailsSpatial: PropertyDetailsSpatial; +// ── Helpers ──────────────────────────────────────────────────────────────────── + +function getEpcHex(letter: string | null | undefined): string { + switch (letter?.toUpperCase()) { + case "A": return "#117d58"; + case "B": return "#2da55c"; + case "C": return "#8dbd40"; + case "D": return "#f7cd14"; + case "E": return "#f3a96a"; + case "F": return "#ef8026"; + case "G": return "#e41e3b"; + default: return "#9ca3af"; + } } -const rowTitleStyle = "text-brandblue align-top pb-3"; -const rowValueStyle = "text-brandblue text-end pr-8 pt-1 align-top pb-3"; +function getEpcDescription(letter: string | null | undefined): string { + switch (letter?.toUpperCase()) { + case "A": + case "B": return "This property is performing at or above modern energy standards."; + case "C": return "This property meets modern energy performance benchmarks."; + case "D": return "This property is performing slightly below modern energy standards."; + case "E": return "This property is performing below modern energy standards."; + case "F": + case "G": return "This property is performing significantly below modern energy standards."; + default: return "Energy performance data is not yet available for this property."; + } +} -function PropertyDetailsCard({ - conditionReportData, - propertyMeta, - propertyDetailsSpatial, -}: PropertyDetailsCardProps) { - const propertyText = [propertyMeta.builtForm, propertyMeta.propertyType] - .filter(Boolean) - .join(" "); +function getDirectionLabel(az: number): { label: string; short: string } { + const norm = ((az % 360) + 360) % 360; + if (norm >= 337.5 || norm < 22.5) return { short: "N", label: "North" }; + if (norm < 67.5) return { short: "NE", label: "North-East" }; + if (norm < 112.5) return { short: "E", label: "East" }; + if (norm < 157.5) return { short: "SE", label: "South-East" }; + if (norm < 202.5) return { short: "S", label: "South" }; + if (norm < 247.5) return { short: "SW", label: "South-West" }; + if (norm < 292.5) return { short: "W", label: "West" }; + return { short: "NW", label: "North-West" }; +} + +function getRatingClasses(rating: string): string { + switch (rating) { + case "Very good": return "bg-green-600 text-white"; + case "Good": return "bg-green-100 text-green-800"; + case "Poor": return "bg-surface-container text-on-surface-variant"; + case "Very poor": return "bg-error-container text-error"; + default: return "bg-gray-100 text-gray-400"; + } +} + +function formatDate(date: Date): string { + return new Date(date).toLocaleDateString("en-GB", { + weekday: "long", + year: "numeric", + month: "long", + day: "numeric", + }); +} + +const SEGMENT_THEMES: Record = { + 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 ( -
-
-
- - - - - - - - - - - - - - - - - - - - - - - - - - - -
Year built:{propertyMeta.yearBuilt}
Property Type:{propertyText}
Total floor area: - {`${conditionReportData.totalFloorArea} m`} - 2 -
In conservation area: - {propertyDetailsSpatial.conservationStatus ? "Yes" : "No"} -
Is listed: - {propertyDetailsSpatial.isListedBuilding ? "Yes" : "No"} -
Is heritage: - {propertyDetailsSpatial.isHeritageBuilding ? "Yes" : "No"} -
+
+
+
+
+

+ Segment {index + 1} +

+

+ {dir.label} +

+
+
+ + {dir.short} +
- - - - - - - - - - - - - - - - - - - -
Local Authority:{propertyMeta.localAuthority}
Constituency:{propertyMeta.constituency}
Tenure{propertyMeta.tenure}
Number of Habitable Rooms: - {propertyMeta.numberOfRooms || "unkown"} -
+ {medianSunshine !== null && ( +
+
+ Median sunshine + {Math.round(medianSunshine)} hrs/yr +
+
+
+
+
+ )}
+ +
+ {[ + { label: "Roof area", value: `${areaMeters2.toFixed(1)} m²` }, + { label: "Ground area", value: `${groundAreaMeters2.toFixed(1)} m²` }, + { label: "Pitch", value: `${pitchDegrees.toFixed(1)}°` }, + { label: "Azimuth", value: `${azimuthDegrees.toFixed(1)}°` }, + { label: "Height", value: `${planeHeightAtCenterMeters.toFixed(1)} m` }, + peakSunshine !== null + ? { label: "Peak (P90)", value: `${Math.round(peakSunshine)} hrs` } + : { label: "", value: "" }, + ].map(({ label, value }, i) => + label ? ( +
+
{label}
+
{value}
+
+ ) : ( +
+ ) + )} +
); } -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 ( -
-
- Last updated: {formatDateTime(propertyMeta.updatedAt)} -
-
-
- + const retrofitFeatures = formatRetrofitFeatures(conditionReport); + const fundamentalDetails = [ + { feature: "Property type", description: propertyMeta.propertyType ?? "Unknown" }, + { feature: "Built form", description: propertyMeta.builtForm ?? "Unknown" }, + { feature: "Floor area", description: conditionReport.totalFloorArea != null ? `${Math.round(conditionReport.totalFloorArea)} m²` : "Unknown" }, + { feature: "Age", description: propertyMeta.yearBuilt != null ? String(propertyMeta.yearBuilt) : "Unknown" }, + ]; + const generalFeatures = [ + ...fundamentalDetails, + ...formatGeneralFeatures(conditionReport, propertyMeta.propertyType) + .filter((f) => !["Mains gas", "Year built", "Property type", "Floor area", "Habitable rooms"].includes(f.feature)) + .slice(0, 4), + ]; - = { + 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 ( +
+ + {/* ── Page Header ─────────────────────────────────────────────────────── */} +
+
+

+ Structural Analysis +

+

+ Property Details +

+
+

+ Last updated: {formatDateTime(propertyMeta.updatedAt)} +

+
+ + {/* ── Row 1: EPC hero + energy metrics + general features ──────────── */} +
+ + {/* EPC Hero — matches overview page style */} +
+
+
+
+

+ Current Efficiency State +

+ +
+
+ + {epcLetter ?? "—"} + + + / {sapScore || "—"} + +
+

+ {getEpcDescription(epcLetter)} +

+
+
+
+
+
+
+ Very Inefficient + Very Efficient +
+
+
+ + {/* Right column: 3 metric cards + general features grid */} +
+
+
+

+ Energy Demand +

+
+

+ {conditionReport.currentEnergyDemand != null + ? Number(conditionReport.currentEnergyDemand).toFixed(0) + : "—"} +

+

kWh / year

+
+
+
+

+ CO₂ Emissions +

+
+

+ {conditionReport.co2Emissions ?? "—"} +

+

tonnes / year

+
+
+
+

+ Primary Energy +

+
+

+ {conditionReport.primaryEnergyConsumption ?? "—"} +

+

kWh / m² / year

+
+
+
+ + {/* General features grid — fills remaining height */} + {generalFeatures.length > 0 && ( +
+

+ General Features +

+
+ {generalFeatures.map((f) => { + const desc = String(f.description ?? ""); + const isUnknown = desc === "Unknown" || desc === ""; + return ( +
+

+ {f.feature} +

+

+ {isUnknown ? "Unknown" : desc} +

+
+ ); + })} +
+
+ )}
+ {/* ── Row 2: Fabric table (full width) ─────────────────────────────────── */} +
+

+ Existing Infrastructure Details +

+
+ + + + + + + + + + {retrofitFeatures.map((f) => ( + + + + + + ))} + +
FeatureDescriptionRating
+ {f.feature} + {f.description} + + {f.rating} + +
+
+
+ + {/* ── Row 3: Non-Intrusive Survey ──────────────────────────────────────── */} {nonIntrusiveSurvey && ( -
-
Non-Intrusive Survey
-
- Conducted by: {nonIntrusiveSurvey.surveyor} on{" "} - {formatDate(nonIntrusiveSurvey.surveyDate)} +
+
+

+ Non-Intrusive Survey +

+

+ Conducted by{" "} + {nonIntrusiveSurvey.surveyor} + {" "}on {formatDate(nonIntrusiveSurvey.surveyDate)} +

+
+ + + + + + + + + {nonIntrusiveSurvey.notes.map((note: { title: string; note: string }, i: number) => ( + + + + + ))} + +
FeatureRecorded Observation
{note.title}{note.note}
+
+ )} + + {/* ── Solar Section ────────────────────────────────────────────────────── */} + + {/* Solar section header — always shown if we have coords */} + {(solarData || spatial?.latitude) && ( +
+
+ +
+
+

+ Solar Potential Analysis +

-
)} -
General Features
- -
Existing Property Features
- -
Heating Demand
- + + {solarData && sp ? ( + <> + {/* Row 4: Solar info + Rooftop Summary side by side, then map below */} +
+ + {/* Left: scenario context + imagery info + rooftop summary */} +
+ + {/* Scenario / imagery context card */} +
+

+ {solarScenarioData?.scenrioType === "building" + ? "Figures represent the building as a whole." + : "Figures represent this individual unit."} +

+
+ {imageryQuality && ( + + {qualityText} + + )} + {imageryDateStr && ( + + Imagery: {imageryDateStr} + + )} +
+
+ + {/* Rooftop Summary */} + + + + Rooftop Summary + +

+ Key metrics extracted from aerial imagery analysis. +

+
+ +
+ {[ + { + icon: , + label: "Max annual output", + value: `${maxAnnualKwh.toLocaleString()} kWh`, + }, + { + icon: , + label: "Max panel count", + value: `${sp.maxArrayPanelsCount} panels`, + }, + { + icon: , + label: "Max array area", + value: `${sp.maxArrayAreaMeters2.toFixed(0)} m²`, + }, + { + icon: , + label: "Roof faces identified", + value: `${roofSegmentStats.length}`, + }, + { + icon: , + label: "Max sunshine hours", + value: `${Math.round(sp.maxSunshineHoursPerYear).toLocaleString()} hrs/yr`, + }, + { + icon: , + label: "Carbon offset factor", + value: `${Math.round(sp.carbonOffsetFactorKgPerMwh)} kg/MWh`, + }, + { + icon: , + label: "Panel dimensions", + value: `${sp.panelWidthMeters} m × ${sp.panelHeightMeters} m`, + }, + { + icon: , + label: "Panel capacity", + value: `${sp.panelCapacityWatts} W`, + }, + ].map(({ icon, label, value }, i) => ( +
+
+ + {icon} + + {label} +
+ {value} +
+ ))} +
+
+
+
+ + {/* Right: Map */} +
+ +
+
+ + {/* Row 5: Roof Profile */} + {roofSegmentStats.length > 0 && ( +
+
+

+ Roof Profile +

+

+ {roofSegmentStats.length} roof face{roofSegmentStats.length !== 1 ? "s" : ""} identified. + South-facing segments with low pitch typically yield the highest solar output. +

+
+
+ {roofSegmentStats.map((seg: any, i: number) => ( + + ))} +
+
+ )} + + {/* Row 6: Solar Simulation */} +
+

+ Solar Configurations +

+

+ 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. +

+ +
+ + ) : (spatial?.latitude && spatial?.longitude) ? ( + /* No solar data — show map with annotation */ +
+
+ +
+
+

Solar data unavailable

+

+ 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. +

+
+
+
+
+
+ +
+

Solar Analysis Not Available

+

+ 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. +

+
+
+ ) : null} +
); } diff --git a/src/app/portfolio/[slug]/building-passport/[propertyId]/decent-homes/DecentHomesSummary.tsx b/src/app/portfolio/[slug]/building-passport/[propertyId]/decent-homes/DecentHomesSummary.tsx new file mode 100644 index 0000000..da2810b --- /dev/null +++ b/src/app/portfolio/[slug]/building-passport/[propertyId]/decent-homes/DecentHomesSummary.tsx @@ -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 = { + 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 = { + "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 ; + if (status === "fail") return ; + return ; +} + +function getStatusDot(status: string) { + const colorMap: Record = { + pass: "bg-emerald-500", + fail: "bg-red-500", + no_data: "bg-gray-300", + }; + return ; +} + +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 ( + + + Pass + + ); + } + if (status === "fail") { + return ( + + + Fail + + ); + } + return ( + + + No Data + + ); +} + +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 = { + Overdue: [], + "0–6 months": [], + "6–12 months": [], + ">12 months": [], + }; + for (const e of entries) { + const diffMs = e.expiry.getTime() - Date.now(); + const diffMonths = diffMs / (1000 * 60 * 60 * 24 * 30); + if (e.overdue) groups.Overdue.push(e); + else if (diffMonths <= 6) groups["0–6 months"].push(e); + else if (diffMonths <= 12) groups["6–12 months"].push(e); + else groups[">12 months"].push(e); + } + return groups; +} + +// ── Sub-components ──────────────────────────────────────────────────────────── + +function CriterionCard({ + letter, + label, + description, + icon, + status, + items, +}: { + letter: string; + label: string; + description: string; + icon: ReactNode; + status: string; + items: { sub_variable: string; result: string }[]; +}) { + const sorted = [...items].sort((a, b) => { + const order = { fail: 0, no_data: 1, pass: 2 }; + return (order[a.result as keyof typeof order] ?? 1) - (order[b.result as keyof typeof order] ?? 1); + }); + + const failCount = items.filter((i) => i.result === "fail").length; + const passCount = items.filter((i) => i.result === "pass").length; + const notAssessedCount = items.length - passCount - failCount; + + return ( +
+ {/* Card header */} +
+
+
+ {icon} +
+
+

+ Criterion {letter} +

+

{label}

+
+
+ {getCriterionStatusBadge(status)} +
+ + {/* Description */} +

{description}

+ + {/* Stats row */} +
+ + + {passCount} pass + + · + + + {failCount} fail + + {notAssessedCount > 0 && ( + <> + · + + + {notAssessedCount} not assessed + + + )} +
+ + {/* Scrollable item list */} +
+
+ {sorted.map((item, idx) => ( +
+ + {DISPLAY_NAMES[item.sub_variable] ?? item.sub_variable} + + {getStatusIcon(item.result)} +
+ ))} +
+ {/* Gradient fade to indicate more items below */} +
+
+
+ ); +} + +function ReplacementsSection({ items }: { items: MetaItem[] }) { + const entries = parseReplacements(items); + const groups = groupReplacements(entries); + + const urgencyConfig: Record = { + Overdue: { + border: "border-red-200", + headerBg: "bg-red-50", + badge: "bg-red-100 text-red-800 border-red-200", + icon: , + label: "Overdue", + }, + "0–6 months": { + border: "border-orange-200", + headerBg: "bg-orange-50", + badge: "bg-orange-100 text-orange-800 border-orange-200", + icon: , + label: "0–6 months", + }, + "6–12 months": { + border: "border-yellow-200", + headerBg: "bg-yellow-50", + badge: "bg-yellow-100 text-yellow-800 border-yellow-200", + icon: , + label: "6–12 months", + }, + ">12 months": { + border: "border-emerald-200", + headerBg: "bg-emerald-50", + badge: "bg-emerald-100 text-emerald-800 border-emerald-200", + icon: , + label: ">12 months", + }, + }; + + const order = ["Overdue", "0–6 months", "6–12 months", ">12 months"] as const; + + return ( +
+ {order.map((urgency) => { + const cfg = urgencyConfig[urgency]; + const list = groups[urgency]; + return ( +
+ {/* Column header */} +
+
+ {cfg.icon} + {cfg.label} +
+ + {list.length} + +
+ + {/* Scrollable body */} +
+
+ {list.length === 0 ? ( +

None

+ ) : ( + list.map((entry, idx) => ( +
+

{entry.label}

+

+ {entry.remaining} +

+
+

+ Installed: {entry.install.toLocaleDateString("en-GB")} +

+

+ Expires: {entry.expiry.toLocaleDateString("en-GB")} +

+
+
+ )) + )} +
+ {list.length > 0 && ( +
+ )} +
+
+ ); + })} +
+ ); +} + +// ── 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: , + statusKey: "criterion_a", + }, + { + key: "B", + letter: "B", + label: "State of Repair", + description: "The home is in a reasonable state of repair", + icon: , + statusKey: "criterion_b", + }, + { + key: "C", + letter: "C", + label: "Modern Facilities", + description: "Has reasonable modern facilities and services", + icon: , + statusKey: "criterion_c", + }, + { + key: "D", + letter: "D", + label: "Thermal Comfort", + description: "Provides a reasonable degree of thermal comfort", + icon: , + 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 = { + 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 = { + A: decentHomes.criterion_a, + B: decentHomes.criterion_b, + C: decentHomes.criterion_c, + D: decentHomes.criterion_d, + }; + + return ( +
+ + {/* ── Page header ──────────────────────────────────────────────────── */} +
+

+ Housing Standards +

+

+ Decent Homes Assessment +

+
+ + {/* ── Hero card ────────────────────────────────────────────────────── */} +
+
+ {overallStatus === "pass" ? ( + + ) : overallStatus === "fail" ? ( + + ) : ( + + )} +
+

+ {overallLabel} +

+

+ Decent Homes Standard assessment · Last updated {lastUpdated} +

+
+
+ + {/* Criteria summary pills */} +
+ {CRITERIA.map((c) => { + const s = criterionStatus[c.letter]; + return ( +
+ {getStatusDot(s)} + Criterion {c.letter} + {c.label} +
+ ); + })} +
+
+ + {/* ── Criteria cards ───────────────────────────────────────────────── */} +
+
+

+ Assessment Criteria +

+
+
+ {CRITERIA.map((c) => ( + + ))} +
+
+ + {/* ── Replacements section ─────────────────────────────────────────── */} +
+
+

+ Component Replacements +

+ {overdueCount > 0 && ( + + {overdueCount} overdue + + )} +
+ +
+ +
+ ); +} diff --git a/src/app/portfolio/[slug]/building-passport/[propertyId]/decent-homes/page.tsx b/src/app/portfolio/[slug]/building-passport/[propertyId]/decent-homes/page.tsx index 2d17f52..1211fe5 100644 --- a/src/app/portfolio/[slug]/building-passport/[propertyId]/decent-homes/page.tsx +++ b/src/app/portfolio/[slug]/building-passport/[propertyId]/decent-homes/page.tsx @@ -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 = { - // 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 = { - // 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 = { - pass: "Pass", - fail: "Fail", - no_data: "Not Assessed", -}; - -const OVERALL_LABEL_MAP: Record = { - pass: "Pass", - fail: "Fail", - no_data: "Information Missing", -}; - -const OVERALL_LABEL_COLORS: Record = { - 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 ( - - {LABEL_MAP[status]} - - ); -} - -// urgency badge -function UrgencyBadge({ label }: { label: string }) { - const colorMap: Record = { - Overdue: "bg-red-700", - "0–6 months": "bg-orange-500", - "6–12 months": "bg-yellow-500", - ">12 months": "bg-green-600", - }; - return ( - - {label} - - ); -} - -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 ( - - - {title} - - -
    - {sortedItems.map((item, idx) => ( -
  • - - {DISPLAY_NAMES[item.sub_variable] ?? item.sub_variable} - - -
  • - ))} -
-
-
- - ); -} - -function ReplacementsContent({ - items, -}: { - items: { - sub_variable: string; - expiry_date: string | null; - install_date: string | null; - }[]; -}) { - const today = new Date(); - const groups: Record< - string, - { - sub_variable: string; - expiry: Date; - install: Date; - remaining: string; - overdue: boolean; - }[] - > = { - Overdue: [], - "0–6 months": [], - "6–12 months": [], - ">12 months": [], - }; - - items.forEach((item) => { - if (!item.expiry_date || !item.install_date) return; - const expiry = new Date(item.expiry_date); - const install = new Date(item.install_date); - const diffMs = expiry.getTime() - today.getTime(); - const diffMonths = diffMs / (1000 * 60 * 60 * 24 * 30); - - const years = Math.floor(Math.abs(diffMonths) / 12); - const months = Math.floor(Math.abs(diffMonths) % 12); - - let remaining = ""; - let overdue = false; - if (diffMs < 0) { - overdue = true; - remaining = `Expired ${years > 0 ? `${years}y ` : ""}${months}m ago`; - } else { - remaining = `${years > 0 ? `${years}y ` : ""}${months}m remaining`; - } - - const entry = { - sub_variable: SUB_ITEMS_TEXT[item.sub_variable] ?? item.sub_variable, - expiry, - install, - remaining, - overdue, - }; - - if (diffMs < 0) groups.Overdue.push(entry); - else if (diffMonths <= 6) groups["0–6 months"].push(entry); - else if (diffMonths <= 12) groups["6–12 months"].push(entry); - else groups[">12 months"].push(entry); - }); - - // sort within each group - Object.values(groups).forEach((comps) => - comps.sort((a, b) => a.expiry.getTime() - b.expiry.getTime()), - ); - - const groupOrder: (keyof typeof groups)[] = [ - "Overdue", - "0–6 months", - "6–12 months", - ">12 months", - ]; - - // urgency → card highlight color + icon - const cardStyles: Record = { - Overdue: { - border: "border-l-4 border-red-600", - icon: , - }, - "0–6 months": { - border: "border-l-4 border-orange-500", - icon: , - }, - "6–12 months": { - border: "border-l-4 border-yellow-500", - icon: , - }, - ">12 months": { - border: "border-l-4 border-green-600", - icon: , - }, - }; - - return ( - - - - Upcoming Replacements - - - - {groupOrder.map((urgency) => - groups[urgency].length > 0 ? ( -
- {/* group header */} -
- - - {groups[urgency].length}{" "} - {groups[urgency].length > 1 ? "items" : "item"} - -
-
- {groups[urgency].map((comp, idx) => ( -
-
- - {cardStyles[urgency].icon} - {comp.sub_variable} - -
-
- {comp.remaining} -
- - {`Installed: ${comp.install.toLocaleDateString("en-GB")}`} - - - {`Expired: ${comp.expiry.toLocaleDateString("en-GB")}`} - -
-
-
- ))} -
-
- ) : null, - )} -
-
- - ); -} - -function StatusCircle({ status }: { status: string }) { - const colorMap: Record = { - pass: "bg-green-600", - fail: "bg-red-700", - no_data: "bg-gray-500", - }; - return
; -} - -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 ( -
- - - - Decent Homes Assessment - - - - - {OVERALL_LABEL_MAP[overallPass]} - -

Last updated: {lastUpdated}

-
-
- - - - -
Criterion A
- -
- -
Criterion B
- -
- -
Criterion C
- -
- -
Criterion D
- -
- - - Replacements - {soonCount > 0 && ( - - {soonCount} - - )} - -
- - - - - - - - - - - - - - - - -
-
- ); -} +import DecentHomesSummary from "./DecentHomesSummary"; export default async function DecentHomesPage(props: { params: Promise<{ slug: string; propertyId: string }>; diff --git a/src/app/portfolio/[slug]/building-passport/[propertyId]/documents/DocumentsClient.tsx b/src/app/portfolio/[slug]/building-passport/[propertyId]/documents/DocumentsClient.tsx new file mode 100644 index 0000000..471b336 --- /dev/null +++ b/src/app/portfolio/[slug]/building-passport/[propertyId]/documents/DocumentsClient.tsx @@ -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 = { + 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 { + 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 ( +
+
+ +
+
+

+ {filename} +

+

+ {label} · {date} +

+
+ +
+ ); + } + + return ( +
+
+
+ +
+
+

+ {label} +

+

+ {filename} +

+
+ {date} + +
+
+ ); +} + +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 ( +
+ {/* Header */} +
+

+ Documents +

+
+ + {/* Controls */} +
+ {/* Filter pills */} +
+ + {presentGroups.map((g) => ( + + ))} +
+ + {/* View toggle */} +
+ + +
+
+ + {/* Empty state */} + {filteredDocs.length === 0 && ( +
+ No documents found. +
+ )} + + {/* Document groups */} + {filteredDocs.length > 0 && ( +
+ {groupsToShow.map((group) => { + const groupDocs = filteredDocs.filter((d) => + group.types.includes(d.docType) + ); + if (groupDocs.length === 0) return null; + return ( +
+ {/* Section header */} +
+

+ {group.label} +

+
+
+ + {/* Cards */} + {viewMode === "grid" ? ( +
+ {groupDocs.map((doc) => ( + + ))} +
+ ) : ( +
+ {groupDocs.map((doc) => ( + + ))} +
+ )} +
+ ); + })} +
+ )} +
+ ); +} diff --git a/src/app/portfolio/[slug]/building-passport/[propertyId]/documents/page.tsx b/src/app/portfolio/[slug]/building-passport/[propertyId]/documents/page.tsx index 4901fa0..07948e4 100644 --- a/src/app/portfolio/[slug]/building-passport/[propertyId]/documents/page.tsx +++ b/src/app/portfolio/[slug]/building-passport/[propertyId]/documents/page.tsx @@ -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 { - const result = surveyDB.query.uploadedFiles.findMany({ - where: eq(uploadedFiles.uprn, String(uprn)), - }); - - return result; -} - -async function getSurveyorDocuments( - portfolioId: string, - propertyId: string -): Promise { - 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 ( - <> -
-
- Core Survey Documents -
-
- -
-
- -
- Surveyor Uploaded Documents -
-
- -
-
- -
- Coordination -
-
- +
+ +
); } diff --git a/src/app/portfolio/[slug]/building-passport/[propertyId]/layout.tsx b/src/app/portfolio/[slug]/building-passport/[propertyId]/layout.tsx index 7b5b333..a4c68a4 100644 --- a/src/app/portfolio/[slug]/building-passport/[propertyId]/layout.tsx +++ b/src/app/portfolio/[slug]/building-passport/[propertyId]/layout.tsx @@ -57,7 +57,7 @@ export default async function DashboardLayout(props: {

{propertyMeta.postcode}

-
+
; +// ── Helpers ─────────────────────────────────────────────────────────────────── + +function formatGbp(value: number | null | undefined): string { + if (value == null) return "—"; + return `£${Math.round(value).toLocaleString("en-GB")}`; +} + +/** Map EPC letter to its hex color from the project's palette */ +function getEpcHex(letter: string | null | undefined): string { + switch (letter?.toUpperCase()) { + case "A": return "#117d58"; + case "B": return "#2da55c"; + case "C": return "#8dbd40"; + case "D": return "#f7cd14"; + case "E": return "#f3a96a"; + case "F": return "#ef8026"; + case "G": return "#e41e3b"; + default: return "#9ca3af"; } -) { +} + +function getEpcDescription(letter: string | null | undefined): string { + switch (letter?.toUpperCase()) { + case "A": + case "B": return "This property is performing at or above modern energy standards."; + case "C": return "This property meets modern energy performance benchmarks."; + case "D": return "This property is performing slightly below modern energy standards."; + case "E": return "This property is performing below modern energy standards."; + case "F": + case "G": return "This property is performing significantly below modern energy standards."; + default: return "Energy performance data is not yet available for this property."; + } +} + +// ── Sub-components ──────────────────────────────────────────────────────────── + +function SectionHeading({ icon, label }: { icon: React.ReactNode; label: string }) { + return ( +
+ {icon} +

{label}

+
+ ); +} + +function YesNoBadge({ value }: { value: boolean }) { + return value ? ( + + + Yes + + ) : ( + + + No + + ); +} + +function DetailRow({ label, value }: { label: string; value: React.ReactNode }) { + return ( +
+ {label} + {value} +
+ ); +} + +// ── Page ────────────────────────────────────────────────────────────────────── + +export default async function BuildingPassportHome(props: { + params: Promise<{ slug: string; propertyId: string }>; +}) { const params = await props.params; - // This is a server component and because we make the exact same request in the layout, - // the response is cached so we just gain access to the data const propertyMeta = await getPropertyMeta(params.propertyId); + const conditionReport = await getConditionReport(params.propertyId); + const spatial = await getSpatialData(propertyMeta.uprn); + const installedMeasures = await getInstalledMeasuresByUprn(Number(propertyMeta.uprn)); + + const annualEnergyCost = + (conditionReport.heatingEnergyCostCurrent ?? 0) + + (conditionReport.hotWaterEnergyCostCurrent ?? 0) + + (conditionReport.lightingEnergyCostCurrent ?? 0); + + const epcLetter = propertyMeta.currentEpcRating ?? null; + const sapScore = propertyMeta.currentSapPoints ?? 0; + const epcHex = getEpcHex(epcLetter); return ( -
-
- -
-
Your property
-
- -
Building Passport Created At:
-
{formatDateTime(propertyMeta.createdAt)}
+
+ + {/* ── Row 1: EPC Hero + Energy Stats ──────────────────────────────── */} +
+ + {/* EPC Hero */} +
+
+
+

+ Current Efficiency State +

+
+ + {epcLetter ?? "—"} + + + / {sapScore || "—"} + +
+

+ {getEpcDescription(epcLetter)} +

-
- -
Property Type:
-
{propertyMeta.propertyType}
+
+
+
+
+
+ Very Inefficient + Very Efficient +
-
- -
Built Form:
-
{propertyMeta.builtForm}
+
+ + {/* Energy Stats + Heritage Status */} +
+ + {/* 3 stat cards */} +
+ + {/* Stat: Energy Demand */} +
+ +
+

Energy Demand

+

+ {conditionReport.currentEnergyDemand != null + ? Math.round(conditionReport.currentEnergyDemand).toLocaleString("en-GB") + : "—"} + kWh/yr +

+
+
+ + {/* Stat: CO₂ Emissions */} +
+ +
+

CO₂ Emissions

+

+ {conditionReport.co2Emissions != null + ? conditionReport.co2Emissions.toFixed(1) + : "—"} + t CO₂/yr +

+
+
+ + {/* Stat: Annual Bills */} +
+ +
+

Est. Annual Bills

+

+ {annualEnergyCost > 0 ? `£${Math.round(annualEnergyCost).toLocaleString("en-GB")}` : "—"} +

+
+
-
- -
Year Built:
-
{propertyMeta.yearBuilt}
-
-
- -
Tenure:
-
{propertyMeta.tenure}
-
-
- -
Number of Habitable Rooms:
-
{propertyMeta.numberOfRooms}
+ + {/* Heritage Status — fills remaining height to match EPC card */} +
+
+
+ + + +

Heritage & Planning Status

+ +
+
+
+

Conservation Area

+ +

+ {spatial.conservationStatus + ? "This property falls within a designated conservation area." + : "No conservation area restrictions apply to this property."} +

+
+
+

Listed Building

+ +

+ {spatial.isListedBuilding + ? "This property is a listed building with statutory protections." + : "This property does not have listed building status."} +

+
+
+

Heritage Building

+ +

+ {spatial.isHeritageBuilding + ? "This property is recognised as a heritage asset." + : "No heritage asset designation applies to this property."} +

+
+
+
+ + {/* ── Row 2: Property Details Grid ────────────────────────────────── */} +
+ } + label="Property Details" + /> +
+ + {/* Building */} +
+

Building

+ + + {Math.round(conditionReport.totalFloorArea)} m² + : "—" + } + /> + + + + : "—" + } + /> +
+ + {/* Location & Status */} +
+

Location & Status

+ + + +
+ + {/* Annual Energy Costs */} +
+

Annual Energy Costs

+ + + + + {annualEnergyCost > 0 && ( +
+ Total (excl. appliances) + + £{Math.round(annualEnergyCost).toLocaleString("en-GB")} + +
+ )} +
+
+
+ + {/* ── Row 3: Installed Measures ────────────────────────────────────── */} + {installedMeasures.length > 0 && ( +
+ } + label="Installed Measures" + /> +
+ {installedMeasures.map((measure, i) => ( +
+
+ + + + {measure.measureType} +
+ {measure.installedAt && ( +

+ Installed {new Date(measure.installedAt).toLocaleDateString("en-GB", { month: "short", year: "numeric" })} +

+ )} +
+ {measure.kwhSavings != null && ( +
+

kWh saved

+

+ {Math.round(measure.kwhSavings).toLocaleString()}/yr +

+
+ )} + {measure.billSavings != null && ( +
+

Bill saving

+

+ £{Math.round(measure.billSavings).toLocaleString()}/yr +

+
+ )} +
+
+ ))} +
+
+ )} +
); -} \ No newline at end of file +} diff --git a/src/app/portfolio/[slug]/building-passport/[propertyId]/plans/PlanCard.tsx b/src/app/portfolio/[slug]/building-passport/[propertyId]/plans/PlanCard.tsx index f6aa2c6..f9c3643 100644 --- a/src/app/portfolio/[slug]/building-passport/[propertyId]/plans/PlanCard.tsx +++ b/src/app/portfolio/[slug]/building-passport/[propertyId]/plans/PlanCard.tsx @@ -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 { +async function fetchPlanDeletionPreview(planId: string): Promise { 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 { @@ -61,36 +46,35 @@ async function confirmPlanDeletion(planId: string): Promise { 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 ( <> - - {/* Delete button */} - +
+
+ {/* Title row */} +
+

+ {planName ?? "Unnamed Plan"} +

+ + + + + + setSetDefaultOpen(true)} + disabled={settingDefault} + > + {settingDefault ? "Setting…" : "Set as Default"} + + setOpen(true)} + > + Delete Plan + + + +
- {/* EPC */} -
- -
- - {/* Content */} -
- - {planName && ( -
- {planName} -
- )} -
- - -
- Total cost: - £{formatNumber(totalEstimatedCost)} + {/* Stats */} +
+
+ Expected EPC + {expectedEpcRating}
-
- Total SAP points: - - {Math.round((totalSapPoints + Number.EPSILON) * 100) / 100} - +
+ Investment + £{formatNumber(totalEstimatedCost)} +
+
+ SAP Gain + +{sapImprovement} pts
- -
- - {/* Right column */} -
-
-
- - {/* ---------------------------------------- - Delete preview modal - ----------------------------------------- */} + {/* CTA */} + +
+ + {/* Set default confirmation modal */} + + + + + Change default plan? + + +

+ {planName ?? "This plan"} will + become the recommended strategy shown at the top of the page. You can change it again + at any time. +

+ + + + +
+
+ + {/* Delete preview modal */} @@ -180,9 +209,7 @@ export default function PlanCard({ {isLoading ? (

Loading deletion preview…

) : isError ? ( -

- Failed to load deletion preview -

+

Failed to load deletion preview

) : (
@@ -195,12 +222,8 @@ export default function PlanCard({ {preview.map((row) => ( - - {row.table} - - - {row.count} - + {row.table} + {row.count} ))} @@ -209,14 +232,9 @@ export default function PlanCard({ )} - - + + + setDeleteOpen(true)} + > + Delete Plan + + + + + + {/* EPC progress */} +
+
+ Current Rating: {currentEpcRating} + Target Rating: {expectedEpcRating} +
+ + {/* Bar + pin */} +
+
+
+
+ {/* Current position pin */} +
+
+ + {/* SAP gain + cost */} +
+
+ + +{sapImprovement} + + + SAP Gain + +
+
+

+ Estimated Investment +

+ + £{formatNumber(totalEstimatedCost)} + +
+
+
+ + {/* CTA button */} +
+ +
+
+ + {/* Right: image */} +
+ {/* eslint-disable-next-line @next/next/no-img-element */} + Modern architectural house exterior +
+
+ + {/* ── Impact card ─────────────────────────────── */} +
+
+

Impact

+ + {/* CO2 */} +
+
+ + + + +
+
+

+ Annual CO₂ Reduction +

+
+

+ {co2Savings != null + ? `${co2Savings.toFixed(1)} Tonnes` + : "—"} +

+ {carsEquivalent != null && carsEquivalent > 0 && ( +
+ +
+
+
+
+ + + + + + +
+

+ Equivalent Impact +

+
+

+ Like taking{" "} + + ~{carsEquivalent} car + {carsEquivalent !== 1 ? "s" : ""} + {" "} + off the road for a year — based on the avg. UK car + emitting 1.47 t CO₂/yr. +

+
+ {/* Caret */} +
+
+
+
+
+
+
+ )} +
+
+
+ + {/* Bill savings */} +
+
+ + + + +
+
+

+ Bill Savings (Est.) +

+

+ {energyBillSavings != null + ? `£${formatNumber(energyBillSavings)} / yr` + : "—"} +

+
+
+ + {/* Valuation uplift */} + {(valuationIncreaseLowerBound != null || + valuationIncreaseUpperBound != null) && ( +
+
+ + + + + +
+
+

+ Valuation Uplift (Est.) +

+

+ {valuationIncreaseLowerBound != null && + valuationIncreaseUpperBound != null + ? `${formatNumber(valuationIncreaseLowerBound * 100)}% – ${formatNumber(valuationIncreaseUpperBound * 100)}%` + : valuationIncreaseLowerBound != null + ? `${formatNumber(valuationIncreaseLowerBound * 100)}%+` + : `Up to ${formatNumber(valuationIncreaseUpperBound! * 100)}%`} +

+
+
+ )} +
+
+
+ + {/* Delete modal */} + + + + Delete plan + + {previewLoading ? ( +

Loading deletion preview…

+ ) : previewError ? ( +

+ Failed to load deletion preview +

+ ) : ( +
+
+ + + Table + Rows deleted + + + + {preview.map((row) => ( + + + {row.table} + + + {row.count} + + + ))} + +
+
+ )} + + + + +
+
+ + ); +} diff --git a/src/app/portfolio/[slug]/building-passport/[propertyId]/plans/[planId]/loading.tsx b/src/app/portfolio/[slug]/building-passport/[propertyId]/plans/[planId]/loading.tsx new file mode 100644 index 0000000..88f721c --- /dev/null +++ b/src/app/portfolio/[slug]/building-passport/[propertyId]/plans/[planId]/loading.tsx @@ -0,0 +1,102 @@ +function Skeleton({ className }: { className?: string }) { + return ( +
+ ); +} + +export default function PlanDetailLoading() { + return ( +
+ + {/* Header skeleton */} +
+ + +
+ + {/* Executive Summary Bento skeleton */} +
+ + {/* SAP Improvement card */} +
+ +
+
+ + +
+ +
+ + +
+
+
+ + +
+ +
+ + {/* 4 stat tiles */} +
+ {[0, 1, 2, 3].map((i) => ( +
+ + + +
+ ))} +
+
+ + {/* Financial + Recommendations skeleton */} +
+ + {/* Left: Financial Overview */} +
+ +
+
+ + +
+
+
+ + +
+
+
+
+ + + + +
+
+ + {/* Right: Recommendations */} +
+ + {[0, 1, 2, 3].map((i) => ( +
+
+ +
+ + +
+
+ + +
+
+ +
+ ))} +
+
+
+ ); +} diff --git a/src/app/portfolio/[slug]/building-passport/[propertyId]/plans/[planId]/page.tsx b/src/app/portfolio/[slug]/building-passport/[propertyId]/plans/[planId]/page.tsx index dcce7e4..320f668 100644 --- a/src/app/portfolio/[slug]/building-passport/[propertyId]/plans/[planId]/page.tsx +++ b/src/app/portfolio/[slug]/building-passport/[propertyId]/plans/[planId]/page.tsx @@ -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 = { + 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 ( -
- +
+ {/* ── Header ─────────────────────────────────────────────────── */} +
+

+ Plan: {planMeta.name ?? "Retrofit Plan"} +

+
+ + {/* ── Executive Summary Bento ──────────────────────────────────── */} +
+ {/* EPC Rating card */} +
+ {/* Decorative corner accent */} +
+ +
+ {/* Header label with decorative line */} +
+ + EPC UPGRADE + +
+
+ + {/* Current → Target letter badges */} +
+
+ + Current + + + {currentEpc ?? "–"} + +
+ + + +
+ + Target + +
+ + {targetEpc ?? "–"} + +
+
+
+
+ +
+ {/* Gradient bar with current-position marker */} +
+
+ {currentEpc && ( +
+ )} +
+ + {/* Bottom: SAP points summary */} +
+
+

+ Projected Rating Increase +

+

+ Improvement from{" "} + + {propertyMeta.currentSapPoints != null + ? Math.round(propertyMeta.currentSapPoints) + : "–"} + {" "} + to{" "} + + {planMeta.postSapPoints?.toFixed(1) ?? "–"} + {" "} + SAP points +

+
+ + + +
+
+
+ + {/* 4 stat tiles — cool blue background, icon at top */} +
+
+ +

+ {planMeta.co2Savings != null + ? `${planMeta.co2Savings.toFixed(1)}t` + : "—"} +

+

+ CO₂ Reduction /yr +

+
+ +
+ +

+ {planMeta.energyConsumptionSavings != null + ? `${formatNumber(planMeta.energyConsumptionSavings)} kWh` + : "—"} +

+

+ Energy Savings /yr +

+
+ +
+ +

+ {planMeta.energyBillSavings != null + ? `£${formatNumber(planMeta.energyBillSavings)}` + : "—"} +

+

+ Bill Reduction /yr +

+
+ +
+ +

+ {valuationLabel ?? "—"} +

+

+ Valuation Boost +

+
+
+
+ + {/* ── Financial + Recommendations ──────────────────────────────── */} +
+ {/* Left: Financial Overview — sticky so it stays visible while scrolling recs */} +
+

+ Financial Overview +

+ +
+
+

+ Total Investment +

+

+ {planMeta.costOfWorks != null + ? `£${formatNumber(planMeta.costOfWorks)}` + : "—"} +

+
+
+
+

+ Contingency +

+

+ {planMeta.contingencyCost != null && + planMeta.contingencyCost > 0 + ? `£${formatNumber(planMeta.contingencyCost)}` + : "—"} +

+
+ {/* Contingency tooltip */} +
+ +
+

+ A contingency buffer is added on top of estimated costs to + account for unexpected variations in materials or labour. + Rates vary per measure type. +

+
+
+
+
+
+ +

+ Based on current market rates and property size. Final quotes may + vary following a technical survey. Contingency rates vary per + measure type. +

+ + {/* About These Estimates box */} +
+
+ +
+

+ About These Estimates +

+
    +
  • + · + Costs modelled using current market rates and SAP 10.2 + methodology. +
  • +
  • + · + Annual savings are projected based on typical occupancy + patterns. +
  • +
  • + · + Final quotes confirmed after a technical survey of the + property. +
  • +
+
+
+
+ + {/* Scenario Configuration */} + {scenarioData && ( +
+

+ Scenario Configuration +

+ {scenarioData.name && ( +
+ Scenario + + {scenarioData.name} + +
+ )} +
+ Housing type + + {scenarioData.housingType} + +
+ {scenarioData.budget != null && ( +
+ Budget + + £{formatNumber(scenarioData.budget)} + +
+ )} + {scenarioData.goal && ( +
+ Goal + + {scenarioData.goal} + {scenarioData.goalValue + ? ` → ${scenarioData.goalValue}` + : ""} + +
+ )} +
+ )} +
+ + {/* Right: Recommended Upgrades — scrollable to avoid excessive page length */} +
+

+ Recommended Upgrades +
+

+ +
+ +
+
+
); } diff --git a/src/app/portfolio/[slug]/building-passport/[propertyId]/plans/page.tsx b/src/app/portfolio/[slug]/building-passport/[propertyId]/plans/page.tsx index d1549b4..735c563 100644 --- a/src/app/portfolio/[slug]/building-passport/[propertyId]/plans/page.tsx +++ b/src/app/portfolio/[slug]/building-passport/[propertyId]/plans/page.tsx @@ -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 ( +
+
+

+ Retrofit Strategy +

+
+
+
+ +
+
+

+ No plans yet +

+

+ Retrofit plans will appear here once they have been generated. +

+
+
+
+ ); + } + + 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 ( -
-
Retrofit Plans
+
+ {/* Page header */} +
+

+ Retrofit Strategy +

+

+ Retrofit Plans +

+
-
- {plans.map((plan) => { - const totalEstimatedCost = plan.costOfWorks || 0; + {/* Hero — default plan + carbon impact card */} + - const totalSapPoints = - (plan.postSapPoints || propertyMeta.currentSapPoints) - - propertyMeta.currentSapPoints; + {/* Secondary plans grid */} +
+ {otherPlans.length > 0 && ( +
+

+ Other Plans +

+ + {otherPlans.length} + +
+ )} - const expectedSapPoints = Math.min( - propertyMeta.currentSapPoints + totalSapPoints, - 100 - ); - - const expectedEpcRating = sapToEpc(expectedSapPoints); - - return ( -
- -
- ); - })} -
+
+ {/* Gradient fade to indicate overflow */} +
+
+ {otherPlans.map((plan) => { + const { totalEstimatedCost, totalSapPoints, expectedEpcRating } = + getPlanMetrics(plan); + return ( +
+ +
+ ); + })} +
+
+
); } diff --git a/src/app/portfolio/[slug]/building-passport/[propertyId]/solar-analysis/PropertyMap.tsx b/src/app/portfolio/[slug]/building-passport/[propertyId]/solar-analysis/PropertyMap.tsx new file mode 100644 index 0000000..cdada90 --- /dev/null +++ b/src/app/portfolio/[slug]/building-passport/[propertyId]/solar-analysis/PropertyMap.tsx @@ -0,0 +1,67 @@ +"use client"; + +import { useEffect, useRef } from "react"; + +interface PropertyMapProps { + latitude: number; + longitude: number; +} + +function loadMapsScript(): Promise { + 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(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 ( +
+ ); +} diff --git a/src/app/portfolio/[slug]/building-passport/[propertyId]/solar-analysis/PropertyMapWrapper.tsx b/src/app/portfolio/[slug]/building-passport/[propertyId]/solar-analysis/PropertyMapWrapper.tsx new file mode 100644 index 0000000..1fdd4a5 --- /dev/null +++ b/src/app/portfolio/[slug]/building-passport/[propertyId]/solar-analysis/PropertyMapWrapper.tsx @@ -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: () => ( +
+ ), +}); + +export default function PropertyMapWrapper( + props: ComponentProps +) { + return ; +} diff --git a/src/app/portfolio/[slug]/building-passport/[propertyId]/solar-analysis/SolarMapWrapper.tsx b/src/app/portfolio/[slug]/building-passport/[propertyId]/solar-analysis/SolarMapWrapper.tsx new file mode 100644 index 0000000..2942f15 --- /dev/null +++ b/src/app/portfolio/[slug]/building-passport/[propertyId]/solar-analysis/SolarMapWrapper.tsx @@ -0,0 +1,8 @@ +"use client"; + +import type { ComponentProps } from "react"; +import SolarPanelMap from "./SolarPanelMap"; + +export default function SolarMapWrapper(props: ComponentProps) { + return ; +} diff --git a/src/app/portfolio/[slug]/building-passport/[propertyId]/solar-analysis/SolarPanelMap.tsx b/src/app/portfolio/[slug]/building-passport/[propertyId]/solar-analysis/SolarPanelMap.tsx new file mode 100644 index 0000000..0e0d731 --- /dev/null +++ b/src/app/portfolio/[slug]/building-passport/[propertyId]/solar-analysis/SolarPanelMap.tsx @@ -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 ( + + + + + + N + S + E + W + + ); +} + +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 ( +
+ + {/* Subtle grid */} + {Array.from({ length: 21 }, (_, i) => i - 10).map((i) => ( + + + + + ))} + + {/* Roof segments */} + {segmentData.map((seg) => ( + + + {/* Label counter-rotates so it's always readable */} + + {seg.direction}-facing + + + ))} + + {/* Solar panels */} + {panelData.map((p) => ( + + + + ))} + + {/* Compass rose — top right */} + + + {/* Legend — bottom left */} + + {segmentData.map((seg, i) => ( + + + + + Segment {seg.segmentIndex + 1} — {seg.direction}-facing + + + ))} + + +
+ ); +} diff --git a/src/app/portfolio/[slug]/building-passport/[propertyId]/solar-analysis/SolarSimulation.tsx b/src/app/portfolio/[slug]/building-passport/[propertyId]/solar-analysis/SolarSimulation.tsx new file mode 100644 index 0000000..cfabdf2 --- /dev/null +++ b/src/app/portfolio/[slug]/building-passport/[propertyId]/solar-analysis/SolarSimulation.tsx @@ -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 ( +
+

+ {label} panels +

+ {payload.map((item, i) => ( +
+
+ + {item.name} +
+ + {Math.round(item.value).toLocaleString()} kWh + +
+ ))} +
+ ); +} + +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 ( +
+ {/* Efficiency curve */} +
+

+ Solar output vs. number of panels +

+

+ A curve that flattens early indicates diminishing returns as panels + are placed on less optimal roof faces. +

+ + + + + + + + + + + `${(v / 1000).toFixed(0)}k`} + /> + } cursor={{ stroke: "#3943b7", strokeWidth: 1, strokeDasharray: "4 2" }} /> + + + +
+ + + + {/* Configurations table */} +
+

+ All modelled configurations +

+

+ Every array size considered, from the smallest viable installation to the + maximum possible for this property. +

+
+ + + + Panels + Capacity + Roof area + Annual output + + Lifetime output + + ({panelLifetimeYears} yr) + + + kWh / kWp + + + + {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 ( + + + {cfg.panelsCount} + + + {capacityKwp.toFixed(1)} + kWp + + + {areaM2.toFixed(1)} + + + + {annualKwh.toLocaleString()} + kWh + + + {lifetimeKwh.toLocaleString()} + kWh + + + {efficiency.toLocaleString()} + + + ); + })} + +
+
+
+
+ ); +} diff --git a/src/app/portfolio/[slug]/building-passport/[propertyId]/solar-analysis/SolarSimulationWrapper.tsx b/src/app/portfolio/[slug]/building-passport/[propertyId]/solar-analysis/SolarSimulationWrapper.tsx new file mode 100644 index 0000000..cf183f8 --- /dev/null +++ b/src/app/portfolio/[slug]/building-passport/[propertyId]/solar-analysis/SolarSimulationWrapper.tsx @@ -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: () => ( +
+
+
+
+ ), +}); + +export default function SolarSimulationWrapper( + props: ComponentProps +) { + return ; +} diff --git a/src/app/portfolio/[slug]/building-passport/[propertyId]/solar-analysis/page.tsx b/src/app/portfolio/[slug]/building-passport/[propertyId]/solar-analysis/page.tsx deleted file mode 100644 index 7f876b8..0000000 --- a/src/app/portfolio/[slug]/building-passport/[propertyId]/solar-analysis/page.tsx +++ /dev/null @@ -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 ( -
-
-
- No Solar Analysis Data Available -
-

Please check back later for updates.

-
-
- ); - } - - 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 ( -
-
-
- - {solarScenarioData.scenrioType === "building" - ? "These figures are for the building as a whole" - : "These figures are for the individual property"} -
- -
-

- Simulation Panel Configuration -

-
-
- -

- Dimensions: {panelWidthMeters}m x {panelHeightMeters}m -

-
-
- -

- Wattage: {panelCapacityWatts}W -

-
-
- -

- Lifetime: {panelLifetimeYears} years -

-
-
- -

- Region: {solarData.googleApiResponse.regionCode} -

-
-
-
- -
-

- Weather and Environmental Data -

-
-
- -

- Max Sunshine Hours per Year: {maxSunshineHoursPerYear} hours -

-
-
- -

- Carbon Offset Factor: {carbonOffsetFactorKgPerMwh} kg/MWh -

-
-
-
- -
-

- Roof Segments Data -

- -
- -
-
-
-

- Solar PV Simulation -

-
    -
  • - - Number of panels: {solarScenarioData.numberPanels} -
  • -
  • - - Array output: {solarScenarioData.arrayKwhp} kWp -
  • -
  • - - Lifetime DC energy:{" "} - {Math.round(solarScenarioData.lifetimeDcKwh)} kWh -
  • -
  • - - Yearly DC energy: {Math.round( - solarScenarioData.yearlyDcKwh - )}{" "} - kWh -
  • - {solarScenarioData.lifetimeAcKwh !== null && ( -
  • - - Lifetime AC energy:{" "} - {Math.round(solarScenarioData.lifetimeAcKwh)} kWh -
  • - )} - {solarScenarioData.yearlyAcKwh !== null && ( -
  • - - Yearly AC energy:{" "} - {Math.round(solarScenarioData.yearlyAcKwh)} kWh -
  • - )} -
  • - - Cost: £{formatNumber(solarScenarioData.cost)} -
  • -
  • - - Expected payback years:{" "} - {solarScenarioData.expectedPaybackYears} -
  • -
  • - - Panelled roof area:{" "} - {solarScenarioData.panelledRoofArea.toFixed(1)} m² -
  • -
-
-
-
Map not currently available
- {/* Solar Image */} -
-
-
-
-
- ); -} diff --git a/src/app/portfolio/[slug]/building-passport/[propertyId]/upload/page.tsx b/src/app/portfolio/[slug]/building-passport/[propertyId]/upload/page.tsx deleted file mode 100644 index dbb9ef2..0000000 --- a/src/app/portfolio/[slug]/building-passport/[propertyId]/upload/page.tsx +++ /dev/null @@ -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([]); - 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) => { - 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 ( -
-

Upload Retrofit Data Files

- -
-

- Portfolio ID: {portfolioId} -

-

- Property ID: {propertyId} -

-
- - - - - -
-

Uploaded Files

- {files.length === 0 ? ( -

No files uploaded yet.

- ) : ( - - - - - - - - - {files.map((file) => ( - - - - - ))} - -
File URLAction
- {file.s3JsonUrl} - - -
- )} -
-
- ); -}; - -export default UploadPage; diff --git a/src/app/portfolio/[slug]/building-passport/[propertyId]/utils.ts b/src/app/portfolio/[slug]/building-passport/[propertyId]/utils.ts index fdb7b21..c56b477 100644 --- a/src/app/portfolio/[slug]/building-passport/[propertyId]/utils.ts +++ b/src/app/portfolio/[slug]/building-passport/[propertyId]/utils.ts @@ -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 { + 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 { diff --git a/src/app/portfolio/[slug]/components/PropertyFilters.tsx b/src/app/portfolio/[slug]/components/PropertyFilters.tsx index 361faf2..546b2b7 100644 --- a/src/app/portfolio/[slug]/components/PropertyFilters.tsx +++ b/src/app/portfolio/[slug]/components/PropertyFilters.tsx @@ -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 = { + 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(""); - const [expectedEpc, setExpectedEpc] = - useState(""); + const [open, setOpen] = useState(false); + const [dropdownStyle, setDropdownStyle] = useState({}); + const ref = useRef(null); + const buttonRef = useRef(null); - /* ---------------------------------------- - Change handlers (no useEffect) - ----------------------------------------- */ - function handleCurrentEpcChange( - value: PropertyFilterValues["current_epc_at_most"], - ) { - setCurrentEpc(value); + const dropdownRef = useRef(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 ( -
-
- {/* Address */} -
- - setAddress(e.target.value)} - /> -
- - {/* Postcode */} -
- - setPostcode(e.target.value)} - /> -
- - {/* Current EPC */} -
- - 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("currentEpc"); + const [operator, setOperator] = useState("epc_less_than"); + const [epcSelected, setEpcSelected] = useState([]); + const [dateValue, setDateValue] = useState(""); + const [preset, setPreset] = useState("expired"); + const [enumSelected, setEnumSelected] = useState([]); + 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 ( +
+ {/* Field */} +
+ + +
+ + {/* Operator */} +
+ + +
+ + {/* Value */} +
+ + + {isEpcField(field) && ( + + )} + + {field === "epcExpiryDate" && operator === "date_preset" && ( + -
+ )} - {/* Expected EPC */} -
- - -
+ {field === "epcExpiryDate" && operator !== "date_preset" && ( + setDateValue(e.target.value)} + /> + )} - {/* Actions */} -
- - -
+ {isEnumField(field) && ( + + )} + + {isNumericField(field) && ( + + )} +
+ + {/* Actions */} +
+ + +
+
+ ); +} + +/* ----------------------------------------------------------------------- + Condition Row +------------------------------------------------------------------------ */ +function ConditionRow({ + condition, + onRemove, +}: { + condition: PropertyFilter; + onRemove: () => void; +}) { + return ( +
+
+ {conditionLabel(condition)} +
+ +
+ ); +} + +/* ----------------------------------------------------------------------- + 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); + // "or" = new OR group, "and:" = 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 ( +
+ {/* Group list */} + {draft.map((group, groupIdx) => { + const isAndTarget = addMode === `and:${group.id}`; + return ( +
+ {/* OR divider between groups */} + {groupIdx > 0 && ( +
+
+ + OR + +
+
+ )} + +
+ {group.conditions.map((condition, condIdx) => ( +
+ {/* AND label between conditions in same group */} + {condIdx > 0 && ( +
+
+ + AND + +
+
+ )} + removeCondition(group.id, condition.id)} + /> +
+ ))} + + {/* AND form inline */} + {isAndTarget ? ( + setAddMode(null)} + /> + ) : ( + + )} +
+
+ ); + })} + + {/* OR: add new group form or button */} + {addMode === "or" ? ( + setAddMode(null)} + /> + ) : ( +
+ {hasGroups && ( + <> +
+ + or + +
+ + )} + +
+ )} + + {/* Apply / Clear */} +
+ +
); } -// #Test git with khalimsdsadsaasdsasdfdsertrsadfsdsadfdssdfds diff --git a/src/app/portfolio/[slug]/components/PropertyTable.tsx b/src/app/portfolio/[slug]/components/PropertyTable.tsx index 03e42cf..5e34583 100644 --- a/src/app/portfolio/[slug]/components/PropertyTable.tsx +++ b/src/app/portfolio/[slug]/components/PropertyTable.tsx @@ -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() {

- Hover over “New Property” to start adding properties + Hover over “New Property” to start adding properties to your portfolio.

@@ -79,6 +154,145 @@ function EmptyPropertyState() { ); } +/* ---------------------------------------- + Loading overlay +----------------------------------------- */ +function LoadingOverlay() { + return ( +
+
+
+ Updating… +
+
+ ); +} + +/* ---------------------------------------- + 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(null); + const inputRef = useRef(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 ( +
+ + + {isOpen && ( +
+ setDraft(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") commit(); + if (e.key === "Escape") { + setDraft(committedValue); + onCommit(committedValue); + } + }} + /> + +
+ )} +
+ ); +} + /* ---------------------------------------- Main table ----------------------------------------- */ @@ -87,29 +301,194 @@ export default function PropertyTable({ }: { portfolioId: string; }) { - const router = useRouter(); + const [sidebarOpen, setSidebarOpen] = useState(false); - const [filters, setFilters] = useState({ - 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(null); + const [filterGroups, setFilterGroups] = useState([]); - const parsedFilters = useMemo(() => parsePropertyFilters(filters), [filters]); - const hasActiveFilters = parsedFilters.length > 0; + // Column visibility — lifted up from DataTable + const [columnVisibility, setColumnVisibility] = useState( + () => { + 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({ + 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(null); - const [deleteLoading, setDeleteLoading] = useState(false); + const [previewLoading] = useState(false); + const [previewError] = useState(null); return ( -
-
-
- - - {isFetching && ( -
-
-
+
+ {/* Action bar */} +
+ {/* Left: results count */} +
+ + Results: + + {isLoading ? ( + Loading… + ) : ( + + Showing{" "} + + {tableData.length.toLocaleString()} + {" "} + of{" "} + + {totalCount.toLocaleString()} + {" "} + properties + )} - - {hasActiveFilters && !isFetching && ( -
- Filters applied ({parsedFilters.length}) -
+ {hasActiveFilters && ( + )} +
+ + {/* Right: action buttons */} +
+ {/* Filters toggle */} + + + {/* Edit Columns dropdown */} + + + + + + + Optional Columns + + + {OPTIONAL_COLUMN_IDS.map((colId) => { + const isVisible = columnVisibility[colId] !== false; + return ( + { + e.preventDefault(); + setColumnVisibility((prev) => ({ + ...prev, + [colId]: !isVisible, + })); + }} + > + + + {OPTIONAL_COLUMN_LABELS[colId]} + + + ); + })} + + + + {/* Export */} + {filteredTotal > EXPORT_LIMIT ? ( + + + + ) : ( + + )} +
+
+ + {/* Quick filters row */} +
+ + setOpenFilter(openFilter === "address" ? null : "address") + } + onCommit={(v) => commitFilter("address", v)} + onClear={() => { + setCommittedAddress(""); + setOpenFilter(null); + }} + inputWidth="w-52" + /> + + + setOpenFilter(openFilter === "postcode" ? null : "postcode") + } + onCommit={(v) => commitFilter("postcode", v)} + onClear={() => { + setCommittedPostcode(""); + setOpenFilter(null); + }} + inputWidth="w-32" + /> + + + setOpenFilter(openFilter === "propertyRef" ? null : "propertyRef") + } + onCommit={(v) => commitFilter("propertyRef", v)} + onClear={() => { + setCommittedPropertyRef(""); + setOpenFilter(null); + }} + inputWidth="w-40" + /> +
+ + {/* Display-limit notice */} + {isAtDisplayLimit && hasMore && ( +
+ + Showing{" "} + + {tableData.length.toLocaleString()} + {" "} + of{" "} + + {filteredTotal.toLocaleString()} + {" "} + properties — more load automatically as you navigate to the last page. + +
+ )} + + {/* Body: sidebar + table */} +
+ {/* Collapsible filter sidebar */} +
+
+

+ Curate Selection +

+ +
+
+ + {/* Table area */} +
+ {((isFetching && !isLoading) || isFetchingMore) && } {isLoading ? ( -
Loading properties…
+
+ Loading properties… +
) : isError ? ( -
Failed to load properties.
- ) : data.length === 0 && hasActiveFilters ? ( -
-

No properties match your filters.

+
+ Failed to load properties. +
+ ) : queryData.length === 0 && hasActiveFilters ? ( +
+

No properties match your filters.

- ) : data.length === 0 ? ( + ) : queryData.length === 0 ? ( ) : ( setDeletePropertyId(id)} + columnVisibility={columnVisibility} + onColumnVisibilityChange={ + setColumnVisibility as ( + updater: Updater, + ) => void + } + pagination={pagination} + onPaginationChange={ + setPagination as (updater: Updater) => void + } /> )}
- {/* ---------------------------------------- - Delete preview modal - ----------------------------------------- */} + {/* Delete preview modal */} { if (!open) { setDeletePropertyId(null); setDeletePreview(null); - setPreviewError(null); } }} > @@ -195,15 +771,6 @@ export default function PropertyTable({ - {previewLoading && ( -
-
- - Calculating deletion impact… - -
- )} - {previewError && (

{previewError}

)} @@ -231,14 +798,6 @@ export default function PropertyTable({ > Cancel - - {/* */}
diff --git a/src/app/portfolio/[slug]/components/SuccessToast.tsx b/src/app/portfolio/[slug]/components/SuccessToast.tsx index 20567a5..5e43601 100644 --- a/src/app/portfolio/[slug]/components/SuccessToast.tsx +++ b/src/app/portfolio/[slug]/components/SuccessToast.tsx @@ -33,7 +33,7 @@ export default function SuccessToast({ }, timeoutMs); return () => clearTimeout(timer); } - }, [show, onClose]); + }, [show, onClose, showConfetti, timeoutMs]); return ( <> diff --git a/src/app/portfolio/[slug]/components/Tooltip.tsx b/src/app/portfolio/[slug]/components/Tooltip.tsx new file mode 100644 index 0000000..fc516e7 --- /dev/null +++ b/src/app/portfolio/[slug]/components/Tooltip.tsx @@ -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({}); + const triggerRef = useRef(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 ( + <> +
setVisible(true)} + onMouseLeave={() => setVisible(false)} + className="inline-flex" + > + {children} +
+ + {visible && + createPortal( +
+
+ {content} +
+ {/* Arrow */} +
+
+
+
, + document.body + )} + + ); +} diff --git a/src/app/portfolio/[slug]/components/dataTable.tsx b/src/app/portfolio/[slug]/components/dataTable.tsx index bdc81da..18acbb7 100644 --- a/src/app/portfolio/[slug]/components/dataTable.tsx +++ b/src/app/portfolio/[slug]/components/dataTable.tsx @@ -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 = (row, columnId, value, addMeta) => { return itemRank.passed; }; +const DEFAULT_PAGINATION: PaginationState = { pageIndex: 0, pageSize: 7 }; + interface DataTableProps { columns: ColumnDef[]; data: TData[]; onDeleteProperty?: (propertyId: number) => void; + columnVisibility?: VisibilityState; + onColumnVisibilityChange?: (updater: Updater) => void; + // Controlled pagination — when omitted the table manages its own pagination state + pagination?: PaginationState; + onPaginationChange?: (updater: Updater) => void; } export default function DataTable>({ data, columns, onDeleteProperty, + columnVisibility = {}, + onColumnVisibilityChange, + pagination: controlledPagination, + onPaginationChange: controlledOnPaginationChange, }: DataTableProps) { const [sorting, setSorting] = useState([]); const [columnFilters, setColumnFilters] = useState([]); const [globalFilter, setGlobalFilter] = useState(""); + const [internalPagination, setInternalPagination] = useState(DEFAULT_PAGINATION); - // ✅ REQUIRED pagination state (fixes TS error) - const [pagination, setPagination] = useState({ - 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>({ 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>({ columnFilters, globalFilter, pagination, + columnVisibility, }, meta: { @@ -86,13 +103,13 @@ export default function DataTable>({ }); return ( -
+
{table.getHeaderGroups().map((headerGroup) => ( {headerGroup.headers.map((header) => ( - + {header.isPlaceholder ? null : flexRender( diff --git a/src/app/portfolio/[slug]/components/propertyTableColumns.tsx b/src/app/portfolio/[slug]/components/propertyTableColumns.tsx index b776849..50ff5b5 100644 --- a/src/app/portfolio/[slug]/components/propertyTableColumns.tsx +++ b/src/app/portfolio/[slug]/components/propertyTableColumns.tsx @@ -11,35 +11,75 @@ import { } from "@/app/shadcn_components/ui/dropdown-menu"; import { Button } from "@/app/shadcn_components/ui/button"; import { ArrowUpDown, MoreHorizontal } from "lucide-react"; -import StatusBadge from "@/app/components/StatusBadge"; -import { HomeIcon } from "@heroicons/react/20/solid"; import { FunnelIcon } from "@heroicons/react/24/outline"; import { formatNumber, getEpcColorClass, sapToEpc } from "@/app/utils"; import { cn } from "@/lib/utils"; -import { PortfolioStatus } from "@/app/db/schema/portfolio"; import { PropertyWithRelations } from "@/app/db/schema/property"; import { X } from "lucide-react"; +import { + EnumOption, + TENURE_OPTIONS, + MAINFUEL_OPTIONS, +} from "@/app/utils/propertyFilters"; -interface DataTableColumnHeaderProps< - TData, - TValue, -> extends React.HTMLAttributes { - column: Column; - 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 ( + + {children} + + ); +} + +/* ----------------------------------------------------------------------- + EPC letter bubble +------------------------------------------------------------------------ */ const EpcLetterBubble = ({ letter }: { letter: string }) => { + if (!letter) return
; return (
{letter}
); }; +/* ----------------------------------------------------------------------- + Column header with dropdown filter +------------------------------------------------------------------------ */ +interface DataTableColumnHeaderProps + extends React.HTMLAttributes { + column: Column; + title: string; +} + export function DataTableFilterHeader({ column, title, @@ -68,7 +108,6 @@ export function DataTableFilterHeader({ - ({ ); } -export const columns: ColumnDef[] = [ +/* ----------------------------------------------------------------------- + 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 = { + 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[] = [ { accessorKey: "address", enableGlobalFilter: true, - header: ({ column }) => { - return ( - - ); - }, - cell: ({ row }) => { - const address = String(row.getValue("address")); - const propertyId = row.original.id; - const portfolioId = row.original.portfolioId; - - return ( -
- - -
- ); - }, - }, - { - accessorKey: "postcode", - enableGlobalFilter: true, header: ({ column }) => ( ), - cell: ({ row }) => ( -
- {row.original.postcode} -
- ), - }, - { - accessorKey: "status", - // header: () =>
Status
, - header: ({ column }) => { - return ( -
- ( - - )} - /> -
- ); - }, 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 ( -
- {status && } -
- ); - }, - }, - { - accessorKey: "fundingScheme", - header: ({ column }) => { - return ( -
- ( - // handle status being null or undefined - - )} - /> -
- ); - }, - 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 ( -
- {fundingScheme && fundingScheme !== "none" && ( - +
+ + {address} + + {postcode && ( + {postcode} )}
); }, }, + { + accessorKey: "landlordPropertyId", + header: () =>
Property Ref
, + cell: ({ row }) => ( +
+ {row.original.landlordPropertyId ?? "—"} +
+ ), + }, { accessorKey: "currentEpc", - header: () =>
Current EPC Rating
, + header: () => ( +
Current EPC
+ ), + cell: ({ row }) => ( +
+ +
+ ), + }, + { + accessorKey: "originalSapPoints", + header: () => ( +
Lodged EPC
+ ), cell: ({ row }) => { + const originalSap = row.original.originalSapPoints; + const letter = originalSap ? sapToEpc(originalSap) : null; return (
- {} +
); }, }, { accessorKey: "targetEpc", - header: () =>
Expected EPC
, + header: () => ( +
Expected EPC
+ ), 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 (
- {} +
); } @@ -252,78 +265,195 @@ export const columns: ColumnDef[] = [ return (
- {} + +
+ ); + }, + }, + { + accessorKey: "epcLodgementDate", + header: () => ( +
EPC Expiry
+ ), + cell: ({ row }) => { + const dateStr = row.original.epcLodgementDate; + const expired = row.original.epcIsExpired; + + if (!dateStr) { + return
; + } + + 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 ( +
+ + Expired + + {formatted} +
+ ); + } + + return ( +
+ {formatted}
); }, }, { accessorKey: "cost", - header: () =>
Cost
, + header: () =>
Plan Cost
, cell: ({ row }) => { const cost = row.original.totalRecommendationCost; - const creationStatus = row.original.creationStatus; + if (creationStatus === "LOADING") { - return
; + return
; } - const formatted = cost ? "£" + formatNumber(cost) : ""; - return ( -
- {formatted} +
+ {cost ? ( + + £{formatNumber(cost)} + + ) : ( + No cost + )}
); }, }, { 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 ( -
- Loading... -
+
Loading...
); } return ( - <> -
- - - - - - Actions - navigator.clipboard.writeText(payment.id)} - className="text-gray-700 cursor-pointer" - > - - Building Passport - - - - - Settings - - - - - -
- +
+ + + + + + Actions + + + Building Passport + + + + Settings + + + + +
); }, }, ]; + +/* ----------------------------------------------------------------------- + Optional columns +------------------------------------------------------------------------ */ +const optionalColumns: ColumnDef[] = [ + { + id: "propertyType", + accessorKey: "propertyType", + header: () =>
Property Type
, + cell: ({ row }) => { + const val = row.original.propertyType; + return val ? {val} : ; + }, + }, + { + id: "builtForm", + accessorKey: "builtForm", + header: () =>
Built Form
, + cell: ({ row }) => { + const val = row.original.builtForm; + return val ? {val} : ; + }, + }, + { + id: "tenure", + accessorKey: "tenure", + header: () =>
Tenure
, + cell: ({ row }) => { + const label = resolveEnumLabel(TENURE_OPTIONS, row.original.tenure); + if (!label) return ; + return {label}; + }, + }, + { + id: "yearBuilt", + accessorKey: "yearBuilt", + header: () =>
Year Built
, + cell: ({ row }) => ( +
{row.original.yearBuilt ?? "—"}
+ ), + }, + { + id: "totalFloorArea", + accessorKey: "totalFloorArea", + header: () =>
Floor Area (m²)
, + cell: ({ row }) => { + const val = row.original.totalFloorArea; + return ( +
+ {val != null ? `${val.toFixed(1)} m²` : "—"} +
+ ); + }, + }, + { + id: "co2Emissions", + accessorKey: "co2Emissions", + header: () =>
CO₂ Emissions
, + cell: ({ row }) => { + const val = row.original.co2Emissions; + return ( +
+ {val != null ? `${val.toFixed(1)} kg/m²/yr` : "—"} +
+ ); + }, + }, + { + id: "mainfuel", + accessorKey: "mainfuel", + header: () =>
Main Fuel
, + cell: ({ row }) => { + const label = resolveEnumLabel(MAINFUEL_OPTIONS, row.original.mainfuel); + return label ? {label} : ; + }, + }, +]; + +export const columns: ColumnDef[] = [ + ...coreColumns, + ...optionalColumns, +]; diff --git a/src/app/portfolio/[slug]/components/useProperties.ts b/src/app/portfolio/[slug]/components/useProperties.ts index b0dca4d..b45455f 100644 --- a/src/app/portfolio/[slug]/components/useProperties.ts +++ b/src/app/portfolio/[slug]/components/useProperties.ts @@ -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({ - 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({ + 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, }), }); diff --git a/src/app/portfolio/[slug]/utils.ts b/src/app/portfolio/[slug]/utils.ts index cd7abf4..8d7ff89 100644 --- a/src/app/portfolio/[slug]/utils.ts +++ b/src/app/portfolio/[slug]/utils.ts @@ -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 = { + 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, + operator: PropertyFilter["operator"], + value: string +): ReturnType | 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 => x !== null); + if (ranges.length === 0) return null; + return sql`(${sql.join(ranges, sql` OR `)})`; + } + + return null; +} + +function buildConditionSql(filter: PropertyFilter): ReturnType | 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> = { + 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[] = []; + 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 { + const groupFragments: ReturnType[] = []; + + for (const group of filterGroups) { + const condFragments = group.conditions + .map(buildConditionSql) + .filter((x): x is ReturnType => 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 { + 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 { // 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(sql` @@ -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}; `); diff --git a/src/app/utils.ts b/src/app/utils.ts index 484a59d..f1b2dcc 100644 --- a/src/app/utils.ts +++ b/src/app/utils.ts @@ -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"; diff --git a/src/app/utils/propertyFilters.ts b/src/app/utils/propertyFilters.ts index 11e29e4..e67309a 100644 --- a/src/app/utils/propertyFilters.ts +++ b/src/app/utils/propertyFilters.ts @@ -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; -} \ No newline at end of file +} + +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!"] }, +]; diff --git a/tailwind.config.js b/tailwind.config.js index 3145938..b3b5829 100644 --- a/tailwind.config.js +++ b/tailwind.config.js @@ -155,6 +155,7 @@ module.exports = { }, fontFamily: { sans: ["var(--font-sans)", ...fontFamily.sans], + manrope: ["var(--font-manrope)", ...fontFamily.sans], }, keyframes: { "accordion-down": { diff --git a/tsconfig.json b/tsconfig.json index 6468155..8c2d3c1 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -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",