diff --git a/package-lock.json b/package-lock.json index ce4708a..be1bac4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -33,6 +33,7 @@ "react": "19.0.0", "react-dom": "19.0.0", "react-hook-form": "^7.54.2", + "recharts": "^3.7.0", "resend": "^6.1.2", "tailwind-merge": "^2.6.0", "tailwindcss-animate": "^1.0.7" @@ -3601,6 +3602,42 @@ "react": "^18.0 || ^19.0 || ^19.0.0-rc" } }, + "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/@rollup/rollup-android-arm-eabi": { "version": "4.57.1", "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.57.1.tgz", @@ -3995,7 +4032,12 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.0.0.tgz", "integrity": "sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA==", - "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": { @@ -4028,6 +4070,69 @@ "@types/node": "*" } }, + "node_modules/@types/d3-array": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz", + "integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==", + "license": "MIT" + }, + "node_modules/@types/d3-color": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", + "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==", + "license": "MIT" + }, + "node_modules/@types/d3-ease": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz", + "integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==", + "license": "MIT" + }, + "node_modules/@types/d3-interpolate": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", + "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==", + "license": "MIT", + "dependencies": { + "@types/d3-color": "*" + } + }, + "node_modules/@types/d3-path": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz", + "integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==", + "license": "MIT" + }, + "node_modules/@types/d3-scale": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz", + "integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==", + "license": "MIT", + "dependencies": { + "@types/d3-time": "*" + } + }, + "node_modules/@types/d3-shape": { + "version": "3.1.8", + "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.8.tgz", + "integrity": "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==", + "license": "MIT", + "dependencies": { + "@types/d3-path": "*" + } + }, + "node_modules/@types/d3-time": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz", + "integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==", + "license": "MIT" + }, + "node_modules/@types/d3-timer": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz", + "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==", + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", @@ -4080,6 +4185,12 @@ "@types/react": "^18.0.0" } }, + "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/@typescript-eslint/eslint-plugin": { "version": "8.48.1", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.48.1.tgz", @@ -5634,6 +5745,127 @@ "deprecated": "Cuid and other k-sortable and non-cryptographic ids (Ulid, ObjectId, KSUID, all UUIDs) are all insecure. Use @paralleldrive/cuid2 instead.", "license": "MIT" }, + "node_modules/d3-array": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz", + "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==", + "license": "ISC", + "dependencies": { + "internmap": "1 - 2" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-color": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", + "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-ease": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", + "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-format": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz", + "integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-interpolate": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", + "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-path": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz", + "integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-scale": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz", + "integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==", + "license": "ISC", + "dependencies": { + "d3-array": "2.10.0 - 3", + "d3-format": "1 - 3", + "d3-interpolate": "1.2.0 - 3", + "d3-time": "2.1.1 - 3", + "d3-time-format": "2 - 4" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-shape": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz", + "integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==", + "license": "ISC", + "dependencies": { + "d3-path": "^3.1.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz", + "integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==", + "license": "ISC", + "dependencies": { + "d3-array": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time-format": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz", + "integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==", + "license": "ISC", + "dependencies": { + "d3-time": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-timer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", + "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, "node_modules/damerau-levenshtein": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz", @@ -5726,6 +5958,12 @@ } } }, + "node_modules/decimal.js-light": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz", + "integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==", + "license": "MIT" + }, "node_modules/deep-eql": { "version": "5.0.2", "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", @@ -6231,6 +6469,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/es-toolkit": { + "version": "1.44.0", + "resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.44.0.tgz", + "integrity": "sha512-6penXeZalaV88MM3cGkFZZfOoLGWshWWfdy0tWw/RlVVyhvMaWSBTOvXNeiW3e5FwdS5ePW0LGEu17zT139ktg==", + "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", @@ -6767,7 +7015,6 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.1.tgz", "integrity": "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==", - "dev": true, "license": "MIT" }, "node_modules/execa": { @@ -7484,6 +7731,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", @@ -7545,6 +7802,15 @@ "node": ">= 0.4" } }, + "node_modules/internmap": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz", + "integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, "node_modules/is-array-buffer": { "version": "3.0.5", "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz", @@ -10044,6 +10310,29 @@ "integrity": "sha512-bCK/2Z4zLidyB4ReuIsvALH6w31YfAQDmXMqMx6FyfHqvBxtjC0eRumeSu4Bs3XtXwpyIywtSTrVT99BxY1f9w==", "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", @@ -10137,6 +10426,51 @@ "url": "https://paulmillr.com/funding/" } }, + "node_modules/recharts": { + "version": "3.7.0", + "resolved": "https://registry.npmjs.org/recharts/-/recharts-3.7.0.tgz", + "integrity": "sha512-l2VCsy3XXeraxIID9fx23eCb6iCBsxUQDnE8tWm6DFdszVAO7WVY/ChAD9wVit01y6B2PMupYiMmQwhgPHc9Ew==", + "license": "MIT", + "workspaces": [ + "www" + ], + "dependencies": { + "@reduxjs/toolkit": "1.x.x || 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": ">=18" + }, + "peerDependencies": { + "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/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", @@ -10187,6 +10521,12 @@ "integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==", "license": "MIT" }, + "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/resend": { "version": "6.5.2", "resolved": "https://registry.npmjs.org/resend/-/resend-6.5.2.tgz", @@ -11454,6 +11794,12 @@ "node": ">=0.8" } }, + "node_modules/tiny-invariant": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz", + "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==", + "license": "MIT" + }, "node_modules/tinybench": { "version": "2.9.0", "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", @@ -11863,6 +12209,28 @@ "node": ">= 0.8" } }, + "node_modules/victory-vendor": { + "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", + "@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/vite": { "version": "5.4.21", "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", diff --git a/package.json b/package.json index 6d1c0a7..e0d33eb 100644 --- a/package.json +++ b/package.json @@ -44,6 +44,7 @@ "react": "19.0.0", "react-dom": "19.0.0", "react-hook-form": "^7.54.2", + "recharts": "^3.7.0", "resend": "^6.1.2", "tailwind-merge": "^2.6.0", "tailwindcss-animate": "^1.0.7" diff --git a/public/team/2026/spring/lighthouse-report/report.html b/public/team/2026/spring/lighthouse-report/report.html new file mode 100644 index 0000000..d2a4ecb --- /dev/null +++ b/public/team/2026/spring/lighthouse-report/report.html @@ -0,0 +1,2897 @@ + + + + + + + + Lighthouse Report + + + + + +
+ + + + + + diff --git a/src/app/admin/_components/NonprofitsTab.tsx b/src/app/admin/_components/NonprofitsTab.tsx new file mode 100644 index 0000000..a74fc3f --- /dev/null +++ b/src/app/admin/_components/NonprofitsTab.tsx @@ -0,0 +1,309 @@ +import { Nonprofit } from '../../../../types/types'; +import ApprovalConfirmationPopup from '@/components/Admin/ApprovalConfirmationPopup'; +import { AdminNonprofitDocument, ApprovalMode } from '../_types'; + +interface ApprovalConfirmState { + open: boolean; + nonprofitId: string; + nonprofitName: string; + mode: ApprovalMode; +} + +interface NonprofitsTabProps { + nonprofits: Nonprofit[]; + documents: AdminNonprofitDocument[]; + approvalConfirm: ApprovalConfirmState; + setApprovalConfirm: React.Dispatch< + React.SetStateAction + >; + handleApproval: ( + _nonprofitId: string, + _approved: boolean, + _closePopup?: () => void, + _nonprofitName?: string + ) => Promise; + getDocumentForNonprofit: ( + _nonprofitId: string + ) => AdminNonprofitDocument | undefined; + downloadDocument: (_doc: AdminNonprofitDocument) => void; + fetchDocuments: () => Promise; +} + +const NonprofitsTab = ({ + nonprofits, + approvalConfirm, + setApprovalConfirm, + handleApproval, + getDocumentForNonprofit, + downloadDocument, + fetchDocuments, +}: NonprofitsTabProps) => { + const pendingNonprofits = nonprofits.filter( + (n) => n.nonprofitDocumentApproval === null && n.users.length > 0 + ); + const processedNonprofits = nonprofits.filter( + (n) => n.nonprofitDocumentApproval !== null && n.users.length > 0 + ); + + const closePopup = () => + setApprovalConfirm((prev) => ({ ...prev, open: false })); + + const DocumentCell = ({ nonprofitId }: { nonprofitId: string }) => { + const document = getDocumentForNonprofit(nonprofitId); + if (!document) return No Document; + return ( + + ); + }; + + return ( + <> +
+
+

+ Nonprofits +

+
+
+ +
+
+ + + + {[ + 'Nonprofit Name', + 'Organization Type', + 'Document', + 'Actions', + 'Status', + ].map((header) => ( + + ))} + + + + {/* Pending Approvals Section */} + + + + {pendingNonprofits.map((nonprofit, index) => ( + + + + + + + + ))} + + {/* Processed Approvals Section */} + + + + {processedNonprofits.map((nonprofit, index) => ( + + + + + + + + ))} + +
+ {header} +
+

+ Pending Approvals + + {pendingNonprofits.length} + +

+
+ {nonprofit.name} + + + {nonprofit.organizationType} + + + + +
+ + +
+
+ + Pending + +
+

+ Processed Approvals + + {processedNonprofits.length} + +

+
+ {nonprofit.name} + + + {nonprofit.organizationType} + + + + +
+ +
+
+ {nonprofit.nonprofitDocumentApproval ? ( + + Approved + + ) : ( + + Rejected + + )} +
+
+
+
+ + + handleApproval( + approvalConfirm.nonprofitId, + approvalConfirm.mode === 'approve' || + approvalConfirm.mode === 'reverse-approve', + closePopup, + approvalConfirm.nonprofitName + ) + } + /> + + ); +}; + +export default NonprofitsTab; diff --git a/src/app/admin/_components/OverviewTab.tsx b/src/app/admin/_components/OverviewTab.tsx new file mode 100644 index 0000000..7d22282 --- /dev/null +++ b/src/app/admin/_components/OverviewTab.tsx @@ -0,0 +1,287 @@ +import { DonutChart } from '@/components/charts/DonutChart'; +import { LineChartComponent } from '@/components/charts/LineChartComponent'; +import { BarChartComponent } from '@/components/charts/BarChartComponent'; +import { KPICard } from '@/components/charts/KPICard'; +import { ADMIN_CHART_INFO } from '@/components/charts/chart-info-text'; +import { AnalyticsData } from '../_types'; + +interface OverviewTabProps { + analyticsData: AnalyticsData | null; + loading: boolean; +} + +const OverviewTab = ({ analyticsData, loading }: OverviewTabProps) => { + if (loading || !analyticsData) return null; + + return ( +
+ {/* System Health KPIs */} +
+ + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> +
+ + {/* Charts Grid */} +
+ {/* Product Distribution Donut Chart */} +
+ item.value > 0)} + /> +
+ + {/* Product Status Trends Line Chart */} +
+ +
+ + {/* Supplier Activity Bar Chart */} +
+ +
+ + {/* Nonprofit Engagement Bar Chart */} +
+ +
+ + {/* Organization Type Breakdown */} +
+ item.value > 0)} + /> +
+ + {/* Supplier Cadence Breakdown */} +
+ item.value > 0)} + /> +
+ + {/* Claims Over Time Line Chart */} +
+ +
+
+
+ ); +}; + +export default OverviewTab; diff --git a/src/app/admin/_components/ProductRequestsTab.tsx b/src/app/admin/_components/ProductRequestsTab.tsx new file mode 100644 index 0000000..98fde36 --- /dev/null +++ b/src/app/admin/_components/ProductRequestsTab.tsx @@ -0,0 +1,95 @@ +import { ProductRequest } from '../../../../types/types'; + +interface ProductRequestsTabProps { + productRequests: ProductRequest[]; + getSupplierName: (_supplierId: string) => string; + getNonprofitName: (_nonprofitId: string | null) => string; +} + +const ProductRequestsTab = ({ + productRequests, + getSupplierName, + getNonprofitName, +}: ProductRequestsTabProps) => { + return ( +
+
+

Product Requests

+
+
+ +
+
+ + + + {[ + 'Name', + 'Unit', + 'Quantity', + 'Description', + 'Status', + 'Supplier', + 'Nonprofit', + ].map((header) => ( + + ))} + + + + {productRequests.map((product, index) => ( + + + + + + + + + + ))} + +
+ {header} +
+ + {product.name} + + + {product.unit} + + {product.quantity} + + {product.description} + + + {product.status} + + + {getSupplierName(product.supplierId)} + + {getNonprofitName(product.claimedById)} +
+
+
+
+ ); +}; + +export default ProductRequestsTab; diff --git a/src/app/admin/_components/SuppliersTab.tsx b/src/app/admin/_components/SuppliersTab.tsx new file mode 100644 index 0000000..1de19e0 --- /dev/null +++ b/src/app/admin/_components/SuppliersTab.tsx @@ -0,0 +1,64 @@ +import { Supplier } from '../../../../types/types'; + +interface SuppliersTabProps { + suppliers: Supplier[]; +} + +const SuppliersTab = ({ suppliers }: SuppliersTabProps) => { + const activeSuppliers = suppliers.filter((s) => s.users.length > 0); + + return ( +
+
+

+ Suppliers +

+
+
+ +
+
+ + + + {['Supplier Name', 'Phone', 'Email', 'Cadence'].map( + (header) => ( + + ) + )} + + + + {activeSuppliers.map((supplier, index) => ( + + + + + + + ))} + +
+ {header} +
+ {supplier.name} + + {supplier.users[0]?.phoneNumber || 'N/A'} + + {supplier.users[0]?.email || 'N/A'} + + {supplier.cadence} +
+
+
+
+ ); +}; + +export default SuppliersTab; diff --git a/src/app/admin/_components/TabNav.tsx b/src/app/admin/_components/TabNav.tsx new file mode 100644 index 0000000..8b695f0 --- /dev/null +++ b/src/app/admin/_components/TabNav.tsx @@ -0,0 +1,132 @@ +interface TabNavProps { + activeTab: string; + setActiveTab: (_tab: string) => void; + supplierCount: number; + nonprofitCount: number; + productRequestCount: number; +} + +const TabNav = ({ + activeTab, + setActiveTab, + supplierCount, + nonprofitCount, + productRequestCount, +}: TabNavProps) => { + const tabs = [ + { + label: 'Overview', + count: null as number | null, + tab: 'overview', + icon: ( + + + + ), + }, + { + label: 'Suppliers', + count: supplierCount, + tab: 'suppliers', + icon: ( + + + + ), + }, + { + label: 'Nonprofits', + count: nonprofitCount, + tab: 'nonprofits', + icon: ( + + + + ), + }, + { + label: 'Product Requests', + count: productRequestCount, + tab: 'productRequests', + icon: ( + + + + ), + }, + ]; + + return ( +
+
+ {tabs.map(({ label, count, tab, icon }) => ( +
setActiveTab(tab)} + > + {icon} +

{label}

+ {count !== null ? ( +

{count}

+ ) : ( +

+ 0 +

+ )} +
+ ))} +
+
+ ); +}; + +export default TabNav; diff --git a/src/app/admin/_hooks/useAdminAnalytics.ts b/src/app/admin/_hooks/useAdminAnalytics.ts new file mode 100644 index 0000000..0cc6044 --- /dev/null +++ b/src/app/admin/_hooks/useAdminAnalytics.ts @@ -0,0 +1,63 @@ +import { useState, useCallback } from 'react'; +import { AnalyticsData } from '../_types'; + +const useAdminAnalytics = () => { + const [analyticsData, setAnalyticsData] = useState( + null + ); + const [loading, setLoading] = useState(true); + + const fetchAnalytics = useCallback(async () => { + try { + setLoading(true); + const [ + distributionRes, + trendsRes, + supplierActivityRes, + nonprofitEngagementRes, + systemHealthRes, + claimsOverTimeRes, + ] = await Promise.all([ + fetch('/api/analytics/product-distribution'), + fetch('/api/analytics/product-status-trends'), + fetch('/api/analytics/supplier-activity'), + fetch('/api/analytics/nonprofit-engagement'), + fetch('/api/analytics/system-health'), + fetch('/api/analytics/claims-over-time'), + ]); + + const [ + distribution, + trends, + supplierActivity, + nonprofitEngagement, + systemHealth, + claimsOverTime, + ] = await Promise.all([ + distributionRes.json(), + trendsRes.json(), + supplierActivityRes.json(), + nonprofitEngagementRes.json(), + systemHealthRes.json(), + claimsOverTimeRes.json(), + ]); + + setAnalyticsData({ + distribution, + trends, + supplierActivity, + nonprofitEngagement, + systemHealth, + claimsOverTime, + }); + } catch (err) { + console.error('Error fetching analytics:', err); + } finally { + setLoading(false); + } + }, []); + + return { analyticsData, loading, fetchAnalytics }; +}; + +export { useAdminAnalytics }; diff --git a/src/app/admin/_hooks/useAdminData.ts b/src/app/admin/_hooks/useAdminData.ts new file mode 100644 index 0000000..ed4fd71 --- /dev/null +++ b/src/app/admin/_hooks/useAdminData.ts @@ -0,0 +1,122 @@ +import { useState, useCallback } from 'react'; +import { Supplier, Nonprofit, ProductRequest } from '../../../../types/types'; +import { AdminNonprofitDocument } from '../_types'; + +const useAdminData = () => { + const [suppliers, setSuppliers] = useState([]); + const [nonprofits, setNonprofits] = useState([]); + const [productRequests, setProductRequests] = useState([]); + const [documents, setDocuments] = useState([]); + const [error, setError] = useState(null); + + const fetchSuppliers = useCallback(async () => { + try { + const response = await fetch('/api/suppliers/all'); + const data = await response.json(); + if (!response.ok) + throw new Error(data.error || 'Failed to fetch suppliers'); + if (data) setSuppliers(data); + } catch (err) { + setError(err instanceof Error ? err.message : 'An error occurred'); + } + }, []); + + const fetchNonprofits = useCallback(async () => { + try { + const response = await fetch('/api/nonprofits/all'); + const data = await response.json(); + if (!response.ok) + throw new Error(data.error || 'Failed to fetch nonprofits'); + if (data) setNonprofits(data); + } catch (err) { + setError(err instanceof Error ? err.message : 'An error occurred'); + } + }, []); + + const fetchProductRequests = useCallback(async () => { + try { + const response = await fetch('/api/product-requests/multiple'); + const data = await response.json(); + if (!response.ok) + throw new Error(data.error || 'Failed to fetch product requests'); + if (data) setProductRequests(data); + } catch (err) { + setError(err instanceof Error ? err.message : 'An error occurred'); + } + }, []); + + const fetchDocuments = useCallback(async () => { + try { + const response = await fetch( + '/api/nonprofit-documents?includeFileData=true' + ); + const data = await response.json(); + if (!response.ok) + throw new Error(data.error || 'Failed to fetch documents'); + if (data) setDocuments(data); + } catch (err) { + setError(err instanceof Error ? err.message : 'An error occurred'); + } + }, []); + + const getDocumentForNonprofit = ( + nonprofitId: string + ): AdminNonprofitDocument | undefined => { + return documents.find((doc) => doc.nonprofit?.id === nonprofitId); + }; + + const countActiveSuppliers = (supplierList: Supplier[]): number => { + return supplierList.filter((s) => s.users.length > 0).length; + }; + + const countActiveNonprofits = (nonprofitList: Nonprofit[]): number => { + return nonprofitList.filter((n) => n.users.length > 0).length; + }; + + const downloadDocument = (doc: AdminNonprofitDocument) => { + if (!doc.fileData) return; + const binaryString = window.atob(doc.fileData as unknown as string); + const bytes = new Uint8Array(binaryString.length); + for (let i = 0; i < binaryString.length; i++) { + bytes[i] = binaryString.charCodeAt(i); + } + const blob = new Blob([bytes], { type: doc.fileType }); + const url = window.URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = doc.fileName; + a.click(); + window.URL.revokeObjectURL(url); + }; + + const getSupplierName = (supplierId: string): string => { + const supplier = suppliers.find((s) => s.id === supplierId); + return supplier ? supplier.name : 'Unknown'; + }; + + const getNonprofitName = (nonprofitId: string | null): string => { + if (!nonprofitId) return 'Not claimed'; + const nonprofit = nonprofits.find((n) => n.id === nonprofitId); + return nonprofit ? nonprofit.name : 'Unknown'; + }; + + return { + suppliers, + nonprofits, + productRequests, + documents, + error, + fetchSuppliers, + fetchNonprofits, + fetchProductRequests, + fetchDocuments, + getDocumentForNonprofit, + countActiveSuppliers, + countActiveNonprofits, + downloadDocument, + getSupplierName, + getNonprofitName, + }; +}; + +export { useAdminData }; diff --git a/src/app/admin/_hooks/useApproval.ts b/src/app/admin/_hooks/useApproval.ts new file mode 100644 index 0000000..ea1b33d --- /dev/null +++ b/src/app/admin/_hooks/useApproval.ts @@ -0,0 +1,99 @@ +import { useState, useCallback } from 'react'; +import { useToast } from '@/hooks/use-toast'; +import { ApprovalMode } from '../_types'; + +interface ApprovalConfirmState { + open: boolean; + nonprofitId: string; + nonprofitName: string; + mode: ApprovalMode; +} + +interface UseApprovalOptions { + fetchNonprofits: () => Promise; + fetchAnalytics: () => Promise; +} + +const useApproval = ({ + fetchNonprofits, + fetchAnalytics, +}: UseApprovalOptions) => { + const { toast } = useToast(); + + const [approvalConfirm, setApprovalConfirm] = useState({ + open: false, + nonprofitId: '', + nonprofitName: '', + mode: 'approve', + }); + + const handleApproval = useCallback( + async ( + nonprofitId: string, + approved: boolean, + closePopup?: () => void, + nonprofitName?: string + ) => { + try { + const response = await fetch('/api/nonprofits', { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ nonprofitId, approved }), + }); + + if (!response.ok) { + const errorData = await response.json().catch(() => null); + throw new Error( + errorData?.error || 'Failed to update approval status' + ); + } + + const approvalStatusResponse = await fetch( + '/api/nonprofit-approval-status-emails', + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ nonprofitId, approved }), + } + ); + + if (!approvalStatusResponse.ok) { + const errorData = await approvalStatusResponse + .json() + .catch(() => null); + throw new Error(errorData?.error || 'Failed to send approval email'); + } + + await Promise.all([fetchNonprofits(), fetchAnalytics()]); + + toast({ + title: approved ? '✓ Nonprofit Approved' : '✓ Nonprofit Rejected', + description: approved + ? `${nonprofitName ? `"${nonprofitName}" has` : 'The nonprofit has'} been approved and notified via email.` + : `${nonprofitName ? `"${nonprofitName}" has` : 'The nonprofit has'} been rejected and notified via email.`, + variant: approved ? 'success' : 'destructive', + duration: 3000, + }); + + closePopup?.(); + } catch (error) { + console.error('Error updating nonprofit status:', error); + toast({ + title: 'Error', + description: + error instanceof Error + ? error.message + : 'Failed to update nonprofit status', + variant: 'destructive', + duration: 3000, + }); + closePopup?.(); + } + }, + [fetchNonprofits, fetchAnalytics, toast] + ); + + return { approvalConfirm, setApprovalConfirm, handleApproval }; +}; + +export { useApproval }; diff --git a/src/app/admin/_types/index.ts b/src/app/admin/_types/index.ts new file mode 100644 index 0000000..0216b16 --- /dev/null +++ b/src/app/admin/_types/index.ts @@ -0,0 +1,60 @@ +import { NonprofitDocument } from '../../../../types/types'; + +// Extends the existing NonprofitDocument type with nonprofit relation +export interface AdminNonprofitDocument extends NonprofitDocument { + nonprofit?: { id: string; name: string; organizationType: string }; +} + +export interface AnalyticsData { + distribution: { + distribution: Record; + proteinTypes: Record; + }; + trends: { + trends: Array<{ + date: string; + AVAILABLE: number; + RESERVED: number; + PENDING: number; + }>; + }; + supplierActivity: { + activity: Array<{ + supplierId: string; + name: string; + cadence: string; + productCount: number; + }>; + cadenceBreakdown: Record; + }; + nonprofitEngagement: { + engagement: Array<{ + nonprofitId: string; + name: string; + organizationType: string; + claimedCount: number; + approvalStatus: boolean | null; + }>; + orgTypeBreakdown: Record; + approvalBreakdown: { approved: number; pending: number; rejected: number }; + }; + systemHealth: { + totalUsers: number; + usersByRole: Record; + totalSuppliers: number; + totalNonprofits: number; + totalProducts: number; + productsByStatus: { AVAILABLE: number; RESERVED: number; PENDING: number }; + avgClaimTimeHours: number; + approvalRate: number; + }; + claimsOverTime: { + timeline: Array<{ month: string; count: number }>; + }; +} + +export type ApprovalMode = + | 'approve' + | 'reject' + | 'reverse-approve' + | 'reverse-reject'; diff --git a/src/app/admin/page.tsx b/src/app/admin/page.tsx index 1ec6d36..110dc87 100644 --- a/src/app/admin/page.tsx +++ b/src/app/admin/page.tsx @@ -2,19 +2,16 @@ import { useSession } from 'next-auth/react'; import { useRouter } from 'next/navigation'; import { useEffect, useState } from 'react'; -import { - Supplier, - Nonprofit, - ProductRequest, - NonprofitDocument, -} from '../../../types/types'; - -// Extend the existing NonprofitDocument type just for this component -interface AdminNonprofitDocument extends NonprofitDocument { - nonprofit?: { id: string; name: string; organizationType: string }; -} - -export default function AdminPage() { +import { useAdminData } from './_hooks/useAdminData'; +import { useAdminAnalytics } from './_hooks/useAdminAnalytics'; +import { useApproval } from './_hooks/useApproval'; +import TabNav from './_components/TabNav'; +import OverviewTab from './_components/OverviewTab'; +import SuppliersTab from './_components/SuppliersTab'; +import NonprofitsTab from './_components/NonprofitsTab'; +import ProductRequestsTab from './_components/ProductRequestsTab'; + +const AdminPage = () => { const { data: session, status } = useSession(); const router = useRouter(); @@ -23,194 +20,46 @@ export default function AdminPage() { if (!session || session.user.role !== 'ADMIN') router.replace('/'); }, [session, status, router]); - const [activeTab, setActiveTab] = useState(null); - const [suppliers, setSuppliers] = useState([]); - const [nonprofits, setNonprofits] = useState([]); - const [productRequests, setProductRequests] = useState([]); - const [documents, setDocuments] = useState([]); - const [error, setError] = useState(null); - - const fetchSuppliers = async () => { - try { - const response = await fetch('/api/suppliers/all'); - const data = await response.json(); - - if (!response.ok) { - throw new Error(data.error || 'Failed to fetch suppliers'); - } - if (data) { - setSuppliers(data); - } - } catch (err) { - setError(err instanceof Error ? err.message : 'An error occurred'); - } - }; - - const fetchNonprofits = async () => { - try { - const response = await fetch('/api/nonprofits/all'); - const data = await response.json(); - - if (!response.ok) { - throw new Error(data.error || 'Failed to fetch nonprofits'); - } - if (data) { - setNonprofits(data); - } - } catch (err) { - setError(err instanceof Error ? err.message : 'An error occurred'); - } - }; - - const fetchProductRequests = async () => { - try { - const response = await fetch('/api/product-requests/multiple'); - const data = await response.json(); - - if (!response.ok) { - throw new Error(data.error || 'Failed to fetch product requests'); - } - if (data) { - setProductRequests(data); - } - } catch (err) { - setError(err instanceof Error ? err.message : 'An error occurred'); - } - }; - - const fetchDocuments = async () => { - try { - const response = await fetch( - '/api/nonprofit-documents?includeFileData=true' - ); - const data = await response.json(); - - if (!response.ok) { - throw new Error(data.error || 'Failed to fetch documents'); - } - if (data) { - setDocuments(data); - } - } catch (err) { - setError(err instanceof Error ? err.message : 'An error occurred'); - } - }; + const [activeTab, setActiveTab] = useState('overview'); + + const { + suppliers, + nonprofits, + productRequests, + documents, + error, + fetchSuppliers, + fetchNonprofits, + fetchProductRequests, + fetchDocuments, + getDocumentForNonprofit, + countActiveSuppliers, + countActiveNonprofits, + downloadDocument, + getSupplierName, + getNonprofitName, + } = useAdminData(); + + const { analyticsData, loading, fetchAnalytics } = useAdminAnalytics(); + + const { approvalConfirm, setApprovalConfirm, handleApproval } = useApproval({ + fetchNonprofits, + fetchAnalytics, + }); useEffect(() => { fetchSuppliers(); fetchNonprofits(); fetchProductRequests(); fetchDocuments(); - }, []); - - const getDocumentForNonprofit = ( - nonprofitId: string - ): AdminNonprofitDocument | undefined => { - return documents.find((doc) => doc.nonprofit?.id === nonprofitId); - }; - - const countActiveSuppliers = (suppliers: Supplier[]): number => { - let totalSupplierCount = 0; - suppliers.forEach((supplier) => { - if (supplier.users.length > 0) { - totalSupplierCount += 1; - } - }); - return totalSupplierCount; - }; - - const countActiveNonprofits = (nonprofits: Nonprofit[]): number => { - let totalNonprofitCount = 0; - nonprofits.forEach((nonprofit) => { - if (nonprofit.users.length > 0) { - totalNonprofitCount += 1; - } - }); - return totalNonprofitCount; - }; - - const downloadDocument = (doc: AdminNonprofitDocument) => { - if (!doc.fileData) return; - - // Convert base64 to binary - const binaryString = window.atob(doc.fileData as unknown as string); - const bytes = new Uint8Array(binaryString.length); - for (let i = 0; i < binaryString.length; i++) { - bytes[i] = binaryString.charCodeAt(i); - } - - const blob = new Blob([bytes], { type: doc.fileType }); - const url = window.URL.createObjectURL(blob); - const a = document.createElement('a'); - a.href = url; - a.download = doc.fileName; - a.click(); - window.URL.revokeObjectURL(url); - }; - - const handleApproval = async (nonprofitId: string, approved: boolean) => { - const isConfirmed = window.confirm( - `Are you sure you want to ${approved ? 'approve' : 'reject'} this nonprofit?` - ); - - if (!isConfirmed) return; - - try { - const response = await fetch('/api/nonprofits', { - method: 'PATCH', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ nonprofitId, approved }), - }); - - if (!response.ok) { - const errorData = await response.json().catch(() => null); - console.error('Server response:', errorData); - throw new Error(errorData?.error || 'Failed to update approval status'); - } else { - const approvalStatusResponse = await fetch( - '/api/nonprofit-approval-status-emails', - { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ nonprofitId, approved }), - } - ); - - if (!approvalStatusResponse.ok) { - const errorData = await approvalStatusResponse - .json() - .catch(() => null); - console.error('Server response:', errorData); - throw new Error( - errorData?.error || 'Failed to update nonprofit approval status' - ); - } - } - - // Refresh the list to ensure we have the latest data - await fetchNonprofits(); - } catch (error) { - console.error('Error updating nonprofit status:', error); - alert( - error instanceof Error - ? error.message - : 'Failed to update nonprofit status' - ); - } - }; - - // Helper function to get supplier name by ID - const getSupplierName = (supplierId: string): string => { - const supplier = suppliers.find((s) => s.id === supplierId); - return supplier ? supplier.name : 'Unknown'; - }; - - // Helper function to get nonprofit name by ID - const getNonprofitName = (nonprofitId: string | null): string => { - if (!nonprofitId) return 'Not claimed'; - const nonprofit = nonprofits.find((n) => n.id === nonprofitId); - return nonprofit ? nonprofit.name : 'Unknown'; - }; + fetchAnalytics(); + }, [ + fetchSuppliers, + fetchNonprofits, + fetchProductRequests, + fetchDocuments, + fetchAnalytics, + ]); return (
@@ -239,471 +88,43 @@ export default function AdminPage() {
)} -
-
- {[ - { - label: 'Suppliers', - count: countActiveSuppliers(suppliers), - tab: 'suppliers', - icon: ( - - - - ), - }, - { - label: 'Nonprofits', - count: countActiveNonprofits(nonprofits), - tab: 'nonprofits', - icon: ( - - - - ), - }, - { - label: 'Product Requests', - count: productRequests.length, - tab: 'productRequests', - icon: ( - - - - ), - }, - ].map(({ label, count, tab, icon }) => ( -
setActiveTab(tab)} - > - {icon} -

{label}

-

{count}

-
- ))} -
-
- {activeTab && ( -
-
-

- {activeTab} -

-
-
+ -
-
- - - - {activeTab === 'suppliers' && - ['Supplier Name', 'Phone', 'Email', 'Cadence'].map( - (header) => ( - - ) - )} - {activeTab === 'nonprofits' && - [ - 'Nonprofit Name', - 'Organization Type', - 'Document', - 'Actions', - 'Status', - ].map((header) => ( - - ))} - {activeTab === 'productRequests' && - [ - 'Name', - 'Unit', - 'Quantity', - 'Description', - 'Status', - 'Supplier', - 'Nonprofit', - ].map((header) => ( - - ))} - - - - {activeTab === 'suppliers' && - suppliers - .filter((supplier) => supplier.users.length > 0) - .map((supplier, index) => ( - - - - - - - ))} - {activeTab === 'nonprofits' && ( - <> - - - - {nonprofits - .filter( - (nonprofit) => - nonprofit.nonprofitDocumentApproval === null && - nonprofit.users.length > 0 - ) - .map((nonprofit, index) => { - const document = getDocumentForNonprofit( - nonprofit.id - ); - return ( - - - - - - - - ); - })} + {activeTab === 'overview' && ( + + )} - {/* Processed Section */} - - - - {nonprofits - .filter( - (nonprofit) => - nonprofit.nonprofitDocumentApproval !== null && - nonprofit.users.length > 0 - ) - .map((nonprofit, index) => { - const document = getDocumentForNonprofit( - nonprofit.id - ); - return ( - - - - - - - - ); - })} - - )} + {activeTab === 'suppliers' && } + + {activeTab === 'nonprofits' && ( + + )} - {activeTab === 'productRequests' && - productRequests.map((product, index) => ( - - - - - - - - - - ))} - -
- {header} - - {header} - - {header} -
- {supplier.name} - - {supplier.users[0]?.phoneNumber || 'N/A'} - - {supplier.users[0]?.email || 'N/A'} - - {supplier.cadence} -
-

- Pending Approvals -

-
- {nonprofit.name} - - - {nonprofit.organizationType} - - - {document ? ( - - ) : ( - - No Document - - )} - -
- {!nonprofit.nonprofitDocumentApproval && ( - - )} - -
-
- - Pending - -
-

- Processed Approvals -

-
- {nonprofit.name} - - - {nonprofit.organizationType} - - - {document ? ( - - ) : ( - - No Document - - )} - -
- -
-
- {nonprofit.nonprofitDocumentApproval ? ( - - Approved - - ) : ( - - Rejected - - )} -
- - {product.name} - - - {product.unit} - - {product.quantity} - - {product.description} - - - {product.status} - - - {getSupplierName(product.supplierId)} - - {getNonprofitName(product.claimedById)} -
-
-
-
+ {activeTab === 'productRequests' && ( + )} ); -} +}; + +export default AdminPage; diff --git a/src/app/api/analytics/claims-over-time/route.test.ts b/src/app/api/analytics/claims-over-time/route.test.ts new file mode 100644 index 0000000..a3f59e0 --- /dev/null +++ b/src/app/api/analytics/claims-over-time/route.test.ts @@ -0,0 +1,74 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { GET } from './route'; +import { prisma } from '@/lib/prisma'; + +vi.mock('@/lib/prisma', () => ({ + prisma: { + productRequest: { + findMany: vi.fn(), + }, + }, +})); + +describe('/api/analytics/claims-over-time - GET', () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.spyOn(console, 'error').mockImplementation(() => {}); + }); + + it('should return a timeline grouped by month', async () => { + vi.mocked(prisma.productRequest.findMany).mockResolvedValue([ + { updatedAt: new Date('2025-11-10T10:00:00.000Z') }, + { updatedAt: new Date('2025-11-20T10:00:00.000Z') }, + { updatedAt: new Date('2025-12-05T10:00:00.000Z') }, + ] as any); + + const response = await GET(); + const data = await response.json(); + + expect(response.status).toBe(200); + expect(data.timeline).toEqual([ + { month: '2025-11', count: 2 }, + { month: '2025-12', count: 1 }, + ]); + }); + + it('should return an empty timeline when no claimed products exist', async () => { + vi.mocked(prisma.productRequest.findMany).mockResolvedValue([]); + + const response = await GET(); + const data = await response.json(); + + expect(response.status).toBe(200); + expect(data.timeline).toEqual([]); + }); + + it('should return sorted timeline in ascending order', async () => { + vi.mocked(prisma.productRequest.findMany).mockResolvedValue([ + { updatedAt: new Date('2026-01-15T10:00:00.000Z') }, + { updatedAt: new Date('2025-10-01T10:00:00.000Z') }, + { updatedAt: new Date('2025-12-20T10:00:00.000Z') }, + ] as any); + + const response = await GET(); + const data = await response.json(); + + expect(response.status).toBe(200); + expect(data.timeline[0].month).toBe('2025-10'); + expect(data.timeline[1].month).toBe('2025-12'); + expect(data.timeline[2].month).toBe('2026-01'); + }); + + it('should return 500 when a database error occurs', async () => { + vi.mocked(prisma.productRequest.findMany).mockRejectedValue( + new Error('DB error') + ); + + const response = await GET(); + const data = await response.json(); + + expect(response.status).toBe(500); + expect(data).toEqual({ error: 'Failed to fetch claims over time' }); + }); +}); diff --git a/src/app/api/analytics/claims-over-time/route.ts b/src/app/api/analytics/claims-over-time/route.ts new file mode 100644 index 0000000..56e4380 --- /dev/null +++ b/src/app/api/analytics/claims-over-time/route.ts @@ -0,0 +1,33 @@ +import { NextResponse } from 'next/server'; +import { prisma } from '@/lib/prisma'; + +export async function GET() { + try { + const claimedProducts = await prisma.productRequest.findMany({ + where: { + claimedById: { not: null }, + }, + select: { + updatedAt: true, + }, + }); + + const monthlyData = new Map(); + claimedProducts.forEach((product) => { + const monthKey = product.updatedAt.toISOString().substring(0, 7); + monthlyData.set(monthKey, (monthlyData.get(monthKey) || 0) + 1); + }); + + const timeline = Array.from(monthlyData.entries()) + .map(([month, count]) => ({ month, count })) + .sort((a, b) => a.month.localeCompare(b.month)); + + return NextResponse.json({ timeline }); + } catch (error) { + console.error('Error fetching claims over time:', error); + return NextResponse.json( + { error: 'Failed to fetch claims over time' }, + { status: 500 } + ); + } +} diff --git a/src/app/api/analytics/nonprofit-engagement/route.test.ts b/src/app/api/analytics/nonprofit-engagement/route.test.ts new file mode 100644 index 0000000..a0a7de0 --- /dev/null +++ b/src/app/api/analytics/nonprofit-engagement/route.test.ts @@ -0,0 +1,122 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { GET } from './route'; +import { prisma } from '@/lib/prisma'; + +vi.mock('@/lib/prisma', () => ({ + prisma: { + nonprofit: { + findMany: vi.fn(), + }, + }, +})); + +const mockNonprofits = [ + { + id: 'n1', + name: 'Food Bank A', + organizationType: 'FOOD_BANK', + nonprofitDocumentApproval: true, + _count: { productsClaimed: 5 }, + }, + { + id: 'n2', + name: 'Pantry B', + organizationType: 'PANTRY', + nonprofitDocumentApproval: false, + _count: { productsClaimed: 2 }, + }, + { + id: 'n3', + name: 'Student Pantry C', + organizationType: 'STUDENT_PANTRY', + nonprofitDocumentApproval: null, + _count: { productsClaimed: 0 }, + }, +]; + +describe('/api/analytics/nonprofit-engagement - GET', () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.spyOn(console, 'error').mockImplementation(() => {}); + }); + + it('should return engagement list, orgTypeBreakdown, and approvalBreakdown', async () => { + vi.mocked(prisma.nonprofit.findMany).mockResolvedValue( + mockNonprofits as any + ); + + const response = await GET(); + const data = await response.json(); + + expect(response.status).toBe(200); + expect(data.engagement).toHaveLength(3); + expect(data.engagement[0]).toMatchObject({ + nonprofitId: 'n1', + name: 'Food Bank A', + organizationType: 'FOOD_BANK', + claimedCount: 5, + approvalStatus: true, + }); + }); + + it('should correctly calculate orgTypeBreakdown', async () => { + vi.mocked(prisma.nonprofit.findMany).mockResolvedValue( + mockNonprofits as any + ); + + const response = await GET(); + const data = await response.json(); + + expect(data.orgTypeBreakdown).toEqual({ + FOOD_BANK: 1, + PANTRY: 1, + STUDENT_PANTRY: 1, + FOOD_RESCUE: 0, + AGRICULTURE: 0, + OTHER: 0, + }); + }); + + it('should correctly calculate approvalBreakdown', async () => { + vi.mocked(prisma.nonprofit.findMany).mockResolvedValue( + mockNonprofits as any + ); + + const response = await GET(); + const data = await response.json(); + + expect(data.approvalBreakdown).toEqual({ + approved: 1, + pending: 1, + rejected: 1, + }); + }); + + it('should return empty results when no nonprofits exist', async () => { + vi.mocked(prisma.nonprofit.findMany).mockResolvedValue([]); + + const response = await GET(); + const data = await response.json(); + + expect(response.status).toBe(200); + expect(data.engagement).toEqual([]); + expect(data.approvalBreakdown).toEqual({ + approved: 0, + pending: 0, + rejected: 0, + }); + }); + + it('should return 500 when a database error occurs', async () => { + vi.mocked(prisma.nonprofit.findMany).mockRejectedValue( + new Error('DB error') + ); + + const response = await GET(); + const data = await response.json(); + + expect(response.status).toBe(500); + expect(data).toEqual({ error: 'Failed to fetch nonprofit engagement' }); + }); +}); diff --git a/src/app/api/analytics/nonprofit-engagement/route.ts b/src/app/api/analytics/nonprofit-engagement/route.ts new file mode 100644 index 0000000..3101412 --- /dev/null +++ b/src/app/api/analytics/nonprofit-engagement/route.ts @@ -0,0 +1,73 @@ +import { NextResponse } from 'next/server'; +import { prisma } from '@/lib/prisma'; + +export async function GET() { + try { + // Get all nonprofits with their claimed product counts + const nonprofits = await prisma.nonprofit.findMany({ + select: { + id: true, + name: true, + organizationType: true, + nonprofitDocumentApproval: true, + _count: { + select: { + productsClaimed: true, + }, + }, + }, + orderBy: { + productsClaimed: { + _count: 'desc', + }, + }, + }); + + const engagement = nonprofits.map((nonprofit) => ({ + nonprofitId: nonprofit.id, + name: nonprofit.name, + organizationType: nonprofit.organizationType, + claimedCount: nonprofit._count.productsClaimed, + approvalStatus: nonprofit.nonprofitDocumentApproval, + })); + + // Get organization type breakdown + const orgTypeBreakdown = { + FOOD_BANK: nonprofits.filter((n) => n.organizationType === 'FOOD_BANK') + .length, + PANTRY: nonprofits.filter((n) => n.organizationType === 'PANTRY').length, + STUDENT_PANTRY: nonprofits.filter( + (n) => n.organizationType === 'STUDENT_PANTRY' + ).length, + FOOD_RESCUE: nonprofits.filter( + (n) => n.organizationType === 'FOOD_RESCUE' + ).length, + AGRICULTURE: nonprofits.filter( + (n) => n.organizationType === 'AGRICULTURE' + ).length, + OTHER: nonprofits.filter((n) => n.organizationType === 'OTHER').length, + }; + + // Get approval status breakdown + const approvalBreakdown = { + approved: nonprofits.filter((n) => n.nonprofitDocumentApproval === true) + .length, + pending: nonprofits.filter((n) => n.nonprofitDocumentApproval === null) + .length, + rejected: nonprofits.filter((n) => n.nonprofitDocumentApproval === false) + .length, + }; + + return NextResponse.json({ + engagement: engagement, + orgTypeBreakdown, + approvalBreakdown, + }); + } catch (error) { + console.error('Error fetching nonprofit engagement:', error); + return NextResponse.json( + { error: 'Failed to fetch nonprofit engagement' }, + { status: 500 } + ); + } +} diff --git a/src/app/api/analytics/nonprofit-metrics/route.test.ts b/src/app/api/analytics/nonprofit-metrics/route.test.ts new file mode 100644 index 0000000..4e117b4 --- /dev/null +++ b/src/app/api/analytics/nonprofit-metrics/route.test.ts @@ -0,0 +1,183 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { NextRequest } from 'next/server'; +import { GET } from './route'; +import { prisma } from '@/lib/prisma'; + +vi.mock('@/lib/prisma', () => ({ + prisma: { + productRequest: { + findMany: vi.fn(), + }, + nonprofit: { + findUnique: vi.fn(), + }, + }, +})); + +const makeRequest = (nonprofitId?: string) => + new NextRequest( + `http://localhost/api/analytics/nonprofit-metrics${nonprofitId ? `?nonprofitId=${nonprofitId}` : ''}` + ); + +describe('/api/analytics/nonprofit-metrics - GET', () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.spyOn(console, 'error').mockImplementation(() => {}); + }); + + it('should return 400 when nonprofitId is missing', async () => { + const response = await GET(makeRequest()); + const data = await response.json(); + + expect(response.status).toBe(400); + expect(data).toEqual({ error: 'Nonprofit ID is required' }); + }); + + it('should return metrics for a valid nonprofitId', async () => { + vi.mocked(prisma.productRequest.findMany).mockResolvedValue([ + { + id: 'pr1', + name: 'Product 1', + createdAt: new Date('2025-12-10T10:00:00.000Z'), + pickupInfo: { pickupDate: new Date('2099-12-01T10:00:00.000Z') }, + productType: { + protein: true, + produce: false, + shelfStable: false, + shelfStableIndividualServing: false, + alreadyPreparedFood: false, + other: false, + }, + }, + ] as any); + + vi.mocked(prisma.nonprofit.findUnique).mockResolvedValue({ + users: [ + { + productSurvey: { + protein: true, + produce: false, + shelfStable: false, + shelfStableIndividualServing: false, + alreadyPreparedFood: false, + other: false, + }, + }, + ], + } as any); + + // Second call from availableProducts query + vi.mocked(prisma.productRequest.findMany).mockResolvedValueOnce([ + { + id: 'pr1', + name: 'Product 1', + createdAt: new Date('2025-12-10T10:00:00.000Z'), + pickupInfo: { pickupDate: new Date('2099-12-01T10:00:00.000Z') }, + productType: { + protein: true, + produce: false, + shelfStable: false, + shelfStableIndividualServing: false, + alreadyPreparedFood: false, + other: false, + }, + }, + ] as any); + + const response = await GET(makeRequest('nonprofit-123')); + const data = await response.json(); + + expect(response.status).toBe(200); + expect(data).toHaveProperty('monthlyTimeline'); + expect(data).toHaveProperty('typeBreakdown'); + expect(data).toHaveProperty('upcomingPickups'); + expect(data).toHaveProperty('matchScore'); + expect(data).toHaveProperty('availabilityTrends'); + expect(data).toHaveProperty('totalClaimed'); + }); + + it('should return totalClaimed count correctly', async () => { + const claimedProducts = [ + { + id: 'pr1', + name: 'P1', + createdAt: new Date('2025-11-01T10:00:00.000Z'), + pickupInfo: null, + productType: { + protein: false, + produce: true, + shelfStable: false, + shelfStableIndividualServing: false, + alreadyPreparedFood: false, + other: false, + }, + }, + { + id: 'pr2', + name: 'P2', + createdAt: new Date('2025-11-15T10:00:00.000Z'), + pickupInfo: null, + productType: { + protein: true, + produce: false, + shelfStable: false, + shelfStableIndividualServing: false, + alreadyPreparedFood: false, + other: false, + }, + }, + ]; + + // First call: claimed products; second call: available products (for recentProducts) + vi.mocked(prisma.productRequest.findMany) + .mockResolvedValueOnce(claimedProducts as any) + .mockResolvedValueOnce([] as any); + + vi.mocked(prisma.nonprofit.findUnique).mockResolvedValue({ + users: [], + } as any); + + const response = await GET(makeRequest('nonprofit-123')); + const data = await response.json(); + + expect(response.status).toBe(200); + expect(data.totalClaimed).toBe(2); + expect(data.typeBreakdown.produce).toBe(1); + expect(data.typeBreakdown.protein).toBe(1); + }); + + it('should return matchScore of 0 when nonprofit has no product survey', async () => { + vi.mocked(prisma.productRequest.findMany).mockResolvedValue([] as any); + vi.mocked(prisma.nonprofit.findUnique).mockResolvedValue({ + users: [{ productSurvey: null }], + } as any); + + vi.mocked(prisma.productRequest.findMany).mockResolvedValueOnce([] as any); + + const response = await GET(makeRequest('nonprofit-123')); + const data = await response.json(); + + expect(response.status).toBe(200); + expect(data.matchScore).toEqual({ + protein: 0, + produce: 0, + shelfStable: 0, + shelfStableIndividualServing: 0, + alreadyPreparedFood: 0, + other: 0, + }); + }); + + it('should return 500 when a database error occurs', async () => { + vi.mocked(prisma.productRequest.findMany).mockRejectedValue( + new Error('DB error') + ); + + const response = await GET(makeRequest('nonprofit-123')); + const data = await response.json(); + + expect(response.status).toBe(500); + expect(data).toEqual({ error: 'Failed to fetch nonprofit metrics' }); + }); +}); diff --git a/src/app/api/analytics/nonprofit-metrics/route.ts b/src/app/api/analytics/nonprofit-metrics/route.ts new file mode 100644 index 0000000..655e614 --- /dev/null +++ b/src/app/api/analytics/nonprofit-metrics/route.ts @@ -0,0 +1,216 @@ +import { NextResponse } from 'next/server'; +import { prisma } from '@/lib/prisma'; + +export async function GET(request: Request) { + try { + const { searchParams } = new URL(request.url); + const nonprofitId = searchParams.get('nonprofitId'); + + if (!nonprofitId) { + return NextResponse.json( + { error: 'Nonprofit ID is required' }, + { status: 400 } + ); + } + + // Get nonprofit's claimed products + const claimedProducts = await prisma.productRequest.findMany({ + where: { + claimedById: nonprofitId, + }, + select: { + id: true, + name: true, + createdAt: true, + pickupInfo: { + select: { + pickupDate: true, + }, + }, + productType: { + select: { + protein: true, + produce: true, + shelfStable: true, + shelfStableIndividualServing: true, + alreadyPreparedFood: true, + other: true, + }, + }, + }, + }); + + // Monthly claims timeline + const monthlyData = new Map(); + claimedProducts.forEach((product) => { + const monthKey = product.createdAt.toISOString().substring(0, 7); + monthlyData.set(monthKey, (monthlyData.get(monthKey) || 0) + 1); + }); + + const monthlyTimeline = Array.from(monthlyData.entries()) + .map(([month, count]) => ({ month, count })) + .sort((a, b) => a.month.localeCompare(b.month)); + + // Product type breakdown + const typeBreakdown = { + protein: claimedProducts.filter((p) => p.productType.protein).length, + produce: claimedProducts.filter((p) => p.productType.produce).length, + shelfStable: claimedProducts.filter((p) => p.productType.shelfStable) + .length, + shelfStableIndividualServing: claimedProducts.filter( + (p) => p.productType.shelfStableIndividualServing + ).length, + alreadyPreparedFood: claimedProducts.filter( + (p) => p.productType.alreadyPreparedFood + ).length, + other: claimedProducts.filter((p) => p.productType.other).length, + }; + + // Upcoming pickups (next 30 days) + const now = new Date(); + const thirtyDaysFromNow = new Date(); + thirtyDaysFromNow.setDate(thirtyDaysFromNow.getDate() + 30); + + const upcomingPickups = claimedProducts + .filter((p) => { + if (!p.pickupInfo) return false; + const pickupDate = new Date(p.pickupInfo.pickupDate); + return pickupDate >= now && pickupDate <= thirtyDaysFromNow; + }) + .map((p) => ({ + id: p.id, + name: p.name, + pickupDate: p.pickupInfo!.pickupDate, + })); + + // Get nonprofit's product interests + const nonprofit = await prisma.nonprofit.findUnique({ + where: { id: nonprofitId }, + select: { + users: { + select: { + productSurvey: { + select: { + protein: true, + produce: true, + shelfStable: true, + shelfStableIndividualServing: true, + alreadyPreparedFood: true, + other: true, + }, + }, + }, + }, + }, + }); + + // Get available products matching interests + const availableProducts = await prisma.productRequest.findMany({ + where: { + status: 'AVAILABLE', + }, + select: { + productType: { + select: { + protein: true, + produce: true, + shelfStable: true, + shelfStableIndividualServing: true, + alreadyPreparedFood: true, + other: true, + }, + }, + }, + }); + + // Calculate match score only if user has product interests from survey + const userInterests = nonprofit?.users[0]?.productSurvey; + let matchScore = { + protein: 0, + produce: 0, + shelfStable: 0, + shelfStableIndividualServing: 0, + alreadyPreparedFood: 0, + other: 0, + }; + + if (userInterests && availableProducts.length > 0) { + matchScore = { + protein: userInterests.protein + ? (availableProducts.filter((p) => p.productType.protein).length / + availableProducts.length) * + 100 + : 0, + produce: userInterests.produce + ? (availableProducts.filter((p) => p.productType.produce).length / + availableProducts.length) * + 100 + : 0, + shelfStable: userInterests.shelfStable + ? (availableProducts.filter((p) => p.productType.shelfStable).length / + availableProducts.length) * + 100 + : 0, + shelfStableIndividualServing: userInterests.shelfStableIndividualServing + ? (availableProducts.filter( + (p) => p.productType.shelfStableIndividualServing + ).length / + availableProducts.length) * + 100 + : 0, + alreadyPreparedFood: userInterests.alreadyPreparedFood + ? (availableProducts.filter((p) => p.productType.alreadyPreparedFood) + .length / + availableProducts.length) * + 100 + : 0, + other: userInterests.other + ? (availableProducts.filter((p) => p.productType.other).length / + availableProducts.length) * + 100 + : 0, + }; + } + + // Availability trends on last 30 days + const thirtyDaysAgo = new Date(); + thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30); + + const recentProducts = await prisma.productRequest.findMany({ + where: { + createdAt: { + gte: thirtyDaysAgo, + }, + status: 'AVAILABLE', + }, + select: { + createdAt: true, + }, + }); + + const dailyAvailability = new Map(); + recentProducts.forEach((product) => { + const dateKey = product.createdAt.toISOString().split('T')[0]; + dailyAvailability.set(dateKey, (dailyAvailability.get(dateKey) || 0) + 1); + }); + + const availabilityTrends = Array.from(dailyAvailability.entries()) + .map(([date, count]) => ({ date, count })) + .sort((a, b) => a.date.localeCompare(b.date)); + + return NextResponse.json({ + monthlyTimeline, + typeBreakdown, + upcomingPickups, + matchScore, + availabilityTrends, + totalClaimed: claimedProducts.length, + }); + } catch (error) { + console.error('Error fetching nonprofit metrics:', error); + return NextResponse.json( + { error: 'Failed to fetch nonprofit metrics' }, + { status: 500 } + ); + } +} diff --git a/src/app/api/analytics/product-distribution/route.test.ts b/src/app/api/analytics/product-distribution/route.test.ts new file mode 100644 index 0000000..522f61b --- /dev/null +++ b/src/app/api/analytics/product-distribution/route.test.ts @@ -0,0 +1,123 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { GET } from './route'; +import { prisma } from '@/lib/prisma'; + +vi.mock('@/lib/prisma', () => ({ + prisma: { + productType: { + findMany: vi.fn(), + }, + }, +})); + +describe('/api/analytics/product-distribution - GET', () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.spyOn(console, 'error').mockImplementation(() => {}); + }); + + it('should return correct distribution counts by category', async () => { + vi.mocked(prisma.productType.findMany).mockResolvedValue([ + { + protein: true, + produce: false, + shelfStable: false, + shelfStableIndividualServing: false, + alreadyPreparedFood: false, + other: false, + proteinTypes: ['CHICKEN'], + }, + { + protein: false, + produce: true, + shelfStable: false, + shelfStableIndividualServing: false, + alreadyPreparedFood: false, + other: false, + proteinTypes: [], + }, + { + protein: true, + produce: false, + shelfStable: true, + shelfStableIndividualServing: false, + alreadyPreparedFood: false, + other: false, + proteinTypes: ['BEEF'], + }, + ] as any); + + const response = await GET(); + const data = await response.json(); + + expect(response.status).toBe(200); + expect(data.distribution).toEqual({ + protein: 2, + produce: 1, + shelfStable: 1, + shelfStableIndividualServing: 0, + alreadyPreparedFood: 0, + other: 0, + }); + }); + + it('should return correct protein subtype counts', async () => { + vi.mocked(prisma.productType.findMany).mockResolvedValue([ + { + protein: true, + produce: false, + shelfStable: false, + shelfStableIndividualServing: false, + alreadyPreparedFood: false, + other: false, + proteinTypes: ['CHICKEN', 'BEEF'], + }, + { + protein: true, + produce: false, + shelfStable: false, + shelfStableIndividualServing: false, + alreadyPreparedFood: false, + other: false, + proteinTypes: ['CHICKEN'], + }, + ] as any); + + const response = await GET(); + const data = await response.json(); + + expect(response.status).toBe(200); + expect(data.proteinTypes).toEqual({ CHICKEN: 2, BEEF: 1 }); + }); + + it('should return all zeros when no product types exist', async () => { + vi.mocked(prisma.productType.findMany).mockResolvedValue([]); + + const response = await GET(); + const data = await response.json(); + + expect(response.status).toBe(200); + expect(data.distribution).toEqual({ + protein: 0, + produce: 0, + shelfStable: 0, + shelfStableIndividualServing: 0, + alreadyPreparedFood: 0, + other: 0, + }); + expect(data.proteinTypes).toEqual({}); + }); + + it('should return 500 when a database error occurs', async () => { + vi.mocked(prisma.productType.findMany).mockRejectedValue( + new Error('DB error') + ); + + const response = await GET(); + const data = await response.json(); + + expect(response.status).toBe(500); + expect(data).toEqual({ error: 'Failed to fetch product distribution' }); + }); +}); diff --git a/src/app/api/analytics/product-distribution/route.ts b/src/app/api/analytics/product-distribution/route.ts new file mode 100644 index 0000000..f29ae11 --- /dev/null +++ b/src/app/api/analytics/product-distribution/route.ts @@ -0,0 +1,53 @@ +import { NextResponse } from 'next/server'; +import { prisma } from '@/lib/prisma'; + +export async function GET() { + try { + // Get all product types with their counts + const productTypes = await prisma.productType.findMany({ + select: { + protein: true, + produce: true, + shelfStable: true, + shelfStableIndividualServing: true, + alreadyPreparedFood: true, + other: true, + proteinTypes: true, + }, + }); + + // Count by category + const distribution = { + protein: productTypes.filter((pt) => pt.protein).length, + produce: productTypes.filter((pt) => pt.produce).length, + shelfStable: productTypes.filter((pt) => pt.shelfStable).length, + shelfStableIndividualServing: productTypes.filter( + (pt) => pt.shelfStableIndividualServing + ).length, + alreadyPreparedFood: productTypes.filter((pt) => pt.alreadyPreparedFood) + .length, + other: productTypes.filter((pt) => pt.other).length, + }; + + // Count protein subtypes + const proteinTypeCount: Record = {}; + productTypes + .filter((pt) => pt.protein && pt.proteinTypes) + .forEach((pt) => { + pt.proteinTypes.forEach((type) => { + proteinTypeCount[type] = (proteinTypeCount[type] || 0) + 1; + }); + }); + + return NextResponse.json({ + distribution, + proteinTypes: proteinTypeCount, + }); + } catch (error) { + console.error('Error fetching product distribution:', error); + return NextResponse.json( + { error: 'Failed to fetch product distribution' }, + { status: 500 } + ); + } +} diff --git a/src/app/api/analytics/product-status-trends/route.test.ts b/src/app/api/analytics/product-status-trends/route.test.ts new file mode 100644 index 0000000..690aca6 --- /dev/null +++ b/src/app/api/analytics/product-status-trends/route.test.ts @@ -0,0 +1,87 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { GET } from './route'; +import { prisma } from '@/lib/prisma'; + +vi.mock('@/lib/prisma', () => ({ + prisma: { + productRequest: { + findMany: vi.fn(), + }, + }, +})); + +describe('/api/analytics/product-status-trends - GET', () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.spyOn(console, 'error').mockImplementation(() => {}); + }); + + it('should return trends grouped by date and status', async () => { + vi.mocked(prisma.productRequest.findMany).mockResolvedValue([ + { status: 'AVAILABLE', createdAt: new Date('2025-12-01T10:00:00.000Z') }, + { status: 'AVAILABLE', createdAt: new Date('2025-12-01T14:00:00.000Z') }, + { status: 'RESERVED', createdAt: new Date('2025-12-01T16:00:00.000Z') }, + { status: 'PENDING', createdAt: new Date('2025-12-02T10:00:00.000Z') }, + ] as any); + + const response = await GET(); + const data = await response.json(); + + expect(response.status).toBe(200); + expect(data.trends).toHaveLength(2); + + const dec1 = data.trends.find((t: any) => t.date === '2025-12-01'); + expect(dec1).toEqual({ + date: '2025-12-01', + AVAILABLE: 2, + RESERVED: 1, + PENDING: 0, + }); + + const dec2 = data.trends.find((t: any) => t.date === '2025-12-02'); + expect(dec2).toEqual({ + date: '2025-12-02', + AVAILABLE: 0, + RESERVED: 0, + PENDING: 1, + }); + }); + + it('should return empty trends when no product requests exist', async () => { + vi.mocked(prisma.productRequest.findMany).mockResolvedValue([]); + + const response = await GET(); + const data = await response.json(); + + expect(response.status).toBe(200); + expect(data.trends).toEqual([]); + }); + + it('should only count AVAILABLE, RESERVED, and PENDING statuses', async () => { + vi.mocked(prisma.productRequest.findMany).mockResolvedValue([ + { status: 'AVAILABLE', createdAt: new Date('2025-11-10T10:00:00.000Z') }, + ] as any); + + const response = await GET(); + const data = await response.json(); + + expect(response.status).toBe(200); + const entry = data.trends[0]; + expect(entry.AVAILABLE).toBe(1); + expect(entry.RESERVED).toBe(0); + expect(entry.PENDING).toBe(0); + }); + + it('should return 500 when a database error occurs', async () => { + vi.mocked(prisma.productRequest.findMany).mockRejectedValue( + new Error('DB error') + ); + + const response = await GET(); + const data = await response.json(); + + expect(response.status).toBe(500); + expect(data).toEqual({ error: 'Failed to fetch product status trends' }); + }); +}); diff --git a/src/app/api/analytics/product-status-trends/route.ts b/src/app/api/analytics/product-status-trends/route.ts new file mode 100644 index 0000000..f475069 --- /dev/null +++ b/src/app/api/analytics/product-status-trends/route.ts @@ -0,0 +1,50 @@ +import { NextResponse } from 'next/server'; +import { prisma } from '@/lib/prisma'; + +export async function GET() { + try { + // Get all product requests with their status and creation date + const products = await prisma.productRequest.findMany({ + select: { + status: true, + createdAt: true, + }, + orderBy: { + createdAt: 'asc', + }, + }); + + // Group by date and status + const trendsMap = new Map< + string, + { date: string; AVAILABLE: number; RESERVED: number; PENDING: number } + >(); + + products.forEach((product) => { + const dateKey = product.createdAt.toISOString().split('T')[0]; + if (!trendsMap.has(dateKey)) { + trendsMap.set(dateKey, { + date: dateKey, + AVAILABLE: 0, + RESERVED: 0, + PENDING: 0, + }); + } + + const entry = trendsMap.get(dateKey)!; + if (product.status === 'AVAILABLE') entry.AVAILABLE++; + else if (product.status === 'RESERVED') entry.RESERVED++; + else if (product.status === 'PENDING') entry.PENDING++; + }); + + const trends = Array.from(trendsMap.values()); + + return NextResponse.json({ trends }); + } catch (error) { + console.error('Error fetching product status trends:', error); + return NextResponse.json( + { error: 'Failed to fetch product status trends' }, + { status: 500 } + ); + } +} diff --git a/src/app/api/analytics/supplier-activity/route.test.ts b/src/app/api/analytics/supplier-activity/route.test.ts new file mode 100644 index 0000000..afbf7fc --- /dev/null +++ b/src/app/api/analytics/supplier-activity/route.test.ts @@ -0,0 +1,100 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { GET } from './route'; +import { prisma } from '@/lib/prisma'; + +vi.mock('@/lib/prisma', () => ({ + prisma: { + supplier: { + findMany: vi.fn(), + }, + }, +})); + +const mockSuppliers = [ + { + id: 's1', + name: 'Supplier A', + cadence: 'WEEKLY', + _count: { products: 10 }, + }, + { + id: 's2', + name: 'Supplier B', + cadence: 'DAILY', + _count: { products: 5 }, + }, + { + id: 's3', + name: 'Supplier C', + cadence: 'MONTHLY', + _count: { products: 2 }, + }, +]; + +describe('/api/analytics/supplier-activity - GET', () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.spyOn(console, 'error').mockImplementation(() => {}); + }); + + it('should return activity list and cadenceBreakdown', async () => { + vi.mocked(prisma.supplier.findMany).mockResolvedValue(mockSuppliers as any); + + const response = await GET(); + const data = await response.json(); + + expect(response.status).toBe(200); + expect(data.activity).toHaveLength(3); + expect(data.activity[0]).toMatchObject({ + supplierId: 's1', + name: 'Supplier A', + cadence: 'WEEKLY', + productCount: 10, + }); + }); + + it('should correctly calculate cadenceBreakdown', async () => { + vi.mocked(prisma.supplier.findMany).mockResolvedValue(mockSuppliers as any); + + const response = await GET(); + const data = await response.json(); + + expect(data.cadenceBreakdown).toEqual({ + DAILY: 1, + WEEKLY: 1, + BIWEEKLY: 0, + MONTHLY: 1, + TBD: 0, + }); + }); + + it('should return empty results when no suppliers exist', async () => { + vi.mocked(prisma.supplier.findMany).mockResolvedValue([]); + + const response = await GET(); + const data = await response.json(); + + expect(response.status).toBe(200); + expect(data.activity).toEqual([]); + expect(data.cadenceBreakdown).toEqual({ + DAILY: 0, + WEEKLY: 0, + BIWEEKLY: 0, + MONTHLY: 0, + TBD: 0, + }); + }); + + it('should return 500 when a database error occurs', async () => { + vi.mocked(prisma.supplier.findMany).mockRejectedValue( + new Error('DB error') + ); + + const response = await GET(); + const data = await response.json(); + + expect(response.status).toBe(500); + expect(data).toEqual({ error: 'Failed to fetch supplier activity' }); + }); +}); diff --git a/src/app/api/analytics/supplier-activity/route.ts b/src/app/api/analytics/supplier-activity/route.ts new file mode 100644 index 0000000..eeeba1a --- /dev/null +++ b/src/app/api/analytics/supplier-activity/route.ts @@ -0,0 +1,52 @@ +import { NextResponse } from 'next/server'; +import { prisma } from '@/lib/prisma'; + +export async function GET() { + try { + // Get all suppliers with their product counts + const suppliers = await prisma.supplier.findMany({ + select: { + id: true, + name: true, + cadence: true, + _count: { + select: { + products: true, + }, + }, + }, + orderBy: { + products: { + _count: 'desc', + }, + }, + }); + + const activity = suppliers.map((supplier) => ({ + supplierId: supplier.id, + name: supplier.name, + cadence: supplier.cadence, + productCount: supplier._count.products, + })); + + // Get cadence breakdown + const cadenceBreakdown = { + DAILY: suppliers.filter((s) => s.cadence === 'DAILY').length, + WEEKLY: suppliers.filter((s) => s.cadence === 'WEEKLY').length, + BIWEEKLY: suppliers.filter((s) => s.cadence === 'BIWEEKLY').length, + MONTHLY: suppliers.filter((s) => s.cadence === 'MONTHLY').length, + TBD: suppliers.filter((s) => s.cadence === 'TBD').length, + }; + + return NextResponse.json({ + activity: activity, + cadenceBreakdown, + }); + } catch (error) { + console.error('Error fetching supplier activity:', error); + return NextResponse.json( + { error: 'Failed to fetch supplier activity' }, + { status: 500 } + ); + } +} diff --git a/src/app/api/analytics/supplier-metrics/route.test.ts b/src/app/api/analytics/supplier-metrics/route.test.ts new file mode 100644 index 0000000..981a9d8 --- /dev/null +++ b/src/app/api/analytics/supplier-metrics/route.test.ts @@ -0,0 +1,159 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { NextRequest } from 'next/server'; +import { GET } from './route'; +import { prisma } from '@/lib/prisma'; + +vi.mock('@/lib/prisma', () => ({ + prisma: { + productRequest: { + findMany: vi.fn(), + }, + }, +})); + +const makeRequest = (supplierId?: string) => + new NextRequest( + `http://localhost/api/analytics/supplier-metrics${supplierId ? `?supplierId=${supplierId}` : ''}` + ); + +const makeProduct = ( + status: string, + createdAt: Date, + updatedAt: Date, + overrides?: Partial +) => ({ + id: Math.random().toString(), + status, + createdAt, + updatedAt, + quantity: 10, + productType: { + protein: false, + produce: false, + shelfStable: false, + shelfStableIndividualServing: false, + alreadyPreparedFood: false, + other: false, + ...overrides?.productType, + }, + ...overrides, +}); + +describe('/api/analytics/supplier-metrics - GET', () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.spyOn(console, 'error').mockImplementation(() => {}); + }); + + it('should return 400 when supplierId is missing', async () => { + const response = await GET(makeRequest()); + const data = await response.json(); + + expect(response.status).toBe(400); + expect(data).toEqual({ error: 'Supplier ID is required' }); + }); + + it('should return statusBreakdown correctly', async () => { + const now = new Date(); + vi.mocked(prisma.productRequest.findMany).mockResolvedValue([ + makeProduct('AVAILABLE', now, now), + makeProduct('AVAILABLE', now, now), + makeProduct('RESERVED', now, now), + makeProduct('PENDING', now, now), + ] as any); + + const response = await GET(makeRequest('supplier-1')); + const data = await response.json(); + + expect(response.status).toBe(200); + expect(data.statusBreakdown).toEqual({ + AVAILABLE: 2, + RESERVED: 1, + PENDING: 1, + }); + }); + + it('should calculate claimSpeeds correctly', async () => { + const base = new Date('2025-12-01T00:00:00.000Z'); + const h12 = new Date(base.getTime() + 12 * 60 * 60 * 1000); // 12h later + const h36 = new Date(base.getTime() + 36 * 60 * 60 * 1000); // 36h later + const h100 = new Date(base.getTime() + 100 * 60 * 60 * 1000); // 100h later + const h200 = new Date(base.getTime() + 200 * 60 * 60 * 1000); // 200h later + + vi.mocked(prisma.productRequest.findMany).mockResolvedValue([ + makeProduct('RESERVED', base, h12), + makeProduct('RESERVED', base, h36), + makeProduct('PENDING', base, h100), + makeProduct('PENDING', base, h200), + ] as any); + + const response = await GET(makeRequest('supplier-1')); + const data = await response.json(); + + expect(data.claimSpeeds).toEqual({ + within24h: 1, + within48h: 1, + within1week: 1, + moreThan1week: 1, + }); + }); + + it('should return product type breakdown correctly', async () => { + const now = new Date(); + vi.mocked(prisma.productRequest.findMany).mockResolvedValue([ + makeProduct('AVAILABLE', now, now, { + productType: { + protein: true, + produce: false, + shelfStable: false, + shelfStableIndividualServing: false, + alreadyPreparedFood: false, + other: false, + }, + }), + makeProduct('AVAILABLE', now, now, { + productType: { + protein: false, + produce: true, + shelfStable: false, + shelfStableIndividualServing: false, + alreadyPreparedFood: false, + other: false, + }, + }), + ] as any); + + const response = await GET(makeRequest('supplier-1')); + const data = await response.json(); + + expect(data.typeBreakdown.protein).toBe(1); + expect(data.typeBreakdown.produce).toBe(1); + }); + + it('should return totalProducts count', async () => { + const now = new Date(); + vi.mocked(prisma.productRequest.findMany).mockResolvedValue([ + makeProduct('AVAILABLE', now, now), + makeProduct('RESERVED', now, now), + makeProduct('PENDING', now, now), + ] as any); + + const response = await GET(makeRequest('supplier-1')); + const data = await response.json(); + + expect(data.totalProducts).toBe(3); + }); + + it('should return 500 when a database error occurs', async () => { + vi.mocked(prisma.productRequest.findMany).mockRejectedValue( + new Error('DB error') + ); + + const response = await GET(makeRequest('supplier-1')); + const data = await response.json(); + + expect(response.status).toBe(500); + expect(data).toEqual({ error: 'Failed to fetch supplier metrics' }); + }); +}); diff --git a/src/app/api/analytics/supplier-metrics/route.ts b/src/app/api/analytics/supplier-metrics/route.ts new file mode 100644 index 0000000..f4a335d --- /dev/null +++ b/src/app/api/analytics/supplier-metrics/route.ts @@ -0,0 +1,121 @@ +import { NextResponse } from 'next/server'; +import { prisma } from '@/lib/prisma'; + +export async function GET(request: Request) { + try { + const { searchParams } = new URL(request.url); + const supplierId = searchParams.get('supplierId'); + + if (!supplierId) { + return NextResponse.json( + { error: 'Supplier ID is required' }, + { status: 400 } + ); + } + + // Get supplier's products + const products = await prisma.productRequest.findMany({ + where: { + supplierId, + }, + select: { + id: true, + status: true, + createdAt: true, + updatedAt: true, + quantity: true, + productType: { + select: { + protein: true, + produce: true, + shelfStable: true, + shelfStableIndividualServing: true, + alreadyPreparedFood: true, + other: true, + }, + }, + }, + }); + + // Status breakdown + const statusBreakdown = { + AVAILABLE: products.filter((p) => p.status === 'AVAILABLE').length, + RESERVED: products.filter((p) => p.status === 'RESERVED').length, + PENDING: products.filter((p) => p.status === 'PENDING').length, + }; + + // Claim speed analysis + const claimedProducts = products.filter((p) => + ['RESERVED', 'PENDING'].includes(p.status) + ); + const claimSpeeds = { + within24h: 0, + within48h: 0, + within1week: 0, + moreThan1week: 0, + }; + + claimedProducts.forEach((product) => { + const diffMs = product.updatedAt.getTime() - product.createdAt.getTime(); + const diffHours = diffMs / (1000 * 60 * 60); + + if (diffHours <= 24) claimSpeeds.within24h++; + else if (diffHours <= 48) claimSpeeds.within48h++; + else if (diffHours <= 168) claimSpeeds.within1week++; + else claimSpeeds.moreThan1week++; + }); + + // Monthly timeline for last 6 months + const sixMonthsAgo = new Date(); + sixMonthsAgo.setMonth(sixMonthsAgo.getMonth() - 6); + + const monthlyData = new Map< + string, + { month: string; count: number; quantity: number } + >(); + + products + .filter((p) => p.createdAt >= sixMonthsAgo) + .forEach((product) => { + const monthKey = product.createdAt.toISOString().substring(0, 7); // YYYY-MM + if (!monthlyData.has(monthKey)) { + monthlyData.set(monthKey, { month: monthKey, count: 0, quantity: 0 }); + } + const data = monthlyData.get(monthKey)!; + data.count++; + data.quantity += product.quantity; + }); + + const monthlyTimeline = Array.from(monthlyData.values()).sort((a, b) => + a.month.localeCompare(b.month) + ); + + // Product type breakdown + const typeBreakdown = { + protein: products.filter((p) => p.productType.protein).length, + produce: products.filter((p) => p.productType.produce).length, + shelfStable: products.filter((p) => p.productType.shelfStable).length, + shelfStableIndividualServing: products.filter( + (p) => p.productType.shelfStableIndividualServing + ).length, + alreadyPreparedFood: products.filter( + (p) => p.productType.alreadyPreparedFood + ).length, + other: products.filter((p) => p.productType.other).length, + }; + + return NextResponse.json({ + statusBreakdown, + claimSpeeds, + monthlyTimeline, + typeBreakdown, + totalProducts: products.length, + }); + } catch (error) { + console.error('Error fetching supplier metrics:', error); + return NextResponse.json( + { error: 'Failed to fetch supplier metrics' }, + { status: 500 } + ); + } +} diff --git a/src/app/api/analytics/system-health/route.test.ts b/src/app/api/analytics/system-health/route.test.ts new file mode 100644 index 0000000..11c4cc4 --- /dev/null +++ b/src/app/api/analytics/system-health/route.test.ts @@ -0,0 +1,141 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { GET } from './route'; +import { prisma } from '@/lib/prisma'; + +vi.mock('@/lib/prisma', () => ({ + prisma: { + user: { + groupBy: vi.fn(), + count: vi.fn(), + }, + supplier: { + count: vi.fn(), + }, + nonprofit: { + count: vi.fn(), + findMany: vi.fn(), + }, + productRequest: { + count: vi.fn(), + groupBy: vi.fn(), + findMany: vi.fn(), + }, + }, +})); + +describe('/api/analytics/system-health - GET', () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.spyOn(console, 'error').mockImplementation(() => {}); + }); + + it('should return full system health metrics', async () => { + vi.mocked(prisma.user.groupBy).mockResolvedValue([ + { role: 'ADMIN', _count: { id: 2 } }, + { role: 'SUPPLIER', _count: { id: 3 } }, + { role: 'NONPROFIT', _count: { id: 5 } }, + ] as any); + + vi.mocked(prisma.user.count).mockResolvedValue(10); + vi.mocked(prisma.supplier.count).mockResolvedValue(3); + vi.mocked(prisma.nonprofit.count).mockResolvedValue(5); + vi.mocked(prisma.productRequest.count).mockResolvedValue(20); + + vi.mocked(prisma.productRequest.groupBy).mockResolvedValue([ + { status: 'AVAILABLE', _count: { id: 10 } }, + { status: 'RESERVED', _count: { id: 5 } }, + { status: 'PENDING', _count: { id: 5 } }, + ] as any); + + vi.mocked(prisma.productRequest.findMany).mockResolvedValue([ + { + createdAt: new Date('2025-12-01T00:00:00.000Z'), + updatedAt: new Date('2025-12-01T12:00:00.000Z'), // 12h diff + }, + { + createdAt: new Date('2025-12-02T00:00:00.000Z'), + updatedAt: new Date('2025-12-02T24:00:00.000Z'), // 24h diff + }, + ] as any); + + vi.mocked(prisma.nonprofit.findMany).mockResolvedValue([ + { nonprofitDocumentApproval: true }, + { nonprofitDocumentApproval: true }, + { nonprofitDocumentApproval: false }, + { nonprofitDocumentApproval: null }, + ] as any); + + const response = await GET(); + const data = await response.json(); + + expect(response.status).toBe(200); + expect(data.totalUsers).toBe(10); + expect(data.totalSuppliers).toBe(3); + expect(data.totalNonprofits).toBe(5); + expect(data.totalProducts).toBe(20); + expect(data.usersByRole).toEqual({ + ADMIN: 2, + STAFF: 0, + SUPPLIER: 3, + NONPROFIT: 5, + }); + expect(data.productsByStatus).toEqual({ + AVAILABLE: 10, + RESERVED: 5, + PENDING: 5, + }); + // avg of 12h and 24h = 18h + expect(data.avgClaimTimeHours).toBe(18); + // 2 approved out of 3 with decisions = 0.67 + expect(data.approvalRate).toBe(0.67); + }); + + it('should return avgClaimTimeHours of 0 when no claimed products', async () => { + vi.mocked(prisma.user.groupBy).mockResolvedValue([] as any); + vi.mocked(prisma.user.count).mockResolvedValue(0); + vi.mocked(prisma.supplier.count).mockResolvedValue(0); + vi.mocked(prisma.nonprofit.count).mockResolvedValue(0); + vi.mocked(prisma.productRequest.count).mockResolvedValue(0); + vi.mocked(prisma.productRequest.groupBy).mockResolvedValue([] as any); + vi.mocked(prisma.productRequest.findMany).mockResolvedValue([]); + vi.mocked(prisma.nonprofit.findMany).mockResolvedValue([]); + + const response = await GET(); + const data = await response.json(); + + expect(response.status).toBe(200); + expect(data.avgClaimTimeHours).toBe(0); + }); + + it('should return approvalRate of 0 when no decisions made', async () => { + vi.mocked(prisma.user.groupBy).mockResolvedValue([] as any); + vi.mocked(prisma.user.count).mockResolvedValue(0); + vi.mocked(prisma.supplier.count).mockResolvedValue(0); + vi.mocked(prisma.nonprofit.count).mockResolvedValue(0); + vi.mocked(prisma.productRequest.count).mockResolvedValue(0); + vi.mocked(prisma.productRequest.groupBy).mockResolvedValue([] as any); + vi.mocked(prisma.productRequest.findMany).mockResolvedValue([]); + // all nonprofits still pending + vi.mocked(prisma.nonprofit.findMany).mockResolvedValue([ + { nonprofitDocumentApproval: null }, + { nonprofitDocumentApproval: null }, + ] as any); + + const response = await GET(); + const data = await response.json(); + + expect(response.status).toBe(200); + expect(data.approvalRate).toBe(0); + }); + + it('should return 500 when a database error occurs', async () => { + vi.mocked(prisma.user.groupBy).mockRejectedValue(new Error('DB error')); + + const response = await GET(); + const data = await response.json(); + + expect(response.status).toBe(500); + expect(data).toEqual({ error: 'Failed to fetch system health' }); + }); +}); diff --git a/src/app/api/analytics/system-health/route.ts b/src/app/api/analytics/system-health/route.ts new file mode 100644 index 0000000..eb90d01 --- /dev/null +++ b/src/app/api/analytics/system-health/route.ts @@ -0,0 +1,102 @@ +import { NextResponse } from 'next/server'; +import { prisma } from '@/lib/prisma'; + +export async function GET() { + try { + // Get total users by role + const userCounts = await prisma.user.groupBy({ + by: ['role'], + _count: { + id: true, + }, + }); + + const usersByRole = { + ADMIN: userCounts.find((u) => u.role === 'ADMIN')?._count.id || 0, + STAFF: userCounts.find((u) => u.role === 'STAFF')?._count.id || 0, + SUPPLIER: userCounts.find((u) => u.role === 'SUPPLIER')?._count.id || 0, + NONPROFIT: userCounts.find((u) => u.role === 'NONPROFIT')?._count.id || 0, + }; + + // Get total counts + const totalUsers = await prisma.user.count(); + const totalSuppliers = await prisma.supplier.count(); + const totalNonprofits = await prisma.nonprofit.count(); + const totalProducts = await prisma.productRequest.count(); + + // Get product status counts + const productStatusCounts = await prisma.productRequest.groupBy({ + by: ['status'], + _count: { + id: true, + }, + }); + + const productsByStatus = { + AVAILABLE: + productStatusCounts.find((p) => p.status === 'AVAILABLE')?._count.id || + 0, + RESERVED: + productStatusCounts.find((p) => p.status === 'RESERVED')?._count.id || + 0, + PENDING: + productStatusCounts.find((p) => p.status === 'PENDING')?._count.id || 0, + }; + + // Calculate average claim time calculated this from creation to claim status + const claimedProducts = await prisma.productRequest.findMany({ + where: { + status: { + in: ['RESERVED', 'PENDING'], + }, + }, + select: { + createdAt: true, + updatedAt: true, + }, + }); + + let avgClaimTimeHours = 0; + if (claimedProducts.length > 0) { + const totalHours = claimedProducts.reduce((sum, product) => { + const diffMs = + product.updatedAt.getTime() - product.createdAt.getTime(); + return sum + diffMs / (1000 * 60 * 60); + }, 0); + avgClaimTimeHours = totalHours / claimedProducts.length; + } + + // Get nonprofit approval rate + const nonprofitsWithApproval = await prisma.nonprofit.findMany({ + select: { + nonprofitDocumentApproval: true, + }, + }); + + const approvedCount = nonprofitsWithApproval.filter( + (n) => n.nonprofitDocumentApproval === true + ).length; + const totalWithDecision = nonprofitsWithApproval.filter( + (n) => n.nonprofitDocumentApproval !== null + ).length; + const approvalRate = + totalWithDecision > 0 ? approvedCount / totalWithDecision : 0; + + return NextResponse.json({ + totalUsers, + usersByRole, + totalSuppliers, + totalNonprofits, + totalProducts, + productsByStatus, + avgClaimTimeHours: Math.round(avgClaimTimeHours * 10) / 10, + approvalRate: Math.round(approvalRate * 100) / 100, + }); + } catch (error) { + console.error('Error fetching system health:', error); + return NextResponse.json( + { error: 'Failed to fetch system health' }, + { status: 500 } + ); + } +} diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 1668f56..9f17058 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -3,6 +3,7 @@ import localFont from 'next/font/local'; import './globals.css'; import { Header } from '@/components/layout/header'; import { SessionProvider } from 'next-auth/react'; +import { auth } from '@/lib/auth'; import { Toaster } from '@/components/ui/toaster'; import { Footer } from '@/components/layout/footer'; import { ThemeProvider } from '@/components/layout/theme-provider'; @@ -29,13 +30,14 @@ export default async function RootLayout({ }: Readonly<{ children: React.ReactNode; }>) { + const session = await auth(); return ( - +
{children}