From 3d741fc9001c72fd168d7fae2ae8368c94db0509 Mon Sep 17 00:00:00 2001 From: trickkyricky Date: Sun, 22 Feb 2026 00:50:51 -0600 Subject: [PATCH 1/6] feat: rough draft ui with dashboard for each page | api mutli call fix | split up huge components into multi files --- package-lock.json | 375 +++++++- package.json | 1 + src/app/admin/_components/NonprofitsTab.tsx | 309 +++++++ src/app/admin/_components/OverviewTab.tsx | 275 ++++++ .../admin/_components/ProductRequestsTab.tsx | 95 ++ src/app/admin/_components/SuppliersTab.tsx | 64 ++ src/app/admin/_components/TabNav.tsx | 132 +++ src/app/admin/_hooks/useAdminAnalytics.ts | 63 ++ src/app/admin/_hooks/useAdminData.ts | 122 +++ src/app/admin/_hooks/useApproval.ts | 99 ++ src/app/admin/_types/index.ts | 60 ++ src/app/admin/page.tsx | 733 ++------------- .../api/analytics/claims-over-time/route.ts | 33 + .../analytics/nonprofit-engagement/route.ts | 73 ++ .../api/analytics/nonprofit-metrics/route.ts | 216 +++++ .../analytics/product-distribution/route.ts | 53 ++ .../analytics/product-status-trends/route.ts | 50 + .../api/analytics/supplier-activity/route.ts | 52 ++ .../api/analytics/supplier-metrics/route.ts | 121 +++ src/app/api/analytics/system-health/route.ts | 102 +++ src/app/layout.tsx | 4 +- .../_components/AvailableProductsTab.tsx | 161 ++++ src/app/nonprofit/_components/ClaimsTab.tsx | 192 ++++ src/app/nonprofit/_components/OverviewTab.tsx | 198 ++++ src/app/nonprofit/_components/TabNav.tsx | 41 + src/app/nonprofit/_hooks/useClaim.ts | 169 ++++ src/app/nonprofit/_hooks/useNonprofitData.ts | 123 +++ .../nonprofit/_hooks/useNonprofitMetrics.ts | 26 + src/app/nonprofit/_types/index.ts | 108 +++ src/app/nonprofit/page.tsx | 673 ++------------ src/app/supplier/_components/OverviewTab.tsx | 199 ++++ .../_components/ProductDetailsInputs.tsx | 197 ++++ src/app/supplier/_components/ProductsTab.tsx | 302 ++++++ src/app/supplier/_components/TabNav.tsx | 34 + src/app/supplier/_hooks/useSupplierData.ts | 104 +++ src/app/supplier/_hooks/useSupplierForm.ts | 227 +++++ src/app/supplier/_hooks/useSupplierMetrics.ts | 26 + src/app/supplier/_types/index.ts | 64 ++ src/app/supplier/page.tsx | 864 ++---------------- .../Admin/ApprovalConfirmationPopup.tsx | 106 +++ .../Nonprofit/ClaimConfirmationPopup.tsx | 94 ++ .../Nonprofit/ClaimedItemDetailsPopup.tsx | 11 +- .../Supplier/DeletionConfirmationPopup.tsx | 5 +- src/components/charts/AreaChartComponent.tsx | 60 ++ src/components/charts/BarChartComponent.tsx | 133 +++ src/components/charts/DonutChart.tsx | 76 ++ src/components/charts/KPICard.tsx | 46 + src/components/charts/LineChartComponent.tsx | 61 ++ src/components/ui/toast.tsx | 1 + src/components/ui/toaster.tsx | 2 +- src/hooks/use-toast.ts | 2 +- 51 files changed, 5294 insertions(+), 2043 deletions(-) create mode 100644 src/app/admin/_components/NonprofitsTab.tsx create mode 100644 src/app/admin/_components/OverviewTab.tsx create mode 100644 src/app/admin/_components/ProductRequestsTab.tsx create mode 100644 src/app/admin/_components/SuppliersTab.tsx create mode 100644 src/app/admin/_components/TabNav.tsx create mode 100644 src/app/admin/_hooks/useAdminAnalytics.ts create mode 100644 src/app/admin/_hooks/useAdminData.ts create mode 100644 src/app/admin/_hooks/useApproval.ts create mode 100644 src/app/admin/_types/index.ts create mode 100644 src/app/api/analytics/claims-over-time/route.ts create mode 100644 src/app/api/analytics/nonprofit-engagement/route.ts create mode 100644 src/app/api/analytics/nonprofit-metrics/route.ts create mode 100644 src/app/api/analytics/product-distribution/route.ts create mode 100644 src/app/api/analytics/product-status-trends/route.ts create mode 100644 src/app/api/analytics/supplier-activity/route.ts create mode 100644 src/app/api/analytics/supplier-metrics/route.ts create mode 100644 src/app/api/analytics/system-health/route.ts create mode 100644 src/app/nonprofit/_components/AvailableProductsTab.tsx create mode 100644 src/app/nonprofit/_components/ClaimsTab.tsx create mode 100644 src/app/nonprofit/_components/OverviewTab.tsx create mode 100644 src/app/nonprofit/_components/TabNav.tsx create mode 100644 src/app/nonprofit/_hooks/useClaim.ts create mode 100644 src/app/nonprofit/_hooks/useNonprofitData.ts create mode 100644 src/app/nonprofit/_hooks/useNonprofitMetrics.ts create mode 100644 src/app/nonprofit/_types/index.ts create mode 100644 src/app/supplier/_components/OverviewTab.tsx create mode 100644 src/app/supplier/_components/ProductDetailsInputs.tsx create mode 100644 src/app/supplier/_components/ProductsTab.tsx create mode 100644 src/app/supplier/_components/TabNav.tsx create mode 100644 src/app/supplier/_hooks/useSupplierData.ts create mode 100644 src/app/supplier/_hooks/useSupplierForm.ts create mode 100644 src/app/supplier/_hooks/useSupplierMetrics.ts create mode 100644 src/app/supplier/_types/index.ts create mode 100644 src/components/Admin/ApprovalConfirmationPopup.tsx create mode 100644 src/components/Nonprofit/ClaimConfirmationPopup.tsx create mode 100644 src/components/charts/AreaChartComponent.tsx create mode 100644 src/components/charts/BarChartComponent.tsx create mode 100644 src/components/charts/DonutChart.tsx create mode 100644 src/components/charts/KPICard.tsx create mode 100644 src/components/charts/LineChartComponent.tsx diff --git a/package-lock.json b/package-lock.json index 9ffb19e..2848327 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" @@ -54,6 +55,9 @@ "react-email": "^4.0.7", "tailwindcss": "^3.4.1", "typescript": "^5" + }, + "engines": { + "node": ">=20.0.0" } }, "node_modules/@alloc/quick-lru": { @@ -3597,6 +3601,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/@rtsao/scc": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz", @@ -3641,7 +3681,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": { @@ -3674,6 +3719,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/json5": { "version": "0.0.29", "resolved": "https://registry.npmjs.org/@types/json5/-/json5-0.0.29.tgz", @@ -3719,6 +3827,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", @@ -5099,6 +5213,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", @@ -5191,6 +5426,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-is": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", @@ -5679,6 +5920,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", @@ -6205,7 +6456,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": { @@ -6912,6 +7162,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", @@ -6973,6 +7233,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", @@ -9445,6 +9714,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", @@ -9538,6 +9830,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", @@ -9588,6 +9925,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", @@ -10789,6 +11132,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/tinyexec": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.2.tgz", @@ -11161,6 +11510,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/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", diff --git a/package.json b/package.json index 4638c67..f1f22bf 100644 --- a/package.json +++ b/package.json @@ -43,6 +43,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/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..a0636ac --- /dev/null +++ b/src/app/admin/_components/OverviewTab.tsx @@ -0,0 +1,275 @@ +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 { 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.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.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.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.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.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.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.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.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}