Compare commits
40 Commits
51e18870b6
...
with-test
| Author | SHA1 | Date | |
|---|---|---|---|
| 3e70de3a5a | |||
| f42b452899 | |||
| 161cd1684a | |||
| 2e4b95f29c | |||
| ee3bfec8b0 | |||
| 8e607478d5 | |||
| 6d4ee93ab3 | |||
| 350e9b96e8 | |||
| 533d8cb8e5 | |||
| 4f73ba5a0e | |||
| 26e8830df2 | |||
| 6e697cb5d8 | |||
| a714c56e81 | |||
| a6b6763fa4 | |||
| d717040f03 | |||
| 9f7b76b3cb | |||
| 213efdba87 | |||
| 5eea430ebd | |||
| 8125ca6567 | |||
| 77cbc64b33 | |||
|
|
03c59c188e | ||
|
|
f1062f4805 | ||
|
|
34fdd0ba8e | ||
|
|
0d0571ebf2 | ||
|
|
0607d79484 | ||
|
|
57a16d089d | ||
|
|
fbcbf895be | ||
|
|
4519bc6080 | ||
|
|
bf02684736 | ||
|
|
d9d6a63e4a | ||
|
|
c7f20e2f32 | ||
|
|
d3a63b0354 | ||
|
|
9d945c33f9 | ||
|
|
8ae8145d86 | ||
|
|
0ff84686f0 | ||
|
|
1dcc7a5990 | ||
|
|
725d88fec2 | ||
|
|
c62a335424 | ||
|
|
baca483fcb | ||
|
|
ee8b12e59c |
@@ -11,7 +11,7 @@ RUN apk add --no-cache libc6-compat
|
||||
WORKDIR /app
|
||||
|
||||
# Copy package files
|
||||
COPY package.json package-lock.json* ./
|
||||
COPY package.json package-lock.json* .npmrc* ./
|
||||
RUN npm ci
|
||||
|
||||
# Rebuild the source code only when needed
|
||||
|
||||
@@ -10,7 +10,7 @@ WORKDIR /app
|
||||
RUN apk add --no-cache libc6-compat openssl
|
||||
|
||||
# Copy package files
|
||||
COPY package.json package-lock.json* ./
|
||||
COPY package.json package-lock.json* .npmrc* ./
|
||||
|
||||
# Install dependencies
|
||||
RUN npm install && npm install tailwindcss-animate
|
||||
|
||||
594
package-lock.json
generated
594
package-lock.json
generated
@@ -8,6 +8,7 @@
|
||||
"name": "mopc-platform",
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"@anthropic-ai/sdk": "^0.78.0",
|
||||
"@auth/prisma-adapter": "^2.7.4",
|
||||
"@blocknote/core": "^0.46.2",
|
||||
"@blocknote/mantine": "^0.46.2",
|
||||
@@ -37,9 +38,11 @@
|
||||
"@radix-ui/react-slot": "^1.1.1",
|
||||
"@radix-ui/react-switch": "^1.1.2",
|
||||
"@radix-ui/react-tabs": "^1.1.2",
|
||||
"@radix-ui/react-toggle": "^1.1.10",
|
||||
"@radix-ui/react-tooltip": "^1.1.6",
|
||||
"@tailwindcss/postcss": "^4.1.18",
|
||||
"@tanstack/react-query": "^5.62.0",
|
||||
"@tremor/react": "^3.18.7",
|
||||
"@trpc/client": "^11.0.0-rc.678",
|
||||
"@trpc/react-query": "^11.0.0-rc.678",
|
||||
"@trpc/server": "^11.0.0-rc.678",
|
||||
@@ -72,7 +75,6 @@
|
||||
"react-hook-form": "^7.54.2",
|
||||
"react-leaflet": "^5.0.0",
|
||||
"react-phone-number-input": "^3.4.14",
|
||||
"recharts": "^3.7.0",
|
||||
"sonner": "^2.0.7",
|
||||
"superjson": "^2.2.2",
|
||||
"tailwind-merge": "^3.4.0",
|
||||
@@ -118,6 +120,26 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/@anthropic-ai/sdk": {
|
||||
"version": "0.78.0",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.78.0.tgz",
|
||||
"integrity": "sha512-PzQhR715td/m1UaaN5hHXjYB8Gl2lF9UVhrrGrZeysiF6Rb74Wc9GCB8hzLdzmQtBd1qe89F9OptgB9Za1Ib5w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"json-schema-to-ts": "^3.1.1"
|
||||
},
|
||||
"bin": {
|
||||
"anthropic-ai-sdk": "bin/cli"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"zod": "^3.25.0 || ^4.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"zod": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@auth/core": {
|
||||
"version": "0.41.1",
|
||||
"resolved": "https://registry.npmjs.org/@auth/core/-/core-0.41.1.tgz",
|
||||
@@ -1024,6 +1046,40 @@
|
||||
"prosemirror-view": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@headlessui/react": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@headlessui/react/-/react-2.2.0.tgz",
|
||||
"integrity": "sha512-RzCEg+LXsuI7mHiSomsu/gBJSjpupm6A1qIZ5sWjd7JhARNlMiSA4kKfJpCKwU9tE+zMRterhhrP74PvfJrpXQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@floating-ui/react": "^0.26.16",
|
||||
"@react-aria/focus": "^3.17.1",
|
||||
"@react-aria/interactions": "^3.21.3",
|
||||
"@tanstack/react-virtual": "^3.8.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^18 || ^19 || ^19.0.0-rc",
|
||||
"react-dom": "^18 || ^19 || ^19.0.0-rc"
|
||||
}
|
||||
},
|
||||
"node_modules/@headlessui/react/node_modules/@floating-ui/react": {
|
||||
"version": "0.26.28",
|
||||
"resolved": "https://registry.npmjs.org/@floating-ui/react/-/react-0.26.28.tgz",
|
||||
"integrity": "sha512-yORQuuAtVpiRjpMhdc0wJj06b9JFjrYF4qp96j++v2NBpbi6SEGF7donUJ3TMieerQ6qVkAv1tgr7L4r5roTqw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@floating-ui/react-dom": "^2.1.2",
|
||||
"@floating-ui/utils": "^0.2.8",
|
||||
"tabbable": "^6.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=16.8.0",
|
||||
"react-dom": ">=16.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@hookform/resolvers": {
|
||||
"version": "3.10.0",
|
||||
"resolved": "https://registry.npmjs.org/@hookform/resolvers/-/resolvers-3.10.0.tgz",
|
||||
@@ -1624,16 +1680,6 @@
|
||||
"react": "^18.x || ^19.x"
|
||||
}
|
||||
},
|
||||
"node_modules/@mantine/utils": {
|
||||
"version": "6.0.22",
|
||||
"resolved": "https://registry.npmjs.org/@mantine/utils/-/utils-6.0.22.tgz",
|
||||
"integrity": "sha512-RSKlNZvxhMCkOFZ6slbYvZYbWjHUM+PxDQnupIOxIdsTZQQjx/BFfrfJ7kQFOP+g7MtpOds8weAetEs5obwMOQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"peerDependencies": {
|
||||
"react": ">=16.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas": {
|
||||
"version": "0.1.80",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas/-/canvas-0.1.80.tgz",
|
||||
@@ -2048,7 +2094,7 @@
|
||||
"version": "1.58.0",
|
||||
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.58.0.tgz",
|
||||
"integrity": "sha512-fWza+Lpbj6SkQKCrU6si4iu+fD2dD3gxNHFhUPxsfXBPhnv3rRSQVd0NtBUT9Z/RhF/boCBcuUaMUSTRTopjZg==",
|
||||
"devOptional": true,
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright": "1.58.0"
|
||||
@@ -2086,7 +2132,7 @@
|
||||
"version": "6.19.2",
|
||||
"resolved": "https://registry.npmjs.org/@prisma/config/-/config-6.19.2.tgz",
|
||||
"integrity": "sha512-kadBGDl+aUswv/zZMk9Mx0C8UZs1kjao8H9/JpI4Wh4SHZaM7zkTwiKn/iFLfRg+XtOAo/Z/c6pAYhijKl0nzQ==",
|
||||
"devOptional": true,
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"c12": "3.1.0",
|
||||
@@ -2099,14 +2145,14 @@
|
||||
"version": "6.19.2",
|
||||
"resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-6.19.2.tgz",
|
||||
"integrity": "sha512-lFnEZsLdFLmEVCVNdskLDCL8Uup41GDfU0LUfquw+ercJC8ODTuL0WNKgOKmYxCJVvFwf0OuZBzW99DuWmoH2A==",
|
||||
"devOptional": true,
|
||||
"dev": true,
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/@prisma/engines": {
|
||||
"version": "6.19.2",
|
||||
"resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-6.19.2.tgz",
|
||||
"integrity": "sha512-TTkJ8r+uk/uqczX40wb+ODG0E0icVsMgwCTyTHXehaEfb0uo80M9g1aW1tEJrxmFHeOZFXdI2sTA1j1AgcHi4A==",
|
||||
"devOptional": true,
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
@@ -2120,14 +2166,14 @@
|
||||
"version": "7.1.1-3.c2990dca591cba766e3b7ef5d9e8a84796e47ab7",
|
||||
"resolved": "https://registry.npmjs.org/@prisma/engines-version/-/engines-version-7.1.1-3.c2990dca591cba766e3b7ef5d9e8a84796e47ab7.tgz",
|
||||
"integrity": "sha512-03bgb1VD5gvuumNf+7fVGBzfpJPjmqV423l/WxsWk2cNQ42JD0/SsFBPhN6z8iAvdHs07/7ei77SKu7aZfq8bA==",
|
||||
"devOptional": true,
|
||||
"dev": true,
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/@prisma/fetch-engine": {
|
||||
"version": "6.19.2",
|
||||
"resolved": "https://registry.npmjs.org/@prisma/fetch-engine/-/fetch-engine-6.19.2.tgz",
|
||||
"integrity": "sha512-h4Ff4Pho+SR1S8XerMCC12X//oY2bG3Iug/fUnudfcXEUnIeRiBdXHFdGlGOgQ3HqKgosTEhkZMvGM9tWtYC+Q==",
|
||||
"devOptional": true,
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@prisma/debug": "6.19.2",
|
||||
@@ -2139,7 +2185,7 @@
|
||||
"version": "6.19.2",
|
||||
"resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-6.19.2.tgz",
|
||||
"integrity": "sha512-PGLr06JUSTqIvztJtAzIxOwtWKtJm5WwOG6xpsgD37Rc84FpfUBGLKz65YpJBGtkRQGXTYEFie7pYALocC3MtA==",
|
||||
"devOptional": true,
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@prisma/debug": "6.19.2"
|
||||
@@ -3261,6 +3307,31 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-toggle": {
|
||||
"version": "1.1.10",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-toggle/-/react-toggle-1.1.10.tgz",
|
||||
"integrity": "sha512-lS1odchhFTeZv3xwHH31YPObmJn8gOg7Lq12inrr0+BH/l3Tsq32VfjqH1oh80ARM3mlkfMic15n0kg4sD1poQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/primitive": "1.1.3",
|
||||
"@radix-ui/react-primitive": "2.1.3",
|
||||
"@radix-ui/react-use-controllable-state": "1.2.2"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"@types/react-dom": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-tooltip": {
|
||||
"version": "1.2.8",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-tooltip/-/react-tooltip-1.2.8.tgz",
|
||||
@@ -3496,6 +3567,73 @@
|
||||
"integrity": "sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@react-aria/focus": {
|
||||
"version": "3.21.4",
|
||||
"resolved": "https://registry.npmjs.org/@react-aria/focus/-/focus-3.21.4.tgz",
|
||||
"integrity": "sha512-6gz+j9ip0/vFRTKJMl3R30MHopn4i19HqqLfSQfElxJD+r9hBnYG1Q6Wd/kl/WRR1+CALn2F+rn06jUnf5sT8Q==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@react-aria/interactions": "^3.27.0",
|
||||
"@react-aria/utils": "^3.33.0",
|
||||
"@react-types/shared": "^3.33.0",
|
||||
"@swc/helpers": "^0.5.0",
|
||||
"clsx": "^2.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1",
|
||||
"react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@react-aria/interactions": {
|
||||
"version": "3.27.0",
|
||||
"resolved": "https://registry.npmjs.org/@react-aria/interactions/-/interactions-3.27.0.tgz",
|
||||
"integrity": "sha512-D27pOy+0jIfHK60BB26AgqjjRFOYdvVSkwC31b2LicIzRCSPOSP06V4gMHuGmkhNTF4+YWDi1HHYjxIvMeiSlA==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@react-aria/ssr": "^3.9.10",
|
||||
"@react-aria/utils": "^3.33.0",
|
||||
"@react-stately/flags": "^3.1.2",
|
||||
"@react-types/shared": "^3.33.0",
|
||||
"@swc/helpers": "^0.5.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1",
|
||||
"react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@react-aria/ssr": {
|
||||
"version": "3.9.10",
|
||||
"resolved": "https://registry.npmjs.org/@react-aria/ssr/-/ssr-3.9.10.tgz",
|
||||
"integrity": "sha512-hvTm77Pf+pMBhuBm760Li0BVIO38jv1IBws1xFm1NoL26PU+fe+FMW5+VZWyANR6nYL65joaJKZqOdTQMkO9IQ==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@swc/helpers": "^0.5.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 12"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@react-aria/utils": {
|
||||
"version": "3.33.0",
|
||||
"resolved": "https://registry.npmjs.org/@react-aria/utils/-/utils-3.33.0.tgz",
|
||||
"integrity": "sha512-yvz7CMH8d2VjwbSa5nGXqjU031tYhD8ddax95VzJsHSPyqHDEGfxul8RkhGV6oO7bVqZxVs6xY66NIgae+FHjw==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@react-aria/ssr": "^3.9.10",
|
||||
"@react-stately/flags": "^3.1.2",
|
||||
"@react-stately/utils": "^3.11.0",
|
||||
"@react-types/shared": "^3.33.0",
|
||||
"@swc/helpers": "^0.5.0",
|
||||
"clsx": "^2.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1",
|
||||
"react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@react-leaflet/core": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@react-leaflet/core/-/core-3.0.0.tgz",
|
||||
@@ -3507,40 +3645,34 @@
|
||||
"react-dom": "^19.0.0"
|
||||
}
|
||||
},
|
||||
"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",
|
||||
"node_modules/@react-stately/flags": {
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@react-stately/flags/-/flags-3.1.2.tgz",
|
||||
"integrity": "sha512-2HjFcZx1MyQXoPqcBGALwWWmgFVUk2TuKVIQxCbRq7fPyWXIl6VHcakCLurdtYC2Iks7zizvz0Idv48MQ38DWg==",
|
||||
"license": "Apache-2.0",
|
||||
"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
|
||||
}
|
||||
"@swc/helpers": "^0.5.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@reduxjs/toolkit/node_modules/immer": {
|
||||
"version": "11.1.3",
|
||||
"resolved": "https://registry.npmjs.org/immer/-/immer-11.1.3.tgz",
|
||||
"integrity": "sha512-6jQTc5z0KJFtr1UgFpIL3N9XSC3saRaI9PwWtzM2pSqkNGtiNkYY2OSwkOGDK2XcTRcLb1pi/aNkKZz0nxVH4Q==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/immer"
|
||||
"node_modules/@react-stately/utils": {
|
||||
"version": "3.11.0",
|
||||
"resolved": "https://registry.npmjs.org/@react-stately/utils/-/utils-3.11.0.tgz",
|
||||
"integrity": "sha512-8LZpYowJ9eZmmYLpudbo/eclIRnbhWIJZ994ncmlKlouNzKohtM8qTC6B1w1pwUbiwGdUoyzLuQbeaIor5Dvcw==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@swc/helpers": "^0.5.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@react-types/shared": {
|
||||
"version": "3.33.0",
|
||||
"resolved": "https://registry.npmjs.org/@react-types/shared/-/shared-3.33.0.tgz",
|
||||
"integrity": "sha512-xuUpP6MyuPmJtzNOqF5pzFUIHH2YogyOQfUQHag54PRmWB7AbjuGWBUv0l1UDmz6+AbzAYGmDVAzcRDOu2PFpw==",
|
||||
"license": "Apache-2.0",
|
||||
"peerDependencies": {
|
||||
"react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@remirror/core-constants": {
|
||||
@@ -3933,12 +4065,7 @@
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
|
||||
"integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
|
||||
"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==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@swc/helpers": {
|
||||
@@ -4250,6 +4377,23 @@
|
||||
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tanstack/react-virtual": {
|
||||
"version": "3.13.18",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/react-virtual/-/react-virtual-3.13.18.tgz",
|
||||
"integrity": "sha512-dZkhyfahpvlaV0rIKnvQiVoWPyURppl6w4m9IwMDpuIjcJ1sD9YGWrt0wISvgU7ewACXx2Ct46WPgI6qAD4v6A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@tanstack/virtual-core": "3.13.18"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/tannerlinsley"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tanstack/store": {
|
||||
"version": "0.7.7",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/store/-/store-0.7.7.tgz",
|
||||
@@ -4260,6 +4404,16 @@
|
||||
"url": "https://github.com/sponsors/tannerlinsley"
|
||||
}
|
||||
},
|
||||
"node_modules/@tanstack/virtual-core": {
|
||||
"version": "3.13.18",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.13.18.tgz",
|
||||
"integrity": "sha512-Mx86Hqu1k39icq2Zusq+Ey2J6dDWTjDvEv43PJtRCoEYTLyfaPnxIQ6iy7YAOK0NV/qOEmZQ/uCufrppZxTgcg==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/tannerlinsley"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/core": {
|
||||
"version": "3.18.0",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/core/-/core-3.18.0.tgz",
|
||||
@@ -4500,6 +4654,87 @@
|
||||
"react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tremor/react": {
|
||||
"version": "3.18.7",
|
||||
"resolved": "https://registry.npmjs.org/@tremor/react/-/react-3.18.7.tgz",
|
||||
"integrity": "sha512-nmqvf/1m0GB4LXc7v2ftdfSLoZhy5WLrhV6HNf0SOriE6/l8WkYeWuhQq8QsBjRi94mUIKLJ/VC3/Y/pj6VubQ==",
|
||||
"license": "Apache 2.0",
|
||||
"dependencies": {
|
||||
"@floating-ui/react": "^0.19.2",
|
||||
"@headlessui/react": "2.2.0",
|
||||
"date-fns": "^3.6.0",
|
||||
"react-day-picker": "^8.10.1",
|
||||
"react-transition-state": "^2.1.2",
|
||||
"recharts": "^2.13.3",
|
||||
"tailwind-merge": "^2.5.2"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^18.0.0",
|
||||
"react-dom": ">=16.6.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tremor/react/node_modules/@floating-ui/react": {
|
||||
"version": "0.19.2",
|
||||
"resolved": "https://registry.npmjs.org/@floating-ui/react/-/react-0.19.2.tgz",
|
||||
"integrity": "sha512-JyNk4A0Ezirq8FlXECvRtQOX/iBe5Ize0W/pLkrZjfHW9GUV7Xnq6zm6fyZuQzaHHqEnVizmvlA96e1/CkZv+w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@floating-ui/react-dom": "^1.3.0",
|
||||
"aria-hidden": "^1.1.3",
|
||||
"tabbable": "^6.0.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=16.8.0",
|
||||
"react-dom": ">=16.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tremor/react/node_modules/@floating-ui/react-dom": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-1.3.0.tgz",
|
||||
"integrity": "sha512-htwHm67Ji5E/pROEAr7f8IKFShuiCKHwUC/UY4vC3I5jiSvGFAYnSYiZO5MlGmads+QqvUkR9ANHEguGrDv72g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@floating-ui/dom": "^1.2.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=16.8.0",
|
||||
"react-dom": ">=16.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tremor/react/node_modules/date-fns": {
|
||||
"version": "3.6.0",
|
||||
"resolved": "https://registry.npmjs.org/date-fns/-/date-fns-3.6.0.tgz",
|
||||
"integrity": "sha512-fRHTG8g/Gif+kSh50gaGEdToemgfj74aRX3swtiouboip5JDLAyDE9F11nHMIcvOaXeOC6D7SpNhi7uFyB7Uww==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/kossnocorp"
|
||||
}
|
||||
},
|
||||
"node_modules/@tremor/react/node_modules/react-day-picker": {
|
||||
"version": "8.10.1",
|
||||
"resolved": "https://registry.npmjs.org/react-day-picker/-/react-day-picker-8.10.1.tgz",
|
||||
"integrity": "sha512-TMx7fNbhLk15eqcMt+7Z7S2KF7mfTId/XJDjKE8f+IUcFn0l08/kI4FiYTL/0yuOLmEcbR4Fwe3GJf/NiiMnPA==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "individual",
|
||||
"url": "https://github.com/sponsors/gpbl"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"date-fns": "^2.28.0 || ^3.0.0",
|
||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tremor/react/node_modules/tailwind-merge": {
|
||||
"version": "2.6.1",
|
||||
"resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-2.6.1.tgz",
|
||||
"integrity": "sha512-Oo6tHdpZsGpkKG88HJ8RR1rg/RdnEkQEfMoEk2x1XRI3F1AxeU+ijRXpiVUF4UbLfcxxRGw6TbUINKYdWVsQTQ==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/dcastil"
|
||||
}
|
||||
},
|
||||
"node_modules/@trpc/client": {
|
||||
"version": "11.9.0",
|
||||
"resolved": "https://registry.npmjs.org/@trpc/client/-/client-11.9.0.tgz",
|
||||
@@ -4799,6 +5034,7 @@
|
||||
"version": "19.2.10",
|
||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.10.tgz",
|
||||
"integrity": "sha512-WPigyYuGhgZ/cTPRXB2EwUw+XvsRA3GqHlsP4qteqrnnjDrApbS7MxcGr/hke5iUoeB7E/gQtrs9I37zAJ0Vjw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"csstype": "^3.2.2"
|
||||
@@ -4808,6 +5044,7 @@
|
||||
"version": "19.2.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz",
|
||||
"integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@types/react": "^19.2.0"
|
||||
@@ -5921,7 +6158,7 @@
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/c12/-/c12-3.1.0.tgz",
|
||||
"integrity": "sha512-uWoS8OU1MEIsOv8p/5a82c3H31LsWVR5qiyXVfBNOzfffjUWtPnhAb4BYI2uG2HfGmZmFjCtui5XNWaps+iFuw==",
|
||||
"devOptional": true,
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"chokidar": "^4.0.3",
|
||||
@@ -6114,7 +6351,7 @@
|
||||
"version": "4.0.3",
|
||||
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz",
|
||||
"integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==",
|
||||
"devOptional": true,
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"readdirp": "^4.0.1"
|
||||
@@ -6130,7 +6367,7 @@
|
||||
"version": "0.1.6",
|
||||
"resolved": "https://registry.npmjs.org/citty/-/citty-0.1.6.tgz",
|
||||
"integrity": "sha512-tskPPKEs8D2KPafUypv2gxwJP8h/OaJmC82QQGGDQcHvXX43xF2VDACcJVmZ0EuSxkpO9Kc4MlrA3q0+FG58AQ==",
|
||||
"devOptional": true,
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"consola": "^3.2.3"
|
||||
@@ -6248,14 +6485,14 @@
|
||||
"version": "0.2.2",
|
||||
"resolved": "https://registry.npmjs.org/confbox/-/confbox-0.2.2.tgz",
|
||||
"integrity": "sha512-1NB+BKqhtNipMsov4xI/NnhCKp9XG9NamYp5PVm9klAT0fsrNPjaFICsCFhNhwZJKNh7zB/3q8qXz0E9oaMNtQ==",
|
||||
"devOptional": true,
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/consola": {
|
||||
"version": "3.4.2",
|
||||
"resolved": "https://registry.npmjs.org/consola/-/consola-3.4.2.tgz",
|
||||
"integrity": "sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==",
|
||||
"devOptional": true,
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "^14.18.0 || >=16.10.0"
|
||||
@@ -6596,7 +6833,7 @@
|
||||
"version": "7.1.5",
|
||||
"resolved": "https://registry.npmjs.org/deepmerge-ts/-/deepmerge-ts-7.1.5.tgz",
|
||||
"integrity": "sha512-HOJkrhaYsweh+W+e74Yn7YStZOilkoPb6fycpwNLKzSPtruFs48nYis0zy5yJz1+ktUhHxoRDJ27RQAWLIJVJw==",
|
||||
"devOptional": true,
|
||||
"dev": true,
|
||||
"license": "BSD-3-Clause",
|
||||
"engines": {
|
||||
"node": ">=16.0.0"
|
||||
@@ -6641,7 +6878,7 @@
|
||||
"version": "6.1.4",
|
||||
"resolved": "https://registry.npmjs.org/defu/-/defu-6.1.4.tgz",
|
||||
"integrity": "sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg==",
|
||||
"devOptional": true,
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/delayed-stream": {
|
||||
@@ -6666,7 +6903,7 @@
|
||||
"version": "2.0.5",
|
||||
"resolved": "https://registry.npmjs.org/destr/-/destr-2.0.5.tgz",
|
||||
"integrity": "sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA==",
|
||||
"devOptional": true,
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/detect-libc": {
|
||||
@@ -6716,6 +6953,16 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/dom-helpers": {
|
||||
"version": "5.2.1",
|
||||
"resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz",
|
||||
"integrity": "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.8.7",
|
||||
"csstype": "^3.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/dompurify": {
|
||||
"version": "3.3.1",
|
||||
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.3.1.tgz",
|
||||
@@ -6730,7 +6977,7 @@
|
||||
"version": "16.6.1",
|
||||
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz",
|
||||
"integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==",
|
||||
"devOptional": true,
|
||||
"dev": true,
|
||||
"license": "BSD-2-Clause",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
@@ -6766,7 +7013,7 @@
|
||||
"version": "3.18.4",
|
||||
"resolved": "https://registry.npmjs.org/effect/-/effect-3.18.4.tgz",
|
||||
"integrity": "sha512-b1LXQJLe9D11wfnOKAk3PKxuqYshQ0Heez+y5pnkd3jLj1yx9QhM72zZ9uUrOQyNvrs2GZZd/3maL0ZV18YuDA==",
|
||||
"devOptional": true,
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@standard-schema/spec": "^1.0.0",
|
||||
@@ -6790,7 +7037,7 @@
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/empathic/-/empathic-2.0.0.tgz",
|
||||
"integrity": "sha512-i6UzDscO/XfAcNYD75CfICkmfLedpyPDdozrLMmQc5ORaQcdMoc21OnlEylMIqI7U8eniKrPMxxtj8k0vhmJhA==",
|
||||
"devOptional": true,
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=14"
|
||||
@@ -7001,16 +7248,6 @@
|
||||
"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/esbuild": {
|
||||
"version": "0.27.2",
|
||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.2.tgz",
|
||||
@@ -7520,7 +7757,7 @@
|
||||
"version": "1.0.8",
|
||||
"resolved": "https://registry.npmjs.org/exsolve/-/exsolve-1.0.8.tgz",
|
||||
"integrity": "sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA==",
|
||||
"devOptional": true,
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/extend": {
|
||||
@@ -7533,7 +7770,7 @@
|
||||
"version": "3.23.2",
|
||||
"resolved": "https://registry.npmjs.org/fast-check/-/fast-check-3.23.2.tgz",
|
||||
"integrity": "sha512-h5+1OzzfCC3Ef7VbtKdcv7zsstUQwUDlYpUTvjeUsJAssPgLn7QzbboPtL5ro04Mq0rPOsMzl7q5hIbRs2wD1A==",
|
||||
"devOptional": true,
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "individual",
|
||||
@@ -7963,7 +8200,7 @@
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/giget/-/giget-2.0.0.tgz",
|
||||
"integrity": "sha512-L5bGsVkxJbJgdnwyuheIunkGatUF/zssUoxxjACCseZYAVbaqdh9Tsmmlkl8vYan09H7sbvKt4pS8GqKLBrEzA==",
|
||||
"devOptional": true,
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"citty": "^0.1.6",
|
||||
@@ -8444,16 +8681,6 @@
|
||||
"integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"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",
|
||||
@@ -9071,6 +9298,19 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/json-schema-to-ts": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/json-schema-to-ts/-/json-schema-to-ts-3.1.1.tgz",
|
||||
"integrity": "sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.18.3",
|
||||
"ts-algebra": "^2.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=16"
|
||||
}
|
||||
},
|
||||
"node_modules/json-schema-traverse": {
|
||||
"version": "0.4.1",
|
||||
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz",
|
||||
@@ -10857,7 +11097,7 @@
|
||||
"version": "1.6.7",
|
||||
"resolved": "https://registry.npmjs.org/node-fetch-native/-/node-fetch-native-1.6.7.tgz",
|
||||
"integrity": "sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q==",
|
||||
"devOptional": true,
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/nodemailer": {
|
||||
@@ -10879,7 +11119,7 @@
|
||||
"version": "0.6.4",
|
||||
"resolved": "https://registry.npmjs.org/nypm/-/nypm-0.6.4.tgz",
|
||||
"integrity": "sha512-1TvCKjZyyklN+JJj2TS3P4uSQEInrM/HkkuSXsEzm1ApPgBffOn8gFguNnZf07r/1X6vlryfIqMUkJKQMzlZiw==",
|
||||
"devOptional": true,
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"citty": "^0.2.0",
|
||||
@@ -10897,7 +11137,7 @@
|
||||
"version": "0.2.0",
|
||||
"resolved": "https://registry.npmjs.org/citty/-/citty-0.2.0.tgz",
|
||||
"integrity": "sha512-8csy5IBFI2ex2hTVpaHN2j+LNE199AgiI7y4dMintrr8i0lQiFn+0AWMZrWdHKIgMOer65f8IThysYhoReqjWA==",
|
||||
"devOptional": true,
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/oauth4webapi": {
|
||||
@@ -11046,7 +11286,7 @@
|
||||
"version": "2.0.11",
|
||||
"resolved": "https://registry.npmjs.org/ohash/-/ohash-2.0.11.tgz",
|
||||
"integrity": "sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ==",
|
||||
"devOptional": true,
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/openai": {
|
||||
@@ -11239,7 +11479,7 @@
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz",
|
||||
"integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==",
|
||||
"devOptional": true,
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/pdf-parse": {
|
||||
@@ -11278,7 +11518,7 @@
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-1.0.0.tgz",
|
||||
"integrity": "sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==",
|
||||
"devOptional": true,
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/performance-now": {
|
||||
@@ -11311,7 +11551,7 @@
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-2.3.0.tgz",
|
||||
"integrity": "sha512-SIqCzDRg0s9npO5XQ3tNZioRY1uK06lA41ynBC1YmFTmnY6FjUjVt6s4LoADmwoig1qqD0oK8h1p/8mlMx8Oig==",
|
||||
"devOptional": true,
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"confbox": "^0.2.2",
|
||||
@@ -11323,7 +11563,7 @@
|
||||
"version": "1.58.0",
|
||||
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.58.0.tgz",
|
||||
"integrity": "sha512-2SVA0sbPktiIY/MCOPX8e86ehA/e+tDNq+e5Y8qjKYti2Z/JG7xnronT/TXTIkKbYGWlCbuucZ6dziEgkoEjQQ==",
|
||||
"devOptional": true,
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright-core": "1.58.0"
|
||||
@@ -11342,7 +11582,7 @@
|
||||
"version": "1.58.0",
|
||||
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.58.0.tgz",
|
||||
"integrity": "sha512-aaoB1RWrdNi3//rOeKuMiS65UCcgOVljU46At6eFcOFPFHWtd2weHRRow6z/n+Lec0Lvu0k9ZPKJSjPugikirw==",
|
||||
"devOptional": true,
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"playwright-core": "cli.js"
|
||||
@@ -11516,7 +11756,7 @@
|
||||
"version": "6.19.2",
|
||||
"resolved": "https://registry.npmjs.org/prisma/-/prisma-6.19.2.tgz",
|
||||
"integrity": "sha512-XTKeKxtQElcq3U9/jHyxSPgiRgeYDKxWTPOf6NkXA0dNj5j40MfEsZkMbyNpwDWCUv7YBFUl7I2VK/6ALbmhEg==",
|
||||
"devOptional": true,
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
@@ -11836,7 +12076,7 @@
|
||||
"version": "6.1.0",
|
||||
"resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-6.1.0.tgz",
|
||||
"integrity": "sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA==",
|
||||
"devOptional": true,
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "individual",
|
||||
@@ -11902,7 +12142,7 @@
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/rc9/-/rc9-2.1.2.tgz",
|
||||
"integrity": "sha512-btXCnMmRIBINM2LDZoEmOogIZU7Qe7zn4BpomSKZ/ykbLObuBdvG+mFq11DL6fjH1DRwHhrlgtYWG96bJiC7Cg==",
|
||||
"devOptional": true,
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"defu": "^6.1.4",
|
||||
@@ -12037,29 +12277,6 @@
|
||||
"react-dom": ">=16.8"
|
||||
}
|
||||
},
|
||||
"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",
|
||||
@@ -12107,6 +12324,21 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/react-smooth": {
|
||||
"version": "4.0.4",
|
||||
"resolved": "https://registry.npmjs.org/react-smooth/-/react-smooth-4.0.4.tgz",
|
||||
"integrity": "sha512-gnGKTpYwqL0Iii09gHobNolvX4Kiq4PKx6eWBCYYix+8cdw+cGo3do906l1NBPKkSWx1DghC1dlWG9L2uGd61Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"fast-equals": "^5.0.1",
|
||||
"prop-types": "^15.8.1",
|
||||
"react-transition-group": "^4.4.5"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-style-singleton": {
|
||||
"version": "2.2.3",
|
||||
"resolved": "https://registry.npmjs.org/react-style-singleton/-/react-style-singleton-2.2.3.tgz",
|
||||
@@ -12146,6 +12378,32 @@
|
||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-transition-group": {
|
||||
"version": "4.4.5",
|
||||
"resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.5.tgz",
|
||||
"integrity": "sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.5.5",
|
||||
"dom-helpers": "^5.0.1",
|
||||
"loose-envify": "^1.4.0",
|
||||
"prop-types": "^15.6.2"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=16.6.0",
|
||||
"react-dom": ">=16.6.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-transition-state": {
|
||||
"version": "2.3.3",
|
||||
"resolved": "https://registry.npmjs.org/react-transition-state/-/react-transition-state-2.3.3.tgz",
|
||||
"integrity": "sha512-wsIyg07ohlWEAYDZHvuXh/DY7mxlcLb0iqVv2aMXJ0gwgPVKNWKhOyNyzuJy/tt/6urSq0WT6BBZ/tdpybaAsQ==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"react": ">=16.8.0",
|
||||
"react-dom": ">=16.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/readable-stream": {
|
||||
"version": "3.6.2",
|
||||
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz",
|
||||
@@ -12164,7 +12422,7 @@
|
||||
"version": "4.1.2",
|
||||
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz",
|
||||
"integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==",
|
||||
"devOptional": true,
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 14.18.0"
|
||||
@@ -12175,49 +12433,48 @@
|
||||
}
|
||||
},
|
||||
"node_modules/recharts": {
|
||||
"version": "3.7.0",
|
||||
"resolved": "https://registry.npmjs.org/recharts/-/recharts-3.7.0.tgz",
|
||||
"integrity": "sha512-l2VCsy3XXeraxIID9fx23eCb6iCBsxUQDnE8tWm6DFdszVAO7WVY/ChAD9wVit01y6B2PMupYiMmQwhgPHc9Ew==",
|
||||
"version": "2.15.4",
|
||||
"resolved": "https://registry.npmjs.org/recharts/-/recharts-2.15.4.tgz",
|
||||
"integrity": "sha512-UT/q6fwS3c1dHbXv2uFgYJ9BMFHu3fwnd7AYZaEQhXuYQ4hgsxLvsUXzGdKeZrW5xopzDCvuA2N41WJ88I7zIw==",
|
||||
"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"
|
||||
"clsx": "^2.0.0",
|
||||
"eventemitter3": "^4.0.1",
|
||||
"lodash": "^4.17.21",
|
||||
"react-is": "^18.3.1",
|
||||
"react-smooth": "^4.0.4",
|
||||
"recharts-scale": "^0.4.4",
|
||||
"tiny-invariant": "^1.3.1",
|
||||
"victory-vendor": "^36.6.8"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
"node": ">=14"
|
||||
},
|
||||
"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"
|
||||
"react": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||
"react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/redux": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz",
|
||||
"integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==",
|
||||
"node_modules/recharts-scale": {
|
||||
"version": "0.4.5",
|
||||
"resolved": "https://registry.npmjs.org/recharts-scale/-/recharts-scale-0.4.5.tgz",
|
||||
"integrity": "sha512-kivNFO+0OcUNu7jQquLXAxz1FIwZj8nrj+YkOKc5694NbjCvcT6aSZiIzNzd2Kul4o4rTto8QVR9lMNtxD4G1w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"decimal.js-light": "^2.4.1"
|
||||
}
|
||||
},
|
||||
"node_modules/recharts/node_modules/eventemitter3": {
|
||||
"version": "4.0.7",
|
||||
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz",
|
||||
"integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/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/recharts/node_modules/react-is": {
|
||||
"version": "18.3.1",
|
||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz",
|
||||
"integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/reflect.getprototypeof": {
|
||||
"version": "1.0.10",
|
||||
@@ -12411,12 +12668,6 @@
|
||||
"url": "https://opencollective.com/unified"
|
||||
}
|
||||
},
|
||||
"node_modules/reselect": {
|
||||
"version": "5.1.1",
|
||||
"resolved": "https://registry.npmjs.org/reselect/-/reselect-5.1.1.tgz",
|
||||
"integrity": "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/resolve": {
|
||||
"version": "1.22.11",
|
||||
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz",
|
||||
@@ -13296,7 +13547,7 @@
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.2.tgz",
|
||||
"integrity": "sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg==",
|
||||
"devOptional": true,
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
@@ -13392,6 +13643,12 @@
|
||||
"url": "https://github.com/sponsors/wooorm"
|
||||
}
|
||||
},
|
||||
"node_modules/ts-algebra": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/ts-algebra/-/ts-algebra-2.0.0.tgz",
|
||||
"integrity": "sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/ts-api-utils": {
|
||||
"version": "2.4.0",
|
||||
"resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.4.0.tgz",
|
||||
@@ -13566,6 +13823,7 @@
|
||||
"version": "5.9.3",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
@@ -13961,9 +14219,9 @@
|
||||
}
|
||||
},
|
||||
"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==",
|
||||
"version": "36.9.2",
|
||||
"resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-36.9.2.tgz",
|
||||
"integrity": "sha512-PnpQQMuxlwYdocC8fIJqVXvkeViHYzotI+NJrCuav0ZYFoq912ZHBk3mCeuj+5/VpodOjPe1z0Fk2ihgzlXqjQ==",
|
||||
"license": "MIT AND ISC",
|
||||
"dependencies": {
|
||||
"@types/d3-array": "^3.0.3",
|
||||
|
||||
@@ -21,6 +21,7 @@
|
||||
"test:e2e": "playwright test"
|
||||
},
|
||||
"dependencies": {
|
||||
"@anthropic-ai/sdk": "^0.78.0",
|
||||
"@auth/prisma-adapter": "^2.7.4",
|
||||
"@blocknote/core": "^0.46.2",
|
||||
"@blocknote/mantine": "^0.46.2",
|
||||
@@ -50,9 +51,11 @@
|
||||
"@radix-ui/react-slot": "^1.1.1",
|
||||
"@radix-ui/react-switch": "^1.1.2",
|
||||
"@radix-ui/react-tabs": "^1.1.2",
|
||||
"@radix-ui/react-toggle": "^1.1.10",
|
||||
"@radix-ui/react-tooltip": "^1.1.6",
|
||||
"@tailwindcss/postcss": "^4.1.18",
|
||||
"@tanstack/react-query": "^5.62.0",
|
||||
"@tremor/react": "^3.18.7",
|
||||
"@trpc/client": "^11.0.0-rc.678",
|
||||
"@trpc/react-query": "^11.0.0-rc.678",
|
||||
"@trpc/server": "^11.0.0-rc.678",
|
||||
@@ -85,7 +88,6 @@
|
||||
"react-hook-form": "^7.54.2",
|
||||
"react-leaflet": "^5.0.0",
|
||||
"react-phone-number-input": "^3.4.14",
|
||||
"recharts": "^3.7.0",
|
||||
"sonner": "^2.0.7",
|
||||
"superjson": "^2.2.2",
|
||||
"tailwind-merge": "^3.4.0",
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
-- Add isTest field to User, Program, Project, Competition for test environment isolation
|
||||
ALTER TABLE "User" ADD COLUMN "isTest" BOOLEAN NOT NULL DEFAULT false;
|
||||
ALTER TABLE "Program" ADD COLUMN "isTest" BOOLEAN NOT NULL DEFAULT false;
|
||||
ALTER TABLE "Project" ADD COLUMN "isTest" BOOLEAN NOT NULL DEFAULT false;
|
||||
ALTER TABLE "Competition" ADD COLUMN "isTest" BOOLEAN NOT NULL DEFAULT false;
|
||||
|
||||
-- Index for efficient test data filtering
|
||||
CREATE INDEX "Competition_isTest_idx" ON "Competition"("isTest");
|
||||
@@ -0,0 +1,13 @@
|
||||
-- Delete any existing LOCALIZATION settings
|
||||
DELETE FROM "SystemSettings" WHERE category = 'LOCALIZATION';
|
||||
|
||||
-- Add provider field to AIUsageLog for cross-provider cost tracking
|
||||
ALTER TABLE "AIUsageLog" ADD COLUMN "provider" TEXT;
|
||||
|
||||
-- Remove LOCALIZATION from SettingCategory enum
|
||||
-- First create new enum without the value, then swap
|
||||
CREATE TYPE "SettingCategory_new" AS ENUM ('AI', 'BRANDING', 'EMAIL', 'STORAGE', 'SECURITY', 'DEFAULTS', 'WHATSAPP', 'AUDIT_CONFIG', 'DIGEST', 'ANALYTICS', 'INTEGRATIONS', 'COMMUNICATION', 'FEATURE_FLAGS');
|
||||
ALTER TABLE "SystemSettings" ALTER COLUMN "category" TYPE "SettingCategory_new" USING ("category"::text::"SettingCategory_new");
|
||||
ALTER TYPE "SettingCategory" RENAME TO "SettingCategory_old";
|
||||
ALTER TYPE "SettingCategory_new" RENAME TO "SettingCategory";
|
||||
DROP TYPE "SettingCategory_old";
|
||||
@@ -101,7 +101,6 @@ enum SettingCategory {
|
||||
DEFAULTS
|
||||
WHATSAPP
|
||||
AUDIT_CONFIG
|
||||
LOCALIZATION
|
||||
DIGEST
|
||||
ANALYTICS
|
||||
INTEGRATIONS
|
||||
@@ -351,6 +350,9 @@ model User {
|
||||
preferredWorkload Int?
|
||||
availabilityJson Json? @db.JsonB // { startDate?: string, endDate?: string }
|
||||
|
||||
// Test environment isolation
|
||||
isTest Boolean @default(false)
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
lastLoginAt DateTime?
|
||||
@@ -495,6 +497,9 @@ model Program {
|
||||
description String?
|
||||
settingsJson Json? @db.JsonB
|
||||
|
||||
// Test environment isolation
|
||||
isTest Boolean @default(false)
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@ -619,6 +624,9 @@ model Project {
|
||||
metadataJson Json? @db.JsonB // Custom fields from Typeform, etc.
|
||||
externalIdsJson Json? @db.JsonB // Typeform ID, Notion ID, etc.
|
||||
|
||||
// Test environment isolation
|
||||
isTest Boolean @default(false)
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@ -907,7 +915,8 @@ model AIUsageLog {
|
||||
entityId String?
|
||||
|
||||
// What was used
|
||||
model String // gpt-4o, gpt-4o-mini, o1, etc.
|
||||
model String // gpt-4o, gpt-4o-mini, o1, claude-sonnet-4-5, etc.
|
||||
provider String? // openai, anthropic, litellm
|
||||
promptTokens Int
|
||||
completionTokens Int
|
||||
totalTokens Int
|
||||
@@ -2090,6 +2099,9 @@ model Competition {
|
||||
notifyOnDeadlineApproach Boolean @default(true)
|
||||
deadlineReminderDays Int[] @default([7, 3, 1])
|
||||
|
||||
// Test environment isolation
|
||||
isTest Boolean @default(false)
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@ -2104,6 +2116,7 @@ model Competition {
|
||||
|
||||
@@index([programId])
|
||||
@@index([status])
|
||||
@@index([isTest])
|
||||
}
|
||||
|
||||
model Round {
|
||||
|
||||
@@ -131,7 +131,7 @@ async function main() {
|
||||
const existingTags = await prisma.expertiseTag.findMany({
|
||||
select: { name: true },
|
||||
})
|
||||
const existingNames = new Set(existingTags.map((t) => t.name))
|
||||
const existingNames = new Set(existingTags.map((t: { name: string }) => t.name))
|
||||
|
||||
// Filter out tags that already exist
|
||||
const newTags = EXPERTISE_TAGS.filter((t) => !existingNames.has(t.name))
|
||||
|
||||
@@ -83,6 +83,11 @@ const ACTION_TYPES = [
|
||||
'ROLE_CHANGED',
|
||||
'PASSWORD_SET',
|
||||
'PASSWORD_CHANGED',
|
||||
'JUROR_DROPOUT_RESHUFFLE',
|
||||
'COI_REASSIGNMENT',
|
||||
'APPLY_AI_SUGGESTIONS',
|
||||
'APPLY_SUGGESTIONS',
|
||||
'NOTIFY_JURORS_OF_ASSIGNMENTS',
|
||||
]
|
||||
|
||||
// Entity type options
|
||||
@@ -118,6 +123,11 @@ const actionColors: Record<string, 'default' | 'destructive' | 'secondary' | 'ou
|
||||
ROLE_CHANGED: 'secondary',
|
||||
PASSWORD_SET: 'outline',
|
||||
PASSWORD_CHANGED: 'outline',
|
||||
JUROR_DROPOUT_RESHUFFLE: 'destructive',
|
||||
COI_REASSIGNMENT: 'secondary',
|
||||
APPLY_AI_SUGGESTIONS: 'default',
|
||||
APPLY_SUGGESTIONS: 'default',
|
||||
NOTIFY_JURORS_OF_ASSIGNMENTS: 'outline',
|
||||
}
|
||||
|
||||
export default function AuditLogPage() {
|
||||
@@ -516,9 +526,15 @@ export default function AuditLogPage() {
|
||||
<p className="text-xs font-medium text-muted-foreground mb-1">
|
||||
Details
|
||||
</p>
|
||||
<pre className="text-xs bg-muted rounded p-2 overflow-x-auto">
|
||||
{JSON.stringify(log.detailsJson, null, 2)}
|
||||
</pre>
|
||||
{log.action === 'JUROR_DROPOUT_RESHUFFLE' ? (
|
||||
<ReshuffleDetailView details={log.detailsJson as Record<string, unknown>} />
|
||||
) : log.action === 'COI_REASSIGNMENT' ? (
|
||||
<COIReassignmentDetailView details={log.detailsJson as Record<string, unknown>} />
|
||||
) : (
|
||||
<pre className="text-xs bg-muted rounded p-2 overflow-x-auto">
|
||||
{JSON.stringify(log.detailsJson, null, 2)}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{!!(log as Record<string, unknown>).previousDataJson && (
|
||||
@@ -622,9 +638,15 @@ export default function AuditLogPage() {
|
||||
<p className="text-xs font-medium text-muted-foreground mb-1">
|
||||
Details
|
||||
</p>
|
||||
<pre className="text-xs bg-muted rounded p-2 overflow-x-auto">
|
||||
{JSON.stringify(log.detailsJson, null, 2)}
|
||||
</pre>
|
||||
{log.action === 'JUROR_DROPOUT_RESHUFFLE' ? (
|
||||
<ReshuffleDetailView details={log.detailsJson as Record<string, unknown>} />
|
||||
) : log.action === 'COI_REASSIGNMENT' ? (
|
||||
<COIReassignmentDetailView details={log.detailsJson as Record<string, unknown>} />
|
||||
) : (
|
||||
<pre className="text-xs bg-muted rounded p-2 overflow-x-auto">
|
||||
{JSON.stringify(log.detailsJson, null, 2)}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -693,6 +715,129 @@ export default function AuditLogPage() {
|
||||
)
|
||||
}
|
||||
|
||||
function ReshuffleDetailView({ details }: { details: Record<string, unknown> }) {
|
||||
const reassignedTo = (details.reassignedTo ?? {}) as Record<string, number>
|
||||
const jurorIds = Object.keys(reassignedTo)
|
||||
const moves = (details.moves ?? []) as { projectId: string; projectTitle: string; newJurorId: string; newJurorName: string }[]
|
||||
|
||||
// Resolve juror IDs to names
|
||||
const { data: nameMap } = trpc.user.resolveNames.useQuery(
|
||||
{ ids: [...jurorIds, details.droppedJurorId as string].filter(Boolean) },
|
||||
{ enabled: jurorIds.length > 0 },
|
||||
)
|
||||
|
||||
const droppedName = (details.droppedJurorName as string) || (nameMap && details.droppedJurorId ? nameMap[details.droppedJurorId as string] : null) || (details.droppedJurorId as string)
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border bg-white overflow-hidden text-sm">
|
||||
{/* Summary */}
|
||||
<div className="p-3 bg-muted/50 border-b space-y-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant="destructive">Juror Dropout</Badge>
|
||||
<span className="font-semibold">{droppedName}</span>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{String(details.movedCount)} project(s) reassigned, {String(details.failedCount)} failed
|
||||
{details.removedFromGroup ? ' — removed from jury group' : ''}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Per-project moves (new format) */}
|
||||
{moves.length > 0 && (
|
||||
<div className="p-3">
|
||||
<p className="text-xs font-medium text-muted-foreground mb-2">Project → New Juror</p>
|
||||
<table className="w-full text-xs">
|
||||
<thead>
|
||||
<tr className="text-muted-foreground border-b">
|
||||
<th className="text-left py-1 font-medium">Project</th>
|
||||
<th className="text-left py-1 font-medium">Reassigned To</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{moves.map((move, i) => (
|
||||
<tr key={i} className="border-b last:border-0">
|
||||
<td className="py-1.5 pr-2">{move.projectTitle}</td>
|
||||
<td className="py-1.5 font-medium">{move.newJurorName}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Fallback: count-based view (old format, no per-project detail) */}
|
||||
{moves.length === 0 && jurorIds.length > 0 && (
|
||||
<div className="p-3">
|
||||
<p className="text-xs font-medium text-muted-foreground mb-2">Reassignment Summary (project detail not available)</p>
|
||||
<table className="w-full text-xs">
|
||||
<thead>
|
||||
<tr className="text-muted-foreground border-b">
|
||||
<th className="text-left py-1 font-medium">Juror</th>
|
||||
<th className="text-right py-1 font-medium">Projects Received</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{jurorIds.map((id) => (
|
||||
<tr key={id} className="border-b last:border-0">
|
||||
<td className="py-1.5">{nameMap?.[id] || id}</td>
|
||||
<td className="py-1.5 text-right font-medium">{reassignedTo[id]}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Failed projects */}
|
||||
{Array.isArray(details.failedProjects) && (details.failedProjects as string[]).length > 0 && (
|
||||
<div className="p-3 border-t bg-red-50/50">
|
||||
<p className="text-xs font-medium text-red-700 mb-1">Could not reassign:</p>
|
||||
<ul className="text-xs text-muted-foreground list-disc list-inside">
|
||||
{(details.failedProjects as string[]).map((p, i) => (
|
||||
<li key={i}>{p}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function COIReassignmentDetailView({ details }: { details: Record<string, unknown> }) {
|
||||
const ids = [details.oldJurorId, details.newJurorId].filter(Boolean) as string[]
|
||||
const { data: nameMap } = trpc.user.resolveNames.useQuery(
|
||||
{ ids },
|
||||
{ enabled: ids.length > 0 },
|
||||
)
|
||||
|
||||
const oldName = nameMap?.[details.oldJurorId as string] || (details.oldJurorId as string)
|
||||
const newName = nameMap?.[details.newJurorId as string] || (details.newJurorId as string)
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border bg-white overflow-hidden text-sm">
|
||||
<div className="p-3 space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant="secondary">COI Reassignment</Badge>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4 text-xs">
|
||||
<div>
|
||||
<p className="text-muted-foreground">From</p>
|
||||
<p className="font-medium">{oldName}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-muted-foreground">To</p>
|
||||
<p className="font-medium">{newName}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
Project: <span className="font-mono">{(details.projectId as string)?.slice(0, 12)}...</span>
|
||||
{' | '}Round: <span className="font-mono">{(details.roundId as string)?.slice(0, 12)}...</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function DiffViewer({ before, after }: { before: unknown; after: unknown }) {
|
||||
const beforeObj = typeof before === 'object' && before !== null ? before as Record<string, unknown> : {}
|
||||
const afterObj = typeof after === 'object' && after !== null ? after as Record<string, unknown> : {}
|
||||
|
||||
@@ -68,8 +68,19 @@ export default function AssignmentsDashboardPage() {
|
||||
|
||||
if (!competition) {
|
||||
return (
|
||||
<div className="container mx-auto space-y-6 p-4 sm:p-6">
|
||||
<p>Competition not found</p>
|
||||
<div className="container mx-auto space-y-6 p-4 sm:p-6">
|
||||
<Card>
|
||||
<CardContent className="flex flex-col items-center justify-center py-12">
|
||||
<p className="font-medium">Competition not found</p>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
The requested competition does not exist or you don't have access.
|
||||
</p>
|
||||
<Button variant="outline" className="mt-4" onClick={() => router.back()}>
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Go Back
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -13,16 +13,34 @@ import type { Route } from 'next';
|
||||
export default function AwardsPage({ params: paramsPromise }: { params: Promise<{ competitionId: string }> }) {
|
||||
const params = use(paramsPromise);
|
||||
const router = useRouter();
|
||||
const { data: competition } = trpc.competition.getById.useQuery({
|
||||
const { data: competition, isError: isCompError } = trpc.competition.getById.useQuery({
|
||||
id: params.competitionId
|
||||
});
|
||||
|
||||
const { data: awards, isLoading } = trpc.specialAward.list.useQuery({
|
||||
const { data: awards, isLoading, isError: isAwardsError } = trpc.specialAward.list.useQuery({
|
||||
programId: competition?.programId
|
||||
}, {
|
||||
enabled: !!competition?.programId
|
||||
});
|
||||
|
||||
if (isCompError || isAwardsError) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<Button variant="ghost" size="icon" onClick={() => router.back()}>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<div>
|
||||
<h1 className="text-xl font-bold">Error Loading Awards</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Could not load competition or awards data. Please try again.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
|
||||
@@ -43,13 +43,13 @@ export default function DeliberationListPage({
|
||||
participantUserIds: [] as string[]
|
||||
});
|
||||
|
||||
const { data: sessions = [], isLoading } = trpc.deliberation.listSessions.useQuery(
|
||||
const { data: sessions = [], isLoading, isError: isSessionsError } = trpc.deliberation.listSessions.useQuery(
|
||||
{ competitionId: params.competitionId },
|
||||
{ enabled: !!params.competitionId }
|
||||
);
|
||||
|
||||
// Get rounds for this competition
|
||||
const { data: competition } = trpc.competition.getById.useQuery(
|
||||
const { data: competition, isError: isCompError } = trpc.competition.getById.useQuery(
|
||||
{ id: params.competitionId },
|
||||
{ enabled: !!params.competitionId }
|
||||
);
|
||||
@@ -121,6 +121,24 @@ export default function DeliberationListPage({
|
||||
return <Badge variant={variants[status] || 'outline'}>{labels[status] || status}</Badge>;
|
||||
};
|
||||
|
||||
if (isCompError || isSessionsError) {
|
||||
return (
|
||||
<div className="space-y-6 p-4 sm:p-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<Button variant="ghost" size="icon" onClick={() => router.back()} aria-label="Go back">
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<div>
|
||||
<h1 className="text-xl font-bold">Error Loading Deliberations</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Could not load competition or deliberation data. Please try again.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="space-y-6 p-4 sm:p-6">
|
||||
|
||||
@@ -48,6 +48,7 @@ import {
|
||||
Loader2,
|
||||
Plus,
|
||||
CalendarDays,
|
||||
Radio,
|
||||
} from 'lucide-react'
|
||||
import { CompetitionTimeline } from '@/components/admin/competition/competition-timeline'
|
||||
|
||||
@@ -435,6 +436,19 @@ export default function CompetitionDetailPage() {
|
||||
<span className="truncate">{round.juryGroup.name}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Live Control link for LIVE_FINAL rounds */}
|
||||
{round.roundType === 'LIVE_FINAL' && (
|
||||
<Link
|
||||
href={`/admin/competitions/${competitionId}/live/${round.id}` as Route}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<Button size="sm" variant="outline" className="w-full text-xs gap-1.5">
|
||||
<Radio className="h-3.5 w-3.5" />
|
||||
Live Control
|
||||
</Button>
|
||||
</Link>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Link>
|
||||
|
||||
@@ -1,10 +0,0 @@
|
||||
import { redirect } from 'next/navigation'
|
||||
|
||||
export default async function MentorDetailPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ id: string }>
|
||||
}) {
|
||||
const { id } = await params
|
||||
redirect(`/admin/members/${id}`)
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
import { redirect } from 'next/navigation'
|
||||
|
||||
export default function MentorsPage() {
|
||||
redirect('/admin/members')
|
||||
}
|
||||
@@ -30,7 +30,7 @@ export default async function AdminDashboardPage({ searchParams }: PageProps) {
|
||||
|
||||
if (!editionId) {
|
||||
const defaultEdition = await prisma.program.findFirst({
|
||||
where: { status: 'ACTIVE' },
|
||||
where: { status: 'ACTIVE', isTest: false },
|
||||
orderBy: { year: 'desc' },
|
||||
select: { id: true },
|
||||
})
|
||||
@@ -38,6 +38,7 @@ export default async function AdminDashboardPage({ searchParams }: PageProps) {
|
||||
|
||||
if (!editionId) {
|
||||
const anyEdition = await prisma.program.findFirst({
|
||||
where: { isTest: false },
|
||||
orderBy: { year: 'desc' },
|
||||
select: { id: true },
|
||||
})
|
||||
|
||||
@@ -19,7 +19,7 @@ import {
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table'
|
||||
import { ArrowLeft, Pencil, Plus } from 'lucide-react'
|
||||
import { ArrowLeft, GraduationCap, Pencil, Plus } from 'lucide-react'
|
||||
import { formatDateOnly } from '@/lib/utils'
|
||||
|
||||
interface ProgramDetailPageProps {
|
||||
@@ -65,12 +65,20 @@ export default async function ProgramDetailPage({ params }: ProgramDetailPagePro
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button variant="outline" asChild>
|
||||
<Link href={`/admin/programs/${id}/edit`}>
|
||||
<Pencil className="mr-2 h-4 w-4" />
|
||||
Edit
|
||||
</Link>
|
||||
</Button>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" asChild>
|
||||
<Link href={`/admin/programs/${id}/mentorship` as Route}>
|
||||
<GraduationCap className="mr-2 h-4 w-4" />
|
||||
Mentorship
|
||||
</Link>
|
||||
</Button>
|
||||
<Button variant="outline" asChild>
|
||||
<Link href={`/admin/programs/${id}/edit`}>
|
||||
<Pencil className="mr-2 h-4 w-4" />
|
||||
Edit
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{program.description && (
|
||||
|
||||
@@ -40,7 +40,7 @@ import { formatDateOnly } from '@/lib/utils'
|
||||
|
||||
async function ProgramsContent() {
|
||||
const programs = await prisma.program.findMany({
|
||||
// Note: PROGRAM_ADMIN filtering should be handled via middleware or a separate relation
|
||||
where: { isTest: false },
|
||||
include: {
|
||||
competitions: {
|
||||
include: {
|
||||
|
||||
@@ -43,9 +43,6 @@ import {
|
||||
Users,
|
||||
FileText,
|
||||
Calendar,
|
||||
CheckCircle2,
|
||||
XCircle,
|
||||
Circle,
|
||||
Clock,
|
||||
BarChart3,
|
||||
ThumbsUp,
|
||||
@@ -562,105 +559,9 @@ function ProjectDetailContent({ projectId }: { projectId: string }) {
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
{/* Requirements organized by round */}
|
||||
{competitionRounds.length > 0 && allRequirements.length > 0 ? (
|
||||
<>
|
||||
{competitionRounds.map((round: { id: string; name: string }) => {
|
||||
const roundRequirements = allRequirements.filter((req: any) => req.roundId === round.id)
|
||||
if (roundRequirements.length === 0) return null
|
||||
|
||||
return (
|
||||
<div key={round.id} className="space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="text-sm font-semibold">{round.name}</h3>
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{roundRequirements.length} requirement{roundRequirements.length !== 1 ? 's' : ''}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
{roundRequirements.map((req: any) => {
|
||||
// Find file that fulfills this requirement
|
||||
const fulfilledFile = files?.find((f: any) => f.requirementId === req.id)
|
||||
const isFulfilled = !!fulfilledFile
|
||||
|
||||
return (
|
||||
<div
|
||||
key={req.id}
|
||||
className={`flex items-center justify-between rounded-lg border p-3 ${
|
||||
isFulfilled
|
||||
? 'border-green-200 bg-green-50/50 dark:border-green-900 dark:bg-green-950/20'
|
||||
: 'border-muted'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
{isFulfilled ? (
|
||||
<CheckCircle2 className="h-5 w-5 shrink-0 text-green-600" />
|
||||
) : (
|
||||
<Circle className="h-5 w-5 shrink-0 text-muted-foreground" />
|
||||
)}
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="text-sm font-medium truncate">{req.name}</p>
|
||||
{req.isRequired && (
|
||||
<Badge variant="destructive" className="text-xs shrink-0">
|
||||
Required
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
{req.description && (
|
||||
<p className="text-xs text-muted-foreground truncate">
|
||||
{req.description}
|
||||
</p>
|
||||
)}
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground mt-0.5">
|
||||
{req.acceptedMimeTypes?.length > 0 && (
|
||||
<span>
|
||||
{req.acceptedMimeTypes.map((mime: string) => {
|
||||
if (mime === 'application/pdf') return 'PDF'
|
||||
if (mime === 'image/*') return 'Images'
|
||||
if (mime === 'video/*') return 'Video'
|
||||
if (mime.includes('wordprocessing')) return 'Word'
|
||||
if (mime.includes('spreadsheet')) return 'Excel'
|
||||
if (mime.includes('presentation')) return 'PowerPoint'
|
||||
return mime.split('/')[1] || mime
|
||||
}).join(', ')}
|
||||
</span>
|
||||
)}
|
||||
{req.maxSizeMB && (
|
||||
<span className="shrink-0">• Max {req.maxSizeMB}MB</span>
|
||||
)}
|
||||
</div>
|
||||
{isFulfilled && fulfilledFile && (
|
||||
<p className="text-xs text-green-700 dark:text-green-400 mt-1 font-medium">
|
||||
✓ {fulfilledFile.fileName}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{!isFulfilled && (
|
||||
<span className="text-xs text-amber-600 dark:text-amber-400 shrink-0 ml-2 font-medium">
|
||||
Missing
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
<Separator />
|
||||
</>
|
||||
) : null}
|
||||
|
||||
{/* General file upload section */}
|
||||
{/* File upload */}
|
||||
<div>
|
||||
<p className="text-sm font-semibold mb-3">
|
||||
{allRequirements.length > 0 ? 'Additional Documents' : 'Upload Files'}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground mb-3">
|
||||
Upload files not tied to specific requirements
|
||||
</p>
|
||||
<p className="text-sm font-semibold mb-3">Upload Files</p>
|
||||
<FileUpload
|
||||
projectId={projectId}
|
||||
availableRounds={competitionRounds?.map((r: any) => ({ id: r.id, name: r.name }))}
|
||||
@@ -674,33 +575,30 @@ function ProjectDetailContent({ projectId }: { projectId: string }) {
|
||||
{files && files.length > 0 && (
|
||||
<>
|
||||
<Separator />
|
||||
<div>
|
||||
<p className="text-sm font-semibold mb-3">All Uploaded Files</p>
|
||||
<FileViewer
|
||||
projectId={projectId}
|
||||
files={files.map((f) => ({
|
||||
id: f.id,
|
||||
fileName: f.fileName,
|
||||
fileType: f.fileType,
|
||||
mimeType: f.mimeType,
|
||||
size: f.size,
|
||||
bucket: f.bucket,
|
||||
objectKey: f.objectKey,
|
||||
pageCount: f.pageCount,
|
||||
textPreview: f.textPreview,
|
||||
detectedLang: f.detectedLang,
|
||||
langConfidence: f.langConfidence,
|
||||
analyzedAt: f.analyzedAt ? String(f.analyzedAt) : null,
|
||||
requirementId: f.requirementId,
|
||||
requirement: f.requirement ? {
|
||||
id: f.requirement.id,
|
||||
name: f.requirement.name,
|
||||
description: f.requirement.description,
|
||||
isRequired: f.requirement.isRequired,
|
||||
} : null,
|
||||
}))}
|
||||
/>
|
||||
</div>
|
||||
<FileViewer
|
||||
projectId={projectId}
|
||||
files={files.map((f) => ({
|
||||
id: f.id,
|
||||
fileName: f.fileName,
|
||||
fileType: f.fileType,
|
||||
mimeType: f.mimeType,
|
||||
size: f.size,
|
||||
bucket: f.bucket,
|
||||
objectKey: f.objectKey,
|
||||
pageCount: f.pageCount,
|
||||
textPreview: f.textPreview,
|
||||
detectedLang: f.detectedLang,
|
||||
langConfidence: f.langConfidence,
|
||||
analyzedAt: f.analyzedAt ? String(f.analyzedAt) : null,
|
||||
requirementId: f.requirementId,
|
||||
requirement: f.requirement ? {
|
||||
id: f.requirement.id,
|
||||
name: f.requirement.name,
|
||||
description: f.requirement.description,
|
||||
isRequired: f.requirement.isRequired,
|
||||
} : null,
|
||||
}))}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
|
||||
@@ -907,17 +907,8 @@ export default function ProjectsPage() {
|
||||
const code = normalizeCountryToCode(project.country)
|
||||
const flag = code ? getCountryFlag(code) : null
|
||||
const name = code ? getCountryName(code) : project.country
|
||||
return flag ? (
|
||||
<TooltipProvider delayDuration={200}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="text-xs cursor-default"> · <span className="text-sm">{flag}</span></span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top"><p>{name}</p></TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
) : (
|
||||
<span className="text-xs text-muted-foreground/70"> · {project.country}</span>
|
||||
return (
|
||||
<span className="text-xs text-muted-foreground/70"> · {flag && <span className="text-sm">{flag}</span>} {name}</span>
|
||||
)
|
||||
})()}
|
||||
</p>
|
||||
@@ -1176,17 +1167,8 @@ export default function ProjectsPage() {
|
||||
const code = normalizeCountryToCode(project.country)
|
||||
const flag = code ? getCountryFlag(code) : null
|
||||
const name = code ? getCountryName(code) : project.country
|
||||
return flag ? (
|
||||
<TooltipProvider delayDuration={200}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="text-xs cursor-default"> · <span className="text-sm">{flag}</span></span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top"><p>{name}</p></TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
) : (
|
||||
<span className="text-xs text-muted-foreground/70"> · {project.country}</span>
|
||||
return (
|
||||
<span className="text-xs text-muted-foreground/70"> · {flag && <span className="text-sm">{flag}</span>} {name}</span>
|
||||
)
|
||||
})()}
|
||||
</CardDescription>
|
||||
|
||||
@@ -28,6 +28,7 @@ import { Badge } from '@/components/ui/badge'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Card, CardContent } from '@/components/ui/card'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { getCountryName, getCountryFlag, normalizeCountryToCode } from '@/lib/countries'
|
||||
import { toast } from 'sonner'
|
||||
import { ArrowLeft, ChevronLeft, ChevronRight, Loader2, X, Layers, Info } from 'lucide-react'
|
||||
|
||||
@@ -387,7 +388,12 @@ export default function ProjectPoolPage() {
|
||||
)}
|
||||
</td>
|
||||
<td className="p-3 text-sm text-muted-foreground">
|
||||
{project.country || '-'}
|
||||
{project.country ? (() => {
|
||||
const code = normalizeCountryToCode(project.country)
|
||||
const flag = code ? getCountryFlag(code) : null
|
||||
const name = code ? getCountryName(code) : project.country
|
||||
return <>{flag && <span>{flag} </span>}{name}</>
|
||||
})() : '-'}
|
||||
</td>
|
||||
<td className="p-3 text-sm text-muted-foreground">
|
||||
{project.submittedAt
|
||||
|
||||
@@ -532,9 +532,9 @@ function StageAnalytics() {
|
||||
<Skeleton className="h-[350px]" />
|
||||
) : scoreDistribution ? (
|
||||
<ScoreDistributionChart
|
||||
data={scoreDistribution.distribution}
|
||||
averageScore={scoreDistribution.averageScore}
|
||||
totalScores={scoreDistribution.totalScores}
|
||||
data={scoreDistribution.distribution ?? []}
|
||||
averageScore={scoreDistribution.averageScore ?? 0}
|
||||
totalScores={scoreDistribution.totalScores ?? 0}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
@@ -657,7 +657,7 @@ function CrossStageTab() {
|
||||
className="cursor-pointer text-sm py-1.5 px-3"
|
||||
onClick={() => toggleRound(stage.id)}
|
||||
>
|
||||
{stage.programName} - {stage.name}
|
||||
{stage.name}
|
||||
</Badge>
|
||||
)
|
||||
})}
|
||||
@@ -740,7 +740,7 @@ function JurorConsistencyTab() {
|
||||
))}
|
||||
{stages.map((stage) => (
|
||||
<SelectItem key={stage.id} value={stage.id}>
|
||||
{stage.programName} - {stage.name}
|
||||
{stage.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
@@ -814,7 +814,7 @@ function DiversityTab() {
|
||||
))}
|
||||
{stages.map((stage) => (
|
||||
<SelectItem key={stage.id} value={stage.id}>
|
||||
{stage.programName} - {stage.name}
|
||||
{stage.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
@@ -992,7 +992,7 @@ export default function ReportsPage() {
|
||||
<SelectContent>
|
||||
{pdfStages.map((stage) => (
|
||||
<SelectItem key={stage.id} value={stage.id}>
|
||||
{stage.programName} - {stage.name}
|
||||
{stage.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -205,14 +205,14 @@ export default function RoundsPage() {
|
||||
}
|
||||
|
||||
const startEditSettings = () => {
|
||||
if (!comp) return
|
||||
if (!comp || !compDetail) return
|
||||
setEditingCompId(comp.id)
|
||||
setCompetitionEdits({
|
||||
name: comp.name,
|
||||
categoryMode: (comp as any).categoryMode,
|
||||
startupFinalistCount: (comp as any).startupFinalistCount,
|
||||
conceptFinalistCount: (comp as any).conceptFinalistCount,
|
||||
notifyOnDeadlineApproach: (comp as any).notifyOnDeadlineApproach,
|
||||
name: compDetail.name,
|
||||
categoryMode: compDetail.categoryMode,
|
||||
startupFinalistCount: compDetail.startupFinalistCount,
|
||||
conceptFinalistCount: compDetail.conceptFinalistCount,
|
||||
notifyOnDeadlineApproach: compDetail.notifyOnDeadlineApproach,
|
||||
})
|
||||
setSettingsOpen(true)
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ export default async function AdminLayout({
|
||||
|
||||
// Fetch all editions (programs) for the edition selector
|
||||
const editions = await prisma.program.findMany({
|
||||
where: { isTest: false },
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { requireRole } from '@/lib/auth-redirect'
|
||||
import { ObserverNav } from '@/components/layouts/observer-nav'
|
||||
import { EditionProvider } from '@/components/observer/observer-edition-context'
|
||||
|
||||
export default async function ObserverLayout({
|
||||
children,
|
||||
@@ -10,13 +11,15 @@ export default async function ObserverLayout({
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
<ObserverNav
|
||||
user={{
|
||||
name: session.user.name,
|
||||
email: session.user.email,
|
||||
}}
|
||||
/>
|
||||
<main className="container-app py-6">{children}</main>
|
||||
<EditionProvider>
|
||||
<ObserverNav
|
||||
user={{
|
||||
name: session.user.name,
|
||||
email: session.user.email,
|
||||
}}
|
||||
/>
|
||||
<main className="container-app py-6">{children}</main>
|
||||
</EditionProvider>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
88
src/app/(observer)/observer/loading.tsx
Normal file
88
src/app/(observer)/observer/loading.tsx
Normal file
@@ -0,0 +1,88 @@
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { Card, CardContent, CardHeader } from '@/components/ui/card'
|
||||
|
||||
export default function ObserverLoading() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<Skeleton className="h-8 w-48" />
|
||||
<Skeleton className="mt-2 h-4 w-32" />
|
||||
</div>
|
||||
<Skeleton className="h-10 w-[200px]" />
|
||||
</div>
|
||||
|
||||
{/* 6 stat tiles */}
|
||||
<div className="grid gap-4 grid-cols-2 md:grid-cols-3 lg:grid-cols-6">
|
||||
{[...Array(6)].map((_, i) => (
|
||||
<Card key={i}>
|
||||
<CardContent className="p-4">
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-4 w-16" />
|
||||
<Skeleton className="h-8 w-12" />
|
||||
<Skeleton className="h-3 w-20" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Pipeline */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<Skeleton className="h-5 w-32" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex gap-4 overflow-hidden">
|
||||
{[...Array(4)].map((_, i) => (
|
||||
<Skeleton key={i} className="h-24 w-48 shrink-0 rounded-lg" />
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 3-col middle row */}
|
||||
<div className="grid gap-4 lg:grid-cols-3">
|
||||
{[...Array(3)].map((_, i) => (
|
||||
<Card key={i}>
|
||||
<CardHeader>
|
||||
<Skeleton className="h-5 w-28" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Skeleton className="h-[200px] w-full" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 2-col bottom row */}
|
||||
<div className="grid gap-4 lg:grid-cols-2">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<Skeleton className="h-5 w-32" />
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
{[...Array(5)].map((_, i) => (
|
||||
<Skeleton key={i} className="h-10 w-full" />
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<Skeleton className="h-5 w-28" />
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{[...Array(5)].map((_, i) => (
|
||||
<div key={i} className="flex items-center gap-3">
|
||||
<Skeleton className="h-3 w-3 rounded-full" />
|
||||
<Skeleton className="h-4 flex-1" />
|
||||
<Skeleton className="h-3 w-16" />
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
15
src/app/(observer)/observer/projects/[projectId]/page.tsx
Normal file
15
src/app/(observer)/observer/projects/[projectId]/page.tsx
Normal file
@@ -0,0 +1,15 @@
|
||||
import type { Metadata } from 'next'
|
||||
import { ObserverProjectDetail } from '@/components/observer/observer-project-detail'
|
||||
|
||||
export const metadata: Metadata = { title: 'Project Detail' }
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
export default async function ObserverProjectDetailPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ projectId: string }>
|
||||
}) {
|
||||
const { projectId } = await params
|
||||
|
||||
return <ObserverProjectDetail projectId={projectId} />
|
||||
}
|
||||
37
src/app/(observer)/observer/projects/loading.tsx
Normal file
37
src/app/(observer)/observer/projects/loading.tsx
Normal file
@@ -0,0 +1,37 @@
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { Card, CardContent, CardHeader } from '@/components/ui/card'
|
||||
|
||||
export default function ObserverProjectsLoading() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-8 w-36" />
|
||||
<Skeleton className="h-4 w-40" />
|
||||
</div>
|
||||
<Skeleton className="h-9 w-28" />
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<Skeleton className="h-5 w-14" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:gap-3">
|
||||
<Skeleton className="h-10 flex-1" />
|
||||
<Skeleton className="h-10 w-full sm:w-[220px]" />
|
||||
<Skeleton className="h-10 w-full sm:w-[180px]" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardContent className="pt-6 space-y-2">
|
||||
{[...Array(8)].map((_, i) => (
|
||||
<Skeleton key={i} className="h-12 w-full" />
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
8
src/app/(observer)/observer/projects/page.tsx
Normal file
8
src/app/(observer)/observer/projects/page.tsx
Normal file
@@ -0,0 +1,8 @@
|
||||
import { ObserverProjectsContent } from '@/components/observer/observer-projects-content'
|
||||
|
||||
export const metadata = { title: 'Observer — Projects' }
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
export default function ObserverProjectsPage() {
|
||||
return <ObserverProjectsContent />
|
||||
}
|
||||
57
src/app/(observer)/observer/reports/loading.tsx
Normal file
57
src/app/(observer)/observer/reports/loading.tsx
Normal file
@@ -0,0 +1,57 @@
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { Card, CardContent, CardHeader } from '@/components/ui/card'
|
||||
|
||||
export default function ObserverReportsLoading() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div>
|
||||
<Skeleton className="h-8 w-32" />
|
||||
<Skeleton className="mt-2 h-4 w-56" />
|
||||
</div>
|
||||
|
||||
{/* Round selector */}
|
||||
<Skeleton className="h-10 w-full sm:w-[300px]" />
|
||||
|
||||
{/* Tab bar */}
|
||||
<Skeleton className="h-10 w-80" />
|
||||
|
||||
{/* 3 stat tiles */}
|
||||
<div className="grid gap-4 sm:grid-cols-3">
|
||||
{[...Array(3)].map((_, i) => (
|
||||
<Card key={i}>
|
||||
<CardHeader className="space-y-0 pb-2">
|
||||
<Skeleton className="h-4 w-20" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Skeleton className="h-8 w-16" />
|
||||
<Skeleton className="mt-2 h-2 w-full" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Chart skeleton */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<Skeleton className="h-5 w-48" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Skeleton className="h-[300px] w-full" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Table skeleton */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<Skeleton className="h-5 w-36" />
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
{[...Array(5)].map((_, i) => (
|
||||
<Skeleton key={i} className="h-12 w-full" />
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,25 +1,9 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useState, useEffect, Suspense } from 'react'
|
||||
import { useSearchParams } from 'next/navigation'
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Progress } from '@/components/ui/progress'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
@@ -29,705 +13,258 @@ import {
|
||||
} from '@/components/ui/select'
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||
import {
|
||||
FileSpreadsheet,
|
||||
BarChart3,
|
||||
Users,
|
||||
ClipboardList,
|
||||
CheckCircle2,
|
||||
TrendingUp,
|
||||
GitCompare,
|
||||
UserCheck,
|
||||
Globe,
|
||||
LayoutDashboard,
|
||||
Filter,
|
||||
FolderOpen,
|
||||
TrendingUp,
|
||||
Users,
|
||||
BarChart3,
|
||||
Upload,
|
||||
Presentation,
|
||||
Vote,
|
||||
} from 'lucide-react'
|
||||
import { formatDateOnly } from '@/lib/utils'
|
||||
import {
|
||||
ScoreDistributionChart,
|
||||
EvaluationTimelineChart,
|
||||
StatusBreakdownChart,
|
||||
JurorWorkloadChart,
|
||||
ProjectRankingsChart,
|
||||
CriteriaScoresChart,
|
||||
CrossStageComparisonChart,
|
||||
JurorConsistencyChart,
|
||||
DiversityMetricsChart,
|
||||
} from '@/components/charts'
|
||||
import { ExportPdfButton } from '@/components/shared/export-pdf-button'
|
||||
import { AnimatedCard } from '@/components/shared/animated-container'
|
||||
import type { LucideIcon } from 'lucide-react'
|
||||
|
||||
// Parse selection value: "all:programId" for edition-wide, or roundId
|
||||
function parseSelection(value: string | null): { roundId?: string; programId?: string } {
|
||||
if (!value) return {}
|
||||
if (value.startsWith('all:')) return { programId: value.slice(4) }
|
||||
return { roundId: value }
|
||||
import { GlobalAnalyticsTab } from '@/components/observer/reports/global-analytics-tab'
|
||||
import { IntakeReportTabs } from '@/components/observer/reports/intake-report-tabs'
|
||||
import { FilteringReportTabs } from '@/components/observer/reports/filtering-report-tabs'
|
||||
import { EvaluationReportTabs } from '@/components/observer/reports/evaluation-report-tabs'
|
||||
import { SubmissionReportTabs } from '@/components/observer/reports/submission-report-tabs'
|
||||
import { MentoringReportTabs } from '@/components/observer/reports/mentoring-report-tabs'
|
||||
import { LiveFinalReportTabs } from '@/components/observer/reports/live-final-report-tabs'
|
||||
import { DeliberationReportTabs } from '@/components/observer/reports/deliberation-report-tabs'
|
||||
|
||||
const ROUND_TYPE_LABELS: Record<string, string> = {
|
||||
INTAKE: 'Intake',
|
||||
FILTERING: 'Filtering',
|
||||
EVALUATION: 'Evaluation',
|
||||
SUBMISSION: 'Submission',
|
||||
MENTORING: 'Mentoring',
|
||||
LIVE_FINAL: 'Live Final',
|
||||
DELIBERATION: 'Deliberation',
|
||||
}
|
||||
|
||||
function OverviewTab({ selectedValue }: { selectedValue: string | null }) {
|
||||
const { data: programs, isLoading } = trpc.program.list.useQuery({ includeStages: true })
|
||||
type Stage = {
|
||||
id: string
|
||||
name: string
|
||||
status: string
|
||||
roundType: string
|
||||
windowCloseAt: Date | null
|
||||
_count: { projects: number; assignments: number; evaluations: number }
|
||||
programId: string
|
||||
programName: string
|
||||
}
|
||||
|
||||
const stages = programs?.flatMap(p =>
|
||||
(p.stages as { id: string; name: string; status: string; windowCloseAt: Date | null; _count: { projects: number; assignments: number } }[]).map(s => ({
|
||||
...s,
|
||||
programName: `${p.year} Edition`,
|
||||
}))
|
||||
) || []
|
||||
type TabDef = { value: string; label: string; icon: LucideIcon }
|
||||
|
||||
const queryInput = parseSelection(selectedValue)
|
||||
const hasSelection = !!queryInput.roundId || !!queryInput.programId
|
||||
|
||||
const { data: overviewStats, isLoading: statsLoading } =
|
||||
trpc.analytics.getOverviewStats.useQuery(
|
||||
queryInput,
|
||||
{ enabled: hasSelection }
|
||||
)
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
{[...Array(4)].map((_, i) => (
|
||||
<Card key={i}>
|
||||
<CardHeader className="space-y-0 pb-2">
|
||||
<Skeleton className="h-4 w-20" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Skeleton className="h-8 w-16" />
|
||||
<Skeleton className="mt-2 h-3 w-24" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
function getRoundTabs(roundType: string): TabDef[] {
|
||||
switch (roundType) {
|
||||
case 'INTAKE':
|
||||
return [{ value: 'overview', label: 'Overview', icon: LayoutDashboard }]
|
||||
case 'FILTERING':
|
||||
return [
|
||||
{ value: 'screening', label: 'Screening', icon: Filter },
|
||||
]
|
||||
case 'EVALUATION':
|
||||
return [
|
||||
{ value: 'evaluation', label: 'Evaluation', icon: TrendingUp },
|
||||
]
|
||||
case 'SUBMISSION':
|
||||
return [{ value: 'overview', label: 'Overview', icon: Upload }]
|
||||
case 'MENTORING':
|
||||
return [{ value: 'overview', label: 'Overview', icon: Users }]
|
||||
case 'LIVE_FINAL':
|
||||
return [{ value: 'session', label: 'Session', icon: Presentation }]
|
||||
case 'DELIBERATION':
|
||||
return [
|
||||
{ value: 'deliberation', label: 'Deliberation', icon: Vote },
|
||||
]
|
||||
default:
|
||||
return []
|
||||
}
|
||||
|
||||
// Count distinct projects by collecting unique IDs, not summing per-round states
|
||||
const totalProjects = overviewStats?.projectCount ?? stages.reduce((acc, s) => acc + (s._count?.projects || 0), 0)
|
||||
const activeStages = stages.filter((s) => s.status === 'ROUND_ACTIVE').length
|
||||
const totalPrograms = programs?.length || 0
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Quick Stats */}
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<AnimatedCard index={0}>
|
||||
<Card className="border-l-4 border-l-blue-500 transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md">
|
||||
<CardContent className="p-5">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-muted-foreground">Total Stages</p>
|
||||
<p className="text-2xl font-bold mt-1">{stages.length}</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
{activeStages} active
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded-xl bg-blue-50 p-3">
|
||||
<BarChart3 className="h-5 w-5 text-blue-600" />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</AnimatedCard>
|
||||
|
||||
<AnimatedCard index={1}>
|
||||
<Card className="border-l-4 border-l-emerald-500 transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md">
|
||||
<CardContent className="p-5">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-muted-foreground">Total Projects</p>
|
||||
<p className="text-2xl font-bold mt-1">{totalProjects}</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">Across all stages</p>
|
||||
</div>
|
||||
<div className="rounded-xl bg-emerald-50 p-3">
|
||||
<ClipboardList className="h-5 w-5 text-emerald-600" />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</AnimatedCard>
|
||||
|
||||
<AnimatedCard index={2}>
|
||||
<Card className="border-l-4 border-l-violet-500 transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md">
|
||||
<CardContent className="p-5">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-muted-foreground">Active Stages</p>
|
||||
<p className="text-2xl font-bold mt-1">{activeStages}</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">Currently active</p>
|
||||
</div>
|
||||
<div className="rounded-xl bg-violet-50 p-3">
|
||||
<Users className="h-5 w-5 text-violet-600" />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</AnimatedCard>
|
||||
|
||||
<AnimatedCard index={3}>
|
||||
<Card className="border-l-4 border-l-brand-teal transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md">
|
||||
<CardContent className="p-5">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-muted-foreground">Programs</p>
|
||||
<p className="text-2xl font-bold mt-1">{totalPrograms}</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">Total programs</p>
|
||||
</div>
|
||||
<div className="rounded-xl bg-brand-teal/10 p-3">
|
||||
<CheckCircle2 className="h-5 w-5 text-brand-teal" />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</AnimatedCard>
|
||||
</div>
|
||||
|
||||
{/* Round/edition-specific overview stats */}
|
||||
{hasSelection && (
|
||||
<>
|
||||
{statsLoading ? (
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
{[...Array(4)].map((_, i) => (
|
||||
<Card key={i}>
|
||||
<CardHeader className="space-y-0 pb-2">
|
||||
<Skeleton className="h-4 w-20" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Skeleton className="h-8 w-16" />
|
||||
<Skeleton className="mt-2 h-3 w-24" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
) : overviewStats ? (
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-lg font-semibold">{queryInput.programId ? 'Edition Overview' : 'Selected Round Details'}</h3>
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Projects</CardTitle>
|
||||
<ClipboardList className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">{overviewStats.projectCount}</div>
|
||||
<p className="text-xs text-muted-foreground">In this round</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Assignments</CardTitle>
|
||||
<Users className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">{overviewStats.assignmentCount}</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{overviewStats.jurorCount} jurors
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Evaluations</CardTitle>
|
||||
<FileSpreadsheet className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">{overviewStats.evaluationCount}</div>
|
||||
<p className="text-xs text-muted-foreground">Submitted</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Completion</CardTitle>
|
||||
<CheckCircle2 className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">{overviewStats.completionRate}%</div>
|
||||
<Progress value={overviewStats.completionRate} className="mt-2 h-2" gradient />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Stages Table - Desktop */}
|
||||
<Card className="hidden md:block">
|
||||
<CardHeader>
|
||||
<CardTitle>Stage Reports</CardTitle>
|
||||
<CardDescription>Progress overview for each stage</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Stage</TableHead>
|
||||
<TableHead>Program</TableHead>
|
||||
<TableHead>Projects</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{stages.map((stage) => (
|
||||
<TableRow key={stage.id}>
|
||||
<TableCell>
|
||||
<div>
|
||||
<p className="font-medium">{stage.name}</p>
|
||||
{stage.windowCloseAt && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Ends: {formatDateOnly(stage.windowCloseAt)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>{stage.programName}</TableCell>
|
||||
<TableCell>{stage._count?.projects || '-'}</TableCell>
|
||||
<TableCell>
|
||||
<Badge
|
||||
variant={
|
||||
stage.status === 'ROUND_ACTIVE'
|
||||
? 'default'
|
||||
: stage.status === 'ROUND_CLOSED'
|
||||
? 'secondary'
|
||||
: 'outline'
|
||||
}
|
||||
>
|
||||
{stage.status === 'ROUND_ACTIVE' ? 'Active' : stage.status === 'ROUND_CLOSED' ? 'Closed' : stage.status}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Stages Cards - Mobile */}
|
||||
<div className="space-y-4 md:hidden">
|
||||
<h2 className="text-lg font-semibold">Stage Reports</h2>
|
||||
{stages.map((stage) => (
|
||||
<Card key={stage.id}>
|
||||
<CardContent className="pt-4 space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="font-medium">{stage.name}</p>
|
||||
<Badge
|
||||
variant={
|
||||
stage.status === 'ROUND_ACTIVE'
|
||||
? 'default'
|
||||
: stage.status === 'ROUND_CLOSED'
|
||||
? 'secondary'
|
||||
: 'outline'
|
||||
}
|
||||
>
|
||||
{stage.status === 'ROUND_ACTIVE' ? 'Active' : stage.status === 'ROUND_CLOSED' ? 'Closed' : stage.status}
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">{stage.programName}</p>
|
||||
{stage.windowCloseAt && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Ends: {formatDateOnly(stage.windowCloseAt)}
|
||||
</p>
|
||||
)}
|
||||
<div className="text-sm">
|
||||
<span>{stage._count?.projects || 0} projects</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function AnalyticsTab({ selectedValue }: { selectedValue: string }) {
|
||||
const queryInput = parseSelection(selectedValue)
|
||||
const hasSelection = !!queryInput.roundId || !!queryInput.programId
|
||||
|
||||
const { data: scoreDistribution, isLoading: scoreLoading } =
|
||||
trpc.analytics.getScoreDistribution.useQuery(
|
||||
queryInput,
|
||||
{ enabled: hasSelection }
|
||||
)
|
||||
|
||||
const { data: timeline, isLoading: timelineLoading } =
|
||||
trpc.analytics.getEvaluationTimeline.useQuery(
|
||||
queryInput,
|
||||
{ enabled: hasSelection }
|
||||
)
|
||||
|
||||
const { data: statusBreakdown, isLoading: statusLoading } =
|
||||
trpc.analytics.getStatusBreakdown.useQuery(
|
||||
queryInput,
|
||||
{ enabled: hasSelection }
|
||||
)
|
||||
|
||||
const { data: jurorWorkload, isLoading: workloadLoading } =
|
||||
trpc.analytics.getJurorWorkload.useQuery(
|
||||
queryInput,
|
||||
{ enabled: hasSelection }
|
||||
)
|
||||
|
||||
const { data: projectRankings, isLoading: rankingsLoading } =
|
||||
trpc.analytics.getProjectRankings.useQuery(
|
||||
{ ...queryInput, limit: 15 },
|
||||
{ enabled: hasSelection }
|
||||
)
|
||||
|
||||
const { data: criteriaScores, isLoading: criteriaLoading } =
|
||||
trpc.analytics.getCriteriaScores.useQuery(
|
||||
queryInput,
|
||||
{ enabled: hasSelection }
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Row 1: Score Distribution & Status Breakdown */}
|
||||
<div className="grid gap-6 lg:grid-cols-2">
|
||||
{scoreLoading ? (
|
||||
<Skeleton className="h-[350px]" />
|
||||
) : scoreDistribution ? (
|
||||
<ScoreDistributionChart
|
||||
data={scoreDistribution.distribution}
|
||||
averageScore={scoreDistribution.averageScore}
|
||||
totalScores={scoreDistribution.totalScores}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{statusLoading ? (
|
||||
<Skeleton className="h-[350px]" />
|
||||
) : statusBreakdown ? (
|
||||
<StatusBreakdownChart data={statusBreakdown} />
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{/* Row 2: Evaluation Timeline */}
|
||||
{timelineLoading ? (
|
||||
<Skeleton className="h-[350px]" />
|
||||
) : timeline?.length ? (
|
||||
<EvaluationTimelineChart data={timeline} />
|
||||
) : (
|
||||
<Card>
|
||||
<CardContent className="flex items-center justify-center py-12">
|
||||
<p className="text-muted-foreground">
|
||||
No evaluation data available yet
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Row 3: Criteria Scores */}
|
||||
{criteriaLoading ? (
|
||||
<Skeleton className="h-[350px]" />
|
||||
) : criteriaScores?.length ? (
|
||||
<CriteriaScoresChart data={criteriaScores} />
|
||||
) : null}
|
||||
|
||||
{/* Row 4: Juror Workload */}
|
||||
{workloadLoading ? (
|
||||
<Skeleton className="h-[450px]" />
|
||||
) : jurorWorkload?.length ? (
|
||||
<JurorWorkloadChart data={jurorWorkload} />
|
||||
) : (
|
||||
<Card>
|
||||
<CardContent className="flex items-center justify-center py-12">
|
||||
<p className="text-muted-foreground">
|
||||
No juror assignments yet
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Row 5: Project Rankings */}
|
||||
{rankingsLoading ? (
|
||||
<Skeleton className="h-[550px]" />
|
||||
) : projectRankings?.length ? (
|
||||
<ProjectRankingsChart data={projectRankings} limit={15} />
|
||||
) : (
|
||||
<Card>
|
||||
<CardContent className="flex items-center justify-center py-12">
|
||||
<p className="text-muted-foreground">
|
||||
No project scores available yet
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function CrossStageTab() {
|
||||
const { data: programs, isLoading: programsLoading } = trpc.program.list.useQuery({ includeStages: true })
|
||||
|
||||
const stages = programs?.flatMap(p =>
|
||||
((p.stages || []) as Array<{ id: string; name: string }>).map(s => ({ id: s.id, name: s.name, programName: `${p.year} Edition` }))
|
||||
) || []
|
||||
|
||||
const [selectedRoundIds, setSelectedRoundIds] = useState<string[]>([])
|
||||
|
||||
const { data: comparison, isLoading: comparisonLoading } =
|
||||
trpc.analytics.getCrossRoundComparison.useQuery(
|
||||
{ roundIds: selectedRoundIds },
|
||||
{ enabled: selectedRoundIds.length >= 2 }
|
||||
)
|
||||
|
||||
const toggleRound = (roundId: string) => {
|
||||
setSelectedRoundIds((prev) =>
|
||||
prev.includes(roundId)
|
||||
? prev.filter((id) => id !== roundId)
|
||||
: [...prev, roundId]
|
||||
)
|
||||
function RoundTypeContent({
|
||||
roundType,
|
||||
roundId,
|
||||
programId,
|
||||
stages,
|
||||
selectedValue,
|
||||
}: {
|
||||
roundType: string
|
||||
roundId: string
|
||||
programId: string
|
||||
stages: Stage[]
|
||||
selectedValue: string | null
|
||||
}) {
|
||||
switch (roundType) {
|
||||
case 'INTAKE':
|
||||
return <IntakeReportTabs roundId={roundId} programId={programId} />
|
||||
case 'FILTERING':
|
||||
return <FilteringReportTabs roundId={roundId} programId={programId} />
|
||||
case 'EVALUATION':
|
||||
return (
|
||||
<EvaluationReportTabs
|
||||
roundId={roundId}
|
||||
programId={programId}
|
||||
stages={stages}
|
||||
selectedValue={selectedValue}
|
||||
/>
|
||||
)
|
||||
case 'SUBMISSION':
|
||||
return <SubmissionReportTabs roundId={roundId} programId={programId} />
|
||||
case 'MENTORING':
|
||||
return <MentoringReportTabs roundId={roundId} programId={programId} />
|
||||
case 'LIVE_FINAL':
|
||||
return <LiveFinalReportTabs roundId={roundId} programId={programId} />
|
||||
case 'DELIBERATION':
|
||||
return <DeliberationReportTabs roundId={roundId} programId={programId} />
|
||||
default:
|
||||
return null
|
||||
}
|
||||
|
||||
if (programsLoading) return <Skeleton className="h-[400px]" />
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Select Stages to Compare</CardTitle>
|
||||
<CardDescription>Choose at least 2 stages</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{stages.map((stage) => (
|
||||
<Badge
|
||||
key={stage.id}
|
||||
variant={selectedRoundIds.includes(stage.id) ? 'default' : 'outline'}
|
||||
className="cursor-pointer text-sm py-1.5 px-3"
|
||||
onClick={() => toggleRound(stage.id)}
|
||||
>
|
||||
{stage.programName} - {stage.name}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
{selectedRoundIds.length < 2 && (
|
||||
<p className="text-sm text-muted-foreground mt-3">
|
||||
Select at least 2 stages to enable comparison
|
||||
</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{comparisonLoading && selectedRoundIds.length >= 2 && <Skeleton className="h-[350px]" />}
|
||||
|
||||
{comparison && (
|
||||
<CrossStageComparisonChart data={comparison as Array<{
|
||||
roundId: string; roundName: string; projectCount: number; evaluationCount: number
|
||||
completionRate: number; averageScore: number | null
|
||||
scoreDistribution: { score: number; count: number }[]
|
||||
}>} />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function JurorConsistencyTab({ selectedValue }: { selectedValue: string }) {
|
||||
const queryInput = parseSelection(selectedValue)
|
||||
const hasSelection = !!queryInput.roundId || !!queryInput.programId
|
||||
|
||||
const { data: consistency, isLoading } =
|
||||
trpc.analytics.getJurorConsistency.useQuery(
|
||||
queryInput,
|
||||
{ enabled: hasSelection }
|
||||
)
|
||||
|
||||
if (isLoading) return <Skeleton className="h-[400px]" />
|
||||
|
||||
if (!consistency) return null
|
||||
|
||||
return (
|
||||
<JurorConsistencyChart
|
||||
data={consistency as {
|
||||
overallAverage: number
|
||||
jurors: Array<{
|
||||
userId: string; name: string; email: string
|
||||
evaluationCount: number; averageScore: number
|
||||
stddev: number; deviationFromOverall: number; isOutlier: boolean
|
||||
}>
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DiversityTab({ selectedValue }: { selectedValue: string }) {
|
||||
const queryInput = parseSelection(selectedValue)
|
||||
const hasSelection = !!queryInput.roundId || !!queryInput.programId
|
||||
|
||||
const { data: diversity, isLoading } =
|
||||
trpc.analytics.getDiversityMetrics.useQuery(
|
||||
queryInput,
|
||||
{ enabled: hasSelection }
|
||||
)
|
||||
|
||||
if (isLoading) return <Skeleton className="h-[400px]" />
|
||||
|
||||
if (!diversity) return null
|
||||
|
||||
return (
|
||||
<DiversityMetricsChart
|
||||
data={diversity as {
|
||||
total: number
|
||||
byCountry: { country: string; count: number; percentage: number }[]
|
||||
byCategory: { category: string; count: number; percentage: number }[]
|
||||
byOceanIssue: { issue: string; count: number; percentage: number }[]
|
||||
byTag: { tag: string; count: number; percentage: number }[]
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export default function ObserverReportsPage() {
|
||||
const [selectedValue, setSelectedValue] = useState<string | null>(null)
|
||||
function ReportsPageContent() {
|
||||
const searchParams = useSearchParams()
|
||||
const roundFromUrl = searchParams.get('round')
|
||||
const [selectedValue, setSelectedValue] = useState<string | null>(roundFromUrl)
|
||||
const [activeTab, setActiveTab] = useState<string | null>(null)
|
||||
|
||||
const { data: programs, isLoading: stagesLoading } = trpc.program.list.useQuery({ includeStages: true })
|
||||
|
||||
const stages = programs?.flatMap(p =>
|
||||
(p.stages as { id: string; name: string; status: string; windowCloseAt: Date | null; _count: { projects: number; assignments: number } }[]).map(s => ({
|
||||
const stages: Stage[] = programs?.flatMap(p =>
|
||||
((p.stages || []) as { id: string; name: string; status: string; roundType: string; windowCloseAt: Date | null; _count: { projects: number; assignments: number; evaluations: number } }[]).map(s => ({
|
||||
...s,
|
||||
programId: p.id,
|
||||
programName: `${p.year} Edition`,
|
||||
}))
|
||||
) || []
|
||||
) ?? []
|
||||
|
||||
const allRoundIds = stages.map((s) => s.id)
|
||||
|
||||
// Set default selected stage
|
||||
useEffect(() => {
|
||||
if (stages.length && !selectedValue) {
|
||||
setSelectedValue(stages[0].id)
|
||||
const active = stages.find((s) => s.status === 'ROUND_ACTIVE')
|
||||
setSelectedValue(active ? active.id : stages[0].id)
|
||||
}
|
||||
}, [stages.length, selectedValue])
|
||||
|
||||
const hasSelection = !!selectedValue
|
||||
// Reset to first round-specific tab when round selection changes
|
||||
useEffect(() => {
|
||||
setActiveTab(null)
|
||||
}, [selectedValue])
|
||||
|
||||
const isAllRounds = selectedValue?.startsWith('all:')
|
||||
const selectedRound = stages.find((s) => s.id === selectedValue)
|
||||
const roundType = selectedRound?.roundType ?? ''
|
||||
const programId = isAllRounds
|
||||
? selectedValue!.slice(4)
|
||||
: selectedRound?.programId ?? programs?.[0]?.id ?? ''
|
||||
|
||||
const roundSpecificTabs = isAllRounds
|
||||
? [{ value: 'progress', label: 'Progress', icon: TrendingUp }]
|
||||
: getRoundTabs(roundType)
|
||||
|
||||
const allTabs: TabDef[] = [
|
||||
...roundSpecificTabs,
|
||||
{ value: 'global', label: 'Global', icon: Globe },
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">Reports</h1>
|
||||
<p className="text-muted-foreground">
|
||||
View evaluation progress and statistics
|
||||
</p>
|
||||
<p className="text-muted-foreground">View evaluation progress and statistics</p>
|
||||
</div>
|
||||
|
||||
{/* Stage Selector */}
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:gap-4">
|
||||
<label className="text-sm font-medium">Select Stage:</label>
|
||||
<label className="text-sm font-medium">Select Round:</label>
|
||||
{stagesLoading ? (
|
||||
<Skeleton className="h-10 w-full sm:w-[300px]" />
|
||||
) : stages.length > 0 ? (
|
||||
<Select value={selectedValue || ''} onValueChange={setSelectedValue}>
|
||||
<SelectTrigger className="w-full sm:w-[300px]">
|
||||
<SelectValue placeholder="Select a stage" />
|
||||
<SelectValue placeholder="Select a round" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{programs?.map((p) => (
|
||||
<SelectItem key={`all:${p.id}`} value={`all:${p.id}`}>
|
||||
{p.year} Edition — All Stages
|
||||
{p.year} Edition — All Rounds
|
||||
</SelectItem>
|
||||
))}
|
||||
{stages.map((stage) => (
|
||||
<SelectItem key={stage.id} value={stage.id}>
|
||||
{stage.programName} - {stage.name}
|
||||
{stage.name}{stage.roundType ? ` (${ROUND_TYPE_LABELS[stage.roundType] || stage.roundType})` : ''}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">No stages available</p>
|
||||
<p className="text-sm text-muted-foreground">No rounds available</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<Tabs defaultValue="overview" className="space-y-6">
|
||||
<div className="flex items-center justify-between flex-wrap gap-4">
|
||||
{selectedValue && (
|
||||
<Tabs value={activeTab ?? allTabs[0]?.value ?? 'global'} onValueChange={setActiveTab} className="space-y-6">
|
||||
<TabsList>
|
||||
<TabsTrigger value="overview" className="gap-2">
|
||||
<FileSpreadsheet className="h-4 w-4" />
|
||||
Overview
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="analytics" className="gap-2" disabled={!hasSelection}>
|
||||
<TrendingUp className="h-4 w-4" />
|
||||
Analytics
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="cross-stage" className="gap-2">
|
||||
<GitCompare className="h-4 w-4" />
|
||||
Cross-Round
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="consistency" className="gap-2" disabled={!hasSelection}>
|
||||
<UserCheck className="h-4 w-4" />
|
||||
Juror Consistency
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="diversity" className="gap-2" disabled={!hasSelection}>
|
||||
<Globe className="h-4 w-4" />
|
||||
Diversity
|
||||
</TabsTrigger>
|
||||
{allTabs.map((tab) => (
|
||||
<TabsTrigger key={tab.value} value={tab.value} className="gap-2">
|
||||
<tab.icon className="h-4 w-4" />
|
||||
{tab.label}
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
{selectedValue && !selectedValue.startsWith('all:') && (
|
||||
<ExportPdfButton
|
||||
roundId={selectedValue}
|
||||
roundName={selectedRound?.name}
|
||||
programName={selectedRound?.programName}
|
||||
|
||||
<TabsContent value="global">
|
||||
<GlobalAnalyticsTab
|
||||
programId={programId}
|
||||
roundIds={allRoundIds.length >= 2 ? allRoundIds : undefined}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="overview">
|
||||
<OverviewTab selectedValue={selectedValue} />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="analytics">
|
||||
{hasSelection ? (
|
||||
<AnalyticsTab selectedValue={selectedValue!} />
|
||||
) : (
|
||||
<Card>
|
||||
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
|
||||
<BarChart3 className="h-12 w-12 text-muted-foreground/50" />
|
||||
<p className="mt-2 font-medium">Select a round</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Choose a round or edition from the dropdown above to view analytics
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="cross-stage">
|
||||
<CrossStageTab />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="consistency">
|
||||
{hasSelection ? (
|
||||
<JurorConsistencyTab selectedValue={selectedValue!} />
|
||||
) : (
|
||||
<Card>
|
||||
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
|
||||
<UserCheck className="h-12 w-12 text-muted-foreground/50" />
|
||||
<p className="mt-2 font-medium">Select a round</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Choose a round or edition above to view juror consistency metrics
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="diversity">
|
||||
{hasSelection ? (
|
||||
<DiversityTab selectedValue={selectedValue!} />
|
||||
) : (
|
||||
<Card>
|
||||
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
|
||||
<Globe className="h-12 w-12 text-muted-foreground/50" />
|
||||
<p className="mt-2 font-medium">Select a round</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Choose a round or edition above to view diversity metrics
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
{/* Round-type-specific or "All Rounds" progress tab */}
|
||||
{roundSpecificTabs.map((tab) => (
|
||||
<TabsContent key={tab.value} value={tab.value}>
|
||||
{isAllRounds ? (
|
||||
<EvaluationReportTabs
|
||||
roundId=""
|
||||
programId={programId}
|
||||
stages={stages}
|
||||
selectedValue={selectedValue}
|
||||
/>
|
||||
) : selectedRound ? (
|
||||
<RoundTypeContent
|
||||
roundType={roundType}
|
||||
roundId={selectedRound.id}
|
||||
programId={programId}
|
||||
stages={stages}
|
||||
selectedValue={selectedValue}
|
||||
/>
|
||||
) : null}
|
||||
</TabsContent>
|
||||
))}
|
||||
</Tabs>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function ObserverReportsPage() {
|
||||
return (
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<Skeleton className="h-8 w-32" />
|
||||
<Skeleton className="mt-2 h-4 w-56" />
|
||||
</div>
|
||||
<Skeleton className="h-10 w-full sm:w-[300px]" />
|
||||
<Skeleton className="h-[400px] w-full" />
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<ReportsPageContent />
|
||||
</Suspense>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -13,8 +13,10 @@ export async function GET(request: NextRequest): Promise<NextResponse> {
|
||||
const now = new Date()
|
||||
|
||||
// Delete projects where isDraft=true AND draftExpiresAt has passed
|
||||
// Exclude test projects — they are managed separately
|
||||
const result = await prisma.project.deleteMany({
|
||||
where: {
|
||||
isTest: false,
|
||||
isDraft: true,
|
||||
draftExpiresAt: {
|
||||
lt: now,
|
||||
|
||||
@@ -43,6 +43,44 @@
|
||||
/* Source the JS config for extended theme values */
|
||||
@config "../../tailwind.config.ts";
|
||||
|
||||
/* Tremor generates Tailwind utility classes dynamically via template literals
|
||||
(e.g. `fill-${color}-${shade}`). Tailwind v4's scanner cannot detect these,
|
||||
so we must explicitly safelist every color+shade+property combination. */
|
||||
@source "../../node_modules/@tremor/react/dist/**/*.js";
|
||||
|
||||
/* Safelist Tremor chart color utilities — all colors × key shades × fill/stroke/bg */
|
||||
@source inline("{fill,stroke,bg,text}-{blue,emerald,amber,violet,rose,indigo,sky,fuchsia,lime,orange,cyan,teal,purple,slate,gray,zinc,neutral,stone,red,yellow,green,pink}-{50,100,200,300,400,500,600,700,800,900,950}");
|
||||
@source inline("hover:{bg,text,border}-{blue,emerald,amber,violet,rose,indigo,sky,fuchsia,lime,orange,cyan,teal,purple,slate,gray,zinc,neutral,stone,red,yellow,green,pink}-{50,100,200,300,400,500,600,700,800,900,950}");
|
||||
@source inline("{border,ring}-{blue,emerald,amber,violet,rose,indigo,sky,fuchsia,lime,orange,cyan,teal,purple,slate,gray,zinc,neutral,stone,red,yellow,green,pink}-{50,100,200,300,400,500,600,700,800,900,950}");
|
||||
|
||||
/* Safelist Tremor design token utility classes */
|
||||
@source inline("{fill,stroke,bg,text,border}-tremor-{brand,background,border,content,content-emphasis,default,label,card,dropdown}");
|
||||
|
||||
/* Tremor design tokens — normally registered by Tremor's TW3 plugin.
|
||||
We define them manually for Tailwind v4 compatibility. */
|
||||
@theme {
|
||||
--color-tremor-brand: var(--color-blue-500);
|
||||
--color-tremor-brand-emphasis: var(--color-blue-700);
|
||||
--color-tremor-brand-inverted: #fff;
|
||||
--color-tremor-brand-muted: var(--color-blue-200);
|
||||
--color-tremor-brand-faint: var(--color-blue-50);
|
||||
--color-tremor-background: #fff;
|
||||
--color-tremor-background-emphasis: var(--color-gray-700);
|
||||
--color-tremor-background-muted: var(--color-gray-50);
|
||||
--color-tremor-background-subtle: var(--color-gray-100);
|
||||
--color-tremor-border: var(--color-gray-200);
|
||||
--color-tremor-content: var(--color-gray-500);
|
||||
--color-tremor-content-emphasis: var(--color-gray-700);
|
||||
--color-tremor-content-strong: var(--color-gray-900);
|
||||
--color-tremor-content-subtle: var(--color-gray-400);
|
||||
--color-tremor-content-inverted: #fff;
|
||||
--color-tremor-ring: var(--color-gray-200);
|
||||
--color-tremor-default: var(--color-gray-500);
|
||||
--color-tremor-label: var(--color-gray-400);
|
||||
--color-tremor-card: #fff;
|
||||
--color-tremor-dropdown: #fff;
|
||||
}
|
||||
|
||||
/* Theme variables - using CSS custom properties with Tailwind v4 @theme */
|
||||
@theme {
|
||||
/* Container */
|
||||
@@ -294,3 +332,46 @@
|
||||
background: hsl(var(--muted-foreground) / 0.5);
|
||||
}
|
||||
}
|
||||
|
||||
/* Tremor chart tooltip fix — ensure solid background */
|
||||
[class*="tremor-"] [role="tooltip"],
|
||||
.recharts-tooltip-wrapper .recharts-default-tooltip,
|
||||
div[class*="tremor"][class*="tooltip"],
|
||||
div[class*="recharts-tooltip"] {
|
||||
background-color: hsl(var(--card)) !important;
|
||||
border: 1px solid hsl(var(--border)) !important;
|
||||
border-radius: 0.5rem !important;
|
||||
box-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1) !important;
|
||||
opacity: 1 !important;
|
||||
}
|
||||
|
||||
.dark div[class*="tremor"][class*="tooltip"],
|
||||
.dark .recharts-tooltip-wrapper .recharts-default-tooltip,
|
||||
.dark div[class*="recharts-tooltip"] {
|
||||
background-color: hsl(var(--card)) !important;
|
||||
border-color: hsl(var(--border)) !important;
|
||||
}
|
||||
|
||||
/* Tremor/Recharts tooltip color indicator icons — fix rendering */
|
||||
.recharts-tooltip-wrapper svg.recharts-surface {
|
||||
display: inline-block !important;
|
||||
overflow: visible !important;
|
||||
}
|
||||
|
||||
/* Tremor custom tooltip color dots */
|
||||
[class*="tremor"] [role="tooltip"] span[class*="bg-"],
|
||||
[class*="tremor"] [role="tooltip"] span[style*="background"] {
|
||||
border-radius: 2px !important;
|
||||
min-width: 10px !important;
|
||||
min-height: 10px !important;
|
||||
flex-shrink: 0 !important;
|
||||
}
|
||||
|
||||
/* Recharts default tooltip icon fix — ensure SVG paths have correct fill */
|
||||
.recharts-default-tooltip .recharts-tooltip-item-list {
|
||||
padding: 0 !important;
|
||||
}
|
||||
|
||||
.recharts-default-tooltip .recharts-tooltip-item svg {
|
||||
border: none !important;
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { Metadata } from 'next'
|
||||
import './globals.css'
|
||||
import { Providers } from './providers'
|
||||
import { Toaster } from 'sonner'
|
||||
import { ImpersonationBanner } from '@/components/shared/impersonation-banner'
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: {
|
||||
@@ -22,7 +23,10 @@ export default function RootLayout({
|
||||
return (
|
||||
<html lang="en" suppressHydrationWarning>
|
||||
<body className="min-h-screen bg-background font-sans antialiased">
|
||||
<Providers>{children}</Providers>
|
||||
<Providers>
|
||||
<ImpersonationBanner />
|
||||
{children}
|
||||
</Providers>
|
||||
<Toaster
|
||||
position="top-right"
|
||||
toastOptions={{
|
||||
|
||||
@@ -9,6 +9,9 @@ import { Badge } from '@/components/ui/badge'
|
||||
import { Checkbox } from '@/components/ui/checkbox'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||
import { ScrollArea } from '@/components/ui/scroll-area'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
@@ -85,6 +88,7 @@ export function ProjectStatesTable({ competitionId, roundId }: ProjectStatesTabl
|
||||
const [removeConfirmId, setRemoveConfirmId] = useState<string | null>(null)
|
||||
const [batchRemoveOpen, setBatchRemoveOpen] = useState(false)
|
||||
const [quickAddOpen, setQuickAddOpen] = useState(false)
|
||||
const [addProjectOpen, setAddProjectOpen] = useState(false)
|
||||
|
||||
const utils = trpc.useUtils()
|
||||
|
||||
@@ -274,16 +278,10 @@ export function ProjectStatesTable({ competitionId, roundId }: ProjectStatesTabl
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button size="sm" variant="outline" onClick={() => { setQuickAddOpen(true) }}>
|
||||
<Button size="sm" variant="outline" onClick={() => { setAddProjectOpen(true) }}>
|
||||
<Plus className="h-4 w-4 mr-1.5" />
|
||||
Quick Add
|
||||
Add Project
|
||||
</Button>
|
||||
<Link href={poolLink}>
|
||||
<Button size="sm" variant="outline">
|
||||
<Plus className="h-4 w-4 mr-1.5" />
|
||||
Add from Pool
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -436,7 +434,7 @@ export function ProjectStatesTable({ competitionId, roundId }: ProjectStatesTabl
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Quick Add Dialog */}
|
||||
{/* Quick Add Dialog (legacy, kept for empty state) */}
|
||||
<QuickAddDialog
|
||||
open={quickAddOpen}
|
||||
onOpenChange={setQuickAddOpen}
|
||||
@@ -447,6 +445,17 @@ export function ProjectStatesTable({ competitionId, roundId }: ProjectStatesTabl
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Add Project Dialog (Create New + From Pool) */}
|
||||
<AddProjectDialog
|
||||
open={addProjectOpen}
|
||||
onOpenChange={setAddProjectOpen}
|
||||
roundId={roundId}
|
||||
competitionId={competitionId}
|
||||
onAssigned={() => {
|
||||
utils.roundEngine.getProjectStates.invalidate({ roundId })
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Single Remove Confirmation */}
|
||||
<AlertDialog open={!!removeConfirmId} onOpenChange={(open) => { if (!open) setRemoveConfirmId(null) }}>
|
||||
<AlertDialogContent>
|
||||
@@ -673,3 +682,287 @@ function QuickAddDialog({
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Add Project Dialog — two tabs: "Create New" and "From Pool".
|
||||
* Create New: form to create a project and assign it directly to the round.
|
||||
* From Pool: search existing projects not yet in this round and assign them.
|
||||
*/
|
||||
function AddProjectDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
roundId,
|
||||
competitionId,
|
||||
onAssigned,
|
||||
}: {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
roundId: string
|
||||
competitionId: string
|
||||
onAssigned: () => void
|
||||
}) {
|
||||
const [activeTab, setActiveTab] = useState<'create' | 'pool'>('create')
|
||||
|
||||
// ── Create New tab state ──
|
||||
const [title, setTitle] = useState('')
|
||||
const [teamName, setTeamName] = useState('')
|
||||
const [description, setDescription] = useState('')
|
||||
const [country, setCountry] = useState('')
|
||||
const [category, setCategory] = useState<string>('')
|
||||
|
||||
// ── From Pool tab state ──
|
||||
const [poolSearch, setPoolSearch] = useState('')
|
||||
const [selectedPoolIds, setSelectedPoolIds] = useState<Set<string>>(new Set())
|
||||
|
||||
const utils = trpc.useUtils()
|
||||
|
||||
// Get the competition to find programId (for pool search)
|
||||
const { data: competition } = trpc.competition.getById.useQuery(
|
||||
{ id: competitionId },
|
||||
{ enabled: open && !!competitionId },
|
||||
)
|
||||
const programId = (competition as any)?.programId || ''
|
||||
|
||||
// Pool query
|
||||
const { data: poolResults, isLoading: poolLoading } = trpc.projectPool.listUnassigned.useQuery(
|
||||
{
|
||||
programId,
|
||||
excludeRoundId: roundId,
|
||||
search: poolSearch.trim() || undefined,
|
||||
perPage: 50,
|
||||
},
|
||||
{ enabled: open && activeTab === 'pool' && !!programId },
|
||||
)
|
||||
|
||||
// Create mutation
|
||||
const createMutation = trpc.project.createAndAssignToRound.useMutation({
|
||||
onSuccess: () => {
|
||||
toast.success('Project created and added to round')
|
||||
utils.roundEngine.getProjectStates.invalidate({ roundId })
|
||||
onAssigned()
|
||||
resetAndClose()
|
||||
},
|
||||
onError: (err) => toast.error(err.message),
|
||||
})
|
||||
|
||||
// Assign from pool mutation
|
||||
const assignMutation = trpc.projectPool.assignToRound.useMutation({
|
||||
onSuccess: (data) => {
|
||||
toast.success(`${data.assignedCount} project(s) added to round`)
|
||||
utils.roundEngine.getProjectStates.invalidate({ roundId })
|
||||
onAssigned()
|
||||
resetAndClose()
|
||||
},
|
||||
onError: (err) => toast.error(err.message),
|
||||
})
|
||||
|
||||
const resetAndClose = () => {
|
||||
setTitle('')
|
||||
setTeamName('')
|
||||
setDescription('')
|
||||
setCountry('')
|
||||
setCategory('')
|
||||
setPoolSearch('')
|
||||
setSelectedPoolIds(new Set())
|
||||
onOpenChange(false)
|
||||
}
|
||||
|
||||
const handleCreate = () => {
|
||||
if (!title.trim()) return
|
||||
createMutation.mutate({
|
||||
title: title.trim(),
|
||||
teamName: teamName.trim() || undefined,
|
||||
description: description.trim() || undefined,
|
||||
country: country.trim() || undefined,
|
||||
competitionCategory: category === 'STARTUP' || category === 'BUSINESS_CONCEPT' ? category : undefined,
|
||||
roundId,
|
||||
})
|
||||
}
|
||||
|
||||
const handleAssignFromPool = () => {
|
||||
if (selectedPoolIds.size === 0) return
|
||||
assignMutation.mutate({
|
||||
projectIds: Array.from(selectedPoolIds),
|
||||
roundId,
|
||||
})
|
||||
}
|
||||
|
||||
const togglePoolProject = (id: string) => {
|
||||
setSelectedPoolIds(prev => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(id)) next.delete(id)
|
||||
else next.add(id)
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
const isMutating = createMutation.isPending || assignMutation.isPending
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(isOpen) => {
|
||||
if (!isOpen) resetAndClose()
|
||||
else onOpenChange(true)
|
||||
}}>
|
||||
<DialogContent className="max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Add Project to Round</DialogTitle>
|
||||
<DialogDescription>
|
||||
Create a new project or select existing ones to add to this round.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<Tabs value={activeTab} onValueChange={(v) => setActiveTab(v as 'create' | 'pool')}>
|
||||
<TabsList className="grid w-full grid-cols-2">
|
||||
<TabsTrigger value="create">Create New</TabsTrigger>
|
||||
<TabsTrigger value="pool">From Pool</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
{/* ── Create New Tab ── */}
|
||||
<TabsContent value="create" className="space-y-4 mt-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="add-project-title">Title *</Label>
|
||||
<Input
|
||||
id="add-project-title"
|
||||
placeholder="Project title"
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="add-project-team">Team Name</Label>
|
||||
<Input
|
||||
id="add-project-team"
|
||||
placeholder="Team or organization name"
|
||||
value={teamName}
|
||||
onChange={(e) => setTeamName(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="add-project-country">Country</Label>
|
||||
<Input
|
||||
id="add-project-country"
|
||||
placeholder="e.g. France"
|
||||
value={country}
|
||||
onChange={(e) => setCountry(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Category</Label>
|
||||
<Select value={category} onValueChange={setCategory}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select..." />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="STARTUP">Startup</SelectItem>
|
||||
<SelectItem value="BUSINESS_CONCEPT">Business Concept</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="add-project-desc">Description</Label>
|
||||
<Input
|
||||
id="add-project-desc"
|
||||
placeholder="Brief description (optional)"
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={resetAndClose}>Cancel</Button>
|
||||
<Button
|
||||
onClick={handleCreate}
|
||||
disabled={!title.trim() || isMutating}
|
||||
>
|
||||
{createMutation.isPending && <Loader2 className="h-4 w-4 mr-1.5 animate-spin" />}
|
||||
Create & Add to Round
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</TabsContent>
|
||||
|
||||
{/* ── From Pool Tab ── */}
|
||||
<TabsContent value="pool" className="space-y-4 mt-4">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Search by project title or team..."
|
||||
value={poolSearch}
|
||||
onChange={(e) => setPoolSearch(e.target.value)}
|
||||
className="pl-8"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<ScrollArea className="h-[320px] rounded-md border">
|
||||
<div className="p-2 space-y-0.5">
|
||||
{poolLoading && (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!poolLoading && poolResults?.projects.length === 0 && (
|
||||
<p className="text-sm text-muted-foreground text-center py-8">
|
||||
{poolSearch.trim() ? `No projects found matching "${poolSearch}"` : 'No projects available to add'}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{poolResults?.projects.map((project: any) => {
|
||||
const isSelected = selectedPoolIds.has(project.id)
|
||||
return (
|
||||
<label
|
||||
key={project.id}
|
||||
className={`flex items-center gap-3 rounded-md px-2.5 py-2 text-sm cursor-pointer transition-colors ${
|
||||
isSelected ? 'bg-accent' : 'hover:bg-muted/50'
|
||||
}`}
|
||||
>
|
||||
<Checkbox
|
||||
checked={isSelected}
|
||||
onCheckedChange={() => togglePoolProject(project.id)}
|
||||
/>
|
||||
<div className="flex flex-1 items-center justify-between min-w-0">
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-medium truncate">{project.title}</p>
|
||||
<p className="text-xs text-muted-foreground truncate">
|
||||
{project.teamName}
|
||||
{project.country && <> · {project.country}</>}
|
||||
</p>
|
||||
</div>
|
||||
{project.competitionCategory && (
|
||||
<Badge variant="outline" className="text-[10px] ml-2 shrink-0">
|
||||
{project.competitionCategory === 'STARTUP' ? 'Startup' : 'Concept'}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</label>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
|
||||
{poolResults && poolResults.total > 50 && (
|
||||
<p className="text-xs text-muted-foreground text-center">
|
||||
Showing 50 of {poolResults.total} — refine your search for more specific results
|
||||
</p>
|
||||
)}
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={resetAndClose}>Cancel</Button>
|
||||
<Button
|
||||
onClick={handleAssignFromPool}
|
||||
disabled={selectedPoolIds.size === 0 || isMutating}
|
||||
>
|
||||
{assignMutation.isPending && <Loader2 className="h-4 w-4 mr-1.5 animate-spin" />}
|
||||
{selectedPoolIds.size <= 1
|
||||
? 'Add to Round'
|
||||
: `Add ${selectedPoolIds.size} Projects to Round`
|
||||
}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,654 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
import { toast } from 'sonner'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from '@/components/ui/dialog'
|
||||
import { Plus, Lock, Unlock, LockKeyhole, Loader2, Pencil, Trash2 } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { format } from 'date-fns'
|
||||
|
||||
type SubmissionWindowManagerProps = {
|
||||
competitionId: string
|
||||
roundId: string
|
||||
}
|
||||
|
||||
export function SubmissionWindowManager({ competitionId, roundId }: SubmissionWindowManagerProps) {
|
||||
const [isCreateOpen, setIsCreateOpen] = useState(false)
|
||||
const [editingWindow, setEditingWindow] = useState<string | null>(null)
|
||||
const [deletingWindow, setDeletingWindow] = useState<string | null>(null)
|
||||
|
||||
// Create form state
|
||||
const [createForm, setCreateForm] = useState({
|
||||
name: '',
|
||||
slug: '',
|
||||
roundNumber: 1,
|
||||
windowOpenAt: '',
|
||||
windowCloseAt: '',
|
||||
deadlinePolicy: 'HARD_DEADLINE' as 'HARD_DEADLINE' | 'FLAG' | 'GRACE',
|
||||
graceHours: 0,
|
||||
lockOnClose: true,
|
||||
})
|
||||
|
||||
// Edit form state
|
||||
const [editForm, setEditForm] = useState({
|
||||
name: '',
|
||||
slug: '',
|
||||
roundNumber: 1,
|
||||
windowOpenAt: '',
|
||||
windowCloseAt: '',
|
||||
deadlinePolicy: 'HARD_DEADLINE' as 'HARD_DEADLINE' | 'FLAG' | 'GRACE',
|
||||
graceHours: 0,
|
||||
lockOnClose: true,
|
||||
sortOrder: 1,
|
||||
})
|
||||
|
||||
const utils = trpc.useUtils()
|
||||
|
||||
const { data: competition, isLoading } = trpc.competition.getById.useQuery({
|
||||
id: competitionId,
|
||||
})
|
||||
|
||||
const createWindowMutation = trpc.round.createSubmissionWindow.useMutation({
|
||||
onSuccess: () => {
|
||||
utils.competition.getById.invalidate({ id: competitionId })
|
||||
toast.success('Submission window created')
|
||||
setIsCreateOpen(false)
|
||||
// Reset form
|
||||
setCreateForm({
|
||||
name: '',
|
||||
slug: '',
|
||||
roundNumber: 1,
|
||||
windowOpenAt: '',
|
||||
windowCloseAt: '',
|
||||
deadlinePolicy: 'HARD_DEADLINE',
|
||||
graceHours: 0,
|
||||
lockOnClose: true,
|
||||
})
|
||||
},
|
||||
onError: (err) => toast.error(err.message),
|
||||
})
|
||||
|
||||
const updateWindowMutation = trpc.round.updateSubmissionWindow.useMutation({
|
||||
onSuccess: () => {
|
||||
utils.competition.getById.invalidate({ id: competitionId })
|
||||
toast.success('Submission window updated')
|
||||
setEditingWindow(null)
|
||||
},
|
||||
onError: (err) => toast.error(err.message),
|
||||
})
|
||||
|
||||
const deleteWindowMutation = trpc.round.deleteSubmissionWindow.useMutation({
|
||||
onSuccess: () => {
|
||||
utils.competition.getById.invalidate({ id: competitionId })
|
||||
toast.success('Submission window deleted')
|
||||
setDeletingWindow(null)
|
||||
},
|
||||
onError: (err) => toast.error(err.message),
|
||||
})
|
||||
|
||||
const openWindowMutation = trpc.round.openSubmissionWindow.useMutation({
|
||||
onSuccess: () => {
|
||||
utils.competition.getById.invalidate({ id: competitionId })
|
||||
toast.success('Window opened')
|
||||
},
|
||||
onError: (err) => toast.error(err.message),
|
||||
})
|
||||
|
||||
const closeWindowMutation = trpc.round.closeSubmissionWindow.useMutation({
|
||||
onSuccess: () => {
|
||||
utils.competition.getById.invalidate({ id: competitionId })
|
||||
toast.success('Window closed')
|
||||
},
|
||||
onError: (err) => toast.error(err.message),
|
||||
})
|
||||
|
||||
const lockWindowMutation = trpc.round.lockSubmissionWindow.useMutation({
|
||||
onSuccess: () => {
|
||||
utils.competition.getById.invalidate({ id: competitionId })
|
||||
toast.success('Window locked')
|
||||
},
|
||||
onError: (err) => toast.error(err.message),
|
||||
})
|
||||
|
||||
const handleCreateNameChange = (value: string) => {
|
||||
const autoSlug = value.toLowerCase().replace(/\s+/g, '-').replace(/[^a-z0-9-]/g, '')
|
||||
setCreateForm({ ...createForm, name: value, slug: autoSlug })
|
||||
}
|
||||
|
||||
const handleEditNameChange = (value: string) => {
|
||||
const autoSlug = value.toLowerCase().replace(/\s+/g, '-').replace(/[^a-z0-9-]/g, '')
|
||||
setEditForm({ ...editForm, name: value, slug: autoSlug })
|
||||
}
|
||||
|
||||
const handleCreate = () => {
|
||||
if (!createForm.name || !createForm.slug) {
|
||||
toast.error('Name and slug are required')
|
||||
return
|
||||
}
|
||||
|
||||
createWindowMutation.mutate({
|
||||
competitionId,
|
||||
name: createForm.name,
|
||||
slug: createForm.slug,
|
||||
roundNumber: createForm.roundNumber,
|
||||
windowOpenAt: createForm.windowOpenAt ? new Date(createForm.windowOpenAt) : undefined,
|
||||
windowCloseAt: createForm.windowCloseAt ? new Date(createForm.windowCloseAt) : undefined,
|
||||
deadlinePolicy: createForm.deadlinePolicy,
|
||||
graceHours: createForm.deadlinePolicy === 'GRACE' ? createForm.graceHours : undefined,
|
||||
lockOnClose: createForm.lockOnClose,
|
||||
})
|
||||
}
|
||||
|
||||
const handleEdit = () => {
|
||||
if (!editingWindow) return
|
||||
if (!editForm.name || !editForm.slug) {
|
||||
toast.error('Name and slug are required')
|
||||
return
|
||||
}
|
||||
|
||||
updateWindowMutation.mutate({
|
||||
id: editingWindow,
|
||||
name: editForm.name,
|
||||
slug: editForm.slug,
|
||||
roundNumber: editForm.roundNumber,
|
||||
windowOpenAt: editForm.windowOpenAt ? new Date(editForm.windowOpenAt) : null,
|
||||
windowCloseAt: editForm.windowCloseAt ? new Date(editForm.windowCloseAt) : null,
|
||||
deadlinePolicy: editForm.deadlinePolicy,
|
||||
graceHours: editForm.deadlinePolicy === 'GRACE' ? editForm.graceHours : null,
|
||||
lockOnClose: editForm.lockOnClose,
|
||||
sortOrder: editForm.sortOrder,
|
||||
})
|
||||
}
|
||||
|
||||
const handleDelete = () => {
|
||||
if (!deletingWindow) return
|
||||
deleteWindowMutation.mutate({ id: deletingWindow })
|
||||
}
|
||||
|
||||
const openEditDialog = (window: any) => {
|
||||
setEditForm({
|
||||
name: window.name,
|
||||
slug: window.slug,
|
||||
roundNumber: window.roundNumber,
|
||||
windowOpenAt: window.windowOpenAt ? new Date(window.windowOpenAt).toISOString().slice(0, 16) : '',
|
||||
windowCloseAt: window.windowCloseAt ? new Date(window.windowCloseAt).toISOString().slice(0, 16) : '',
|
||||
deadlinePolicy: window.deadlinePolicy ?? 'HARD_DEADLINE',
|
||||
graceHours: window.graceHours ?? 0,
|
||||
lockOnClose: window.lockOnClose ?? true,
|
||||
sortOrder: window.sortOrder ?? 1,
|
||||
})
|
||||
setEditingWindow(window.id)
|
||||
}
|
||||
|
||||
const formatDate = (date: Date | null | undefined) => {
|
||||
if (!date) return 'Not set'
|
||||
return format(new Date(date), 'MMM d, yyyy h:mm a')
|
||||
}
|
||||
|
||||
const windows = competition?.submissionWindows ?? []
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<CardTitle className="text-base">Submission Windows</CardTitle>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
File upload windows for this round
|
||||
</p>
|
||||
</div>
|
||||
<Dialog open={isCreateOpen} onOpenChange={setIsCreateOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button size="sm" variant="outline" className="w-full sm:w-auto">
|
||||
<Plus className="h-4 w-4 mr-1" />
|
||||
Create Window
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="max-h-[90vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Create Submission Window</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="create-name">Window Name</Label>
|
||||
<Input
|
||||
id="create-name"
|
||||
placeholder="e.g., Round 1 Submissions"
|
||||
value={createForm.name}
|
||||
onChange={(e) => handleCreateNameChange(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="create-slug">Slug</Label>
|
||||
<Input
|
||||
id="create-slug"
|
||||
placeholder="e.g., round-1-submissions"
|
||||
value={createForm.slug}
|
||||
onChange={(e) => setCreateForm({ ...createForm, slug: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="create-roundNumber">Round Number</Label>
|
||||
<Input
|
||||
id="create-roundNumber"
|
||||
type="number"
|
||||
min={1}
|
||||
value={createForm.roundNumber}
|
||||
onChange={(e) => setCreateForm({ ...createForm, roundNumber: parseInt(e.target.value, 10) || 1 })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="create-windowOpenAt">Window Open At</Label>
|
||||
<Input
|
||||
id="create-windowOpenAt"
|
||||
type="datetime-local"
|
||||
value={createForm.windowOpenAt}
|
||||
onChange={(e) => setCreateForm({ ...createForm, windowOpenAt: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="create-windowCloseAt">Window Close At</Label>
|
||||
<Input
|
||||
id="create-windowCloseAt"
|
||||
type="datetime-local"
|
||||
value={createForm.windowCloseAt}
|
||||
onChange={(e) => setCreateForm({ ...createForm, windowCloseAt: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="create-deadlinePolicy">Deadline Policy</Label>
|
||||
<Select
|
||||
value={createForm.deadlinePolicy}
|
||||
onValueChange={(value: 'HARD_DEADLINE' | 'FLAG' | 'GRACE') =>
|
||||
setCreateForm({ ...createForm, deadlinePolicy: value })
|
||||
}
|
||||
>
|
||||
<SelectTrigger id="create-deadlinePolicy">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="HARD_DEADLINE">Hard Deadline</SelectItem>
|
||||
<SelectItem value="FLAG">Flag Late Submissions</SelectItem>
|
||||
<SelectItem value="GRACE">Grace Period</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{createForm.deadlinePolicy === 'GRACE' && (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="create-graceHours">Grace Hours</Label>
|
||||
<Input
|
||||
id="create-graceHours"
|
||||
type="number"
|
||||
min={0}
|
||||
value={createForm.graceHours}
|
||||
onChange={(e) => setCreateForm({ ...createForm, graceHours: parseInt(e.target.value, 10) || 0 })}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Switch
|
||||
id="create-lockOnClose"
|
||||
checked={createForm.lockOnClose}
|
||||
onCheckedChange={(checked) => setCreateForm({ ...createForm, lockOnClose: checked })}
|
||||
/>
|
||||
<Label htmlFor="create-lockOnClose" className="cursor-pointer">
|
||||
Lock window on close
|
||||
</Label>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 pt-4">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="flex-1"
|
||||
onClick={() => setIsCreateOpen(false)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
className="flex-1"
|
||||
onClick={handleCreate}
|
||||
disabled={createWindowMutation.isPending}
|
||||
>
|
||||
{createWindowMutation.isPending && (
|
||||
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||
)}
|
||||
Create
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{isLoading ? (
|
||||
<div className="py-8 text-center text-sm text-muted-foreground">
|
||||
Loading windows...
|
||||
</div>
|
||||
) : windows.length === 0 ? (
|
||||
<div className="py-8 text-center text-sm text-muted-foreground">
|
||||
No submission windows yet. Create one to enable file uploads.
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{windows.map((window) => {
|
||||
const isPending = !window.windowOpenAt
|
||||
const isOpen = window.windowOpenAt && !window.windowCloseAt
|
||||
const isClosed = window.windowCloseAt && !window.isLocked
|
||||
const isLocked = window.isLocked
|
||||
|
||||
return (
|
||||
<div
|
||||
key={window.id}
|
||||
className="flex flex-col gap-3 border rounded-lg p-3"
|
||||
>
|
||||
<div className="flex flex-col sm:flex-row sm:items-start sm:justify-between gap-3">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<p className="text-sm font-medium truncate">{window.name}</p>
|
||||
{isPending && (
|
||||
<Badge variant="secondary" className="text-[10px] bg-gray-100 text-gray-700">
|
||||
Pending
|
||||
</Badge>
|
||||
)}
|
||||
{isOpen && (
|
||||
<Badge variant="secondary" className="text-[10px] bg-emerald-100 text-emerald-700">
|
||||
Open
|
||||
</Badge>
|
||||
)}
|
||||
{isClosed && (
|
||||
<Badge variant="secondary" className="text-[10px] bg-blue-100 text-blue-700">
|
||||
Closed
|
||||
</Badge>
|
||||
)}
|
||||
{isLocked && (
|
||||
<Badge variant="secondary" className="text-[10px] bg-red-100 text-red-700">
|
||||
<LockKeyhole className="h-2.5 w-2.5 mr-1" />
|
||||
Locked
|
||||
</Badge>
|
||||
)}
|
||||
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground font-mono mt-0.5">{window.slug}</p>
|
||||
<div className="flex flex-wrap gap-2 mt-1 text-xs text-muted-foreground">
|
||||
<span>Round {window.roundNumber}</span>
|
||||
<span>•</span>
|
||||
<span>{window._count.fileRequirements} requirements</span>
|
||||
<span>•</span>
|
||||
<span>{window._count.projectFiles} files</span>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2 mt-1 text-xs text-muted-foreground">
|
||||
<span>Open: {formatDate(window.windowOpenAt)}</span>
|
||||
<span>•</span>
|
||||
<span>Close: {formatDate(window.windowCloseAt)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 shrink-0 flex-wrap">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => openEditDialog(window)}
|
||||
className="h-8 px-2"
|
||||
>
|
||||
<Pencil className="h-3 w-3" />
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => setDeletingWindow(window.id)}
|
||||
className="h-8 px-2 text-destructive hover:text-destructive"
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</Button>
|
||||
{isPending && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => openWindowMutation.mutate({ windowId: window.id })}
|
||||
disabled={openWindowMutation.isPending}
|
||||
>
|
||||
{openWindowMutation.isPending ? (
|
||||
<Loader2 className="h-3 w-3 mr-1 animate-spin" />
|
||||
) : (
|
||||
<Unlock className="h-3 w-3 mr-1" />
|
||||
)}
|
||||
Open
|
||||
</Button>
|
||||
)}
|
||||
{isOpen && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => closeWindowMutation.mutate({ windowId: window.id })}
|
||||
disabled={closeWindowMutation.isPending}
|
||||
>
|
||||
{closeWindowMutation.isPending ? (
|
||||
<Loader2 className="h-3 w-3 mr-1 animate-spin" />
|
||||
) : (
|
||||
<Lock className="h-3 w-3 mr-1" />
|
||||
)}
|
||||
Close
|
||||
</Button>
|
||||
)}
|
||||
{isClosed && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => lockWindowMutation.mutate({ windowId: window.id })}
|
||||
disabled={lockWindowMutation.isPending}
|
||||
>
|
||||
{lockWindowMutation.isPending ? (
|
||||
<Loader2 className="h-3 w-3 mr-1 animate-spin" />
|
||||
) : (
|
||||
<LockKeyhole className="h-3 w-3 mr-1" />
|
||||
)}
|
||||
Lock
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Edit Dialog */}
|
||||
<Dialog open={!!editingWindow} onOpenChange={(open) => !open && setEditingWindow(null)}>
|
||||
<DialogContent className="max-h-[90vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Edit Submission Window</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="edit-name">Window Name</Label>
|
||||
<Input
|
||||
id="edit-name"
|
||||
placeholder="e.g., Round 1 Submissions"
|
||||
value={editForm.name}
|
||||
onChange={(e) => handleEditNameChange(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="edit-slug">Slug</Label>
|
||||
<Input
|
||||
id="edit-slug"
|
||||
placeholder="e.g., round-1-submissions"
|
||||
value={editForm.slug}
|
||||
onChange={(e) => setEditForm({ ...editForm, slug: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="edit-roundNumber">Round Number</Label>
|
||||
<Input
|
||||
id="edit-roundNumber"
|
||||
type="number"
|
||||
min={1}
|
||||
value={editForm.roundNumber}
|
||||
onChange={(e) => setEditForm({ ...editForm, roundNumber: parseInt(e.target.value, 10) || 1 })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="edit-windowOpenAt">Window Open At</Label>
|
||||
<Input
|
||||
id="edit-windowOpenAt"
|
||||
type="datetime-local"
|
||||
value={editForm.windowOpenAt}
|
||||
onChange={(e) => setEditForm({ ...editForm, windowOpenAt: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="edit-windowCloseAt">Window Close At</Label>
|
||||
<Input
|
||||
id="edit-windowCloseAt"
|
||||
type="datetime-local"
|
||||
value={editForm.windowCloseAt}
|
||||
onChange={(e) => setEditForm({ ...editForm, windowCloseAt: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="edit-deadlinePolicy">Deadline Policy</Label>
|
||||
<Select
|
||||
value={editForm.deadlinePolicy}
|
||||
onValueChange={(value: 'HARD_DEADLINE' | 'FLAG' | 'GRACE') =>
|
||||
setEditForm({ ...editForm, deadlinePolicy: value })
|
||||
}
|
||||
>
|
||||
<SelectTrigger id="edit-deadlinePolicy">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="HARD_DEADLINE">Hard Deadline</SelectItem>
|
||||
<SelectItem value="FLAG">Flag Late Submissions</SelectItem>
|
||||
<SelectItem value="GRACE">Grace Period</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{editForm.deadlinePolicy === 'GRACE' && (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="edit-graceHours">Grace Hours</Label>
|
||||
<Input
|
||||
id="edit-graceHours"
|
||||
type="number"
|
||||
min={0}
|
||||
value={editForm.graceHours}
|
||||
onChange={(e) => setEditForm({ ...editForm, graceHours: parseInt(e.target.value, 10) || 0 })}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Switch
|
||||
id="edit-lockOnClose"
|
||||
checked={editForm.lockOnClose}
|
||||
onCheckedChange={(checked) => setEditForm({ ...editForm, lockOnClose: checked })}
|
||||
/>
|
||||
<Label htmlFor="edit-lockOnClose" className="cursor-pointer">
|
||||
Lock window on close
|
||||
</Label>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="edit-sortOrder">Sort Order</Label>
|
||||
<Input
|
||||
id="edit-sortOrder"
|
||||
type="number"
|
||||
min={1}
|
||||
value={editForm.sortOrder}
|
||||
onChange={(e) => setEditForm({ ...editForm, sortOrder: parseInt(e.target.value, 10) || 1 })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 pt-4">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="flex-1"
|
||||
onClick={() => setEditingWindow(null)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
className="flex-1"
|
||||
onClick={handleEdit}
|
||||
disabled={updateWindowMutation.isPending}
|
||||
>
|
||||
{updateWindowMutation.isPending && (
|
||||
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||
)}
|
||||
Save Changes
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Delete Confirmation Dialog */}
|
||||
<Dialog open={!!deletingWindow} onOpenChange={(open) => !open && setDeletingWindow(null)}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Delete Submission Window</DialogTitle>
|
||||
<DialogDescription>
|
||||
Are you sure you want to delete this submission window? This action cannot be undone.
|
||||
{(windows.find(w => w.id === deletingWindow)?._count?.projectFiles ?? 0) > 0 && (
|
||||
<span className="block mt-2 text-destructive font-medium">
|
||||
Warning: This window has uploaded files and cannot be deleted until they are removed.
|
||||
</span>
|
||||
)}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setDeletingWindow(null)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={handleDelete}
|
||||
disabled={deleteWindowMutation.isPending}
|
||||
>
|
||||
{deleteWindowMutation.isPending && (
|
||||
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||
)}
|
||||
Delete
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
140
src/components/charts/chart-theme.ts
Normal file
140
src/components/charts/chart-theme.ts
Normal file
@@ -0,0 +1,140 @@
|
||||
// Brand colors from CLAUDE.md
|
||||
export const BRAND_DARK_BLUE = '#053d57'
|
||||
export const BRAND_RED = '#de0f1e'
|
||||
export const BRAND_TEAL = '#557f8c'
|
||||
export const BRAND_WHITE = '#fefefe'
|
||||
|
||||
// Extended palette derived from brand
|
||||
export const BRAND_COLORS = [
|
||||
'#053d57', // Dark Blue
|
||||
'#de0f1e', // Red
|
||||
'#557f8c', // Teal
|
||||
'#1e7a8a', // Deep Teal
|
||||
'#c4453a', // Coral
|
||||
'#3a6f7f', // Mid Teal
|
||||
'#8b1a24', // Dark Red
|
||||
'#2d8659', // Sea Green
|
||||
'#7c9aa6', // Light Teal
|
||||
'#a83240', // Rose
|
||||
] as const
|
||||
|
||||
// Tremor named colors for chart components
|
||||
// These are the official Tremor palette names that render correctly
|
||||
export const TREMOR_BRAND = 'blue' as const
|
||||
export const TREMOR_ACCENT = 'indigo' as const
|
||||
export const TREMOR_CHART_COLORS = [
|
||||
'blue',
|
||||
'emerald',
|
||||
'amber',
|
||||
'violet',
|
||||
'rose',
|
||||
'indigo',
|
||||
'sky',
|
||||
'fuchsia',
|
||||
'lime',
|
||||
'orange',
|
||||
] as const
|
||||
|
||||
// Donut / status chart colors (mapped to Tremor names)
|
||||
// Covers both global ProjectStatus and round-level ProjectRoundState values
|
||||
export const TREMOR_STATUS_COLORS: Record<string, string> = {
|
||||
// Global project statuses
|
||||
SUBMITTED: 'sky',
|
||||
ELIGIBLE: 'blue',
|
||||
ASSIGNED: 'violet',
|
||||
SEMIFINALIST: 'amber',
|
||||
FINALIST: 'emerald',
|
||||
REJECTED: 'rose',
|
||||
DRAFT: 'gray',
|
||||
WITHDRAWN: 'slate',
|
||||
// Round-level states (ProjectRoundState)
|
||||
PENDING: 'sky',
|
||||
IN_PROGRESS: 'blue',
|
||||
PASSED: 'emerald',
|
||||
COMPLETED: 'indigo',
|
||||
// Evaluation review states
|
||||
FULLY_REVIEWED: 'emerald',
|
||||
PARTIALLY_REVIEWED: 'amber',
|
||||
NOT_REVIEWED: 'rose',
|
||||
}
|
||||
|
||||
// Project status colors — mapped to actual ProjectStatus enum values
|
||||
export const STATUS_COLORS: Record<string, string> = {
|
||||
SUBMITTED: '#557f8c', // Teal
|
||||
ELIGIBLE: '#053d57', // Dark Blue
|
||||
ASSIGNED: '#1e7a8a', // Deep Teal
|
||||
SEMIFINALIST: '#c4453a', // Coral
|
||||
FINALIST: '#2d8659', // Sea Green
|
||||
REJECTED: '#de0f1e', // Red
|
||||
DRAFT: '#9ca3af', // Gray
|
||||
WITHDRAWN: '#6b7280', // Dark Gray
|
||||
// Evaluation review states
|
||||
FULLY_REVIEWED: '#2d8659', // Sea Green
|
||||
PARTIALLY_REVIEWED: '#d97706', // Amber
|
||||
NOT_REVIEWED: '#de0f1e', // Red
|
||||
}
|
||||
|
||||
// Human-readable status labels
|
||||
export const STATUS_LABELS: Record<string, string> = {
|
||||
SUBMITTED: 'Submitted',
|
||||
ELIGIBLE: 'In-Competition',
|
||||
ASSIGNED: 'Special Award',
|
||||
SEMIFINALIST: 'Semi-finalist',
|
||||
FINALIST: 'Finalist',
|
||||
REJECTED: 'Rejected',
|
||||
DRAFT: 'Draft',
|
||||
WITHDRAWN: 'Withdrawn',
|
||||
// Round-level states
|
||||
PENDING: 'Pending',
|
||||
IN_PROGRESS: 'In Progress',
|
||||
PASSED: 'Passed',
|
||||
COMPLETED: 'Completed',
|
||||
// Evaluation review states
|
||||
FULLY_REVIEWED: 'Fully Reviewed',
|
||||
PARTIALLY_REVIEWED: 'Partially Reviewed',
|
||||
NOT_REVIEWED: 'Not Reviewed',
|
||||
}
|
||||
|
||||
/**
|
||||
* Score gradient: Red (low) → Teal (mid) → Dark Blue (high)
|
||||
* for scores on a 1-10 scale
|
||||
*/
|
||||
export function scoreGradient(score: number): string {
|
||||
const t = Math.max(0, Math.min(1, (score - 1) / 9))
|
||||
if (t < 0.5) {
|
||||
// Red → Teal (0 → 0.5)
|
||||
const p = t * 2
|
||||
return lerpColor(BRAND_RED, BRAND_TEAL, p)
|
||||
}
|
||||
// Teal → Dark Blue (0.5 → 1)
|
||||
const p = (t - 0.5) * 2
|
||||
return lerpColor(BRAND_TEAL, BRAND_DARK_BLUE, p)
|
||||
}
|
||||
|
||||
function lerpColor(a: string, b: string, t: number): string {
|
||||
const ar = parseInt(a.slice(1, 3), 16)
|
||||
const ag = parseInt(a.slice(3, 5), 16)
|
||||
const ab = parseInt(a.slice(5, 7), 16)
|
||||
const br = parseInt(b.slice(1, 3), 16)
|
||||
const bg = parseInt(b.slice(3, 5), 16)
|
||||
const bb = parseInt(b.slice(5, 7), 16)
|
||||
const r = Math.round(ar + (br - ar) * t)
|
||||
const g = Math.round(ag + (bg - ag) * t)
|
||||
const bl = Math.round(ab + (bb - ab) * t)
|
||||
return `#${r.toString(16).padStart(2, '0')}${g.toString(16).padStart(2, '0')}${bl.toString(16).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper: get color for a status value from STATUS_COLORS
|
||||
* Falls back to a neutral gray
|
||||
*/
|
||||
export function getStatusColor(status: string): string {
|
||||
return TREMOR_STATUS_COLORS[status] || 'gray'
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper: format a status value for display
|
||||
*/
|
||||
export function formatStatus(status: string): string {
|
||||
return STATUS_LABELS[status] || status.charAt(0) + status.slice(1).toLowerCase().replace(/_/g, ' ')
|
||||
}
|
||||
@@ -1,15 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import {
|
||||
BarChart,
|
||||
Bar,
|
||||
XAxis,
|
||||
YAxis,
|
||||
CartesianGrid,
|
||||
Tooltip,
|
||||
ResponsiveContainer,
|
||||
Cell,
|
||||
} from 'recharts'
|
||||
import { BarChart } from '@tremor/react'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
|
||||
interface CriteriaScoreData {
|
||||
@@ -23,31 +14,24 @@ interface CriteriaScoresProps {
|
||||
data: CriteriaScoreData[]
|
||||
}
|
||||
|
||||
// Color scale from red to green based on score
|
||||
const getScoreColor = (score: number): string => {
|
||||
if (score >= 8) return '#0bd90f' // Excellent - green
|
||||
if (score >= 6) return '#82ca9d' // Good - light green
|
||||
if (score >= 4) return '#ffc658' // Average - yellow
|
||||
if (score >= 2) return '#ff7300' // Poor - orange
|
||||
return '#de0f1e' // Very poor - red
|
||||
}
|
||||
|
||||
export function CriteriaScoresChart({ data }: CriteriaScoresProps) {
|
||||
const formattedData = data.map((d) => ({
|
||||
...d,
|
||||
displayName:
|
||||
d.name.length > 20 ? d.name.substring(0, 20) + '...' : d.name,
|
||||
}))
|
||||
if (!data?.length) return null
|
||||
|
||||
const overallAverage =
|
||||
data.length > 0
|
||||
? data.reduce((sum, d) => sum + d.averageScore, 0) / data.length
|
||||
: 0
|
||||
|
||||
const chartData = data.map((d) => ({
|
||||
criterion:
|
||||
d.name.length > 40 ? d.name.substring(0, 40) + '...' : d.name,
|
||||
'Avg Score': parseFloat(d.averageScore.toFixed(2)),
|
||||
}))
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center justify-between">
|
||||
<CardTitle className="flex flex-col gap-1 sm:flex-row sm:items-center sm:justify-between">
|
||||
<span>Score by Evaluation Criteria</span>
|
||||
<span className="text-sm font-normal text-muted-foreground">
|
||||
Overall Avg: {overallAverage.toFixed(2)}
|
||||
@@ -55,51 +39,17 @@ export function CriteriaScoresChart({ data }: CriteriaScoresProps) {
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="h-[300px]">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<BarChart
|
||||
data={formattedData}
|
||||
margin={{ top: 20, right: 30, bottom: 60, left: 20 }}
|
||||
>
|
||||
<CartesianGrid strokeDasharray="3 3" className="stroke-muted" />
|
||||
<XAxis
|
||||
dataKey="displayName"
|
||||
tick={{ fontSize: 11 }}
|
||||
angle={-45}
|
||||
textAnchor="end"
|
||||
interval={0}
|
||||
height={60}
|
||||
/>
|
||||
<YAxis domain={[0, 10]} />
|
||||
<Tooltip
|
||||
contentStyle={{
|
||||
backgroundColor: 'hsl(var(--card))',
|
||||
border: '1px solid hsl(var(--border))',
|
||||
borderRadius: '6px',
|
||||
}}
|
||||
formatter={(value: number | undefined) => [
|
||||
(value ?? 0).toFixed(2),
|
||||
'Average Score',
|
||||
]}
|
||||
labelFormatter={(_, payload) => {
|
||||
if (payload && payload[0]) {
|
||||
const item = payload[0].payload as CriteriaScoreData
|
||||
return `${item.name} (${item.count} ratings)`
|
||||
}
|
||||
return ''
|
||||
}}
|
||||
/>
|
||||
<Bar dataKey="averageScore" radius={[4, 4, 0, 0]}>
|
||||
{formattedData.map((entry, index) => (
|
||||
<Cell
|
||||
key={`cell-${index}`}
|
||||
fill={getScoreColor(entry.averageScore)}
|
||||
/>
|
||||
))}
|
||||
</Bar>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
<BarChart
|
||||
data={chartData}
|
||||
index="criterion"
|
||||
categories={['Avg Score']}
|
||||
colors={['indigo']}
|
||||
maxValue={10}
|
||||
layout="vertical"
|
||||
yAxisWidth={160}
|
||||
showLegend={false}
|
||||
className="h-[300px]"
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
|
||||
@@ -1,15 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import {
|
||||
BarChart,
|
||||
Bar,
|
||||
XAxis,
|
||||
YAxis,
|
||||
CartesianGrid,
|
||||
Tooltip,
|
||||
ResponsiveContainer,
|
||||
Legend,
|
||||
} from 'recharts'
|
||||
import { BarChart } from '@tremor/react'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
|
||||
interface StageComparison {
|
||||
@@ -26,128 +17,114 @@ interface CrossStageComparisonProps {
|
||||
data: StageComparison[]
|
||||
}
|
||||
|
||||
const STAGE_COLORS = ['#053d57', '#de0f1e', '#557f8c', '#f38a52', '#6ad82f']
|
||||
export function CrossStageComparisonChart({
|
||||
data,
|
||||
}: CrossStageComparisonProps) {
|
||||
if (!data?.length) {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="flex items-center justify-center py-12">
|
||||
<p className="text-muted-foreground">No comparison data available</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
export function CrossStageComparisonChart({ data }: CrossStageComparisonProps) {
|
||||
// Prepare comparison data
|
||||
const comparisonData = data.map((stage, i) => ({
|
||||
name: stage.roundName.length > 20 ? stage.roundName.slice(0, 20) + '...' : stage.roundName,
|
||||
projects: stage.projectCount,
|
||||
evaluations: stage.evaluationCount,
|
||||
completionRate: stage.completionRate,
|
||||
avgScore: stage.averageScore ? parseFloat(stage.averageScore.toFixed(2)) : 0,
|
||||
color: STAGE_COLORS[i % STAGE_COLORS.length],
|
||||
const baseData = data.map((round) => ({
|
||||
name: round.roundName,
|
||||
Projects: round.projectCount,
|
||||
Evaluations: round.evaluationCount,
|
||||
'Completion Rate': round.completionRate,
|
||||
'Avg Score': round.averageScore
|
||||
? parseFloat(round.averageScore.toFixed(2))
|
||||
: 0,
|
||||
}))
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Metrics Comparison */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Stage Metrics Comparison</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="h-[350px]">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Round Metrics Comparison</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-medium">Projects</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-0">
|
||||
<BarChart
|
||||
data={comparisonData}
|
||||
margin={{ top: 20, right: 30, bottom: 60, left: 20 }}
|
||||
>
|
||||
<CartesianGrid strokeDasharray="3 3" className="stroke-muted" />
|
||||
<XAxis
|
||||
dataKey="name"
|
||||
angle={-25}
|
||||
textAnchor="end"
|
||||
height={60}
|
||||
tick={{ fontSize: 12 }}
|
||||
/>
|
||||
<YAxis />
|
||||
<Tooltip
|
||||
contentStyle={{
|
||||
backgroundColor: 'hsl(var(--card))',
|
||||
border: '1px solid hsl(var(--border))',
|
||||
borderRadius: '6px',
|
||||
}}
|
||||
/>
|
||||
<Legend />
|
||||
<Bar dataKey="projects" name="Projects" fill="#053d57" radius={[4, 4, 0, 0]} />
|
||||
<Bar dataKey="evaluations" name="Evaluations" fill="#557f8c" radius={[4, 4, 0, 0]} />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
data={baseData}
|
||||
index="name"
|
||||
categories={['Projects']}
|
||||
colors={['blue']}
|
||||
showLegend={false}
|
||||
yAxisWidth={40}
|
||||
className="h-[200px]"
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Completion & Score Comparison */}
|
||||
<div className="grid gap-6 lg:grid-cols-2">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Completion Rate by Stage</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="h-[300px]">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<BarChart
|
||||
data={comparisonData}
|
||||
margin={{ top: 20, right: 20, bottom: 60, left: 20 }}
|
||||
>
|
||||
<CartesianGrid strokeDasharray="3 3" className="stroke-muted" />
|
||||
<XAxis
|
||||
dataKey="name"
|
||||
angle={-25}
|
||||
textAnchor="end"
|
||||
height={60}
|
||||
tick={{ fontSize: 12 }}
|
||||
/>
|
||||
<YAxis domain={[0, 100]} unit="%" />
|
||||
<Tooltip
|
||||
contentStyle={{
|
||||
backgroundColor: 'hsl(var(--card))',
|
||||
border: '1px solid hsl(var(--border))',
|
||||
borderRadius: '6px',
|
||||
}}
|
||||
/>
|
||||
<Bar dataKey="completionRate" name="Completion %" fill="#6ad82f" radius={[4, 4, 0, 0]} />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-medium">
|
||||
Evaluations
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-0">
|
||||
<BarChart
|
||||
data={baseData}
|
||||
index="name"
|
||||
categories={['Evaluations']}
|
||||
colors={['violet']}
|
||||
showLegend={false}
|
||||
yAxisWidth={40}
|
||||
className="h-[200px]"
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Average Score by Stage</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="h-[300px]">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<BarChart
|
||||
data={comparisonData}
|
||||
margin={{ top: 20, right: 20, bottom: 60, left: 20 }}
|
||||
>
|
||||
<CartesianGrid strokeDasharray="3 3" className="stroke-muted" />
|
||||
<XAxis
|
||||
dataKey="name"
|
||||
angle={-25}
|
||||
textAnchor="end"
|
||||
height={60}
|
||||
tick={{ fontSize: 12 }}
|
||||
/>
|
||||
<YAxis domain={[0, 10]} />
|
||||
<Tooltip
|
||||
contentStyle={{
|
||||
backgroundColor: 'hsl(var(--card))',
|
||||
border: '1px solid hsl(var(--border))',
|
||||
borderRadius: '6px',
|
||||
}}
|
||||
/>
|
||||
<Bar dataKey="avgScore" name="Avg Score" fill="#de0f1e" radius={[4, 4, 0, 0]} />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-medium">
|
||||
Completion Rate
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-0">
|
||||
<BarChart
|
||||
data={baseData}
|
||||
index="name"
|
||||
categories={['Completion Rate']}
|
||||
colors={['emerald']}
|
||||
showLegend={false}
|
||||
maxValue={100}
|
||||
yAxisWidth={40}
|
||||
valueFormatter={(v) => `${v}%`}
|
||||
className="h-[200px]"
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-medium">
|
||||
Average Score
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-0">
|
||||
<BarChart
|
||||
data={baseData}
|
||||
index="name"
|
||||
categories={['Avg Score']}
|
||||
colors={['amber']}
|
||||
showLegend={false}
|
||||
maxValue={10}
|
||||
yAxisWidth={40}
|
||||
className="h-[200px]"
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,18 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import {
|
||||
PieChart,
|
||||
Pie,
|
||||
Cell,
|
||||
Tooltip,
|
||||
ResponsiveContainer,
|
||||
Legend,
|
||||
BarChart,
|
||||
Bar,
|
||||
XAxis,
|
||||
YAxis,
|
||||
CartesianGrid,
|
||||
} from 'recharts'
|
||||
import { BarChart } from '@tremor/react'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
|
||||
@@ -28,12 +16,6 @@ interface DiversityMetricsProps {
|
||||
data: DiversityData
|
||||
}
|
||||
|
||||
const PIE_COLORS = [
|
||||
'#053d57', '#de0f1e', '#557f8c', '#f38a52', '#6ad82f',
|
||||
'#3be31e', '#c9c052', '#e6382f', '#ed6141', '#0bd90f',
|
||||
'#8884d8', '#82ca9d', '#ffc658', '#ff7c7c', '#8dd1e1',
|
||||
]
|
||||
|
||||
/** Convert ISO 3166-1 alpha-2 code to full country name using Intl API */
|
||||
function getCountryName(code: string): string {
|
||||
if (code === 'Others') return 'Others'
|
||||
@@ -54,35 +36,8 @@ function formatLabel(value: string): string {
|
||||
.replace(/\b\w/g, (c) => c.toUpperCase())
|
||||
}
|
||||
|
||||
/** Custom tooltip for the pie chart */
|
||||
function CountryTooltip({ active, payload }: { active?: boolean; payload?: Array<{ payload: { country: string; count: number; percentage: number } }> }) {
|
||||
if (!active || !payload?.length) return null
|
||||
const d = payload[0].payload
|
||||
return (
|
||||
<div className="rounded-md border bg-card px-3 py-2 text-sm shadow-md">
|
||||
<p className="font-medium">{getCountryName(d.country)}</p>
|
||||
<p className="text-muted-foreground">{d.count} projects ({d.percentage.toFixed(1)}%)</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** Custom tooltip for bar charts */
|
||||
function BarTooltip({ active, payload, labelFormatter }: { active?: boolean; payload?: Array<{ value: number }>; label?: string; labelFormatter: (val: string) => string }) {
|
||||
if (!active || !payload?.length) return null
|
||||
const entry = payload[0]
|
||||
const rawPayload = entry as unknown as { payload: Record<string, unknown> }
|
||||
const dataPoint = rawPayload.payload
|
||||
const rawLabel = (dataPoint.category || dataPoint.issue || '') as string
|
||||
return (
|
||||
<div className="rounded-md border bg-card px-3 py-2 text-sm shadow-md">
|
||||
<p className="font-medium">{labelFormatter(rawLabel)}</p>
|
||||
<p className="text-muted-foreground">{entry.value} projects</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function DiversityMetricsChart({ data }: DiversityMetricsProps) {
|
||||
if (data.total === 0) {
|
||||
if (!data || data.total === 0) {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="flex items-center justify-center py-12">
|
||||
@@ -92,125 +47,117 @@ export function DiversityMetricsChart({ data }: DiversityMetricsProps) {
|
||||
)
|
||||
}
|
||||
|
||||
// Top countries for pie chart (max 10, others grouped)
|
||||
const topCountries = data.byCountry.slice(0, 10)
|
||||
const otherCountries = data.byCountry.slice(10)
|
||||
const countryPieData = otherCountries.length > 0
|
||||
? [...topCountries, {
|
||||
country: 'Others',
|
||||
count: otherCountries.reduce((sum, c) => sum + c.count, 0),
|
||||
percentage: otherCountries.reduce((sum, c) => sum + c.percentage, 0),
|
||||
}]
|
||||
: topCountries
|
||||
|
||||
// Pre-format category and ocean issue data for display
|
||||
const formattedCategories = data.byCategory.slice(0, 10).map((c) => ({
|
||||
...c,
|
||||
category: formatLabel(c.category),
|
||||
// Top countries — horizontal bar chart for readability
|
||||
const countryBarData = (data.byCountry || []).slice(0, 15).map((c) => ({
|
||||
country: getCountryName(c.country),
|
||||
Projects: c.count,
|
||||
}))
|
||||
|
||||
const formattedOceanIssues = data.byOceanIssue.slice(0, 15).map((o) => ({
|
||||
...o,
|
||||
const categoryData = (data.byCategory || []).slice(0, 10).map((c) => ({
|
||||
category: formatLabel(c.category),
|
||||
Projects: c.count,
|
||||
}))
|
||||
|
||||
const oceanIssueData = (data.byOceanIssue || []).slice(0, 15).map((o) => ({
|
||||
issue: formatLabel(o.issue),
|
||||
Projects: o.count,
|
||||
}))
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Summary */}
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
{/* Summary stats row */}
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="text-2xl font-bold">{data.total}</div>
|
||||
<p className="text-sm text-muted-foreground">Total Projects</p>
|
||||
<CardContent className="p-4">
|
||||
<p className="text-2xl font-bold tabular-nums">{data.total}</p>
|
||||
<p className="text-xs text-muted-foreground">Total Projects</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="text-2xl font-bold">{data.byCountry.length}</div>
|
||||
<p className="text-sm text-muted-foreground">Countries Represented</p>
|
||||
<CardContent className="p-4">
|
||||
<p className="text-2xl font-bold tabular-nums">{(data.byCountry || []).length}</p>
|
||||
<p className="text-xs text-muted-foreground">Countries</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="text-2xl font-bold">{data.byCategory.length}</div>
|
||||
<p className="text-sm text-muted-foreground">Categories</p>
|
||||
<CardContent className="p-4">
|
||||
<p className="text-2xl font-bold tabular-nums">{(data.byCategory || []).length}</p>
|
||||
<p className="text-xs text-muted-foreground">Categories</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="text-2xl font-bold">{data.byTag.length}</div>
|
||||
<p className="text-sm text-muted-foreground">Unique Tags</p>
|
||||
<CardContent className="p-4">
|
||||
<p className="text-2xl font-bold tabular-nums">{(data.byOceanIssue || []).length}</p>
|
||||
<p className="text-xs text-muted-foreground">Ocean Issues</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 lg:grid-cols-2">
|
||||
{/* Country Distribution */}
|
||||
{/* Country Distribution — horizontal bars */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Geographic Distribution</CardTitle>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-base">Geographic Distribution</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="h-[400px]">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<PieChart>
|
||||
<Pie
|
||||
data={countryPieData}
|
||||
cx="50%"
|
||||
cy="50%"
|
||||
innerRadius={60}
|
||||
outerRadius={120}
|
||||
paddingAngle={2}
|
||||
dataKey="count"
|
||||
nameKey="country"
|
||||
label={((props: unknown) => {
|
||||
const p = props as { country: string; percentage: number }
|
||||
return `${getCountryName(p.country)} (${p.percentage.toFixed(0)}%)`
|
||||
}) as unknown as boolean}
|
||||
fontSize={13}
|
||||
>
|
||||
{countryPieData.map((_, index) => (
|
||||
<Cell key={`cell-${index}`} fill={PIE_COLORS[index % PIE_COLORS.length]} />
|
||||
))}
|
||||
</Pie>
|
||||
<Tooltip content={<CountryTooltip />} />
|
||||
<Legend
|
||||
formatter={(value: string) => getCountryName(value)}
|
||||
wrapperStyle={{ fontSize: '13px' }}
|
||||
/>
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
{countryBarData.length > 0 ? (
|
||||
<BarChart
|
||||
data={countryBarData}
|
||||
index="country"
|
||||
categories={['Projects']}
|
||||
colors={['cyan']}
|
||||
showLegend={false}
|
||||
layout="horizontal"
|
||||
yAxisWidth={120}
|
||||
className="h-[360px]"
|
||||
/>
|
||||
) : (
|
||||
<p className="text-muted-foreground text-center py-8">No geographic data</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Category Distribution */}
|
||||
{/* Competition Categories — horizontal bars */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Competition Categories</CardTitle>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-base">Competition Categories</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{formattedCategories.length > 0 ? (
|
||||
<div className="h-[400px]">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<BarChart
|
||||
data={formattedCategories}
|
||||
layout="vertical"
|
||||
margin={{ top: 5, right: 30, bottom: 5, left: 120 }}
|
||||
>
|
||||
<CartesianGrid strokeDasharray="3 3" className="stroke-muted" />
|
||||
<XAxis type="number" tick={{ fontSize: 13 }} />
|
||||
<YAxis
|
||||
type="category"
|
||||
dataKey="category"
|
||||
width={110}
|
||||
tick={{ fontSize: 13 }}
|
||||
/>
|
||||
<Tooltip content={<BarTooltip labelFormatter={(v) => v} />} />
|
||||
<Bar dataKey="count" fill="#053d57" radius={[0, 4, 4, 0]} />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
{categoryData.length > 0 ? (
|
||||
categoryData.length <= 4 ? (
|
||||
/* Clean stacked bars for few categories */
|
||||
<div className="space-y-4 pt-2">
|
||||
{categoryData.map((c) => {
|
||||
const maxCount = Math.max(...categoryData.map((d) => d.Projects))
|
||||
const pct = maxCount > 0 ? (c.Projects / maxCount) * 100 : 0
|
||||
return (
|
||||
<div key={c.category} className="space-y-1.5">
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="font-medium">{c.category}</span>
|
||||
<span className="tabular-nums text-muted-foreground">{c.Projects}</span>
|
||||
</div>
|
||||
<div className="h-3 w-full rounded-full bg-muted overflow-hidden">
|
||||
<div
|
||||
className="h-full rounded-full bg-[#053d57] transition-all duration-500"
|
||||
style={{ width: `${pct}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<BarChart
|
||||
data={categoryData}
|
||||
index="category"
|
||||
categories={['Projects']}
|
||||
colors={['indigo']}
|
||||
layout="horizontal"
|
||||
yAxisWidth={140}
|
||||
showLegend={false}
|
||||
className="h-[280px]"
|
||||
/>
|
||||
)
|
||||
) : (
|
||||
<p className="text-muted-foreground text-center py-8">No category data</p>
|
||||
)}
|
||||
@@ -218,56 +165,43 @@ export function DiversityMetricsChart({ data }: DiversityMetricsProps) {
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Ocean Issues */}
|
||||
{formattedOceanIssues.length > 0 && (
|
||||
{/* Ocean Issues — horizontal bars for readability */}
|
||||
{oceanIssueData.length > 0 && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Ocean Issues Addressed</CardTitle>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-base">Ocean Issues Addressed</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="h-[400px]">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<BarChart
|
||||
data={formattedOceanIssues}
|
||||
margin={{ top: 20, right: 30, bottom: 80, left: 20 }}
|
||||
>
|
||||
<CartesianGrid strokeDasharray="3 3" className="stroke-muted" />
|
||||
<XAxis
|
||||
dataKey="issue"
|
||||
angle={-35}
|
||||
textAnchor="end"
|
||||
height={100}
|
||||
tick={{ fontSize: 12 }}
|
||||
interval={0}
|
||||
/>
|
||||
<YAxis tick={{ fontSize: 13 }} />
|
||||
<Tooltip content={<BarTooltip labelFormatter={(v) => v} />} />
|
||||
<Bar dataKey="count" fill="#557f8c" radius={[4, 4, 0, 0]} />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
<BarChart
|
||||
data={oceanIssueData}
|
||||
index="issue"
|
||||
categories={['Projects']}
|
||||
colors={['blue']}
|
||||
showLegend={false}
|
||||
layout="horizontal"
|
||||
yAxisWidth={200}
|
||||
className="h-[400px]"
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Tags Cloud */}
|
||||
{data.byTag.length > 0 && (
|
||||
{/* Tags — clean pill cloud */}
|
||||
{(data.byTag || []).length > 0 && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Project Tags</CardTitle>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-base">Project Tags</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{data.byTag.slice(0, 30).map((tag) => (
|
||||
{(data.byTag || []).slice(0, 30).map((tag) => (
|
||||
<Badge
|
||||
key={tag.tag}
|
||||
variant="secondary"
|
||||
className="text-sm"
|
||||
style={{
|
||||
fontSize: `${Math.max(0.75, Math.min(1.4, 0.75 + tag.percentage / 20))}rem`,
|
||||
}}
|
||||
variant="outline"
|
||||
className="px-3 py-1 text-sm font-normal"
|
||||
>
|
||||
{tag.tag} ({tag.count})
|
||||
{tag.tag}
|
||||
<span className="ml-1.5 text-muted-foreground tabular-nums">({tag.count})</span>
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -1,18 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import {
|
||||
LineChart,
|
||||
Line,
|
||||
XAxis,
|
||||
YAxis,
|
||||
CartesianGrid,
|
||||
Tooltip,
|
||||
Legend,
|
||||
ResponsiveContainer,
|
||||
Area,
|
||||
ComposedChart,
|
||||
Bar,
|
||||
} from 'recharts'
|
||||
import { AreaChart } from '@tremor/react'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
|
||||
interface TimelineDataPoint {
|
||||
@@ -26,18 +14,20 @@ interface EvaluationTimelineProps {
|
||||
}
|
||||
|
||||
export function EvaluationTimelineChart({ data }: EvaluationTimelineProps) {
|
||||
// Format date for display
|
||||
const formattedData = data.map((d) => ({
|
||||
...d,
|
||||
dateFormatted: new Date(d.date).toLocaleDateString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
}),
|
||||
}))
|
||||
if (!data?.length) return null
|
||||
|
||||
const totalEvaluations =
|
||||
data.length > 0 ? data[data.length - 1].cumulative : 0
|
||||
|
||||
const chartData = data.map((d) => ({
|
||||
date: new Date(d.date).toLocaleDateString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
}),
|
||||
Cumulative: d.cumulative,
|
||||
Daily: d.daily,
|
||||
}))
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
@@ -49,53 +39,16 @@ export function EvaluationTimelineChart({ data }: EvaluationTimelineProps) {
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="h-[300px]">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<ComposedChart
|
||||
data={formattedData}
|
||||
margin={{ top: 20, right: 30, bottom: 20, left: 20 }}
|
||||
>
|
||||
<CartesianGrid strokeDasharray="3 3" className="stroke-muted" />
|
||||
<XAxis
|
||||
dataKey="dateFormatted"
|
||||
tick={{ fontSize: 12 }}
|
||||
interval="preserveStartEnd"
|
||||
/>
|
||||
<YAxis yAxisId="left" orientation="left" stroke="#8884d8" />
|
||||
<YAxis yAxisId="right" orientation="right" stroke="#82ca9d" />
|
||||
<Tooltip
|
||||
contentStyle={{
|
||||
backgroundColor: 'hsl(var(--card))',
|
||||
border: '1px solid hsl(var(--border))',
|
||||
borderRadius: '6px',
|
||||
}}
|
||||
formatter={(value: number | undefined, name: string | undefined) => [
|
||||
value ?? 0,
|
||||
(name ?? '') === 'daily' ? 'Daily' : 'Cumulative',
|
||||
]}
|
||||
labelFormatter={(label) => `Date: ${label}`}
|
||||
/>
|
||||
<Legend />
|
||||
<Bar
|
||||
yAxisId="left"
|
||||
dataKey="daily"
|
||||
name="Daily Evaluations"
|
||||
fill="#8884d8"
|
||||
radius={[4, 4, 0, 0]}
|
||||
/>
|
||||
<Line
|
||||
yAxisId="right"
|
||||
type="monotone"
|
||||
dataKey="cumulative"
|
||||
name="Cumulative Total"
|
||||
stroke="#82ca9d"
|
||||
strokeWidth={2}
|
||||
dot={{ r: 3 }}
|
||||
activeDot={{ r: 6 }}
|
||||
/>
|
||||
</ComposedChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
<AreaChart
|
||||
data={chartData}
|
||||
index="date"
|
||||
categories={['Cumulative', 'Daily']}
|
||||
colors={['indigo', 'amber']}
|
||||
curveType="monotone"
|
||||
showGradient={true}
|
||||
yAxisWidth={50}
|
||||
className="h-[300px]"
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
|
||||
@@ -10,3 +10,4 @@ export { GeographicSummaryCard } from './geographic-summary-card'
|
||||
export { CrossStageComparisonChart } from './cross-round-comparison'
|
||||
export { JurorConsistencyChart } from './juror-consistency'
|
||||
export { DiversityMetricsChart } from './diversity-metrics'
|
||||
export { JurorScoreHeatmap } from './juror-score-heatmap'
|
||||
|
||||
@@ -1,15 +1,5 @@
|
||||
'use client'
|
||||
|
||||
import {
|
||||
ScatterChart,
|
||||
Scatter,
|
||||
XAxis,
|
||||
YAxis,
|
||||
CartesianGrid,
|
||||
Tooltip,
|
||||
ResponsiveContainer,
|
||||
ReferenceLine,
|
||||
} from 'recharts'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import {
|
||||
@@ -21,11 +11,11 @@ import {
|
||||
TableRow,
|
||||
} from '@/components/ui/table'
|
||||
import { AlertTriangle } from 'lucide-react'
|
||||
import { scoreGradient } from './chart-theme'
|
||||
|
||||
interface JurorMetric {
|
||||
userId: string
|
||||
name: string
|
||||
email: string
|
||||
evaluationCount: number
|
||||
averageScore: number
|
||||
stddev: number
|
||||
@@ -40,28 +30,49 @@ interface JurorConsistencyProps {
|
||||
}
|
||||
}
|
||||
|
||||
function ScoreDot({ score, maxScore = 10 }: { score: number; maxScore?: number }) {
|
||||
const pct = ((score / maxScore) * 100).toFixed(1)
|
||||
return (
|
||||
<div className="flex items-center gap-2 w-full min-w-[120px]">
|
||||
<div className="flex-1 h-2.5 rounded-full bg-muted overflow-hidden">
|
||||
<div
|
||||
className="h-full rounded-full transition-all duration-300"
|
||||
style={{
|
||||
width: `${pct}%`,
|
||||
backgroundColor: scoreGradient(score),
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-xs tabular-nums font-medium w-8 text-right">{score.toFixed(1)}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function JurorConsistencyChart({ data }: JurorConsistencyProps) {
|
||||
const scatterData = data.jurors.map((j) => ({
|
||||
name: j.name,
|
||||
avgScore: parseFloat(j.averageScore.toFixed(2)),
|
||||
stddev: parseFloat(j.stddev.toFixed(2)),
|
||||
evaluations: j.evaluationCount,
|
||||
isOutlier: j.isOutlier,
|
||||
}))
|
||||
if (!data?.jurors?.length) {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="flex items-center justify-center py-12">
|
||||
<p className="text-muted-foreground">No juror consistency data available</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
const outlierCount = data.jurors.filter((j) => j.isOutlier).length
|
||||
const sorted = [...data.jurors].sort((a, b) => b.averageScore - a.averageScore)
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Scatter: Average Score vs Standard Deviation */}
|
||||
{/* Juror Scoring Patterns — bar-based visual instead of scatter */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center justify-between">
|
||||
<span>Juror Scoring Patterns</span>
|
||||
<span className="text-sm font-normal text-muted-foreground">
|
||||
<CardTitle className="flex items-center justify-between flex-wrap gap-2">
|
||||
<span className="text-base">Juror Scoring Patterns</span>
|
||||
<span className="text-sm font-normal text-muted-foreground flex items-center gap-2">
|
||||
Overall Avg: {data.overallAverage.toFixed(2)}
|
||||
{outlierCount > 0 && (
|
||||
<Badge variant="destructive" className="ml-2">
|
||||
<Badge variant="destructive">
|
||||
{outlierCount} outlier{outlierCount > 1 ? 's' : ''}
|
||||
</Badge>
|
||||
)}
|
||||
@@ -69,51 +80,31 @@ export function JurorConsistencyChart({ data }: JurorConsistencyProps) {
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="h-[400px]">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<ScatterChart margin={{ top: 20, right: 20, bottom: 20, left: 20 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" className="stroke-muted" />
|
||||
<XAxis
|
||||
type="number"
|
||||
dataKey="avgScore"
|
||||
name="Average Score"
|
||||
domain={[0, 10]}
|
||||
label={{ value: 'Average Score', position: 'insideBottom', offset: -10 }}
|
||||
/>
|
||||
<YAxis
|
||||
type="number"
|
||||
dataKey="stddev"
|
||||
name="Std Deviation"
|
||||
label={{ value: 'Std Deviation', angle: -90, position: 'insideLeft' }}
|
||||
/>
|
||||
<Tooltip
|
||||
contentStyle={{
|
||||
backgroundColor: 'hsl(var(--card))',
|
||||
border: '1px solid hsl(var(--border))',
|
||||
borderRadius: '6px',
|
||||
}}
|
||||
/>
|
||||
<ReferenceLine
|
||||
x={data.overallAverage}
|
||||
stroke="#de0f1e"
|
||||
strokeDasharray="3 3"
|
||||
label={{ value: 'Avg', fill: '#de0f1e', position: 'top' }}
|
||||
/>
|
||||
<Scatter data={scatterData} fill="#053d57">
|
||||
{scatterData.map((entry, index) => (
|
||||
<circle
|
||||
key={index}
|
||||
r={Math.max(4, entry.evaluations)}
|
||||
fill={entry.isOutlier ? '#de0f1e' : '#053d57'}
|
||||
fillOpacity={0.7}
|
||||
/>
|
||||
))}
|
||||
</Scatter>
|
||||
</ScatterChart>
|
||||
</ResponsiveContainer>
|
||||
<div className="space-y-2">
|
||||
{sorted.map((juror) => (
|
||||
<div
|
||||
key={juror.userId}
|
||||
className={`flex items-center gap-3 rounded-md px-3 py-2 ${juror.isOutlier ? 'bg-destructive/5 border border-destructive/20' : 'hover:bg-muted/50'}`}
|
||||
>
|
||||
<div className="w-36 shrink-0 truncate">
|
||||
<span className="text-sm font-medium">{juror.name}</span>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<ScoreDot score={juror.averageScore} />
|
||||
</div>
|
||||
<div className="hidden sm:flex items-center gap-3 text-xs text-muted-foreground shrink-0">
|
||||
<span className="tabular-nums">σ {juror.stddev.toFixed(1)}</span>
|
||||
<span className="tabular-nums">{juror.evaluationCount} eval{juror.evaluationCount !== 1 ? 's' : ''}</span>
|
||||
</div>
|
||||
{juror.isOutlier && (
|
||||
<AlertTriangle className="h-3.5 w-3.5 text-destructive shrink-0" />
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-2 text-center">
|
||||
Dot size represents number of evaluations. Red dots indicate outlier jurors (2+ points from mean).
|
||||
{/* Overall average line */}
|
||||
<p className="text-xs text-muted-foreground mt-4 text-center">
|
||||
Bars show average score per juror. σ = standard deviation. Outliers deviate 2+ points from the overall mean.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -121,49 +112,92 @@ export function JurorConsistencyChart({ data }: JurorConsistencyProps) {
|
||||
{/* Juror details table */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Juror Consistency Details</CardTitle>
|
||||
<CardTitle className="text-base">Juror Consistency Details</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Juror</TableHead>
|
||||
<TableHead className="text-right">Evaluations</TableHead>
|
||||
<TableHead className="text-right">Avg Score</TableHead>
|
||||
<TableHead className="text-right">Std Dev</TableHead>
|
||||
<TableHead className="text-right">Deviation from Mean</TableHead>
|
||||
<TableHead className="text-center">Status</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{data.jurors.map((juror) => (
|
||||
<TableRow key={juror.userId} className={juror.isOutlier ? 'bg-destructive/5' : ''}>
|
||||
<TableCell>
|
||||
<div>
|
||||
<p className="font-medium">{juror.name}</p>
|
||||
<p className="text-xs text-muted-foreground">{juror.email}</p>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-right tabular-nums">{juror.evaluationCount}</TableCell>
|
||||
<TableCell className="text-right tabular-nums">{juror.averageScore.toFixed(2)}</TableCell>
|
||||
<TableCell className="text-right tabular-nums">{juror.stddev.toFixed(2)}</TableCell>
|
||||
<TableCell className="text-right tabular-nums">
|
||||
{juror.deviationFromOverall.toFixed(2)}
|
||||
</TableCell>
|
||||
<TableCell className="text-center">
|
||||
{juror.isOutlier ? (
|
||||
<Badge variant="destructive" className="gap-1">
|
||||
<AlertTriangle className="h-3 w-3" />
|
||||
Outlier
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge variant="secondary">Normal</Badge>
|
||||
)}
|
||||
</TableCell>
|
||||
{/* Desktop table */}
|
||||
<div className="hidden md:block">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Juror</TableHead>
|
||||
<TableHead className="text-right">Evaluations</TableHead>
|
||||
<TableHead className="text-right">Avg Score</TableHead>
|
||||
<TableHead className="text-right">Std Dev</TableHead>
|
||||
<TableHead className="text-right">Deviation</TableHead>
|
||||
<TableHead className="text-center">Status</TableHead>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{sorted.map((juror) => (
|
||||
<TableRow
|
||||
key={juror.userId}
|
||||
className={juror.isOutlier ? 'bg-destructive/5' : ''}
|
||||
>
|
||||
<TableCell className="font-medium">{juror.name}</TableCell>
|
||||
<TableCell className="text-right tabular-nums">
|
||||
{juror.evaluationCount}
|
||||
</TableCell>
|
||||
<TableCell className="text-right tabular-nums">
|
||||
{juror.averageScore.toFixed(2)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right tabular-nums">
|
||||
{juror.stddev.toFixed(2)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right tabular-nums">
|
||||
{juror.deviationFromOverall >= 0 ? '+' : ''}{juror.deviationFromOverall.toFixed(2)}
|
||||
</TableCell>
|
||||
<TableCell className="text-center">
|
||||
{juror.isOutlier ? (
|
||||
<Badge variant="destructive" className="gap-1">
|
||||
<AlertTriangle className="h-3 w-3" />
|
||||
Outlier
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge variant="secondary">Normal</Badge>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
{/* Mobile card stack */}
|
||||
<div className="space-y-2 md:hidden">
|
||||
{sorted.map((juror) => (
|
||||
<div
|
||||
key={juror.userId}
|
||||
className={`rounded-md border p-3 space-y-1 ${juror.isOutlier ? 'bg-destructive/5 border-destructive/20' : ''}`}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-medium">{juror.name}</span>
|
||||
{juror.isOutlier ? (
|
||||
<Badge variant="destructive" className="gap-1 text-[10px]">
|
||||
<AlertTriangle className="h-3 w-3" />
|
||||
Outlier
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge variant="secondary" className="text-[10px]">Normal</Badge>
|
||||
)}
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-2 text-xs">
|
||||
<div>
|
||||
<p className="text-muted-foreground">Avg Score</p>
|
||||
<p className="font-medium tabular-nums">{juror.averageScore.toFixed(2)}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-muted-foreground">Std Dev</p>
|
||||
<p className="font-medium tabular-nums">{juror.stddev.toFixed(2)}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-muted-foreground">Evals</p>
|
||||
<p className="font-medium tabular-nums">{juror.evaluationCount}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
240
src/components/charts/juror-score-heatmap.tsx
Normal file
240
src/components/charts/juror-score-heatmap.tsx
Normal file
@@ -0,0 +1,240 @@
|
||||
'use client'
|
||||
|
||||
import { Fragment, useState } from 'react'
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { scoreGradient } from './chart-theme'
|
||||
|
||||
interface JurorScoreHeatmapProps {
|
||||
jurors: { id: string; name: string }[]
|
||||
projects: { id: string; title: string }[]
|
||||
cells: { jurorId: string; projectId: string; score: number | null }[]
|
||||
truncated?: boolean
|
||||
totalProjects?: number
|
||||
}
|
||||
|
||||
function getScoreColor(score: number | null): string {
|
||||
if (score === null) return 'transparent'
|
||||
return scoreGradient(score)
|
||||
}
|
||||
|
||||
function getTextColor(score: number | null): string {
|
||||
if (score === null) return 'inherit'
|
||||
return score >= 6 ? '#ffffff' : '#1a1a1a'
|
||||
}
|
||||
|
||||
function ScoreBadge({ score }: { score: number }) {
|
||||
return (
|
||||
<span
|
||||
className="inline-flex items-center justify-center rounded-md px-2 py-0.5 text-xs font-semibold tabular-nums min-w-[36px]"
|
||||
style={{
|
||||
backgroundColor: getScoreColor(score),
|
||||
color: getTextColor(score),
|
||||
}}
|
||||
>
|
||||
{score.toFixed(1)}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
function JurorSummaryRow({
|
||||
juror,
|
||||
scores,
|
||||
averageScore,
|
||||
projectCount,
|
||||
isExpanded,
|
||||
onToggle,
|
||||
projects,
|
||||
}: {
|
||||
juror: { id: string; name: string }
|
||||
scores: { projectId: string; score: number | null }[]
|
||||
averageScore: number | null
|
||||
projectCount: number
|
||||
isExpanded: boolean
|
||||
onToggle: () => void
|
||||
projects: { id: string; title: string }[]
|
||||
}) {
|
||||
const scored = scores.filter((s) => s.score !== null)
|
||||
const unscored = projectCount - scored.length
|
||||
|
||||
return (
|
||||
<>
|
||||
<tr
|
||||
className="border-b cursor-pointer transition-colors hover:bg-muted/50"
|
||||
onClick={onToggle}
|
||||
>
|
||||
<td className="py-3 px-4 font-medium text-sm whitespace-nowrap">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`inline-flex h-5 w-5 items-center justify-center rounded text-[10px] font-bold transition-transform ${isExpanded ? 'rotate-90' : ''}`}>
|
||||
›
|
||||
</span>
|
||||
{juror.name}
|
||||
</div>
|
||||
</td>
|
||||
<td className="py-3 px-4 text-center tabular-nums text-sm">
|
||||
{scored.length}
|
||||
<span className="text-muted-foreground">/{projectCount}</span>
|
||||
</td>
|
||||
<td className="py-3 px-4 text-center">
|
||||
{averageScore !== null ? (
|
||||
<ScoreBadge score={averageScore} />
|
||||
) : (
|
||||
<span className="text-xs text-muted-foreground">—</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="py-3 px-4">
|
||||
{/* Mini score bar */}
|
||||
<div className="flex items-center gap-0.5">
|
||||
{scored
|
||||
.sort((a, b) => (a.score ?? 0) - (b.score ?? 0))
|
||||
.map((s, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="h-4 w-1.5 rounded-full"
|
||||
style={{ backgroundColor: getScoreColor(s.score) }}
|
||||
title={`${s.score?.toFixed(1)}`}
|
||||
/>
|
||||
))}
|
||||
{unscored > 0 &&
|
||||
Array.from({ length: Math.min(unscored, 10) }).map((_, i) => (
|
||||
<div
|
||||
key={`empty-${i}`}
|
||||
className="h-4 w-1.5 rounded-full bg-muted"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{isExpanded && (
|
||||
<tr className="border-b bg-muted/30">
|
||||
<td colSpan={4} className="p-4">
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-2">
|
||||
{projects.map((p) => {
|
||||
const cell = scores.find((s) => s.projectId === p.id)
|
||||
const score = cell?.score ?? null
|
||||
return (
|
||||
<div
|
||||
key={p.id}
|
||||
className="flex items-center gap-2 rounded-md border bg-background px-2.5 py-1.5"
|
||||
>
|
||||
{score !== null ? (
|
||||
<ScoreBadge score={score} />
|
||||
) : (
|
||||
<span className="inline-flex items-center justify-center rounded-md bg-muted px-2 py-0.5 text-xs text-muted-foreground min-w-[36px]">
|
||||
—
|
||||
</span>
|
||||
)}
|
||||
<span className="text-xs truncate" title={p.title}>
|
||||
{p.title}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export function JurorScoreHeatmap({
|
||||
jurors,
|
||||
projects,
|
||||
cells,
|
||||
truncated,
|
||||
totalProjects,
|
||||
}: JurorScoreHeatmapProps) {
|
||||
const [expandedId, setExpandedId] = useState<string | null>(null)
|
||||
|
||||
const cellMap = new Map<string, number | null>()
|
||||
for (const c of cells) {
|
||||
cellMap.set(`${c.jurorId}:${c.projectId}`, c.score)
|
||||
}
|
||||
|
||||
if (jurors.length === 0 || projects.length === 0) {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="flex items-center justify-center py-12">
|
||||
<p className="text-sm text-muted-foreground">No score data available for heatmap</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
// Compute per-juror data
|
||||
const jurorData = jurors.map((j) => {
|
||||
const scores = projects.map((p) => ({
|
||||
projectId: p.id,
|
||||
score: cellMap.get(`${j.id}:${p.id}`) ?? null,
|
||||
}))
|
||||
const scored = scores.filter((s) => s.score !== null)
|
||||
const avg = scored.length > 0
|
||||
? scored.reduce((sum, s) => sum + (s.score ?? 0), 0) / scored.length
|
||||
: null
|
||||
return { juror: j, scores, averageScore: avg ? parseFloat(avg.toFixed(1)) : null, scoredCount: scored.length }
|
||||
})
|
||||
|
||||
// Sort: jurors with most evaluations first
|
||||
jurorData.sort((a, b) => b.scoredCount - a.scoredCount)
|
||||
|
||||
// Color legend
|
||||
const legendScores = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<CardTitle className="text-base">Score Heatmap</CardTitle>
|
||||
<CardDescription>
|
||||
{jurors.length} juror{jurors.length !== 1 ? 's' : ''} · {projects.length} project{projects.length !== 1 ? 's' : ''}
|
||||
{truncated && totalProjects ? ` (top ${projects.length} of ${totalProjects})` : ''}
|
||||
</CardDescription>
|
||||
</div>
|
||||
{/* Color legend */}
|
||||
<div className="hidden sm:flex items-center gap-1 shrink-0">
|
||||
<span className="text-[10px] text-muted-foreground mr-1">Low</span>
|
||||
{legendScores.map((s) => (
|
||||
<div
|
||||
key={s}
|
||||
className="h-4 w-4 rounded-sm"
|
||||
style={{ backgroundColor: getScoreColor(s) }}
|
||||
title={s.toString()}
|
||||
/>
|
||||
))}
|
||||
<span className="text-[10px] text-muted-foreground ml-1">High</span>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="border-b text-xs text-muted-foreground">
|
||||
<th className="text-left py-2 px-4 font-medium">Juror</th>
|
||||
<th className="text-center py-2 px-4 font-medium whitespace-nowrap">Reviewed</th>
|
||||
<th className="text-center py-2 px-4 font-medium">Avg</th>
|
||||
<th className="text-left py-2 px-4 font-medium">Score Distribution</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{jurorData.map(({ juror, scores, averageScore }) => (
|
||||
<JurorSummaryRow
|
||||
key={juror.id}
|
||||
juror={juror}
|
||||
scores={scores}
|
||||
averageScore={averageScore}
|
||||
projectCount={projects.length}
|
||||
isExpanded={expandedId === juror.id}
|
||||
onToggle={() => setExpandedId(expandedId === juror.id ? null : juror.id)}
|
||||
projects={projects}
|
||||
/>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -1,15 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import {
|
||||
BarChart,
|
||||
Bar,
|
||||
XAxis,
|
||||
YAxis,
|
||||
CartesianGrid,
|
||||
Tooltip,
|
||||
Legend,
|
||||
ResponsiveContainer,
|
||||
} from 'recharts'
|
||||
import { BarChart } from '@tremor/react'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
|
||||
interface JurorWorkloadData {
|
||||
@@ -25,17 +16,23 @@ interface JurorWorkloadProps {
|
||||
}
|
||||
|
||||
export function JurorWorkloadChart({ data }: JurorWorkloadProps) {
|
||||
// Truncate names for display
|
||||
const formattedData = data.map((d) => ({
|
||||
...d,
|
||||
displayName: d.name.length > 15 ? d.name.substring(0, 15) + '...' : d.name,
|
||||
}))
|
||||
if (!data?.length) return null
|
||||
|
||||
const totalAssigned = data.reduce((sum, d) => sum + d.assigned, 0)
|
||||
const totalCompleted = data.reduce((sum, d) => sum + d.completed, 0)
|
||||
const overallRate =
|
||||
totalAssigned > 0 ? Math.round((totalCompleted / totalAssigned) * 100) : 0
|
||||
|
||||
const sortedData = [...data].sort(
|
||||
(a, b) => b.completionRate - a.completionRate,
|
||||
)
|
||||
|
||||
const chartData = sortedData.map((d) => ({
|
||||
juror: d.name.length > 25 ? d.name.substring(0, 25) + '...' : d.name,
|
||||
Completed: d.completed,
|
||||
Remaining: d.assigned - d.completed,
|
||||
}))
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
@@ -47,55 +44,17 @@ export function JurorWorkloadChart({ data }: JurorWorkloadProps) {
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="h-[400px]">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<BarChart
|
||||
data={formattedData}
|
||||
layout="vertical"
|
||||
margin={{ top: 20, right: 30, bottom: 20, left: 100 }}
|
||||
>
|
||||
<CartesianGrid strokeDasharray="3 3" className="stroke-muted" />
|
||||
<XAxis type="number" />
|
||||
<YAxis
|
||||
dataKey="displayName"
|
||||
type="category"
|
||||
width={90}
|
||||
tick={{ fontSize: 12 }}
|
||||
/>
|
||||
<Tooltip
|
||||
contentStyle={{
|
||||
backgroundColor: 'hsl(var(--card))',
|
||||
border: '1px solid hsl(var(--border))',
|
||||
borderRadius: '6px',
|
||||
}}
|
||||
formatter={(value: number | undefined, name: string | undefined) => [
|
||||
value ?? 0,
|
||||
(name ?? '') === 'assigned' ? 'Assigned' : 'Completed',
|
||||
]}
|
||||
labelFormatter={(_, payload) => {
|
||||
if (payload && payload[0]) {
|
||||
const item = payload[0].payload as JurorWorkloadData
|
||||
return `${item.name} (${item.completionRate}% complete)`
|
||||
}
|
||||
return ''
|
||||
}}
|
||||
/>
|
||||
<Legend />
|
||||
<Bar
|
||||
dataKey="assigned"
|
||||
name="Assigned"
|
||||
fill="#8884d8"
|
||||
radius={[0, 4, 4, 0]}
|
||||
/>
|
||||
<Bar
|
||||
dataKey="completed"
|
||||
name="Completed"
|
||||
fill="#82ca9d"
|
||||
radius={[0, 4, 4, 0]}
|
||||
/>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
<BarChart
|
||||
data={chartData}
|
||||
index="juror"
|
||||
categories={['Completed', 'Remaining']}
|
||||
colors={['blue', 'slate']}
|
||||
layout="horizontal"
|
||||
stack={true}
|
||||
yAxisWidth={160}
|
||||
className={`h-[${Math.max(300, data.length * 35)}px]`}
|
||||
style={{ height: `${Math.max(300, data.length * 35)}px` }}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
|
||||
@@ -1,16 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import {
|
||||
BarChart,
|
||||
Bar,
|
||||
XAxis,
|
||||
YAxis,
|
||||
CartesianGrid,
|
||||
Tooltip,
|
||||
ResponsiveContainer,
|
||||
Cell,
|
||||
ReferenceLine,
|
||||
} from 'recharts'
|
||||
import { BarChart } from '@tremor/react'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
|
||||
interface ProjectRankingData {
|
||||
@@ -27,31 +17,24 @@ interface ProjectRankingsProps {
|
||||
limit?: number
|
||||
}
|
||||
|
||||
// Generate color based on score (red to green gradient)
|
||||
const getScoreColor = (score: number): string => {
|
||||
if (score >= 8) return '#0bd90f' // Excellent - green
|
||||
if (score >= 6) return '#82ca9d' // Good - light green
|
||||
if (score >= 4) return '#ffc658' // Average - yellow
|
||||
if (score >= 2) return '#ff7300' // Poor - orange
|
||||
return '#de0f1e' // Very poor - red
|
||||
}
|
||||
|
||||
export function ProjectRankingsChart({
|
||||
data,
|
||||
limit = 20,
|
||||
}: ProjectRankingsProps) {
|
||||
const displayData = data.slice(0, limit).map((d, index) => ({
|
||||
...d,
|
||||
rank: index + 1,
|
||||
displayTitle:
|
||||
d.title.length > 25 ? d.title.substring(0, 25) + '...' : d.title,
|
||||
score: d.averageScore || 0,
|
||||
}))
|
||||
const scoredData = (data ?? []).filter(
|
||||
(d): d is ProjectRankingData & { averageScore: number } =>
|
||||
d.averageScore !== null,
|
||||
)
|
||||
|
||||
const averageScore =
|
||||
data.length > 0
|
||||
? data.reduce((sum, d) => sum + (d.averageScore || 0), 0) / data.length
|
||||
: 0
|
||||
if (!scoredData.length) return null
|
||||
|
||||
const displayData = scoredData.slice(0, limit)
|
||||
|
||||
const chartData = displayData.map((d) => ({
|
||||
project:
|
||||
d.title.length > 30 ? d.title.substring(0, 30) + '...' : d.title,
|
||||
Score: parseFloat(d.averageScore.toFixed(2)),
|
||||
}))
|
||||
|
||||
return (
|
||||
<Card>
|
||||
@@ -59,62 +42,23 @@ export function ProjectRankingsChart({
|
||||
<CardTitle className="flex items-center justify-between">
|
||||
<span>Project Rankings</span>
|
||||
<span className="text-sm font-normal text-muted-foreground">
|
||||
Top {displayData.length} of {data.length} projects
|
||||
Top {displayData.length} of {scoredData.length} scored projects
|
||||
</span>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="h-[500px]">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<BarChart
|
||||
data={displayData}
|
||||
layout="vertical"
|
||||
margin={{ top: 20, right: 30, bottom: 20, left: 150 }}
|
||||
>
|
||||
<CartesianGrid strokeDasharray="3 3" className="stroke-muted" />
|
||||
<XAxis type="number" domain={[0, 10]} />
|
||||
<YAxis
|
||||
dataKey="displayTitle"
|
||||
type="category"
|
||||
width={140}
|
||||
tick={{ fontSize: 11 }}
|
||||
/>
|
||||
<Tooltip
|
||||
contentStyle={{
|
||||
backgroundColor: 'hsl(var(--card))',
|
||||
border: '1px solid hsl(var(--border))',
|
||||
borderRadius: '6px',
|
||||
}}
|
||||
formatter={(value: number | undefined) => [(value ?? 0).toFixed(2), 'Average Score']}
|
||||
labelFormatter={(_, payload) => {
|
||||
if (payload && payload[0]) {
|
||||
const item = payload[0].payload as ProjectRankingData & {
|
||||
rank: number
|
||||
}
|
||||
return `#${item.rank} - ${item.title}${item.teamName ? ` (${item.teamName})` : ''}`
|
||||
}
|
||||
return ''
|
||||
}}
|
||||
/>
|
||||
<ReferenceLine
|
||||
x={averageScore}
|
||||
stroke="#666"
|
||||
strokeDasharray="5 5"
|
||||
label={{
|
||||
value: `Avg: ${averageScore.toFixed(1)}`,
|
||||
position: 'top',
|
||||
fill: '#666',
|
||||
fontSize: 11,
|
||||
}}
|
||||
/>
|
||||
<Bar dataKey="score" radius={[0, 4, 4, 0]}>
|
||||
{displayData.map((entry, index) => (
|
||||
<Cell key={`cell-${index}`} fill={getScoreColor(entry.score)} />
|
||||
))}
|
||||
</Bar>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
<BarChart
|
||||
data={chartData}
|
||||
index="project"
|
||||
categories={['Score']}
|
||||
colors={['blue']}
|
||||
layout="horizontal"
|
||||
yAxisWidth={200}
|
||||
maxValue={10}
|
||||
showLegend={false}
|
||||
className={`h-[${Math.max(400, displayData.length * 30)}px]`}
|
||||
style={{ height: `${Math.max(400, displayData.length * 30)}px` }}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
|
||||
@@ -1,15 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import {
|
||||
BarChart,
|
||||
Bar,
|
||||
XAxis,
|
||||
YAxis,
|
||||
CartesianGrid,
|
||||
Tooltip,
|
||||
ResponsiveContainer,
|
||||
Cell,
|
||||
} from 'recharts'
|
||||
import { BarChart } from '@tremor/react'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
|
||||
interface ScoreDistributionProps {
|
||||
@@ -18,24 +9,18 @@ interface ScoreDistributionProps {
|
||||
totalScores: number
|
||||
}
|
||||
|
||||
const COLORS = [
|
||||
'#de0f1e', // 1 - red (poor)
|
||||
'#e6382f',
|
||||
'#ed6141',
|
||||
'#f38a52',
|
||||
'#f8b364', // 5 - yellow (average)
|
||||
'#c9c052',
|
||||
'#99cc41',
|
||||
'#6ad82f',
|
||||
'#3be31e',
|
||||
'#0bd90f', // 10 - green (excellent)
|
||||
]
|
||||
|
||||
export function ScoreDistributionChart({
|
||||
data,
|
||||
averageScore,
|
||||
totalScores,
|
||||
}: ScoreDistributionProps) {
|
||||
if (!data?.length) return null
|
||||
|
||||
const chartData = data.map((d) => ({
|
||||
score: String(d.score),
|
||||
Count: d.count,
|
||||
}))
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
@@ -47,45 +32,15 @@ export function ScoreDistributionChart({
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="h-[300px]">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<BarChart
|
||||
data={data}
|
||||
margin={{ top: 20, right: 20, bottom: 20, left: 20 }}
|
||||
>
|
||||
<CartesianGrid strokeDasharray="3 3" className="stroke-muted" />
|
||||
<XAxis
|
||||
dataKey="score"
|
||||
label={{
|
||||
value: 'Score',
|
||||
position: 'insideBottom',
|
||||
offset: -10,
|
||||
}}
|
||||
/>
|
||||
<YAxis
|
||||
label={{
|
||||
value: 'Count',
|
||||
angle: -90,
|
||||
position: 'insideLeft',
|
||||
}}
|
||||
/>
|
||||
<Tooltip
|
||||
contentStyle={{
|
||||
backgroundColor: 'hsl(var(--card))',
|
||||
border: '1px solid hsl(var(--border))',
|
||||
borderRadius: '6px',
|
||||
}}
|
||||
formatter={(value: number | undefined) => [value ?? 0, 'Count']}
|
||||
labelFormatter={(label) => `Score: ${label}`}
|
||||
/>
|
||||
<Bar dataKey="count" radius={[4, 4, 0, 0]}>
|
||||
{data.map((_, index) => (
|
||||
<Cell key={`cell-${index}`} fill={COLORS[index]} />
|
||||
))}
|
||||
</Bar>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
<BarChart
|
||||
data={chartData}
|
||||
index="score"
|
||||
categories={['Count']}
|
||||
colors={['blue']}
|
||||
yAxisWidth={40}
|
||||
showLegend={false}
|
||||
className="h-[300px]"
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
|
||||
@@ -1,13 +1,8 @@
|
||||
'use client'
|
||||
import {
|
||||
PieChart,
|
||||
Pie,
|
||||
Cell,
|
||||
Tooltip,
|
||||
Legend,
|
||||
ResponsiveContainer,
|
||||
} from 'recharts'
|
||||
|
||||
import { DonutChart } from '@tremor/react'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { formatStatus, getStatusColor } from './chart-theme'
|
||||
|
||||
interface StatusDataPoint {
|
||||
status: string
|
||||
@@ -18,68 +13,18 @@ interface StatusBreakdownProps {
|
||||
data: StatusDataPoint[]
|
||||
}
|
||||
|
||||
const STATUS_COLORS: Record<string, string> = {
|
||||
PENDING: '#8884d8',
|
||||
UNDER_REVIEW: '#82ca9d',
|
||||
SHORTLISTED: '#ffc658',
|
||||
SEMIFINALIST: '#ff7300',
|
||||
FINALIST: '#00C49F',
|
||||
WINNER: '#0088FE',
|
||||
ELIMINATED: '#de0f1e',
|
||||
WITHDRAWN: '#999999',
|
||||
}
|
||||
|
||||
const renderCustomLabel = ({
|
||||
cx,
|
||||
cy,
|
||||
midAngle,
|
||||
innerRadius,
|
||||
outerRadius,
|
||||
percent,
|
||||
}: {
|
||||
cx?: number
|
||||
cy?: number
|
||||
midAngle?: number
|
||||
innerRadius?: number
|
||||
outerRadius?: number
|
||||
percent?: number
|
||||
}) => {
|
||||
if (cx === undefined || cy === undefined || midAngle === undefined ||
|
||||
innerRadius === undefined || outerRadius === undefined || percent === undefined) {
|
||||
return null
|
||||
}
|
||||
if (percent < 0.05) return null // Don't show labels for small slices
|
||||
|
||||
const RADIAN = Math.PI / 180
|
||||
const radius = innerRadius + (outerRadius - innerRadius) * 0.5
|
||||
const x = cx + radius * Math.cos(-midAngle * RADIAN)
|
||||
const y = cy + radius * Math.sin(-midAngle * RADIAN)
|
||||
|
||||
return (
|
||||
<text
|
||||
x={x}
|
||||
y={y}
|
||||
fill="white"
|
||||
textAnchor={x > cx ? 'start' : 'end'}
|
||||
dominantBaseline="central"
|
||||
fontSize={12}
|
||||
fontWeight={600}
|
||||
>
|
||||
{`${(percent * 100).toFixed(0)}%`}
|
||||
</text>
|
||||
)
|
||||
}
|
||||
|
||||
export function StatusBreakdownChart({ data }: StatusBreakdownProps) {
|
||||
if (!data?.length) return null
|
||||
|
||||
const total = data.reduce((sum, item) => sum + item.count, 0)
|
||||
|
||||
// Format status for display
|
||||
const formattedData = data.map((d) => ({
|
||||
...d,
|
||||
name: d.status.replace(/_/g, ' '),
|
||||
color: STATUS_COLORS[d.status] || '#8884d8',
|
||||
const chartData = data.map((d) => ({
|
||||
name: formatStatus(d.status),
|
||||
value: d.count,
|
||||
}))
|
||||
|
||||
const colors = data.map((d) => getStatusColor(d.status))
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
@@ -91,40 +36,14 @@ export function StatusBreakdownChart({ data }: StatusBreakdownProps) {
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="h-[300px]">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<PieChart>
|
||||
<Pie
|
||||
data={formattedData}
|
||||
cx="50%"
|
||||
cy="50%"
|
||||
labelLine={false}
|
||||
label={renderCustomLabel}
|
||||
outerRadius={100}
|
||||
innerRadius={50}
|
||||
fill="#8884d8"
|
||||
dataKey="count"
|
||||
nameKey="name"
|
||||
>
|
||||
{formattedData.map((entry, index) => (
|
||||
<Cell key={`cell-${index}`} fill={entry.color} />
|
||||
))}
|
||||
</Pie>
|
||||
<Tooltip
|
||||
contentStyle={{
|
||||
backgroundColor: 'hsl(var(--card))',
|
||||
border: '1px solid hsl(var(--border))',
|
||||
borderRadius: '6px',
|
||||
}}
|
||||
formatter={(value: number | undefined, name: string | undefined) => [
|
||||
`${value ?? 0} (${(((value ?? 0) / total) * 100).toFixed(1)}%)`,
|
||||
name ?? '',
|
||||
]}
|
||||
/>
|
||||
<Legend />
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
<DonutChart
|
||||
data={chartData}
|
||||
category="value"
|
||||
index="name"
|
||||
colors={colors}
|
||||
showLabel={true}
|
||||
className="h-[300px]"
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
|
||||
@@ -39,6 +39,7 @@ export function formatAction(action: string, entityType: string | null): string
|
||||
DELETE_OWN_ACCOUNT: 'deleted their account',
|
||||
EVALUATION_SUBMITTED: 'submitted an evaluation',
|
||||
COI_DECLARED: 'declared a conflict of interest',
|
||||
COI_NO_CONFLICT: 'confirmed no conflict of interest',
|
||||
COI_REVIEWED: 'reviewed a COI declaration',
|
||||
REMINDERS_TRIGGERED: 'triggered evaluation reminders',
|
||||
DISCUSSION_COMMENT_ADDED: 'added a discussion comment',
|
||||
|
||||
@@ -226,7 +226,8 @@ export function AdminSidebar({ user }: AdminSidebarProps) {
|
||||
{navigation.map((item) => {
|
||||
const isActive =
|
||||
pathname === item.href ||
|
||||
(item.href !== '/admin' && pathname.startsWith(item.href))
|
||||
(item.href !== '/admin' && pathname.startsWith(item.href)) ||
|
||||
(item.href === '/admin/rounds' && pathname.startsWith('/admin/competitions'))
|
||||
return (
|
||||
<div key={item.name}>
|
||||
<Link
|
||||
@@ -258,12 +259,24 @@ export function AdminSidebar({ user }: AdminSidebarProps) {
|
||||
Administration
|
||||
</p>
|
||||
{dynamicAdminNav.map((item) => {
|
||||
const isDisabled = item.name === 'Apply Page' && !currentEdition?.id
|
||||
let isActive = pathname.startsWith(item.href)
|
||||
if (item.activeMatch) {
|
||||
isActive = pathname.includes(item.activeMatch)
|
||||
} else if (item.activeExclude && pathname.includes(item.activeExclude)) {
|
||||
isActive = false
|
||||
}
|
||||
if (isDisabled) {
|
||||
return (
|
||||
<span
|
||||
key={item.name}
|
||||
className="group flex items-center gap-3 rounded-lg px-3 py-2.5 text-sm font-medium opacity-50 pointer-events-none text-muted-foreground"
|
||||
>
|
||||
<item.icon className="h-4 w-4 text-muted-foreground" />
|
||||
{item.name}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<Link
|
||||
key={item.name}
|
||||
|
||||
@@ -1,12 +1,41 @@
|
||||
'use client'
|
||||
|
||||
import { BarChart3, Home } from 'lucide-react'
|
||||
import { BarChart3, Home, FolderKanban } from 'lucide-react'
|
||||
import { RoleNav, type NavItem, type RoleNavUser } from '@/components/layouts/role-nav'
|
||||
import { useEditionContext } from '@/components/observer/observer-edition-context'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
|
||||
interface ObserverNavProps {
|
||||
user: RoleNavUser
|
||||
}
|
||||
|
||||
function EditionSelector() {
|
||||
const { programs, selectedProgramId, setSelectedProgramId } = useEditionContext()
|
||||
|
||||
if (programs.length <= 1) return null
|
||||
|
||||
return (
|
||||
<Select value={selectedProgramId} onValueChange={setSelectedProgramId}>
|
||||
<SelectTrigger className="w-full md:w-[180px]">
|
||||
<SelectValue placeholder="Select edition" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{programs.map((p) => (
|
||||
<SelectItem key={p.id} value={p.id}>
|
||||
{p.year ? `${p.year} Edition` : p.name ?? p.id}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)
|
||||
}
|
||||
|
||||
export function ObserverNav({ user }: ObserverNavProps) {
|
||||
const navigation: NavItem[] = [
|
||||
{
|
||||
@@ -14,6 +43,11 @@ export function ObserverNav({ user }: ObserverNavProps) {
|
||||
href: '/observer',
|
||||
icon: Home,
|
||||
},
|
||||
{
|
||||
name: 'Projects',
|
||||
href: '/observer/projects',
|
||||
icon: FolderKanban,
|
||||
},
|
||||
{
|
||||
name: 'Reports',
|
||||
href: '/observer/reports',
|
||||
@@ -27,6 +61,7 @@ export function ObserverNav({ user }: ObserverNavProps) {
|
||||
roleName="Observer"
|
||||
user={user}
|
||||
basePath="/observer"
|
||||
editionSelector={<EditionSelector />}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -41,13 +41,15 @@ type RoleNavProps = {
|
||||
basePath: string
|
||||
/** Optional status badge displayed next to the logo (e.g., remaining evaluations count) */
|
||||
statusBadge?: React.ReactNode
|
||||
/** Optional slot rendered in the mobile hamburger menu (between nav links and sign out) and desktop header */
|
||||
editionSelector?: React.ReactNode
|
||||
}
|
||||
|
||||
function isNavItemActive(pathname: string, href: string, basePath: string): boolean {
|
||||
return pathname === href || (href !== basePath && pathname.startsWith(href))
|
||||
}
|
||||
|
||||
export function RoleNav({ navigation, roleName, user, basePath, statusBadge }: RoleNavProps) {
|
||||
export function RoleNav({ navigation, roleName, user, basePath, statusBadge, editionSelector }: RoleNavProps) {
|
||||
const pathname = usePathname()
|
||||
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false)
|
||||
const { status: sessionStatus } = useSession()
|
||||
@@ -93,6 +95,7 @@ export function RoleNav({ navigation, roleName, user, basePath, statusBadge }: R
|
||||
|
||||
{/* User menu & mobile toggle */}
|
||||
<div className="flex items-center gap-2">
|
||||
{editionSelector && <div className="hidden md:block">{editionSelector}</div>}
|
||||
{mounted && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
@@ -161,42 +164,54 @@ export function RoleNav({ navigation, roleName, user, basePath, statusBadge }: R
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Mobile menu */}
|
||||
{isMobileMenuOpen && (
|
||||
<div className="border-t md:hidden">
|
||||
<nav className="container-app py-4 space-y-1">
|
||||
{navigation.map((item) => {
|
||||
const isActive = isNavItemActive(pathname, item.href, basePath)
|
||||
return (
|
||||
<Link
|
||||
key={item.name}
|
||||
href={item.href as Route}
|
||||
onClick={() => setIsMobileMenuOpen(false)}
|
||||
className={cn(
|
||||
'flex items-center gap-3 rounded-md px-3 py-2 text-sm font-medium transition-colors',
|
||||
isActive
|
||||
? 'bg-primary/10 text-primary'
|
||||
: 'text-muted-foreground hover:bg-muted hover:text-foreground'
|
||||
)}
|
||||
{/* Mobile menu — animated with CSS grid */}
|
||||
<div
|
||||
className={cn(
|
||||
'grid md:hidden transition-[grid-template-rows] duration-200 ease-out',
|
||||
isMobileMenuOpen ? 'grid-rows-[1fr]' : 'grid-rows-[0fr]',
|
||||
)}
|
||||
>
|
||||
<div className="overflow-hidden">
|
||||
<div className={cn('border-t', !isMobileMenuOpen && 'border-transparent')}>
|
||||
<nav className="container-app py-4 space-y-1">
|
||||
{navigation.map((item) => {
|
||||
const isActive = isNavItemActive(pathname, item.href, basePath)
|
||||
return (
|
||||
<Link
|
||||
key={item.name}
|
||||
href={item.href as Route}
|
||||
onClick={() => setIsMobileMenuOpen(false)}
|
||||
className={cn(
|
||||
'flex items-center gap-3 rounded-md px-3 py-2 text-sm font-medium transition-colors',
|
||||
isActive
|
||||
? 'bg-primary/10 text-primary'
|
||||
: 'text-muted-foreground hover:bg-muted hover:text-foreground'
|
||||
)}
|
||||
>
|
||||
<item.icon className="h-4 w-4" />
|
||||
{item.name}
|
||||
</Link>
|
||||
)
|
||||
})}
|
||||
{editionSelector && (
|
||||
<div className="border-t pt-4 mt-4 px-3">
|
||||
{editionSelector}
|
||||
</div>
|
||||
)}
|
||||
<div className="border-t pt-4 mt-4">
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="w-full justify-start text-destructive hover:text-destructive"
|
||||
onClick={() => signOut({ callbackUrl: '/login' })}
|
||||
>
|
||||
<item.icon className="h-4 w-4" />
|
||||
{item.name}
|
||||
</Link>
|
||||
)
|
||||
})}
|
||||
<div className="border-t pt-4 mt-4">
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="w-full justify-start text-destructive hover:text-destructive"
|
||||
onClick={() => signOut({ callbackUrl: '/login' })}
|
||||
>
|
||||
<LogOut className="mr-2 h-4 w-4" />
|
||||
Sign Out
|
||||
</Button>
|
||||
</div>
|
||||
</nav>
|
||||
<LogOut className="mr-2 h-4 w-4" />
|
||||
Sign Out
|
||||
</Button>
|
||||
</div>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
)
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
67
src/components/observer/observer-edition-context.tsx
Normal file
67
src/components/observer/observer-edition-context.tsx
Normal file
@@ -0,0 +1,67 @@
|
||||
'use client'
|
||||
|
||||
import { createContext, useContext, useState, useEffect, type ReactNode } from 'react'
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
|
||||
type Program = {
|
||||
id: string
|
||||
name: string | null
|
||||
year?: number
|
||||
rounds?: Array<{ id: string; name: string; status: string; competitionId?: string }>
|
||||
}
|
||||
|
||||
type EditionContextValue = {
|
||||
programs: Program[]
|
||||
selectedProgramId: string
|
||||
setSelectedProgramId: (id: string) => void
|
||||
activeRoundId: string
|
||||
}
|
||||
|
||||
const EditionContext = createContext<EditionContextValue | null>(null)
|
||||
|
||||
export function useEditionContext() {
|
||||
const ctx = useContext(EditionContext)
|
||||
if (!ctx) throw new Error('useEditionContext must be used within EditionProvider')
|
||||
return ctx
|
||||
}
|
||||
|
||||
function findBestRound(rounds: Array<{ id: string; status: string }>): string {
|
||||
const active = rounds.find(r => r.status === 'ROUND_ACTIVE')
|
||||
if (active) return active.id
|
||||
const closed = [...rounds].filter(r => r.status === 'ROUND_CLOSED').pop()
|
||||
if (closed) return closed.id
|
||||
return rounds[0]?.id ?? ''
|
||||
}
|
||||
|
||||
export function EditionProvider({ children }: { children: ReactNode }) {
|
||||
const [selectedProgramId, setSelectedProgramId] = useState<string>('')
|
||||
|
||||
const { data: programs } = trpc.program.list.useQuery(
|
||||
{ includeStages: true },
|
||||
{ refetchInterval: 30_000 },
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (programs && programs.length > 0 && !selectedProgramId) {
|
||||
setSelectedProgramId(programs[0].id)
|
||||
}
|
||||
}, [programs, selectedProgramId])
|
||||
|
||||
const typedPrograms = (programs ?? []) as Program[]
|
||||
const selectedProgram = typedPrograms.find(p => p.id === selectedProgramId)
|
||||
const rounds = (selectedProgram?.rounds ?? []) as Array<{ id: string; status: string }>
|
||||
const activeRoundId = findBestRound(rounds)
|
||||
|
||||
return (
|
||||
<EditionContext.Provider
|
||||
value={{
|
||||
programs: typedPrograms,
|
||||
selectedProgramId,
|
||||
setSelectedProgramId,
|
||||
activeRoundId,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</EditionContext.Provider>
|
||||
)
|
||||
}
|
||||
960
src/components/observer/observer-project-detail.tsx
Normal file
960
src/components/observer/observer-project-detail.tsx
Normal file
@@ -0,0 +1,960 @@
|
||||
'use client'
|
||||
|
||||
import Link from 'next/link'
|
||||
import type { Route } from 'next'
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { Progress } from '@/components/ui/progress'
|
||||
import { Separator } from '@/components/ui/separator'
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||
import { FileViewer } from '@/components/shared/file-viewer'
|
||||
import { ProjectLogoWithUrl } from '@/components/shared/project-logo-with-url'
|
||||
import { UserAvatar } from '@/components/shared/user-avatar'
|
||||
import { StatusBadge } from '@/components/shared/status-badge'
|
||||
import { AnimatedCard } from '@/components/shared/animated-container'
|
||||
import {
|
||||
AlertCircle,
|
||||
Users,
|
||||
FileText,
|
||||
Calendar,
|
||||
CheckCircle2,
|
||||
Circle,
|
||||
XCircle,
|
||||
BarChart3,
|
||||
ThumbsUp,
|
||||
ThumbsDown,
|
||||
MapPin,
|
||||
Waves,
|
||||
GraduationCap,
|
||||
Heart,
|
||||
Clock,
|
||||
MessageSquare,
|
||||
ArrowLeft,
|
||||
} from 'lucide-react'
|
||||
import { cn, formatDate, formatDateOnly } from '@/lib/utils'
|
||||
|
||||
export function ObserverProjectDetail({ projectId }: { projectId: string }) {
|
||||
const { data, isLoading } = trpc.analytics.getProjectDetail.useQuery(
|
||||
{ id: projectId },
|
||||
{ refetchInterval: 30_000 },
|
||||
)
|
||||
|
||||
const roundId = data?.assignments?.[0]?.roundId as string | undefined
|
||||
const { data: activeForm } = trpc.evaluation.getStageForm.useQuery(
|
||||
{ roundId: roundId ?? '' },
|
||||
{ enabled: !!roundId },
|
||||
)
|
||||
|
||||
if (isLoading) {
|
||||
return <ProjectDetailSkeleton />
|
||||
}
|
||||
|
||||
if (!data) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<nav className="flex items-center gap-1 text-sm text-muted-foreground">
|
||||
<Link href={'/observer' as Route} className="hover:text-foreground">
|
||||
Observer
|
||||
</Link>
|
||||
<span>/</span>
|
||||
<Link
|
||||
href={'/observer/projects' as Route}
|
||||
className="hover:text-foreground"
|
||||
>
|
||||
Projects
|
||||
</Link>
|
||||
</nav>
|
||||
<Card>
|
||||
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
|
||||
<AlertCircle className="h-12 w-12 text-destructive/50" />
|
||||
<p className="mt-2 font-medium">Project Not Found</p>
|
||||
<Button asChild className="mt-4">
|
||||
<Link href={'/observer' as Route}>Back to Dashboard</Link>
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const { project, assignments, stats, competitionRounds, projectRoundStates, filteringResult } =
|
||||
data
|
||||
|
||||
const roundStateMap = new Map(
|
||||
(projectRoundStates ?? []).map((s) => [s.roundId, s]),
|
||||
)
|
||||
|
||||
const criteriaMap = new Map<
|
||||
string,
|
||||
{ label: string; type: string; trueLabel?: string; falseLabel?: string }
|
||||
>()
|
||||
if (activeForm?.criteriaJson) {
|
||||
for (const c of activeForm.criteriaJson as Array<{
|
||||
id: string
|
||||
label: string
|
||||
type?: string
|
||||
trueLabel?: string
|
||||
falseLabel?: string
|
||||
}>) {
|
||||
criteriaMap.set(c.id, {
|
||||
label: c.label,
|
||||
type: c.type || 'numeric',
|
||||
trueLabel: c.trueLabel,
|
||||
falseLabel: c.falseLabel,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Compute per-criterion averages from all submitted evaluations
|
||||
const criterionTotals = new Map<string, { sum: number; count: number }>()
|
||||
for (const assignment of assignments) {
|
||||
const ev = assignment.evaluation
|
||||
if (ev?.status !== 'SUBMITTED') continue
|
||||
const scores = (ev.criterionScoresJson || {}) as Record<
|
||||
string,
|
||||
number | boolean | string
|
||||
>
|
||||
for (const [key, value] of Object.entries(scores)) {
|
||||
const meta = criteriaMap.get(key)
|
||||
const type =
|
||||
meta?.type ||
|
||||
(typeof value === 'boolean'
|
||||
? 'boolean'
|
||||
: typeof value === 'string'
|
||||
? 'text'
|
||||
: 'numeric')
|
||||
if (type !== 'numeric' && type !== 'section_header') continue
|
||||
if (type === 'section_header') continue
|
||||
if (typeof value !== 'number') continue
|
||||
const existing = criterionTotals.get(key) || { sum: 0, count: 0 }
|
||||
criterionTotals.set(key, {
|
||||
sum: existing.sum + value,
|
||||
count: existing.count + 1,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const criterionAverages = new Map<string, number>()
|
||||
for (const [key, { sum, count }] of criterionTotals.entries()) {
|
||||
if (count > 0) criterionAverages.set(key, sum / count)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Back button */}
|
||||
<Button variant="ghost" size="sm" className="gap-1.5 -ml-2 text-muted-foreground" asChild>
|
||||
<Link href={'/observer/projects' as Route}>
|
||||
<ArrowLeft className="h-3.5 w-3.5" />
|
||||
Back to Projects
|
||||
</Link>
|
||||
</Button>
|
||||
|
||||
{/* Project Header */}
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div className="flex items-start gap-4">
|
||||
<ProjectLogoWithUrl project={project} size="lg" fallback="initials" />
|
||||
<div className="min-w-0 space-y-1.5">
|
||||
<h1 className="text-2xl font-semibold tracking-tight">
|
||||
{project.title}
|
||||
</h1>
|
||||
{project.teamName && (
|
||||
<p className="text-muted-foreground">{project.teamName}</p>
|
||||
)}
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{(project.country || project.geographicZone) && (
|
||||
<Badge variant="outline" className="gap-1">
|
||||
<MapPin className="h-3 w-3" />
|
||||
{project.country || project.geographicZone}
|
||||
</Badge>
|
||||
)}
|
||||
{project.competitionCategory && (
|
||||
<Badge variant="outline" className="gap-1">
|
||||
<GraduationCap className="h-3 w-3" />
|
||||
{project.competitionCategory === 'STARTUP'
|
||||
? 'Start-up'
|
||||
: 'Business Concept'}
|
||||
</Badge>
|
||||
)}
|
||||
{project.oceanIssue && (
|
||||
<Badge variant="outline" className="gap-1">
|
||||
<Waves className="h-3 w-3" />
|
||||
{project.oceanIssue.replace(/_/g, ' ')}
|
||||
</Badge>
|
||||
)}
|
||||
<StatusBadge status={project.status ?? 'SUBMITTED'} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Score card */}
|
||||
{stats && (
|
||||
<Card className="w-full shrink-0 sm:w-48">
|
||||
<CardContent className="pt-4">
|
||||
<div className="flex flex-col items-center text-center">
|
||||
<div className="rounded-lg bg-brand-teal/10 p-2">
|
||||
<BarChart3 className="h-5 w-5 text-brand-teal" />
|
||||
</div>
|
||||
<p className="mt-2 text-4xl font-bold tabular-nums">
|
||||
{stats.averageGlobalScore != null
|
||||
? stats.averageGlobalScore.toFixed(1)
|
||||
: '-'}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{stats.minScore ?? '-'} – {stats.maxScore ?? '-'} range
|
||||
</p>
|
||||
<Separator className="my-2" />
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{stats.totalEvaluations} evaluation
|
||||
{stats.totalEvaluations !== 1 ? 's' : ''}
|
||||
</p>
|
||||
{stats.yesPercentage != null && (
|
||||
<p className="mt-0.5 text-xs font-medium text-emerald-600">
|
||||
{stats.yesPercentage.toFixed(0)}% recommended
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Tabs */}
|
||||
<Tabs defaultValue="overview">
|
||||
<TabsList>
|
||||
<TabsTrigger value="overview">Overview</TabsTrigger>
|
||||
<TabsTrigger value="evaluations">
|
||||
Evaluations
|
||||
{assignments.length > 0 && (
|
||||
<Badge variant="secondary" className="ml-1.5 h-4 px-1 text-xs">
|
||||
{assignments.length}
|
||||
</Badge>
|
||||
)}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="files">Files</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
{/* ── Overview Tab ── */}
|
||||
<TabsContent value="overview" className="mt-6 space-y-6">
|
||||
{/* Criteria mini-cards */}
|
||||
{criterionAverages.size > 0 && (
|
||||
<AnimatedCard index={0}>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2.5 text-lg">
|
||||
<div className="rounded-lg bg-brand-teal/10 p-1.5">
|
||||
<BarChart3 className="h-4 w-4 text-brand-teal" />
|
||||
</div>
|
||||
Criteria Averages
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Averaged across all submitted evaluations
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-4">
|
||||
{Array.from(criterionAverages.entries()).map(
|
||||
([key, avg]) => {
|
||||
const meta = criteriaMap.get(key)
|
||||
const label = meta?.label || key
|
||||
return (
|
||||
<div
|
||||
key={key}
|
||||
className="rounded-lg border p-3 space-y-2"
|
||||
>
|
||||
<p className="text-xs font-medium text-muted-foreground line-clamp-2">
|
||||
{label}
|
||||
</p>
|
||||
<p className="text-xl font-bold tabular-nums">
|
||||
{avg.toFixed(1)}
|
||||
</p>
|
||||
<Progress
|
||||
value={(avg / 10) * 100}
|
||||
className="h-1.5"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</AnimatedCard>
|
||||
)}
|
||||
|
||||
{/* AI Rejection Reason */}
|
||||
{project.status === 'REJECTED' && filteringResult?.aiScreeningJson && (() => {
|
||||
const screening = filteringResult.aiScreeningJson as Record<string, Record<string, unknown>>
|
||||
// Extract reasoning from the first rule's result
|
||||
const firstRule = Object.values(screening)[0]
|
||||
const reasoning = firstRule?.reasoning as string | undefined
|
||||
const confidence = firstRule?.confidence as number | undefined
|
||||
if (!reasoning) return null
|
||||
return (
|
||||
<AnimatedCard index={1}>
|
||||
<Card className="border-red-200 bg-red-50/50">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="flex items-center gap-2.5 text-lg text-red-700">
|
||||
<div className="rounded-lg bg-red-100 p-1.5">
|
||||
<AlertCircle className="h-4 w-4 text-red-600" />
|
||||
</div>
|
||||
AI Screening — Rejected
|
||||
{filteringResult.round && (
|
||||
<span className="text-sm font-normal text-red-500 ml-auto">
|
||||
at {filteringResult.round.name}
|
||||
</span>
|
||||
)}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-sm text-red-800 whitespace-pre-wrap">{reasoning}</p>
|
||||
{confidence != null && (
|
||||
<p className="mt-2 text-xs text-red-500">
|
||||
AI Confidence: {Math.round(confidence * 100)}%
|
||||
</p>
|
||||
)}
|
||||
{filteringResult.overrideReason && (
|
||||
<div className="mt-3 border-t border-red-200 pt-3">
|
||||
<p className="text-xs font-medium text-red-600">Override Reason</p>
|
||||
<p className="text-sm text-red-800">{filteringResult.overrideReason}</p>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</AnimatedCard>
|
||||
)
|
||||
})()}
|
||||
|
||||
{/* Project Info — matches admin layout */}
|
||||
<AnimatedCard index={2}>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2.5 text-lg">
|
||||
<div className="rounded-lg bg-emerald-500/10 p-1.5">
|
||||
<FileText className="h-4 w-4 text-emerald-500" />
|
||||
</div>
|
||||
Project Information
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{/* Category & Ocean Issue badges */}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{project.competitionCategory && (
|
||||
<Badge variant="outline" className="gap-1">
|
||||
<GraduationCap className="h-3 w-3" />
|
||||
{project.competitionCategory === 'STARTUP' ? 'Start-up' : 'Business Concept'}
|
||||
</Badge>
|
||||
)}
|
||||
{project.oceanIssue && (
|
||||
<Badge variant="outline" className="gap-1">
|
||||
<Waves className="h-3 w-3" />
|
||||
{project.oceanIssue.replace(/_/g, ' ')}
|
||||
</Badge>
|
||||
)}
|
||||
{project.wantsMentorship && (
|
||||
<Badge variant="outline" className="gap-1 text-pink-600 border-pink-200 bg-pink-50">
|
||||
<Heart className="h-3 w-3" />
|
||||
Wants Mentorship
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{project.description && (
|
||||
<div>
|
||||
<p className="text-sm font-medium text-muted-foreground mb-1">Description</p>
|
||||
<p className="text-sm whitespace-pre-wrap">{project.description}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Location, Institution, Founded */}
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
{(project.country || project.geographicZone) && (
|
||||
<div className="flex items-start gap-2">
|
||||
<MapPin className="h-4 w-4 text-muted-foreground mt-0.5" />
|
||||
<div>
|
||||
<p className="text-sm font-medium text-muted-foreground">Location</p>
|
||||
<p className="text-sm">{project.geographicZone || project.country}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{project.institution && (
|
||||
<div className="flex items-start gap-2">
|
||||
<GraduationCap className="h-4 w-4 text-muted-foreground mt-0.5" />
|
||||
<div>
|
||||
<p className="text-sm font-medium text-muted-foreground">Institution</p>
|
||||
<p className="text-sm">{project.institution}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{project.foundedAt && (
|
||||
<div className="flex items-start gap-2">
|
||||
<Calendar className="h-4 w-4 text-muted-foreground mt-0.5" />
|
||||
<div>
|
||||
<p className="text-sm font-medium text-muted-foreground">Founded</p>
|
||||
<p className="text-sm">{formatDateOnly(project.foundedAt)}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* AI-Assigned Expertise Tags */}
|
||||
{project.projectTags && project.projectTags.length > 0 && (
|
||||
<div>
|
||||
<p className="text-sm font-medium text-muted-foreground mb-2">Expertise Tags</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{project.projectTags.map((pt) => (
|
||||
<Badge
|
||||
key={pt.tag.id}
|
||||
variant="secondary"
|
||||
className="flex items-center gap-1"
|
||||
style={pt.tag.color ? { backgroundColor: `${pt.tag.color}20`, borderColor: pt.tag.color } : undefined}
|
||||
>
|
||||
{pt.tag.name}
|
||||
{pt.confidence < 1 && (
|
||||
<span className="text-xs opacity-60">
|
||||
{Math.round(pt.confidence * 100)}%
|
||||
</span>
|
||||
)}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Simple Tags (legacy) */}
|
||||
{project.tags && project.tags.length > 0 && (
|
||||
<div>
|
||||
<p className="text-sm font-medium text-muted-foreground mb-2">Tags</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{project.tags.map((tag: string) => (
|
||||
<Badge key={tag} variant="secondary">{tag}</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Internal Info */}
|
||||
{(project.internalComments || project.applicationStatus || project.referralSource) && (
|
||||
<div className="border-t pt-4 mt-4">
|
||||
<p className="text-sm font-medium text-muted-foreground mb-3">Internal Notes</p>
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
{project.applicationStatus && (
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground">Application Status</p>
|
||||
<p className="text-sm">{project.applicationStatus}</p>
|
||||
</div>
|
||||
)}
|
||||
{project.referralSource && (
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground">Referral Source</p>
|
||||
<p className="text-sm">{project.referralSource}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{project.internalComments && (
|
||||
<div className="mt-3">
|
||||
<p className="text-xs text-muted-foreground">Comments</p>
|
||||
<p className="text-sm whitespace-pre-wrap">{project.internalComments}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-wrap gap-6 text-sm pt-2">
|
||||
<div>
|
||||
<span className="text-muted-foreground">Created:</span>{' '}
|
||||
{formatDateOnly(project.createdAt)}
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground">Updated:</span>{' '}
|
||||
{formatDateOnly(project.updatedAt)}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</AnimatedCard>
|
||||
|
||||
{/* Round History */}
|
||||
{competitionRounds.length > 0 && (() => {
|
||||
// Find the furthest round index where the project is active or beyond
|
||||
// Any round before this must have been passed
|
||||
let furthestActiveIdx = -1
|
||||
for (let i = competitionRounds.length - 1; i >= 0; i--) {
|
||||
const s = roundStateMap.get(competitionRounds[i].id)
|
||||
if (s && (s.state === 'IN_PROGRESS' || s.state === 'PASSED' || s.state === 'COMPLETED')) {
|
||||
furthestActiveIdx = i
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// Find the rejection round — either explicit REJECTED state or inferred
|
||||
const isProjectRejected = project.status === 'REJECTED'
|
||||
const explicitRejectedRound = competitionRounds.find((r) => {
|
||||
const s = roundStateMap.get(r.id)
|
||||
return s?.state === 'REJECTED'
|
||||
})
|
||||
|
||||
// If project is globally rejected but no round has explicit REJECTED state,
|
||||
// infer the rejection round as the furthest round the project reached
|
||||
let inferredRejectionRoundId: string | null = null
|
||||
if (isProjectRejected && !explicitRejectedRound) {
|
||||
for (let i = competitionRounds.length - 1; i >= 0; i--) {
|
||||
const s = roundStateMap.get(competitionRounds[i].id)
|
||||
if (s) {
|
||||
if (s.state === 'PASSED' || s.state === 'COMPLETED') {
|
||||
if (i + 1 < competitionRounds.length) {
|
||||
inferredRejectionRoundId = competitionRounds[i + 1].id
|
||||
}
|
||||
} else {
|
||||
inferredRejectionRoundId = competitionRounds[i].id
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const rejectedRound = explicitRejectedRound
|
||||
?? (inferredRejectionRoundId
|
||||
? competitionRounds.find((r) => r.id === inferredRejectionRoundId)
|
||||
: null)
|
||||
|
||||
const rejectedRoundIdx = rejectedRound
|
||||
? competitionRounds.findIndex((r) => r.id === rejectedRound.id)
|
||||
: -1
|
||||
|
||||
// Compute effective states for all rounds
|
||||
const effectiveStates = competitionRounds.map((round, idx) => {
|
||||
const rawState = roundStateMap.get(round.id)?.state
|
||||
const isRejectionRound = round.id === rejectedRound?.id
|
||||
const isNotReached = rejectedRoundIdx >= 0 && idx > rejectedRoundIdx
|
||||
|
||||
if (isRejectionRound && !explicitRejectedRound) return 'REJECTED'
|
||||
if (isNotReached) return 'NOT_REACHED'
|
||||
// If this round is before the furthest active round and not already PASSED/COMPLETED,
|
||||
// the project must have passed it to reach the later round
|
||||
if (furthestActiveIdx > idx && (!rawState || rawState === 'PENDING')) return 'PASSED'
|
||||
return rawState
|
||||
})
|
||||
|
||||
const passedCount = effectiveStates.filter(
|
||||
(s) => s === 'PASSED' || s === 'COMPLETED',
|
||||
).length
|
||||
|
||||
return (
|
||||
<AnimatedCard index={3}>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2.5 text-lg">
|
||||
<div className="rounded-lg bg-violet-500/10 p-1.5">
|
||||
<Calendar className="h-4 w-4 text-violet-500" />
|
||||
</div>
|
||||
Round History
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
{rejectedRound
|
||||
? `Rejected at ${rejectedRound.name}`
|
||||
: `Passed ${passedCount} of ${competitionRounds.length} rounds`}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ol className="space-y-4">
|
||||
{competitionRounds.map((round, idx) => {
|
||||
const effectiveState = effectiveStates[idx]
|
||||
|
||||
const roundAssignments = assignments.filter(
|
||||
(a) => a.roundId === round.id,
|
||||
)
|
||||
|
||||
let icon: React.ReactNode
|
||||
let statusLabel: string | null = null
|
||||
let labelClass = 'text-muted-foreground'
|
||||
|
||||
if (effectiveState === 'PASSED' || effectiveState === 'COMPLETED') {
|
||||
icon = <CheckCircle2 className="mt-0.5 h-5 w-5 shrink-0 text-emerald-500" />
|
||||
statusLabel = 'Passed'
|
||||
} else if (effectiveState === 'REJECTED') {
|
||||
icon = <XCircle className="mt-0.5 h-5 w-5 shrink-0 text-red-500" />
|
||||
statusLabel = 'Rejected at this round'
|
||||
labelClass = 'text-red-600 font-medium'
|
||||
} else if (effectiveState === 'IN_PROGRESS') {
|
||||
icon = (
|
||||
<span className="mt-0.5 flex h-5 w-5 shrink-0 items-center justify-center">
|
||||
<span className="relative flex h-3 w-3">
|
||||
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-blue-400 opacity-75" />
|
||||
<span className="relative inline-flex h-3 w-3 rounded-full bg-blue-500" />
|
||||
</span>
|
||||
</span>
|
||||
)
|
||||
statusLabel = 'Active'
|
||||
} else if (effectiveState === 'NOT_REACHED') {
|
||||
icon = <Circle className="mt-0.5 h-5 w-5 shrink-0 text-muted-foreground/15" />
|
||||
statusLabel = 'Not reached'
|
||||
labelClass = 'text-muted-foreground/50 italic'
|
||||
} else if (effectiveState === 'PENDING') {
|
||||
icon = <Circle className="mt-0.5 h-5 w-5 shrink-0 text-muted-foreground/40" />
|
||||
statusLabel = 'Pending'
|
||||
} else {
|
||||
icon = <Circle className="mt-0.5 h-5 w-5 shrink-0 text-muted-foreground/20" />
|
||||
}
|
||||
|
||||
return (
|
||||
<li key={round.id} className={cn(
|
||||
'flex items-start gap-3',
|
||||
effectiveState === 'NOT_REACHED' && 'opacity-50',
|
||||
)}>
|
||||
{icon}
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className={cn(
|
||||
'text-sm font-medium',
|
||||
effectiveState === 'NOT_REACHED' && 'text-muted-foreground',
|
||||
)}>{round.name}</p>
|
||||
{statusLabel && (
|
||||
<p className={cn('text-xs', labelClass)}>
|
||||
{statusLabel}
|
||||
</p>
|
||||
)}
|
||||
{roundAssignments.length > 0 && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{roundAssignments.filter((a) => a.evaluation?.status === 'SUBMITTED').length}/{roundAssignments.length} evaluations
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{effectiveState === 'IN_PROGRESS' && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="ml-auto shrink-0 border-blue-200 bg-blue-50 text-blue-600 text-xs"
|
||||
>
|
||||
Active
|
||||
</Badge>
|
||||
)}
|
||||
{effectiveState === 'REJECTED' && (
|
||||
<Badge
|
||||
variant="destructive"
|
||||
className="ml-auto shrink-0 text-xs"
|
||||
>
|
||||
Rejected
|
||||
</Badge>
|
||||
)}
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ol>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</AnimatedCard>
|
||||
)
|
||||
})()}
|
||||
</TabsContent>
|
||||
|
||||
{/* ── Evaluations Tab ── */}
|
||||
<TabsContent value="evaluations" className="mt-6 space-y-4">
|
||||
{assignments.length === 0 ? (
|
||||
<Card>
|
||||
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
|
||||
<Users className="h-10 w-10 text-muted-foreground/40" />
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
{project.status === 'ASSIGNED'
|
||||
? 'Awaiting jury evaluation — assigned and pending review'
|
||||
: 'No jury assignments yet'}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
assignments.map((assignment) => {
|
||||
const ev = assignment.evaluation
|
||||
const isSubmitted = ev?.status === 'SUBMITTED'
|
||||
const criterionScores = (ev?.criterionScoresJson || {}) as Record<
|
||||
string,
|
||||
number | boolean | string
|
||||
>
|
||||
|
||||
return (
|
||||
<AnimatedCard key={assignment.id} index={0}>
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-center justify-between gap-3 flex-wrap">
|
||||
<div className="flex items-center gap-3">
|
||||
<UserAvatar
|
||||
user={assignment.user}
|
||||
avatarUrl={assignment.user.avatarUrl}
|
||||
size="md"
|
||||
/>
|
||||
<div>
|
||||
<p className="text-sm font-semibold">
|
||||
{assignment.user.name || 'Unnamed'}
|
||||
</p>
|
||||
<div className="flex items-center gap-2 mt-0.5">
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="text-xs whitespace-nowrap"
|
||||
>
|
||||
{assignment.round.name}
|
||||
</Badge>
|
||||
{isSubmitted && ev.submittedAt && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{formatDate(ev.submittedAt)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{isSubmitted && ev.globalScore != null && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge className="bg-brand-teal text-white text-base px-3 py-1 font-bold">
|
||||
{ev.globalScore}/10
|
||||
</Badge>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
{isSubmitted && ev ? (
|
||||
<CardContent className="space-y-4">
|
||||
{/* Criterion scores */}
|
||||
{Object.keys(criterionScores).length > 0 && (
|
||||
<div className="space-y-2.5">
|
||||
{Object.entries(criterionScores).map(
|
||||
([key, value]) => {
|
||||
const meta = criteriaMap.get(key)
|
||||
const label = meta?.label || key
|
||||
const type =
|
||||
meta?.type ||
|
||||
(typeof value === 'boolean'
|
||||
? 'boolean'
|
||||
: typeof value === 'string'
|
||||
? 'text'
|
||||
: 'numeric')
|
||||
|
||||
if (type === 'section_header') return null
|
||||
|
||||
if (type === 'boolean') {
|
||||
return (
|
||||
<div
|
||||
key={key}
|
||||
className="flex items-center justify-between rounded-lg border p-2.5"
|
||||
>
|
||||
<span className="text-sm">{label}</span>
|
||||
{value === true ? (
|
||||
<Badge
|
||||
className="border-emerald-200 bg-emerald-100 text-emerald-700"
|
||||
variant="outline"
|
||||
>
|
||||
<ThumbsUp className="mr-1 h-3 w-3" />
|
||||
{meta?.trueLabel || 'Yes'}
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge
|
||||
className="border-red-200 bg-red-100 text-red-700"
|
||||
variant="outline"
|
||||
>
|
||||
<ThumbsDown className="mr-1 h-3 w-3" />
|
||||
{meta?.falseLabel || 'No'}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (type === 'text') {
|
||||
return (
|
||||
<div key={key} className="space-y-1">
|
||||
<p className="text-sm font-medium">
|
||||
{label}
|
||||
</p>
|
||||
<div className="whitespace-pre-wrap rounded-lg border bg-muted/50 p-2.5 text-sm text-muted-foreground">
|
||||
{typeof value === 'string'
|
||||
? value
|
||||
: String(value)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Numeric
|
||||
return (
|
||||
<div
|
||||
key={key}
|
||||
className="flex items-center gap-3 rounded-lg border p-2.5"
|
||||
>
|
||||
<span className="flex-1 truncate text-sm">
|
||||
{label}
|
||||
</span>
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<Progress
|
||||
value={
|
||||
typeof value === 'number'
|
||||
? (value / 10) * 100
|
||||
: 0
|
||||
}
|
||||
className="h-1.5 w-20"
|
||||
/>
|
||||
<span className="w-8 text-right text-sm font-bold tabular-nums">
|
||||
{typeof value === 'number' ? value : '-'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Feedback */}
|
||||
{ev.feedbackText && (
|
||||
<div>
|
||||
<p className="mb-1.5 flex items-center gap-1.5 text-sm font-medium">
|
||||
<MessageSquare className="h-4 w-4" />
|
||||
Feedback
|
||||
</p>
|
||||
<div className="whitespace-pre-wrap rounded-lg border bg-muted/30 p-3 text-sm leading-relaxed text-muted-foreground">
|
||||
{ev.feedbackText}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Binary decision */}
|
||||
{ev.binaryDecision != null && (
|
||||
<div className="flex items-center gap-2">
|
||||
{ev.binaryDecision ? (
|
||||
<div className="flex items-center gap-1.5 text-emerald-600 font-medium text-sm">
|
||||
<ThumbsUp className="h-4 w-4" />
|
||||
Recommended
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-1.5 text-red-600 font-medium text-sm">
|
||||
<ThumbsDown className="h-4 w-4" />
|
||||
Not recommended
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
) : (
|
||||
<CardContent>
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Clock className="h-4 w-4" />
|
||||
Evaluation pending
|
||||
</div>
|
||||
</CardContent>
|
||||
)}
|
||||
</Card>
|
||||
</AnimatedCard>
|
||||
)
|
||||
})
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
{/* ── Files Tab ── */}
|
||||
<TabsContent value="files" className="mt-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2.5 text-lg">
|
||||
<div className="rounded-lg bg-rose-500/10 p-1.5">
|
||||
<FileText className="h-4 w-4 text-rose-500" />
|
||||
</div>
|
||||
Project Files
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{project.files && project.files.length > 0 ? (
|
||||
<FileViewer
|
||||
projectId={projectId}
|
||||
files={project.files.map((f) => ({
|
||||
id: f.id,
|
||||
fileName: f.fileName,
|
||||
fileType: f.fileType as
|
||||
| 'EXEC_SUMMARY'
|
||||
| 'PRESENTATION'
|
||||
| 'VIDEO'
|
||||
| 'OTHER'
|
||||
| 'BUSINESS_PLAN'
|
||||
| 'VIDEO_PITCH'
|
||||
| 'SUPPORTING_DOC',
|
||||
mimeType: f.mimeType,
|
||||
size: f.size,
|
||||
bucket: f.bucket,
|
||||
objectKey: f.objectKey,
|
||||
pageCount: f.pageCount,
|
||||
textPreview: f.textPreview,
|
||||
detectedLang: f.detectedLang,
|
||||
langConfidence: f.langConfidence,
|
||||
analyzedAt: f.analyzedAt ? String(f.analyzedAt) : null,
|
||||
requirementId: f.requirementId,
|
||||
requirement: f.requirement
|
||||
? {
|
||||
id: f.requirement.id,
|
||||
name: f.requirement.name,
|
||||
description: f.requirement.description,
|
||||
isRequired: f.requirement.isRequired,
|
||||
}
|
||||
: null,
|
||||
}))}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex flex-col items-center justify-center py-8 text-center">
|
||||
<FileText className="h-12 w-12 text-muted-foreground/50" />
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
No files uploaded yet
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ProjectDetailSkeleton() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center gap-2">
|
||||
<Skeleton className="h-4 w-16" />
|
||||
<Skeleton className="h-4 w-2" />
|
||||
<Skeleton className="h-4 w-16" />
|
||||
<Skeleton className="h-4 w-2" />
|
||||
<Skeleton className="h-4 w-32" />
|
||||
</div>
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="flex items-start gap-4">
|
||||
<Skeleton className="h-16 w-16 rounded-lg" />
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-8 w-64" />
|
||||
<Skeleton className="h-4 w-40" />
|
||||
<div className="flex gap-2">
|
||||
<Skeleton className="h-5 w-20" />
|
||||
<Skeleton className="h-5 w-24" />
|
||||
<Skeleton className="h-5 w-16" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Skeleton className="h-32 w-48 rounded-lg" />
|
||||
</div>
|
||||
<Skeleton className="h-px w-full" />
|
||||
<div className="flex gap-2">
|
||||
<Skeleton className="h-9 w-24" />
|
||||
<Skeleton className="h-9 w-28" />
|
||||
<Skeleton className="h-9 w-16" />
|
||||
</div>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<Skeleton className="h-5 w-40" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Skeleton className="h-32 w-full" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
490
src/components/observer/observer-projects-content.tsx
Normal file
490
src/components/observer/observer-projects-content.tsx
Normal file
@@ -0,0 +1,490 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useCallback } from 'react'
|
||||
import Link from 'next/link'
|
||||
import type { Route } from 'next'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
CardDescription,
|
||||
} from '@/components/ui/card'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import { StatusBadge } from '@/components/shared/status-badge'
|
||||
import { CsvExportDialog } from '@/components/shared/csv-export-dialog'
|
||||
import { scoreGradient } from '@/components/charts/chart-theme'
|
||||
import {
|
||||
Search,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
ArrowUpDown,
|
||||
ArrowUp,
|
||||
ArrowDown,
|
||||
ClipboardList,
|
||||
Download,
|
||||
X,
|
||||
} from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useDebouncedCallback } from 'use-debounce'
|
||||
|
||||
|
||||
export function ObserverProjectsContent() {
|
||||
const router = useRouter()
|
||||
const [search, setSearch] = useState('')
|
||||
const [debouncedSearch, setDebouncedSearch] = useState('')
|
||||
const [roundFilter, setRoundFilter] = useState('all')
|
||||
const [statusFilter, setStatusFilter] = useState('all')
|
||||
const [sortBy, setSortBy] = useState<'title' | 'score' | 'evaluations'>('title')
|
||||
const [sortDir, setSortDir] = useState<'asc' | 'desc'>('asc')
|
||||
const [page, setPage] = useState(1)
|
||||
const [perPage] = useState(20)
|
||||
const [csvOpen, setCsvOpen] = useState(false)
|
||||
const [csvExportData, setCsvExportData] = useState<
|
||||
{ data: Record<string, unknown>[]; columns: string[] } | undefined
|
||||
>(undefined)
|
||||
const [csvLoading, setCsvLoading] = useState(false)
|
||||
|
||||
const debouncedSetSearch = useDebouncedCallback((value: string) => {
|
||||
setDebouncedSearch(value)
|
||||
setPage(1)
|
||||
}, 300)
|
||||
|
||||
const handleSearchChange = (value: string) => {
|
||||
setSearch(value)
|
||||
debouncedSetSearch(value)
|
||||
}
|
||||
|
||||
const handleRoundChange = (value: string) => {
|
||||
setRoundFilter(value)
|
||||
setPage(1)
|
||||
}
|
||||
|
||||
const handleStatusChange = (value: string) => {
|
||||
setStatusFilter(value)
|
||||
setPage(1)
|
||||
}
|
||||
|
||||
const handleSort = (column: 'title' | 'score' | 'evaluations') => {
|
||||
if (sortBy === column) {
|
||||
setSortDir(sortDir === 'asc' ? 'desc' : 'asc')
|
||||
} else {
|
||||
setSortBy(column)
|
||||
setSortDir(column === 'title' ? 'asc' : 'desc')
|
||||
}
|
||||
setPage(1)
|
||||
}
|
||||
|
||||
const clearFilters = () => {
|
||||
setSearch('')
|
||||
setDebouncedSearch('')
|
||||
setRoundFilter('all')
|
||||
setStatusFilter('all')
|
||||
setPage(1)
|
||||
}
|
||||
|
||||
const activeFilterCount =
|
||||
(debouncedSearch ? 1 : 0) +
|
||||
(roundFilter !== 'all' ? 1 : 0) +
|
||||
(statusFilter !== 'all' ? 1 : 0)
|
||||
|
||||
const { data: programs } = trpc.program.list.useQuery(
|
||||
{ includeStages: true },
|
||||
{ refetchInterval: 30_000 },
|
||||
)
|
||||
|
||||
const rounds =
|
||||
programs?.flatMap((p) =>
|
||||
(p.rounds ?? []).map((r: { id: string; name: string; status: string; roundType?: string }) => ({
|
||||
id: r.id,
|
||||
name: r.name,
|
||||
programName: `${p.year} Edition`,
|
||||
status: r.status,
|
||||
roundType: r.roundType,
|
||||
})),
|
||||
) ?? []
|
||||
|
||||
const roundIdParam = roundFilter !== 'all' ? roundFilter : undefined
|
||||
|
||||
const { data: projectsData, isLoading: projectsLoading } =
|
||||
trpc.analytics.getAllProjects.useQuery(
|
||||
{
|
||||
roundId: roundIdParam,
|
||||
search: debouncedSearch || undefined,
|
||||
status: statusFilter !== 'all' ? statusFilter : undefined,
|
||||
sortBy,
|
||||
sortDir,
|
||||
page,
|
||||
perPage,
|
||||
},
|
||||
{ refetchInterval: 30_000 },
|
||||
)
|
||||
|
||||
const handleRequestCsvData = useCallback(async () => {
|
||||
setCsvLoading(true)
|
||||
try {
|
||||
const allData = await new Promise<typeof projectsData>((resolve) => {
|
||||
resolve(projectsData)
|
||||
})
|
||||
|
||||
if (!allData?.projects) {
|
||||
setCsvLoading(false)
|
||||
return undefined
|
||||
}
|
||||
|
||||
const rows = allData.projects.map((p) => ({
|
||||
title: p.title,
|
||||
teamName: p.teamName ?? '',
|
||||
country: p.country ?? '',
|
||||
roundName: p.roundName ?? '',
|
||||
status: p.status,
|
||||
averageScore: p.averageScore !== null ? p.averageScore.toFixed(2) : '',
|
||||
evaluationCount: p.evaluationCount,
|
||||
}))
|
||||
|
||||
const result = {
|
||||
data: rows,
|
||||
columns: ['title', 'teamName', 'country', 'roundName', 'status', 'averageScore', 'evaluationCount'],
|
||||
}
|
||||
setCsvExportData(result)
|
||||
setCsvLoading(false)
|
||||
return result
|
||||
} catch {
|
||||
setCsvLoading(false)
|
||||
return undefined
|
||||
}
|
||||
}, [projectsData])
|
||||
|
||||
const SortIcon = ({ column }: { column: 'title' | 'score' | 'evaluations' }) => {
|
||||
if (sortBy !== column)
|
||||
return <ArrowUpDown className="ml-1 inline h-3 w-3 text-muted-foreground/50" />
|
||||
return sortDir === 'asc' ? (
|
||||
<ArrowUp className="ml-1 inline h-3 w-3" />
|
||||
) : (
|
||||
<ArrowDown className="ml-1 inline h-3 w-3" />
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">All Projects</h1>
|
||||
<p className="text-muted-foreground">
|
||||
{projectsData
|
||||
? `${projectsData.total} project${projectsData.total !== 1 ? 's' : ''} total`
|
||||
: 'Loading projects...'}
|
||||
</p>
|
||||
</div>
|
||||
<Button variant="outline" size="sm" onClick={() => setCsvOpen(true)}>
|
||||
<Download className="mr-2 h-4 w-4" />
|
||||
Export CSV
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-base">Filters</CardTitle>
|
||||
{activeFilterCount > 0 && (
|
||||
<CardDescription className="flex items-center gap-2">
|
||||
<Badge variant="secondary">{activeFilterCount} active</Badge>
|
||||
<button
|
||||
type="button"
|
||||
onClick={clearFilters}
|
||||
className="flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
Clear all
|
||||
</button>
|
||||
</CardDescription>
|
||||
)}
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:gap-3">
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Search by title or team..."
|
||||
value={search}
|
||||
onChange={(e) => handleSearchChange(e.target.value)}
|
||||
className="pl-10"
|
||||
/>
|
||||
</div>
|
||||
<Select value={roundFilter} onValueChange={handleRoundChange}>
|
||||
<SelectTrigger className="w-full sm:w-[220px]">
|
||||
<SelectValue placeholder="All Rounds" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All Rounds</SelectItem>
|
||||
{rounds.map((round) => (
|
||||
<SelectItem key={round.id} value={round.id}>
|
||||
{round.name}
|
||||
{round.roundType ? ` (${round.roundType.replace(/_/g, ' ')})` : ''}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select value={statusFilter} onValueChange={handleStatusChange}>
|
||||
<SelectTrigger className="w-full sm:w-[180px]">
|
||||
<SelectValue placeholder="All Statuses" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All Statuses</SelectItem>
|
||||
<SelectItem value="SUBMITTED">Submitted</SelectItem>
|
||||
<SelectItem value="NOT_REVIEWED">Not Reviewed</SelectItem>
|
||||
<SelectItem value="UNDER_REVIEW">Under Review</SelectItem>
|
||||
<SelectItem value="REVIEWED">Reviewed</SelectItem>
|
||||
<SelectItem value="SEMIFINALIST">Semi-finalist</SelectItem>
|
||||
<SelectItem value="FINALIST">Finalist</SelectItem>
|
||||
<SelectItem value="REJECTED">Rejected</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{projectsLoading ? (
|
||||
<Card>
|
||||
<CardContent className="pt-6 space-y-2">
|
||||
{[...Array(8)].map((_, i) => (
|
||||
<Skeleton key={i} className="h-12 w-full" />
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : projectsData && projectsData.projects.length > 0 ? (
|
||||
<>
|
||||
<div className="hidden md:block">
|
||||
<Card>
|
||||
<CardContent className="p-0">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="pl-6">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleSort('title')}
|
||||
className="inline-flex items-center hover:text-foreground transition-colors"
|
||||
>
|
||||
Project
|
||||
<SortIcon column="title" />
|
||||
</button>
|
||||
</TableHead>
|
||||
<TableHead>Country</TableHead>
|
||||
<TableHead>Round</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleSort('score')}
|
||||
className="inline-flex items-center hover:text-foreground transition-colors"
|
||||
>
|
||||
Score
|
||||
<SortIcon column="score" />
|
||||
</button>
|
||||
</TableHead>
|
||||
<TableHead>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleSort('evaluations')}
|
||||
className="inline-flex items-center hover:text-foreground transition-colors"
|
||||
>
|
||||
Jurors
|
||||
<SortIcon column="evaluations" />
|
||||
</button>
|
||||
</TableHead>
|
||||
<TableHead className="pr-6 w-10" />
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{projectsData.projects.map((project) => (
|
||||
<TableRow
|
||||
key={project.id}
|
||||
className="cursor-pointer hover:bg-muted/50"
|
||||
onClick={() => router.push(`/observer/projects/${project.id}`)}
|
||||
>
|
||||
<TableCell className="pl-6 max-w-[260px]">
|
||||
<Link
|
||||
href={`/observer/projects/${project.id}` as Route}
|
||||
className="font-medium hover:underline truncate block"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{project.title}
|
||||
</Link>
|
||||
{project.teamName && (
|
||||
<p className="text-xs text-muted-foreground truncate">
|
||||
{project.teamName}
|
||||
</p>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="text-sm">
|
||||
{project.country ?? '-'}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant="outline" className="text-xs whitespace-nowrap">
|
||||
{project.roundName}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<StatusBadge status={project.observerStatus ?? project.status} />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{project.evaluationCount > 0 && project.averageScore !== null ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="tabular-nums w-8 text-sm">
|
||||
{project.averageScore.toFixed(1)}
|
||||
</span>
|
||||
<div className="h-2 w-16 rounded-full bg-muted overflow-hidden">
|
||||
<div
|
||||
className="h-full rounded-full"
|
||||
style={{
|
||||
width: `${(project.averageScore / 10) * 100}%`,
|
||||
backgroundColor: scoreGradient(project.averageScore),
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-muted-foreground text-sm">-</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="tabular-nums text-sm">
|
||||
{project.evaluationCount}
|
||||
</TableCell>
|
||||
<TableCell className="pr-6">
|
||||
<Link
|
||||
href={`/observer/projects/${project.id}` as Route}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<ChevronRight className="h-4 w-4 text-muted-foreground" />
|
||||
</Link>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3 md:hidden">
|
||||
{projectsData.projects.map((project) => (
|
||||
<Link
|
||||
key={project.id}
|
||||
href={`/observer/projects/${project.id}` as Route}
|
||||
>
|
||||
<Card className="transition-colors hover:bg-muted/50">
|
||||
<CardContent className="pt-4 space-y-2">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="min-w-0">
|
||||
<p className="font-medium text-sm leading-tight truncate">
|
||||
{project.title}
|
||||
</p>
|
||||
{project.teamName && (
|
||||
<p className="text-xs text-muted-foreground truncate">
|
||||
{project.teamName}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<StatusBadge status={project.observerStatus ?? project.status} />
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-2 text-xs text-muted-foreground">
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{project.roundName}
|
||||
</Badge>
|
||||
{project.evaluationCount > 0 && (
|
||||
<div className="flex gap-3">
|
||||
<span>
|
||||
Score:{' '}
|
||||
{project.averageScore !== null
|
||||
? project.averageScore.toFixed(1)
|
||||
: '-'}
|
||||
</span>
|
||||
<span>
|
||||
{project.evaluationCount} eval
|
||||
{project.evaluationCount !== 1 ? 's' : ''}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Page {projectsData.page} of {projectsData.totalPages} ·{' '}
|
||||
{projectsData.total} result{projectsData.total !== 1 ? 's' : ''}
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
||||
disabled={page <= 1}
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
setPage((p) => Math.min(projectsData.totalPages, p + 1))
|
||||
}
|
||||
disabled={page >= projectsData.totalPages}
|
||||
>
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div
|
||||
className={cn(
|
||||
'flex flex-col items-center justify-center rounded-lg border border-dashed py-16 text-center',
|
||||
)}
|
||||
>
|
||||
<ClipboardList className="h-12 w-12 text-muted-foreground/50" />
|
||||
<p className="mt-3 font-medium">
|
||||
{activeFilterCount > 0 ? 'No projects match your filters' : 'No projects found'}
|
||||
</p>
|
||||
{activeFilterCount > 0 && (
|
||||
<Button variant="ghost" size="sm" className="mt-2" onClick={clearFilters}>
|
||||
Clear filters
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<CsvExportDialog
|
||||
open={csvOpen}
|
||||
onOpenChange={setCsvOpen}
|
||||
exportData={csvExportData}
|
||||
isLoading={csvLoading}
|
||||
filename="observer-projects"
|
||||
onRequestData={handleRequestCsvData}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
303
src/components/observer/reports/deliberation-report-tabs.tsx
Normal file
303
src/components/observer/reports/deliberation-report-tabs.tsx
Normal file
@@ -0,0 +1,303 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table'
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||
import { Users, Trophy } from 'lucide-react'
|
||||
import { RoundTypeStatsCards } from '@/components/observer/round-type-stats'
|
||||
|
||||
interface DeliberationReportTabsProps {
|
||||
roundId: string
|
||||
programId: string
|
||||
}
|
||||
|
||||
function sessionStatusBadge(status: string) {
|
||||
switch (status) {
|
||||
case 'DELIB_LOCKED':
|
||||
return <Badge variant="default">Locked</Badge>
|
||||
case 'VOTING':
|
||||
return <Badge variant="secondary">Voting</Badge>
|
||||
case 'TALLYING':
|
||||
return <Badge className="bg-amber-100 text-amber-800 border-amber-200">Tallying</Badge>
|
||||
case 'RUNOFF':
|
||||
return <Badge className="bg-rose-100 text-rose-800 border-rose-200">Runoff</Badge>
|
||||
case 'DELIB_OPEN':
|
||||
return <Badge variant="outline">Open</Badge>
|
||||
default:
|
||||
return <Badge variant="outline">{status}</Badge>
|
||||
}
|
||||
}
|
||||
|
||||
function sessionModeBadge(mode: string) {
|
||||
return <Badge variant="outline">{mode.charAt(0) + mode.slice(1).toLowerCase()}</Badge>
|
||||
}
|
||||
|
||||
function SessionsTab({ roundId }: { roundId: string }) {
|
||||
const { data: sessions, isLoading } =
|
||||
trpc.analytics.getDeliberationSessions.useQuery({ roundId })
|
||||
|
||||
if (isLoading) return <Skeleton className="h-[350px]" />
|
||||
|
||||
if (!sessions?.length) {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
|
||||
<Users className="h-10 w-10 text-muted-foreground/40 mb-3" />
|
||||
<p className="text-sm font-medium text-muted-foreground">No deliberation sessions yet</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Sessions will appear here once created by administrators
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Desktop table */}
|
||||
<div className="hidden md:block rounded-md border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Category</TableHead>
|
||||
<TableHead>Mode</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead className="text-right tabular-nums">Participants</TableHead>
|
||||
<TableHead className="text-right tabular-nums">Votes Cast</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{sessions.map((session) => (
|
||||
<TableRow key={session.id}>
|
||||
<TableCell className="font-medium">
|
||||
{session.category ?? <span className="text-muted-foreground italic">General</span>}
|
||||
</TableCell>
|
||||
<TableCell>{sessionModeBadge(session.mode)}</TableCell>
|
||||
<TableCell>{sessionStatusBadge(session.status)}</TableCell>
|
||||
<TableCell className="text-right tabular-nums">{session._count.participants}</TableCell>
|
||||
<TableCell className="text-right tabular-nums">{session._count.votes}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
{/* Mobile card stack */}
|
||||
<div className="space-y-3 md:hidden">
|
||||
{sessions.map((session) => (
|
||||
<Card key={session.id}>
|
||||
<CardContent className="pt-4 space-y-3">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<p className="font-medium text-sm">
|
||||
{session.category ?? <span className="italic text-muted-foreground">General</span>}
|
||||
</p>
|
||||
{sessionStatusBadge(session.status)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{sessionModeBadge(session.mode)}
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2 text-xs">
|
||||
<div>
|
||||
<p className="text-muted-foreground">Participants</p>
|
||||
<p className="font-medium tabular-nums">{session._count.participants}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-muted-foreground">Votes Cast</p>
|
||||
<p className="font-medium tabular-nums">{session._count.votes}</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function ResultsTab({ roundId }: { roundId: string }) {
|
||||
const [selectedSessionId, setSelectedSessionId] = useState<string | null>(null)
|
||||
|
||||
const { data: sessions, isLoading: sessionsLoading } =
|
||||
trpc.analytics.getDeliberationSessions.useQuery({ roundId })
|
||||
|
||||
const activeSessions = sessions?.filter((s) => s._count.votes > 0) ?? []
|
||||
const activeSessionId = selectedSessionId ?? activeSessions[0]?.id ?? null
|
||||
|
||||
const { data: aggregate, isLoading: aggregateLoading } =
|
||||
trpc.analytics.getDeliberationAggregate.useQuery(
|
||||
{ sessionId: activeSessionId! },
|
||||
{ enabled: !!activeSessionId }
|
||||
)
|
||||
|
||||
if (sessionsLoading) return <Skeleton className="h-[400px]" />
|
||||
|
||||
if (!activeSessions.length) {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="flex items-center justify-center py-12">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
No votes have been cast yet. Results will appear once deliberation is underway.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
const currentSessionId = selectedSessionId ?? activeSessions[0]?.id
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Session selector if multiple */}
|
||||
{activeSessions.length > 1 && (
|
||||
<div className="flex items-center gap-3 flex-wrap">
|
||||
{activeSessions.map((s) => (
|
||||
<button
|
||||
key={s.id}
|
||||
onClick={() => setSelectedSessionId(s.id)}
|
||||
className={
|
||||
currentSessionId === s.id
|
||||
? 'rounded-full px-3 py-1 text-sm font-medium bg-primary text-primary-foreground'
|
||||
: 'rounded-full px-3 py-1 text-sm font-medium bg-muted text-muted-foreground hover:bg-muted/80 transition-colors'
|
||||
}
|
||||
>
|
||||
{s.category ?? 'General'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{aggregateLoading ? (
|
||||
<Skeleton className="h-[300px]" />
|
||||
) : aggregate ? (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<Trophy className="h-4 w-4 text-amber-500" />
|
||||
Ranking Results
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
{aggregate.rankings.length} project{aggregate.rankings.length !== 1 ? 's' : ''} ranked
|
||||
{aggregate.hasTies && (
|
||||
<span className="ml-1 text-amber-600 font-medium">· Ties detected</span>
|
||||
)}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{/* Desktop table */}
|
||||
<div className="hidden md:block rounded-md border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-12 text-center">Rank</TableHead>
|
||||
<TableHead>Project</TableHead>
|
||||
<TableHead>Team</TableHead>
|
||||
<TableHead className="text-right tabular-nums">Score</TableHead>
|
||||
<TableHead className="text-right tabular-nums">Votes</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{aggregate.rankings.map((r, idx) => {
|
||||
const isTied = aggregate.tiedProjectIds.includes(r.projectId)
|
||||
return (
|
||||
<TableRow key={r.projectId} className={isTied ? 'bg-amber-50/50' : undefined}>
|
||||
<TableCell className="text-center font-bold tabular-nums text-lg">
|
||||
{idx + 1}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium">{r.projectTitle}</span>
|
||||
{isTied && (
|
||||
<Badge className="bg-amber-100 text-amber-800 border-amber-200 text-[10px]">
|
||||
Tie
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-muted-foreground text-sm">{r.teamName}</TableCell>
|
||||
<TableCell className="text-right tabular-nums">
|
||||
{typeof r.score === 'number' ? r.score : '—'}
|
||||
</TableCell>
|
||||
<TableCell className="text-right tabular-nums">{r.voteCount}</TableCell>
|
||||
</TableRow>
|
||||
)
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
{/* Mobile list */}
|
||||
<div className="space-y-2 md:hidden">
|
||||
{aggregate.rankings.map((r, idx) => {
|
||||
const isTied = aggregate.tiedProjectIds.includes(r.projectId)
|
||||
return (
|
||||
<div
|
||||
key={r.projectId}
|
||||
className={`flex items-center gap-3 rounded-md p-3 border${isTied ? ' bg-amber-50/50' : ''}`}
|
||||
>
|
||||
<span className="text-2xl font-bold tabular-nums w-8 text-center shrink-0">
|
||||
{idx + 1}
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<p className="font-medium text-sm truncate">{r.projectTitle}</p>
|
||||
{isTied && (
|
||||
<Badge className="bg-amber-100 text-amber-800 border-amber-200 text-[10px]">
|
||||
Tie
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">{r.teamName}</p>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground tabular-nums shrink-0">
|
||||
{r.voteCount} votes
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function DeliberationReportTabs({ roundId }: DeliberationReportTabsProps) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<RoundTypeStatsCards roundId={roundId} />
|
||||
|
||||
<Tabs defaultValue="sessions" className="space-y-6">
|
||||
<TabsList>
|
||||
<TabsTrigger value="sessions" className="gap-2">
|
||||
<Users className="h-4 w-4" />
|
||||
Sessions
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="results" className="gap-2">
|
||||
<Trophy className="h-4 w-4" />
|
||||
Results
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="sessions">
|
||||
<SessionsTab roundId={roundId} />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="results">
|
||||
<ResultsTab roundId={roundId} />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
790
src/components/observer/reports/evaluation-report-tabs.tsx
Normal file
790
src/components/observer/reports/evaluation-report-tabs.tsx
Normal file
@@ -0,0 +1,790 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useCallback } from 'react'
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Progress } from '@/components/ui/progress'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table'
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
FileSpreadsheet,
|
||||
BarChart3,
|
||||
Users,
|
||||
TrendingUp,
|
||||
Download,
|
||||
Clock,
|
||||
ClipboardCheck,
|
||||
} from 'lucide-react'
|
||||
import { formatDateOnly } from '@/lib/utils'
|
||||
import {
|
||||
ScoreDistributionChart,
|
||||
EvaluationTimelineChart,
|
||||
StatusBreakdownChart,
|
||||
CriteriaScoresChart,
|
||||
JurorConsistencyChart,
|
||||
JurorScoreHeatmap,
|
||||
} from '@/components/charts'
|
||||
import { BarChart } from '@tremor/react'
|
||||
import { CsvExportDialog } from '@/components/shared/csv-export-dialog'
|
||||
import { ExportPdfButton } from '@/components/shared/export-pdf-button'
|
||||
import { AnimatedCard } from '@/components/shared/animated-container'
|
||||
import { RoundTypeStatsCards } from '@/components/observer/round-type-stats'
|
||||
import { ExpandableJurorTable } from './expandable-juror-table'
|
||||
|
||||
const ROUND_TYPE_LABELS: Record<string, string> = {
|
||||
INTAKE: 'Intake',
|
||||
FILTERING: 'Filtering',
|
||||
EVALUATION: 'Evaluation',
|
||||
SUBMISSION: 'Submission',
|
||||
MENTORING: 'Mentoring',
|
||||
LIVE_FINAL: 'Live Final',
|
||||
DELIBERATION: 'Deliberation',
|
||||
}
|
||||
|
||||
type Stage = {
|
||||
id: string
|
||||
name: string
|
||||
status: string
|
||||
roundType: string
|
||||
windowCloseAt: Date | null
|
||||
_count: { projects: number; assignments: number; evaluations: number }
|
||||
programId: string
|
||||
programName: string
|
||||
}
|
||||
|
||||
function roundStatusLabel(status: string): string {
|
||||
if (status === 'ROUND_ACTIVE') return 'Active'
|
||||
if (status === 'ROUND_CLOSED') return 'Closed'
|
||||
if (status === 'ROUND_DRAFT') return 'Draft'
|
||||
if (status === 'ROUND_ARCHIVED') return 'Archived'
|
||||
return status
|
||||
}
|
||||
|
||||
function roundStatusVariant(status: string): 'default' | 'secondary' | 'outline' {
|
||||
if (status === 'ROUND_ACTIVE') return 'default'
|
||||
if (status === 'ROUND_CLOSED') return 'secondary'
|
||||
return 'outline'
|
||||
}
|
||||
|
||||
function parseSelection(value: string | null): { roundId?: string; programId?: string } {
|
||||
if (!value) return {}
|
||||
if (value.startsWith('all:')) return { programId: value.slice(4) }
|
||||
return { roundId: value }
|
||||
}
|
||||
|
||||
interface EvaluationReportTabsProps {
|
||||
roundId: string
|
||||
programId: string
|
||||
stages: Stage[]
|
||||
selectedValue: string | null
|
||||
}
|
||||
|
||||
// ---- Progress sub-tab ----
|
||||
|
||||
function ProgressSubTab({
|
||||
selectedValue,
|
||||
stages,
|
||||
stagesLoading,
|
||||
selectedRound,
|
||||
}: {
|
||||
selectedValue: string | null
|
||||
stages: Stage[]
|
||||
stagesLoading: boolean
|
||||
selectedRound: Stage | undefined
|
||||
}) {
|
||||
const queryInput = parseSelection(selectedValue)
|
||||
const hasSelection = !!queryInput.roundId || !!queryInput.programId
|
||||
|
||||
const { data: overviewStats, isLoading: statsLoading } =
|
||||
trpc.analytics.getOverviewStats.useQuery(queryInput, { enabled: hasSelection })
|
||||
|
||||
const { data: timeline, isLoading: timelineLoading } =
|
||||
trpc.analytics.getEvaluationTimeline.useQuery(queryInput, { enabled: hasSelection })
|
||||
|
||||
const [csvOpen, setCsvOpen] = useState(false)
|
||||
const [csvData, setCsvData] = useState<{ data: Record<string, unknown>[]; columns: string[] } | undefined>()
|
||||
const [csvLoading, setCsvLoading] = useState(false)
|
||||
|
||||
const handleRequestCsvData = useCallback(async () => {
|
||||
setCsvLoading(true)
|
||||
const columns = ['roundName', 'roundType', 'status', 'projects', 'assignments', 'completionRate']
|
||||
const data = stages.map((s) => {
|
||||
const assigned = s._count.assignments
|
||||
const projects = s._count.projects
|
||||
const rate = assigned > 0 && projects > 0 ? Math.round((assigned / projects) * 100) : 0
|
||||
return {
|
||||
roundName: s.name,
|
||||
roundType: ROUND_TYPE_LABELS[s.roundType] || s.roundType,
|
||||
status: roundStatusLabel(s.status),
|
||||
projects,
|
||||
assignments: assigned,
|
||||
completionRate: rate,
|
||||
}
|
||||
})
|
||||
const result = { data, columns }
|
||||
setCsvData(result)
|
||||
setCsvLoading(false)
|
||||
return result
|
||||
}, [stages])
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between flex-wrap gap-3">
|
||||
<div>
|
||||
<h2 className="text-base font-semibold">Progress Overview</h2>
|
||||
<p className="text-sm text-muted-foreground">Evaluation progress across rounds</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{selectedValue && !selectedValue.startsWith('all:') && (
|
||||
<ExportPdfButton
|
||||
roundId={selectedValue}
|
||||
roundName={selectedRound?.name}
|
||||
programName={selectedRound?.programName}
|
||||
/>
|
||||
)}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setCsvOpen(true)}
|
||||
disabled={stagesLoading || stages.length === 0}
|
||||
>
|
||||
<Download className="mr-2 h-4 w-4" />
|
||||
Export CSV
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<CsvExportDialog
|
||||
open={csvOpen}
|
||||
onOpenChange={setCsvOpen}
|
||||
exportData={csvData}
|
||||
isLoading={csvLoading}
|
||||
filename="round-progress"
|
||||
onRequestData={handleRequestCsvData}
|
||||
/>
|
||||
|
||||
{/* Stats tiles */}
|
||||
{hasSelection && (
|
||||
<>
|
||||
{statsLoading ? (
|
||||
<div className="grid gap-4 sm:grid-cols-3">
|
||||
{[...Array(3)].map((_, i) => (
|
||||
<Card key={i}>
|
||||
<CardHeader className="space-y-0 pb-2">
|
||||
<Skeleton className="h-4 w-20" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Skeleton className="h-8 w-16" />
|
||||
<Skeleton className="mt-2 h-3 w-24" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
) : overviewStats ? (
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<AnimatedCard index={0}>
|
||||
<Card className="border-l-4 border-l-blue-500 transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md">
|
||||
<CardContent className="p-5">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-muted-foreground">Projects</p>
|
||||
<p className="text-2xl font-bold mt-1">{overviewStats.projectCount}</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">In round</p>
|
||||
</div>
|
||||
<div className="rounded-xl bg-blue-50 p-3">
|
||||
<FileSpreadsheet className="h-5 w-5 text-blue-600" />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</AnimatedCard>
|
||||
|
||||
<AnimatedCard index={1}>
|
||||
<Card className="border-l-4 border-l-teal-500 transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md">
|
||||
<CardContent className="p-5">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-muted-foreground">Assignments</p>
|
||||
<p className="text-2xl font-bold mt-1">{overviewStats.assignmentCount}</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
{overviewStats.projectCount > 0
|
||||
? `${(overviewStats.assignmentCount / overviewStats.projectCount).toFixed(1)} reviews/project`
|
||||
: 'No projects'}
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded-xl bg-teal-50 p-3">
|
||||
<ClipboardCheck className="h-5 w-5 text-teal-600" />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</AnimatedCard>
|
||||
|
||||
<AnimatedCard index={2}>
|
||||
<Card className="border-l-4 border-l-emerald-500 transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md">
|
||||
<CardContent className="p-5">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-muted-foreground">Evaluations</p>
|
||||
<p className="text-2xl font-bold mt-1">{overviewStats.evaluationCount}</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
{overviewStats.assignmentCount > 0
|
||||
? `${overviewStats.evaluationCount}/${overviewStats.assignmentCount} submitted`
|
||||
: 'Submitted'}
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded-xl bg-emerald-50 p-3">
|
||||
<TrendingUp className="h-5 w-5 text-emerald-600" />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</AnimatedCard>
|
||||
|
||||
<AnimatedCard index={3}>
|
||||
<Card className="border-l-4 border-l-violet-500 transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md">
|
||||
<CardContent className="p-5">
|
||||
<div>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-muted-foreground">Completion</p>
|
||||
<p className="text-2xl font-bold mt-1">{overviewStats.completionRate}%</p>
|
||||
</div>
|
||||
<div className="rounded-xl bg-violet-50 p-3">
|
||||
<BarChart3 className="h-5 w-5 text-violet-600" />
|
||||
</div>
|
||||
</div>
|
||||
<Progress value={overviewStats.completionRate} className="mt-3 h-2" gradient />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</AnimatedCard>
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Completion Timeline */}
|
||||
{hasSelection && (
|
||||
<>
|
||||
{timelineLoading ? (
|
||||
<Skeleton className="h-[320px]" />
|
||||
) : timeline?.length ? (
|
||||
<EvaluationTimelineChart data={timeline} />
|
||||
) : (
|
||||
<Card>
|
||||
<CardContent className="flex items-center justify-center py-12">
|
||||
<p className="text-muted-foreground text-sm">No evaluation timeline data available yet</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Round Breakdown Table - Desktop */}
|
||||
{stagesLoading ? (
|
||||
<Skeleton className="h-[300px]" />
|
||||
) : (
|
||||
<>
|
||||
<Card className="hidden md:block">
|
||||
<CardHeader>
|
||||
<CardTitle>Round Breakdown</CardTitle>
|
||||
<CardDescription>Progress overview for each round</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Round</TableHead>
|
||||
<TableHead>Type</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead className="text-right">Projects</TableHead>
|
||||
<TableHead className="text-right">Assignments</TableHead>
|
||||
<TableHead className="min-w-[140px]">Completion</TableHead>
|
||||
<TableHead className="text-right">Avg Days</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{stages.map((stage) => {
|
||||
const projects = stage._count.projects
|
||||
const assignments = stage._count.assignments
|
||||
const evaluations = stage._count.evaluations
|
||||
const isClosed = stage.status === 'ROUND_CLOSED' || stage.status === 'ROUND_ARCHIVED'
|
||||
const rate = isClosed
|
||||
? 100
|
||||
: assignments > 0
|
||||
? Math.min(100, Math.round((evaluations / assignments) * 100))
|
||||
: 0
|
||||
return (
|
||||
<TableRow key={stage.id}>
|
||||
<TableCell>
|
||||
<div>
|
||||
<p className="font-medium">{stage.name}</p>
|
||||
{stage.windowCloseAt && (
|
||||
<p className="text-xs text-muted-foreground flex items-center gap-1 mt-0.5">
|
||||
<Clock className="h-3 w-3" />
|
||||
{formatDateOnly(stage.windowCloseAt)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant="outline">
|
||||
{ROUND_TYPE_LABELS[stage.roundType] || stage.roundType}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant={roundStatusVariant(stage.status)}>
|
||||
{roundStatusLabel(stage.status)}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-right">{projects}</TableCell>
|
||||
<TableCell className="text-right">{assignments}</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-2">
|
||||
<Progress value={rate} className="h-2 w-20" />
|
||||
<span className="text-sm tabular-nums">{rate}%</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-right text-muted-foreground">-</TableCell>
|
||||
</TableRow>
|
||||
)
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Round Breakdown Cards - Mobile */}
|
||||
<div className="space-y-3 md:hidden">
|
||||
<h2 className="text-base font-semibold">Round Breakdown</h2>
|
||||
{stages.map((stage) => {
|
||||
const projects = stage._count.projects
|
||||
const assignments = stage._count.assignments
|
||||
const evaluations = stage._count.evaluations
|
||||
const isClosed = stage.status === 'ROUND_CLOSED' || stage.status === 'ROUND_ARCHIVED'
|
||||
const rate = isClosed
|
||||
? 100
|
||||
: assignments > 0
|
||||
? Math.min(100, Math.round((evaluations / assignments) * 100))
|
||||
: 0
|
||||
return (
|
||||
<Card key={stage.id}>
|
||||
<CardContent className="pt-4 space-y-3">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<p className="font-medium leading-tight">{stage.name}</p>
|
||||
<Badge variant={roundStatusVariant(stage.status)} className="shrink-0">
|
||||
{roundStatusLabel(stage.status)}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant="outline">
|
||||
{ROUND_TYPE_LABELS[stage.roundType] || stage.roundType}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2 text-sm">
|
||||
<div>
|
||||
<p className="text-muted-foreground text-xs">Projects</p>
|
||||
<p className="font-medium">{projects}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-muted-foreground text-xs">Assignments</p>
|
||||
<p className="font-medium">{assignments}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex items-center justify-between text-sm mb-1">
|
||||
<span className="text-muted-foreground text-xs">Completion</span>
|
||||
<span className="font-medium">{rate}%</span>
|
||||
</div>
|
||||
<Progress value={rate} className="h-2" />
|
||||
</div>
|
||||
{stage.windowCloseAt && (
|
||||
<p className="text-xs text-muted-foreground flex items-center gap-1">
|
||||
<Clock className="h-3 w-3" />
|
||||
Closes: {formatDateOnly(stage.windowCloseAt)}
|
||||
</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ---- Jurors sub-tab ----
|
||||
|
||||
function JurorsSubTab({ roundId, selectedValue }: { roundId: string; selectedValue: string | null }) {
|
||||
const queryInput = parseSelection(selectedValue)
|
||||
const hasSelection = !!queryInput.roundId || !!queryInput.programId
|
||||
|
||||
const { data: workload, isLoading: workloadLoading } =
|
||||
trpc.analytics.getJurorWorkload.useQuery(queryInput, { enabled: hasSelection })
|
||||
|
||||
const { data: consistency, isLoading: consistencyLoading } =
|
||||
trpc.analytics.getJurorConsistency.useQuery(queryInput, { enabled: hasSelection })
|
||||
|
||||
const { data: heatmapData, isLoading: heatmapLoading } =
|
||||
trpc.analytics.getJurorScoreMatrix.useQuery({ roundId }, { enabled: !!roundId })
|
||||
|
||||
const [csvOpen, setCsvOpen] = useState(false)
|
||||
const [csvData, setCsvData] = useState<{ data: Record<string, unknown>[]; columns: string[] } | undefined>()
|
||||
const [csvLoading, setCsvLoading] = useState(false)
|
||||
|
||||
type WorkloadItem = { id: string; name: string; assigned: number; completed: number; completionRate: number; projects: { id: string; title: string; evalStatus: string }[] }
|
||||
type ConsistencyJuror = { userId: string; name: string; evaluationCount: number; averageScore: number; stddev: number; isOutlier: boolean }
|
||||
|
||||
const handleRequestCsvData = useCallback(async () => {
|
||||
setCsvLoading(true)
|
||||
const columns = ['name', 'assigned', 'completed', 'completionRate', 'avgScore', 'stddev', 'isOutlier']
|
||||
|
||||
const workloadMap = new Map<string, WorkloadItem>()
|
||||
if (workload) {
|
||||
for (const w of (workload as unknown as WorkloadItem[])) {
|
||||
workloadMap.set(w.id, w)
|
||||
}
|
||||
}
|
||||
|
||||
const jurors = (consistency as { overallAverage: number; jurors: ConsistencyJuror[] } | undefined)?.jurors ?? []
|
||||
const data = jurors.map((j) => {
|
||||
const w = workloadMap.get(j.userId)
|
||||
return {
|
||||
name: j.name,
|
||||
assigned: w?.assigned ?? '-',
|
||||
completed: w?.completed ?? '-',
|
||||
completionRate: w ? `${w.completionRate}%` : '-',
|
||||
avgScore: j.averageScore,
|
||||
stddev: j.stddev,
|
||||
isOutlier: j.isOutlier ? 'Yes' : 'No',
|
||||
}
|
||||
})
|
||||
|
||||
const result = { data, columns }
|
||||
setCsvData(result)
|
||||
setCsvLoading(false)
|
||||
return result
|
||||
}, [workload, consistency])
|
||||
|
||||
const isLoading = workloadLoading || consistencyLoading
|
||||
|
||||
type JurorRow = {
|
||||
userId: string
|
||||
name: string
|
||||
assigned: number
|
||||
completed: number
|
||||
completionRate: number
|
||||
averageScore: number
|
||||
stddev: number
|
||||
isOutlier: boolean
|
||||
projects: { id: string; title: string; evalStatus: string }[]
|
||||
}
|
||||
|
||||
const jurors: JurorRow[] = (() => {
|
||||
if (!consistency) return []
|
||||
const workloadMap = new Map<string, WorkloadItem>()
|
||||
if (workload) {
|
||||
for (const w of (workload as unknown as WorkloadItem[])) {
|
||||
workloadMap.set(w.id, w)
|
||||
}
|
||||
}
|
||||
const jurorList = (consistency as { overallAverage: number; jurors: ConsistencyJuror[] }).jurors ?? []
|
||||
return jurorList
|
||||
.map((j) => {
|
||||
const w = workloadMap.get(j.userId)
|
||||
return {
|
||||
userId: j.userId,
|
||||
name: j.name,
|
||||
assigned: w?.assigned ?? 0,
|
||||
completed: w?.completed ?? 0,
|
||||
completionRate: w?.completionRate ?? 0,
|
||||
averageScore: j.averageScore,
|
||||
stddev: j.stddev,
|
||||
isOutlier: j.isOutlier,
|
||||
projects: w?.projects ?? [],
|
||||
}
|
||||
})
|
||||
.sort((a, b) => b.assigned - a.assigned)
|
||||
})()
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between flex-wrap gap-3">
|
||||
<div>
|
||||
<h2 className="text-base font-semibold">Juror Performance</h2>
|
||||
<p className="text-sm text-muted-foreground">Workload and scoring consistency per juror</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setCsvOpen(true)}
|
||||
disabled={!hasSelection || isLoading}
|
||||
>
|
||||
<Download className="mr-2 h-4 w-4" />
|
||||
Export CSV
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<CsvExportDialog
|
||||
open={csvOpen}
|
||||
onOpenChange={setCsvOpen}
|
||||
exportData={csvData}
|
||||
isLoading={csvLoading}
|
||||
filename="juror-performance"
|
||||
onRequestData={handleRequestCsvData}
|
||||
/>
|
||||
|
||||
{/* Expandable Juror Table */}
|
||||
{isLoading ? (
|
||||
<Skeleton className="h-[400px]" />
|
||||
) : jurors.length > 0 ? (
|
||||
<ExpandableJurorTable jurors={jurors} />
|
||||
) : hasSelection ? (
|
||||
<Card>
|
||||
<CardContent className="flex items-center justify-center py-12">
|
||||
<p className="text-sm text-muted-foreground">No juror data available for this selection</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
{/* Juror Score Heatmap */}
|
||||
{heatmapLoading ? (
|
||||
<Skeleton className="h-[400px]" />
|
||||
) : heatmapData ? (
|
||||
<JurorScoreHeatmap
|
||||
jurors={heatmapData.jurors}
|
||||
projects={heatmapData.projects}
|
||||
cells={heatmapData.cells}
|
||||
truncated={heatmapData.truncated}
|
||||
totalProjects={heatmapData.totalProjects}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{/* Juror Consistency Chart */}
|
||||
{consistencyLoading ? (
|
||||
<Skeleton className="h-[400px]" />
|
||||
) : consistency ? (
|
||||
<JurorConsistencyChart
|
||||
data={consistency as { overallAverage: number; jurors: Array<{ userId: string; name: string; evaluationCount: number; averageScore: number; stddev: number; deviationFromOverall: number; isOutlier: boolean }> }}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ---- Scores sub-tab ----
|
||||
|
||||
function ScoresSubTab({ selectedValue, programId }: { selectedValue: string | null; programId: string }) {
|
||||
const queryInput = parseSelection(selectedValue)
|
||||
const hasSelection = !!queryInput.roundId || !!queryInput.programId
|
||||
|
||||
const { data: scoreDistribution, isLoading: scoreLoading } =
|
||||
trpc.analytics.getScoreDistribution.useQuery(queryInput, { enabled: hasSelection })
|
||||
|
||||
const { data: statusBreakdown, isLoading: statusLoading } =
|
||||
trpc.analytics.getStatusBreakdown.useQuery(queryInput, { enabled: hasSelection })
|
||||
|
||||
const { data: criteriaScores, isLoading: criteriaLoading } =
|
||||
trpc.analytics.getCriteriaScores.useQuery(queryInput, { enabled: hasSelection })
|
||||
|
||||
const geoProgramId = queryInput.programId || programId
|
||||
const { data: geoData, isLoading: geoLoading } =
|
||||
trpc.analytics.getGeographicDistribution.useQuery(
|
||||
{ programId: geoProgramId, roundId: queryInput.roundId },
|
||||
{ enabled: !!geoProgramId }
|
||||
)
|
||||
|
||||
const [csvOpen, setCsvOpen] = useState(false)
|
||||
const [csvData, setCsvData] = useState<{ data: Record<string, unknown>[]; columns: string[] } | undefined>()
|
||||
const [csvLoading, setCsvLoading] = useState(false)
|
||||
|
||||
type CriterionItem = { criterionName: string; averageScore: number; count: number }
|
||||
|
||||
const handleRequestCsvData = useCallback(async () => {
|
||||
setCsvLoading(true)
|
||||
const columns = ['criterionName', 'averageScore', 'count']
|
||||
const data = ((criteriaScores as CriterionItem[] | undefined) ?? []).map((c) => ({
|
||||
criterionName: c.criterionName,
|
||||
averageScore: c.averageScore,
|
||||
count: c.count,
|
||||
}))
|
||||
const result = { data, columns }
|
||||
setCsvData(result)
|
||||
setCsvLoading(false)
|
||||
return result
|
||||
}, [criteriaScores])
|
||||
|
||||
const countryChartData = (() => {
|
||||
if (!geoData?.length) return []
|
||||
const sorted = [...geoData].sort((a, b) => b.count - a.count)
|
||||
return sorted.slice(0, 15).map((d) => {
|
||||
let name = d.countryCode
|
||||
try {
|
||||
const displayNames = new Intl.DisplayNames(['en'], { type: 'region' })
|
||||
name = displayNames.of(d.countryCode.toUpperCase()) || d.countryCode
|
||||
} catch { /* keep code */ }
|
||||
return { country: name, Projects: d.count }
|
||||
})
|
||||
})()
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between flex-wrap gap-3">
|
||||
<div>
|
||||
<h2 className="text-base font-semibold">Scores & Analytics</h2>
|
||||
<p className="text-sm text-muted-foreground">Score distributions, criteria breakdown and geographic data</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setCsvOpen(true)}
|
||||
disabled={!hasSelection || criteriaLoading}
|
||||
>
|
||||
<Download className="mr-2 h-4 w-4" />
|
||||
Export CSV
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<CsvExportDialog
|
||||
open={csvOpen}
|
||||
onOpenChange={setCsvOpen}
|
||||
exportData={csvData}
|
||||
isLoading={csvLoading}
|
||||
filename="scores-criteria"
|
||||
onRequestData={handleRequestCsvData}
|
||||
/>
|
||||
|
||||
{/* Score Distribution & Status Breakdown */}
|
||||
<div className="grid gap-6 lg:grid-cols-2">
|
||||
{scoreLoading ? (
|
||||
<Skeleton className="h-[350px]" />
|
||||
) : scoreDistribution ? (
|
||||
<ScoreDistributionChart
|
||||
data={scoreDistribution.distribution ?? []}
|
||||
averageScore={scoreDistribution.averageScore ?? 0}
|
||||
totalScores={scoreDistribution.totalScores ?? 0}
|
||||
/>
|
||||
) : hasSelection ? (
|
||||
<Card>
|
||||
<CardContent className="flex items-center justify-center py-12">
|
||||
<p className="text-sm text-muted-foreground">No score data available yet</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
{statusLoading ? (
|
||||
<Skeleton className="h-[350px]" />
|
||||
) : statusBreakdown ? (
|
||||
<StatusBreakdownChart data={statusBreakdown} />
|
||||
) : hasSelection ? (
|
||||
<Card>
|
||||
<CardContent className="flex items-center justify-center py-12">
|
||||
<p className="text-sm text-muted-foreground">No status data available yet</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{/* Criteria Breakdown */}
|
||||
{criteriaLoading ? (
|
||||
<Skeleton className="h-[350px]" />
|
||||
) : criteriaScores?.length ? (
|
||||
<CriteriaScoresChart data={criteriaScores} />
|
||||
) : hasSelection ? (
|
||||
<Card>
|
||||
<CardContent className="flex items-center justify-center py-12">
|
||||
<p className="text-sm text-muted-foreground">No criteria score data available yet</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
{/* Country Distribution */}
|
||||
{geoLoading ? (
|
||||
<Skeleton className="h-[400px]" />
|
||||
) : countryChartData.length > 0 ? (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center justify-between">
|
||||
<span>Top Countries</span>
|
||||
<span className="text-sm font-normal text-muted-foreground">
|
||||
{geoData?.length ?? 0} countries represented
|
||||
</span>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<BarChart
|
||||
data={countryChartData}
|
||||
index="country"
|
||||
categories={['Projects']}
|
||||
colors={['blue']}
|
||||
layout="vertical"
|
||||
yAxisWidth={140}
|
||||
showLegend={false}
|
||||
className="h-[400px]"
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ---- Main component ----
|
||||
|
||||
export function EvaluationReportTabs({ roundId, programId, stages, selectedValue }: EvaluationReportTabsProps) {
|
||||
const selectedRound = stages.find((s) => s.id === selectedValue)
|
||||
const stagesLoading = false // stages passed from parent already loaded
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<RoundTypeStatsCards roundId={roundId} />
|
||||
|
||||
<Tabs defaultValue="progress" className="space-y-6">
|
||||
<TabsList>
|
||||
<TabsTrigger value="progress" className="gap-2">
|
||||
<TrendingUp className="h-4 w-4" />
|
||||
Progress
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="jurors" className="gap-2">
|
||||
<Users className="h-4 w-4" />
|
||||
Jurors
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="scores" className="gap-2">
|
||||
<BarChart3 className="h-4 w-4" />
|
||||
Scores
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="progress">
|
||||
<ProgressSubTab
|
||||
selectedValue={selectedValue}
|
||||
stages={stages}
|
||||
stagesLoading={stagesLoading}
|
||||
selectedRound={selectedRound}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="jurors">
|
||||
<JurorsSubTab roundId={roundId} selectedValue={selectedValue} />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="scores">
|
||||
<ScoresSubTab selectedValue={selectedValue} programId={programId} />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
269
src/components/observer/reports/expandable-juror-table.tsx
Normal file
269
src/components/observer/reports/expandable-juror-table.tsx
Normal file
@@ -0,0 +1,269 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { Card, CardContent } from '@/components/ui/card'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Progress } from '@/components/ui/progress'
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table'
|
||||
import { ChevronDown, ChevronUp } from 'lucide-react'
|
||||
import { scoreGradient } from '@/components/charts/chart-theme'
|
||||
import { ProjectPreviewDialog } from './project-preview-dialog'
|
||||
|
||||
interface JurorRow {
|
||||
userId: string
|
||||
name: string
|
||||
assigned: number
|
||||
completed: number
|
||||
completionRate: number
|
||||
averageScore: number
|
||||
stddev: number
|
||||
isOutlier: boolean
|
||||
projects: { id: string; title: string; evalStatus: string; score?: number | null }[]
|
||||
}
|
||||
|
||||
interface ExpandableJurorTableProps {
|
||||
jurors: JurorRow[]
|
||||
}
|
||||
|
||||
function evalStatusBadge(status: string) {
|
||||
switch (status) {
|
||||
case 'REVIEWED':
|
||||
return <Badge variant="default">Reviewed</Badge>
|
||||
case 'UNDER_REVIEW':
|
||||
return <Badge variant="secondary">Under Review</Badge>
|
||||
default:
|
||||
return <Badge variant="outline">Not Reviewed</Badge>
|
||||
}
|
||||
}
|
||||
|
||||
function ScorePill({ score }: { score: number }) {
|
||||
const bg = scoreGradient(score)
|
||||
const text = score >= 6 ? '#ffffff' : '#1a1a1a'
|
||||
return (
|
||||
<span
|
||||
className="inline-flex items-center justify-center rounded-md px-2 py-0.5 text-xs font-semibold tabular-nums min-w-[36px]"
|
||||
style={{ backgroundColor: bg, color: text }}
|
||||
>
|
||||
{score.toFixed(1)}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
export function ExpandableJurorTable({ jurors }: ExpandableJurorTableProps) {
|
||||
const [expanded, setExpanded] = useState<string | null>(null)
|
||||
const [previewProjectId, setPreviewProjectId] = useState<string | null>(null)
|
||||
|
||||
function toggle(userId: string) {
|
||||
setExpanded((prev) => (prev === userId ? null : userId))
|
||||
}
|
||||
|
||||
function openPreview(projectId: string, e: React.MouseEvent) {
|
||||
e.stopPropagation()
|
||||
setPreviewProjectId(projectId)
|
||||
}
|
||||
|
||||
if (jurors.length === 0) {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="flex items-center justify-center py-12">
|
||||
<p className="text-sm text-muted-foreground">No juror data available</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Desktop table */}
|
||||
<div className="hidden md:block rounded-md border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Juror</TableHead>
|
||||
<TableHead className="text-right tabular-nums">Assigned</TableHead>
|
||||
<TableHead className="text-right tabular-nums">Completed</TableHead>
|
||||
<TableHead>Rate</TableHead>
|
||||
<TableHead className="text-right tabular-nums">Avg Score</TableHead>
|
||||
<TableHead className="text-right tabular-nums">Std Dev</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead className="w-8" />
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{jurors.map((j) => (
|
||||
<>
|
||||
<TableRow
|
||||
key={j.userId}
|
||||
className="cursor-pointer hover:bg-muted/50"
|
||||
onClick={() => toggle(j.userId)}
|
||||
>
|
||||
<TableCell className="font-medium">{j.name}</TableCell>
|
||||
<TableCell className="text-right tabular-nums">{j.assigned}</TableCell>
|
||||
<TableCell className="text-right tabular-nums">{j.completed}</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-2">
|
||||
<Progress value={j.completionRate} className="w-20 h-2" />
|
||||
<span className="text-xs tabular-nums text-muted-foreground">
|
||||
{j.completionRate.toFixed(0)}%
|
||||
</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-right tabular-nums">
|
||||
{j.averageScore > 0 ? j.averageScore.toFixed(2) : '—'}
|
||||
</TableCell>
|
||||
<TableCell className="text-right tabular-nums">
|
||||
{j.stddev > 0 ? j.stddev.toFixed(2) : '—'}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{j.isOutlier ? (
|
||||
<Badge variant="destructive">Outlier</Badge>
|
||||
) : (
|
||||
<Badge variant="outline">Normal</Badge>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{expanded === j.userId ? (
|
||||
<ChevronUp className="h-4 w-4 text-muted-foreground" />
|
||||
) : (
|
||||
<ChevronDown className="h-4 w-4 text-muted-foreground" />
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
{expanded === j.userId && (
|
||||
<TableRow key={`${j.userId}-expanded`}>
|
||||
<TableCell colSpan={8} className="bg-muted/30 p-0">
|
||||
<div className="px-6 py-3">
|
||||
{j.projects.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">No projects</p>
|
||||
) : (
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="text-left text-xs text-muted-foreground border-b">
|
||||
<th className="pb-2 font-medium">Project</th>
|
||||
<th className="pb-2 font-medium text-center">Score</th>
|
||||
<th className="pb-2 font-medium text-right">Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{j.projects.map((p) => (
|
||||
<tr key={p.id} className="border-b last:border-0">
|
||||
<td className="py-2 pr-4">
|
||||
<button
|
||||
className="text-primary hover:underline text-left"
|
||||
onClick={(e) => openPreview(p.id, e)}
|
||||
>
|
||||
{p.title}
|
||||
</button>
|
||||
</td>
|
||||
<td className="py-2 text-center">
|
||||
{p.score != null ? (
|
||||
<ScorePill score={p.score} />
|
||||
) : (
|
||||
<span className="text-xs text-muted-foreground">—</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="py-2 text-right">{evalStatusBadge(p.evalStatus)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
{/* Mobile card stack */}
|
||||
<div className="space-y-3 md:hidden">
|
||||
{jurors.map((j) => (
|
||||
<Card key={j.userId}>
|
||||
<CardContent className="p-4">
|
||||
<button
|
||||
className="w-full text-left"
|
||||
onClick={() => toggle(j.userId)}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="font-medium">{j.name}</p>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
{j.completed}/{j.assigned} completed
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{j.isOutlier && <Badge variant="destructive">Outlier</Badge>}
|
||||
{expanded === j.userId ? (
|
||||
<ChevronUp className="h-4 w-4 text-muted-foreground" />
|
||||
) : (
|
||||
<ChevronDown className="h-4 w-4 text-muted-foreground" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-2 flex items-center gap-2">
|
||||
<Progress value={j.completionRate} className="flex-1 h-1.5" />
|
||||
<span className="text-xs tabular-nums text-muted-foreground">
|
||||
{j.completionRate.toFixed(0)}%
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-2 grid grid-cols-2 gap-2 text-xs">
|
||||
<div>
|
||||
<span className="text-muted-foreground">Avg Score: </span>
|
||||
<span className="tabular-nums font-medium">
|
||||
{j.averageScore > 0 ? j.averageScore.toFixed(2) : '—'}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground">Std Dev: </span>
|
||||
<span className="tabular-nums font-medium">
|
||||
{j.stddev > 0 ? j.stddev.toFixed(2) : '—'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{expanded === j.userId && j.projects.length > 0 && (
|
||||
<div className="mt-3 pt-3 border-t space-y-2">
|
||||
{j.projects.map((p) => (
|
||||
<div key={p.id} className="flex items-center justify-between gap-2">
|
||||
<button
|
||||
className="text-sm text-primary hover:underline truncate text-left"
|
||||
onClick={(e) => openPreview(p.id, e)}
|
||||
>
|
||||
{p.title}
|
||||
</button>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
{p.score != null && <ScorePill score={p.score} />}
|
||||
{evalStatusBadge(p.evalStatus)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{expanded === j.userId && j.projects.length === 0 && (
|
||||
<p className="mt-3 pt-3 border-t text-sm text-muted-foreground">No projects</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Project Preview Dialog */}
|
||||
<ProjectPreviewDialog
|
||||
projectId={previewProjectId}
|
||||
open={!!previewProjectId}
|
||||
onOpenChange={(open) => { if (!open) setPreviewProjectId(null) }}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
329
src/components/observer/reports/filtering-report-tabs.tsx
Normal file
329
src/components/observer/reports/filtering-report-tabs.tsx
Normal file
@@ -0,0 +1,329 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
import { Card, CardContent } from '@/components/ui/card'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { ChevronLeft, ChevronRight, ChevronDown, ChevronUp } from 'lucide-react'
|
||||
import { RoundTypeStatsCards } from '@/components/observer/round-type-stats'
|
||||
import { FilteringScreeningBar } from './filtering-screening-bar'
|
||||
import { ProjectPreviewDialog } from './project-preview-dialog'
|
||||
|
||||
interface FilteringReportTabsProps {
|
||||
roundId: string
|
||||
programId: string
|
||||
}
|
||||
|
||||
type OutcomeFilter = 'ALL' | 'PASSED' | 'FILTERED_OUT' | 'FLAGGED'
|
||||
|
||||
function outcomeBadge(outcome: string) {
|
||||
switch (outcome) {
|
||||
case 'PASSED':
|
||||
return <Badge className="bg-emerald-100 text-emerald-800 border-emerald-200">Passed</Badge>
|
||||
case 'FILTERED_OUT':
|
||||
return <Badge className="bg-rose-100 text-rose-800 border-rose-200">Filtered Out</Badge>
|
||||
case 'FLAGGED':
|
||||
return <Badge className="bg-amber-100 text-amber-800 border-amber-200">Flagged</Badge>
|
||||
default:
|
||||
return <Badge variant="outline">{outcome}</Badge>
|
||||
}
|
||||
}
|
||||
|
||||
/** Extract reasoning text from aiScreeningJson */
|
||||
function extractReasoning(aiScreeningJson: unknown): string | null {
|
||||
if (!aiScreeningJson || typeof aiScreeningJson !== 'object' || Array.isArray(aiScreeningJson)) {
|
||||
return null
|
||||
}
|
||||
const obj = aiScreeningJson as Record<string, unknown>
|
||||
// Direct reasoning field
|
||||
if (typeof obj.reasoning === 'string') return obj.reasoning
|
||||
// Nested under rule ID: { [ruleId]: { reasoning, confidence, ... } }
|
||||
for (const key of Object.keys(obj)) {
|
||||
const inner = obj[key]
|
||||
if (inner && typeof inner === 'object' && !Array.isArray(inner)) {
|
||||
const innerObj = inner as Record<string, unknown>
|
||||
if (typeof innerObj.reasoning === 'string') return innerObj.reasoning
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
export function FilteringReportTabs({ roundId }: FilteringReportTabsProps) {
|
||||
const [outcomeFilter, setOutcomeFilter] = useState<OutcomeFilter>('ALL')
|
||||
const [page, setPage] = useState(1)
|
||||
const [expandedId, setExpandedId] = useState<string | null>(null)
|
||||
const [previewProjectId, setPreviewProjectId] = useState<string | null>(null)
|
||||
const perPage = 20
|
||||
|
||||
const { data, isLoading } = trpc.analytics.getFilteringResults.useQuery({
|
||||
roundId,
|
||||
outcome: outcomeFilter === 'ALL' ? undefined : outcomeFilter,
|
||||
page,
|
||||
perPage,
|
||||
})
|
||||
|
||||
function handleOutcomeChange(value: string) {
|
||||
setOutcomeFilter(value as OutcomeFilter)
|
||||
setPage(1)
|
||||
}
|
||||
|
||||
function toggleExpand(id: string) {
|
||||
setExpandedId((prev) => (prev === id ? null : id))
|
||||
}
|
||||
|
||||
function openPreview(projectId: string, e: React.MouseEvent) {
|
||||
e.stopPropagation()
|
||||
setPreviewProjectId(projectId)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<RoundTypeStatsCards roundId={roundId} />
|
||||
<FilteringScreeningBar roundId={roundId} />
|
||||
|
||||
{/* Filter + count */}
|
||||
<div className="flex items-center gap-3">
|
||||
<Select value={outcomeFilter} onValueChange={handleOutcomeChange}>
|
||||
<SelectTrigger className="w-[180px]">
|
||||
<SelectValue placeholder="All Outcomes" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="ALL">All Outcomes</SelectItem>
|
||||
<SelectItem value="PASSED">Passed</SelectItem>
|
||||
<SelectItem value="FILTERED_OUT">Filtered Out</SelectItem>
|
||||
<SelectItem value="FLAGGED">Flagged</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{data && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{data.total} project{data.total !== 1 ? 's' : ''}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<Skeleton className="h-[400px]" />
|
||||
) : data?.results.length ? (
|
||||
<>
|
||||
{/* Desktop table */}
|
||||
<div className="hidden md:block rounded-md border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-8" />
|
||||
<TableHead>Project</TableHead>
|
||||
<TableHead>Team</TableHead>
|
||||
<TableHead>Category</TableHead>
|
||||
<TableHead>Country</TableHead>
|
||||
<TableHead>Outcome</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{data.results.map((r) => {
|
||||
const effectiveOutcome = r.finalOutcome ?? r.outcome
|
||||
const reasoning = extractReasoning(r.aiScreeningJson)
|
||||
const isExpanded = expandedId === r.id
|
||||
return (
|
||||
<>
|
||||
<TableRow
|
||||
key={r.id}
|
||||
className="cursor-pointer hover:bg-muted/50"
|
||||
onClick={() => toggleExpand(r.id)}
|
||||
>
|
||||
<TableCell className="w-8 pr-0">
|
||||
{isExpanded ? (
|
||||
<ChevronUp className="h-4 w-4 text-muted-foreground" />
|
||||
) : (
|
||||
<ChevronDown className="h-4 w-4 text-muted-foreground" />
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<button
|
||||
className="font-medium text-primary hover:underline text-left"
|
||||
onClick={(e) => openPreview(r.project.id, e)}
|
||||
>
|
||||
{r.project.title}
|
||||
</button>
|
||||
</TableCell>
|
||||
<TableCell className="text-muted-foreground">{r.project.teamName}</TableCell>
|
||||
<TableCell className="text-muted-foreground">
|
||||
{r.project.competitionCategory ?? '—'}
|
||||
</TableCell>
|
||||
<TableCell className="text-muted-foreground">
|
||||
{r.project.country ?? '—'}
|
||||
</TableCell>
|
||||
<TableCell>{outcomeBadge(effectiveOutcome)}</TableCell>
|
||||
</TableRow>
|
||||
{isExpanded && (
|
||||
<TableRow key={`${r.id}-detail`}>
|
||||
<TableCell colSpan={6} className="bg-muted/30 p-0">
|
||||
<div className="px-6 py-4 space-y-2">
|
||||
{reasoning ? (
|
||||
<div>
|
||||
<p className="text-xs font-medium text-muted-foreground mb-1">AI Reasoning</p>
|
||||
<p className="text-sm whitespace-pre-wrap leading-relaxed">{reasoning}</p>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground italic">No AI reasoning available</p>
|
||||
)}
|
||||
{r.overrideReason && (
|
||||
<div className="mt-2">
|
||||
<p className="text-xs font-medium text-amber-700 mb-1">Override Reason</p>
|
||||
<p className="text-sm rounded-md bg-amber-50 border border-amber-200 p-2">
|
||||
{r.overrideReason}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{r.project.awardEligibilities.length > 0 && (
|
||||
<div className="mt-2">
|
||||
<p className="text-xs font-medium text-muted-foreground mb-1">Award Routing</p>
|
||||
<div className="flex gap-1.5">
|
||||
{r.project.awardEligibilities.map((ae, i) => (
|
||||
<Badge key={i} variant="secondary">{ae.award.name}</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
{/* Mobile card stack */}
|
||||
<div className="space-y-3 md:hidden">
|
||||
{data.results.map((r) => {
|
||||
const effectiveOutcome = r.finalOutcome ?? r.outcome
|
||||
const reasoning = extractReasoning(r.aiScreeningJson)
|
||||
const isExpanded = expandedId === r.id
|
||||
return (
|
||||
<Card key={r.id}>
|
||||
<CardContent className="p-4">
|
||||
<button
|
||||
className="w-full text-left"
|
||||
onClick={() => toggleExpand(r.id)}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="min-w-0">
|
||||
<button
|
||||
className="font-medium text-sm text-primary hover:underline text-left truncate block max-w-full"
|
||||
onClick={(e) => openPreview(r.project.id, e)}
|
||||
>
|
||||
{r.project.title}
|
||||
</button>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">{r.project.teamName}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
{outcomeBadge(effectiveOutcome)}
|
||||
{isExpanded ? (
|
||||
<ChevronUp className="h-4 w-4 text-muted-foreground" />
|
||||
) : (
|
||||
<ChevronDown className="h-4 w-4 text-muted-foreground" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-3 text-xs text-muted-foreground mt-1">
|
||||
{r.project.competitionCategory && <span>{r.project.competitionCategory}</span>}
|
||||
{r.project.country && <span>{r.project.country}</span>}
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{isExpanded && (
|
||||
<div className="mt-3 pt-3 border-t space-y-2">
|
||||
{reasoning ? (
|
||||
<div>
|
||||
<p className="text-xs font-medium text-muted-foreground mb-1">AI Reasoning</p>
|
||||
<p className="text-sm whitespace-pre-wrap leading-relaxed">{reasoning}</p>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground italic">No AI reasoning available</p>
|
||||
)}
|
||||
{r.overrideReason && (
|
||||
<div>
|
||||
<p className="text-xs font-medium text-amber-700 mb-1">Override Reason</p>
|
||||
<p className="text-sm rounded-md bg-amber-50 border border-amber-200 p-2">
|
||||
{r.overrideReason}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Pagination */}
|
||||
{data.totalPages > 1 && (
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
||||
disabled={page === 1}
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
{Array.from({ length: Math.min(data.totalPages, 7) }, (_, i) => {
|
||||
const pageNum = i + 1
|
||||
return (
|
||||
<Button
|
||||
key={pageNum}
|
||||
variant={page === pageNum ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
onClick={() => setPage(pageNum)}
|
||||
>
|
||||
{pageNum}
|
||||
</Button>
|
||||
)
|
||||
})}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setPage((p) => Math.min(data.totalPages, p + 1))}
|
||||
disabled={page === data.totalPages}
|
||||
>
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<Card>
|
||||
<CardContent className="flex items-center justify-center py-12">
|
||||
<p className="text-sm text-muted-foreground">No filtering results found</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<ProjectPreviewDialog
|
||||
projectId={previewProjectId}
|
||||
open={!!previewProjectId}
|
||||
onOpenChange={(open) => { if (!open) setPreviewProjectId(null) }}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
115
src/components/observer/reports/filtering-screening-bar.tsx
Normal file
115
src/components/observer/reports/filtering-screening-bar.tsx
Normal file
@@ -0,0 +1,115 @@
|
||||
'use client'
|
||||
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const SEGMENTS = [
|
||||
{ key: 'passed' as const, label: 'Passed', color: '#2d8659', bg: '#2d865915' },
|
||||
{ key: 'filteredOut' as const, label: 'Filtered Out', color: '#de0f1e', bg: '#de0f1e15' },
|
||||
{ key: 'flagged' as const, label: 'Flagged', color: '#d97706', bg: '#d9770615' },
|
||||
]
|
||||
|
||||
interface FilteringScreeningBarProps {
|
||||
roundId: string
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function FilteringScreeningBar({ roundId, className }: FilteringScreeningBarProps) {
|
||||
const { data, isLoading } = trpc.analytics.getFilteringResultStats.useQuery(
|
||||
{ roundId },
|
||||
{ enabled: !!roundId }
|
||||
)
|
||||
|
||||
return (
|
||||
<Card className={cn(className)}>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Screening Results</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{isLoading ? (
|
||||
<>
|
||||
<Skeleton className="h-5 w-full rounded-full" />
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Skeleton className="h-8 w-28 rounded-lg" />
|
||||
<Skeleton className="h-8 w-32 rounded-lg" />
|
||||
<Skeleton className="h-8 w-24 rounded-lg" />
|
||||
</div>
|
||||
</>
|
||||
) : !data || data.total === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">No screening data available.</p>
|
||||
) : (
|
||||
<>
|
||||
{/* Segmented bar */}
|
||||
<div className="flex h-5 w-full overflow-hidden rounded-full bg-muted">
|
||||
{SEGMENTS.map(({ key, color }) => {
|
||||
const pct = (data[key] / data.total) * 100
|
||||
if (pct === 0) return null
|
||||
return (
|
||||
<div
|
||||
key={key}
|
||||
title={`${data[key]} (${Math.round(pct)}%)`}
|
||||
style={{ width: `${pct}%`, backgroundColor: color, minWidth: '4px' }}
|
||||
className="transition-all duration-500 first:rounded-l-full last:rounded-r-full"
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Stat pills */}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{SEGMENTS.map(({ key, label, color, bg }) => {
|
||||
const count = data[key]
|
||||
const pct = Math.round((count / data.total) * 100)
|
||||
return (
|
||||
<div
|
||||
key={key}
|
||||
className="flex items-center gap-1.5 rounded-lg px-3 py-1.5 text-sm font-medium"
|
||||
style={{ backgroundColor: bg, color }}
|
||||
>
|
||||
<span
|
||||
className="inline-block h-2 w-2 shrink-0 rounded-full"
|
||||
style={{ backgroundColor: color }}
|
||||
/>
|
||||
<span>{label}</span>
|
||||
<span className="tabular-nums font-bold">{count}</span>
|
||||
<span className="font-normal opacity-70">({pct}%)</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
|
||||
{/* Total */}
|
||||
<div className="flex items-center gap-1.5 rounded-lg bg-muted px-3 py-1.5 text-sm font-medium text-muted-foreground">
|
||||
<span>Total</span>
|
||||
<span className="tabular-nums font-bold text-foreground">{data.total}</span>
|
||||
</div>
|
||||
|
||||
{/* Overridden — only if any */}
|
||||
{data.overridden > 0 && (
|
||||
<div
|
||||
className="flex items-center gap-1.5 rounded-lg px-3 py-1.5 text-sm font-medium"
|
||||
style={{ backgroundColor: '#7c3aed15', color: '#7c3aed' }}
|
||||
>
|
||||
<span>Overridden</span>
|
||||
<span className="tabular-nums font-bold">{data.overridden}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Routed to awards — only if any */}
|
||||
{data.routedToAwards > 0 && (
|
||||
<div
|
||||
className="flex items-center gap-1.5 rounded-lg px-3 py-1.5 text-sm font-medium"
|
||||
style={{ backgroundColor: '#053d5715', color: '#053d57' }}
|
||||
>
|
||||
<span>Routed to Awards</span>
|
||||
<span className="tabular-nums font-bold">{data.routedToAwards}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
69
src/components/observer/reports/global-analytics-tab.tsx
Normal file
69
src/components/observer/reports/global-analytics-tab.tsx
Normal file
@@ -0,0 +1,69 @@
|
||||
'use client'
|
||||
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import {
|
||||
GeographicDistribution,
|
||||
StatusBreakdownChart,
|
||||
DiversityMetricsChart,
|
||||
CrossStageComparisonChart,
|
||||
} from '@/components/charts'
|
||||
|
||||
interface GlobalAnalyticsTabProps {
|
||||
programId: string
|
||||
roundIds?: string[]
|
||||
}
|
||||
|
||||
export function GlobalAnalyticsTab({ programId, roundIds }: GlobalAnalyticsTabProps) {
|
||||
const { data: geoData, isLoading: geoLoading } =
|
||||
trpc.analytics.getGeographicDistribution.useQuery({ programId })
|
||||
|
||||
const { data: diversity, isLoading: diversityLoading } =
|
||||
trpc.analytics.getDiversityMetrics.useQuery({ programId })
|
||||
|
||||
const { data: statusBreakdown, isLoading: statusLoading } =
|
||||
trpc.analytics.getStatusBreakdown.useQuery({ programId })
|
||||
|
||||
const { data: crossRound, isLoading: crossLoading } =
|
||||
trpc.analytics.getCrossRoundComparison.useQuery(
|
||||
{ roundIds: roundIds ?? [] },
|
||||
{ enabled: !!roundIds && roundIds.length >= 2 }
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Diversity Metrics — includes summary cards, category breakdown, ocean issues, tags */}
|
||||
{diversityLoading ? (
|
||||
<Skeleton className="h-[400px]" />
|
||||
) : diversity ? (
|
||||
<DiversityMetricsChart data={diversity} />
|
||||
) : null}
|
||||
|
||||
{/* Geographic Distribution — full-width map with top countries */}
|
||||
{geoLoading ? (
|
||||
<Skeleton className="h-[500px]" />
|
||||
) : geoData?.length ? (
|
||||
<GeographicDistribution data={geoData} />
|
||||
) : null}
|
||||
|
||||
{/* Project Status + Cross-Round Comparison */}
|
||||
<div className="grid gap-6 lg:grid-cols-2">
|
||||
{statusLoading ? (
|
||||
<Skeleton className="h-[350px]" />
|
||||
) : statusBreakdown ? (
|
||||
<StatusBreakdownChart data={statusBreakdown} />
|
||||
) : null}
|
||||
|
||||
{roundIds && roundIds.length >= 2 && (
|
||||
<>
|
||||
{crossLoading ? (
|
||||
<Skeleton className="h-[350px]" />
|
||||
) : crossRound ? (
|
||||
<CrossStageComparisonChart data={crossRound} />
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
37
src/components/observer/reports/intake-report-tabs.tsx
Normal file
37
src/components/observer/reports/intake-report-tabs.tsx
Normal file
@@ -0,0 +1,37 @@
|
||||
'use client'
|
||||
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { StatusBreakdownChart, DiversityMetricsChart } from '@/components/charts'
|
||||
import { RoundTypeStatsCards } from '@/components/observer/round-type-stats'
|
||||
|
||||
interface IntakeReportTabsProps {
|
||||
roundId: string
|
||||
programId: string
|
||||
}
|
||||
|
||||
export function IntakeReportTabs({ roundId, programId }: IntakeReportTabsProps) {
|
||||
const { data: statusBreakdown, isLoading: statusLoading } =
|
||||
trpc.analytics.getStatusBreakdown.useQuery({ roundId })
|
||||
|
||||
const { data: diversity, isLoading: diversityLoading } =
|
||||
trpc.analytics.getDiversityMetrics.useQuery({ roundId })
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<RoundTypeStatsCards roundId={roundId} />
|
||||
|
||||
{statusLoading ? (
|
||||
<Skeleton className="h-[350px]" />
|
||||
) : statusBreakdown ? (
|
||||
<StatusBreakdownChart data={statusBreakdown} />
|
||||
) : null}
|
||||
|
||||
{diversityLoading ? (
|
||||
<Skeleton className="h-[400px]" />
|
||||
) : diversity ? (
|
||||
<DiversityMetricsChart data={diversity} />
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
29
src/components/observer/reports/live-final-report-tabs.tsx
Normal file
29
src/components/observer/reports/live-final-report-tabs.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
'use client'
|
||||
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { StatusBreakdownChart } from '@/components/charts'
|
||||
import { RoundTypeStatsCards } from '@/components/observer/round-type-stats'
|
||||
|
||||
interface LiveFinalReportTabsProps {
|
||||
roundId: string
|
||||
programId: string
|
||||
}
|
||||
|
||||
function StatusBreakdownSection({ roundId }: { roundId: string }) {
|
||||
const { data: statusBreakdown, isLoading } =
|
||||
trpc.analytics.getStatusBreakdown.useQuery({ roundId })
|
||||
|
||||
if (isLoading) return <Skeleton className="h-[350px]" />
|
||||
if (!statusBreakdown) return null
|
||||
return <StatusBreakdownChart data={statusBreakdown} />
|
||||
}
|
||||
|
||||
export function LiveFinalReportTabs({ roundId }: LiveFinalReportTabsProps) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<RoundTypeStatsCards roundId={roundId} />
|
||||
<StatusBreakdownSection roundId={roundId} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
29
src/components/observer/reports/mentoring-report-tabs.tsx
Normal file
29
src/components/observer/reports/mentoring-report-tabs.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
'use client'
|
||||
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { StatusBreakdownChart } from '@/components/charts'
|
||||
import { RoundTypeStatsCards } from '@/components/observer/round-type-stats'
|
||||
|
||||
interface MentoringReportTabsProps {
|
||||
roundId: string
|
||||
programId: string
|
||||
}
|
||||
|
||||
function StatusBreakdownSection({ roundId }: { roundId: string }) {
|
||||
const { data: statusBreakdown, isLoading } =
|
||||
trpc.analytics.getStatusBreakdown.useQuery({ roundId })
|
||||
|
||||
if (isLoading) return <Skeleton className="h-[350px]" />
|
||||
if (!statusBreakdown) return null
|
||||
return <StatusBreakdownChart data={statusBreakdown} />
|
||||
}
|
||||
|
||||
export function MentoringReportTabs({ roundId }: MentoringReportTabsProps) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<RoundTypeStatsCards roundId={roundId} />
|
||||
<StatusBreakdownSection roundId={roundId} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
183
src/components/observer/reports/project-preview-dialog.tsx
Normal file
183
src/components/observer/reports/project-preview-dialog.tsx
Normal file
@@ -0,0 +1,183 @@
|
||||
'use client'
|
||||
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { Separator } from '@/components/ui/separator'
|
||||
import { StatusBadge } from '@/components/shared/status-badge'
|
||||
import { ExternalLink, MapPin, Waves, Users } from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
import type { Route } from 'next'
|
||||
import { scoreGradient } from '@/components/charts/chart-theme'
|
||||
|
||||
interface ProjectPreviewDialogProps {
|
||||
projectId: string | null
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
}
|
||||
|
||||
function ScorePill({ score }: { score: number }) {
|
||||
const bg = scoreGradient(score)
|
||||
const text = score >= 6 ? '#ffffff' : '#1a1a1a'
|
||||
return (
|
||||
<span
|
||||
className="inline-flex items-center justify-center rounded-md px-2.5 py-1 text-sm font-bold tabular-nums"
|
||||
style={{ backgroundColor: bg, color: text }}
|
||||
>
|
||||
{score.toFixed(1)}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
export function ProjectPreviewDialog({ projectId, open, onOpenChange }: ProjectPreviewDialogProps) {
|
||||
const { data, isLoading } = trpc.analytics.getProjectDetail.useQuery(
|
||||
{ id: projectId! },
|
||||
{ enabled: !!projectId && open },
|
||||
)
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-2xl max-h-[85vh] overflow-y-auto">
|
||||
{isLoading || !data ? (
|
||||
<>
|
||||
<DialogHeader>
|
||||
<Skeleton className="h-6 w-48" />
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
<Skeleton className="h-4 w-32" />
|
||||
<Skeleton className="h-20 w-full" />
|
||||
<Skeleton className="h-32 w-full" />
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-lg leading-tight pr-8">
|
||||
{data.project.title}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
{/* Project info row */}
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<StatusBadge status={data.project.status} />
|
||||
{data.project.teamName && (
|
||||
<Badge variant="outline" className="gap-1">
|
||||
<Users className="h-3 w-3" />
|
||||
{data.project.teamName}
|
||||
</Badge>
|
||||
)}
|
||||
{data.project.country && (
|
||||
<Badge variant="outline" className="gap-1">
|
||||
<MapPin className="h-3 w-3" />
|
||||
{data.project.country}
|
||||
</Badge>
|
||||
)}
|
||||
{data.project.competitionCategory && (
|
||||
<Badge variant="secondary">{data.project.competitionCategory}</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
{data.project.description && (
|
||||
<p className="text-sm text-muted-foreground line-clamp-4">
|
||||
{data.project.description}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Ocean Issue */}
|
||||
{data.project.oceanIssue && (
|
||||
<Badge variant="outline" className="gap-1 text-xs">
|
||||
<Waves className="h-3 w-3" />
|
||||
{data.project.oceanIssue.replace(/_/g, ' ').toLowerCase().replace(/\b\w/g, (c: string) => c.toUpperCase())}
|
||||
</Badge>
|
||||
)}
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Evaluation summary */}
|
||||
{data.stats && (
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold mb-2">Evaluation Summary</h3>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
|
||||
<div className="rounded-md border p-3 text-center">
|
||||
<p className="text-lg font-bold tabular-nums">
|
||||
{data.stats.averageGlobalScore != null ? (
|
||||
<ScorePill score={data.stats.averageGlobalScore} />
|
||||
) : '—'}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">Avg Score</p>
|
||||
</div>
|
||||
<div className="rounded-md border p-3 text-center">
|
||||
<p className="text-lg font-bold tabular-nums">{data.stats.totalEvaluations ?? 0}</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">Evaluations</p>
|
||||
</div>
|
||||
<div className="rounded-md border p-3 text-center">
|
||||
<p className="text-lg font-bold tabular-nums">{data.assignments?.length ?? 0}</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">Assignments</p>
|
||||
</div>
|
||||
<div className="rounded-md border p-3 text-center">
|
||||
<p className="text-lg font-bold tabular-nums">
|
||||
{data.stats.yesPercentage != null ? `${Math.round(data.stats.yesPercentage)}%` : '—'}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">Recommend</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Individual evaluations */}
|
||||
{data.assignments?.length > 0 && (
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold mb-2">Juror Evaluations</h3>
|
||||
<div className="space-y-1.5">
|
||||
{data.assignments.map((a: { id: string; user: { name: string | null }; evaluation: { status: string; globalScore: unknown } | null }) => {
|
||||
const ev = a.evaluation
|
||||
const score = ev?.status === 'SUBMITTED' && ev.globalScore != null
|
||||
? Number(ev.globalScore)
|
||||
: null
|
||||
return (
|
||||
<div key={a.id} className="flex items-center justify-between rounded-md border px-3 py-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium">{a.user.name ?? 'Unknown'}</span>
|
||||
{ev?.status === 'SUBMITTED' ? (
|
||||
<Badge variant="default" className="text-[10px]">Reviewed</Badge>
|
||||
) : ev?.status === 'DRAFT' ? (
|
||||
<Badge variant="secondary" className="text-[10px]">Draft</Badge>
|
||||
) : (
|
||||
<Badge variant="outline" className="text-[10px]">Pending</Badge>
|
||||
)}
|
||||
</div>
|
||||
{score !== null && <ScorePill score={score} />}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* View full project button */}
|
||||
<div className="flex justify-end">
|
||||
<Button asChild>
|
||||
<Link href={`/observer/projects/${projectId}` as Route}>
|
||||
<ExternalLink className="mr-2 h-4 w-4" />
|
||||
View Full Project
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
29
src/components/observer/reports/submission-report-tabs.tsx
Normal file
29
src/components/observer/reports/submission-report-tabs.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
'use client'
|
||||
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { StatusBreakdownChart } from '@/components/charts'
|
||||
import { RoundTypeStatsCards } from '@/components/observer/round-type-stats'
|
||||
|
||||
interface SubmissionReportTabsProps {
|
||||
roundId: string
|
||||
programId: string
|
||||
}
|
||||
|
||||
function StatusBreakdownSection({ roundId }: { roundId: string }) {
|
||||
const { data: statusBreakdown, isLoading } =
|
||||
trpc.analytics.getStatusBreakdown.useQuery({ roundId })
|
||||
|
||||
if (isLoading) return <Skeleton className="h-[350px]" />
|
||||
if (!statusBreakdown) return null
|
||||
return <StatusBreakdownChart data={statusBreakdown} />
|
||||
}
|
||||
|
||||
export function SubmissionReportTabs({ roundId }: SubmissionReportTabsProps) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<RoundTypeStatsCards roundId={roundId} />
|
||||
<StatusBreakdownSection roundId={roundId} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
158
src/components/observer/round-type-stats.tsx
Normal file
158
src/components/observer/round-type-stats.tsx
Normal file
@@ -0,0 +1,158 @@
|
||||
'use client'
|
||||
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
import { Card, CardContent } from '@/components/ui/card'
|
||||
import { AnimatedCard } from '@/components/shared/animated-container'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import {
|
||||
Inbox,
|
||||
Filter,
|
||||
ClipboardCheck,
|
||||
Upload,
|
||||
Users,
|
||||
Presentation,
|
||||
Vote,
|
||||
CheckCircle2,
|
||||
BarChart3,
|
||||
FileText,
|
||||
MessageSquare,
|
||||
Lock,
|
||||
} from 'lucide-react'
|
||||
import type { LucideIcon } from 'lucide-react'
|
||||
|
||||
interface StatCardData {
|
||||
label: string
|
||||
value: string | number
|
||||
icon: LucideIcon
|
||||
color: string
|
||||
}
|
||||
|
||||
function StatCard({ label, value, icon: Icon, color, index }: StatCardData & { index: number }) {
|
||||
return (
|
||||
<AnimatedCard index={index}>
|
||||
<Card className="relative overflow-hidden">
|
||||
<div className={`absolute left-0 top-0 bottom-0 w-1`} style={{ backgroundColor: color }} />
|
||||
<CardContent className="flex items-center gap-4 pt-6">
|
||||
<div className="rounded-lg p-2" style={{ backgroundColor: `${color}15` }}>
|
||||
<Icon className="h-5 w-5" style={{ color }} />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-2xl font-bold tabular-nums">{value}</p>
|
||||
<p className="text-sm text-muted-foreground">{label}</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</AnimatedCard>
|
||||
)
|
||||
}
|
||||
|
||||
interface RoundTypeStatsCardsProps {
|
||||
roundId: string
|
||||
}
|
||||
|
||||
export function RoundTypeStatsCards({ roundId }: RoundTypeStatsCardsProps) {
|
||||
const { data, isLoading } = trpc.analytics.getRoundTypeStats.useQuery(
|
||||
{ roundId },
|
||||
{ enabled: !!roundId }
|
||||
)
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
{[...Array(4)].map((_, i) => (
|
||||
<Card key={i}>
|
||||
<CardContent className="pt-6">
|
||||
<Skeleton className="h-8 w-16" />
|
||||
<Skeleton className="mt-2 h-4 w-24" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!data) return null
|
||||
|
||||
const stats = data.stats as Record<string, unknown>
|
||||
|
||||
const cards: StatCardData[] = (() => {
|
||||
switch (data.roundType) {
|
||||
case 'INTAKE':
|
||||
return [
|
||||
{ label: 'Total Projects', value: (stats.totalProjects as number) ?? 0, icon: Inbox, color: '#053d57' },
|
||||
{ label: 'States', value: ((stats.byState as Array<unknown>)?.length ?? 0), icon: BarChart3, color: '#557f8c' },
|
||||
{ label: 'Categories', value: ((stats.byCategory as Array<unknown>)?.length ?? 0), icon: Filter, color: '#1e7a8a' },
|
||||
]
|
||||
|
||||
case 'FILTERING':
|
||||
return [
|
||||
{ label: 'Total Screened', value: (stats.totalScreened as number) ?? 0, icon: Filter, color: '#053d57' },
|
||||
{ label: 'Passed', value: (stats.passed as number) ?? 0, icon: CheckCircle2, color: '#2d8659' },
|
||||
{ label: 'Filtered Out', value: (stats.filteredOut as number) ?? 0, icon: Filter, color: '#de0f1e' },
|
||||
{ label: 'Pass Rate', value: `${(stats.passRate as number) ?? 0}%`, icon: BarChart3, color: '#557f8c' },
|
||||
]
|
||||
|
||||
case 'EVALUATION':
|
||||
return [
|
||||
{ label: 'Assignments', value: (stats.totalAssignments as number) ?? 0, icon: ClipboardCheck, color: '#053d57' },
|
||||
{ label: 'Completed', value: (stats.completedEvaluations as number) ?? 0, icon: CheckCircle2, color: '#2d8659' },
|
||||
{ label: 'Completion Rate', value: `${(stats.completionRate as number) ?? 0}%`, icon: BarChart3, color: '#557f8c' },
|
||||
{ label: 'Active Jurors', value: (stats.activeJurors as number) ?? 0, icon: Users, color: '#1e7a8a' },
|
||||
]
|
||||
|
||||
case 'SUBMISSION':
|
||||
return [
|
||||
{ label: 'Total Files', value: (stats.totalFiles as number) ?? 0, icon: Upload, color: '#053d57' },
|
||||
{ label: 'Teams Submitted', value: (stats.teamsSubmitted as number) ?? 0, icon: FileText, color: '#557f8c' },
|
||||
]
|
||||
|
||||
case 'MENTORING':
|
||||
return [
|
||||
{ label: 'Mentor Assignments', value: (stats.mentorAssignments as number) ?? 0, icon: Users, color: '#053d57' },
|
||||
{ label: 'Total Messages', value: (stats.totalMessages as number) ?? 0, icon: MessageSquare, color: '#557f8c' },
|
||||
]
|
||||
|
||||
case 'LIVE_FINAL':
|
||||
return [
|
||||
{ label: 'Session Status', value: formatSessionStatus((stats.sessionStatus as string) ?? 'NOT_STARTED'), icon: Presentation, color: '#053d57' },
|
||||
{ label: 'Total Votes', value: (stats.voteCount as number) ?? 0, icon: Vote, color: '#de0f1e' },
|
||||
]
|
||||
|
||||
case 'DELIBERATION':
|
||||
return [
|
||||
{ label: 'Sessions', value: (stats.totalSessions as number) ?? 0, icon: Users, color: '#053d57' },
|
||||
{ label: 'Votes Cast', value: (stats.totalVotes as number) ?? 0, icon: Vote, color: '#557f8c' },
|
||||
{ label: 'Results Locked', value: (stats.resultsLocked as number) ?? 0, icon: Lock, color: '#2d8659' },
|
||||
]
|
||||
|
||||
default:
|
||||
return []
|
||||
}
|
||||
})()
|
||||
|
||||
if (cards.length === 0) return null
|
||||
|
||||
return (
|
||||
<div className={
|
||||
cards.length <= 2
|
||||
? 'grid gap-4 sm:grid-cols-2'
|
||||
: cards.length === 3
|
||||
? 'grid gap-4 sm:grid-cols-2 lg:grid-cols-3'
|
||||
: 'grid gap-4 sm:grid-cols-2 lg:grid-cols-4'
|
||||
}>
|
||||
{cards.map((card, i) => (
|
||||
<StatCard key={card.label} {...card} index={i} />
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function formatSessionStatus(status: string): string {
|
||||
switch (status) {
|
||||
case 'NOT_STARTED': return 'Not Started'
|
||||
case 'IN_PROGRESS': return 'In Progress'
|
||||
case 'PAUSED': return 'Paused'
|
||||
case 'COMPLETED': return 'Completed'
|
||||
default: return status
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { useForm } from 'react-hook-form'
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { z } from 'zod'
|
||||
@@ -36,6 +37,7 @@ const formSchema = z.object({
|
||||
ai_model: z.string(),
|
||||
ai_send_descriptions: z.boolean(),
|
||||
openai_api_key: z.string().optional(),
|
||||
anthropic_api_key: z.string().optional(),
|
||||
openai_base_url: z.string().optional(),
|
||||
})
|
||||
|
||||
@@ -48,6 +50,7 @@ interface AISettingsFormProps {
|
||||
ai_model?: string
|
||||
ai_send_descriptions?: string
|
||||
openai_api_key?: string
|
||||
anthropic_api_key?: string
|
||||
openai_base_url?: string
|
||||
}
|
||||
}
|
||||
@@ -63,12 +66,29 @@ export function AISettingsForm({ settings }: AISettingsFormProps) {
|
||||
ai_model: settings.ai_model || 'gpt-4o',
|
||||
ai_send_descriptions: settings.ai_send_descriptions === 'true',
|
||||
openai_api_key: '',
|
||||
anthropic_api_key: '',
|
||||
openai_base_url: settings.openai_base_url || '',
|
||||
},
|
||||
})
|
||||
|
||||
const watchProvider = form.watch('ai_provider')
|
||||
const isLiteLLM = watchProvider === 'litellm'
|
||||
const isAnthropic = watchProvider === 'anthropic'
|
||||
const prevProviderRef = useRef(settings.ai_provider || 'openai')
|
||||
|
||||
// Auto-reset model when provider changes
|
||||
useEffect(() => {
|
||||
if (watchProvider !== prevProviderRef.current) {
|
||||
prevProviderRef.current = watchProvider
|
||||
if (watchProvider === 'anthropic') {
|
||||
form.setValue('ai_model', 'claude-sonnet-4-5-20250514')
|
||||
} else if (watchProvider === 'openai') {
|
||||
form.setValue('ai_model', 'gpt-4o')
|
||||
} else if (watchProvider === 'litellm') {
|
||||
form.setValue('ai_model', '')
|
||||
}
|
||||
}
|
||||
}, [watchProvider, form])
|
||||
|
||||
// Fetch available models from OpenAI API (skip for LiteLLM — no models.list support)
|
||||
const {
|
||||
@@ -119,6 +139,9 @@ export function AISettingsForm({ settings }: AISettingsFormProps) {
|
||||
if (data.openai_api_key && data.openai_api_key.trim()) {
|
||||
settingsToUpdate.push({ key: 'openai_api_key', value: data.openai_api_key })
|
||||
}
|
||||
if (data.anthropic_api_key && data.anthropic_api_key.trim()) {
|
||||
settingsToUpdate.push({ key: 'anthropic_api_key', value: data.anthropic_api_key })
|
||||
}
|
||||
|
||||
// Save base URL (empty string clears it)
|
||||
settingsToUpdate.push({ key: 'openai_base_url', value: data.openai_base_url?.trim() || '' })
|
||||
@@ -139,6 +162,9 @@ export function AISettingsForm({ settings }: AISettingsFormProps) {
|
||||
)
|
||||
|
||||
const categoryLabels: Record<string, string> = {
|
||||
'claude-4.5': 'Claude 4.5 Series (Latest)',
|
||||
'claude-4': 'Claude 4 Series',
|
||||
'claude-3.5': 'Claude 3.5 Series',
|
||||
'gpt-5+': 'GPT-5+ Series (Latest)',
|
||||
'gpt-4o': 'GPT-4o Series',
|
||||
'gpt-4': 'GPT-4 Series',
|
||||
@@ -147,7 +173,7 @@ export function AISettingsForm({ settings }: AISettingsFormProps) {
|
||||
other: 'Other Models',
|
||||
}
|
||||
|
||||
const categoryOrder = ['gpt-5+', 'gpt-4o', 'gpt-4', 'gpt-3.5', 'reasoning', 'other']
|
||||
const categoryOrder = ['claude-4.5', 'claude-4', 'claude-3.5', 'gpt-5+', 'gpt-4o', 'gpt-4', 'gpt-3.5', 'reasoning', 'other']
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
@@ -187,13 +213,16 @@ export function AISettingsForm({ settings }: AISettingsFormProps) {
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
<SelectItem value="openai">OpenAI (API Key)</SelectItem>
|
||||
<SelectItem value="anthropic">Anthropic (Claude API)</SelectItem>
|
||||
<SelectItem value="litellm">LiteLLM Proxy (ChatGPT Subscription)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormDescription>
|
||||
{field.value === 'litellm'
|
||||
? 'Route AI calls through a LiteLLM proxy connected to your ChatGPT Plus/Pro subscription'
|
||||
: 'Direct OpenAI API access using your API key'}
|
||||
: field.value === 'anthropic'
|
||||
? 'Direct Anthropic API access using Claude models'
|
||||
: 'Direct OpenAI API access using your API key'}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
@@ -211,37 +240,71 @@ export function AISettingsForm({ settings }: AISettingsFormProps) {
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="openai_api_key"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{isLiteLLM ? 'API Key (Optional)' : 'API Key'}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="password"
|
||||
placeholder={isLiteLLM
|
||||
? 'Optional — leave blank for default'
|
||||
: (settings.openai_api_key ? '••••••••' : 'Enter API key')}
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{isLiteLLM
|
||||
? 'LiteLLM proxy usually does not require an API key. Leave blank to use default.'
|
||||
: 'Your OpenAI API key. Leave blank to keep the existing key.'}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
{isAnthropic && (
|
||||
<Alert>
|
||||
<Info className="h-4 w-4" />
|
||||
<AlertDescription>
|
||||
<strong>Anthropic Claude Mode</strong> — AI calls use the Anthropic Messages API.
|
||||
Claude Opus models include extended thinking for deeper analysis.
|
||||
JSON responses are validated with automatic retry.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{isAnthropic ? (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="anthropic_api_key"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Anthropic API Key</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="password"
|
||||
placeholder={settings.anthropic_api_key ? '••••••••' : 'Enter Anthropic API key'}
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Your Anthropic API key. Leave blank to keep the existing key.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
) : (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="openai_api_key"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{isLiteLLM ? 'API Key (Optional)' : 'OpenAI API Key'}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="password"
|
||||
placeholder={isLiteLLM
|
||||
? 'Optional — leave blank for default'
|
||||
: (settings.openai_api_key ? '••••••••' : 'Enter API key')}
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{isLiteLLM
|
||||
? 'LiteLLM proxy usually does not require an API key. Leave blank to use default.'
|
||||
: 'Your OpenAI API key. Leave blank to keep the existing key.'}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="openai_base_url"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{isLiteLLM ? 'LiteLLM Proxy URL' : 'API Base URL (Optional)'}</FormLabel>
|
||||
<FormLabel>{isLiteLLM ? 'LiteLLM Proxy URL' : isAnthropic ? 'Anthropic Base URL (Optional)' : 'API Base URL (Optional)'}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder={isLiteLLM ? 'http://localhost:4000' : 'https://api.openai.com/v1'}
|
||||
@@ -255,6 +318,10 @@ export function AISettingsForm({ settings }: AISettingsFormProps) {
|
||||
<code className="text-xs bg-muted px-1 rounded">http://localhost:4000</code>{' '}
|
||||
or your server address.
|
||||
</>
|
||||
) : isAnthropic ? (
|
||||
<>
|
||||
Custom base URL for Anthropic API proxy or gateway. Leave blank for default Anthropic API.
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
Custom base URL for OpenAI-compatible providers. Leave blank for OpenAI.
|
||||
@@ -288,7 +355,42 @@ export function AISettingsForm({ settings }: AISettingsFormProps) {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isLiteLLM || modelsData?.manualEntry ? (
|
||||
{isAnthropic ? (
|
||||
// Anthropic: fetch models from server (hardcoded list)
|
||||
modelsLoading ? (
|
||||
<Skeleton className="h-10 w-full" />
|
||||
) : modelsData?.success && modelsData.models && modelsData.models.length > 0 ? (
|
||||
<Select onValueChange={field.onChange} value={field.value}>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select Claude model" />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
{categoryOrder
|
||||
.filter((cat) => groupedModels?.[cat]?.length)
|
||||
.map((category) => (
|
||||
<SelectGroup key={category}>
|
||||
<SelectLabel className="text-xs font-semibold text-muted-foreground">
|
||||
{categoryLabels[category] || category}
|
||||
</SelectLabel>
|
||||
{groupedModels?.[category]?.map((model) => (
|
||||
<SelectItem key={model.id} value={model.id}>
|
||||
{model.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
) : (
|
||||
<Input
|
||||
value={field.value}
|
||||
onChange={(e) => field.onChange(e.target.value)}
|
||||
placeholder="claude-sonnet-4-5-20250514"
|
||||
/>
|
||||
)
|
||||
) : isLiteLLM || modelsData?.manualEntry ? (
|
||||
<Input
|
||||
value={field.value}
|
||||
onChange={(e) => field.onChange(e.target.value)}
|
||||
@@ -341,7 +443,16 @@ export function AISettingsForm({ settings }: AISettingsFormProps) {
|
||||
</Select>
|
||||
)}
|
||||
<FormDescription>
|
||||
{isLiteLLM ? (
|
||||
{isAnthropic ? (
|
||||
form.watch('ai_model')?.includes('opus') ? (
|
||||
<span className="flex items-center gap-1 text-amber-600">
|
||||
<SlidersHorizontal className="h-3 w-3" />
|
||||
Opus model — includes extended thinking for deeper analysis
|
||||
</span>
|
||||
) : (
|
||||
'Anthropic Claude model to use for AI features'
|
||||
)
|
||||
) : isLiteLLM ? (
|
||||
<>
|
||||
Enter the model ID with the{' '}
|
||||
<code className="text-xs bg-muted px-1 rounded">chatgpt/</code> prefix.
|
||||
|
||||
@@ -23,14 +23,15 @@ import {
|
||||
Newspaper,
|
||||
BarChart3,
|
||||
ShieldAlert,
|
||||
Globe,
|
||||
Webhook,
|
||||
MessageCircle,
|
||||
FlaskConical,
|
||||
} from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
import { AnimatedCard } from '@/components/shared/animated-container'
|
||||
import { AISettingsForm } from './ai-settings-form'
|
||||
import { AIUsageCard } from './ai-usage-card'
|
||||
import { TestEnvironmentPanel } from './test-environment-panel'
|
||||
import { BrandingSettingsForm } from './branding-settings-form'
|
||||
import { EmailSettingsForm } from './email-settings-form'
|
||||
import { StorageSettingsForm } from './storage-settings-form'
|
||||
@@ -158,11 +159,6 @@ export function SettingsContent({ initialSettings, isSuperAdmin = true }: Settin
|
||||
'whatsapp_provider',
|
||||
])
|
||||
|
||||
const localizationSettings = getSettingsByKeys([
|
||||
'localization_enabled_locales',
|
||||
'localization_default_locale',
|
||||
])
|
||||
|
||||
return (
|
||||
<>
|
||||
<Tabs defaultValue="defaults" className="space-y-6">
|
||||
@@ -176,10 +172,6 @@ export function SettingsContent({ initialSettings, isSuperAdmin = true }: Settin
|
||||
<Palette className="h-4 w-4" />
|
||||
Branding
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="localization" className="gap-2 shrink-0">
|
||||
<Globe className="h-4 w-4" />
|
||||
Locale
|
||||
</TabsTrigger>
|
||||
{isSuperAdmin && (
|
||||
<TabsTrigger value="email" className="gap-2 shrink-0">
|
||||
<Mail className="h-4 w-4" />
|
||||
@@ -236,6 +228,12 @@ export function SettingsContent({ initialSettings, isSuperAdmin = true }: Settin
|
||||
Webhooks
|
||||
</Link>
|
||||
)}
|
||||
{isSuperAdmin && (
|
||||
<TabsTrigger value="testenv" className="gap-2 shrink-0">
|
||||
<FlaskConical className="h-4 w-4" />
|
||||
Test Env
|
||||
</TabsTrigger>
|
||||
)}
|
||||
</TabsList>
|
||||
|
||||
<div className="lg:flex lg:gap-8">
|
||||
@@ -253,10 +251,6 @@ export function SettingsContent({ initialSettings, isSuperAdmin = true }: Settin
|
||||
<Palette className="h-4 w-4" />
|
||||
Branding
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="localization" className="justify-start gap-2 w-full px-3 py-2 h-auto data-[state=active]:bg-muted">
|
||||
<Globe className="h-4 w-4" />
|
||||
Locale
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
</div>
|
||||
<div>
|
||||
@@ -333,6 +327,12 @@ export function SettingsContent({ initialSettings, isSuperAdmin = true }: Settin
|
||||
Webhooks
|
||||
<ExternalLink className="ml-auto h-3 w-3 opacity-50" />
|
||||
</Link>
|
||||
<TabsList className="flex flex-col items-stretch h-auto w-full bg-transparent p-0 gap-0.5 mt-1">
|
||||
<TabsTrigger value="testenv" className="justify-start gap-2 w-full px-3 py-2 h-auto data-[state=active]:bg-muted">
|
||||
<FlaskConical className="h-4 w-4" />
|
||||
Test Env
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
</div>
|
||||
)}
|
||||
</nav>
|
||||
@@ -510,22 +510,6 @@ export function SettingsContent({ initialSettings, isSuperAdmin = true }: Settin
|
||||
</AnimatedCard>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="localization" className="space-y-6">
|
||||
<AnimatedCard>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Localization</CardTitle>
|
||||
<CardDescription>
|
||||
Configure language and locale settings
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<LocalizationSettingsSection settings={localizationSettings} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</AnimatedCard>
|
||||
</TabsContent>
|
||||
|
||||
{isSuperAdmin && (
|
||||
<TabsContent value="whatsapp" className="space-y-6">
|
||||
<AnimatedCard>
|
||||
@@ -543,6 +527,28 @@ export function SettingsContent({ initialSettings, isSuperAdmin = true }: Settin
|
||||
</AnimatedCard>
|
||||
</TabsContent>
|
||||
)}
|
||||
|
||||
{isSuperAdmin && (
|
||||
<TabsContent value="testenv" className="space-y-6">
|
||||
<AnimatedCard>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<FlaskConical className="h-5 w-5" />
|
||||
Test Environment
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Create a sandboxed test competition with dummy data for testing all roles and workflows.
|
||||
Fully isolated from production data.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<TestEnvironmentPanel />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</AnimatedCard>
|
||||
</TabsContent>
|
||||
)}
|
||||
</div>{/* end content area */}
|
||||
</div>{/* end lg:flex */}
|
||||
</Tabs>
|
||||
@@ -858,66 +864,3 @@ function WhatsAppSettingsSection({ settings }: { settings: Record<string, string
|
||||
)
|
||||
}
|
||||
|
||||
function LocalizationSettingsSection({ settings }: { settings: Record<string, string> }) {
|
||||
const mutation = useSettingsMutation()
|
||||
const enabledLocales = (settings.localization_enabled_locales || 'en').split(',')
|
||||
|
||||
const toggleLocale = (locale: string) => {
|
||||
const current = new Set(enabledLocales)
|
||||
if (current.has(locale)) {
|
||||
if (current.size <= 1) {
|
||||
toast.error('At least one locale must be enabled')
|
||||
return
|
||||
}
|
||||
current.delete(locale)
|
||||
} else {
|
||||
current.add(locale)
|
||||
}
|
||||
mutation.mutate({
|
||||
key: 'localization_enabled_locales',
|
||||
value: Array.from(current).join(','),
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-3">
|
||||
<Label className="text-sm font-medium">Enabled Languages</Label>
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between rounded-lg border p-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium text-sm">EN</span>
|
||||
<span className="text-sm text-muted-foreground">English</span>
|
||||
</div>
|
||||
<Checkbox
|
||||
checked={enabledLocales.includes('en')}
|
||||
onCheckedChange={() => toggleLocale('en')}
|
||||
disabled={mutation.isPending}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between rounded-lg border p-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium text-sm">FR</span>
|
||||
<span className="text-sm text-muted-foreground">Français</span>
|
||||
</div>
|
||||
<Checkbox
|
||||
checked={enabledLocales.includes('fr')}
|
||||
onCheckedChange={() => toggleLocale('fr')}
|
||||
disabled={mutation.isPending}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<SettingSelect
|
||||
label="Default Locale"
|
||||
description="The default language for new users"
|
||||
settingKey="localization_default_locale"
|
||||
value={settings.localization_default_locale || 'en'}
|
||||
options={[
|
||||
{ value: 'en', label: 'English' },
|
||||
{ value: 'fr', label: 'Fran\u00e7ais' },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
297
src/components/settings/test-environment-panel.tsx
Normal file
297
src/components/settings/test-environment-panel.tsx
Normal file
@@ -0,0 +1,297 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useSession } from 'next-auth/react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card'
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from '@/components/ui/alert-dialog'
|
||||
import {
|
||||
FlaskConical,
|
||||
Plus,
|
||||
Trash2,
|
||||
ExternalLink,
|
||||
Loader2,
|
||||
Users,
|
||||
UserCog,
|
||||
CheckCircle2,
|
||||
AlertTriangle,
|
||||
} from 'lucide-react'
|
||||
import type { UserRole } from '@prisma/client'
|
||||
|
||||
const ROLE_LABELS: Record<string, string> = {
|
||||
JURY_MEMBER: 'Jury Member',
|
||||
APPLICANT: 'Applicant',
|
||||
MENTOR: 'Mentor',
|
||||
OBSERVER: 'Observer',
|
||||
AWARD_MASTER: 'Award Master',
|
||||
PROGRAM_ADMIN: 'Program Admin',
|
||||
}
|
||||
|
||||
const ROLE_COLORS: Record<string, string> = {
|
||||
JURY_MEMBER: 'bg-blue-100 text-blue-800',
|
||||
APPLICANT: 'bg-green-100 text-green-800',
|
||||
MENTOR: 'bg-purple-100 text-purple-800',
|
||||
OBSERVER: 'bg-orange-100 text-orange-800',
|
||||
AWARD_MASTER: 'bg-yellow-100 text-yellow-800',
|
||||
PROGRAM_ADMIN: 'bg-red-100 text-red-800',
|
||||
}
|
||||
|
||||
const ROLE_LANDING: Record<string, string> = {
|
||||
JURY_MEMBER: '/jury',
|
||||
APPLICANT: '/applicant',
|
||||
MENTOR: '/mentor',
|
||||
OBSERVER: '/observer',
|
||||
AWARD_MASTER: '/admin',
|
||||
PROGRAM_ADMIN: '/admin',
|
||||
}
|
||||
|
||||
export function TestEnvironmentPanel() {
|
||||
const { update } = useSession()
|
||||
const router = useRouter()
|
||||
const utils = trpc.useUtils()
|
||||
|
||||
const { data: status, isLoading } = trpc.testEnvironment.status.useQuery()
|
||||
const createMutation = trpc.testEnvironment.create.useMutation({
|
||||
onSuccess: () => utils.testEnvironment.status.invalidate(),
|
||||
})
|
||||
const tearDownMutation = trpc.testEnvironment.tearDown.useMutation({
|
||||
onSuccess: () => utils.testEnvironment.status.invalidate(),
|
||||
})
|
||||
|
||||
const [confirmText, setConfirmText] = useState('')
|
||||
const [tearDownOpen, setTearDownOpen] = useState(false)
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// No test environment — show creation card
|
||||
if (!status?.active) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="rounded-lg border-2 border-dashed p-8 text-center">
|
||||
<FlaskConical className="mx-auto h-12 w-12 text-muted-foreground/50" />
|
||||
<h3 className="mt-4 text-lg font-semibold">No Test Environment</h3>
|
||||
<p className="mt-2 text-sm text-muted-foreground max-w-md mx-auto">
|
||||
Create a sandboxed test competition with dummy users, projects, jury assignments,
|
||||
and partial evaluations. All test data is fully isolated from production.
|
||||
</p>
|
||||
<Button
|
||||
className="mt-6"
|
||||
onClick={() => createMutation.mutate()}
|
||||
disabled={createMutation.isPending}
|
||||
>
|
||||
{createMutation.isPending ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Creating test environment...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Create Test Competition
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
{createMutation.isError && (
|
||||
<p className="mt-3 text-sm text-destructive">
|
||||
{createMutation.error.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Test environment is active
|
||||
const { competition, rounds, users, emailRedirect } = status
|
||||
|
||||
// Group users by role for impersonation cards
|
||||
const roleGroups = users.reduce(
|
||||
(acc, u) => {
|
||||
const role = u.role as string
|
||||
if (!acc[role]) acc[role] = []
|
||||
acc[role].push(u)
|
||||
return acc
|
||||
},
|
||||
{} as Record<string, typeof users>
|
||||
)
|
||||
|
||||
async function handleImpersonate(userId: string, role: UserRole) {
|
||||
await update({ impersonateUserId: userId })
|
||||
router.push((ROLE_LANDING[role] || '/admin') as any)
|
||||
router.refresh()
|
||||
}
|
||||
|
||||
function handleTearDown() {
|
||||
if (confirmText !== 'DELETE TEST') return
|
||||
tearDownMutation.mutate(undefined, {
|
||||
onSuccess: () => {
|
||||
setTearDownOpen(false)
|
||||
setConfirmText('')
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Status header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<Badge variant="outline" className="bg-green-50 text-green-700 border-green-200">
|
||||
<CheckCircle2 className="mr-1 h-3 w-3" />
|
||||
Test Active
|
||||
</Badge>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{competition.name}
|
||||
</span>
|
||||
</div>
|
||||
<Button variant="outline" size="sm" asChild>
|
||||
<a href={`/admin/competitions/${competition.id}`} target="_blank" rel="noopener">
|
||||
View Competition
|
||||
<ExternalLink className="ml-1.5 h-3 w-3" />
|
||||
</a>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Quick stats */}
|
||||
<div className="grid grid-cols-3 gap-4 text-center">
|
||||
<div className="rounded-lg border p-3">
|
||||
<p className="text-2xl font-bold">{rounds.length}</p>
|
||||
<p className="text-xs text-muted-foreground">Rounds</p>
|
||||
</div>
|
||||
<div className="rounded-lg border p-3">
|
||||
<p className="text-2xl font-bold">{users.length}</p>
|
||||
<p className="text-xs text-muted-foreground">Test Users</p>
|
||||
</div>
|
||||
<div className="rounded-lg border p-3">
|
||||
<p className="text-2xl font-bold truncate text-sm font-mono">
|
||||
{emailRedirect || '—'}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">Email Redirect</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Impersonation section */}
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<UserCog className="h-4 w-4 text-muted-foreground" />
|
||||
<h4 className="text-sm font-semibold">Impersonate Test User</h4>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
{Object.entries(roleGroups).map(([role, roleUsers]) => (
|
||||
<Card key={role} className="overflow-hidden">
|
||||
<CardHeader className="py-2 px-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<Badge variant="secondary" className={ROLE_COLORS[role] || ''}>
|
||||
{ROLE_LABELS[role] || role}
|
||||
</Badge>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{roleUsers.length} user{roleUsers.length !== 1 ? 's' : ''}
|
||||
</span>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="py-2 px-3 space-y-1.5">
|
||||
{roleUsers.slice(0, 3).map((u) => (
|
||||
<button
|
||||
key={u.id}
|
||||
onClick={() => handleImpersonate(u.id, u.role as UserRole)}
|
||||
className="flex items-center justify-between w-full rounded-md px-2 py-1.5 text-sm hover:bg-muted transition-colors text-left"
|
||||
>
|
||||
<span className="truncate">{u.name || u.email}</span>
|
||||
<span className="text-xs text-muted-foreground shrink-0 ml-2">
|
||||
Impersonate
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
{roleUsers.length > 3 && (
|
||||
<p className="text-xs text-muted-foreground px-2">
|
||||
+{roleUsers.length - 3} more (switch via banner)
|
||||
</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tear down */}
|
||||
<div className="border-t pt-4">
|
||||
<AlertDialog open={tearDownOpen} onOpenChange={setTearDownOpen}>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button variant="destructive" size="sm">
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
Tear Down Test Environment
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle className="flex items-center gap-2">
|
||||
<AlertTriangle className="h-5 w-5 text-destructive" />
|
||||
Destroy Test Environment
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
This will permanently delete ALL test data: users, projects, competitions,
|
||||
assignments, evaluations, and files. This action cannot be undone.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<div className="space-y-2 py-2">
|
||||
<p className="text-sm font-medium">
|
||||
Type <code className="rounded bg-muted px-1.5 py-0.5 font-mono text-sm">DELETE TEST</code> to confirm:
|
||||
</p>
|
||||
<Input
|
||||
value={confirmText}
|
||||
onChange={(e) => setConfirmText(e.target.value)}
|
||||
placeholder="DELETE TEST"
|
||||
className="font-mono"
|
||||
/>
|
||||
</div>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel onClick={() => setConfirmText('')}>
|
||||
Cancel
|
||||
</AlertDialogCancel>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={handleTearDown}
|
||||
disabled={confirmText !== 'DELETE TEST' || tearDownMutation.isPending}
|
||||
>
|
||||
{tearDownMutation.isPending ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Tearing down...
|
||||
</>
|
||||
) : (
|
||||
'Destroy Test Environment'
|
||||
)}
|
||||
</Button>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -3,9 +3,10 @@
|
||||
import { motion } from 'motion/react'
|
||||
import { type ReactNode } from 'react'
|
||||
|
||||
export function AnimatedCard({ children, index = 0 }: { children: ReactNode; index?: number }) {
|
||||
export function AnimatedCard({ children, index = 0, className }: { children: ReactNode; index?: number; className?: string }) {
|
||||
return (
|
||||
<motion.div
|
||||
className={className}
|
||||
initial={{ opacity: 0, y: 12 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.3, delay: index * 0.05, ease: 'easeOut' }}
|
||||
|
||||
149
src/components/shared/impersonation-banner.tsx
Normal file
149
src/components/shared/impersonation-banner.tsx
Normal file
@@ -0,0 +1,149 @@
|
||||
'use client'
|
||||
|
||||
import { useSession } from 'next-auth/react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { useState } from 'react'
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { ChevronDown, LogOut, UserCog } from 'lucide-react'
|
||||
import type { UserRole } from '@prisma/client'
|
||||
|
||||
const ROLE_LABELS: Record<string, string> = {
|
||||
JURY_MEMBER: 'Jury Member',
|
||||
APPLICANT: 'Applicant',
|
||||
MENTOR: 'Mentor',
|
||||
OBSERVER: 'Observer',
|
||||
AWARD_MASTER: 'Award Master',
|
||||
PROGRAM_ADMIN: 'Program Admin',
|
||||
SUPER_ADMIN: 'Super Admin',
|
||||
}
|
||||
|
||||
const ROLE_LANDING: Record<string, string> = {
|
||||
JURY_MEMBER: '/jury',
|
||||
APPLICANT: '/applicant',
|
||||
MENTOR: '/mentor',
|
||||
OBSERVER: '/observer',
|
||||
AWARD_MASTER: '/admin',
|
||||
PROGRAM_ADMIN: '/admin',
|
||||
SUPER_ADMIN: '/admin',
|
||||
}
|
||||
|
||||
export function ImpersonationBanner() {
|
||||
const { data: session, update } = useSession()
|
||||
const router = useRouter()
|
||||
const [switching, setSwitching] = useState(false)
|
||||
|
||||
// Only fetch test users when impersonating (realRole check happens server-side)
|
||||
const { data: testEnv } = trpc.testEnvironment.status.useQuery(undefined, {
|
||||
enabled: !!session?.user?.isImpersonating,
|
||||
staleTime: 60_000,
|
||||
})
|
||||
|
||||
if (!session?.user?.isImpersonating) return null
|
||||
|
||||
const currentRole = session.user.role
|
||||
const currentName = session.user.impersonatedName || session.user.name || 'Unknown'
|
||||
|
||||
// Group available test users by role (exclude currently impersonated user)
|
||||
const availableUsers = testEnv?.active
|
||||
? testEnv.users.filter((u) => u.id !== session.user.id)
|
||||
: []
|
||||
|
||||
const roleGroups = availableUsers.reduce(
|
||||
(acc, u) => {
|
||||
const role = u.role as string
|
||||
if (!acc[role]) acc[role] = []
|
||||
acc[role].push(u)
|
||||
return acc
|
||||
},
|
||||
{} as Record<string, typeof availableUsers>
|
||||
)
|
||||
|
||||
async function handleSwitch(userId: string, role: UserRole) {
|
||||
setSwitching(true)
|
||||
await update({ impersonateUserId: userId })
|
||||
router.push((ROLE_LANDING[role] || '/admin') as any)
|
||||
router.refresh()
|
||||
setSwitching(false)
|
||||
}
|
||||
|
||||
async function handleStopImpersonation() {
|
||||
setSwitching(true)
|
||||
await update({ stopImpersonation: true })
|
||||
router.push('/admin/settings' as any)
|
||||
router.refresh()
|
||||
setSwitching(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed top-0 left-0 right-0 z-50 bg-amber-500 text-amber-950 shadow-md">
|
||||
<div className="mx-auto flex items-center justify-between px-4 py-1.5 text-sm font-medium">
|
||||
<div className="flex items-center gap-2">
|
||||
<UserCog className="h-4 w-4" />
|
||||
<span>
|
||||
Viewing as <strong>{currentName}</strong>{' '}
|
||||
<span className="rounded bg-amber-600/30 px-1.5 py-0.5 text-xs font-semibold">
|
||||
{ROLE_LABELS[currentRole] || currentRole}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Quick-switch dropdown */}
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 gap-1 bg-amber-600/20 text-amber-950 hover:bg-amber-600/40"
|
||||
disabled={switching}
|
||||
>
|
||||
Switch Role
|
||||
<ChevronDown className="h-3 w-3" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-56">
|
||||
{Object.entries(roleGroups).map(([role, users]) => (
|
||||
<div key={role}>
|
||||
<DropdownMenuLabel className="text-xs text-muted-foreground">
|
||||
{ROLE_LABELS[role] || role}
|
||||
</DropdownMenuLabel>
|
||||
{users.map((u) => (
|
||||
<DropdownMenuItem
|
||||
key={u.id}
|
||||
onClick={() => handleSwitch(u.id, u.role as UserRole)}
|
||||
disabled={switching}
|
||||
>
|
||||
<span className="truncate">{u.name || u.email}</span>
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
<DropdownMenuSeparator />
|
||||
</div>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
{/* Return to admin */}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 gap-1 bg-amber-600/20 text-amber-950 hover:bg-amber-600/40"
|
||||
onClick={handleStopImpersonation}
|
||||
disabled={switching}
|
||||
>
|
||||
<LogOut className="h-3 w-3" />
|
||||
Return to Admin
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -24,6 +24,10 @@ const STATUS_STYLES: Record<string, { variant: BadgeProps['variant']; className?
|
||||
REJECTED: { variant: 'destructive' },
|
||||
WITHDRAWN: { variant: 'secondary' },
|
||||
|
||||
// Observer-derived statuses
|
||||
NOT_REVIEWED: { variant: 'secondary', className: 'bg-slate-500/10 text-slate-600 border-slate-200 dark:text-slate-400' },
|
||||
REVIEWED: { variant: 'default', className: 'bg-emerald-500/10 text-emerald-700 border-emerald-200 dark:text-emerald-400' },
|
||||
|
||||
// Evaluation statuses
|
||||
IN_PROGRESS: { variant: 'default', className: 'bg-blue-500/10 text-blue-700 border-blue-200 dark:text-blue-400' },
|
||||
COMPLETED: { variant: 'default', className: 'bg-emerald-500/10 text-emerald-700 border-emerald-200 dark:text-emerald-400' },
|
||||
@@ -43,7 +47,14 @@ type StatusBadgeProps = {
|
||||
|
||||
export function StatusBadge({ status, className, size = 'default' }: StatusBadgeProps) {
|
||||
const style = STATUS_STYLES[status] || { variant: 'secondary' as const }
|
||||
const label = status === 'NONE' ? 'NOT INVITED' : status.replace(/_/g, ' ')
|
||||
const LABEL_OVERRIDES: Record<string, string> = {
|
||||
NONE: 'NOT INVITED',
|
||||
NOT_REVIEWED: 'Not Reviewed',
|
||||
UNDER_REVIEW: 'Under Review',
|
||||
REVIEWED: 'Reviewed',
|
||||
SEMIFINALIST: 'Semi-finalist',
|
||||
}
|
||||
const label = LABEL_OVERRIDES[status] ?? status.replace(/_/g, ' ')
|
||||
|
||||
return (
|
||||
<Badge
|
||||
|
||||
@@ -13,7 +13,7 @@ const Checkbox = React.forwardRef<
|
||||
<CheckboxPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'peer h-4 w-4 shrink-0 rounded-xs border border-primary ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground',
|
||||
'peer h-4 w-4 shrink-0 rounded-xs border border-input ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground data-[state=checked]:border-primary',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
||||
45
src/components/ui/toggle.tsx
Normal file
45
src/components/ui/toggle.tsx
Normal file
@@ -0,0 +1,45 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as TogglePrimitive from "@radix-ui/react-toggle"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const toggleVariants = cva(
|
||||
"inline-flex items-center justify-center rounded-md text-sm font-medium ring-offset-background transition-colors hover:bg-muted hover:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=on]:bg-accent data-[state=on]:text-accent-foreground [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0 gap-2",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-transparent",
|
||||
outline:
|
||||
"border border-input bg-transparent hover:bg-accent hover:text-accent-foreground",
|
||||
},
|
||||
size: {
|
||||
default: "h-10 px-3 min-w-10",
|
||||
sm: "h-9 px-2.5 min-w-9",
|
||||
lg: "h-11 px-5 min-w-11",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
const Toggle = React.forwardRef<
|
||||
React.ElementRef<typeof TogglePrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof TogglePrimitive.Root> &
|
||||
VariantProps<typeof toggleVariants>
|
||||
>(({ className, variant, size, ...props }, ref) => (
|
||||
<TogglePrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(toggleVariants({ variant, size, className }))}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
|
||||
Toggle.displayName = TogglePrimitive.Root.displayName
|
||||
|
||||
export { Toggle, toggleVariants }
|
||||
@@ -10,6 +10,11 @@ declare module 'next-auth' {
|
||||
name?: string | null
|
||||
role: UserRole
|
||||
mustSetPassword?: boolean
|
||||
// Impersonation fields
|
||||
isImpersonating?: boolean
|
||||
realUserId?: string
|
||||
realRole?: UserRole
|
||||
impersonatedName?: string | null
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,6 +29,12 @@ declare module '@auth/core/jwt' {
|
||||
id: string
|
||||
role: UserRole
|
||||
mustSetPassword?: boolean
|
||||
// Impersonation fields
|
||||
impersonatedUserId?: string
|
||||
impersonatedRole?: UserRole
|
||||
impersonatedName?: string | null
|
||||
realUserId?: string
|
||||
realRole?: UserRole
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -190,7 +190,7 @@ export const { handlers, auth, signIn, signOut } = NextAuth({
|
||||
],
|
||||
callbacks: {
|
||||
...authConfig.callbacks,
|
||||
async jwt({ token, user, trigger }) {
|
||||
async jwt({ token, user, trigger, session: sessionUpdate }) {
|
||||
// Initial sign in
|
||||
if (user) {
|
||||
token.id = user.id as string
|
||||
@@ -198,15 +198,48 @@ export const { handlers, auth, signIn, signOut } = NextAuth({
|
||||
token.mustSetPassword = user.mustSetPassword
|
||||
}
|
||||
|
||||
// On session update, refresh from database
|
||||
// On session update
|
||||
if (trigger === 'update') {
|
||||
const dbUser = await prisma.user.findUnique({
|
||||
where: { id: token.id as string },
|
||||
select: { role: true, mustSetPassword: true },
|
||||
})
|
||||
if (dbUser) {
|
||||
token.role = dbUser.role
|
||||
token.mustSetPassword = dbUser.mustSetPassword
|
||||
// Handle impersonation request
|
||||
if (sessionUpdate?.impersonateUserId) {
|
||||
const testUser = await prisma.user.findUnique({
|
||||
where: { id: sessionUpdate.impersonateUserId },
|
||||
select: { id: true, name: true, email: true, role: true, isTest: true },
|
||||
})
|
||||
// Only allow impersonating test users with @test.local emails
|
||||
if (testUser?.isTest && testUser.email.endsWith('@test.local')) {
|
||||
// Preserve original identity (only set once in case of quick-switch)
|
||||
if (!token.realUserId) {
|
||||
token.realUserId = token.id as string
|
||||
token.realRole = token.role as UserRole
|
||||
}
|
||||
token.id = testUser.id
|
||||
token.role = testUser.role
|
||||
token.impersonatedUserId = testUser.id
|
||||
token.impersonatedRole = testUser.role
|
||||
token.impersonatedName = testUser.name
|
||||
}
|
||||
}
|
||||
// Handle stop impersonation
|
||||
else if (sessionUpdate?.stopImpersonation && token.realUserId) {
|
||||
token.id = token.realUserId
|
||||
token.role = token.realRole!
|
||||
delete token.impersonatedUserId
|
||||
delete token.impersonatedRole
|
||||
delete token.impersonatedName
|
||||
delete token.realUserId
|
||||
delete token.realRole
|
||||
}
|
||||
// Normal session refresh (only when not impersonating)
|
||||
else if (!token.impersonatedUserId) {
|
||||
const dbUser = await prisma.user.findUnique({
|
||||
where: { id: token.id as string },
|
||||
select: { role: true, mustSetPassword: true },
|
||||
})
|
||||
if (dbUser) {
|
||||
token.role = dbUser.role
|
||||
token.mustSetPassword = dbUser.mustSetPassword
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -217,6 +250,15 @@ export const { handlers, auth, signIn, signOut } = NextAuth({
|
||||
session.user.id = token.id as string
|
||||
session.user.role = token.role as UserRole
|
||||
session.user.mustSetPassword = token.mustSetPassword as boolean | undefined
|
||||
// Impersonation state
|
||||
session.user.isImpersonating = !!token.impersonatedUserId
|
||||
if (token.realUserId) {
|
||||
session.user.realUserId = token.realUserId as string
|
||||
session.user.realRole = token.realRole as UserRole
|
||||
}
|
||||
if (token.impersonatedName !== undefined) {
|
||||
session.user.impersonatedName = token.impersonatedName as string | null
|
||||
}
|
||||
}
|
||||
return session
|
||||
},
|
||||
|
||||
109
src/lib/email.ts
109
src/lib/email.ts
@@ -7,6 +7,32 @@ let cachedTransporter: Transporter | null = null
|
||||
let cachedConfigHash = ''
|
||||
let cachedFrom = ''
|
||||
|
||||
/**
|
||||
* Resolve test email recipients: @test.local emails are redirected
|
||||
* to the admin's email (from test_email_redirect setting) and
|
||||
* the subject is prefixed with [TEST]. Real emails are never affected.
|
||||
*/
|
||||
async function resolveTestEmailRecipient(
|
||||
to: string,
|
||||
subject: string
|
||||
): Promise<{ to: string; subject: string }> {
|
||||
if (!to.endsWith('@test.local')) {
|
||||
return { to, subject }
|
||||
}
|
||||
const redirect = await prisma.systemSettings.findUnique({
|
||||
where: { key: 'test_email_redirect' },
|
||||
select: { value: true },
|
||||
})
|
||||
if (redirect?.value) {
|
||||
return {
|
||||
to: redirect.value,
|
||||
subject: `[TEST] ${subject}`,
|
||||
}
|
||||
}
|
||||
// No redirect configured — suppress the email entirely
|
||||
return { to: '', subject }
|
||||
}
|
||||
|
||||
/**
|
||||
* Get SMTP transporter using database settings with env var fallback.
|
||||
* Caches the transporter and rebuilds it when settings change.
|
||||
@@ -47,12 +73,31 @@ async function getTransporter(): Promise<{ transporter: Transporter; from: strin
|
||||
}
|
||||
|
||||
// Create new transporter
|
||||
cachedTransporter = nodemailer.createTransport({
|
||||
const rawTransporter = nodemailer.createTransport({
|
||||
host,
|
||||
port: parseInt(port),
|
||||
secure: port === '465',
|
||||
auth: { user, pass },
|
||||
})
|
||||
|
||||
// Wrap sendMail to auto-redirect @test.local emails
|
||||
const originalSendMail = rawTransporter.sendMail.bind(rawTransporter)
|
||||
rawTransporter.sendMail = async function (mailOptions: any) {
|
||||
if (mailOptions.to && typeof mailOptions.to === 'string') {
|
||||
const resolved = await resolveTestEmailRecipient(
|
||||
mailOptions.to,
|
||||
mailOptions.subject || ''
|
||||
)
|
||||
if (!resolved.to) {
|
||||
// Suppress email entirely (no redirect configured for test)
|
||||
return { messageId: 'suppressed-test-email' }
|
||||
}
|
||||
mailOptions = { ...mailOptions, to: resolved.to, subject: resolved.subject }
|
||||
}
|
||||
return originalSendMail(mailOptions)
|
||||
} as any
|
||||
|
||||
cachedTransporter = rawTransporter
|
||||
cachedConfigHash = configHash
|
||||
cachedFrom = from
|
||||
|
||||
@@ -1083,6 +1128,60 @@ Together for a healthier ocean.
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate "3 Days Remaining" email template (for jury)
|
||||
*/
|
||||
function getReminder3DaysTemplate(
|
||||
name: string,
|
||||
pendingCount: number,
|
||||
roundName: string,
|
||||
deadline: string,
|
||||
assignmentsUrl?: string
|
||||
): EmailTemplate {
|
||||
const greeting = name ? `Hello ${name},` : 'Hello,'
|
||||
|
||||
const urgentBox = `
|
||||
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0" style="margin: 20px 0;">
|
||||
<tr>
|
||||
<td style="background-color: #fef3c7; border-left: 4px solid #f59e0b; border-radius: 0 8px 8px 0; padding: 16px 20px;">
|
||||
<p style="color: #92400e; margin: 0; font-size: 14px; font-weight: 600;">⚠ 3 Days Remaining</p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
`
|
||||
|
||||
const content = `
|
||||
${sectionTitle(greeting)}
|
||||
${urgentBox}
|
||||
${paragraph(`This is a reminder that <strong style="color: ${BRAND.darkBlue};">${roundName}</strong> closes in 3 days.`)}
|
||||
${statCard('Pending Evaluations', pendingCount)}
|
||||
${infoBox(`<strong>Deadline:</strong> ${deadline}`, 'warning')}
|
||||
${paragraph('Please plan to complete your remaining evaluations before the deadline to ensure your feedback is included in the selection process.')}
|
||||
${assignmentsUrl ? ctaButton(assignmentsUrl, 'Complete Evaluations') : ''}
|
||||
`
|
||||
|
||||
return {
|
||||
subject: `Reminder: ${pendingCount} evaluation${pendingCount !== 1 ? 's' : ''} due in 3 days`,
|
||||
html: getEmailWrapper(content),
|
||||
text: `
|
||||
${greeting}
|
||||
|
||||
This is a reminder that ${roundName} closes in 3 days.
|
||||
|
||||
You have ${pendingCount} pending evaluation${pendingCount !== 1 ? 's' : ''}.
|
||||
Deadline: ${deadline}
|
||||
|
||||
Please plan to complete your remaining evaluations before the deadline.
|
||||
|
||||
${assignmentsUrl ? `Complete evaluations: ${assignmentsUrl}` : ''}
|
||||
|
||||
---
|
||||
Monaco Ocean Protection Challenge
|
||||
Together for a healthier ocean.
|
||||
`,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate "1 Hour Reminder" email template (for jury)
|
||||
*/
|
||||
@@ -1457,6 +1556,14 @@ export const NOTIFICATION_EMAIL_TEMPLATES: Record<string, TemplateGenerator> = {
|
||||
ctx.metadata?.deadline as string | undefined,
|
||||
ctx.linkUrl
|
||||
),
|
||||
REMINDER_3_DAYS: (ctx) =>
|
||||
getReminder3DaysTemplate(
|
||||
ctx.name || '',
|
||||
(ctx.metadata?.pendingCount as number) || 0,
|
||||
(ctx.metadata?.roundName as string) || 'this round',
|
||||
(ctx.metadata?.deadline as string) || 'Soon',
|
||||
ctx.linkUrl
|
||||
),
|
||||
REMINDER_24H: (ctx) =>
|
||||
getReminder24HTemplate(
|
||||
ctx.name || '',
|
||||
|
||||
@@ -1,10 +1,36 @@
|
||||
import OpenAI from 'openai'
|
||||
import type { ChatCompletionCreateParamsNonStreaming } from 'openai/resources/chat/completions'
|
||||
import Anthropic from '@anthropic-ai/sdk'
|
||||
import { prisma } from './prisma'
|
||||
|
||||
// Hardcoded Claude model list (Anthropic API doesn't expose a models.list endpoint for all users)
|
||||
export const ANTHROPIC_CLAUDE_MODELS = [
|
||||
'claude-opus-4-5-20250514',
|
||||
'claude-sonnet-4-5-20250514',
|
||||
'claude-haiku-3-5-20241022',
|
||||
'claude-opus-4-20250514',
|
||||
'claude-sonnet-4-20250514',
|
||||
] as const
|
||||
|
||||
/**
|
||||
* AI client type returned by getOpenAI().
|
||||
* Both the OpenAI SDK and the Anthropic adapter satisfy this interface.
|
||||
* All AI services only use .chat.completions.create(), so this is safe.
|
||||
*/
|
||||
export type AIClient = OpenAI | AnthropicClientAdapter
|
||||
|
||||
type AnthropicClientAdapter = {
|
||||
__isAnthropicAdapter: true
|
||||
chat: {
|
||||
completions: {
|
||||
create(params: ChatCompletionCreateParamsNonStreaming): Promise<OpenAI.Chat.Completions.ChatCompletion>
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// OpenAI client singleton with lazy initialization
|
||||
const globalForOpenAI = globalThis as unknown as {
|
||||
openai: OpenAI | undefined
|
||||
openai: AIClient | undefined
|
||||
openaiInitialized: boolean
|
||||
}
|
||||
|
||||
@@ -12,15 +38,17 @@ const globalForOpenAI = globalThis as unknown as {
|
||||
|
||||
/**
|
||||
* Get the configured AI provider from SystemSettings.
|
||||
* Returns 'openai' (default) or 'litellm' (ChatGPT subscription proxy).
|
||||
* Returns 'openai' (default), 'litellm' (ChatGPT subscription proxy), or 'anthropic' (Claude API).
|
||||
*/
|
||||
export async function getConfiguredProvider(): Promise<'openai' | 'litellm'> {
|
||||
export async function getConfiguredProvider(): Promise<'openai' | 'litellm' | 'anthropic'> {
|
||||
try {
|
||||
const setting = await prisma.systemSettings.findUnique({
|
||||
where: { key: 'ai_provider' },
|
||||
})
|
||||
const value = setting?.value || 'openai'
|
||||
return value === 'litellm' ? 'litellm' : 'openai'
|
||||
if (value === 'litellm') return 'litellm'
|
||||
if (value === 'anthropic') return 'anthropic'
|
||||
return 'openai'
|
||||
} catch {
|
||||
return 'openai'
|
||||
}
|
||||
@@ -219,6 +247,20 @@ async function getOpenAIApiKey(): Promise<string | null> {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get Anthropic API key from SystemSettings
|
||||
*/
|
||||
async function getAnthropicApiKey(): Promise<string | null> {
|
||||
try {
|
||||
const setting = await prisma.systemSettings.findUnique({
|
||||
where: { key: 'anthropic_api_key' },
|
||||
})
|
||||
return setting?.value || process.env.ANTHROPIC_API_KEY || null
|
||||
} catch {
|
||||
return process.env.ANTHROPIC_API_KEY || null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get custom base URL for OpenAI-compatible providers.
|
||||
* Supports OpenRouter, Together AI, Groq, local models, etc.
|
||||
@@ -265,15 +307,165 @@ async function createOpenAIClient(): Promise<OpenAI | null> {
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the OpenAI client singleton
|
||||
* Returns null if API key is not configured
|
||||
* Check if a model is a Claude Opus model (supports extended thinking).
|
||||
*/
|
||||
export async function getOpenAI(): Promise<OpenAI | null> {
|
||||
function isClaudeOpusModel(model: string): boolean {
|
||||
return model.toLowerCase().includes('opus')
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an Anthropic adapter that wraps the Anthropic SDK behind the
|
||||
* same `.chat.completions.create()` surface as OpenAI. This allows all
|
||||
* AI service files to work with zero changes.
|
||||
*/
|
||||
async function createAnthropicAdapter(): Promise<AnthropicClientAdapter | null> {
|
||||
const apiKey = await getAnthropicApiKey()
|
||||
if (!apiKey) {
|
||||
console.warn('Anthropic API key not configured')
|
||||
return null
|
||||
}
|
||||
|
||||
const baseURL = await getBaseURL()
|
||||
const anthropic = new Anthropic({
|
||||
apiKey,
|
||||
...(baseURL ? { baseURL } : {}),
|
||||
})
|
||||
|
||||
if (baseURL) {
|
||||
console.log(`[Anthropic] Using custom base URL: ${baseURL}`)
|
||||
}
|
||||
|
||||
return {
|
||||
__isAnthropicAdapter: true,
|
||||
chat: {
|
||||
completions: {
|
||||
async create(params: ChatCompletionCreateParamsNonStreaming): Promise<OpenAI.Chat.Completions.ChatCompletion> {
|
||||
// Extract system messages → Anthropic's system parameter
|
||||
const systemMessages: string[] = []
|
||||
const userAssistantMessages: Anthropic.MessageParam[] = []
|
||||
|
||||
for (const msg of params.messages) {
|
||||
const content = typeof msg.content === 'string' ? msg.content : ''
|
||||
if (msg.role === 'system' || msg.role === 'developer') {
|
||||
systemMessages.push(content)
|
||||
} else {
|
||||
userAssistantMessages.push({
|
||||
role: msg.role === 'assistant' ? 'assistant' : 'user',
|
||||
content,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure messages start with a user message (Anthropic requirement)
|
||||
if (userAssistantMessages.length === 0 || userAssistantMessages[0].role !== 'user') {
|
||||
userAssistantMessages.unshift({ role: 'user', content: 'Hello' })
|
||||
}
|
||||
|
||||
// Determine max_tokens (required by Anthropic, default 16384)
|
||||
const maxTokens = params.max_tokens ?? params.max_completion_tokens ?? 16384
|
||||
|
||||
// Build Anthropic request
|
||||
const anthropicParams: Anthropic.MessageCreateParamsNonStreaming = {
|
||||
model: params.model,
|
||||
max_tokens: maxTokens,
|
||||
messages: userAssistantMessages,
|
||||
...(systemMessages.length > 0 ? { system: systemMessages.join('\n\n') } : {}),
|
||||
}
|
||||
|
||||
// Add temperature if present (Anthropic supports 0-1)
|
||||
if (params.temperature !== undefined && params.temperature !== null) {
|
||||
anthropicParams.temperature = params.temperature
|
||||
}
|
||||
|
||||
// Extended thinking for Opus models
|
||||
if (isClaudeOpusModel(params.model)) {
|
||||
anthropicParams.thinking = { type: 'enabled', budget_tokens: Math.min(8192, maxTokens - 1) }
|
||||
}
|
||||
|
||||
// Call Anthropic API
|
||||
let response = await anthropic.messages.create(anthropicParams)
|
||||
|
||||
// Extract text from response (skip thinking blocks)
|
||||
let responseText = response.content
|
||||
.filter((block): block is Anthropic.TextBlock => block.type === 'text')
|
||||
.map((block) => block.text)
|
||||
.join('')
|
||||
|
||||
// JSON retry: if response_format was set but response isn't valid JSON
|
||||
const wantsJson = params.response_format && 'type' in params.response_format && params.response_format.type === 'json_object'
|
||||
if (wantsJson && responseText) {
|
||||
try {
|
||||
JSON.parse(responseText)
|
||||
} catch {
|
||||
// Retry once with explicit JSON instruction
|
||||
const retryMessages = [...userAssistantMessages]
|
||||
const lastIdx = retryMessages.length - 1
|
||||
if (lastIdx >= 0 && retryMessages[lastIdx].role === 'user') {
|
||||
retryMessages[lastIdx] = {
|
||||
...retryMessages[lastIdx],
|
||||
content: retryMessages[lastIdx].content + '\n\nIMPORTANT: You MUST respond with valid JSON only. No markdown, no extra text, just a JSON object or array.',
|
||||
}
|
||||
}
|
||||
|
||||
const retryParams: Anthropic.MessageCreateParamsNonStreaming = {
|
||||
...anthropicParams,
|
||||
messages: retryMessages,
|
||||
}
|
||||
|
||||
response = await anthropic.messages.create(retryParams)
|
||||
responseText = response.content
|
||||
.filter((block): block is Anthropic.TextBlock => block.type === 'text')
|
||||
.map((block) => block.text)
|
||||
.join('')
|
||||
}
|
||||
}
|
||||
|
||||
// Normalize response to OpenAI shape
|
||||
return {
|
||||
id: response.id,
|
||||
object: 'chat.completion' as const,
|
||||
created: Math.floor(Date.now() / 1000),
|
||||
model: response.model,
|
||||
choices: [
|
||||
{
|
||||
index: 0,
|
||||
message: {
|
||||
role: 'assistant' as const,
|
||||
content: responseText || null,
|
||||
refusal: null,
|
||||
},
|
||||
finish_reason: response.stop_reason === 'end_turn' || response.stop_reason === 'stop_sequence' ? 'stop' : response.stop_reason === 'max_tokens' ? 'length' : 'stop',
|
||||
logprobs: null,
|
||||
},
|
||||
],
|
||||
usage: {
|
||||
prompt_tokens: response.usage.input_tokens,
|
||||
completion_tokens: response.usage.output_tokens,
|
||||
total_tokens: response.usage.input_tokens + response.usage.output_tokens,
|
||||
prompt_tokens_details: undefined as any,
|
||||
completion_tokens_details: undefined as any,
|
||||
},
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the AI client singleton.
|
||||
* Returns an OpenAI client or an Anthropic adapter (both expose .chat.completions.create()).
|
||||
* Returns null if the API key is not configured.
|
||||
*/
|
||||
export async function getOpenAI(): Promise<AIClient | null> {
|
||||
if (globalForOpenAI.openaiInitialized) {
|
||||
return globalForOpenAI.openai || null
|
||||
}
|
||||
|
||||
const client = await createOpenAIClient()
|
||||
const provider = await getConfiguredProvider()
|
||||
const client = provider === 'anthropic'
|
||||
? await createAnthropicAdapter()
|
||||
: await createOpenAIClient()
|
||||
|
||||
if (process.env.NODE_ENV !== 'production') {
|
||||
globalForOpenAI.openai = client || undefined
|
||||
@@ -298,10 +490,13 @@ export function resetOpenAIClient(): void {
|
||||
export async function isOpenAIConfigured(): Promise<boolean> {
|
||||
const provider = await getConfiguredProvider()
|
||||
if (provider === 'litellm') {
|
||||
// LiteLLM just needs a base URL configured
|
||||
const baseURL = await getBaseURL()
|
||||
return !!baseURL
|
||||
}
|
||||
if (provider === 'anthropic') {
|
||||
const apiKey = await getAnthropicApiKey()
|
||||
return !!apiKey
|
||||
}
|
||||
const apiKey = await getOpenAIApiKey()
|
||||
return !!apiKey
|
||||
}
|
||||
@@ -327,6 +522,18 @@ export async function listAvailableModels(): Promise<{
|
||||
}
|
||||
}
|
||||
|
||||
// Anthropic: return hardcoded Claude model list
|
||||
if (provider === 'anthropic') {
|
||||
const apiKey = await getAnthropicApiKey()
|
||||
if (!apiKey) {
|
||||
return { success: false, error: 'Anthropic API key not configured' }
|
||||
}
|
||||
return {
|
||||
success: true,
|
||||
models: [...ANTHROPIC_CLAUDE_MODELS],
|
||||
}
|
||||
}
|
||||
|
||||
const client = await getOpenAI()
|
||||
|
||||
if (!client) {
|
||||
@@ -336,7 +543,7 @@ export async function listAvailableModels(): Promise<{
|
||||
}
|
||||
}
|
||||
|
||||
const response = await client.models.list()
|
||||
const response = await (client as OpenAI).models.list()
|
||||
const chatModels = response.data
|
||||
.filter((m) => m.id.includes('gpt') || m.id.includes('o1') || m.id.includes('o3') || m.id.includes('o4'))
|
||||
.map((m) => m.id)
|
||||
@@ -367,14 +574,16 @@ export async function validateModel(modelId: string): Promise<{
|
||||
if (!client) {
|
||||
return {
|
||||
valid: false,
|
||||
error: 'OpenAI API key not configured',
|
||||
error: 'AI API key not configured',
|
||||
}
|
||||
}
|
||||
|
||||
// Try a minimal completion with the model using correct parameters
|
||||
const provider = await getConfiguredProvider()
|
||||
|
||||
// For Anthropic, use minimal max_tokens
|
||||
const params = buildCompletionParams(modelId, {
|
||||
messages: [{ role: 'user', content: 'test' }],
|
||||
maxTokens: 1,
|
||||
maxTokens: provider === 'anthropic' ? 16 : 1,
|
||||
})
|
||||
|
||||
await client.chat.completions.create(params)
|
||||
@@ -407,11 +616,13 @@ export async function testOpenAIConnection(): Promise<{
|
||||
}> {
|
||||
try {
|
||||
const client = await getOpenAI()
|
||||
const provider = await getConfiguredProvider()
|
||||
|
||||
if (!client) {
|
||||
const label = provider === 'anthropic' ? 'Anthropic' : 'OpenAI'
|
||||
return {
|
||||
success: false,
|
||||
error: 'OpenAI API key not configured',
|
||||
error: `${label} API key not configured`,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -421,7 +632,7 @@ export async function testOpenAIConnection(): Promise<{
|
||||
// Test with the configured model using correct parameters
|
||||
const params = buildCompletionParams(configuredModel, {
|
||||
messages: [{ role: 'user', content: 'Hello' }],
|
||||
maxTokens: 5,
|
||||
maxTokens: provider === 'anthropic' ? 16 : 5,
|
||||
})
|
||||
|
||||
const response = await client.chat.completions.create(params)
|
||||
@@ -436,7 +647,7 @@ export async function testOpenAIConnection(): Promise<{
|
||||
const configuredModel = await getConfiguredModel()
|
||||
|
||||
// Check for model-specific errors
|
||||
if (message.includes('does not exist') || message.includes('model_not_found')) {
|
||||
if (message.includes('does not exist') || message.includes('model_not_found') || message.includes('not_found_error')) {
|
||||
return {
|
||||
success: false,
|
||||
error: `Model "${configuredModel}" is not available. Check Settings → AI to select a valid model.`,
|
||||
|
||||
@@ -51,6 +51,7 @@ import { roundEngineRouter } from './roundEngine'
|
||||
import { roundAssignmentRouter } from './roundAssignment'
|
||||
import { deliberationRouter } from './deliberation'
|
||||
import { resultLockRouter } from './resultLock'
|
||||
import { testEnvironmentRouter } from './testEnvironment'
|
||||
|
||||
/**
|
||||
* Root tRPC router that combines all domain routers
|
||||
@@ -108,6 +109,8 @@ export const appRouter = router({
|
||||
roundAssignment: roundAssignmentRouter,
|
||||
deliberation: deliberationRouter,
|
||||
resultLock: resultLockRouter,
|
||||
// Test environment
|
||||
testEnvironment: testEnvironmentRouter,
|
||||
})
|
||||
|
||||
export type AppRouter = typeof appRouter
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -105,7 +105,7 @@ export const applicationRouter = router({
|
||||
if (input.mode === 'edition') {
|
||||
// Edition-wide application mode
|
||||
const program = await ctx.prisma.program.findFirst({
|
||||
where: { slug: input.slug },
|
||||
where: { slug: input.slug, isTest: false },
|
||||
})
|
||||
|
||||
if (!program) {
|
||||
@@ -687,6 +687,7 @@ export const applicationRouter = router({
|
||||
const projects = await ctx.prisma.project.findMany({
|
||||
where: {
|
||||
isDraft: true,
|
||||
isTest: false,
|
||||
},
|
||||
})
|
||||
|
||||
@@ -837,6 +838,7 @@ export const applicationRouter = router({
|
||||
const projects = await ctx.prisma.project.findMany({
|
||||
where: {
|
||||
isDraft: true,
|
||||
isTest: false,
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -142,7 +142,7 @@ export const competitionRouter = router({
|
||||
.input(z.object({ programId: z.string() }))
|
||||
.query(async ({ ctx, input }) => {
|
||||
return ctx.prisma.competition.findMany({
|
||||
where: { programId: input.programId },
|
||||
where: { programId: input.programId, isTest: false },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
include: {
|
||||
_count: {
|
||||
@@ -254,7 +254,7 @@ export const competitionRouter = router({
|
||||
const competitionIds = [...new Set(memberships.map((m) => m.juryGroup.competitionId))]
|
||||
if (competitionIds.length === 0) return []
|
||||
return ctx.prisma.competition.findMany({
|
||||
where: { id: { in: competitionIds }, status: { not: 'ARCHIVED' } },
|
||||
where: { id: { in: competitionIds }, status: { not: 'ARCHIVED' }, isTest: false },
|
||||
include: {
|
||||
rounds: {
|
||||
orderBy: { sortOrder: 'asc' },
|
||||
|
||||
@@ -172,18 +172,19 @@ export const dashboardRouter = router({
|
||||
|
||||
// 7. Project count
|
||||
ctx.prisma.project.count({
|
||||
where: { programId: editionId },
|
||||
where: { programId: editionId, isTest: false },
|
||||
}),
|
||||
|
||||
// 8. New projects this week
|
||||
ctx.prisma.project.count({
|
||||
where: { programId: editionId, createdAt: { gte: sevenDaysAgo } },
|
||||
where: { programId: editionId, isTest: false, createdAt: { gte: sevenDaysAgo } },
|
||||
}),
|
||||
|
||||
// 9. Total jurors
|
||||
ctx.prisma.user.count({
|
||||
where: {
|
||||
role: 'JURY_MEMBER',
|
||||
isTest: false,
|
||||
status: { in: ['ACTIVE', 'INVITED', 'NONE'] },
|
||||
assignments: { some: { round: { competition: { programId: editionId } } } },
|
||||
},
|
||||
@@ -193,6 +194,7 @@ export const dashboardRouter = router({
|
||||
ctx.prisma.user.count({
|
||||
where: {
|
||||
role: 'JURY_MEMBER',
|
||||
isTest: false,
|
||||
status: 'ACTIVE',
|
||||
assignments: { some: { round: { competition: { programId: editionId } } } },
|
||||
},
|
||||
@@ -212,7 +214,7 @@ export const dashboardRouter = router({
|
||||
|
||||
// 13. Latest projects
|
||||
ctx.prisma.project.findMany({
|
||||
where: { programId: editionId },
|
||||
where: { programId: editionId, isTest: false },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: 8,
|
||||
select: {
|
||||
@@ -232,20 +234,20 @@ export const dashboardRouter = router({
|
||||
// 14. Category breakdown
|
||||
ctx.prisma.project.groupBy({
|
||||
by: ['competitionCategory'],
|
||||
where: { programId: editionId },
|
||||
where: { programId: editionId, isTest: false },
|
||||
_count: true,
|
||||
}),
|
||||
|
||||
// 15. Ocean issue breakdown
|
||||
ctx.prisma.project.groupBy({
|
||||
by: ['oceanIssue'],
|
||||
where: { programId: editionId },
|
||||
where: { programId: editionId, isTest: false },
|
||||
_count: true,
|
||||
}),
|
||||
|
||||
// 16. Recent activity
|
||||
// 16. Recent activity (exclude test user actions)
|
||||
ctx.prisma.auditLog.findMany({
|
||||
where: { timestamp: { gte: sevenDaysAgo } },
|
||||
where: { timestamp: { gte: sevenDaysAgo }, user: { isTest: false } },
|
||||
orderBy: { timestamp: 'desc' },
|
||||
take: 8,
|
||||
select: {
|
||||
|
||||
@@ -3,7 +3,8 @@ import { TRPCError } from '@trpc/server'
|
||||
import { router, protectedProcedure, adminProcedure, juryProcedure } from '../trpc'
|
||||
import { logAudit } from '@/server/utils/audit'
|
||||
import { notifyAdmins, NotificationTypes } from '../services/in-app-notification'
|
||||
import { processEvaluationReminders } from '../services/evaluation-reminders'
|
||||
import { reassignAfterCOI } from './assignment'
|
||||
import { sendManualReminders } from '../services/evaluation-reminders'
|
||||
import { generateSummary } from '@/server/services/ai-evaluation-summary'
|
||||
|
||||
export const evaluationRouter = router({
|
||||
@@ -132,9 +133,9 @@ export const evaluationRouter = router({
|
||||
z.object({
|
||||
id: z.string(),
|
||||
criterionScoresJson: z.record(z.union([z.number(), z.string(), z.boolean()])),
|
||||
globalScore: z.number().int().min(1).max(10),
|
||||
binaryDecision: z.boolean(),
|
||||
feedbackText: z.string().min(10),
|
||||
globalScore: z.number().int().min(1).max(10).optional(),
|
||||
binaryDecision: z.boolean().optional(),
|
||||
feedbackText: z.string().optional(),
|
||||
})
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
@@ -145,6 +146,7 @@ export const evaluationRouter = router({
|
||||
where: { id },
|
||||
include: {
|
||||
assignment: true,
|
||||
form: { select: { criteriaJson: true } },
|
||||
},
|
||||
})
|
||||
|
||||
@@ -152,6 +154,17 @@ export const evaluationRouter = router({
|
||||
throw new TRPCError({ code: 'FORBIDDEN' })
|
||||
}
|
||||
|
||||
// Server-side COI check
|
||||
const coi = await ctx.prisma.conflictOfInterest.findFirst({
|
||||
where: { assignmentId: evaluation.assignmentId, hasConflict: true },
|
||||
})
|
||||
if (coi) {
|
||||
throw new TRPCError({
|
||||
code: 'FORBIDDEN',
|
||||
message: 'Cannot submit evaluation — conflict of interest declared',
|
||||
})
|
||||
}
|
||||
|
||||
// Check voting window via round
|
||||
const round = await ctx.prisma.round.findUniqueOrThrow({
|
||||
where: { id: evaluation.assignment.roundId },
|
||||
@@ -194,12 +207,70 @@ export const evaluationRouter = router({
|
||||
})
|
||||
}
|
||||
|
||||
// Load round config for validation
|
||||
const config = (round.configJson as Record<string, unknown>) || {}
|
||||
const scoringMode = (config.scoringMode as string) || 'criteria'
|
||||
|
||||
// Fix 3: Dynamic feedback validation based on config
|
||||
const requireFeedback = config.requireFeedback !== false
|
||||
if (requireFeedback) {
|
||||
const feedbackMinLength = (config.feedbackMinLength as number) || 10
|
||||
if (!data.feedbackText || data.feedbackText.length < feedbackMinLength) {
|
||||
throw new TRPCError({
|
||||
code: 'BAD_REQUEST',
|
||||
message: `Feedback must be at least ${feedbackMinLength} characters`,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Fix 4: Normalize binaryDecision and globalScore based on scoringMode
|
||||
if (scoringMode !== 'binary') {
|
||||
data.binaryDecision = undefined
|
||||
}
|
||||
if (scoringMode === 'binary') {
|
||||
data.globalScore = undefined
|
||||
}
|
||||
|
||||
// Fix 5: requireAllCriteriaScored validation
|
||||
// Use the form the juror was assigned (evaluation.form), NOT the current active form.
|
||||
// If the admin re-saves the form, criterion IDs change — jurors who started before
|
||||
// the re-save would have scores keyed to old IDs that don't match the new form.
|
||||
if (config.requireAllCriteriaScored && scoringMode === 'criteria') {
|
||||
const evalForm = evaluation.form
|
||||
if (evalForm?.criteriaJson) {
|
||||
const criteria = evalForm.criteriaJson as Array<{ id: string; label?: string; type?: string; required?: boolean }>
|
||||
const scorableCriteria = criteria.filter(
|
||||
(c) => c.type !== 'section_header' && c.type !== 'text' && c.required !== false
|
||||
)
|
||||
const scores = data.criterionScoresJson as Record<string, unknown> | undefined
|
||||
const missingCriteria = scorableCriteria.filter((c) => {
|
||||
if (!scores) return true
|
||||
const val = scores[c.id]
|
||||
// Boolean criteria store true/false, numeric criteria store numbers
|
||||
if (c.type === 'boolean') return typeof val !== 'boolean'
|
||||
return typeof val !== 'number'
|
||||
})
|
||||
if (missingCriteria.length > 0) {
|
||||
throw new TRPCError({
|
||||
code: 'BAD_REQUEST',
|
||||
message: `Missing scores for criteria: ${missingCriteria.map((c) => c.label || c.id).join(', ')}`,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Submit evaluation and mark assignment as completed atomically
|
||||
const saveData = {
|
||||
criterionScoresJson: data.criterionScoresJson,
|
||||
globalScore: data.globalScore ?? null,
|
||||
binaryDecision: data.binaryDecision ?? null,
|
||||
feedbackText: data.feedbackText ?? null,
|
||||
}
|
||||
const [updated] = await ctx.prisma.$transaction([
|
||||
ctx.prisma.evaluation.update({
|
||||
where: { id },
|
||||
data: {
|
||||
...data,
|
||||
...saveData,
|
||||
status: 'SUBMITTED',
|
||||
submittedAt: now,
|
||||
},
|
||||
@@ -457,7 +528,7 @@ export const evaluationRouter = router({
|
||||
await logAudit({
|
||||
prisma: ctx.prisma,
|
||||
userId: ctx.user.id,
|
||||
action: 'COI_DECLARED',
|
||||
action: input.hasConflict ? 'COI_DECLARED' : 'COI_NO_CONFLICT',
|
||||
entityType: 'ConflictOfInterest',
|
||||
entityId: coi.id,
|
||||
detailsJson: {
|
||||
@@ -471,7 +542,23 @@ export const evaluationRouter = router({
|
||||
userAgent: ctx.userAgent,
|
||||
})
|
||||
|
||||
return coi
|
||||
// Auto-reassign the project to another eligible juror
|
||||
let reassignment: { newJurorId: string; newJurorName: string } | null = null
|
||||
if (input.hasConflict) {
|
||||
try {
|
||||
reassignment = await reassignAfterCOI({
|
||||
assignmentId: input.assignmentId,
|
||||
auditUserId: ctx.user.id,
|
||||
auditIp: ctx.ip,
|
||||
auditUserAgent: ctx.userAgent,
|
||||
})
|
||||
} catch (err) {
|
||||
// Don't fail the COI declaration if reassignment fails
|
||||
console.error('[COI] Auto-reassignment failed:', err)
|
||||
}
|
||||
}
|
||||
|
||||
return { ...coi, reassignment }
|
||||
}),
|
||||
|
||||
/**
|
||||
@@ -534,6 +621,17 @@ export const evaluationRouter = router({
|
||||
},
|
||||
})
|
||||
|
||||
// If admin chose "reassigned", trigger actual reassignment
|
||||
let reassignment: { newJurorId: string; newJurorName: string } | null = null
|
||||
if (input.reviewAction === 'reassigned') {
|
||||
reassignment = await reassignAfterCOI({
|
||||
assignmentId: coi.assignmentId,
|
||||
auditUserId: ctx.user.id,
|
||||
auditIp: ctx.ip,
|
||||
auditUserAgent: ctx.userAgent,
|
||||
})
|
||||
}
|
||||
|
||||
// Audit log
|
||||
await logAudit({
|
||||
prisma: ctx.prisma,
|
||||
@@ -546,12 +644,13 @@ export const evaluationRouter = router({
|
||||
assignmentId: coi.assignmentId,
|
||||
userId: coi.userId,
|
||||
projectId: coi.projectId,
|
||||
reassignedTo: reassignment?.newJurorId,
|
||||
},
|
||||
ipAddress: ctx.ip,
|
||||
userAgent: ctx.userAgent,
|
||||
})
|
||||
|
||||
return coi
|
||||
return { ...coi, reassignment }
|
||||
}),
|
||||
|
||||
// =========================================================================
|
||||
@@ -564,7 +663,7 @@ export const evaluationRouter = router({
|
||||
triggerReminders: adminProcedure
|
||||
.input(z.object({ roundId: z.string() }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const result = await processEvaluationReminders(input.roundId)
|
||||
const result = await sendManualReminders(input.roundId)
|
||||
|
||||
await logAudit({
|
||||
prisma: ctx.prisma,
|
||||
@@ -784,7 +883,7 @@ export const evaluationRouter = router({
|
||||
})
|
||||
|
||||
const settings = (stage.configJson as Record<string, unknown>) || {}
|
||||
if (!settings.peer_review_enabled) {
|
||||
if (!settings.peerReviewEnabled) {
|
||||
throw new TRPCError({
|
||||
code: 'FORBIDDEN',
|
||||
message: 'Peer review is not enabled for this stage',
|
||||
@@ -843,7 +942,7 @@ export const evaluationRouter = router({
|
||||
})
|
||||
|
||||
// Anonymize individual scores based on round settings
|
||||
const anonymizationLevel = (settings.anonymization_level as string) || 'fully_anonymous'
|
||||
const anonymizationLevel = (settings.anonymizationLevel as string) || 'fully_anonymous'
|
||||
|
||||
const individualScores = evaluations.map((e) => {
|
||||
let jurorLabel: string
|
||||
@@ -926,7 +1025,7 @@ export const evaluationRouter = router({
|
||||
where: { id: input.roundId },
|
||||
})
|
||||
const settings = (round.configJson as Record<string, unknown>) || {}
|
||||
const anonymizationLevel = (settings.anonymization_level as string) || 'fully_anonymous'
|
||||
const anonymizationLevel = (settings.anonymizationLevel as string) || 'fully_anonymous'
|
||||
|
||||
const anonymizedComments = discussion.comments.map((c: { id: string; userId: string; user: { name: string | null }; content: string; createdAt: Date }, idx: number) => {
|
||||
let authorLabel: string
|
||||
@@ -978,7 +1077,7 @@ export const evaluationRouter = router({
|
||||
where: { id: input.roundId },
|
||||
})
|
||||
const settings = (round.configJson as Record<string, unknown>) || {}
|
||||
const maxLength = (settings.max_comment_length as number) || 2000
|
||||
const maxLength = (settings.maxCommentLength as number) || 2000
|
||||
if (input.content.length > maxLength) {
|
||||
throw new TRPCError({
|
||||
code: 'BAD_REQUEST',
|
||||
|
||||
@@ -105,6 +105,7 @@ export const exportRouter = router({
|
||||
.query(async ({ ctx, input }) => {
|
||||
const projects = await ctx.prisma.project.findMany({
|
||||
where: {
|
||||
isTest: false,
|
||||
assignments: { some: { roundId: input.roundId } },
|
||||
},
|
||||
include: {
|
||||
@@ -355,7 +356,7 @@ export const exportRouter = router({
|
||||
}
|
||||
|
||||
const logs = await ctx.prisma.auditLog.findMany({
|
||||
where,
|
||||
where: { ...where, user: { isTest: false } },
|
||||
orderBy: { timestamp: 'desc' },
|
||||
include: {
|
||||
user: { select: { name: true, email: true } },
|
||||
@@ -431,7 +432,7 @@ export const exportRouter = router({
|
||||
if (includeSection('summary')) {
|
||||
const [projectCount, assignmentCount, evaluationCount, jurorCount] = await Promise.all([
|
||||
ctx.prisma.project.count({
|
||||
where: { assignments: { some: { roundId: input.roundId } } },
|
||||
where: { isTest: false, assignments: { some: { roundId: input.roundId } } },
|
||||
}),
|
||||
ctx.prisma.assignment.count({ where: { roundId: input.roundId } }),
|
||||
ctx.prisma.evaluation.count({
|
||||
@@ -486,7 +487,7 @@ export const exportRouter = router({
|
||||
// Rankings
|
||||
if (includeSection('rankings')) {
|
||||
const projects = await ctx.prisma.project.findMany({
|
||||
where: { assignments: { some: { roundId: input.roundId } } },
|
||||
where: { isTest: false, assignments: { some: { roundId: input.roundId } } },
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
|
||||
@@ -20,9 +20,9 @@ export const fileRouter = router({
|
||||
})
|
||||
)
|
||||
.query(async ({ ctx, input }) => {
|
||||
const isAdmin = ['SUPER_ADMIN', 'PROGRAM_ADMIN'].includes(ctx.user.role)
|
||||
const isAdminOrObserver = ['SUPER_ADMIN', 'PROGRAM_ADMIN', 'OBSERVER'].includes(ctx.user.role)
|
||||
|
||||
if (!isAdmin) {
|
||||
if (!isAdminOrObserver) {
|
||||
const file = await ctx.prisma.projectFile.findFirst({
|
||||
where: { bucket: input.bucket, objectKey: input.objectKey },
|
||||
select: {
|
||||
@@ -283,9 +283,9 @@ export const fileRouter = router({
|
||||
roundId: z.string().optional(),
|
||||
}))
|
||||
.query(async ({ ctx, input }) => {
|
||||
const isAdmin = ['SUPER_ADMIN', 'PROGRAM_ADMIN'].includes(ctx.user.role)
|
||||
const isAdminOrObserver = ['SUPER_ADMIN', 'PROGRAM_ADMIN', 'OBSERVER'].includes(ctx.user.role)
|
||||
|
||||
if (!isAdmin) {
|
||||
if (!isAdminOrObserver) {
|
||||
const [juryAssignment, mentorAssignment, teamMembership] = await Promise.all([
|
||||
ctx.prisma.assignment.findFirst({
|
||||
where: { userId: ctx.user.id, projectId: input.projectId },
|
||||
@@ -348,9 +348,9 @@ export const fileRouter = router({
|
||||
roundId: z.string(),
|
||||
}))
|
||||
.query(async ({ ctx, input }) => {
|
||||
const isAdmin = ['SUPER_ADMIN', 'PROGRAM_ADMIN'].includes(ctx.user.role)
|
||||
const isAdminOrObserver = ['SUPER_ADMIN', 'PROGRAM_ADMIN', 'OBSERVER'].includes(ctx.user.role)
|
||||
|
||||
if (!isAdmin) {
|
||||
if (!isAdminOrObserver) {
|
||||
const [juryAssignment, mentorAssignment, teamMembership] = await Promise.all([
|
||||
ctx.prisma.assignment.findFirst({
|
||||
where: { userId: ctx.user.id, projectId: input.projectId },
|
||||
@@ -468,9 +468,9 @@ export const fileRouter = router({
|
||||
})
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const isAdmin = ['SUPER_ADMIN', 'PROGRAM_ADMIN'].includes(ctx.user.role)
|
||||
const isAdminOrObserver = ['SUPER_ADMIN', 'PROGRAM_ADMIN', 'OBSERVER'].includes(ctx.user.role)
|
||||
|
||||
if (!isAdmin) {
|
||||
if (!isAdminOrObserver) {
|
||||
// Check user has access to the project (assigned or team member)
|
||||
const [assignment, mentorAssignment, teamMembership] = await Promise.all([
|
||||
ctx.prisma.assignment.findFirst({
|
||||
@@ -652,9 +652,9 @@ export const fileRouter = router({
|
||||
})
|
||||
)
|
||||
.query(async ({ ctx, input }) => {
|
||||
const isAdmin = ['SUPER_ADMIN', 'PROGRAM_ADMIN'].includes(ctx.user.role)
|
||||
const isAdminOrObserver = ['SUPER_ADMIN', 'PROGRAM_ADMIN', 'OBSERVER'].includes(ctx.user.role)
|
||||
|
||||
if (!isAdmin) {
|
||||
if (!isAdminOrObserver) {
|
||||
const [assignment, mentorAssignment, teamMembership] = await Promise.all([
|
||||
ctx.prisma.assignment.findFirst({
|
||||
where: { userId: ctx.user.id, projectId: input.projectId },
|
||||
@@ -994,6 +994,7 @@ export const fileRouter = router({
|
||||
// Build project filter
|
||||
const projectWhere: Record<string, unknown> = {
|
||||
programId: window.competition.programId,
|
||||
isTest: false,
|
||||
}
|
||||
if (input.search) {
|
||||
projectWhere.OR = [
|
||||
@@ -1303,6 +1304,7 @@ export const fileRouter = router({
|
||||
// Build project filter
|
||||
const projectWhere: Record<string, unknown> = {
|
||||
programId: round.competition.programId,
|
||||
isTest: false,
|
||||
}
|
||||
if (input.search) {
|
||||
projectWhere.OR = [
|
||||
|
||||
@@ -115,6 +115,7 @@ export async function runFilteringJob(jobId: string, roundId: string, userId: st
|
||||
roundId,
|
||||
exitedAt: null,
|
||||
state: { in: ['PENDING', 'IN_PROGRESS'] },
|
||||
project: { isTest: false },
|
||||
},
|
||||
include: {
|
||||
project: {
|
||||
|
||||
@@ -420,6 +420,7 @@ export const mentorRouter = router({
|
||||
const projects = await ctx.prisma.project.findMany({
|
||||
where: {
|
||||
programId: input.programId,
|
||||
isTest: false,
|
||||
mentorAssignment: null,
|
||||
wantsMentorship: true,
|
||||
},
|
||||
|
||||
@@ -402,7 +402,7 @@ async function resolveRecipients(
|
||||
const role = filter?.role as string
|
||||
if (!role) return []
|
||||
const users = await prisma.user.findMany({
|
||||
where: { role: role as any, status: 'ACTIVE' },
|
||||
where: { role: role as any, status: 'ACTIVE', isTest: false },
|
||||
select: { id: true },
|
||||
})
|
||||
return users.map((u) => u.id)
|
||||
@@ -412,7 +412,7 @@ async function resolveRecipients(
|
||||
const targetRoundId = roundId || (filter?.roundId as string)
|
||||
if (!targetRoundId) return []
|
||||
const assignments = await prisma.assignment.findMany({
|
||||
where: { roundId: targetRoundId },
|
||||
where: { roundId: targetRoundId, user: { isTest: false } },
|
||||
select: { userId: true },
|
||||
distinct: ['userId'],
|
||||
})
|
||||
@@ -423,7 +423,7 @@ async function resolveRecipients(
|
||||
const programId = filter?.programId as string
|
||||
if (!programId) return []
|
||||
const projects = await prisma.project.findMany({
|
||||
where: { programId },
|
||||
where: { programId, isTest: false },
|
||||
select: { submittedByUserId: true },
|
||||
})
|
||||
const ids = new Set(projects.map((p) => p.submittedByUserId).filter(Boolean) as string[])
|
||||
@@ -432,7 +432,7 @@ async function resolveRecipients(
|
||||
|
||||
case 'ALL': {
|
||||
const users = await prisma.user.findMany({
|
||||
where: { status: 'ACTIVE' },
|
||||
where: { status: 'ACTIVE', isTest: false },
|
||||
select: { id: true },
|
||||
})
|
||||
return users.map((u) => u.id)
|
||||
|
||||
@@ -22,7 +22,7 @@ export const programRouter = router({
|
||||
const includeStages = input?.includeStages || false
|
||||
|
||||
const programs = await ctx.prisma.program.findMany({
|
||||
where: input?.status ? { status: input.status } : undefined,
|
||||
where: input?.status ? { isTest: false, status: input.status } : { isTest: false },
|
||||
orderBy: { year: 'desc' },
|
||||
include: includeStages
|
||||
? {
|
||||
@@ -34,6 +34,10 @@ export const programRouter = router({
|
||||
_count: {
|
||||
select: { assignments: true, projectRoundStates: true },
|
||||
},
|
||||
assignments: {
|
||||
where: { evaluation: { status: 'SUBMITTED' } },
|
||||
select: { id: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -52,9 +56,11 @@ export const programRouter = router({
|
||||
// Provide `stages` as alias for backward compatibility
|
||||
stages: allRounds.map((round: any) => ({
|
||||
...round,
|
||||
assignments: undefined, // don't leak raw assignments array
|
||||
_count: {
|
||||
projects: round._count?.projectRoundStates || 0,
|
||||
assignments: round._count?.assignments || 0,
|
||||
evaluations: round.assignments?.length || 0,
|
||||
},
|
||||
})),
|
||||
// Main rounds array
|
||||
@@ -63,10 +69,12 @@ export const programRouter = router({
|
||||
name: round.name,
|
||||
competitionId: round.competitionId,
|
||||
status: round.status,
|
||||
roundType: round.roundType,
|
||||
votingEndAt: round.windowCloseAt,
|
||||
_count: {
|
||||
projects: round._count?.projectRoundStates || 0,
|
||||
assignments: round._count?.assignments || 0,
|
||||
evaluations: round.assignments?.length || 0,
|
||||
},
|
||||
})),
|
||||
}
|
||||
|
||||
@@ -103,6 +103,7 @@ export const projectPoolRouter = router({
|
||||
|
||||
// Build where clause
|
||||
const where: Record<string, unknown> = {
|
||||
isTest: false,
|
||||
programId,
|
||||
}
|
||||
|
||||
@@ -317,6 +318,7 @@ export const projectPoolRouter = router({
|
||||
|
||||
// Find projects to assign
|
||||
const where: Record<string, unknown> = {
|
||||
isTest: false,
|
||||
programId,
|
||||
}
|
||||
|
||||
|
||||
@@ -84,7 +84,9 @@ export const projectRouter = router({
|
||||
const skip = (page - 1) * perPage
|
||||
|
||||
// Build where clause
|
||||
const where: Record<string, unknown> = {}
|
||||
const where: Record<string, unknown> = {
|
||||
isTest: false,
|
||||
}
|
||||
|
||||
// Filter by program
|
||||
if (programId) where.programId = programId
|
||||
@@ -219,7 +221,9 @@ export const projectRouter = router({
|
||||
wantsMentorship, hasFiles, hasAssignments,
|
||||
} = input
|
||||
|
||||
const where: Record<string, unknown> = {}
|
||||
const where: Record<string, unknown> = {
|
||||
isTest: false,
|
||||
}
|
||||
|
||||
if (programId) where.programId = programId
|
||||
if (roundId) {
|
||||
@@ -357,19 +361,19 @@ export const projectRouter = router({
|
||||
.query(async ({ ctx }) => {
|
||||
const [countries, categories, issues] = await Promise.all([
|
||||
ctx.prisma.project.findMany({
|
||||
where: { country: { not: null } },
|
||||
where: { isTest: false, country: { not: null } },
|
||||
select: { country: true },
|
||||
distinct: ['country'],
|
||||
orderBy: { country: 'asc' },
|
||||
}),
|
||||
ctx.prisma.project.groupBy({
|
||||
by: ['competitionCategory'],
|
||||
where: { competitionCategory: { not: null } },
|
||||
where: { isTest: false, competitionCategory: { not: null } },
|
||||
_count: true,
|
||||
}),
|
||||
ctx.prisma.project.groupBy({
|
||||
by: ['oceanIssue'],
|
||||
where: { oceanIssue: { not: null } },
|
||||
where: { isTest: false, oceanIssue: { not: null } },
|
||||
_count: true,
|
||||
}),
|
||||
])
|
||||
@@ -838,7 +842,7 @@ export const projectRouter = router({
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const projects = await ctx.prisma.project.findMany({
|
||||
where: { id: { in: input.ids } },
|
||||
where: { id: { in: input.ids }, isTest: false },
|
||||
select: { id: true, title: true, status: true },
|
||||
})
|
||||
|
||||
@@ -948,11 +952,13 @@ export const projectRouter = router({
|
||||
programId: z.string().optional(),
|
||||
}))
|
||||
.query(async ({ ctx, input }) => {
|
||||
const where: Record<string, unknown> = {}
|
||||
const where: Record<string, unknown> = {
|
||||
isTest: false,
|
||||
}
|
||||
if (input.programId) where.programId = input.programId
|
||||
|
||||
const projects = await ctx.prisma.project.findMany({
|
||||
where: Object.keys(where).length > 0 ? where : undefined,
|
||||
where,
|
||||
select: { tags: true },
|
||||
})
|
||||
|
||||
@@ -984,6 +990,7 @@ export const projectRouter = router({
|
||||
const projects = await ctx.prisma.project.findMany({
|
||||
where: {
|
||||
id: { in: input.ids },
|
||||
isTest: false,
|
||||
},
|
||||
select: { id: true, title: true },
|
||||
})
|
||||
@@ -1102,6 +1109,7 @@ export const projectRouter = router({
|
||||
|
||||
const where: Record<string, unknown> = {
|
||||
programId,
|
||||
isTest: false,
|
||||
projectRoundStates: { none: {} }, // Projects not assigned to any round
|
||||
}
|
||||
|
||||
@@ -1190,7 +1198,23 @@ export const projectRouter = router({
|
||||
const globalScores = submittedEvaluations
|
||||
.map((e) => e.globalScore)
|
||||
.filter((s): s is number => s !== null)
|
||||
const yesVotes = submittedEvaluations.filter((e) => e.binaryDecision === true).length
|
||||
|
||||
// Count recommendations: check binaryDecision first, fall back to boolean criteria
|
||||
const yesVotes = submittedEvaluations.filter((e) => {
|
||||
if (e.binaryDecision != null) return e.binaryDecision === true
|
||||
const scores = e.criterionScoresJson as Record<string, unknown> | null
|
||||
if (!scores) return false
|
||||
const boolValues = Object.values(scores).filter((v) => typeof v === 'boolean')
|
||||
return boolValues.length > 0 && boolValues.every((v) => v === true)
|
||||
}).length
|
||||
|
||||
const hasRecommendationData = submittedEvaluations.some((e) => {
|
||||
if (e.binaryDecision != null) return true
|
||||
const scores = e.criterionScoresJson as Record<string, unknown> | null
|
||||
if (!scores) return false
|
||||
return Object.values(scores).some((v) => typeof v === 'boolean')
|
||||
})
|
||||
|
||||
stats = {
|
||||
totalEvaluations: submittedEvaluations.length,
|
||||
averageGlobalScore: globalScores.length > 0
|
||||
@@ -1200,7 +1224,9 @@ export const projectRouter = router({
|
||||
maxScore: globalScores.length > 0 ? Math.max(...globalScores) : null,
|
||||
yesVotes,
|
||||
noVotes: submittedEvaluations.length - yesVotes,
|
||||
yesPercentage: (yesVotes / submittedEvaluations.length) * 100,
|
||||
yesPercentage: hasRecommendationData
|
||||
? (yesVotes / submittedEvaluations.length) * 100
|
||||
: null,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1249,4 +1275,97 @@ export const projectRouter = router({
|
||||
stats,
|
||||
}
|
||||
}),
|
||||
|
||||
/**
|
||||
* Create a new project and assign it directly to a round.
|
||||
* Used for late-arriving projects that need to enter a specific round immediately.
|
||||
*/
|
||||
createAndAssignToRound: adminProcedure
|
||||
.input(
|
||||
z.object({
|
||||
title: z.string().min(1).max(500),
|
||||
teamName: z.string().optional(),
|
||||
description: z.string().optional(),
|
||||
country: z.string().optional(),
|
||||
competitionCategory: z.enum(['STARTUP', 'BUSINESS_CONCEPT']).optional(),
|
||||
roundId: z.string(),
|
||||
})
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const { roundId, country, ...projectFields } = input
|
||||
|
||||
// Get the round to find competitionId, then competition to find programId
|
||||
const round = await ctx.prisma.round.findUniqueOrThrow({
|
||||
where: { id: roundId },
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
competition: {
|
||||
select: {
|
||||
id: true,
|
||||
programId: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
// Normalize country to ISO code if provided
|
||||
const normalizedCountry = country
|
||||
? normalizeCountryToCode(country)
|
||||
: undefined
|
||||
|
||||
const project = await ctx.prisma.$transaction(async (tx) => {
|
||||
// 1. Create the project
|
||||
const created = await tx.project.create({
|
||||
data: {
|
||||
programId: round.competition.programId,
|
||||
title: projectFields.title,
|
||||
teamName: projectFields.teamName,
|
||||
description: projectFields.description,
|
||||
country: normalizedCountry,
|
||||
competitionCategory: projectFields.competitionCategory,
|
||||
status: 'ASSIGNED',
|
||||
},
|
||||
})
|
||||
|
||||
// 2. Create ProjectRoundState entry
|
||||
await tx.projectRoundState.create({
|
||||
data: {
|
||||
projectId: created.id,
|
||||
roundId,
|
||||
state: 'PENDING',
|
||||
},
|
||||
})
|
||||
|
||||
// 3. Create ProjectStatusHistory entry
|
||||
await tx.projectStatusHistory.create({
|
||||
data: {
|
||||
projectId: created.id,
|
||||
status: 'ASSIGNED',
|
||||
changedBy: ctx.user.id,
|
||||
},
|
||||
})
|
||||
|
||||
return created
|
||||
})
|
||||
|
||||
// Audit outside transaction
|
||||
await logAudit({
|
||||
prisma: ctx.prisma,
|
||||
userId: ctx.user.id,
|
||||
action: 'CREATE_AND_ASSIGN',
|
||||
entityType: 'Project',
|
||||
entityId: project.id,
|
||||
detailsJson: {
|
||||
title: input.title,
|
||||
roundId,
|
||||
roundName: round.name,
|
||||
programId: round.competition.programId,
|
||||
},
|
||||
ipAddress: ctx.ip,
|
||||
userAgent: ctx.userAgent,
|
||||
})
|
||||
|
||||
return project
|
||||
}),
|
||||
})
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { z } from 'zod'
|
||||
import { TRPCError } from '@trpc/server'
|
||||
import { Prisma } from '@prisma/client'
|
||||
import { Prisma, type PrismaClient } from '@prisma/client'
|
||||
import { router, adminProcedure, protectedProcedure } from '../trpc'
|
||||
import { logAudit } from '@/server/utils/audit'
|
||||
import { validateRoundConfig, defaultRoundConfig } from '@/types/competition-configs'
|
||||
import { generateShortlist } from '../services/ai-shortlist'
|
||||
import { createBulkNotifications } from '../services/in-app-notification'
|
||||
import { sendAnnouncementEmail } from '@/lib/email'
|
||||
import {
|
||||
openWindow,
|
||||
closeWindow,
|
||||
@@ -255,19 +257,43 @@ export const roundRouter = router({
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const { roundId, targetRoundId, projectIds, autoPassPending } = input
|
||||
|
||||
// Get current round with competition context
|
||||
// Get current round with competition context + status
|
||||
const currentRound = await ctx.prisma.round.findUniqueOrThrow({
|
||||
where: { id: roundId },
|
||||
select: { id: true, name: true, competitionId: true, sortOrder: true },
|
||||
select: { id: true, name: true, competitionId: true, sortOrder: true, status: true, configJson: true },
|
||||
})
|
||||
|
||||
// Validate: current round must be ROUND_ACTIVE or ROUND_CLOSED
|
||||
if (currentRound.status !== 'ROUND_ACTIVE' && currentRound.status !== 'ROUND_CLOSED') {
|
||||
throw new TRPCError({
|
||||
code: 'BAD_REQUEST',
|
||||
message: `Cannot advance from round with status ${currentRound.status}. Round must be ROUND_ACTIVE or ROUND_CLOSED.`,
|
||||
})
|
||||
}
|
||||
|
||||
// Determine target round
|
||||
let targetRound: { id: string; name: string }
|
||||
let targetRound: { id: string; name: string; competitionId: string; sortOrder: number; configJson: unknown }
|
||||
if (targetRoundId) {
|
||||
targetRound = await ctx.prisma.round.findUniqueOrThrow({
|
||||
where: { id: targetRoundId },
|
||||
select: { id: true, name: true },
|
||||
select: { id: true, name: true, competitionId: true, sortOrder: true, configJson: true },
|
||||
})
|
||||
|
||||
// Validate: target must be in same competition
|
||||
if (targetRound.competitionId !== currentRound.competitionId) {
|
||||
throw new TRPCError({
|
||||
code: 'BAD_REQUEST',
|
||||
message: 'Target round must belong to the same competition as the source round.',
|
||||
})
|
||||
}
|
||||
|
||||
// Validate: target must be after current round
|
||||
if (targetRound.sortOrder <= currentRound.sortOrder) {
|
||||
throw new TRPCError({
|
||||
code: 'BAD_REQUEST',
|
||||
message: 'Target round must come after the current round (higher sortOrder).',
|
||||
})
|
||||
}
|
||||
} else {
|
||||
// Find next round in same competition by sortOrder
|
||||
const nextRound = await ctx.prisma.round.findFirst({
|
||||
@@ -276,7 +302,7 @@ export const roundRouter = router({
|
||||
sortOrder: { gt: currentRound.sortOrder },
|
||||
},
|
||||
orderBy: { sortOrder: 'asc' },
|
||||
select: { id: true, name: true },
|
||||
select: { id: true, name: true, competitionId: true, sortOrder: true, configJson: true },
|
||||
})
|
||||
if (!nextRound) {
|
||||
throw new TRPCError({
|
||||
@@ -287,35 +313,50 @@ export const roundRouter = router({
|
||||
targetRound = nextRound
|
||||
}
|
||||
|
||||
// Auto-pass all PENDING projects first (for intake/bulk workflows)
|
||||
let autoPassedCount = 0
|
||||
if (autoPassPending) {
|
||||
const result = await ctx.prisma.projectRoundState.updateMany({
|
||||
where: { roundId, state: 'PENDING' },
|
||||
data: { state: 'PASSED' },
|
||||
})
|
||||
autoPassedCount = result.count
|
||||
}
|
||||
|
||||
// Determine which projects to advance
|
||||
let idsToAdvance: string[]
|
||||
// Validate projectIds exist in current round if provided
|
||||
if (projectIds && projectIds.length > 0) {
|
||||
idsToAdvance = projectIds
|
||||
} else {
|
||||
// Default: all PASSED projects in current round
|
||||
const passedStates = await ctx.prisma.projectRoundState.findMany({
|
||||
where: { roundId, state: 'PASSED' },
|
||||
const existingStates = await ctx.prisma.projectRoundState.findMany({
|
||||
where: { roundId, projectId: { in: projectIds } },
|
||||
select: { projectId: true },
|
||||
})
|
||||
idsToAdvance = passedStates.map((s) => s.projectId)
|
||||
const existingIds = new Set(existingStates.map((s) => s.projectId))
|
||||
const missing = projectIds.filter((id) => !existingIds.has(id))
|
||||
if (missing.length > 0) {
|
||||
throw new TRPCError({
|
||||
code: 'BAD_REQUEST',
|
||||
message: `Projects not found in current round: ${missing.join(', ')}`,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if (idsToAdvance.length === 0) {
|
||||
return { advancedCount: 0, targetRoundId: targetRound.id, targetRoundName: targetRound.name }
|
||||
}
|
||||
// Transaction: auto-pass + create entries in target round + mark current as COMPLETED
|
||||
let autoPassedCount = 0
|
||||
let idsToAdvance: string[]
|
||||
|
||||
// Transaction: create entries in target round + mark current as COMPLETED
|
||||
await ctx.prisma.$transaction(async (tx) => {
|
||||
// Auto-pass all PENDING projects first (for intake/bulk workflows) — inside tx
|
||||
if (autoPassPending) {
|
||||
const result = await tx.projectRoundState.updateMany({
|
||||
where: { roundId, state: 'PENDING' },
|
||||
data: { state: 'PASSED' },
|
||||
})
|
||||
autoPassedCount = result.count
|
||||
}
|
||||
|
||||
// Determine which projects to advance
|
||||
if (projectIds && projectIds.length > 0) {
|
||||
idsToAdvance = projectIds
|
||||
} else {
|
||||
// Default: all PASSED projects in current round
|
||||
const passedStates = await tx.projectRoundState.findMany({
|
||||
where: { roundId, state: 'PASSED' },
|
||||
select: { projectId: true },
|
||||
})
|
||||
idsToAdvance = passedStates.map((s) => s.projectId)
|
||||
}
|
||||
|
||||
if (idsToAdvance.length === 0) return
|
||||
|
||||
// Create ProjectRoundState in target round
|
||||
await tx.projectRoundState.createMany({
|
||||
data: idsToAdvance.map((projectId) => ({
|
||||
@@ -351,6 +392,12 @@ export const roundRouter = router({
|
||||
})
|
||||
})
|
||||
|
||||
// If nothing to advance (set inside tx), return early
|
||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
||||
if (!idsToAdvance! || idsToAdvance!.length === 0) {
|
||||
return { advancedCount: 0, autoPassedCount, targetRoundId: targetRound.id, targetRoundName: targetRound.name }
|
||||
}
|
||||
|
||||
// Audit
|
||||
await logAudit({
|
||||
prisma: ctx.prisma,
|
||||
@@ -362,16 +409,105 @@ export const roundRouter = router({
|
||||
fromRound: currentRound.name,
|
||||
toRound: targetRound.name,
|
||||
targetRoundId: targetRound.id,
|
||||
projectCount: idsToAdvance.length,
|
||||
projectCount: idsToAdvance!.length,
|
||||
autoPassedCount,
|
||||
projectIds: idsToAdvance,
|
||||
projectIds: idsToAdvance!,
|
||||
},
|
||||
ipAddress: ctx.ip,
|
||||
userAgent: ctx.userAgent,
|
||||
})
|
||||
|
||||
// Fix 5: notifyOnEntry — notify team members when projects enter target round
|
||||
try {
|
||||
const targetConfig = (targetRound.configJson as Record<string, unknown>) || {}
|
||||
if (targetConfig.notifyOnEntry) {
|
||||
const teamMembers = await ctx.prisma.teamMember.findMany({
|
||||
where: { projectId: { in: idsToAdvance! } },
|
||||
select: { userId: true },
|
||||
})
|
||||
const userIds = [...new Set(teamMembers.map((tm) => tm.userId))]
|
||||
if (userIds.length > 0) {
|
||||
void createBulkNotifications({
|
||||
userIds,
|
||||
type: 'round_entry',
|
||||
title: `Projects entered: ${targetRound.name}`,
|
||||
message: `Your project has been advanced to the round "${targetRound.name}".`,
|
||||
linkUrl: '/dashboard',
|
||||
linkLabel: 'View Dashboard',
|
||||
icon: 'ArrowRight',
|
||||
})
|
||||
}
|
||||
}
|
||||
} catch (notifyErr) {
|
||||
console.error('[advanceProjects] notifyOnEntry notification failed (non-fatal):', notifyErr)
|
||||
}
|
||||
|
||||
// Fix 6: notifyOnAdvance — notify applicants from source round that projects advanced
|
||||
try {
|
||||
const sourceConfig = (currentRound.configJson as Record<string, unknown>) || {}
|
||||
if (sourceConfig.notifyOnAdvance) {
|
||||
const projects = await ctx.prisma.project.findMany({
|
||||
where: { id: { in: idsToAdvance! } },
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
submittedByEmail: true,
|
||||
teamMembers: {
|
||||
select: { user: { select: { id: true, email: true, name: true } } },
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
// Collect unique user IDs for in-app notifications
|
||||
const applicantUserIds = new Set<string>()
|
||||
for (const project of projects) {
|
||||
for (const tm of project.teamMembers) {
|
||||
applicantUserIds.add(tm.user.id)
|
||||
}
|
||||
}
|
||||
|
||||
if (applicantUserIds.size > 0) {
|
||||
void createBulkNotifications({
|
||||
userIds: [...applicantUserIds],
|
||||
type: 'project_advanced',
|
||||
title: 'Your project has advanced!',
|
||||
message: `Congratulations! Your project has advanced from "${currentRound.name}" to "${targetRound.name}".`,
|
||||
linkUrl: '/dashboard',
|
||||
linkLabel: 'View Dashboard',
|
||||
icon: 'Trophy',
|
||||
priority: 'high',
|
||||
})
|
||||
}
|
||||
|
||||
// Send emails to team members (fire-and-forget)
|
||||
for (const project of projects) {
|
||||
const recipients = new Map<string, string | null>()
|
||||
for (const tm of project.teamMembers) {
|
||||
if (tm.user.email) recipients.set(tm.user.email, tm.user.name)
|
||||
}
|
||||
if (recipients.size === 0 && project.submittedByEmail) {
|
||||
recipients.set(project.submittedByEmail, null)
|
||||
}
|
||||
for (const [email, name] of recipients) {
|
||||
void sendAnnouncementEmail(
|
||||
email,
|
||||
name,
|
||||
`Your project has advanced to: ${targetRound.name}`,
|
||||
`Congratulations! Your project "${project.title}" has advanced from "${currentRound.name}" to "${targetRound.name}" in the Monaco Ocean Protection Challenge.`,
|
||||
'View Your Dashboard',
|
||||
`${process.env.NEXTAUTH_URL || 'https://monaco-opc.com'}/dashboard`,
|
||||
).catch((err) => {
|
||||
console.error(`[advanceProjects] notifyOnAdvance email failed for ${email}:`, err)
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (notifyErr) {
|
||||
console.error('[advanceProjects] notifyOnAdvance notification failed (non-fatal):', notifyErr)
|
||||
}
|
||||
|
||||
return {
|
||||
advancedCount: idsToAdvance.length,
|
||||
advancedCount: idsToAdvance!.length,
|
||||
autoPassedCount,
|
||||
targetRoundId: targetRound.id,
|
||||
targetRoundName: targetRound.name,
|
||||
|
||||
@@ -105,6 +105,7 @@ export const roundEngineRouter = router({
|
||||
input.newState,
|
||||
ctx.user.id,
|
||||
ctx.prisma,
|
||||
{ adminOverride: true },
|
||||
)
|
||||
if (!result.success) {
|
||||
throw new TRPCError({
|
||||
@@ -133,6 +134,7 @@ export const roundEngineRouter = router({
|
||||
input.newState,
|
||||
ctx.user.id,
|
||||
ctx.prisma,
|
||||
{ adminOverride: true },
|
||||
)
|
||||
}),
|
||||
|
||||
@@ -188,6 +190,14 @@ export const roundEngineRouter = router({
|
||||
|
||||
const roundIds = roundsToRemoveFrom.map((r) => r.id)
|
||||
|
||||
// Delete Assignment records first (Prisma cascade handles Evaluations)
|
||||
await ctx.prisma.assignment.deleteMany({
|
||||
where: {
|
||||
projectId: input.projectId,
|
||||
roundId: { in: roundIds },
|
||||
},
|
||||
})
|
||||
|
||||
// Delete ProjectRoundState entries for this project in all affected rounds
|
||||
const deleted = await ctx.prisma.projectRoundState.deleteMany({
|
||||
where: {
|
||||
@@ -238,6 +248,14 @@ export const roundEngineRouter = router({
|
||||
|
||||
const roundIds = roundsToRemoveFrom.map((r) => r.id)
|
||||
|
||||
// Delete Assignment records first (Prisma cascade handles Evaluations)
|
||||
await ctx.prisma.assignment.deleteMany({
|
||||
where: {
|
||||
projectId: { in: input.projectIds },
|
||||
roundId: { in: roundIds },
|
||||
},
|
||||
})
|
||||
|
||||
const deleted = await ctx.prisma.projectRoundState.deleteMany({
|
||||
where: {
|
||||
projectId: { in: input.projectIds },
|
||||
|
||||
@@ -17,6 +17,11 @@ function categorizeModel(modelId: string): string {
|
||||
if (id.startsWith('gpt-4')) return 'gpt-4'
|
||||
if (id.startsWith('gpt-3.5')) return 'gpt-3.5'
|
||||
if (id.startsWith('o1') || id.startsWith('o3') || id.startsWith('o4')) return 'reasoning'
|
||||
// Anthropic Claude models
|
||||
if (id.startsWith('claude-opus-4-5') || id.startsWith('claude-sonnet-4-5')) return 'claude-4.5'
|
||||
if (id.startsWith('claude-opus-4') || id.startsWith('claude-sonnet-4')) return 'claude-4'
|
||||
if (id.startsWith('claude-haiku') || id.startsWith('claude-3')) return 'claude-3.5'
|
||||
if (id.startsWith('claude')) return 'claude-4'
|
||||
return 'other'
|
||||
}
|
||||
|
||||
@@ -26,16 +31,10 @@ export const settingsRouter = router({
|
||||
* These are non-sensitive settings that can be exposed to any user
|
||||
*/
|
||||
getFeatureFlags: protectedProcedure.query(async ({ ctx }) => {
|
||||
const [whatsappEnabled, defaultLocale, availableLocales, juryCompareEnabled] = await Promise.all([
|
||||
const [whatsappEnabled, juryCompareEnabled] = await Promise.all([
|
||||
ctx.prisma.systemSettings.findUnique({
|
||||
where: { key: 'whatsapp_enabled' },
|
||||
}),
|
||||
ctx.prisma.systemSettings.findUnique({
|
||||
where: { key: 'i18n_default_locale' },
|
||||
}),
|
||||
ctx.prisma.systemSettings.findUnique({
|
||||
where: { key: 'i18n_available_locales' },
|
||||
}),
|
||||
ctx.prisma.systemSettings.findUnique({
|
||||
where: { key: 'jury_compare_enabled' },
|
||||
}),
|
||||
@@ -43,8 +42,6 @@ export const settingsRouter = router({
|
||||
|
||||
return {
|
||||
whatsappEnabled: whatsappEnabled?.value === 'true',
|
||||
defaultLocale: defaultLocale?.value || 'en',
|
||||
availableLocales: availableLocales?.value ? JSON.parse(availableLocales.value) : ['en', 'fr'],
|
||||
juryCompareEnabled: juryCompareEnabled?.value === 'true',
|
||||
}
|
||||
}),
|
||||
@@ -171,14 +168,13 @@ export const settingsRouter = router({
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
// Infer category from key prefix if not provided
|
||||
const inferCategory = (key: string): 'AI' | 'BRANDING' | 'EMAIL' | 'STORAGE' | 'SECURITY' | 'DEFAULTS' | 'WHATSAPP' | 'LOCALIZATION' => {
|
||||
if (key.startsWith('openai') || key.startsWith('ai_')) return 'AI'
|
||||
const inferCategory = (key: string): 'AI' | 'BRANDING' | 'EMAIL' | 'STORAGE' | 'SECURITY' | 'DEFAULTS' | 'WHATSAPP' => {
|
||||
if (key.startsWith('openai') || key.startsWith('ai_') || key.startsWith('anthropic')) return 'AI'
|
||||
if (key.startsWith('smtp_') || key.startsWith('email_')) return 'EMAIL'
|
||||
if (key.startsWith('storage_') || key.startsWith('local_storage') || key.startsWith('max_file') || key.startsWith('avatar_') || key.startsWith('allowed_file')) return 'STORAGE'
|
||||
if (key.startsWith('brand_') || key.startsWith('logo_') || key.startsWith('primary_') || key.startsWith('theme_')) return 'BRANDING'
|
||||
if (key.startsWith('whatsapp_')) return 'WHATSAPP'
|
||||
if (key.startsWith('security_') || key.startsWith('session_')) return 'SECURITY'
|
||||
if (key.startsWith('i18n_') || key.startsWith('locale_')) return 'LOCALIZATION'
|
||||
return 'DEFAULTS'
|
||||
}
|
||||
|
||||
@@ -206,7 +202,7 @@ export const settingsRouter = router({
|
||||
}
|
||||
|
||||
// Reset OpenAI client if API key, base URL, model, or provider changed
|
||||
if (input.settings.some((s) => s.key === 'openai_api_key' || s.key === 'openai_base_url' || s.key === 'ai_model' || s.key === 'ai_provider')) {
|
||||
if (input.settings.some((s) => s.key === 'openai_api_key' || s.key === 'anthropic_api_key' || s.key === 'openai_base_url' || s.key === 'ai_model' || s.key === 'ai_provider')) {
|
||||
const { resetOpenAIClient } = await import('@/lib/openai')
|
||||
resetOpenAIClient()
|
||||
}
|
||||
@@ -276,9 +272,9 @@ export const settingsRouter = router({
|
||||
category: categorizeModel(model),
|
||||
}))
|
||||
|
||||
// Sort: GPT-5+ first, then GPT-4o, then other GPT-4, then GPT-3.5, then reasoning models
|
||||
// Sort by category priority
|
||||
const sorted = categorizedModels.sort((a, b) => {
|
||||
const order = ['gpt-5+', 'gpt-4o', 'gpt-4', 'gpt-3.5', 'reasoning', 'other']
|
||||
const order = ['claude-4.5', 'claude-4', 'claude-3.5', 'gpt-5+', 'gpt-4o', 'gpt-4', 'gpt-3.5', 'reasoning', 'other']
|
||||
const aOrder = order.findIndex(cat => a.category === cat)
|
||||
const bOrder = order.findIndex(cat => b.category === cat)
|
||||
if (aOrder !== bOrder) return aOrder - bOrder
|
||||
@@ -740,62 +736,4 @@ export const settingsRouter = router({
|
||||
return results
|
||||
}),
|
||||
|
||||
/**
|
||||
* Get localization settings
|
||||
*/
|
||||
getLocalizationSettings: adminProcedure.query(async ({ ctx }) => {
|
||||
const settings = await ctx.prisma.systemSettings.findMany({
|
||||
where: { category: 'LOCALIZATION' },
|
||||
orderBy: { key: 'asc' },
|
||||
})
|
||||
|
||||
return settings
|
||||
}),
|
||||
|
||||
/**
|
||||
* Update localization settings
|
||||
*/
|
||||
updateLocalizationSettings: superAdminProcedure
|
||||
.input(
|
||||
z.object({
|
||||
settings: z.array(
|
||||
z.object({
|
||||
key: z.string(),
|
||||
value: z.string(),
|
||||
})
|
||||
),
|
||||
})
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const results = await Promise.all(
|
||||
input.settings.map((s) =>
|
||||
ctx.prisma.systemSettings.upsert({
|
||||
where: { key: s.key },
|
||||
update: { value: s.value, updatedBy: ctx.user.id },
|
||||
create: {
|
||||
key: s.key,
|
||||
value: s.value,
|
||||
category: 'LOCALIZATION',
|
||||
updatedBy: ctx.user.id,
|
||||
},
|
||||
})
|
||||
)
|
||||
)
|
||||
|
||||
try {
|
||||
await logAudit({
|
||||
prisma: ctx.prisma,
|
||||
userId: ctx.user.id,
|
||||
action: 'UPDATE_LOCALIZATION_SETTINGS',
|
||||
entityType: 'SystemSettings',
|
||||
detailsJson: { keys: input.settings.map((s) => s.key) },
|
||||
ipAddress: ctx.ip,
|
||||
userAgent: ctx.userAgent,
|
||||
})
|
||||
} catch {
|
||||
// Never throw on audit failure
|
||||
}
|
||||
|
||||
return results
|
||||
}),
|
||||
})
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user