Compare commits
122 Commits
f572336781
...
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 | ||
|
|
51e18870b6 | ||
|
|
ae1685179c | ||
|
|
d117090fca | ||
|
|
099157bf74 | ||
| 1308c3ba87 | |||
| aa1bf564ee | |||
|
|
6838b01724 | ||
|
|
735b841f4a | ||
|
|
7c3f041892 | ||
|
|
998ffe3af8 | ||
|
|
6abf962fa0 | ||
|
|
8bbdc31d17 | ||
|
|
a212bde51b | ||
|
|
7e85348a6d | ||
|
|
cab311fbbb | ||
|
|
9c19661400 | ||
|
|
8d28104d51 | ||
|
|
0f6473c999 | ||
|
|
9ce56f13fd | ||
|
|
73759eaddd | ||
|
|
f814cf6dc4 | ||
|
|
9b1b319362 | ||
|
|
7b16873b9c | ||
|
|
fc7a37094b | ||
|
|
35f30af7ce | ||
|
|
6e9fcda45a | ||
| 1ec2247295 | |||
| 1c68512598 | |||
| 04c54b6794 | |||
| d02b0b91b9 | |||
| 8a7da0fd93 | |||
| 70d24036f9 | |||
| 619206c03f | |||
| 1fe6667400 | |||
|
|
4fa3ca0bb6 | ||
|
|
cf1508f856 | ||
|
|
bed444e5f4 | ||
|
|
a4ff278db2 | ||
|
|
1c6961355b | ||
|
|
a02ed59158 | ||
|
|
6743119c4d | ||
|
|
a7b6031f4d | ||
|
|
a62f511d7f | ||
|
|
cef4709444 | ||
|
|
cf3c7631cb | ||
|
|
b3b3bbb8b3 | ||
|
|
bfdbd0fc6a | ||
|
|
ef1bf24388 | ||
|
|
f9016168e7 | ||
|
|
a006c6505c | ||
|
|
d80043c4aa | ||
|
|
1a0525c108 | ||
|
|
842e79e319 | ||
|
|
ed5e782f61 | ||
|
|
c9640c6086 | ||
|
|
771f35c695 | ||
|
|
fbeec846a3 | ||
|
|
cfeef9a601 | ||
|
|
fcee8761b9 | ||
| 7b98b64c1c | |||
|
|
09049d2911 | ||
|
|
3fb0d128a1 | ||
|
|
5965f7889d | ||
|
|
b2279067e2 | ||
|
|
014bb15890 | ||
|
|
f12c29103c | ||
|
|
65a22e6f19 | ||
|
|
989db4dc14 | ||
|
|
5e0c8b2dfe | ||
|
|
85a0fa5016 | ||
|
|
c707899179 | ||
|
|
4d40afec6e | ||
|
|
effc078918 | ||
|
|
763b2ef0f5 | ||
|
|
86fa542371 | ||
|
|
079468d2ca | ||
| de73a6f080 | |||
| 80c9e35971 | |||
| 93f4ad4b31 | |||
| 8e5fc18da6 | |||
| 845554fdb8 | |||
| 7f334ed095 |
@@ -11,7 +11,7 @@ RUN apk add --no-cache libc6-compat
|
|||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# Copy package files
|
# Copy package files
|
||||||
COPY package.json package-lock.json* ./
|
COPY package.json package-lock.json* .npmrc* ./
|
||||||
RUN npm ci
|
RUN npm ci
|
||||||
|
|
||||||
# Rebuild the source code only when needed
|
# Rebuild the source code only when needed
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ WORKDIR /app
|
|||||||
RUN apk add --no-cache libc6-compat openssl
|
RUN apk add --no-cache libc6-compat openssl
|
||||||
|
|
||||||
# Copy package files
|
# Copy package files
|
||||||
COPY package.json package-lock.json* ./
|
COPY package.json package-lock.json* .npmrc* ./
|
||||||
|
|
||||||
# Install dependencies
|
# Install dependencies
|
||||||
RUN npm install && npm install tailwindcss-animate
|
RUN npm install && npm install tailwindcss-animate
|
||||||
|
|||||||
1091
package-lock.json
generated
1091
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -21,6 +21,7 @@
|
|||||||
"test:e2e": "playwright test"
|
"test:e2e": "playwright test"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@anthropic-ai/sdk": "^0.78.0",
|
||||||
"@auth/prisma-adapter": "^2.7.4",
|
"@auth/prisma-adapter": "^2.7.4",
|
||||||
"@blocknote/core": "^0.46.2",
|
"@blocknote/core": "^0.46.2",
|
||||||
"@blocknote/mantine": "^0.46.2",
|
"@blocknote/mantine": "^0.46.2",
|
||||||
@@ -50,9 +51,11 @@
|
|||||||
"@radix-ui/react-slot": "^1.1.1",
|
"@radix-ui/react-slot": "^1.1.1",
|
||||||
"@radix-ui/react-switch": "^1.1.2",
|
"@radix-ui/react-switch": "^1.1.2",
|
||||||
"@radix-ui/react-tabs": "^1.1.2",
|
"@radix-ui/react-tabs": "^1.1.2",
|
||||||
|
"@radix-ui/react-toggle": "^1.1.10",
|
||||||
"@radix-ui/react-tooltip": "^1.1.6",
|
"@radix-ui/react-tooltip": "^1.1.6",
|
||||||
"@tailwindcss/postcss": "^4.1.18",
|
"@tailwindcss/postcss": "^4.1.18",
|
||||||
"@tanstack/react-query": "^5.62.0",
|
"@tanstack/react-query": "^5.62.0",
|
||||||
|
"@tremor/react": "^3.18.7",
|
||||||
"@trpc/client": "^11.0.0-rc.678",
|
"@trpc/client": "^11.0.0-rc.678",
|
||||||
"@trpc/react-query": "^11.0.0-rc.678",
|
"@trpc/react-query": "^11.0.0-rc.678",
|
||||||
"@trpc/server": "^11.0.0-rc.678",
|
"@trpc/server": "^11.0.0-rc.678",
|
||||||
@@ -62,11 +65,13 @@
|
|||||||
"cmdk": "^1.0.4",
|
"cmdk": "^1.0.4",
|
||||||
"csv-parse": "^6.1.0",
|
"csv-parse": "^6.1.0",
|
||||||
"date-fns": "^4.1.0",
|
"date-fns": "^4.1.0",
|
||||||
|
"franc": "^6.2.0",
|
||||||
"html2canvas": "^1.4.1",
|
"html2canvas": "^1.4.1",
|
||||||
"jspdf": "^4.1.0",
|
"jspdf": "^4.1.0",
|
||||||
"jspdf-autotable": "^5.0.7",
|
"jspdf-autotable": "^5.0.7",
|
||||||
"leaflet": "^1.9.4",
|
"leaflet": "^1.9.4",
|
||||||
"lucide-react": "^0.563.0",
|
"lucide-react": "^0.563.0",
|
||||||
|
"mammoth": "^1.11.0",
|
||||||
"minio": "^8.0.2",
|
"minio": "^8.0.2",
|
||||||
"motion": "^11.15.0",
|
"motion": "^11.15.0",
|
||||||
"next": "^15.1.0",
|
"next": "^15.1.0",
|
||||||
@@ -75,6 +80,7 @@
|
|||||||
"nodemailer": "^7.0.7",
|
"nodemailer": "^7.0.7",
|
||||||
"openai": "^6.16.0",
|
"openai": "^6.16.0",
|
||||||
"papaparse": "^5.4.1",
|
"papaparse": "^5.4.1",
|
||||||
|
"pdf-parse": "^2.4.5",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
"react-day-picker": "^9.13.0",
|
"react-day-picker": "^9.13.0",
|
||||||
"react-dom": "^19.0.0",
|
"react-dom": "^19.0.0",
|
||||||
@@ -82,10 +88,10 @@
|
|||||||
"react-hook-form": "^7.54.2",
|
"react-hook-form": "^7.54.2",
|
||||||
"react-leaflet": "^5.0.0",
|
"react-leaflet": "^5.0.0",
|
||||||
"react-phone-number-input": "^3.4.14",
|
"react-phone-number-input": "^3.4.14",
|
||||||
"recharts": "^3.7.0",
|
|
||||||
"sonner": "^2.0.7",
|
"sonner": "^2.0.7",
|
||||||
"superjson": "^2.2.2",
|
"superjson": "^2.2.2",
|
||||||
"tailwind-merge": "^3.4.0",
|
"tailwind-merge": "^3.4.0",
|
||||||
|
"unpdf": "^1.4.0",
|
||||||
"use-debounce": "^10.0.4",
|
"use-debounce": "^10.0.4",
|
||||||
"zod": "^3.24.1"
|
"zod": "^3.24.1"
|
||||||
},
|
},
|
||||||
@@ -96,6 +102,7 @@
|
|||||||
"@types/node": "^25.0.10",
|
"@types/node": "^25.0.10",
|
||||||
"@types/nodemailer": "^7.0.9",
|
"@types/nodemailer": "^7.0.9",
|
||||||
"@types/papaparse": "^5.3.15",
|
"@types/papaparse": "^5.3.15",
|
||||||
|
"@types/pdf-parse": "^1.1.5",
|
||||||
"@types/react": "^19.0.2",
|
"@types/react": "^19.0.2",
|
||||||
"@types/react-dom": "^19.0.2",
|
"@types/react-dom": "^19.0.2",
|
||||||
"eslint": "^9.17.0",
|
"eslint": "^9.17.0",
|
||||||
|
|||||||
@@ -16,105 +16,143 @@
|
|||||||
-- the enum.
|
-- the enum.
|
||||||
|
|
||||||
|
|
||||||
ALTER TYPE "SettingCategory" ADD VALUE 'DIGEST';
|
DO $$ BEGIN ALTER TYPE "SettingCategory" ADD VALUE 'DIGEST'; EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||||
ALTER TYPE "SettingCategory" ADD VALUE 'ANALYTICS';
|
DO $$ BEGIN ALTER TYPE "SettingCategory" ADD VALUE 'ANALYTICS'; EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||||
ALTER TYPE "SettingCategory" ADD VALUE 'AUDIT_CONFIG';
|
DO $$ BEGIN ALTER TYPE "SettingCategory" ADD VALUE 'AUDIT_CONFIG'; EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||||
ALTER TYPE "SettingCategory" ADD VALUE 'INTEGRATIONS';
|
DO $$ BEGIN ALTER TYPE "SettingCategory" ADD VALUE 'INTEGRATIONS'; EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||||
ALTER TYPE "SettingCategory" ADD VALUE 'LOCALIZATION';
|
DO $$ BEGIN ALTER TYPE "SettingCategory" ADD VALUE 'LOCALIZATION'; EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||||
ALTER TYPE "SettingCategory" ADD VALUE 'COMMUNICATION';
|
DO $$ BEGIN ALTER TYPE "SettingCategory" ADD VALUE 'COMMUNICATION'; EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||||
|
|
||||||
-- DropForeignKey
|
-- DropForeignKey
|
||||||
ALTER TABLE "ApplicationForm" DROP CONSTRAINT "ApplicationForm_programId_fkey";
|
ALTER TABLE "ApplicationForm" DROP CONSTRAINT IF EXISTS "ApplicationForm_programId_fkey";
|
||||||
|
|
||||||
-- DropForeignKey
|
-- DropForeignKey
|
||||||
ALTER TABLE "ApplicationForm" DROP CONSTRAINT "ApplicationForm_roundId_fkey";
|
ALTER TABLE "ApplicationForm" DROP CONSTRAINT IF EXISTS "ApplicationForm_roundId_fkey";
|
||||||
|
|
||||||
-- DropForeignKey
|
-- DropForeignKey
|
||||||
ALTER TABLE "ApplicationFormField" DROP CONSTRAINT "ApplicationFormField_formId_fkey";
|
ALTER TABLE "ApplicationFormField" DROP CONSTRAINT IF EXISTS "ApplicationFormField_formId_fkey";
|
||||||
|
|
||||||
-- DropForeignKey
|
-- DropForeignKey
|
||||||
ALTER TABLE "ApplicationFormField" DROP CONSTRAINT "ApplicationFormField_stepId_fkey";
|
ALTER TABLE "ApplicationFormField" DROP CONSTRAINT IF EXISTS "ApplicationFormField_stepId_fkey";
|
||||||
|
|
||||||
-- DropForeignKey
|
-- DropForeignKey
|
||||||
ALTER TABLE "ApplicationFormSubmission" DROP CONSTRAINT "ApplicationFormSubmission_formId_fkey";
|
ALTER TABLE "ApplicationFormSubmission" DROP CONSTRAINT IF EXISTS "ApplicationFormSubmission_formId_fkey";
|
||||||
|
|
||||||
-- DropForeignKey
|
-- DropForeignKey
|
||||||
ALTER TABLE "OnboardingStep" DROP CONSTRAINT "OnboardingStep_formId_fkey";
|
ALTER TABLE "OnboardingStep" DROP CONSTRAINT IF EXISTS "OnboardingStep_formId_fkey";
|
||||||
|
|
||||||
-- DropForeignKey
|
-- DropForeignKey
|
||||||
ALTER TABLE "SubmissionFile" DROP CONSTRAINT "SubmissionFile_submissionId_fkey";
|
ALTER TABLE "SubmissionFile" DROP CONSTRAINT IF EXISTS "SubmissionFile_submissionId_fkey";
|
||||||
|
|
||||||
-- DropIndex
|
-- DropIndex
|
||||||
DROP INDEX "User_email_idx";
|
DROP INDEX IF EXISTS "User_email_idx";
|
||||||
|
|
||||||
-- AlterTable
|
-- AlterTable
|
||||||
ALTER TABLE "AssignmentJob" ALTER COLUMN "updatedAt" DROP DEFAULT;
|
DO $$ BEGIN ALTER TABLE "AssignmentJob" ALTER COLUMN "updatedAt" DROP DEFAULT; EXCEPTION WHEN others THEN NULL; END $$;
|
||||||
|
|
||||||
-- AlterTable
|
-- AlterTable
|
||||||
ALTER TABLE "AuditLog" ADD COLUMN "previousDataJson" JSONB,
|
DO $$ BEGIN
|
||||||
ADD COLUMN "sessionId" TEXT;
|
ALTER TABLE "AuditLog" ADD COLUMN "previousDataJson" JSONB;
|
||||||
|
EXCEPTION WHEN duplicate_column THEN NULL; END $$;
|
||||||
|
DO $$ BEGIN
|
||||||
|
ALTER TABLE "AuditLog" ADD COLUMN "sessionId" TEXT;
|
||||||
|
EXCEPTION WHEN duplicate_column THEN NULL; END $$;
|
||||||
|
|
||||||
-- AlterTable
|
-- AlterTable
|
||||||
ALTER TABLE "FilteringJob" ALTER COLUMN "updatedAt" DROP DEFAULT;
|
DO $$ BEGIN ALTER TABLE "FilteringJob" ALTER COLUMN "updatedAt" DROP DEFAULT; EXCEPTION WHEN others THEN NULL; END $$;
|
||||||
|
|
||||||
-- AlterTable
|
-- AlterTable
|
||||||
ALTER TABLE "LiveVote" ADD COLUMN "isAudienceVote" BOOLEAN NOT NULL DEFAULT false;
|
DO $$ BEGIN
|
||||||
|
ALTER TABLE "LiveVote" ADD COLUMN "isAudienceVote" BOOLEAN NOT NULL DEFAULT false;
|
||||||
|
EXCEPTION WHEN duplicate_column THEN NULL; END $$;
|
||||||
|
|
||||||
-- AlterTable
|
-- AlterTable
|
||||||
ALTER TABLE "LiveVotingSession" ADD COLUMN "allowAudienceVotes" BOOLEAN NOT NULL DEFAULT false,
|
DO $$ BEGIN
|
||||||
ADD COLUMN "audienceVoteWeight" DOUBLE PRECISION NOT NULL DEFAULT 0,
|
ALTER TABLE "LiveVotingSession" ADD COLUMN "allowAudienceVotes" BOOLEAN NOT NULL DEFAULT false;
|
||||||
ADD COLUMN "presentationSettingsJson" JSONB,
|
EXCEPTION WHEN duplicate_column THEN NULL; END $$;
|
||||||
ADD COLUMN "tieBreakerMethod" TEXT NOT NULL DEFAULT 'admin_decides';
|
DO $$ BEGIN
|
||||||
|
ALTER TABLE "LiveVotingSession" ADD COLUMN "audienceVoteWeight" DOUBLE PRECISION NOT NULL DEFAULT 0;
|
||||||
|
EXCEPTION WHEN duplicate_column THEN NULL; END $$;
|
||||||
|
DO $$ BEGIN
|
||||||
|
ALTER TABLE "LiveVotingSession" ADD COLUMN "presentationSettingsJson" JSONB;
|
||||||
|
EXCEPTION WHEN duplicate_column THEN NULL; END $$;
|
||||||
|
DO $$ BEGIN
|
||||||
|
ALTER TABLE "LiveVotingSession" ADD COLUMN "tieBreakerMethod" TEXT NOT NULL DEFAULT 'admin_decides';
|
||||||
|
EXCEPTION WHEN duplicate_column THEN NULL; END $$;
|
||||||
|
|
||||||
-- AlterTable
|
-- AlterTable
|
||||||
ALTER TABLE "MentorAssignment" ADD COLUMN "completionStatus" TEXT NOT NULL DEFAULT 'in_progress',
|
DO $$ BEGIN
|
||||||
ADD COLUMN "lastViewedAt" TIMESTAMP(3);
|
ALTER TABLE "MentorAssignment" ADD COLUMN "completionStatus" TEXT NOT NULL DEFAULT 'in_progress';
|
||||||
|
EXCEPTION WHEN duplicate_column THEN NULL; END $$;
|
||||||
|
DO $$ BEGIN
|
||||||
|
ALTER TABLE "MentorAssignment" ADD COLUMN "lastViewedAt" TIMESTAMP(3);
|
||||||
|
EXCEPTION WHEN duplicate_column THEN NULL; END $$;
|
||||||
|
|
||||||
-- AlterTable
|
-- AlterTable
|
||||||
ALTER TABLE "NotificationEmailSetting" ALTER COLUMN "updatedAt" DROP DEFAULT;
|
DO $$ BEGIN ALTER TABLE "NotificationEmailSetting" ALTER COLUMN "updatedAt" DROP DEFAULT; EXCEPTION WHEN others THEN NULL; END $$;
|
||||||
|
|
||||||
-- AlterTable
|
-- AlterTable
|
||||||
ALTER TABLE "Project" ADD COLUMN "draftDataJson" JSONB,
|
DO $$ BEGIN
|
||||||
ADD COLUMN "draftExpiresAt" TIMESTAMP(3),
|
ALTER TABLE "Project" ADD COLUMN "draftDataJson" JSONB;
|
||||||
ADD COLUMN "isDraft" BOOLEAN NOT NULL DEFAULT false;
|
EXCEPTION WHEN duplicate_column THEN NULL; END $$;
|
||||||
|
DO $$ BEGIN
|
||||||
|
ALTER TABLE "Project" ADD COLUMN "draftExpiresAt" TIMESTAMP(3);
|
||||||
|
EXCEPTION WHEN duplicate_column THEN NULL; END $$;
|
||||||
|
DO $$ BEGIN
|
||||||
|
ALTER TABLE "Project" ADD COLUMN "isDraft" BOOLEAN NOT NULL DEFAULT false;
|
||||||
|
EXCEPTION WHEN duplicate_column THEN NULL; END $$;
|
||||||
|
|
||||||
-- AlterTable
|
-- AlterTable
|
||||||
ALTER TABLE "ProjectFile" ADD COLUMN "isLate" BOOLEAN NOT NULL DEFAULT false,
|
DO $$ BEGIN
|
||||||
ADD COLUMN "replacedById" TEXT,
|
ALTER TABLE "ProjectFile" ADD COLUMN "isLate" BOOLEAN NOT NULL DEFAULT false;
|
||||||
ADD COLUMN "roundId" TEXT,
|
EXCEPTION WHEN duplicate_column THEN NULL; END $$;
|
||||||
ADD COLUMN "version" INTEGER NOT NULL DEFAULT 1;
|
DO $$ BEGIN
|
||||||
|
ALTER TABLE "ProjectFile" ADD COLUMN "replacedById" TEXT;
|
||||||
|
EXCEPTION WHEN duplicate_column THEN NULL; END $$;
|
||||||
|
DO $$ BEGIN
|
||||||
|
ALTER TABLE "ProjectFile" ADD COLUMN "roundId" TEXT;
|
||||||
|
EXCEPTION WHEN duplicate_column THEN NULL; END $$;
|
||||||
|
DO $$ BEGIN
|
||||||
|
ALTER TABLE "ProjectFile" ADD COLUMN "version" INTEGER NOT NULL DEFAULT 1;
|
||||||
|
EXCEPTION WHEN duplicate_column THEN NULL; END $$;
|
||||||
|
|
||||||
-- AlterTable
|
-- AlterTable
|
||||||
ALTER TABLE "TaggingJob" ALTER COLUMN "updatedAt" DROP DEFAULT;
|
DO $$ BEGIN ALTER TABLE "TaggingJob" ALTER COLUMN "updatedAt" DROP DEFAULT; EXCEPTION WHEN others THEN NULL; END $$;
|
||||||
|
|
||||||
-- AlterTable
|
-- AlterTable
|
||||||
ALTER TABLE "User" ADD COLUMN "availabilityJson" JSONB,
|
DO $$ BEGIN
|
||||||
ADD COLUMN "digestFrequency" TEXT NOT NULL DEFAULT 'none',
|
ALTER TABLE "User" ADD COLUMN "availabilityJson" JSONB;
|
||||||
ADD COLUMN "preferredWorkload" INTEGER;
|
EXCEPTION WHEN duplicate_column THEN NULL; END $$;
|
||||||
|
DO $$ BEGIN
|
||||||
|
ALTER TABLE "User" ADD COLUMN "digestFrequency" TEXT NOT NULL DEFAULT 'none';
|
||||||
|
EXCEPTION WHEN duplicate_column THEN NULL; END $$;
|
||||||
|
DO $$ BEGIN
|
||||||
|
ALTER TABLE "User" ADD COLUMN "preferredWorkload" INTEGER;
|
||||||
|
EXCEPTION WHEN duplicate_column THEN NULL; END $$;
|
||||||
|
|
||||||
-- DropTable
|
-- DropTable
|
||||||
DROP TABLE "ApplicationForm";
|
DROP TABLE IF EXISTS "ApplicationForm";
|
||||||
|
|
||||||
-- DropTable
|
-- DropTable
|
||||||
DROP TABLE "ApplicationFormField";
|
DROP TABLE IF EXISTS "ApplicationFormField";
|
||||||
|
|
||||||
-- DropTable
|
-- DropTable
|
||||||
DROP TABLE "ApplicationFormSubmission";
|
DROP TABLE IF EXISTS "ApplicationFormSubmission";
|
||||||
|
|
||||||
-- DropTable
|
-- DropTable
|
||||||
DROP TABLE "OnboardingStep";
|
DROP TABLE IF EXISTS "OnboardingStep";
|
||||||
|
|
||||||
-- DropTable
|
-- DropTable
|
||||||
DROP TABLE "SubmissionFile";
|
DROP TABLE IF EXISTS "SubmissionFile";
|
||||||
|
|
||||||
-- DropEnum
|
-- DropEnum
|
||||||
DROP TYPE "FormFieldType";
|
DROP TYPE IF EXISTS "FormFieldType";
|
||||||
|
|
||||||
-- DropEnum
|
-- DropEnum
|
||||||
DROP TYPE "SpecialFieldType";
|
DROP TYPE IF EXISTS "SpecialFieldType";
|
||||||
|
|
||||||
-- CreateTable
|
-- CreateTable
|
||||||
CREATE TABLE "ReminderLog" (
|
CREATE TABLE IF NOT EXISTS "ReminderLog" (
|
||||||
"id" TEXT NOT NULL,
|
"id" TEXT NOT NULL,
|
||||||
"roundId" TEXT NOT NULL,
|
"roundId" TEXT NOT NULL,
|
||||||
"userId" TEXT NOT NULL,
|
"userId" TEXT NOT NULL,
|
||||||
@@ -125,7 +163,7 @@ CREATE TABLE "ReminderLog" (
|
|||||||
);
|
);
|
||||||
|
|
||||||
-- CreateTable
|
-- CreateTable
|
||||||
CREATE TABLE "ConflictOfInterest" (
|
CREATE TABLE IF NOT EXISTS "ConflictOfInterest" (
|
||||||
"id" TEXT NOT NULL,
|
"id" TEXT NOT NULL,
|
||||||
"assignmentId" TEXT NOT NULL,
|
"assignmentId" TEXT NOT NULL,
|
||||||
"userId" TEXT NOT NULL,
|
"userId" TEXT NOT NULL,
|
||||||
@@ -143,7 +181,7 @@ CREATE TABLE "ConflictOfInterest" (
|
|||||||
);
|
);
|
||||||
|
|
||||||
-- CreateTable
|
-- CreateTable
|
||||||
CREATE TABLE "EvaluationSummary" (
|
CREATE TABLE IF NOT EXISTS "EvaluationSummary" (
|
||||||
"id" TEXT NOT NULL,
|
"id" TEXT NOT NULL,
|
||||||
"projectId" TEXT NOT NULL,
|
"projectId" TEXT NOT NULL,
|
||||||
"roundId" TEXT NOT NULL,
|
"roundId" TEXT NOT NULL,
|
||||||
@@ -157,7 +195,7 @@ CREATE TABLE "EvaluationSummary" (
|
|||||||
);
|
);
|
||||||
|
|
||||||
-- CreateTable
|
-- CreateTable
|
||||||
CREATE TABLE "ProjectStatusHistory" (
|
CREATE TABLE IF NOT EXISTS "ProjectStatusHistory" (
|
||||||
"id" TEXT NOT NULL,
|
"id" TEXT NOT NULL,
|
||||||
"projectId" TEXT NOT NULL,
|
"projectId" TEXT NOT NULL,
|
||||||
"status" "ProjectStatus" NOT NULL,
|
"status" "ProjectStatus" NOT NULL,
|
||||||
@@ -168,7 +206,7 @@ CREATE TABLE "ProjectStatusHistory" (
|
|||||||
);
|
);
|
||||||
|
|
||||||
-- CreateTable
|
-- CreateTable
|
||||||
CREATE TABLE "MentorMessage" (
|
CREATE TABLE IF NOT EXISTS "MentorMessage" (
|
||||||
"id" TEXT NOT NULL,
|
"id" TEXT NOT NULL,
|
||||||
"projectId" TEXT NOT NULL,
|
"projectId" TEXT NOT NULL,
|
||||||
"senderId" TEXT NOT NULL,
|
"senderId" TEXT NOT NULL,
|
||||||
@@ -180,7 +218,7 @@ CREATE TABLE "MentorMessage" (
|
|||||||
);
|
);
|
||||||
|
|
||||||
-- CreateTable
|
-- CreateTable
|
||||||
CREATE TABLE "DigestLog" (
|
CREATE TABLE IF NOT EXISTS "DigestLog" (
|
||||||
"id" TEXT NOT NULL,
|
"id" TEXT NOT NULL,
|
||||||
"userId" TEXT NOT NULL,
|
"userId" TEXT NOT NULL,
|
||||||
"digestType" TEXT NOT NULL,
|
"digestType" TEXT NOT NULL,
|
||||||
@@ -191,7 +229,7 @@ CREATE TABLE "DigestLog" (
|
|||||||
);
|
);
|
||||||
|
|
||||||
-- CreateTable
|
-- CreateTable
|
||||||
CREATE TABLE "RoundTemplate" (
|
CREATE TABLE IF NOT EXISTS "RoundTemplate" (
|
||||||
"id" TEXT NOT NULL,
|
"id" TEXT NOT NULL,
|
||||||
"name" TEXT NOT NULL,
|
"name" TEXT NOT NULL,
|
||||||
"description" TEXT,
|
"description" TEXT,
|
||||||
@@ -208,7 +246,7 @@ CREATE TABLE "RoundTemplate" (
|
|||||||
);
|
);
|
||||||
|
|
||||||
-- CreateTable
|
-- CreateTable
|
||||||
CREATE TABLE "MentorNote" (
|
CREATE TABLE IF NOT EXISTS "MentorNote" (
|
||||||
"id" TEXT NOT NULL,
|
"id" TEXT NOT NULL,
|
||||||
"mentorAssignmentId" TEXT NOT NULL,
|
"mentorAssignmentId" TEXT NOT NULL,
|
||||||
"authorId" TEXT NOT NULL,
|
"authorId" TEXT NOT NULL,
|
||||||
@@ -221,7 +259,7 @@ CREATE TABLE "MentorNote" (
|
|||||||
);
|
);
|
||||||
|
|
||||||
-- CreateTable
|
-- CreateTable
|
||||||
CREATE TABLE "MentorMilestone" (
|
CREATE TABLE IF NOT EXISTS "MentorMilestone" (
|
||||||
"id" TEXT NOT NULL,
|
"id" TEXT NOT NULL,
|
||||||
"programId" TEXT NOT NULL,
|
"programId" TEXT NOT NULL,
|
||||||
"name" TEXT NOT NULL,
|
"name" TEXT NOT NULL,
|
||||||
@@ -236,7 +274,7 @@ CREATE TABLE "MentorMilestone" (
|
|||||||
);
|
);
|
||||||
|
|
||||||
-- CreateTable
|
-- CreateTable
|
||||||
CREATE TABLE "MentorMilestoneCompletion" (
|
CREATE TABLE IF NOT EXISTS "MentorMilestoneCompletion" (
|
||||||
"id" TEXT NOT NULL,
|
"id" TEXT NOT NULL,
|
||||||
"milestoneId" TEXT NOT NULL,
|
"milestoneId" TEXT NOT NULL,
|
||||||
"mentorAssignmentId" TEXT NOT NULL,
|
"mentorAssignmentId" TEXT NOT NULL,
|
||||||
@@ -247,7 +285,7 @@ CREATE TABLE "MentorMilestoneCompletion" (
|
|||||||
);
|
);
|
||||||
|
|
||||||
-- CreateTable
|
-- CreateTable
|
||||||
CREATE TABLE "Message" (
|
CREATE TABLE IF NOT EXISTS "Message" (
|
||||||
"id" TEXT NOT NULL,
|
"id" TEXT NOT NULL,
|
||||||
"senderId" TEXT NOT NULL,
|
"senderId" TEXT NOT NULL,
|
||||||
"recipientType" TEXT NOT NULL,
|
"recipientType" TEXT NOT NULL,
|
||||||
@@ -266,7 +304,7 @@ CREATE TABLE "Message" (
|
|||||||
);
|
);
|
||||||
|
|
||||||
-- CreateTable
|
-- CreateTable
|
||||||
CREATE TABLE "MessageTemplate" (
|
CREATE TABLE IF NOT EXISTS "MessageTemplate" (
|
||||||
"id" TEXT NOT NULL,
|
"id" TEXT NOT NULL,
|
||||||
"name" TEXT NOT NULL,
|
"name" TEXT NOT NULL,
|
||||||
"category" TEXT NOT NULL,
|
"category" TEXT NOT NULL,
|
||||||
@@ -282,7 +320,7 @@ CREATE TABLE "MessageTemplate" (
|
|||||||
);
|
);
|
||||||
|
|
||||||
-- CreateTable
|
-- CreateTable
|
||||||
CREATE TABLE "MessageRecipient" (
|
CREATE TABLE IF NOT EXISTS "MessageRecipient" (
|
||||||
"id" TEXT NOT NULL,
|
"id" TEXT NOT NULL,
|
||||||
"messageId" TEXT NOT NULL,
|
"messageId" TEXT NOT NULL,
|
||||||
"userId" TEXT NOT NULL,
|
"userId" TEXT NOT NULL,
|
||||||
@@ -295,7 +333,7 @@ CREATE TABLE "MessageRecipient" (
|
|||||||
);
|
);
|
||||||
|
|
||||||
-- CreateTable
|
-- CreateTable
|
||||||
CREATE TABLE "Webhook" (
|
CREATE TABLE IF NOT EXISTS "Webhook" (
|
||||||
"id" TEXT NOT NULL,
|
"id" TEXT NOT NULL,
|
||||||
"name" TEXT NOT NULL,
|
"name" TEXT NOT NULL,
|
||||||
"url" TEXT NOT NULL,
|
"url" TEXT NOT NULL,
|
||||||
@@ -312,7 +350,7 @@ CREATE TABLE "Webhook" (
|
|||||||
);
|
);
|
||||||
|
|
||||||
-- CreateTable
|
-- CreateTable
|
||||||
CREATE TABLE "WebhookDelivery" (
|
CREATE TABLE IF NOT EXISTS "WebhookDelivery" (
|
||||||
"id" TEXT NOT NULL,
|
"id" TEXT NOT NULL,
|
||||||
"webhookId" TEXT NOT NULL,
|
"webhookId" TEXT NOT NULL,
|
||||||
"event" TEXT NOT NULL,
|
"event" TEXT NOT NULL,
|
||||||
@@ -328,7 +366,7 @@ CREATE TABLE "WebhookDelivery" (
|
|||||||
);
|
);
|
||||||
|
|
||||||
-- CreateTable
|
-- CreateTable
|
||||||
CREATE TABLE "EvaluationDiscussion" (
|
CREATE TABLE IF NOT EXISTS "EvaluationDiscussion" (
|
||||||
"id" TEXT NOT NULL,
|
"id" TEXT NOT NULL,
|
||||||
"projectId" TEXT NOT NULL,
|
"projectId" TEXT NOT NULL,
|
||||||
"roundId" TEXT NOT NULL,
|
"roundId" TEXT NOT NULL,
|
||||||
@@ -341,7 +379,7 @@ CREATE TABLE "EvaluationDiscussion" (
|
|||||||
);
|
);
|
||||||
|
|
||||||
-- CreateTable
|
-- CreateTable
|
||||||
CREATE TABLE "DiscussionComment" (
|
CREATE TABLE IF NOT EXISTS "DiscussionComment" (
|
||||||
"id" TEXT NOT NULL,
|
"id" TEXT NOT NULL,
|
||||||
"discussionId" TEXT NOT NULL,
|
"discussionId" TEXT NOT NULL,
|
||||||
"userId" TEXT NOT NULL,
|
"userId" TEXT NOT NULL,
|
||||||
@@ -352,199 +390,257 @@ CREATE TABLE "DiscussionComment" (
|
|||||||
);
|
);
|
||||||
|
|
||||||
-- CreateIndex
|
-- CreateIndex
|
||||||
CREATE INDEX "ReminderLog_roundId_idx" ON "ReminderLog"("roundId");
|
CREATE INDEX IF NOT EXISTS "ReminderLog_roundId_idx" ON "ReminderLog"("roundId");
|
||||||
|
|
||||||
-- CreateIndex
|
-- CreateIndex
|
||||||
CREATE UNIQUE INDEX "ReminderLog_roundId_userId_type_key" ON "ReminderLog"("roundId", "userId", "type");
|
CREATE UNIQUE INDEX IF NOT EXISTS "ReminderLog_roundId_userId_type_key" ON "ReminderLog"("roundId", "userId", "type");
|
||||||
|
|
||||||
-- CreateIndex
|
-- CreateIndex
|
||||||
CREATE UNIQUE INDEX "ConflictOfInterest_assignmentId_key" ON "ConflictOfInterest"("assignmentId");
|
CREATE UNIQUE INDEX IF NOT EXISTS "ConflictOfInterest_assignmentId_key" ON "ConflictOfInterest"("assignmentId");
|
||||||
|
|
||||||
-- CreateIndex
|
-- CreateIndex
|
||||||
CREATE INDEX "ConflictOfInterest_userId_idx" ON "ConflictOfInterest"("userId");
|
CREATE INDEX IF NOT EXISTS "ConflictOfInterest_userId_idx" ON "ConflictOfInterest"("userId");
|
||||||
|
|
||||||
-- CreateIndex
|
-- CreateIndex
|
||||||
CREATE INDEX "ConflictOfInterest_roundId_hasConflict_idx" ON "ConflictOfInterest"("roundId", "hasConflict");
|
CREATE INDEX IF NOT EXISTS "ConflictOfInterest_roundId_hasConflict_idx" ON "ConflictOfInterest"("roundId", "hasConflict");
|
||||||
|
|
||||||
-- CreateIndex
|
-- CreateIndex
|
||||||
CREATE INDEX "EvaluationSummary_roundId_idx" ON "EvaluationSummary"("roundId");
|
CREATE INDEX IF NOT EXISTS "EvaluationSummary_roundId_idx" ON "EvaluationSummary"("roundId");
|
||||||
|
|
||||||
-- CreateIndex
|
-- CreateIndex
|
||||||
CREATE UNIQUE INDEX "EvaluationSummary_projectId_roundId_key" ON "EvaluationSummary"("projectId", "roundId");
|
CREATE UNIQUE INDEX IF NOT EXISTS "EvaluationSummary_projectId_roundId_key" ON "EvaluationSummary"("projectId", "roundId");
|
||||||
|
|
||||||
-- CreateIndex
|
-- CreateIndex
|
||||||
CREATE INDEX "ProjectStatusHistory_projectId_changedAt_idx" ON "ProjectStatusHistory"("projectId", "changedAt");
|
CREATE INDEX IF NOT EXISTS "ProjectStatusHistory_projectId_changedAt_idx" ON "ProjectStatusHistory"("projectId", "changedAt");
|
||||||
|
|
||||||
-- CreateIndex
|
-- CreateIndex
|
||||||
CREATE INDEX "MentorMessage_projectId_createdAt_idx" ON "MentorMessage"("projectId", "createdAt");
|
CREATE INDEX IF NOT EXISTS "MentorMessage_projectId_createdAt_idx" ON "MentorMessage"("projectId", "createdAt");
|
||||||
|
|
||||||
-- CreateIndex
|
-- CreateIndex
|
||||||
CREATE INDEX "DigestLog_userId_idx" ON "DigestLog"("userId");
|
CREATE INDEX IF NOT EXISTS "DigestLog_userId_idx" ON "DigestLog"("userId");
|
||||||
|
|
||||||
-- CreateIndex
|
-- CreateIndex
|
||||||
CREATE INDEX "DigestLog_sentAt_idx" ON "DigestLog"("sentAt");
|
CREATE INDEX IF NOT EXISTS "DigestLog_sentAt_idx" ON "DigestLog"("sentAt");
|
||||||
|
|
||||||
-- CreateIndex
|
-- CreateIndex
|
||||||
CREATE INDEX "RoundTemplate_programId_idx" ON "RoundTemplate"("programId");
|
CREATE INDEX IF NOT EXISTS "RoundTemplate_programId_idx" ON "RoundTemplate"("programId");
|
||||||
|
|
||||||
-- CreateIndex
|
-- CreateIndex
|
||||||
CREATE INDEX "MentorNote_mentorAssignmentId_idx" ON "MentorNote"("mentorAssignmentId");
|
CREATE INDEX IF NOT EXISTS "MentorNote_mentorAssignmentId_idx" ON "MentorNote"("mentorAssignmentId");
|
||||||
|
|
||||||
-- CreateIndex
|
-- CreateIndex
|
||||||
CREATE INDEX "MentorMilestone_programId_idx" ON "MentorMilestone"("programId");
|
CREATE INDEX IF NOT EXISTS "MentorMilestone_programId_idx" ON "MentorMilestone"("programId");
|
||||||
|
|
||||||
-- CreateIndex
|
-- CreateIndex
|
||||||
CREATE INDEX "MentorMilestone_sortOrder_idx" ON "MentorMilestone"("sortOrder");
|
CREATE INDEX IF NOT EXISTS "MentorMilestone_sortOrder_idx" ON "MentorMilestone"("sortOrder");
|
||||||
|
|
||||||
-- CreateIndex
|
-- CreateIndex
|
||||||
CREATE INDEX "MentorMilestoneCompletion_mentorAssignmentId_idx" ON "MentorMilestoneCompletion"("mentorAssignmentId");
|
CREATE INDEX IF NOT EXISTS "MentorMilestoneCompletion_mentorAssignmentId_idx" ON "MentorMilestoneCompletion"("mentorAssignmentId");
|
||||||
|
|
||||||
-- CreateIndex
|
-- CreateIndex
|
||||||
CREATE UNIQUE INDEX "MentorMilestoneCompletion_milestoneId_mentorAssignmentId_key" ON "MentorMilestoneCompletion"("milestoneId", "mentorAssignmentId");
|
CREATE UNIQUE INDEX IF NOT EXISTS "MentorMilestoneCompletion_milestoneId_mentorAssignmentId_key" ON "MentorMilestoneCompletion"("milestoneId", "mentorAssignmentId");
|
||||||
|
|
||||||
-- CreateIndex
|
-- CreateIndex
|
||||||
CREATE INDEX "Message_senderId_idx" ON "Message"("senderId");
|
CREATE INDEX IF NOT EXISTS "Message_senderId_idx" ON "Message"("senderId");
|
||||||
|
|
||||||
-- CreateIndex
|
-- CreateIndex
|
||||||
CREATE INDEX "Message_sentAt_idx" ON "Message"("sentAt");
|
CREATE INDEX IF NOT EXISTS "Message_sentAt_idx" ON "Message"("sentAt");
|
||||||
|
|
||||||
-- CreateIndex
|
-- CreateIndex
|
||||||
CREATE INDEX "Message_scheduledAt_idx" ON "Message"("scheduledAt");
|
CREATE INDEX IF NOT EXISTS "Message_scheduledAt_idx" ON "Message"("scheduledAt");
|
||||||
|
|
||||||
-- CreateIndex
|
-- CreateIndex
|
||||||
CREATE INDEX "MessageTemplate_category_idx" ON "MessageTemplate"("category");
|
CREATE INDEX IF NOT EXISTS "MessageTemplate_category_idx" ON "MessageTemplate"("category");
|
||||||
|
|
||||||
-- CreateIndex
|
-- CreateIndex
|
||||||
CREATE INDEX "MessageTemplate_isActive_idx" ON "MessageTemplate"("isActive");
|
CREATE INDEX IF NOT EXISTS "MessageTemplate_isActive_idx" ON "MessageTemplate"("isActive");
|
||||||
|
|
||||||
-- CreateIndex
|
-- CreateIndex
|
||||||
CREATE INDEX "MessageRecipient_messageId_idx" ON "MessageRecipient"("messageId");
|
CREATE INDEX IF NOT EXISTS "MessageRecipient_messageId_idx" ON "MessageRecipient"("messageId");
|
||||||
|
|
||||||
-- CreateIndex
|
-- CreateIndex
|
||||||
CREATE INDEX "MessageRecipient_userId_isRead_idx" ON "MessageRecipient"("userId", "isRead");
|
CREATE INDEX IF NOT EXISTS "MessageRecipient_userId_isRead_idx" ON "MessageRecipient"("userId", "isRead");
|
||||||
|
|
||||||
-- CreateIndex
|
-- CreateIndex
|
||||||
CREATE INDEX "Webhook_isActive_idx" ON "Webhook"("isActive");
|
CREATE INDEX IF NOT EXISTS "Webhook_isActive_idx" ON "Webhook"("isActive");
|
||||||
|
|
||||||
-- CreateIndex
|
-- CreateIndex
|
||||||
CREATE INDEX "WebhookDelivery_webhookId_idx" ON "WebhookDelivery"("webhookId");
|
CREATE INDEX IF NOT EXISTS "WebhookDelivery_webhookId_idx" ON "WebhookDelivery"("webhookId");
|
||||||
|
|
||||||
-- CreateIndex
|
-- CreateIndex
|
||||||
CREATE INDEX "WebhookDelivery_status_idx" ON "WebhookDelivery"("status");
|
CREATE INDEX IF NOT EXISTS "WebhookDelivery_status_idx" ON "WebhookDelivery"("status");
|
||||||
|
|
||||||
-- CreateIndex
|
-- CreateIndex
|
||||||
CREATE INDEX "WebhookDelivery_createdAt_idx" ON "WebhookDelivery"("createdAt");
|
CREATE INDEX IF NOT EXISTS "WebhookDelivery_createdAt_idx" ON "WebhookDelivery"("createdAt");
|
||||||
|
|
||||||
-- CreateIndex
|
-- CreateIndex
|
||||||
CREATE INDEX "EvaluationDiscussion_roundId_idx" ON "EvaluationDiscussion"("roundId");
|
CREATE INDEX IF NOT EXISTS "EvaluationDiscussion_roundId_idx" ON "EvaluationDiscussion"("roundId");
|
||||||
|
|
||||||
-- CreateIndex
|
-- CreateIndex
|
||||||
CREATE INDEX "EvaluationDiscussion_status_idx" ON "EvaluationDiscussion"("status");
|
CREATE INDEX IF NOT EXISTS "EvaluationDiscussion_status_idx" ON "EvaluationDiscussion"("status");
|
||||||
|
|
||||||
-- CreateIndex
|
-- CreateIndex
|
||||||
CREATE UNIQUE INDEX "EvaluationDiscussion_projectId_roundId_key" ON "EvaluationDiscussion"("projectId", "roundId");
|
CREATE UNIQUE INDEX IF NOT EXISTS "EvaluationDiscussion_projectId_roundId_key" ON "EvaluationDiscussion"("projectId", "roundId");
|
||||||
|
|
||||||
-- CreateIndex
|
-- CreateIndex
|
||||||
CREATE INDEX "DiscussionComment_discussionId_createdAt_idx" ON "DiscussionComment"("discussionId", "createdAt");
|
CREATE INDEX IF NOT EXISTS "DiscussionComment_discussionId_createdAt_idx" ON "DiscussionComment"("discussionId", "createdAt");
|
||||||
|
|
||||||
-- CreateIndex
|
-- CreateIndex
|
||||||
CREATE INDEX "AuditLog_entityType_entityId_timestamp_idx" ON "AuditLog"("entityType", "entityId", "timestamp");
|
CREATE INDEX IF NOT EXISTS "AuditLog_entityType_entityId_timestamp_idx" ON "AuditLog"("entityType", "entityId", "timestamp");
|
||||||
|
|
||||||
-- CreateIndex
|
-- CreateIndex
|
||||||
CREATE INDEX "Evaluation_status_formId_idx" ON "Evaluation"("status", "formId");
|
CREATE INDEX IF NOT EXISTS "Evaluation_status_formId_idx" ON "Evaluation"("status", "formId");
|
||||||
|
|
||||||
-- CreateIndex
|
-- CreateIndex
|
||||||
CREATE INDEX "GracePeriod_roundId_userId_extendedUntil_idx" ON "GracePeriod"("roundId", "userId", "extendedUntil");
|
CREATE INDEX IF NOT EXISTS "GracePeriod_roundId_userId_extendedUntil_idx" ON "GracePeriod"("roundId", "userId", "extendedUntil");
|
||||||
|
|
||||||
-- CreateIndex
|
-- CreateIndex
|
||||||
CREATE INDEX "LiveVote_isAudienceVote_idx" ON "LiveVote"("isAudienceVote");
|
CREATE INDEX IF NOT EXISTS "LiveVote_isAudienceVote_idx" ON "LiveVote"("isAudienceVote");
|
||||||
|
|
||||||
-- CreateIndex
|
-- CreateIndex
|
||||||
CREATE INDEX "ProjectFile_roundId_idx" ON "ProjectFile"("roundId");
|
CREATE INDEX IF NOT EXISTS "ProjectFile_roundId_idx" ON "ProjectFile"("roundId");
|
||||||
|
|
||||||
-- AddForeignKey
|
-- AddForeignKey
|
||||||
ALTER TABLE "ProjectFile" ADD CONSTRAINT "ProjectFile_roundId_fkey" FOREIGN KEY ("roundId") REFERENCES "Round"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
DO $$ BEGIN
|
||||||
|
ALTER TABLE "ProjectFile" ADD CONSTRAINT "ProjectFile_roundId_fkey" FOREIGN KEY ("roundId") REFERENCES "Round"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||||
|
|
||||||
-- AddForeignKey
|
-- AddForeignKey
|
||||||
ALTER TABLE "SpecialAward" ADD CONSTRAINT "SpecialAward_winnerOverriddenBy_fkey" FOREIGN KEY ("winnerOverriddenBy") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
DO $$ BEGIN
|
||||||
|
ALTER TABLE "SpecialAward" ADD CONSTRAINT "SpecialAward_winnerOverriddenBy_fkey" FOREIGN KEY ("winnerOverriddenBy") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||||
|
|
||||||
-- AddForeignKey
|
-- AddForeignKey
|
||||||
ALTER TABLE "ReminderLog" ADD CONSTRAINT "ReminderLog_roundId_fkey" FOREIGN KEY ("roundId") REFERENCES "Round"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
DO $$ BEGIN
|
||||||
|
ALTER TABLE "ReminderLog" ADD CONSTRAINT "ReminderLog_roundId_fkey" FOREIGN KEY ("roundId") REFERENCES "Round"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||||
|
|
||||||
-- AddForeignKey
|
-- AddForeignKey
|
||||||
ALTER TABLE "ReminderLog" ADD CONSTRAINT "ReminderLog_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
DO $$ BEGIN
|
||||||
|
ALTER TABLE "ReminderLog" ADD CONSTRAINT "ReminderLog_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||||
|
|
||||||
-- AddForeignKey
|
-- AddForeignKey
|
||||||
ALTER TABLE "ConflictOfInterest" ADD CONSTRAINT "ConflictOfInterest_assignmentId_fkey" FOREIGN KEY ("assignmentId") REFERENCES "Assignment"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
DO $$ BEGIN
|
||||||
|
ALTER TABLE "ConflictOfInterest" ADD CONSTRAINT "ConflictOfInterest_assignmentId_fkey" FOREIGN KEY ("assignmentId") REFERENCES "Assignment"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||||
|
|
||||||
-- AddForeignKey
|
-- AddForeignKey
|
||||||
ALTER TABLE "ConflictOfInterest" ADD CONSTRAINT "ConflictOfInterest_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
DO $$ BEGIN
|
||||||
|
ALTER TABLE "ConflictOfInterest" ADD CONSTRAINT "ConflictOfInterest_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||||
|
|
||||||
-- AddForeignKey
|
-- AddForeignKey
|
||||||
ALTER TABLE "ConflictOfInterest" ADD CONSTRAINT "ConflictOfInterest_reviewedById_fkey" FOREIGN KEY ("reviewedById") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
DO $$ BEGIN
|
||||||
|
ALTER TABLE "ConflictOfInterest" ADD CONSTRAINT "ConflictOfInterest_reviewedById_fkey" FOREIGN KEY ("reviewedById") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||||
|
|
||||||
-- AddForeignKey
|
-- AddForeignKey
|
||||||
ALTER TABLE "EvaluationSummary" ADD CONSTRAINT "EvaluationSummary_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
DO $$ BEGIN
|
||||||
|
ALTER TABLE "EvaluationSummary" ADD CONSTRAINT "EvaluationSummary_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||||
|
|
||||||
-- AddForeignKey
|
-- AddForeignKey
|
||||||
ALTER TABLE "EvaluationSummary" ADD CONSTRAINT "EvaluationSummary_generatedById_fkey" FOREIGN KEY ("generatedById") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
DO $$ BEGIN
|
||||||
|
ALTER TABLE "EvaluationSummary" ADD CONSTRAINT "EvaluationSummary_generatedById_fkey" FOREIGN KEY ("generatedById") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||||
|
|
||||||
-- AddForeignKey
|
-- AddForeignKey
|
||||||
ALTER TABLE "ProjectStatusHistory" ADD CONSTRAINT "ProjectStatusHistory_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
DO $$ BEGIN
|
||||||
|
ALTER TABLE "ProjectStatusHistory" ADD CONSTRAINT "ProjectStatusHistory_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||||
|
|
||||||
-- AddForeignKey
|
-- AddForeignKey
|
||||||
ALTER TABLE "MentorMessage" ADD CONSTRAINT "MentorMessage_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
DO $$ BEGIN
|
||||||
|
ALTER TABLE "MentorMessage" ADD CONSTRAINT "MentorMessage_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||||
|
|
||||||
-- AddForeignKey
|
-- AddForeignKey
|
||||||
ALTER TABLE "MentorMessage" ADD CONSTRAINT "MentorMessage_senderId_fkey" FOREIGN KEY ("senderId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
DO $$ BEGIN
|
||||||
|
ALTER TABLE "MentorMessage" ADD CONSTRAINT "MentorMessage_senderId_fkey" FOREIGN KEY ("senderId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||||
|
|
||||||
-- AddForeignKey
|
-- AddForeignKey
|
||||||
ALTER TABLE "DigestLog" ADD CONSTRAINT "DigestLog_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
DO $$ BEGIN
|
||||||
|
ALTER TABLE "DigestLog" ADD CONSTRAINT "DigestLog_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||||
|
|
||||||
-- AddForeignKey
|
-- AddForeignKey
|
||||||
ALTER TABLE "MentorNote" ADD CONSTRAINT "MentorNote_mentorAssignmentId_fkey" FOREIGN KEY ("mentorAssignmentId") REFERENCES "MentorAssignment"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
DO $$ BEGIN
|
||||||
|
ALTER TABLE "MentorNote" ADD CONSTRAINT "MentorNote_mentorAssignmentId_fkey" FOREIGN KEY ("mentorAssignmentId") REFERENCES "MentorAssignment"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||||
|
|
||||||
-- AddForeignKey
|
-- AddForeignKey
|
||||||
ALTER TABLE "MentorNote" ADD CONSTRAINT "MentorNote_authorId_fkey" FOREIGN KEY ("authorId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
DO $$ BEGIN
|
||||||
|
ALTER TABLE "MentorNote" ADD CONSTRAINT "MentorNote_authorId_fkey" FOREIGN KEY ("authorId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||||
|
|
||||||
-- AddForeignKey
|
-- AddForeignKey
|
||||||
ALTER TABLE "MentorMilestone" ADD CONSTRAINT "MentorMilestone_programId_fkey" FOREIGN KEY ("programId") REFERENCES "Program"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
DO $$ BEGIN
|
||||||
|
ALTER TABLE "MentorMilestone" ADD CONSTRAINT "MentorMilestone_programId_fkey" FOREIGN KEY ("programId") REFERENCES "Program"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||||
|
|
||||||
-- AddForeignKey
|
-- AddForeignKey
|
||||||
ALTER TABLE "MentorMilestoneCompletion" ADD CONSTRAINT "MentorMilestoneCompletion_milestoneId_fkey" FOREIGN KEY ("milestoneId") REFERENCES "MentorMilestone"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
DO $$ BEGIN
|
||||||
|
ALTER TABLE "MentorMilestoneCompletion" ADD CONSTRAINT "MentorMilestoneCompletion_milestoneId_fkey" FOREIGN KEY ("milestoneId") REFERENCES "MentorMilestone"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||||
|
|
||||||
-- AddForeignKey
|
-- AddForeignKey
|
||||||
ALTER TABLE "MentorMilestoneCompletion" ADD CONSTRAINT "MentorMilestoneCompletion_mentorAssignmentId_fkey" FOREIGN KEY ("mentorAssignmentId") REFERENCES "MentorAssignment"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
DO $$ BEGIN
|
||||||
|
ALTER TABLE "MentorMilestoneCompletion" ADD CONSTRAINT "MentorMilestoneCompletion_mentorAssignmentId_fkey" FOREIGN KEY ("mentorAssignmentId") REFERENCES "MentorAssignment"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||||
|
|
||||||
-- AddForeignKey
|
-- AddForeignKey
|
||||||
ALTER TABLE "MentorMilestoneCompletion" ADD CONSTRAINT "MentorMilestoneCompletion_completedById_fkey" FOREIGN KEY ("completedById") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
DO $$ BEGIN
|
||||||
|
ALTER TABLE "MentorMilestoneCompletion" ADD CONSTRAINT "MentorMilestoneCompletion_completedById_fkey" FOREIGN KEY ("completedById") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||||
|
|
||||||
-- AddForeignKey
|
-- AddForeignKey
|
||||||
ALTER TABLE "Message" ADD CONSTRAINT "Message_senderId_fkey" FOREIGN KEY ("senderId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
DO $$ BEGIN
|
||||||
|
ALTER TABLE "Message" ADD CONSTRAINT "Message_senderId_fkey" FOREIGN KEY ("senderId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||||
|
|
||||||
-- AddForeignKey
|
-- AddForeignKey
|
||||||
ALTER TABLE "Message" ADD CONSTRAINT "Message_templateId_fkey" FOREIGN KEY ("templateId") REFERENCES "MessageTemplate"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
DO $$ BEGIN
|
||||||
|
ALTER TABLE "Message" ADD CONSTRAINT "Message_templateId_fkey" FOREIGN KEY ("templateId") REFERENCES "MessageTemplate"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||||
|
|
||||||
-- AddForeignKey
|
-- AddForeignKey
|
||||||
ALTER TABLE "MessageRecipient" ADD CONSTRAINT "MessageRecipient_messageId_fkey" FOREIGN KEY ("messageId") REFERENCES "Message"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
DO $$ BEGIN
|
||||||
|
ALTER TABLE "MessageRecipient" ADD CONSTRAINT "MessageRecipient_messageId_fkey" FOREIGN KEY ("messageId") REFERENCES "Message"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||||
|
|
||||||
-- AddForeignKey
|
-- AddForeignKey
|
||||||
ALTER TABLE "MessageRecipient" ADD CONSTRAINT "MessageRecipient_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
DO $$ BEGIN
|
||||||
|
ALTER TABLE "MessageRecipient" ADD CONSTRAINT "MessageRecipient_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||||
|
|
||||||
-- AddForeignKey
|
-- AddForeignKey
|
||||||
ALTER TABLE "Webhook" ADD CONSTRAINT "Webhook_createdById_fkey" FOREIGN KEY ("createdById") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
DO $$ BEGIN
|
||||||
|
ALTER TABLE "Webhook" ADD CONSTRAINT "Webhook_createdById_fkey" FOREIGN KEY ("createdById") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||||
|
|
||||||
-- AddForeignKey
|
-- AddForeignKey
|
||||||
ALTER TABLE "WebhookDelivery" ADD CONSTRAINT "WebhookDelivery_webhookId_fkey" FOREIGN KEY ("webhookId") REFERENCES "Webhook"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
DO $$ BEGIN
|
||||||
|
ALTER TABLE "WebhookDelivery" ADD CONSTRAINT "WebhookDelivery_webhookId_fkey" FOREIGN KEY ("webhookId") REFERENCES "Webhook"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||||
|
|
||||||
-- AddForeignKey
|
-- AddForeignKey
|
||||||
ALTER TABLE "EvaluationDiscussion" ADD CONSTRAINT "EvaluationDiscussion_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
DO $$ BEGIN
|
||||||
|
ALTER TABLE "EvaluationDiscussion" ADD CONSTRAINT "EvaluationDiscussion_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||||
|
|
||||||
-- AddForeignKey
|
-- AddForeignKey
|
||||||
ALTER TABLE "EvaluationDiscussion" ADD CONSTRAINT "EvaluationDiscussion_closedById_fkey" FOREIGN KEY ("closedById") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
DO $$ BEGIN
|
||||||
|
ALTER TABLE "EvaluationDiscussion" ADD CONSTRAINT "EvaluationDiscussion_closedById_fkey" FOREIGN KEY ("closedById") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||||
|
|
||||||
-- AddForeignKey
|
-- AddForeignKey
|
||||||
ALTER TABLE "DiscussionComment" ADD CONSTRAINT "DiscussionComment_discussionId_fkey" FOREIGN KEY ("discussionId") REFERENCES "EvaluationDiscussion"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
DO $$ BEGIN
|
||||||
|
ALTER TABLE "DiscussionComment" ADD CONSTRAINT "DiscussionComment_discussionId_fkey" FOREIGN KEY ("discussionId") REFERENCES "EvaluationDiscussion"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||||
|
|
||||||
-- AddForeignKey
|
-- AddForeignKey
|
||||||
ALTER TABLE "DiscussionComment" ADD CONSTRAINT "DiscussionComment_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
DO $$ BEGIN
|
||||||
|
ALTER TABLE "DiscussionComment" ADD CONSTRAINT "DiscussionComment_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||||
|
|||||||
@@ -6,36 +6,46 @@
|
|||||||
-- Missing Foreign Keys
|
-- Missing Foreign Keys
|
||||||
-- =====================================================
|
-- =====================================================
|
||||||
|
|
||||||
-- RoundTemplate → Program
|
-- RoundTemplate -> Program
|
||||||
ALTER TABLE "RoundTemplate" ADD CONSTRAINT "RoundTemplate_programId_fkey"
|
DO $$ BEGIN
|
||||||
|
ALTER TABLE "RoundTemplate" ADD CONSTRAINT "RoundTemplate_programId_fkey"
|
||||||
FOREIGN KEY ("programId") REFERENCES "Program"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
FOREIGN KEY ("programId") REFERENCES "Program"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||||
|
|
||||||
-- RoundTemplate → User (creator)
|
-- RoundTemplate -> User (creator)
|
||||||
ALTER TABLE "RoundTemplate" ADD CONSTRAINT "RoundTemplate_createdBy_fkey"
|
DO $$ BEGIN
|
||||||
|
ALTER TABLE "RoundTemplate" ADD CONSTRAINT "RoundTemplate_createdBy_fkey"
|
||||||
FOREIGN KEY ("createdBy") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
FOREIGN KEY ("createdBy") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||||
|
|
||||||
-- Message → Round
|
-- Message -> Round
|
||||||
ALTER TABLE "Message" ADD CONSTRAINT "Message_roundId_fkey"
|
DO $$ BEGIN
|
||||||
|
ALTER TABLE "Message" ADD CONSTRAINT "Message_roundId_fkey"
|
||||||
FOREIGN KEY ("roundId") REFERENCES "Round"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
FOREIGN KEY ("roundId") REFERENCES "Round"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||||
|
|
||||||
-- EvaluationDiscussion → Round
|
-- EvaluationDiscussion -> Round
|
||||||
ALTER TABLE "EvaluationDiscussion" ADD CONSTRAINT "EvaluationDiscussion_roundId_fkey"
|
DO $$ BEGIN
|
||||||
|
ALTER TABLE "EvaluationDiscussion" ADD CONSTRAINT "EvaluationDiscussion_roundId_fkey"
|
||||||
FOREIGN KEY ("roundId") REFERENCES "Round"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
FOREIGN KEY ("roundId") REFERENCES "Round"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||||
|
|
||||||
-- ProjectFile → ProjectFile (self-relation for file versioning)
|
-- ProjectFile -> ProjectFile (self-relation for file versioning)
|
||||||
ALTER TABLE "ProjectFile" ADD CONSTRAINT "ProjectFile_replacedById_fkey"
|
DO $$ BEGIN
|
||||||
|
ALTER TABLE "ProjectFile" ADD CONSTRAINT "ProjectFile_replacedById_fkey"
|
||||||
FOREIGN KEY ("replacedById") REFERENCES "ProjectFile"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
FOREIGN KEY ("replacedById") REFERENCES "ProjectFile"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||||
|
|
||||||
-- =====================================================
|
-- =====================================================
|
||||||
-- Missing Indexes
|
-- Missing Indexes
|
||||||
-- =====================================================
|
-- =====================================================
|
||||||
|
|
||||||
CREATE INDEX "RoundTemplate_roundType_idx" ON "RoundTemplate"("roundType");
|
CREATE INDEX IF NOT EXISTS "RoundTemplate_roundType_idx" ON "RoundTemplate"("roundType");
|
||||||
CREATE INDEX "MentorNote_authorId_idx" ON "MentorNote"("authorId");
|
CREATE INDEX IF NOT EXISTS "MentorNote_authorId_idx" ON "MentorNote"("authorId");
|
||||||
CREATE INDEX "MentorMilestoneCompletion_completedById_idx" ON "MentorMilestoneCompletion"("completedById");
|
CREATE INDEX IF NOT EXISTS "MentorMilestoneCompletion_completedById_idx" ON "MentorMilestoneCompletion"("completedById");
|
||||||
CREATE INDEX "Webhook_createdById_idx" ON "Webhook"("createdById");
|
CREATE INDEX IF NOT EXISTS "Webhook_createdById_idx" ON "Webhook"("createdById");
|
||||||
CREATE INDEX "WebhookDelivery_event_idx" ON "WebhookDelivery"("event");
|
CREATE INDEX IF NOT EXISTS "WebhookDelivery_event_idx" ON "WebhookDelivery"("event");
|
||||||
CREATE INDEX "Message_roundId_idx" ON "Message"("roundId");
|
CREATE INDEX IF NOT EXISTS "Message_roundId_idx" ON "Message"("roundId");
|
||||||
CREATE INDEX "EvaluationDiscussion_closedById_idx" ON "EvaluationDiscussion"("closedById");
|
CREATE INDEX IF NOT EXISTS "EvaluationDiscussion_closedById_idx" ON "EvaluationDiscussion"("closedById");
|
||||||
CREATE INDEX "DiscussionComment_discussionId_idx" ON "DiscussionComment"("discussionId");
|
CREATE INDEX IF NOT EXISTS "DiscussionComment_discussionId_idx" ON "DiscussionComment"("discussionId");
|
||||||
CREATE INDEX "DiscussionComment_userId_idx" ON "DiscussionComment"("userId");
|
CREATE INDEX IF NOT EXISTS "DiscussionComment_userId_idx" ON "DiscussionComment"("userId");
|
||||||
|
|||||||
@@ -3,11 +3,15 @@
|
|||||||
-- Add SET NULL on ProjectFile.roundId so deleting Round nullifies the reference
|
-- Add SET NULL on ProjectFile.roundId so deleting Round nullifies the reference
|
||||||
|
|
||||||
-- AlterTable: Evaluation.formId -> onDelete CASCADE
|
-- AlterTable: Evaluation.formId -> onDelete CASCADE
|
||||||
ALTER TABLE "Evaluation" DROP CONSTRAINT "Evaluation_formId_fkey";
|
ALTER TABLE "Evaluation" DROP CONSTRAINT IF EXISTS "Evaluation_formId_fkey";
|
||||||
ALTER TABLE "Evaluation" ADD CONSTRAINT "Evaluation_formId_fkey"
|
DO $$ BEGIN
|
||||||
|
ALTER TABLE "Evaluation" ADD CONSTRAINT "Evaluation_formId_fkey"
|
||||||
FOREIGN KEY ("formId") REFERENCES "EvaluationForm"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
FOREIGN KEY ("formId") REFERENCES "EvaluationForm"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||||
|
|
||||||
-- AlterTable: ProjectFile.roundId -> onDelete SET NULL
|
-- AlterTable: ProjectFile.roundId -> onDelete SET NULL
|
||||||
ALTER TABLE "ProjectFile" DROP CONSTRAINT "ProjectFile_roundId_fkey";
|
ALTER TABLE "ProjectFile" DROP CONSTRAINT IF EXISTS "ProjectFile_roundId_fkey";
|
||||||
ALTER TABLE "ProjectFile" ADD CONSTRAINT "ProjectFile_roundId_fkey"
|
DO $$ BEGIN
|
||||||
|
ALTER TABLE "ProjectFile" ADD CONSTRAINT "ProjectFile_roundId_fkey"
|
||||||
FOREIGN KEY ("roundId") REFERENCES "Round"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
FOREIGN KEY ("roundId") REFERENCES "Round"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
-- CreateTable
|
-- CreateTable
|
||||||
CREATE TABLE "FileRequirement" (
|
CREATE TABLE IF NOT EXISTS "FileRequirement" (
|
||||||
"id" TEXT NOT NULL,
|
"id" TEXT NOT NULL,
|
||||||
"roundId" TEXT NOT NULL,
|
"roundId" TEXT NOT NULL,
|
||||||
"name" TEXT NOT NULL,
|
"name" TEXT NOT NULL,
|
||||||
@@ -15,16 +15,22 @@ CREATE TABLE "FileRequirement" (
|
|||||||
);
|
);
|
||||||
|
|
||||||
-- CreateIndex
|
-- CreateIndex
|
||||||
CREATE INDEX "FileRequirement_roundId_idx" ON "FileRequirement"("roundId");
|
CREATE INDEX IF NOT EXISTS "FileRequirement_roundId_idx" ON "FileRequirement"("roundId");
|
||||||
|
|
||||||
-- AddForeignKey
|
-- AddForeignKey
|
||||||
ALTER TABLE "FileRequirement" ADD CONSTRAINT "FileRequirement_roundId_fkey" FOREIGN KEY ("roundId") REFERENCES "Round"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
DO $$ BEGIN
|
||||||
|
ALTER TABLE "FileRequirement" ADD CONSTRAINT "FileRequirement_roundId_fkey" FOREIGN KEY ("roundId") REFERENCES "Round"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||||
|
|
||||||
-- AlterTable: add requirementId to ProjectFile
|
-- AlterTable: add requirementId to ProjectFile
|
||||||
ALTER TABLE "ProjectFile" ADD COLUMN "requirementId" TEXT;
|
DO $$ BEGIN
|
||||||
|
ALTER TABLE "ProjectFile" ADD COLUMN "requirementId" TEXT;
|
||||||
|
EXCEPTION WHEN duplicate_column THEN NULL; END $$;
|
||||||
|
|
||||||
-- CreateIndex
|
-- CreateIndex
|
||||||
CREATE INDEX "ProjectFile_requirementId_idx" ON "ProjectFile"("requirementId");
|
CREATE INDEX IF NOT EXISTS "ProjectFile_requirementId_idx" ON "ProjectFile"("requirementId");
|
||||||
|
|
||||||
-- AddForeignKey
|
-- AddForeignKey
|
||||||
ALTER TABLE "ProjectFile" ADD CONSTRAINT "ProjectFile_requirementId_fkey" FOREIGN KEY ("requirementId") REFERENCES "FileRequirement"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
DO $$ BEGIN
|
||||||
|
ALTER TABLE "ProjectFile" ADD CONSTRAINT "ProjectFile_requirementId_fkey" FOREIGN KEY ("requirementId") REFERENCES "FileRequirement"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||||
|
|||||||
@@ -1,2 +1,2 @@
|
|||||||
-- CreateIndex
|
-- CreateIndex
|
||||||
CREATE INDEX "AwardVote_awardId_userId_idx" ON "AwardVote"("awardId", "userId");
|
CREATE INDEX IF NOT EXISTS "AwardVote_awardId_userId_idx" ON "AwardVote"("awardId", "userId");
|
||||||
|
|||||||
@@ -1,28 +1,36 @@
|
|||||||
-- Simplify RoutingMode enum: remove POST_MAIN, rename PARALLEL → SHARED
|
-- Simplify RoutingMode enum: remove POST_MAIN, rename PARALLEL -> SHARED
|
||||||
-- Drop RoutingRule table (routing is now handled via award assignment)
|
-- Drop RoutingRule table (routing is now handled via award assignment)
|
||||||
|
|
||||||
-- 1. Update existing PARALLEL values to SHARED, POST_MAIN to SHARED
|
-- 1. Update existing PARALLEL values to SHARED, POST_MAIN to SHARED
|
||||||
|
-- (safe to run even if no rows match)
|
||||||
UPDATE "Track" SET "routingMode" = 'PARALLEL' WHERE "routingMode" = 'POST_MAIN';
|
UPDATE "Track" SET "routingMode" = 'PARALLEL' WHERE "routingMode" = 'POST_MAIN';
|
||||||
|
|
||||||
-- 2. Rename PARALLEL → SHARED in the enum
|
-- 2. Rename PARALLEL -> SHARED in the enum (only if PARALLEL still exists)
|
||||||
ALTER TYPE "RoutingMode" RENAME VALUE 'PARALLEL' TO 'SHARED';
|
DO $$ BEGIN
|
||||||
|
ALTER TYPE "RoutingMode" RENAME VALUE 'PARALLEL' TO 'SHARED';
|
||||||
|
EXCEPTION WHEN invalid_parameter_value THEN NULL; WHEN others THEN NULL; END $$;
|
||||||
|
|
||||||
-- 3. Remove POST_MAIN from the enum
|
-- 3. Remove POST_MAIN from the enum
|
||||||
-- PostgreSQL doesn't support DROP VALUE directly, so we recreate the enum
|
-- PostgreSQL doesn't support DROP VALUE directly, so we recreate the enum
|
||||||
-- Since we already converted POST_MAIN values to PARALLEL (now SHARED), this is safe
|
-- Since we already converted POST_MAIN values to PARALLEL (now SHARED), this is safe
|
||||||
|
|
||||||
-- Create new enum without POST_MAIN
|
-- Only recreate if the old enum still has POST_MAIN (i.e., hasn't been done yet)
|
||||||
-- Actually, since we already renamed PARALLEL to SHARED and converted POST_MAIN rows,
|
DO $$ BEGIN
|
||||||
-- we just need to remove the POST_MAIN value. PostgreSQL 13+ doesn't support dropping
|
IF EXISTS (
|
||||||
-- enum values natively, but since all rows are already migrated, we can:
|
SELECT 1 FROM pg_enum
|
||||||
CREATE TYPE "RoutingMode_new" AS ENUM ('SHARED', 'EXCLUSIVE');
|
WHERE enumlabel = 'POST_MAIN'
|
||||||
|
AND enumtypid = (SELECT oid FROM pg_type WHERE typname = 'RoutingMode')
|
||||||
|
) THEN
|
||||||
|
CREATE TYPE "RoutingMode_new" AS ENUM ('SHARED', 'EXCLUSIVE');
|
||||||
|
|
||||||
ALTER TABLE "Track"
|
ALTER TABLE "Track"
|
||||||
ALTER COLUMN "routingMode" TYPE "RoutingMode_new"
|
ALTER COLUMN "routingMode" TYPE "RoutingMode_new"
|
||||||
USING ("routingMode"::text::"RoutingMode_new");
|
USING ("routingMode"::text::"RoutingMode_new");
|
||||||
|
|
||||||
DROP TYPE "RoutingMode";
|
DROP TYPE "RoutingMode";
|
||||||
ALTER TYPE "RoutingMode_new" RENAME TO "RoutingMode";
|
ALTER TYPE "RoutingMode_new" RENAME TO "RoutingMode";
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
|
|
||||||
-- 4. Drop the RoutingRule table (no longer needed)
|
-- 4. Drop the RoutingRule table (no longer needed)
|
||||||
DROP TABLE IF EXISTS "RoutingRule";
|
DROP TABLE IF EXISTS "RoutingRule";
|
||||||
|
|||||||
@@ -1,36 +1,36 @@
|
|||||||
-- =============================================================================
|
-- =============================================================================
|
||||||
-- Phase 0+1: Add Competition/Round Architecture (additive — no breaking changes)
|
-- Phase 0+1: Add Competition/Round Architecture (additive -- no breaking changes)
|
||||||
-- =============================================================================
|
-- =============================================================================
|
||||||
-- New enums, new tables, new optional columns on existing tables.
|
-- New enums, new tables, new optional columns on existing tables.
|
||||||
-- Old Pipeline/Track/Stage tables are untouched.
|
-- Old Pipeline/Track/Stage tables are untouched.
|
||||||
|
|
||||||
-- ─── New Enum Types ──────────────────────────────────────────────────────────
|
-- --- New Enum Types ---
|
||||||
|
|
||||||
CREATE TYPE "CompetitionStatus" AS ENUM ('DRAFT', 'ACTIVE', 'CLOSED', 'ARCHIVED');
|
DO $$ BEGIN CREATE TYPE "CompetitionStatus" AS ENUM ('DRAFT', 'ACTIVE', 'CLOSED', 'ARCHIVED'); EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||||
CREATE TYPE "RoundType" AS ENUM ('INTAKE', 'FILTERING', 'EVALUATION', 'SUBMISSION', 'MENTORING', 'LIVE_FINAL', 'DELIBERATION');
|
DO $$ BEGIN CREATE TYPE "RoundType" AS ENUM ('INTAKE', 'FILTERING', 'EVALUATION', 'SUBMISSION', 'MENTORING', 'LIVE_FINAL', 'DELIBERATION'); EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||||
CREATE TYPE "RoundStatus" AS ENUM ('ROUND_DRAFT', 'ROUND_ACTIVE', 'ROUND_CLOSED', 'ROUND_ARCHIVED');
|
DO $$ BEGIN CREATE TYPE "RoundStatus" AS ENUM ('ROUND_DRAFT', 'ROUND_ACTIVE', 'ROUND_CLOSED', 'ROUND_ARCHIVED'); EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||||
CREATE TYPE "ProjectRoundStateValue" AS ENUM ('PENDING', 'IN_PROGRESS', 'PASSED', 'REJECTED', 'COMPLETED', 'WITHDRAWN');
|
DO $$ BEGIN CREATE TYPE "ProjectRoundStateValue" AS ENUM ('PENDING', 'IN_PROGRESS', 'PASSED', 'REJECTED', 'COMPLETED', 'WITHDRAWN'); EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||||
CREATE TYPE "AdvancementRuleType" AS ENUM ('AUTO_ADVANCE', 'SCORE_THRESHOLD', 'TOP_N', 'ADMIN_SELECTION', 'AI_RECOMMENDED');
|
DO $$ BEGIN CREATE TYPE "AdvancementRuleType" AS ENUM ('AUTO_ADVANCE', 'SCORE_THRESHOLD', 'TOP_N', 'ADMIN_SELECTION', 'AI_RECOMMENDED'); EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||||
CREATE TYPE "CapMode" AS ENUM ('HARD', 'SOFT', 'NONE');
|
DO $$ BEGIN CREATE TYPE "CapMode" AS ENUM ('HARD', 'SOFT', 'NONE'); EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||||
CREATE TYPE "DeadlinePolicy" AS ENUM ('HARD_DEADLINE', 'FLAG', 'GRACE');
|
DO $$ BEGIN CREATE TYPE "DeadlinePolicy" AS ENUM ('HARD_DEADLINE', 'FLAG', 'GRACE'); EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||||
CREATE TYPE "JuryGroupMemberRole" AS ENUM ('CHAIR', 'MEMBER', 'OBSERVER');
|
DO $$ BEGIN CREATE TYPE "JuryGroupMemberRole" AS ENUM ('CHAIR', 'MEMBER', 'OBSERVER'); EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||||
CREATE TYPE "AssignmentIntentSource" AS ENUM ('INVITE', 'ADMIN', 'SYSTEM');
|
DO $$ BEGIN CREATE TYPE "AssignmentIntentSource" AS ENUM ('INVITE', 'ADMIN', 'SYSTEM'); EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||||
CREATE TYPE "AssignmentIntentStatus" AS ENUM ('INTENT_PENDING', 'HONORED', 'OVERRIDDEN', 'EXPIRED', 'CANCELLED');
|
DO $$ BEGIN CREATE TYPE "AssignmentIntentStatus" AS ENUM ('INTENT_PENDING', 'HONORED', 'OVERRIDDEN', 'EXPIRED', 'CANCELLED'); EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||||
CREATE TYPE "MentorMessageRole" AS ENUM ('MENTOR_ROLE', 'APPLICANT_ROLE', 'ADMIN_ROLE');
|
DO $$ BEGIN CREATE TYPE "MentorMessageRole" AS ENUM ('MENTOR_ROLE', 'APPLICANT_ROLE', 'ADMIN_ROLE'); EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||||
CREATE TYPE "SubmissionPromotionSource" AS ENUM ('MENTOR_FILE', 'ADMIN_REPLACEMENT');
|
DO $$ BEGIN CREATE TYPE "SubmissionPromotionSource" AS ENUM ('MENTOR_FILE', 'ADMIN_REPLACEMENT'); EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||||
CREATE TYPE "DeliberationMode" AS ENUM ('SINGLE_WINNER_VOTE', 'FULL_RANKING');
|
DO $$ BEGIN CREATE TYPE "DeliberationMode" AS ENUM ('SINGLE_WINNER_VOTE', 'FULL_RANKING'); EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||||
CREATE TYPE "DeliberationStatus" AS ENUM ('DELIB_OPEN', 'VOTING', 'TALLYING', 'RUNOFF', 'DELIB_LOCKED');
|
DO $$ BEGIN CREATE TYPE "DeliberationStatus" AS ENUM ('DELIB_OPEN', 'VOTING', 'TALLYING', 'RUNOFF', 'DELIB_LOCKED'); EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||||
CREATE TYPE "TieBreakMethod" AS ENUM ('TIE_RUNOFF', 'TIE_ADMIN_DECIDES', 'SCORE_FALLBACK');
|
DO $$ BEGIN CREATE TYPE "TieBreakMethod" AS ENUM ('TIE_RUNOFF', 'TIE_ADMIN_DECIDES', 'SCORE_FALLBACK'); EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||||
CREATE TYPE "DeliberationParticipantStatus" AS ENUM ('REQUIRED', 'ABSENT_EXCUSED', 'REPLACED', 'REPLACEMENT_ACTIVE');
|
DO $$ BEGIN CREATE TYPE "DeliberationParticipantStatus" AS ENUM ('REQUIRED', 'ABSENT_EXCUSED', 'REPLACED', 'REPLACEMENT_ACTIVE'); EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||||
CREATE TYPE "AwardEligibilityMode" AS ENUM ('SEPARATE_POOL', 'STAY_IN_MAIN');
|
DO $$ BEGIN CREATE TYPE "AwardEligibilityMode" AS ENUM ('SEPARATE_POOL', 'STAY_IN_MAIN'); EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||||
|
|
||||||
-- Add FEATURE_FLAGS to SettingCategory enum
|
-- Add FEATURE_FLAGS to SettingCategory enum
|
||||||
ALTER TYPE "SettingCategory" ADD VALUE 'FEATURE_FLAGS';
|
DO $$ BEGIN ALTER TYPE "SettingCategory" ADD VALUE 'FEATURE_FLAGS'; EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||||
|
|
||||||
-- ─── New Tables ──────────────────────────────────────────────────────────────
|
-- --- New Tables ---
|
||||||
|
|
||||||
-- Competition (replaces Pipeline)
|
-- Competition (replaces Pipeline)
|
||||||
CREATE TABLE "Competition" (
|
CREATE TABLE IF NOT EXISTS "Competition" (
|
||||||
"id" TEXT NOT NULL,
|
"id" TEXT NOT NULL,
|
||||||
"programId" TEXT NOT NULL,
|
"programId" TEXT NOT NULL,
|
||||||
"name" TEXT NOT NULL,
|
"name" TEXT NOT NULL,
|
||||||
@@ -49,7 +49,7 @@ CREATE TABLE "Competition" (
|
|||||||
);
|
);
|
||||||
|
|
||||||
-- Round (replaces Stage)
|
-- Round (replaces Stage)
|
||||||
CREATE TABLE "Round" (
|
CREATE TABLE IF NOT EXISTS "Round" (
|
||||||
"id" TEXT NOT NULL,
|
"id" TEXT NOT NULL,
|
||||||
"competitionId" TEXT NOT NULL,
|
"competitionId" TEXT NOT NULL,
|
||||||
"name" TEXT NOT NULL,
|
"name" TEXT NOT NULL,
|
||||||
@@ -70,7 +70,7 @@ CREATE TABLE "Round" (
|
|||||||
);
|
);
|
||||||
|
|
||||||
-- ProjectRoundState
|
-- ProjectRoundState
|
||||||
CREATE TABLE "ProjectRoundState" (
|
CREATE TABLE IF NOT EXISTS "ProjectRoundState" (
|
||||||
"id" TEXT NOT NULL,
|
"id" TEXT NOT NULL,
|
||||||
"projectId" TEXT NOT NULL,
|
"projectId" TEXT NOT NULL,
|
||||||
"roundId" TEXT NOT NULL,
|
"roundId" TEXT NOT NULL,
|
||||||
@@ -85,7 +85,7 @@ CREATE TABLE "ProjectRoundState" (
|
|||||||
);
|
);
|
||||||
|
|
||||||
-- AdvancementRule
|
-- AdvancementRule
|
||||||
CREATE TABLE "AdvancementRule" (
|
CREATE TABLE IF NOT EXISTS "AdvancementRule" (
|
||||||
"id" TEXT NOT NULL,
|
"id" TEXT NOT NULL,
|
||||||
"roundId" TEXT NOT NULL,
|
"roundId" TEXT NOT NULL,
|
||||||
"targetRoundId" TEXT,
|
"targetRoundId" TEXT,
|
||||||
@@ -99,7 +99,7 @@ CREATE TABLE "AdvancementRule" (
|
|||||||
);
|
);
|
||||||
|
|
||||||
-- JuryGroup
|
-- JuryGroup
|
||||||
CREATE TABLE "JuryGroup" (
|
CREATE TABLE IF NOT EXISTS "JuryGroup" (
|
||||||
"id" TEXT NOT NULL,
|
"id" TEXT NOT NULL,
|
||||||
"competitionId" TEXT NOT NULL,
|
"competitionId" TEXT NOT NULL,
|
||||||
"name" TEXT NOT NULL,
|
"name" TEXT NOT NULL,
|
||||||
@@ -120,7 +120,7 @@ CREATE TABLE "JuryGroup" (
|
|||||||
);
|
);
|
||||||
|
|
||||||
-- JuryGroupMember
|
-- JuryGroupMember
|
||||||
CREATE TABLE "JuryGroupMember" (
|
CREATE TABLE IF NOT EXISTS "JuryGroupMember" (
|
||||||
"id" TEXT NOT NULL,
|
"id" TEXT NOT NULL,
|
||||||
"juryGroupId" TEXT NOT NULL,
|
"juryGroupId" TEXT NOT NULL,
|
||||||
"userId" TEXT NOT NULL,
|
"userId" TEXT NOT NULL,
|
||||||
@@ -138,7 +138,7 @@ CREATE TABLE "JuryGroupMember" (
|
|||||||
);
|
);
|
||||||
|
|
||||||
-- SubmissionWindow
|
-- SubmissionWindow
|
||||||
CREATE TABLE "SubmissionWindow" (
|
CREATE TABLE IF NOT EXISTS "SubmissionWindow" (
|
||||||
"id" TEXT NOT NULL,
|
"id" TEXT NOT NULL,
|
||||||
"competitionId" TEXT NOT NULL,
|
"competitionId" TEXT NOT NULL,
|
||||||
"name" TEXT NOT NULL,
|
"name" TEXT NOT NULL,
|
||||||
@@ -158,7 +158,7 @@ CREATE TABLE "SubmissionWindow" (
|
|||||||
);
|
);
|
||||||
|
|
||||||
-- SubmissionFileRequirement
|
-- SubmissionFileRequirement
|
||||||
CREATE TABLE "SubmissionFileRequirement" (
|
CREATE TABLE IF NOT EXISTS "SubmissionFileRequirement" (
|
||||||
"id" TEXT NOT NULL,
|
"id" TEXT NOT NULL,
|
||||||
"submissionWindowId" TEXT NOT NULL,
|
"submissionWindowId" TEXT NOT NULL,
|
||||||
"label" TEXT NOT NULL,
|
"label" TEXT NOT NULL,
|
||||||
@@ -175,7 +175,7 @@ CREATE TABLE "SubmissionFileRequirement" (
|
|||||||
);
|
);
|
||||||
|
|
||||||
-- RoundSubmissionVisibility
|
-- RoundSubmissionVisibility
|
||||||
CREATE TABLE "RoundSubmissionVisibility" (
|
CREATE TABLE IF NOT EXISTS "RoundSubmissionVisibility" (
|
||||||
"id" TEXT NOT NULL,
|
"id" TEXT NOT NULL,
|
||||||
"roundId" TEXT NOT NULL,
|
"roundId" TEXT NOT NULL,
|
||||||
"submissionWindowId" TEXT NOT NULL,
|
"submissionWindowId" TEXT NOT NULL,
|
||||||
@@ -186,7 +186,7 @@ CREATE TABLE "RoundSubmissionVisibility" (
|
|||||||
);
|
);
|
||||||
|
|
||||||
-- AssignmentIntent
|
-- AssignmentIntent
|
||||||
CREATE TABLE "AssignmentIntent" (
|
CREATE TABLE IF NOT EXISTS "AssignmentIntent" (
|
||||||
"id" TEXT NOT NULL,
|
"id" TEXT NOT NULL,
|
||||||
"juryGroupMemberId" TEXT NOT NULL,
|
"juryGroupMemberId" TEXT NOT NULL,
|
||||||
"roundId" TEXT NOT NULL,
|
"roundId" TEXT NOT NULL,
|
||||||
@@ -200,7 +200,7 @@ CREATE TABLE "AssignmentIntent" (
|
|||||||
);
|
);
|
||||||
|
|
||||||
-- AssignmentException
|
-- AssignmentException
|
||||||
CREATE TABLE "AssignmentException" (
|
CREATE TABLE IF NOT EXISTS "AssignmentException" (
|
||||||
"id" TEXT NOT NULL,
|
"id" TEXT NOT NULL,
|
||||||
"assignmentId" TEXT NOT NULL,
|
"assignmentId" TEXT NOT NULL,
|
||||||
"reason" TEXT NOT NULL,
|
"reason" TEXT NOT NULL,
|
||||||
@@ -212,7 +212,7 @@ CREATE TABLE "AssignmentException" (
|
|||||||
);
|
);
|
||||||
|
|
||||||
-- MentorFile
|
-- MentorFile
|
||||||
CREATE TABLE "MentorFile" (
|
CREATE TABLE IF NOT EXISTS "MentorFile" (
|
||||||
"id" TEXT NOT NULL,
|
"id" TEXT NOT NULL,
|
||||||
"mentorAssignmentId" TEXT NOT NULL,
|
"mentorAssignmentId" TEXT NOT NULL,
|
||||||
"uploadedByUserId" TEXT NOT NULL,
|
"uploadedByUserId" TEXT NOT NULL,
|
||||||
@@ -232,7 +232,7 @@ CREATE TABLE "MentorFile" (
|
|||||||
);
|
);
|
||||||
|
|
||||||
-- MentorFileComment
|
-- MentorFileComment
|
||||||
CREATE TABLE "MentorFileComment" (
|
CREATE TABLE IF NOT EXISTS "MentorFileComment" (
|
||||||
"id" TEXT NOT NULL,
|
"id" TEXT NOT NULL,
|
||||||
"mentorFileId" TEXT NOT NULL,
|
"mentorFileId" TEXT NOT NULL,
|
||||||
"authorId" TEXT NOT NULL,
|
"authorId" TEXT NOT NULL,
|
||||||
@@ -245,7 +245,7 @@ CREATE TABLE "MentorFileComment" (
|
|||||||
);
|
);
|
||||||
|
|
||||||
-- SubmissionPromotionEvent
|
-- SubmissionPromotionEvent
|
||||||
CREATE TABLE "SubmissionPromotionEvent" (
|
CREATE TABLE IF NOT EXISTS "SubmissionPromotionEvent" (
|
||||||
"id" TEXT NOT NULL,
|
"id" TEXT NOT NULL,
|
||||||
"projectId" TEXT NOT NULL,
|
"projectId" TEXT NOT NULL,
|
||||||
"roundId" TEXT NOT NULL,
|
"roundId" TEXT NOT NULL,
|
||||||
@@ -259,7 +259,7 @@ CREATE TABLE "SubmissionPromotionEvent" (
|
|||||||
);
|
);
|
||||||
|
|
||||||
-- DeliberationSession
|
-- DeliberationSession
|
||||||
CREATE TABLE "DeliberationSession" (
|
CREATE TABLE IF NOT EXISTS "DeliberationSession" (
|
||||||
"id" TEXT NOT NULL,
|
"id" TEXT NOT NULL,
|
||||||
"competitionId" TEXT NOT NULL,
|
"competitionId" TEXT NOT NULL,
|
||||||
"roundId" TEXT NOT NULL,
|
"roundId" TEXT NOT NULL,
|
||||||
@@ -277,7 +277,7 @@ CREATE TABLE "DeliberationSession" (
|
|||||||
);
|
);
|
||||||
|
|
||||||
-- DeliberationVote
|
-- DeliberationVote
|
||||||
CREATE TABLE "DeliberationVote" (
|
CREATE TABLE IF NOT EXISTS "DeliberationVote" (
|
||||||
"id" TEXT NOT NULL,
|
"id" TEXT NOT NULL,
|
||||||
"sessionId" TEXT NOT NULL,
|
"sessionId" TEXT NOT NULL,
|
||||||
"juryMemberId" TEXT NOT NULL,
|
"juryMemberId" TEXT NOT NULL,
|
||||||
@@ -291,7 +291,7 @@ CREATE TABLE "DeliberationVote" (
|
|||||||
);
|
);
|
||||||
|
|
||||||
-- DeliberationResult
|
-- DeliberationResult
|
||||||
CREATE TABLE "DeliberationResult" (
|
CREATE TABLE IF NOT EXISTS "DeliberationResult" (
|
||||||
"id" TEXT NOT NULL,
|
"id" TEXT NOT NULL,
|
||||||
"sessionId" TEXT NOT NULL,
|
"sessionId" TEXT NOT NULL,
|
||||||
"projectId" TEXT NOT NULL,
|
"projectId" TEXT NOT NULL,
|
||||||
@@ -304,7 +304,7 @@ CREATE TABLE "DeliberationResult" (
|
|||||||
);
|
);
|
||||||
|
|
||||||
-- DeliberationParticipant
|
-- DeliberationParticipant
|
||||||
CREATE TABLE "DeliberationParticipant" (
|
CREATE TABLE IF NOT EXISTS "DeliberationParticipant" (
|
||||||
"id" TEXT NOT NULL,
|
"id" TEXT NOT NULL,
|
||||||
"sessionId" TEXT NOT NULL,
|
"sessionId" TEXT NOT NULL,
|
||||||
"userId" TEXT NOT NULL,
|
"userId" TEXT NOT NULL,
|
||||||
@@ -315,7 +315,7 @@ CREATE TABLE "DeliberationParticipant" (
|
|||||||
);
|
);
|
||||||
|
|
||||||
-- ResultLock
|
-- ResultLock
|
||||||
CREATE TABLE "ResultLock" (
|
CREATE TABLE IF NOT EXISTS "ResultLock" (
|
||||||
"id" TEXT NOT NULL,
|
"id" TEXT NOT NULL,
|
||||||
"competitionId" TEXT NOT NULL,
|
"competitionId" TEXT NOT NULL,
|
||||||
"roundId" TEXT NOT NULL,
|
"roundId" TEXT NOT NULL,
|
||||||
@@ -328,7 +328,7 @@ CREATE TABLE "ResultLock" (
|
|||||||
);
|
);
|
||||||
|
|
||||||
-- ResultUnlockEvent
|
-- ResultUnlockEvent
|
||||||
CREATE TABLE "ResultUnlockEvent" (
|
CREATE TABLE IF NOT EXISTS "ResultUnlockEvent" (
|
||||||
"id" TEXT NOT NULL,
|
"id" TEXT NOT NULL,
|
||||||
"resultLockId" TEXT NOT NULL,
|
"resultLockId" TEXT NOT NULL,
|
||||||
"unlockedById" TEXT NOT NULL,
|
"unlockedById" TEXT NOT NULL,
|
||||||
@@ -338,235 +338,365 @@ CREATE TABLE "ResultUnlockEvent" (
|
|||||||
CONSTRAINT "ResultUnlockEvent_pkey" PRIMARY KEY ("id")
|
CONSTRAINT "ResultUnlockEvent_pkey" PRIMARY KEY ("id")
|
||||||
);
|
);
|
||||||
|
|
||||||
-- ─── Add Columns to Existing Tables ──────────────────────────────────────────
|
-- --- Add Columns to Existing Tables ---
|
||||||
|
|
||||||
-- Assignment: add juryGroupId
|
-- Assignment: add juryGroupId
|
||||||
ALTER TABLE "Assignment" ADD COLUMN "juryGroupId" TEXT;
|
DO $$ BEGIN
|
||||||
|
ALTER TABLE "Assignment" ADD COLUMN "juryGroupId" TEXT;
|
||||||
|
EXCEPTION WHEN duplicate_column THEN NULL; END $$;
|
||||||
|
|
||||||
-- SpecialAward: add competition/round architecture fields
|
-- SpecialAward: add competition/round architecture fields
|
||||||
ALTER TABLE "SpecialAward" ADD COLUMN "competitionId" TEXT;
|
DO $$ BEGIN
|
||||||
ALTER TABLE "SpecialAward" ADD COLUMN "evaluationRoundId" TEXT;
|
ALTER TABLE "SpecialAward" ADD COLUMN "competitionId" TEXT;
|
||||||
ALTER TABLE "SpecialAward" ADD COLUMN "juryGroupId" TEXT;
|
EXCEPTION WHEN duplicate_column THEN NULL; END $$;
|
||||||
ALTER TABLE "SpecialAward" ADD COLUMN "eligibilityMode" "AwardEligibilityMode" NOT NULL DEFAULT 'STAY_IN_MAIN';
|
DO $$ BEGIN
|
||||||
ALTER TABLE "SpecialAward" ADD COLUMN "decisionMode" TEXT;
|
ALTER TABLE "SpecialAward" ADD COLUMN "evaluationRoundId" TEXT;
|
||||||
|
EXCEPTION WHEN duplicate_column THEN NULL; END $$;
|
||||||
|
DO $$ BEGIN
|
||||||
|
ALTER TABLE "SpecialAward" ADD COLUMN "juryGroupId" TEXT;
|
||||||
|
EXCEPTION WHEN duplicate_column THEN NULL; END $$;
|
||||||
|
DO $$ BEGIN
|
||||||
|
ALTER TABLE "SpecialAward" ADD COLUMN "eligibilityMode" "AwardEligibilityMode" NOT NULL DEFAULT 'STAY_IN_MAIN';
|
||||||
|
EXCEPTION WHEN duplicate_column THEN NULL; END $$;
|
||||||
|
DO $$ BEGIN
|
||||||
|
ALTER TABLE "SpecialAward" ADD COLUMN "decisionMode" TEXT;
|
||||||
|
EXCEPTION WHEN duplicate_column THEN NULL; END $$;
|
||||||
|
|
||||||
-- MentorAssignment: add workspace fields
|
-- MentorAssignment: add workspace fields
|
||||||
ALTER TABLE "MentorAssignment" ADD COLUMN "workspaceEnabled" BOOLEAN NOT NULL DEFAULT false;
|
DO $$ BEGIN
|
||||||
ALTER TABLE "MentorAssignment" ADD COLUMN "workspaceOpenAt" TIMESTAMP(3);
|
ALTER TABLE "MentorAssignment" ADD COLUMN "workspaceEnabled" BOOLEAN NOT NULL DEFAULT false;
|
||||||
ALTER TABLE "MentorAssignment" ADD COLUMN "workspaceCloseAt" TIMESTAMP(3);
|
EXCEPTION WHEN duplicate_column THEN NULL; END $$;
|
||||||
|
DO $$ BEGIN
|
||||||
|
ALTER TABLE "MentorAssignment" ADD COLUMN "workspaceOpenAt" TIMESTAMP(3);
|
||||||
|
EXCEPTION WHEN duplicate_column THEN NULL; END $$;
|
||||||
|
DO $$ BEGIN
|
||||||
|
ALTER TABLE "MentorAssignment" ADD COLUMN "workspaceCloseAt" TIMESTAMP(3);
|
||||||
|
EXCEPTION WHEN duplicate_column THEN NULL; END $$;
|
||||||
|
|
||||||
-- MentorMessage: add workspace fields
|
-- MentorMessage: add workspace fields
|
||||||
ALTER TABLE "MentorMessage" ADD COLUMN "workspaceId" TEXT;
|
DO $$ BEGIN
|
||||||
ALTER TABLE "MentorMessage" ADD COLUMN "senderRole" "MentorMessageRole";
|
ALTER TABLE "MentorMessage" ADD COLUMN "workspaceId" TEXT;
|
||||||
|
EXCEPTION WHEN duplicate_column THEN NULL; END $$;
|
||||||
|
DO $$ BEGIN
|
||||||
|
ALTER TABLE "MentorMessage" ADD COLUMN "senderRole" "MentorMessageRole";
|
||||||
|
EXCEPTION WHEN duplicate_column THEN NULL; END $$;
|
||||||
|
|
||||||
-- ProjectFile: add submission window link
|
-- ProjectFile: add submission window link
|
||||||
ALTER TABLE "ProjectFile" ADD COLUMN "submissionWindowId" TEXT;
|
DO $$ BEGIN
|
||||||
ALTER TABLE "ProjectFile" ADD COLUMN "submissionFileRequirementId" TEXT;
|
ALTER TABLE "ProjectFile" ADD COLUMN "submissionWindowId" TEXT;
|
||||||
|
EXCEPTION WHEN duplicate_column THEN NULL; END $$;
|
||||||
|
DO $$ BEGIN
|
||||||
|
ALTER TABLE "ProjectFile" ADD COLUMN "submissionFileRequirementId" TEXT;
|
||||||
|
EXCEPTION WHEN duplicate_column THEN NULL; END $$;
|
||||||
|
|
||||||
-- ─── Unique Constraints ──────────────────────────────────────────────────────
|
-- --- Unique Constraints ---
|
||||||
|
|
||||||
CREATE UNIQUE INDEX "Competition_slug_key" ON "Competition"("slug");
|
CREATE UNIQUE INDEX IF NOT EXISTS "Competition_slug_key" ON "Competition"("slug");
|
||||||
CREATE UNIQUE INDEX "Round_competitionId_slug_key" ON "Round"("competitionId", "slug");
|
CREATE UNIQUE INDEX IF NOT EXISTS "Round_competitionId_slug_key" ON "Round"("competitionId", "slug");
|
||||||
CREATE UNIQUE INDEX "Round_competitionId_sortOrder_key" ON "Round"("competitionId", "sortOrder");
|
CREATE UNIQUE INDEX IF NOT EXISTS "Round_competitionId_sortOrder_key" ON "Round"("competitionId", "sortOrder");
|
||||||
CREATE UNIQUE INDEX "ProjectRoundState_projectId_roundId_key" ON "ProjectRoundState"("projectId", "roundId");
|
CREATE UNIQUE INDEX IF NOT EXISTS "ProjectRoundState_projectId_roundId_key" ON "ProjectRoundState"("projectId", "roundId");
|
||||||
CREATE UNIQUE INDEX "JuryGroup_competitionId_slug_key" ON "JuryGroup"("competitionId", "slug");
|
CREATE UNIQUE INDEX IF NOT EXISTS "JuryGroup_competitionId_slug_key" ON "JuryGroup"("competitionId", "slug");
|
||||||
CREATE UNIQUE INDEX "JuryGroupMember_juryGroupId_userId_key" ON "JuryGroupMember"("juryGroupId", "userId");
|
CREATE UNIQUE INDEX IF NOT EXISTS "JuryGroupMember_juryGroupId_userId_key" ON "JuryGroupMember"("juryGroupId", "userId");
|
||||||
CREATE UNIQUE INDEX "SubmissionWindow_competitionId_slug_key" ON "SubmissionWindow"("competitionId", "slug");
|
CREATE UNIQUE INDEX IF NOT EXISTS "SubmissionWindow_competitionId_slug_key" ON "SubmissionWindow"("competitionId", "slug");
|
||||||
CREATE UNIQUE INDEX "SubmissionWindow_competitionId_roundNumber_key" ON "SubmissionWindow"("competitionId", "roundNumber");
|
CREATE UNIQUE INDEX IF NOT EXISTS "SubmissionWindow_competitionId_roundNumber_key" ON "SubmissionWindow"("competitionId", "roundNumber");
|
||||||
CREATE UNIQUE INDEX "RoundSubmissionVisibility_roundId_submissionWindowId_key" ON "RoundSubmissionVisibility"("roundId", "submissionWindowId");
|
CREATE UNIQUE INDEX IF NOT EXISTS "RoundSubmissionVisibility_roundId_submissionWindowId_key" ON "RoundSubmissionVisibility"("roundId", "submissionWindowId");
|
||||||
CREATE UNIQUE INDEX "AssignmentIntent_juryGroupMemberId_roundId_projectId_key" ON "AssignmentIntent"("juryGroupMemberId", "roundId", "projectId");
|
CREATE UNIQUE INDEX IF NOT EXISTS "AssignmentIntent_juryGroupMemberId_roundId_projectId_key" ON "AssignmentIntent"("juryGroupMemberId", "roundId", "projectId");
|
||||||
CREATE UNIQUE INDEX "MentorFile_promotedToFileId_key" ON "MentorFile"("promotedToFileId");
|
CREATE UNIQUE INDEX IF NOT EXISTS "MentorFile_promotedToFileId_key" ON "MentorFile"("promotedToFileId");
|
||||||
CREATE UNIQUE INDEX "DeliberationVote_sessionId_juryMemberId_projectId_runoffRo_key" ON "DeliberationVote"("sessionId", "juryMemberId", "projectId", "runoffRound");
|
CREATE UNIQUE INDEX IF NOT EXISTS "DeliberationVote_sessionId_juryMemberId_projectId_runoffRo_key" ON "DeliberationVote"("sessionId", "juryMemberId", "projectId", "runoffRound");
|
||||||
CREATE UNIQUE INDEX "DeliberationResult_sessionId_projectId_key" ON "DeliberationResult"("sessionId", "projectId");
|
CREATE UNIQUE INDEX IF NOT EXISTS "DeliberationResult_sessionId_projectId_key" ON "DeliberationResult"("sessionId", "projectId");
|
||||||
CREATE UNIQUE INDEX "DeliberationParticipant_sessionId_userId_key" ON "DeliberationParticipant"("sessionId", "userId");
|
CREATE UNIQUE INDEX IF NOT EXISTS "DeliberationParticipant_sessionId_userId_key" ON "DeliberationParticipant"("sessionId", "userId");
|
||||||
CREATE UNIQUE INDEX "SubmissionFileRequirement_submissionWindowId_slug_key" ON "SubmissionFileRequirement"("submissionWindowId", "slug");
|
CREATE UNIQUE INDEX IF NOT EXISTS "SubmissionFileRequirement_submissionWindowId_slug_key" ON "SubmissionFileRequirement"("submissionWindowId", "slug");
|
||||||
CREATE UNIQUE INDEX "AdvancementRule_roundId_sortOrder_key" ON "AdvancementRule"("roundId", "sortOrder");
|
CREATE UNIQUE INDEX IF NOT EXISTS "AdvancementRule_roundId_sortOrder_key" ON "AdvancementRule"("roundId", "sortOrder");
|
||||||
|
|
||||||
-- ─── Indexes ─────────────────────────────────────────────────────────────────
|
-- --- Indexes ---
|
||||||
|
|
||||||
-- Competition
|
-- Competition
|
||||||
CREATE INDEX "Competition_programId_idx" ON "Competition"("programId");
|
CREATE INDEX IF NOT EXISTS "Competition_programId_idx" ON "Competition"("programId");
|
||||||
CREATE INDEX "Competition_status_idx" ON "Competition"("status");
|
CREATE INDEX IF NOT EXISTS "Competition_status_idx" ON "Competition"("status");
|
||||||
|
|
||||||
-- Round
|
-- Round
|
||||||
CREATE INDEX "Round_competitionId_idx" ON "Round"("competitionId");
|
CREATE INDEX IF NOT EXISTS "Round_competitionId_idx" ON "Round"("competitionId");
|
||||||
CREATE INDEX "Round_roundType_idx" ON "Round"("roundType");
|
CREATE INDEX IF NOT EXISTS "Round_roundType_idx" ON "Round"("roundType");
|
||||||
CREATE INDEX "Round_status_idx" ON "Round"("status");
|
CREATE INDEX IF NOT EXISTS "Round_status_idx" ON "Round"("status");
|
||||||
|
|
||||||
-- ProjectRoundState
|
-- ProjectRoundState
|
||||||
CREATE INDEX "ProjectRoundState_projectId_idx" ON "ProjectRoundState"("projectId");
|
CREATE INDEX IF NOT EXISTS "ProjectRoundState_projectId_idx" ON "ProjectRoundState"("projectId");
|
||||||
CREATE INDEX "ProjectRoundState_roundId_idx" ON "ProjectRoundState"("roundId");
|
CREATE INDEX IF NOT EXISTS "ProjectRoundState_roundId_idx" ON "ProjectRoundState"("roundId");
|
||||||
CREATE INDEX "ProjectRoundState_state_idx" ON "ProjectRoundState"("state");
|
CREATE INDEX IF NOT EXISTS "ProjectRoundState_state_idx" ON "ProjectRoundState"("state");
|
||||||
|
|
||||||
-- AdvancementRule
|
-- AdvancementRule
|
||||||
CREATE INDEX "AdvancementRule_roundId_idx" ON "AdvancementRule"("roundId");
|
CREATE INDEX IF NOT EXISTS "AdvancementRule_roundId_idx" ON "AdvancementRule"("roundId");
|
||||||
|
|
||||||
-- JuryGroup
|
-- JuryGroup
|
||||||
CREATE INDEX "JuryGroup_competitionId_idx" ON "JuryGroup"("competitionId");
|
CREATE INDEX IF NOT EXISTS "JuryGroup_competitionId_idx" ON "JuryGroup"("competitionId");
|
||||||
|
|
||||||
-- JuryGroupMember
|
-- JuryGroupMember
|
||||||
CREATE INDEX "JuryGroupMember_juryGroupId_idx" ON "JuryGroupMember"("juryGroupId");
|
CREATE INDEX IF NOT EXISTS "JuryGroupMember_juryGroupId_idx" ON "JuryGroupMember"("juryGroupId");
|
||||||
CREATE INDEX "JuryGroupMember_userId_idx" ON "JuryGroupMember"("userId");
|
CREATE INDEX IF NOT EXISTS "JuryGroupMember_userId_idx" ON "JuryGroupMember"("userId");
|
||||||
|
|
||||||
-- SubmissionWindow
|
-- SubmissionWindow
|
||||||
CREATE INDEX "SubmissionWindow_competitionId_idx" ON "SubmissionWindow"("competitionId");
|
CREATE INDEX IF NOT EXISTS "SubmissionWindow_competitionId_idx" ON "SubmissionWindow"("competitionId");
|
||||||
|
|
||||||
-- SubmissionFileRequirement
|
-- SubmissionFileRequirement
|
||||||
CREATE INDEX "SubmissionFileRequirement_submissionWindowId_idx" ON "SubmissionFileRequirement"("submissionWindowId");
|
CREATE INDEX IF NOT EXISTS "SubmissionFileRequirement_submissionWindowId_idx" ON "SubmissionFileRequirement"("submissionWindowId");
|
||||||
|
|
||||||
-- RoundSubmissionVisibility
|
-- RoundSubmissionVisibility
|
||||||
CREATE INDEX "RoundSubmissionVisibility_roundId_idx" ON "RoundSubmissionVisibility"("roundId");
|
CREATE INDEX IF NOT EXISTS "RoundSubmissionVisibility_roundId_idx" ON "RoundSubmissionVisibility"("roundId");
|
||||||
|
|
||||||
-- AssignmentIntent
|
-- AssignmentIntent
|
||||||
CREATE INDEX "AssignmentIntent_roundId_idx" ON "AssignmentIntent"("roundId");
|
CREATE INDEX IF NOT EXISTS "AssignmentIntent_roundId_idx" ON "AssignmentIntent"("roundId");
|
||||||
CREATE INDEX "AssignmentIntent_projectId_idx" ON "AssignmentIntent"("projectId");
|
CREATE INDEX IF NOT EXISTS "AssignmentIntent_projectId_idx" ON "AssignmentIntent"("projectId");
|
||||||
CREATE INDEX "AssignmentIntent_status_idx" ON "AssignmentIntent"("status");
|
CREATE INDEX IF NOT EXISTS "AssignmentIntent_status_idx" ON "AssignmentIntent"("status");
|
||||||
|
|
||||||
-- AssignmentException
|
-- AssignmentException
|
||||||
CREATE INDEX "AssignmentException_assignmentId_idx" ON "AssignmentException"("assignmentId");
|
CREATE INDEX IF NOT EXISTS "AssignmentException_assignmentId_idx" ON "AssignmentException"("assignmentId");
|
||||||
CREATE INDEX "AssignmentException_approvedById_idx" ON "AssignmentException"("approvedById");
|
CREATE INDEX IF NOT EXISTS "AssignmentException_approvedById_idx" ON "AssignmentException"("approvedById");
|
||||||
|
|
||||||
-- MentorFile
|
-- MentorFile
|
||||||
CREATE INDEX "MentorFile_mentorAssignmentId_idx" ON "MentorFile"("mentorAssignmentId");
|
CREATE INDEX IF NOT EXISTS "MentorFile_mentorAssignmentId_idx" ON "MentorFile"("mentorAssignmentId");
|
||||||
CREATE INDEX "MentorFile_uploadedByUserId_idx" ON "MentorFile"("uploadedByUserId");
|
CREATE INDEX IF NOT EXISTS "MentorFile_uploadedByUserId_idx" ON "MentorFile"("uploadedByUserId");
|
||||||
|
|
||||||
-- MentorFileComment
|
-- MentorFileComment
|
||||||
CREATE INDEX "MentorFileComment_mentorFileId_idx" ON "MentorFileComment"("mentorFileId");
|
CREATE INDEX IF NOT EXISTS "MentorFileComment_mentorFileId_idx" ON "MentorFileComment"("mentorFileId");
|
||||||
CREATE INDEX "MentorFileComment_authorId_idx" ON "MentorFileComment"("authorId");
|
CREATE INDEX IF NOT EXISTS "MentorFileComment_authorId_idx" ON "MentorFileComment"("authorId");
|
||||||
CREATE INDEX "MentorFileComment_parentCommentId_idx" ON "MentorFileComment"("parentCommentId");
|
CREATE INDEX IF NOT EXISTS "MentorFileComment_parentCommentId_idx" ON "MentorFileComment"("parentCommentId");
|
||||||
|
|
||||||
-- SubmissionPromotionEvent
|
-- SubmissionPromotionEvent
|
||||||
CREATE INDEX "SubmissionPromotionEvent_projectId_idx" ON "SubmissionPromotionEvent"("projectId");
|
CREATE INDEX IF NOT EXISTS "SubmissionPromotionEvent_projectId_idx" ON "SubmissionPromotionEvent"("projectId");
|
||||||
CREATE INDEX "SubmissionPromotionEvent_roundId_idx" ON "SubmissionPromotionEvent"("roundId");
|
CREATE INDEX IF NOT EXISTS "SubmissionPromotionEvent_roundId_idx" ON "SubmissionPromotionEvent"("roundId");
|
||||||
CREATE INDEX "SubmissionPromotionEvent_sourceFileId_idx" ON "SubmissionPromotionEvent"("sourceFileId");
|
CREATE INDEX IF NOT EXISTS "SubmissionPromotionEvent_sourceFileId_idx" ON "SubmissionPromotionEvent"("sourceFileId");
|
||||||
|
|
||||||
-- DeliberationSession
|
-- DeliberationSession
|
||||||
CREATE INDEX "DeliberationSession_competitionId_idx" ON "DeliberationSession"("competitionId");
|
CREATE INDEX IF NOT EXISTS "DeliberationSession_competitionId_idx" ON "DeliberationSession"("competitionId");
|
||||||
CREATE INDEX "DeliberationSession_roundId_idx" ON "DeliberationSession"("roundId");
|
CREATE INDEX IF NOT EXISTS "DeliberationSession_roundId_idx" ON "DeliberationSession"("roundId");
|
||||||
CREATE INDEX "DeliberationSession_status_idx" ON "DeliberationSession"("status");
|
CREATE INDEX IF NOT EXISTS "DeliberationSession_status_idx" ON "DeliberationSession"("status");
|
||||||
|
|
||||||
-- DeliberationVote
|
-- DeliberationVote
|
||||||
CREATE INDEX "DeliberationVote_sessionId_idx" ON "DeliberationVote"("sessionId");
|
CREATE INDEX IF NOT EXISTS "DeliberationVote_sessionId_idx" ON "DeliberationVote"("sessionId");
|
||||||
CREATE INDEX "DeliberationVote_juryMemberId_idx" ON "DeliberationVote"("juryMemberId");
|
CREATE INDEX IF NOT EXISTS "DeliberationVote_juryMemberId_idx" ON "DeliberationVote"("juryMemberId");
|
||||||
CREATE INDEX "DeliberationVote_projectId_idx" ON "DeliberationVote"("projectId");
|
CREATE INDEX IF NOT EXISTS "DeliberationVote_projectId_idx" ON "DeliberationVote"("projectId");
|
||||||
|
|
||||||
-- DeliberationResult
|
-- DeliberationResult
|
||||||
CREATE INDEX "DeliberationResult_sessionId_idx" ON "DeliberationResult"("sessionId");
|
CREATE INDEX IF NOT EXISTS "DeliberationResult_sessionId_idx" ON "DeliberationResult"("sessionId");
|
||||||
CREATE INDEX "DeliberationResult_projectId_idx" ON "DeliberationResult"("projectId");
|
CREATE INDEX IF NOT EXISTS "DeliberationResult_projectId_idx" ON "DeliberationResult"("projectId");
|
||||||
|
|
||||||
-- DeliberationParticipant
|
-- DeliberationParticipant
|
||||||
CREATE INDEX "DeliberationParticipant_sessionId_idx" ON "DeliberationParticipant"("sessionId");
|
CREATE INDEX IF NOT EXISTS "DeliberationParticipant_sessionId_idx" ON "DeliberationParticipant"("sessionId");
|
||||||
CREATE INDEX "DeliberationParticipant_userId_idx" ON "DeliberationParticipant"("userId");
|
CREATE INDEX IF NOT EXISTS "DeliberationParticipant_userId_idx" ON "DeliberationParticipant"("userId");
|
||||||
|
|
||||||
-- ResultLock
|
-- ResultLock
|
||||||
CREATE INDEX "ResultLock_competitionId_idx" ON "ResultLock"("competitionId");
|
CREATE INDEX IF NOT EXISTS "ResultLock_competitionId_idx" ON "ResultLock"("competitionId");
|
||||||
CREATE INDEX "ResultLock_roundId_idx" ON "ResultLock"("roundId");
|
CREATE INDEX IF NOT EXISTS "ResultLock_roundId_idx" ON "ResultLock"("roundId");
|
||||||
CREATE INDEX "ResultLock_category_idx" ON "ResultLock"("category");
|
CREATE INDEX IF NOT EXISTS "ResultLock_category_idx" ON "ResultLock"("category");
|
||||||
|
|
||||||
-- ResultUnlockEvent
|
-- ResultUnlockEvent
|
||||||
CREATE INDEX "ResultUnlockEvent_resultLockId_idx" ON "ResultUnlockEvent"("resultLockId");
|
CREATE INDEX IF NOT EXISTS "ResultUnlockEvent_resultLockId_idx" ON "ResultUnlockEvent"("resultLockId");
|
||||||
CREATE INDEX "ResultUnlockEvent_unlockedById_idx" ON "ResultUnlockEvent"("unlockedById");
|
CREATE INDEX IF NOT EXISTS "ResultUnlockEvent_unlockedById_idx" ON "ResultUnlockEvent"("unlockedById");
|
||||||
|
|
||||||
-- Indexes on modified existing tables
|
-- Indexes on modified existing tables
|
||||||
CREATE INDEX "Assignment_juryGroupId_idx" ON "Assignment"("juryGroupId");
|
CREATE INDEX IF NOT EXISTS "Assignment_juryGroupId_idx" ON "Assignment"("juryGroupId");
|
||||||
CREATE INDEX "SpecialAward_competitionId_idx" ON "SpecialAward"("competitionId");
|
CREATE INDEX IF NOT EXISTS "SpecialAward_competitionId_idx" ON "SpecialAward"("competitionId");
|
||||||
CREATE INDEX "SpecialAward_evaluationRoundId_idx" ON "SpecialAward"("evaluationRoundId");
|
CREATE INDEX IF NOT EXISTS "SpecialAward_evaluationRoundId_idx" ON "SpecialAward"("evaluationRoundId");
|
||||||
CREATE INDEX "MentorMessage_workspaceId_idx" ON "MentorMessage"("workspaceId");
|
CREATE INDEX IF NOT EXISTS "MentorMessage_workspaceId_idx" ON "MentorMessage"("workspaceId");
|
||||||
CREATE INDEX "ProjectFile_submissionWindowId_idx" ON "ProjectFile"("submissionWindowId");
|
CREATE INDEX IF NOT EXISTS "ProjectFile_submissionWindowId_idx" ON "ProjectFile"("submissionWindowId");
|
||||||
CREATE INDEX "ProjectFile_submissionFileRequirementId_idx" ON "ProjectFile"("submissionFileRequirementId");
|
CREATE INDEX IF NOT EXISTS "ProjectFile_submissionFileRequirementId_idx" ON "ProjectFile"("submissionFileRequirementId");
|
||||||
|
|
||||||
-- ─── Foreign Keys ────────────────────────────────────────────────────────────
|
-- --- Foreign Keys ---
|
||||||
|
|
||||||
-- Competition
|
-- Competition
|
||||||
ALTER TABLE "Competition" ADD CONSTRAINT "Competition_programId_fkey" FOREIGN KEY ("programId") REFERENCES "Program"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
DO $$ BEGIN
|
||||||
|
ALTER TABLE "Competition" ADD CONSTRAINT "Competition_programId_fkey" FOREIGN KEY ("programId") REFERENCES "Program"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||||
|
|
||||||
-- Round
|
-- Round
|
||||||
ALTER TABLE "Round" ADD CONSTRAINT "Round_competitionId_fkey" FOREIGN KEY ("competitionId") REFERENCES "Competition"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
DO $$ BEGIN
|
||||||
ALTER TABLE "Round" ADD CONSTRAINT "Round_juryGroupId_fkey" FOREIGN KEY ("juryGroupId") REFERENCES "JuryGroup"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
ALTER TABLE "Round" ADD CONSTRAINT "Round_competitionId_fkey" FOREIGN KEY ("competitionId") REFERENCES "Competition"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
ALTER TABLE "Round" ADD CONSTRAINT "Round_submissionWindowId_fkey" FOREIGN KEY ("submissionWindowId") REFERENCES "SubmissionWindow"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||||
|
DO $$ BEGIN
|
||||||
|
ALTER TABLE "Round" ADD CONSTRAINT "Round_juryGroupId_fkey" FOREIGN KEY ("juryGroupId") REFERENCES "JuryGroup"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||||
|
DO $$ BEGIN
|
||||||
|
ALTER TABLE "Round" ADD CONSTRAINT "Round_submissionWindowId_fkey" FOREIGN KEY ("submissionWindowId") REFERENCES "SubmissionWindow"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||||
|
|
||||||
-- ProjectRoundState
|
-- ProjectRoundState
|
||||||
ALTER TABLE "ProjectRoundState" ADD CONSTRAINT "ProjectRoundState_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
DO $$ BEGIN
|
||||||
ALTER TABLE "ProjectRoundState" ADD CONSTRAINT "ProjectRoundState_roundId_fkey" FOREIGN KEY ("roundId") REFERENCES "Round"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
ALTER TABLE "ProjectRoundState" ADD CONSTRAINT "ProjectRoundState_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||||
|
DO $$ BEGIN
|
||||||
|
ALTER TABLE "ProjectRoundState" ADD CONSTRAINT "ProjectRoundState_roundId_fkey" FOREIGN KEY ("roundId") REFERENCES "Round"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||||
|
|
||||||
-- AdvancementRule
|
-- AdvancementRule
|
||||||
ALTER TABLE "AdvancementRule" ADD CONSTRAINT "AdvancementRule_roundId_fkey" FOREIGN KEY ("roundId") REFERENCES "Round"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
DO $$ BEGIN
|
||||||
|
ALTER TABLE "AdvancementRule" ADD CONSTRAINT "AdvancementRule_roundId_fkey" FOREIGN KEY ("roundId") REFERENCES "Round"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||||
|
|
||||||
-- JuryGroup
|
-- JuryGroup
|
||||||
ALTER TABLE "JuryGroup" ADD CONSTRAINT "JuryGroup_competitionId_fkey" FOREIGN KEY ("competitionId") REFERENCES "Competition"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
DO $$ BEGIN
|
||||||
|
ALTER TABLE "JuryGroup" ADD CONSTRAINT "JuryGroup_competitionId_fkey" FOREIGN KEY ("competitionId") REFERENCES "Competition"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||||
|
|
||||||
-- JuryGroupMember
|
-- JuryGroupMember
|
||||||
ALTER TABLE "JuryGroupMember" ADD CONSTRAINT "JuryGroupMember_juryGroupId_fkey" FOREIGN KEY ("juryGroupId") REFERENCES "JuryGroup"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
DO $$ BEGIN
|
||||||
ALTER TABLE "JuryGroupMember" ADD CONSTRAINT "JuryGroupMember_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
ALTER TABLE "JuryGroupMember" ADD CONSTRAINT "JuryGroupMember_juryGroupId_fkey" FOREIGN KEY ("juryGroupId") REFERENCES "JuryGroup"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||||
|
DO $$ BEGIN
|
||||||
|
ALTER TABLE "JuryGroupMember" ADD CONSTRAINT "JuryGroupMember_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||||
|
|
||||||
-- SubmissionWindow
|
-- SubmissionWindow
|
||||||
ALTER TABLE "SubmissionWindow" ADD CONSTRAINT "SubmissionWindow_competitionId_fkey" FOREIGN KEY ("competitionId") REFERENCES "Competition"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
DO $$ BEGIN
|
||||||
|
ALTER TABLE "SubmissionWindow" ADD CONSTRAINT "SubmissionWindow_competitionId_fkey" FOREIGN KEY ("competitionId") REFERENCES "Competition"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||||
|
|
||||||
-- SubmissionFileRequirement
|
-- SubmissionFileRequirement
|
||||||
ALTER TABLE "SubmissionFileRequirement" ADD CONSTRAINT "SubmissionFileRequirement_submissionWindowId_fkey" FOREIGN KEY ("submissionWindowId") REFERENCES "SubmissionWindow"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
DO $$ BEGIN
|
||||||
|
ALTER TABLE "SubmissionFileRequirement" ADD CONSTRAINT "SubmissionFileRequirement_submissionWindowId_fkey" FOREIGN KEY ("submissionWindowId") REFERENCES "SubmissionWindow"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||||
|
|
||||||
-- RoundSubmissionVisibility
|
-- RoundSubmissionVisibility
|
||||||
ALTER TABLE "RoundSubmissionVisibility" ADD CONSTRAINT "RoundSubmissionVisibility_roundId_fkey" FOREIGN KEY ("roundId") REFERENCES "Round"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
DO $$ BEGIN
|
||||||
ALTER TABLE "RoundSubmissionVisibility" ADD CONSTRAINT "RoundSubmissionVisibility_submissionWindowId_fkey" FOREIGN KEY ("submissionWindowId") REFERENCES "SubmissionWindow"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
ALTER TABLE "RoundSubmissionVisibility" ADD CONSTRAINT "RoundSubmissionVisibility_roundId_fkey" FOREIGN KEY ("roundId") REFERENCES "Round"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||||
|
DO $$ BEGIN
|
||||||
|
ALTER TABLE "RoundSubmissionVisibility" ADD CONSTRAINT "RoundSubmissionVisibility_submissionWindowId_fkey" FOREIGN KEY ("submissionWindowId") REFERENCES "SubmissionWindow"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||||
|
|
||||||
-- AssignmentIntent
|
-- AssignmentIntent
|
||||||
ALTER TABLE "AssignmentIntent" ADD CONSTRAINT "AssignmentIntent_juryGroupMemberId_fkey" FOREIGN KEY ("juryGroupMemberId") REFERENCES "JuryGroupMember"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
DO $$ BEGIN
|
||||||
ALTER TABLE "AssignmentIntent" ADD CONSTRAINT "AssignmentIntent_roundId_fkey" FOREIGN KEY ("roundId") REFERENCES "Round"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
ALTER TABLE "AssignmentIntent" ADD CONSTRAINT "AssignmentIntent_juryGroupMemberId_fkey" FOREIGN KEY ("juryGroupMemberId") REFERENCES "JuryGroupMember"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
ALTER TABLE "AssignmentIntent" ADD CONSTRAINT "AssignmentIntent_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||||
|
DO $$ BEGIN
|
||||||
|
ALTER TABLE "AssignmentIntent" ADD CONSTRAINT "AssignmentIntent_roundId_fkey" FOREIGN KEY ("roundId") REFERENCES "Round"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||||
|
DO $$ BEGIN
|
||||||
|
ALTER TABLE "AssignmentIntent" ADD CONSTRAINT "AssignmentIntent_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||||
|
|
||||||
-- AssignmentException
|
-- AssignmentException
|
||||||
ALTER TABLE "AssignmentException" ADD CONSTRAINT "AssignmentException_assignmentId_fkey" FOREIGN KEY ("assignmentId") REFERENCES "Assignment"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
DO $$ BEGIN
|
||||||
ALTER TABLE "AssignmentException" ADD CONSTRAINT "AssignmentException_approvedById_fkey" FOREIGN KEY ("approvedById") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
ALTER TABLE "AssignmentException" ADD CONSTRAINT "AssignmentException_assignmentId_fkey" FOREIGN KEY ("assignmentId") REFERENCES "Assignment"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||||
|
DO $$ BEGIN
|
||||||
|
ALTER TABLE "AssignmentException" ADD CONSTRAINT "AssignmentException_approvedById_fkey" FOREIGN KEY ("approvedById") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||||
|
|
||||||
-- MentorFile
|
-- MentorFile
|
||||||
ALTER TABLE "MentorFile" ADD CONSTRAINT "MentorFile_mentorAssignmentId_fkey" FOREIGN KEY ("mentorAssignmentId") REFERENCES "MentorAssignment"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
DO $$ BEGIN
|
||||||
ALTER TABLE "MentorFile" ADD CONSTRAINT "MentorFile_uploadedByUserId_fkey" FOREIGN KEY ("uploadedByUserId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
ALTER TABLE "MentorFile" ADD CONSTRAINT "MentorFile_mentorAssignmentId_fkey" FOREIGN KEY ("mentorAssignmentId") REFERENCES "MentorAssignment"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
ALTER TABLE "MentorFile" ADD CONSTRAINT "MentorFile_promotedByUserId_fkey" FOREIGN KEY ("promotedByUserId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||||
ALTER TABLE "MentorFile" ADD CONSTRAINT "MentorFile_promotedToFileId_fkey" FOREIGN KEY ("promotedToFileId") REFERENCES "ProjectFile"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
DO $$ BEGIN
|
||||||
|
ALTER TABLE "MentorFile" ADD CONSTRAINT "MentorFile_uploadedByUserId_fkey" FOREIGN KEY ("uploadedByUserId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||||
|
DO $$ BEGIN
|
||||||
|
ALTER TABLE "MentorFile" ADD CONSTRAINT "MentorFile_promotedByUserId_fkey" FOREIGN KEY ("promotedByUserId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||||
|
DO $$ BEGIN
|
||||||
|
ALTER TABLE "MentorFile" ADD CONSTRAINT "MentorFile_promotedToFileId_fkey" FOREIGN KEY ("promotedToFileId") REFERENCES "ProjectFile"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||||
|
|
||||||
-- MentorFileComment
|
-- MentorFileComment
|
||||||
ALTER TABLE "MentorFileComment" ADD CONSTRAINT "MentorFileComment_mentorFileId_fkey" FOREIGN KEY ("mentorFileId") REFERENCES "MentorFile"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
DO $$ BEGIN
|
||||||
ALTER TABLE "MentorFileComment" ADD CONSTRAINT "MentorFileComment_authorId_fkey" FOREIGN KEY ("authorId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
ALTER TABLE "MentorFileComment" ADD CONSTRAINT "MentorFileComment_mentorFileId_fkey" FOREIGN KEY ("mentorFileId") REFERENCES "MentorFile"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
ALTER TABLE "MentorFileComment" ADD CONSTRAINT "MentorFileComment_parentCommentId_fkey" FOREIGN KEY ("parentCommentId") REFERENCES "MentorFileComment"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||||
|
DO $$ BEGIN
|
||||||
|
ALTER TABLE "MentorFileComment" ADD CONSTRAINT "MentorFileComment_authorId_fkey" FOREIGN KEY ("authorId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||||
|
DO $$ BEGIN
|
||||||
|
ALTER TABLE "MentorFileComment" ADD CONSTRAINT "MentorFileComment_parentCommentId_fkey" FOREIGN KEY ("parentCommentId") REFERENCES "MentorFileComment"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||||
|
|
||||||
-- SubmissionPromotionEvent
|
-- SubmissionPromotionEvent
|
||||||
ALTER TABLE "SubmissionPromotionEvent" ADD CONSTRAINT "SubmissionPromotionEvent_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
DO $$ BEGIN
|
||||||
ALTER TABLE "SubmissionPromotionEvent" ADD CONSTRAINT "SubmissionPromotionEvent_roundId_fkey" FOREIGN KEY ("roundId") REFERENCES "Round"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
ALTER TABLE "SubmissionPromotionEvent" ADD CONSTRAINT "SubmissionPromotionEvent_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
ALTER TABLE "SubmissionPromotionEvent" ADD CONSTRAINT "SubmissionPromotionEvent_sourceFileId_fkey" FOREIGN KEY ("sourceFileId") REFERENCES "MentorFile"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||||
ALTER TABLE "SubmissionPromotionEvent" ADD CONSTRAINT "SubmissionPromotionEvent_promotedById_fkey" FOREIGN KEY ("promotedById") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
DO $$ BEGIN
|
||||||
|
ALTER TABLE "SubmissionPromotionEvent" ADD CONSTRAINT "SubmissionPromotionEvent_roundId_fkey" FOREIGN KEY ("roundId") REFERENCES "Round"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||||
|
DO $$ BEGIN
|
||||||
|
ALTER TABLE "SubmissionPromotionEvent" ADD CONSTRAINT "SubmissionPromotionEvent_sourceFileId_fkey" FOREIGN KEY ("sourceFileId") REFERENCES "MentorFile"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||||
|
DO $$ BEGIN
|
||||||
|
ALTER TABLE "SubmissionPromotionEvent" ADD CONSTRAINT "SubmissionPromotionEvent_promotedById_fkey" FOREIGN KEY ("promotedById") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||||
|
|
||||||
-- DeliberationSession
|
-- DeliberationSession
|
||||||
ALTER TABLE "DeliberationSession" ADD CONSTRAINT "DeliberationSession_competitionId_fkey" FOREIGN KEY ("competitionId") REFERENCES "Competition"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
DO $$ BEGIN
|
||||||
ALTER TABLE "DeliberationSession" ADD CONSTRAINT "DeliberationSession_roundId_fkey" FOREIGN KEY ("roundId") REFERENCES "Round"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
ALTER TABLE "DeliberationSession" ADD CONSTRAINT "DeliberationSession_competitionId_fkey" FOREIGN KEY ("competitionId") REFERENCES "Competition"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||||
|
DO $$ BEGIN
|
||||||
|
ALTER TABLE "DeliberationSession" ADD CONSTRAINT "DeliberationSession_roundId_fkey" FOREIGN KEY ("roundId") REFERENCES "Round"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||||
|
|
||||||
-- DeliberationVote
|
-- DeliberationVote
|
||||||
ALTER TABLE "DeliberationVote" ADD CONSTRAINT "DeliberationVote_sessionId_fkey" FOREIGN KEY ("sessionId") REFERENCES "DeliberationSession"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
DO $$ BEGIN
|
||||||
ALTER TABLE "DeliberationVote" ADD CONSTRAINT "DeliberationVote_juryMemberId_fkey" FOREIGN KEY ("juryMemberId") REFERENCES "JuryGroupMember"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
ALTER TABLE "DeliberationVote" ADD CONSTRAINT "DeliberationVote_sessionId_fkey" FOREIGN KEY ("sessionId") REFERENCES "DeliberationSession"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
ALTER TABLE "DeliberationVote" ADD CONSTRAINT "DeliberationVote_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||||
|
DO $$ BEGIN
|
||||||
|
ALTER TABLE "DeliberationVote" ADD CONSTRAINT "DeliberationVote_juryMemberId_fkey" FOREIGN KEY ("juryMemberId") REFERENCES "JuryGroupMember"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||||
|
DO $$ BEGIN
|
||||||
|
ALTER TABLE "DeliberationVote" ADD CONSTRAINT "DeliberationVote_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||||
|
|
||||||
-- DeliberationResult
|
-- DeliberationResult
|
||||||
ALTER TABLE "DeliberationResult" ADD CONSTRAINT "DeliberationResult_sessionId_fkey" FOREIGN KEY ("sessionId") REFERENCES "DeliberationSession"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
DO $$ BEGIN
|
||||||
ALTER TABLE "DeliberationResult" ADD CONSTRAINT "DeliberationResult_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
ALTER TABLE "DeliberationResult" ADD CONSTRAINT "DeliberationResult_sessionId_fkey" FOREIGN KEY ("sessionId") REFERENCES "DeliberationSession"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||||
|
DO $$ BEGIN
|
||||||
|
ALTER TABLE "DeliberationResult" ADD CONSTRAINT "DeliberationResult_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||||
|
|
||||||
-- DeliberationParticipant
|
-- DeliberationParticipant
|
||||||
ALTER TABLE "DeliberationParticipant" ADD CONSTRAINT "DeliberationParticipant_sessionId_fkey" FOREIGN KEY ("sessionId") REFERENCES "DeliberationSession"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
DO $$ BEGIN
|
||||||
ALTER TABLE "DeliberationParticipant" ADD CONSTRAINT "DeliberationParticipant_userId_fkey" FOREIGN KEY ("userId") REFERENCES "JuryGroupMember"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
ALTER TABLE "DeliberationParticipant" ADD CONSTRAINT "DeliberationParticipant_sessionId_fkey" FOREIGN KEY ("sessionId") REFERENCES "DeliberationSession"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
ALTER TABLE "DeliberationParticipant" ADD CONSTRAINT "DeliberationParticipant_replacedById_fkey" FOREIGN KEY ("replacedById") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||||
|
DO $$ BEGIN
|
||||||
|
ALTER TABLE "DeliberationParticipant" ADD CONSTRAINT "DeliberationParticipant_userId_fkey" FOREIGN KEY ("userId") REFERENCES "JuryGroupMember"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||||
|
DO $$ BEGIN
|
||||||
|
ALTER TABLE "DeliberationParticipant" ADD CONSTRAINT "DeliberationParticipant_replacedById_fkey" FOREIGN KEY ("replacedById") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||||
|
|
||||||
-- ResultLock
|
-- ResultLock
|
||||||
ALTER TABLE "ResultLock" ADD CONSTRAINT "ResultLock_competitionId_fkey" FOREIGN KEY ("competitionId") REFERENCES "Competition"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
DO $$ BEGIN
|
||||||
ALTER TABLE "ResultLock" ADD CONSTRAINT "ResultLock_roundId_fkey" FOREIGN KEY ("roundId") REFERENCES "Round"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
ALTER TABLE "ResultLock" ADD CONSTRAINT "ResultLock_competitionId_fkey" FOREIGN KEY ("competitionId") REFERENCES "Competition"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
ALTER TABLE "ResultLock" ADD CONSTRAINT "ResultLock_lockedById_fkey" FOREIGN KEY ("lockedById") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||||
|
DO $$ BEGIN
|
||||||
|
ALTER TABLE "ResultLock" ADD CONSTRAINT "ResultLock_roundId_fkey" FOREIGN KEY ("roundId") REFERENCES "Round"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||||
|
DO $$ BEGIN
|
||||||
|
ALTER TABLE "ResultLock" ADD CONSTRAINT "ResultLock_lockedById_fkey" FOREIGN KEY ("lockedById") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||||
|
|
||||||
-- ResultUnlockEvent
|
-- ResultUnlockEvent
|
||||||
ALTER TABLE "ResultUnlockEvent" ADD CONSTRAINT "ResultUnlockEvent_resultLockId_fkey" FOREIGN KEY ("resultLockId") REFERENCES "ResultLock"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
DO $$ BEGIN
|
||||||
ALTER TABLE "ResultUnlockEvent" ADD CONSTRAINT "ResultUnlockEvent_unlockedById_fkey" FOREIGN KEY ("unlockedById") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
ALTER TABLE "ResultUnlockEvent" ADD CONSTRAINT "ResultUnlockEvent_resultLockId_fkey" FOREIGN KEY ("resultLockId") REFERENCES "ResultLock"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||||
|
DO $$ BEGIN
|
||||||
|
ALTER TABLE "ResultUnlockEvent" ADD CONSTRAINT "ResultUnlockEvent_unlockedById_fkey" FOREIGN KEY ("unlockedById") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||||
|
|
||||||
-- FKs on modified existing tables
|
-- FKs on modified existing tables
|
||||||
ALTER TABLE "Assignment" ADD CONSTRAINT "Assignment_juryGroupId_fkey" FOREIGN KEY ("juryGroupId") REFERENCES "JuryGroup"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
DO $$ BEGIN
|
||||||
ALTER TABLE "SpecialAward" ADD CONSTRAINT "SpecialAward_competitionId_fkey" FOREIGN KEY ("competitionId") REFERENCES "Competition"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
ALTER TABLE "Assignment" ADD CONSTRAINT "Assignment_juryGroupId_fkey" FOREIGN KEY ("juryGroupId") REFERENCES "JuryGroup"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
ALTER TABLE "SpecialAward" ADD CONSTRAINT "SpecialAward_evaluationRoundId_fkey" FOREIGN KEY ("evaluationRoundId") REFERENCES "Round"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||||
ALTER TABLE "SpecialAward" ADD CONSTRAINT "SpecialAward_juryGroupId_fkey" FOREIGN KEY ("juryGroupId") REFERENCES "JuryGroup"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
DO $$ BEGIN
|
||||||
ALTER TABLE "MentorMessage" ADD CONSTRAINT "MentorMessage_workspaceId_fkey" FOREIGN KEY ("workspaceId") REFERENCES "MentorAssignment"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
ALTER TABLE "SpecialAward" ADD CONSTRAINT "SpecialAward_competitionId_fkey" FOREIGN KEY ("competitionId") REFERENCES "Competition"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
ALTER TABLE "ProjectFile" ADD CONSTRAINT "ProjectFile_submissionWindowId_fkey" FOREIGN KEY ("submissionWindowId") REFERENCES "SubmissionWindow"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||||
ALTER TABLE "ProjectFile" ADD CONSTRAINT "ProjectFile_submissionFileRequirementId_fkey" FOREIGN KEY ("submissionFileRequirementId") REFERENCES "SubmissionFileRequirement"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
DO $$ BEGIN
|
||||||
|
ALTER TABLE "SpecialAward" ADD CONSTRAINT "SpecialAward_evaluationRoundId_fkey" FOREIGN KEY ("evaluationRoundId") REFERENCES "Round"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||||
|
DO $$ BEGIN
|
||||||
|
ALTER TABLE "SpecialAward" ADD CONSTRAINT "SpecialAward_juryGroupId_fkey" FOREIGN KEY ("juryGroupId") REFERENCES "JuryGroup"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||||
|
DO $$ BEGIN
|
||||||
|
ALTER TABLE "MentorMessage" ADD CONSTRAINT "MentorMessage_workspaceId_fkey" FOREIGN KEY ("workspaceId") REFERENCES "MentorAssignment"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||||
|
DO $$ BEGIN
|
||||||
|
ALTER TABLE "ProjectFile" ADD CONSTRAINT "ProjectFile_submissionWindowId_fkey" FOREIGN KEY ("submissionWindowId") REFERENCES "SubmissionWindow"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||||
|
DO $$ BEGIN
|
||||||
|
ALTER TABLE "ProjectFile" ADD CONSTRAINT "ProjectFile_submissionFileRequirementId_fkey" FOREIGN KEY ("submissionFileRequirementId") REFERENCES "SubmissionFileRequirement"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||||
|
|||||||
@@ -1,3 +1,7 @@
|
|||||||
-- AlterTable
|
-- AlterTable
|
||||||
ALTER TABLE "JuryGroupMember" ADD COLUMN "selfServiceCap" INTEGER,
|
DO $$ BEGIN
|
||||||
ADD COLUMN "selfServiceRatio" DOUBLE PRECISION;
|
ALTER TABLE "JuryGroupMember" ADD COLUMN "selfServiceCap" INTEGER;
|
||||||
|
EXCEPTION WHEN duplicate_column THEN NULL; END $$;
|
||||||
|
DO $$ BEGIN
|
||||||
|
ALTER TABLE "JuryGroupMember" ADD COLUMN "selfServiceRatio" DOUBLE PRECISION;
|
||||||
|
EXCEPTION WHEN duplicate_column THEN NULL; END $$;
|
||||||
|
|||||||
@@ -1,14 +1,14 @@
|
|||||||
-- =============================================================================
|
-- =============================================================================
|
||||||
-- Phase 7/8 Migration Part 1: Rename stageId → roundId on 15 tables
|
-- Phase 7/8 Migration Part 1: Rename stageId -> roundId on 15 tables
|
||||||
-- =============================================================================
|
-- =============================================================================
|
||||||
-- This migration renames stageId columns to roundId and updates FK constraints
|
-- This migration renames stageId columns to roundId and updates FK constraints
|
||||||
-- to point to the Round table instead of Stage table.
|
-- to point to the Round table instead of Stage table.
|
||||||
--
|
--
|
||||||
-- NOTE: After the pipeline migration (20260213), most tables have BOTH a
|
-- NOTE: After the pipeline migration (20260213), most tables have BOTH a
|
||||||
-- nullable roundId column (legacy, no FK) AND a stageId column. We must
|
-- nullable roundId column (legacy, no FK) AND a stageId column. We must
|
||||||
-- drop the old roundId column before renaming stageId → roundId.
|
-- drop the old roundId column before renaming stageId -> roundId.
|
||||||
|
|
||||||
-- ─── 1. EvaluationForm ───────────────────────────────────────────────────────
|
-- --- 1. EvaluationForm ---
|
||||||
|
|
||||||
-- Drop old roundId column (nullable, no FK since 20260213 migration)
|
-- Drop old roundId column (nullable, no FK since 20260213 migration)
|
||||||
ALTER TABLE "EvaluationForm" DROP COLUMN IF EXISTS "roundId";
|
ALTER TABLE "EvaluationForm" DROP COLUMN IF EXISTS "roundId";
|
||||||
@@ -20,18 +20,22 @@ ALTER TABLE "EvaluationForm" DROP CONSTRAINT IF EXISTS "EvaluationForm_stageId_f
|
|||||||
DROP INDEX IF EXISTS "EvaluationForm_stageId_version_key";
|
DROP INDEX IF EXISTS "EvaluationForm_stageId_version_key";
|
||||||
DROP INDEX IF EXISTS "EvaluationForm_stageId_isActive_idx";
|
DROP INDEX IF EXISTS "EvaluationForm_stageId_isActive_idx";
|
||||||
|
|
||||||
-- Rename column
|
-- Rename column (only if stageId exists)
|
||||||
ALTER TABLE "EvaluationForm" RENAME COLUMN "stageId" TO "roundId";
|
DO $$ BEGIN
|
||||||
|
ALTER TABLE "EvaluationForm" RENAME COLUMN "stageId" TO "roundId";
|
||||||
|
EXCEPTION WHEN undefined_column THEN NULL; END $$;
|
||||||
|
|
||||||
-- Recreate indexes with new name
|
-- Recreate indexes with new name
|
||||||
CREATE UNIQUE INDEX "EvaluationForm_roundId_version_key" ON "EvaluationForm"("roundId", "version");
|
CREATE UNIQUE INDEX IF NOT EXISTS "EvaluationForm_roundId_version_key" ON "EvaluationForm"("roundId", "version");
|
||||||
CREATE INDEX "EvaluationForm_roundId_isActive_idx" ON "EvaluationForm"("roundId", "isActive");
|
CREATE INDEX IF NOT EXISTS "EvaluationForm_roundId_isActive_idx" ON "EvaluationForm"("roundId", "isActive");
|
||||||
|
|
||||||
-- Recreate FK pointing to Round
|
-- Recreate FK pointing to Round
|
||||||
ALTER TABLE "EvaluationForm" ADD CONSTRAINT "EvaluationForm_roundId_fkey"
|
DO $$ BEGIN
|
||||||
|
ALTER TABLE "EvaluationForm" ADD CONSTRAINT "EvaluationForm_roundId_fkey"
|
||||||
FOREIGN KEY ("roundId") REFERENCES "Round"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
FOREIGN KEY ("roundId") REFERENCES "Round"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||||
|
|
||||||
-- ─── 2. FileRequirement ──────────────────────────────────────────────────────
|
-- --- 2. FileRequirement ---
|
||||||
|
|
||||||
ALTER TABLE "FileRequirement" DROP COLUMN IF EXISTS "roundId";
|
ALTER TABLE "FileRequirement" DROP COLUMN IF EXISTS "roundId";
|
||||||
|
|
||||||
@@ -39,14 +43,18 @@ ALTER TABLE "FileRequirement" DROP CONSTRAINT IF EXISTS "FileRequirement_stageId
|
|||||||
|
|
||||||
DROP INDEX IF EXISTS "FileRequirement_stageId_idx";
|
DROP INDEX IF EXISTS "FileRequirement_stageId_idx";
|
||||||
|
|
||||||
ALTER TABLE "FileRequirement" RENAME COLUMN "stageId" TO "roundId";
|
DO $$ BEGIN
|
||||||
|
ALTER TABLE "FileRequirement" RENAME COLUMN "stageId" TO "roundId";
|
||||||
|
EXCEPTION WHEN undefined_column THEN NULL; END $$;
|
||||||
|
|
||||||
CREATE INDEX "FileRequirement_roundId_idx" ON "FileRequirement"("roundId");
|
CREATE INDEX IF NOT EXISTS "FileRequirement_roundId_idx" ON "FileRequirement"("roundId");
|
||||||
|
|
||||||
ALTER TABLE "FileRequirement" ADD CONSTRAINT "FileRequirement_roundId_fkey"
|
DO $$ BEGIN
|
||||||
|
ALTER TABLE "FileRequirement" ADD CONSTRAINT "FileRequirement_roundId_fkey"
|
||||||
FOREIGN KEY ("roundId") REFERENCES "Round"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
FOREIGN KEY ("roundId") REFERENCES "Round"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||||
|
|
||||||
-- ─── 3. Assignment ───────────────────────────────────────────────────────────
|
-- --- 3. Assignment ---
|
||||||
|
|
||||||
ALTER TABLE "Assignment" DROP COLUMN IF EXISTS "roundId";
|
ALTER TABLE "Assignment" DROP COLUMN IF EXISTS "roundId";
|
||||||
|
|
||||||
@@ -55,15 +63,19 @@ ALTER TABLE "Assignment" DROP CONSTRAINT IF EXISTS "Assignment_stageId_fkey";
|
|||||||
DROP INDEX IF EXISTS "Assignment_userId_projectId_stageId_key";
|
DROP INDEX IF EXISTS "Assignment_userId_projectId_stageId_key";
|
||||||
DROP INDEX IF EXISTS "Assignment_stageId_idx";
|
DROP INDEX IF EXISTS "Assignment_stageId_idx";
|
||||||
|
|
||||||
ALTER TABLE "Assignment" RENAME COLUMN "stageId" TO "roundId";
|
DO $$ BEGIN
|
||||||
|
ALTER TABLE "Assignment" RENAME COLUMN "stageId" TO "roundId";
|
||||||
|
EXCEPTION WHEN undefined_column THEN NULL; END $$;
|
||||||
|
|
||||||
CREATE UNIQUE INDEX "Assignment_userId_projectId_roundId_key" ON "Assignment"("userId", "projectId", "roundId");
|
CREATE UNIQUE INDEX IF NOT EXISTS "Assignment_userId_projectId_roundId_key" ON "Assignment"("userId", "projectId", "roundId");
|
||||||
CREATE INDEX "Assignment_roundId_idx" ON "Assignment"("roundId");
|
CREATE INDEX IF NOT EXISTS "Assignment_roundId_idx" ON "Assignment"("roundId");
|
||||||
|
|
||||||
ALTER TABLE "Assignment" ADD CONSTRAINT "Assignment_roundId_fkey"
|
DO $$ BEGIN
|
||||||
|
ALTER TABLE "Assignment" ADD CONSTRAINT "Assignment_roundId_fkey"
|
||||||
FOREIGN KEY ("roundId") REFERENCES "Round"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
FOREIGN KEY ("roundId") REFERENCES "Round"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||||
|
|
||||||
-- ─── 4. GracePeriod ──────────────────────────────────────────────────────────
|
-- --- 4. GracePeriod ---
|
||||||
|
|
||||||
ALTER TABLE "GracePeriod" DROP COLUMN IF EXISTS "roundId";
|
ALTER TABLE "GracePeriod" DROP COLUMN IF EXISTS "roundId";
|
||||||
|
|
||||||
@@ -72,15 +84,19 @@ ALTER TABLE "GracePeriod" DROP CONSTRAINT IF EXISTS "GracePeriod_stageId_fkey";
|
|||||||
DROP INDEX IF EXISTS "GracePeriod_stageId_idx";
|
DROP INDEX IF EXISTS "GracePeriod_stageId_idx";
|
||||||
DROP INDEX IF EXISTS "GracePeriod_stageId_userId_extendedUntil_idx";
|
DROP INDEX IF EXISTS "GracePeriod_stageId_userId_extendedUntil_idx";
|
||||||
|
|
||||||
ALTER TABLE "GracePeriod" RENAME COLUMN "stageId" TO "roundId";
|
DO $$ BEGIN
|
||||||
|
ALTER TABLE "GracePeriod" RENAME COLUMN "stageId" TO "roundId";
|
||||||
|
EXCEPTION WHEN undefined_column THEN NULL; END $$;
|
||||||
|
|
||||||
CREATE INDEX "GracePeriod_roundId_idx" ON "GracePeriod"("roundId");
|
CREATE INDEX IF NOT EXISTS "GracePeriod_roundId_idx" ON "GracePeriod"("roundId");
|
||||||
CREATE INDEX "GracePeriod_roundId_userId_extendedUntil_idx" ON "GracePeriod"("roundId", "userId", "extendedUntil");
|
CREATE INDEX IF NOT EXISTS "GracePeriod_roundId_userId_extendedUntil_idx" ON "GracePeriod"("roundId", "userId", "extendedUntil");
|
||||||
|
|
||||||
ALTER TABLE "GracePeriod" ADD CONSTRAINT "GracePeriod_roundId_fkey"
|
DO $$ BEGIN
|
||||||
|
ALTER TABLE "GracePeriod" ADD CONSTRAINT "GracePeriod_roundId_fkey"
|
||||||
FOREIGN KEY ("roundId") REFERENCES "Round"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
FOREIGN KEY ("roundId") REFERENCES "Round"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||||
|
|
||||||
-- ─── 5. LiveVotingSession ────────────────────────────────────────────────────
|
-- --- 5. LiveVotingSession ---
|
||||||
|
|
||||||
ALTER TABLE "LiveVotingSession" DROP COLUMN IF EXISTS "roundId";
|
ALTER TABLE "LiveVotingSession" DROP COLUMN IF EXISTS "roundId";
|
||||||
|
|
||||||
@@ -88,14 +104,18 @@ ALTER TABLE "LiveVotingSession" DROP CONSTRAINT IF EXISTS "LiveVotingSession_sta
|
|||||||
|
|
||||||
DROP INDEX IF EXISTS "LiveVotingSession_stageId_key";
|
DROP INDEX IF EXISTS "LiveVotingSession_stageId_key";
|
||||||
|
|
||||||
ALTER TABLE "LiveVotingSession" RENAME COLUMN "stageId" TO "roundId";
|
DO $$ BEGIN
|
||||||
|
ALTER TABLE "LiveVotingSession" RENAME COLUMN "stageId" TO "roundId";
|
||||||
|
EXCEPTION WHEN undefined_column THEN NULL; END $$;
|
||||||
|
|
||||||
CREATE UNIQUE INDEX "LiveVotingSession_roundId_key" ON "LiveVotingSession"("roundId");
|
CREATE UNIQUE INDEX IF NOT EXISTS "LiveVotingSession_roundId_key" ON "LiveVotingSession"("roundId");
|
||||||
|
|
||||||
ALTER TABLE "LiveVotingSession" ADD CONSTRAINT "LiveVotingSession_roundId_fkey"
|
DO $$ BEGIN
|
||||||
|
ALTER TABLE "LiveVotingSession" ADD CONSTRAINT "LiveVotingSession_roundId_fkey"
|
||||||
FOREIGN KEY ("roundId") REFERENCES "Round"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
FOREIGN KEY ("roundId") REFERENCES "Round"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||||
|
|
||||||
-- ─── 6. FilteringRule ────────────────────────────────────────────────────────
|
-- --- 6. FilteringRule ---
|
||||||
|
|
||||||
ALTER TABLE "FilteringRule" DROP COLUMN IF EXISTS "roundId";
|
ALTER TABLE "FilteringRule" DROP COLUMN IF EXISTS "roundId";
|
||||||
|
|
||||||
@@ -103,14 +123,18 @@ ALTER TABLE "FilteringRule" DROP CONSTRAINT IF EXISTS "FilteringRule_stageId_fke
|
|||||||
|
|
||||||
DROP INDEX IF EXISTS "FilteringRule_stageId_idx";
|
DROP INDEX IF EXISTS "FilteringRule_stageId_idx";
|
||||||
|
|
||||||
ALTER TABLE "FilteringRule" RENAME COLUMN "stageId" TO "roundId";
|
DO $$ BEGIN
|
||||||
|
ALTER TABLE "FilteringRule" RENAME COLUMN "stageId" TO "roundId";
|
||||||
|
EXCEPTION WHEN undefined_column THEN NULL; END $$;
|
||||||
|
|
||||||
CREATE INDEX "FilteringRule_roundId_idx" ON "FilteringRule"("roundId");
|
CREATE INDEX IF NOT EXISTS "FilteringRule_roundId_idx" ON "FilteringRule"("roundId");
|
||||||
|
|
||||||
ALTER TABLE "FilteringRule" ADD CONSTRAINT "FilteringRule_roundId_fkey"
|
DO $$ BEGIN
|
||||||
|
ALTER TABLE "FilteringRule" ADD CONSTRAINT "FilteringRule_roundId_fkey"
|
||||||
FOREIGN KEY ("roundId") REFERENCES "Round"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
FOREIGN KEY ("roundId") REFERENCES "Round"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||||
|
|
||||||
-- ─── 7. FilteringResult ──────────────────────────────────────────────────────
|
-- --- 7. FilteringResult ---
|
||||||
|
|
||||||
ALTER TABLE "FilteringResult" DROP COLUMN IF EXISTS "roundId";
|
ALTER TABLE "FilteringResult" DROP COLUMN IF EXISTS "roundId";
|
||||||
|
|
||||||
@@ -119,15 +143,19 @@ ALTER TABLE "FilteringResult" DROP CONSTRAINT IF EXISTS "FilteringResult_stageId
|
|||||||
DROP INDEX IF EXISTS "FilteringResult_stageId_projectId_key";
|
DROP INDEX IF EXISTS "FilteringResult_stageId_projectId_key";
|
||||||
DROP INDEX IF EXISTS "FilteringResult_stageId_idx";
|
DROP INDEX IF EXISTS "FilteringResult_stageId_idx";
|
||||||
|
|
||||||
ALTER TABLE "FilteringResult" RENAME COLUMN "stageId" TO "roundId";
|
DO $$ BEGIN
|
||||||
|
ALTER TABLE "FilteringResult" RENAME COLUMN "stageId" TO "roundId";
|
||||||
|
EXCEPTION WHEN undefined_column THEN NULL; END $$;
|
||||||
|
|
||||||
CREATE UNIQUE INDEX "FilteringResult_roundId_projectId_key" ON "FilteringResult"("roundId", "projectId");
|
CREATE UNIQUE INDEX IF NOT EXISTS "FilteringResult_roundId_projectId_key" ON "FilteringResult"("roundId", "projectId");
|
||||||
CREATE INDEX "FilteringResult_roundId_idx" ON "FilteringResult"("roundId");
|
CREATE INDEX IF NOT EXISTS "FilteringResult_roundId_idx" ON "FilteringResult"("roundId");
|
||||||
|
|
||||||
ALTER TABLE "FilteringResult" ADD CONSTRAINT "FilteringResult_roundId_fkey"
|
DO $$ BEGIN
|
||||||
|
ALTER TABLE "FilteringResult" ADD CONSTRAINT "FilteringResult_roundId_fkey"
|
||||||
FOREIGN KEY ("roundId") REFERENCES "Round"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
FOREIGN KEY ("roundId") REFERENCES "Round"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||||
|
|
||||||
-- ─── 8. FilteringJob ─────────────────────────────────────────────────────────
|
-- --- 8. FilteringJob ---
|
||||||
|
|
||||||
ALTER TABLE "FilteringJob" DROP COLUMN IF EXISTS "roundId";
|
ALTER TABLE "FilteringJob" DROP COLUMN IF EXISTS "roundId";
|
||||||
|
|
||||||
@@ -135,14 +163,18 @@ ALTER TABLE "FilteringJob" DROP CONSTRAINT IF EXISTS "FilteringJob_stageId_fkey"
|
|||||||
|
|
||||||
DROP INDEX IF EXISTS "FilteringJob_stageId_idx";
|
DROP INDEX IF EXISTS "FilteringJob_stageId_idx";
|
||||||
|
|
||||||
ALTER TABLE "FilteringJob" RENAME COLUMN "stageId" TO "roundId";
|
DO $$ BEGIN
|
||||||
|
ALTER TABLE "FilteringJob" RENAME COLUMN "stageId" TO "roundId";
|
||||||
|
EXCEPTION WHEN undefined_column THEN NULL; END $$;
|
||||||
|
|
||||||
CREATE INDEX "FilteringJob_roundId_idx" ON "FilteringJob"("roundId");
|
CREATE INDEX IF NOT EXISTS "FilteringJob_roundId_idx" ON "FilteringJob"("roundId");
|
||||||
|
|
||||||
ALTER TABLE "FilteringJob" ADD CONSTRAINT "FilteringJob_roundId_fkey"
|
DO $$ BEGIN
|
||||||
|
ALTER TABLE "FilteringJob" ADD CONSTRAINT "FilteringJob_roundId_fkey"
|
||||||
FOREIGN KEY ("roundId") REFERENCES "Round"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
FOREIGN KEY ("roundId") REFERENCES "Round"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||||
|
|
||||||
-- ─── 9. AssignmentJob ────────────────────────────────────────────────────────
|
-- --- 9. AssignmentJob ---
|
||||||
|
|
||||||
ALTER TABLE "AssignmentJob" DROP COLUMN IF EXISTS "roundId";
|
ALTER TABLE "AssignmentJob" DROP COLUMN IF EXISTS "roundId";
|
||||||
|
|
||||||
@@ -150,14 +182,18 @@ ALTER TABLE "AssignmentJob" DROP CONSTRAINT IF EXISTS "AssignmentJob_stageId_fke
|
|||||||
|
|
||||||
DROP INDEX IF EXISTS "AssignmentJob_stageId_idx";
|
DROP INDEX IF EXISTS "AssignmentJob_stageId_idx";
|
||||||
|
|
||||||
ALTER TABLE "AssignmentJob" RENAME COLUMN "stageId" TO "roundId";
|
DO $$ BEGIN
|
||||||
|
ALTER TABLE "AssignmentJob" RENAME COLUMN "stageId" TO "roundId";
|
||||||
|
EXCEPTION WHEN undefined_column THEN NULL; END $$;
|
||||||
|
|
||||||
CREATE INDEX "AssignmentJob_roundId_idx" ON "AssignmentJob"("roundId");
|
CREATE INDEX IF NOT EXISTS "AssignmentJob_roundId_idx" ON "AssignmentJob"("roundId");
|
||||||
|
|
||||||
ALTER TABLE "AssignmentJob" ADD CONSTRAINT "AssignmentJob_roundId_fkey"
|
DO $$ BEGIN
|
||||||
|
ALTER TABLE "AssignmentJob" ADD CONSTRAINT "AssignmentJob_roundId_fkey"
|
||||||
FOREIGN KEY ("roundId") REFERENCES "Round"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
FOREIGN KEY ("roundId") REFERENCES "Round"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||||
|
|
||||||
-- ─── 10. ReminderLog ─────────────────────────────────────────────────────────
|
-- --- 10. ReminderLog ---
|
||||||
|
|
||||||
ALTER TABLE "ReminderLog" DROP COLUMN IF EXISTS "roundId";
|
ALTER TABLE "ReminderLog" DROP COLUMN IF EXISTS "roundId";
|
||||||
|
|
||||||
@@ -166,15 +202,19 @@ ALTER TABLE "ReminderLog" DROP CONSTRAINT IF EXISTS "ReminderLog_stageId_fkey";
|
|||||||
DROP INDEX IF EXISTS "ReminderLog_stageId_userId_type_key";
|
DROP INDEX IF EXISTS "ReminderLog_stageId_userId_type_key";
|
||||||
DROP INDEX IF EXISTS "ReminderLog_stageId_idx";
|
DROP INDEX IF EXISTS "ReminderLog_stageId_idx";
|
||||||
|
|
||||||
ALTER TABLE "ReminderLog" RENAME COLUMN "stageId" TO "roundId";
|
DO $$ BEGIN
|
||||||
|
ALTER TABLE "ReminderLog" RENAME COLUMN "stageId" TO "roundId";
|
||||||
|
EXCEPTION WHEN undefined_column THEN NULL; END $$;
|
||||||
|
|
||||||
CREATE UNIQUE INDEX "ReminderLog_roundId_userId_type_key" ON "ReminderLog"("roundId", "userId", "type");
|
CREATE UNIQUE INDEX IF NOT EXISTS "ReminderLog_roundId_userId_type_key" ON "ReminderLog"("roundId", "userId", "type");
|
||||||
CREATE INDEX "ReminderLog_roundId_idx" ON "ReminderLog"("roundId");
|
CREATE INDEX IF NOT EXISTS "ReminderLog_roundId_idx" ON "ReminderLog"("roundId");
|
||||||
|
|
||||||
ALTER TABLE "ReminderLog" ADD CONSTRAINT "ReminderLog_roundId_fkey"
|
DO $$ BEGIN
|
||||||
|
ALTER TABLE "ReminderLog" ADD CONSTRAINT "ReminderLog_roundId_fkey"
|
||||||
FOREIGN KEY ("roundId") REFERENCES "Round"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
FOREIGN KEY ("roundId") REFERENCES "Round"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||||
|
|
||||||
-- ─── 11. EvaluationSummary ───────────────────────────────────────────────────
|
-- --- 11. EvaluationSummary ---
|
||||||
|
|
||||||
ALTER TABLE "EvaluationSummary" DROP COLUMN IF EXISTS "roundId";
|
ALTER TABLE "EvaluationSummary" DROP COLUMN IF EXISTS "roundId";
|
||||||
|
|
||||||
@@ -183,15 +223,19 @@ ALTER TABLE "EvaluationSummary" DROP CONSTRAINT IF EXISTS "EvaluationSummary_sta
|
|||||||
DROP INDEX IF EXISTS "EvaluationSummary_projectId_stageId_key";
|
DROP INDEX IF EXISTS "EvaluationSummary_projectId_stageId_key";
|
||||||
DROP INDEX IF EXISTS "EvaluationSummary_stageId_idx";
|
DROP INDEX IF EXISTS "EvaluationSummary_stageId_idx";
|
||||||
|
|
||||||
ALTER TABLE "EvaluationSummary" RENAME COLUMN "stageId" TO "roundId";
|
DO $$ BEGIN
|
||||||
|
ALTER TABLE "EvaluationSummary" RENAME COLUMN "stageId" TO "roundId";
|
||||||
|
EXCEPTION WHEN undefined_column THEN NULL; END $$;
|
||||||
|
|
||||||
CREATE UNIQUE INDEX "EvaluationSummary_projectId_roundId_key" ON "EvaluationSummary"("projectId", "roundId");
|
CREATE UNIQUE INDEX IF NOT EXISTS "EvaluationSummary_projectId_roundId_key" ON "EvaluationSummary"("projectId", "roundId");
|
||||||
CREATE INDEX "EvaluationSummary_roundId_idx" ON "EvaluationSummary"("roundId");
|
CREATE INDEX IF NOT EXISTS "EvaluationSummary_roundId_idx" ON "EvaluationSummary"("roundId");
|
||||||
|
|
||||||
ALTER TABLE "EvaluationSummary" ADD CONSTRAINT "EvaluationSummary_roundId_fkey"
|
DO $$ BEGIN
|
||||||
|
ALTER TABLE "EvaluationSummary" ADD CONSTRAINT "EvaluationSummary_roundId_fkey"
|
||||||
FOREIGN KEY ("roundId") REFERENCES "Round"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
FOREIGN KEY ("roundId") REFERENCES "Round"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||||
|
|
||||||
-- ─── 12. EvaluationDiscussion ────────────────────────────────────────────────
|
-- --- 12. EvaluationDiscussion ---
|
||||||
|
|
||||||
ALTER TABLE "EvaluationDiscussion" DROP COLUMN IF EXISTS "roundId";
|
ALTER TABLE "EvaluationDiscussion" DROP COLUMN IF EXISTS "roundId";
|
||||||
|
|
||||||
@@ -200,15 +244,19 @@ ALTER TABLE "EvaluationDiscussion" DROP CONSTRAINT IF EXISTS "EvaluationDiscussi
|
|||||||
DROP INDEX IF EXISTS "EvaluationDiscussion_projectId_stageId_key";
|
DROP INDEX IF EXISTS "EvaluationDiscussion_projectId_stageId_key";
|
||||||
DROP INDEX IF EXISTS "EvaluationDiscussion_stageId_idx";
|
DROP INDEX IF EXISTS "EvaluationDiscussion_stageId_idx";
|
||||||
|
|
||||||
ALTER TABLE "EvaluationDiscussion" RENAME COLUMN "stageId" TO "roundId";
|
DO $$ BEGIN
|
||||||
|
ALTER TABLE "EvaluationDiscussion" RENAME COLUMN "stageId" TO "roundId";
|
||||||
|
EXCEPTION WHEN undefined_column THEN NULL; END $$;
|
||||||
|
|
||||||
CREATE UNIQUE INDEX "EvaluationDiscussion_projectId_roundId_key" ON "EvaluationDiscussion"("projectId", "roundId");
|
CREATE UNIQUE INDEX IF NOT EXISTS "EvaluationDiscussion_projectId_roundId_key" ON "EvaluationDiscussion"("projectId", "roundId");
|
||||||
CREATE INDEX "EvaluationDiscussion_roundId_idx" ON "EvaluationDiscussion"("roundId");
|
CREATE INDEX IF NOT EXISTS "EvaluationDiscussion_roundId_idx" ON "EvaluationDiscussion"("roundId");
|
||||||
|
|
||||||
ALTER TABLE "EvaluationDiscussion" ADD CONSTRAINT "EvaluationDiscussion_roundId_fkey"
|
DO $$ BEGIN
|
||||||
|
ALTER TABLE "EvaluationDiscussion" ADD CONSTRAINT "EvaluationDiscussion_roundId_fkey"
|
||||||
FOREIGN KEY ("roundId") REFERENCES "Round"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
FOREIGN KEY ("roundId") REFERENCES "Round"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||||
|
|
||||||
-- ─── 13. Message ─────────────────────────────────────────────────────────────
|
-- --- 13. Message ---
|
||||||
|
|
||||||
-- Message has roundId (from init, nullable) and stageId (from pipeline, nullable)
|
-- Message has roundId (from init, nullable) and stageId (from pipeline, nullable)
|
||||||
ALTER TABLE "Message" DROP COLUMN IF EXISTS "roundId";
|
ALTER TABLE "Message" DROP COLUMN IF EXISTS "roundId";
|
||||||
@@ -217,42 +265,54 @@ ALTER TABLE "Message" DROP CONSTRAINT IF EXISTS "Message_stageId_fkey";
|
|||||||
|
|
||||||
DROP INDEX IF EXISTS "Message_stageId_idx";
|
DROP INDEX IF EXISTS "Message_stageId_idx";
|
||||||
|
|
||||||
ALTER TABLE "Message" RENAME COLUMN "stageId" TO "roundId";
|
DO $$ BEGIN
|
||||||
|
ALTER TABLE "Message" RENAME COLUMN "stageId" TO "roundId";
|
||||||
|
EXCEPTION WHEN undefined_column THEN NULL; END $$;
|
||||||
|
|
||||||
CREATE INDEX "Message_roundId_idx" ON "Message"("roundId");
|
CREATE INDEX IF NOT EXISTS "Message_roundId_idx" ON "Message"("roundId");
|
||||||
|
|
||||||
ALTER TABLE "Message" ADD CONSTRAINT "Message_roundId_fkey"
|
DO $$ BEGIN
|
||||||
|
ALTER TABLE "Message" ADD CONSTRAINT "Message_roundId_fkey"
|
||||||
FOREIGN KEY ("roundId") REFERENCES "Round"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
FOREIGN KEY ("roundId") REFERENCES "Round"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||||
|
|
||||||
-- ─── 14. Cohort ──────────────────────────────────────────────────────────────
|
-- --- 14. Cohort ---
|
||||||
-- Cohort was created in pipeline migration with stageId only (no roundId)
|
-- Cohort was created in pipeline migration with stageId only (no roundId)
|
||||||
|
|
||||||
ALTER TABLE "Cohort" DROP CONSTRAINT IF EXISTS "Cohort_stageId_fkey";
|
ALTER TABLE "Cohort" DROP CONSTRAINT IF EXISTS "Cohort_stageId_fkey";
|
||||||
|
|
||||||
DROP INDEX IF EXISTS "Cohort_stageId_idx";
|
DROP INDEX IF EXISTS "Cohort_stageId_idx";
|
||||||
|
|
||||||
ALTER TABLE "Cohort" RENAME COLUMN "stageId" TO "roundId";
|
DO $$ BEGIN
|
||||||
|
ALTER TABLE "Cohort" RENAME COLUMN "stageId" TO "roundId";
|
||||||
|
EXCEPTION WHEN undefined_column THEN NULL; END $$;
|
||||||
|
|
||||||
CREATE INDEX "Cohort_roundId_idx" ON "Cohort"("roundId");
|
CREATE INDEX IF NOT EXISTS "Cohort_roundId_idx" ON "Cohort"("roundId");
|
||||||
|
|
||||||
ALTER TABLE "Cohort" ADD CONSTRAINT "Cohort_roundId_fkey"
|
DO $$ BEGIN
|
||||||
|
ALTER TABLE "Cohort" ADD CONSTRAINT "Cohort_roundId_fkey"
|
||||||
FOREIGN KEY ("roundId") REFERENCES "Round"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
FOREIGN KEY ("roundId") REFERENCES "Round"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||||
|
|
||||||
-- ─── 15. LiveProgressCursor ──────────────────────────────────────────────────
|
-- --- 15. LiveProgressCursor ---
|
||||||
-- LiveProgressCursor was created in pipeline migration with stageId only (no roundId)
|
-- LiveProgressCursor was created in pipeline migration with stageId only (no roundId)
|
||||||
|
|
||||||
ALTER TABLE "LiveProgressCursor" DROP CONSTRAINT IF EXISTS "LiveProgressCursor_stageId_fkey";
|
ALTER TABLE "LiveProgressCursor" DROP CONSTRAINT IF EXISTS "LiveProgressCursor_stageId_fkey";
|
||||||
|
|
||||||
DROP INDEX IF EXISTS "LiveProgressCursor_stageId_key";
|
DROP INDEX IF EXISTS "LiveProgressCursor_stageId_key";
|
||||||
|
|
||||||
ALTER TABLE "LiveProgressCursor" RENAME COLUMN "stageId" TO "roundId";
|
DO $$ BEGIN
|
||||||
|
ALTER TABLE "LiveProgressCursor" RENAME COLUMN "stageId" TO "roundId";
|
||||||
|
EXCEPTION WHEN undefined_column THEN NULL; END $$;
|
||||||
|
|
||||||
CREATE UNIQUE INDEX "LiveProgressCursor_roundId_key" ON "LiveProgressCursor"("roundId");
|
CREATE UNIQUE INDEX IF NOT EXISTS "LiveProgressCursor_roundId_key" ON "LiveProgressCursor"("roundId");
|
||||||
|
|
||||||
ALTER TABLE "LiveProgressCursor" ADD CONSTRAINT "LiveProgressCursor_roundId_fkey"
|
DO $$ BEGIN
|
||||||
|
ALTER TABLE "LiveProgressCursor" ADD CONSTRAINT "LiveProgressCursor_roundId_fkey"
|
||||||
FOREIGN KEY ("roundId") REFERENCES "Round"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
FOREIGN KEY ("roundId") REFERENCES "Round"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||||
|
|
||||||
-- ─── 16. SpecialAward: Drop trackId column ───────────────────────────────────
|
-- --- 16. SpecialAward: Drop trackId column ---
|
||||||
|
|
||||||
ALTER TABLE "SpecialAward" DROP CONSTRAINT IF EXISTS "SpecialAward_trackId_fkey";
|
ALTER TABLE "SpecialAward" DROP CONSTRAINT IF EXISTS "SpecialAward_trackId_fkey";
|
||||||
|
|
||||||
@@ -260,12 +320,16 @@ DROP INDEX IF EXISTS "SpecialAward_trackId_key";
|
|||||||
|
|
||||||
ALTER TABLE "SpecialAward" DROP COLUMN IF EXISTS "trackId";
|
ALTER TABLE "SpecialAward" DROP COLUMN IF EXISTS "trackId";
|
||||||
|
|
||||||
-- ─── 17. ConflictOfInterest: roundId was made nullable in pipeline migration ─
|
-- --- 17. ConflictOfInterest: roundId was made nullable in pipeline migration ---
|
||||||
-- It still exists, just restore FK to new Round table
|
-- It still exists, just restore FK to new Round table
|
||||||
ALTER TABLE "ConflictOfInterest" ADD CONSTRAINT "ConflictOfInterest_roundId_fkey"
|
DO $$ BEGIN
|
||||||
|
ALTER TABLE "ConflictOfInterest" ADD CONSTRAINT "ConflictOfInterest_roundId_fkey"
|
||||||
FOREIGN KEY ("roundId") REFERENCES "Round"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
FOREIGN KEY ("roundId") REFERENCES "Round"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||||
|
|
||||||
-- ─── 18. TaggingJob: roundId was made nullable in pipeline migration ─────────
|
-- --- 18. TaggingJob: roundId was made nullable in pipeline migration ---
|
||||||
-- Restore FK to new Round table
|
-- Restore FK to new Round table
|
||||||
ALTER TABLE "TaggingJob" ADD CONSTRAINT "TaggingJob_roundId_fkey"
|
DO $$ BEGIN
|
||||||
|
ALTER TABLE "TaggingJob" ADD CONSTRAINT "TaggingJob_roundId_fkey"
|
||||||
FOREIGN KEY ("roundId") REFERENCES "Round"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
FOREIGN KEY ("roundId") REFERENCES "Round"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
-- Add pageCount column to ProjectFile (was in schema but missing migration)
|
||||||
|
ALTER TABLE "ProjectFile" ADD COLUMN IF NOT EXISTS "pageCount" INTEGER;
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
-- =============================================================================
|
||||||
|
-- Schema Reconciliation: Fill remaining gaps between migrations and schema.prisma
|
||||||
|
-- =============================================================================
|
||||||
|
-- All statements are idempotent (safe to re-run on any database state).
|
||||||
|
|
||||||
|
-- 1. ConflictOfInterest: add standalone hasConflict index (schema has @@index([hasConflict]))
|
||||||
|
-- Migration 20260205223133 only created composite (roundId, hasConflict) index.
|
||||||
|
CREATE INDEX IF NOT EXISTS "ConflictOfInterest_hasConflict_idx" ON "ConflictOfInterest"("hasConflict");
|
||||||
|
|
||||||
|
-- 2. Ensure ConflictOfInterest.roundId is nullable (schema says String?)
|
||||||
|
-- Pipeline migration (20260213) makes it nullable, but guard for safety.
|
||||||
|
DO $$ BEGIN
|
||||||
|
ALTER TABLE "ConflictOfInterest" ALTER COLUMN "roundId" DROP NOT NULL;
|
||||||
|
EXCEPTION WHEN others THEN NULL;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
-- 3. Drop stale composite index that no longer matches schema
|
||||||
|
-- Schema only has @@index([hasConflict]) and @@index([userId]), not (roundId, hasConflict).
|
||||||
|
DROP INDEX IF EXISTS "ConflictOfInterest_roundId_hasConflict_idx";
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "ProjectFile" ADD COLUMN "textPreview" TEXT;
|
||||||
|
ALTER TABLE "ProjectFile" ADD COLUMN "detectedLang" TEXT;
|
||||||
|
ALTER TABLE "ProjectFile" ADD COLUMN "langConfidence" DOUBLE PRECISION;
|
||||||
|
ALTER TABLE "ProjectFile" ADD COLUMN "analyzedAt" TIMESTAMP(3);
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
-- AlterTable: Add shortlistSize to SpecialAward
|
||||||
|
ALTER TABLE "SpecialAward" ADD COLUMN IF NOT EXISTS "shortlistSize" INTEGER NOT NULL DEFAULT 10;
|
||||||
|
|
||||||
|
-- AlterTable: Add qualityScore, shortlisted, confirmedAt, confirmedBy to AwardEligibility
|
||||||
|
ALTER TABLE "AwardEligibility" ADD COLUMN IF NOT EXISTS "qualityScore" DOUBLE PRECISION;
|
||||||
|
ALTER TABLE "AwardEligibility" ADD COLUMN IF NOT EXISTS "shortlisted" BOOLEAN NOT NULL DEFAULT false;
|
||||||
|
ALTER TABLE "AwardEligibility" ADD COLUMN IF NOT EXISTS "confirmedAt" TIMESTAMP(3);
|
||||||
|
ALTER TABLE "AwardEligibility" ADD COLUMN IF NOT EXISTS "confirmedBy" TEXT;
|
||||||
|
|
||||||
|
-- AlterTable: Add specialAwardId to Round
|
||||||
|
ALTER TABLE "Round" ADD COLUMN IF NOT EXISTS "specialAwardId" TEXT;
|
||||||
|
|
||||||
|
-- AddForeignKey: AwardEligibility.confirmedBy -> User.id
|
||||||
|
ALTER TABLE "AwardEligibility" ADD CONSTRAINT "AwardEligibility_confirmedBy_fkey" FOREIGN KEY ("confirmedBy") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey: Round.specialAwardId -> SpecialAward.id
|
||||||
|
ALTER TABLE "Round" ADD CONSTRAINT "Round_specialAwardId_fkey" FOREIGN KEY ("specialAwardId") REFERENCES "SpecialAward"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX IF NOT EXISTS "Round_specialAwardId_idx" ON "Round"("specialAwardId");
|
||||||
@@ -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
|
DEFAULTS
|
||||||
WHATSAPP
|
WHATSAPP
|
||||||
AUDIT_CONFIG
|
AUDIT_CONFIG
|
||||||
LOCALIZATION
|
|
||||||
DIGEST
|
DIGEST
|
||||||
ANALYTICS
|
ANALYTICS
|
||||||
INTEGRATIONS
|
INTEGRATIONS
|
||||||
@@ -351,6 +350,9 @@ model User {
|
|||||||
preferredWorkload Int?
|
preferredWorkload Int?
|
||||||
availabilityJson Json? @db.JsonB // { startDate?: string, endDate?: string }
|
availabilityJson Json? @db.JsonB // { startDate?: string, endDate?: string }
|
||||||
|
|
||||||
|
// Test environment isolation
|
||||||
|
isTest Boolean @default(false)
|
||||||
|
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
lastLoginAt DateTime?
|
lastLoginAt DateTime?
|
||||||
@@ -379,6 +381,7 @@ model User {
|
|||||||
|
|
||||||
// Award overrides
|
// Award overrides
|
||||||
awardEligibilityOverrides AwardEligibility[] @relation("AwardEligibilityOverriddenBy")
|
awardEligibilityOverrides AwardEligibility[] @relation("AwardEligibilityOverriddenBy")
|
||||||
|
awardEligibilityConfirms AwardEligibility[] @relation("AwardEligibilityConfirmer")
|
||||||
awardWinnerOverrides SpecialAward[] @relation("AwardOverriddenBy")
|
awardWinnerOverrides SpecialAward[] @relation("AwardOverriddenBy")
|
||||||
|
|
||||||
// In-app notifications
|
// In-app notifications
|
||||||
@@ -494,6 +497,9 @@ model Program {
|
|||||||
description String?
|
description String?
|
||||||
settingsJson Json? @db.JsonB
|
settingsJson Json? @db.JsonB
|
||||||
|
|
||||||
|
// Test environment isolation
|
||||||
|
isTest Boolean @default(false)
|
||||||
|
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
@@ -618,6 +624,9 @@ model Project {
|
|||||||
metadataJson Json? @db.JsonB // Custom fields from Typeform, etc.
|
metadataJson Json? @db.JsonB // Custom fields from Typeform, etc.
|
||||||
externalIdsJson Json? @db.JsonB // Typeform ID, Notion ID, etc.
|
externalIdsJson Json? @db.JsonB // Typeform ID, Notion ID, etc.
|
||||||
|
|
||||||
|
// Test environment isolation
|
||||||
|
isTest Boolean @default(false)
|
||||||
|
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
@@ -687,6 +696,13 @@ model ProjectFile {
|
|||||||
fileName String
|
fileName String
|
||||||
mimeType String
|
mimeType String
|
||||||
size Int // bytes
|
size Int // bytes
|
||||||
|
pageCount Int? // Number of pages (PDFs, presentations, etc.)
|
||||||
|
|
||||||
|
// Document analysis (optional, populated by document-analyzer service)
|
||||||
|
textPreview String? @db.Text // First ~2000 chars of extracted text
|
||||||
|
detectedLang String? // ISO 639-3 code (e.g. 'eng', 'fra', 'und')
|
||||||
|
langConfidence Float? // 0.0–1.0 confidence
|
||||||
|
analyzedAt DateTime? // When analysis last ran
|
||||||
|
|
||||||
// MinIO location
|
// MinIO location
|
||||||
bucket String
|
bucket String
|
||||||
@@ -899,7 +915,8 @@ model AIUsageLog {
|
|||||||
entityId String?
|
entityId String?
|
||||||
|
|
||||||
// What was used
|
// 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
|
promptTokens Int
|
||||||
completionTokens Int
|
completionTokens Int
|
||||||
totalTokens Int
|
totalTokens Int
|
||||||
@@ -1500,6 +1517,7 @@ model SpecialAward {
|
|||||||
juryGroupId String?
|
juryGroupId String?
|
||||||
eligibilityMode AwardEligibilityMode @default(STAY_IN_MAIN)
|
eligibilityMode AwardEligibilityMode @default(STAY_IN_MAIN)
|
||||||
decisionMode String? // "JURY_VOTE" | "AWARD_MASTER_DECISION" | "ADMIN_DECISION"
|
decisionMode String? // "JURY_VOTE" | "AWARD_MASTER_DECISION" | "ADMIN_DECISION"
|
||||||
|
shortlistSize Int @default(10)
|
||||||
|
|
||||||
// Eligibility job tracking
|
// Eligibility job tracking
|
||||||
eligibilityJobStatus String? // PENDING, PROCESSING, COMPLETED, FAILED
|
eligibilityJobStatus String? // PENDING, PROCESSING, COMPLETED, FAILED
|
||||||
@@ -1523,6 +1541,7 @@ model SpecialAward {
|
|||||||
competition Competition? @relation(fields: [competitionId], references: [id], onDelete: SetNull)
|
competition Competition? @relation(fields: [competitionId], references: [id], onDelete: SetNull)
|
||||||
evaluationRound Round? @relation(fields: [evaluationRoundId], references: [id], onDelete: SetNull)
|
evaluationRound Round? @relation(fields: [evaluationRoundId], references: [id], onDelete: SetNull)
|
||||||
awardJuryGroup JuryGroup? @relation(fields: [juryGroupId], references: [id], onDelete: SetNull)
|
awardJuryGroup JuryGroup? @relation(fields: [juryGroupId], references: [id], onDelete: SetNull)
|
||||||
|
rounds Round[] @relation("AwardRounds")
|
||||||
|
|
||||||
@@index([programId])
|
@@index([programId])
|
||||||
@@index([status])
|
@@index([status])
|
||||||
@@ -1538,11 +1557,17 @@ model AwardEligibility {
|
|||||||
method EligibilityMethod @default(AUTO)
|
method EligibilityMethod @default(AUTO)
|
||||||
eligible Boolean @default(false)
|
eligible Boolean @default(false)
|
||||||
aiReasoningJson Json? @db.JsonB
|
aiReasoningJson Json? @db.JsonB
|
||||||
|
qualityScore Float?
|
||||||
|
shortlisted Boolean @default(false)
|
||||||
|
|
||||||
// Admin override
|
// Admin override
|
||||||
overriddenBy String?
|
overriddenBy String?
|
||||||
overriddenAt DateTime?
|
overriddenAt DateTime?
|
||||||
|
|
||||||
|
// Shortlist confirmation
|
||||||
|
confirmedAt DateTime?
|
||||||
|
confirmedBy String?
|
||||||
|
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
@@ -1550,6 +1575,7 @@ model AwardEligibility {
|
|||||||
award SpecialAward @relation(fields: [awardId], references: [id], onDelete: Cascade)
|
award SpecialAward @relation(fields: [awardId], references: [id], onDelete: Cascade)
|
||||||
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
|
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
|
||||||
overriddenByUser User? @relation("AwardEligibilityOverriddenBy", fields: [overriddenBy], references: [id], onDelete: SetNull)
|
overriddenByUser User? @relation("AwardEligibilityOverriddenBy", fields: [overriddenBy], references: [id], onDelete: SetNull)
|
||||||
|
confirmer User? @relation("AwardEligibilityConfirmer", fields: [confirmedBy], references: [id], onDelete: SetNull)
|
||||||
|
|
||||||
@@unique([awardId, projectId])
|
@@unique([awardId, projectId])
|
||||||
@@index([awardId])
|
@@index([awardId])
|
||||||
@@ -2073,6 +2099,9 @@ model Competition {
|
|||||||
notifyOnDeadlineApproach Boolean @default(true)
|
notifyOnDeadlineApproach Boolean @default(true)
|
||||||
deadlineReminderDays Int[] @default([7, 3, 1])
|
deadlineReminderDays Int[] @default([7, 3, 1])
|
||||||
|
|
||||||
|
// Test environment isolation
|
||||||
|
isTest Boolean @default(false)
|
||||||
|
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
@@ -2087,6 +2116,7 @@ model Competition {
|
|||||||
|
|
||||||
@@index([programId])
|
@@index([programId])
|
||||||
@@index([status])
|
@@index([status])
|
||||||
|
@@index([isTest])
|
||||||
}
|
}
|
||||||
|
|
||||||
model Round {
|
model Round {
|
||||||
@@ -2111,12 +2141,14 @@ model Round {
|
|||||||
// Links to other entities
|
// Links to other entities
|
||||||
juryGroupId String?
|
juryGroupId String?
|
||||||
submissionWindowId String?
|
submissionWindowId String?
|
||||||
|
specialAwardId String?
|
||||||
|
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
// Relations
|
// Relations
|
||||||
competition Competition @relation(fields: [competitionId], references: [id], onDelete: Cascade)
|
competition Competition @relation(fields: [competitionId], references: [id], onDelete: Cascade)
|
||||||
|
specialAward SpecialAward? @relation("AwardRounds", fields: [specialAwardId], references: [id], onDelete: SetNull)
|
||||||
juryGroup JuryGroup? @relation(fields: [juryGroupId], references: [id], onDelete: SetNull)
|
juryGroup JuryGroup? @relation(fields: [juryGroupId], references: [id], onDelete: SetNull)
|
||||||
submissionWindow SubmissionWindow? @relation(fields: [submissionWindowId], references: [id], onDelete: SetNull)
|
submissionWindow SubmissionWindow? @relation(fields: [submissionWindowId], references: [id], onDelete: SetNull)
|
||||||
projectRoundStates ProjectRoundState[]
|
projectRoundStates ProjectRoundState[]
|
||||||
@@ -2150,6 +2182,7 @@ model Round {
|
|||||||
@@index([competitionId])
|
@@index([competitionId])
|
||||||
@@index([roundType])
|
@@index([roundType])
|
||||||
@@index([status])
|
@@index([status])
|
||||||
|
@@index([specialAwardId])
|
||||||
}
|
}
|
||||||
|
|
||||||
model ProjectRoundState {
|
model ProjectRoundState {
|
||||||
|
|||||||
@@ -131,7 +131,7 @@ async function main() {
|
|||||||
const existingTags = await prisma.expertiseTag.findMany({
|
const existingTags = await prisma.expertiseTag.findMany({
|
||||||
select: { name: true },
|
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
|
// Filter out tags that already exist
|
||||||
const newTags = EXPERTISE_TAGS.filter((t) => !existingNames.has(t.name))
|
const newTags = EXPERTISE_TAGS.filter((t) => !existingNames.has(t.name))
|
||||||
|
|||||||
@@ -83,6 +83,11 @@ const ACTION_TYPES = [
|
|||||||
'ROLE_CHANGED',
|
'ROLE_CHANGED',
|
||||||
'PASSWORD_SET',
|
'PASSWORD_SET',
|
||||||
'PASSWORD_CHANGED',
|
'PASSWORD_CHANGED',
|
||||||
|
'JUROR_DROPOUT_RESHUFFLE',
|
||||||
|
'COI_REASSIGNMENT',
|
||||||
|
'APPLY_AI_SUGGESTIONS',
|
||||||
|
'APPLY_SUGGESTIONS',
|
||||||
|
'NOTIFY_JURORS_OF_ASSIGNMENTS',
|
||||||
]
|
]
|
||||||
|
|
||||||
// Entity type options
|
// Entity type options
|
||||||
@@ -118,6 +123,11 @@ const actionColors: Record<string, 'default' | 'destructive' | 'secondary' | 'ou
|
|||||||
ROLE_CHANGED: 'secondary',
|
ROLE_CHANGED: 'secondary',
|
||||||
PASSWORD_SET: 'outline',
|
PASSWORD_SET: 'outline',
|
||||||
PASSWORD_CHANGED: '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() {
|
export default function AuditLogPage() {
|
||||||
@@ -151,7 +161,7 @@ export default function AuditLogPage() {
|
|||||||
)
|
)
|
||||||
|
|
||||||
// Fetch audit logs
|
// Fetch audit logs
|
||||||
const { data, isLoading, refetch } = trpc.audit.list.useQuery(queryInput)
|
const { data, isLoading, refetch } = trpc.audit.list.useQuery(queryInput, { refetchInterval: 30_000 })
|
||||||
|
|
||||||
// Fetch users for filter dropdown
|
// Fetch users for filter dropdown
|
||||||
const { data: usersData } = trpc.user.list.useQuery({
|
const { data: usersData } = trpc.user.list.useQuery({
|
||||||
@@ -516,9 +526,15 @@ export default function AuditLogPage() {
|
|||||||
<p className="text-xs font-medium text-muted-foreground mb-1">
|
<p className="text-xs font-medium text-muted-foreground mb-1">
|
||||||
Details
|
Details
|
||||||
</p>
|
</p>
|
||||||
|
{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">
|
<pre className="text-xs bg-muted rounded p-2 overflow-x-auto">
|
||||||
{JSON.stringify(log.detailsJson, null, 2)}
|
{JSON.stringify(log.detailsJson, null, 2)}
|
||||||
</pre>
|
</pre>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{!!(log as Record<string, unknown>).previousDataJson && (
|
{!!(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">
|
<p className="text-xs font-medium text-muted-foreground mb-1">
|
||||||
Details
|
Details
|
||||||
</p>
|
</p>
|
||||||
|
{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">
|
<pre className="text-xs bg-muted rounded p-2 overflow-x-auto">
|
||||||
{JSON.stringify(log.detailsJson, null, 2)}
|
{JSON.stringify(log.detailsJson, null, 2)}
|
||||||
</pre>
|
</pre>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</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 }) {
|
function DiffViewer({ before, after }: { before: unknown; after: unknown }) {
|
||||||
const beforeObj = typeof before === 'object' && before !== null ? before as Record<string, 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> : {}
|
const afterObj = typeof after === 'object' && after !== null ? after as Record<string, unknown> : {}
|
||||||
|
|||||||
@@ -25,15 +25,7 @@ import {
|
|||||||
import { Switch } from '@/components/ui/switch'
|
import { Switch } from '@/components/ui/switch'
|
||||||
import { Skeleton } from '@/components/ui/skeleton'
|
import { Skeleton } from '@/components/ui/skeleton'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
import { ArrowLeft, Save, Loader2, Plus, X, Info } from 'lucide-react'
|
import { ArrowLeft, Save, Loader2 } from 'lucide-react'
|
||||||
import { Badge } from '@/components/ui/badge'
|
|
||||||
|
|
||||||
type AutoTagRule = {
|
|
||||||
id: string
|
|
||||||
field: 'competitionCategory' | 'country' | 'geographicZone' | 'tags' | 'oceanIssue'
|
|
||||||
operator: 'equals' | 'contains' | 'in'
|
|
||||||
value: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function EditAwardPage({
|
export default function EditAwardPage({
|
||||||
params,
|
params,
|
||||||
@@ -46,12 +38,8 @@ export default function EditAwardPage({
|
|||||||
const utils = trpc.useUtils()
|
const utils = trpc.useUtils()
|
||||||
const { data: award, isLoading } = trpc.specialAward.get.useQuery({ id: awardId })
|
const { data: award, isLoading } = trpc.specialAward.get.useQuery({ id: awardId })
|
||||||
|
|
||||||
// Fetch competition rounds for source round selector
|
// Rounds come from the award's included competition relation
|
||||||
const competitionId = award?.competitionId
|
const competitionRounds = award?.competition?.rounds ?? []
|
||||||
const { data: competition } = trpc.competition.getById.useQuery(
|
|
||||||
{ id: competitionId! },
|
|
||||||
{ enabled: !!competitionId }
|
|
||||||
)
|
|
||||||
|
|
||||||
const updateAward = trpc.specialAward.update.useMutation({
|
const updateAward = trpc.specialAward.update.useMutation({
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
@@ -70,7 +58,6 @@ export default function EditAwardPage({
|
|||||||
const [votingEndAt, setVotingEndAt] = useState('')
|
const [votingEndAt, setVotingEndAt] = useState('')
|
||||||
const [evaluationRoundId, setEvaluationRoundId] = useState('')
|
const [evaluationRoundId, setEvaluationRoundId] = useState('')
|
||||||
const [eligibilityMode, setEligibilityMode] = useState<'STAY_IN_MAIN' | 'SEPARATE_POOL'>('STAY_IN_MAIN')
|
const [eligibilityMode, setEligibilityMode] = useState<'STAY_IN_MAIN' | 'SEPARATE_POOL'>('STAY_IN_MAIN')
|
||||||
const [autoTagRules, setAutoTagRules] = useState<AutoTagRule[]>([])
|
|
||||||
|
|
||||||
// Helper to format date for datetime-local input
|
// Helper to format date for datetime-local input
|
||||||
const formatDateForInput = (date: Date | string | null | undefined): string => {
|
const formatDateForInput = (date: Date | string | null | undefined): string => {
|
||||||
@@ -93,14 +80,6 @@ export default function EditAwardPage({
|
|||||||
setVotingEndAt(formatDateForInput(award.votingEndAt))
|
setVotingEndAt(formatDateForInput(award.votingEndAt))
|
||||||
setEvaluationRoundId(award.evaluationRoundId || '')
|
setEvaluationRoundId(award.evaluationRoundId || '')
|
||||||
setEligibilityMode(award.eligibilityMode as 'STAY_IN_MAIN' | 'SEPARATE_POOL')
|
setEligibilityMode(award.eligibilityMode as 'STAY_IN_MAIN' | 'SEPARATE_POOL')
|
||||||
|
|
||||||
// Parse autoTagRulesJson
|
|
||||||
if (award.autoTagRulesJson && typeof award.autoTagRulesJson === 'object') {
|
|
||||||
const rules = award.autoTagRulesJson as { rules?: AutoTagRule[] }
|
|
||||||
setAutoTagRules(rules.rules || [])
|
|
||||||
} else {
|
|
||||||
setAutoTagRules([])
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}, [award])
|
}, [award])
|
||||||
|
|
||||||
@@ -119,7 +98,6 @@ export default function EditAwardPage({
|
|||||||
votingEndAt: votingEndAt ? new Date(votingEndAt) : undefined,
|
votingEndAt: votingEndAt ? new Date(votingEndAt) : undefined,
|
||||||
evaluationRoundId: evaluationRoundId || undefined,
|
evaluationRoundId: evaluationRoundId || undefined,
|
||||||
eligibilityMode,
|
eligibilityMode,
|
||||||
autoTagRulesJson: autoTagRules.length > 0 ? { rules: autoTagRules } : undefined,
|
|
||||||
})
|
})
|
||||||
toast.success('Award updated')
|
toast.success('Award updated')
|
||||||
router.push(`/admin/awards/${awardId}`)
|
router.push(`/admin/awards/${awardId}`)
|
||||||
@@ -130,28 +108,6 @@ export default function EditAwardPage({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const addRule = () => {
|
|
||||||
setAutoTagRules([
|
|
||||||
...autoTagRules,
|
|
||||||
{
|
|
||||||
id: `rule-${Date.now()}`,
|
|
||||||
field: 'competitionCategory',
|
|
||||||
operator: 'equals',
|
|
||||||
value: '',
|
|
||||||
},
|
|
||||||
])
|
|
||||||
}
|
|
||||||
|
|
||||||
const removeRule = (id: string) => {
|
|
||||||
setAutoTagRules(autoTagRules.filter((r) => r.id !== id))
|
|
||||||
}
|
|
||||||
|
|
||||||
const updateRule = (id: string, updates: Partial<AutoTagRule>) => {
|
|
||||||
setAutoTagRules(
|
|
||||||
autoTagRules.map((r) => (r.id === id ? { ...r, ...updates } : r))
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
@@ -306,9 +262,7 @@ export default function EditAwardPage({
|
|||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="none">No source round</SelectItem>
|
<SelectItem value="none">No source round</SelectItem>
|
||||||
{competition?.rounds
|
{competitionRounds.map((round) => (
|
||||||
?.sort((a, b) => a.sortOrder - b.sortOrder)
|
|
||||||
.map((round) => (
|
|
||||||
<SelectItem key={round.id} value={round.id}>
|
<SelectItem key={round.id} value={round.id}>
|
||||||
{round.name} ({round.roundType})
|
{round.name} ({round.roundType})
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
@@ -348,135 +302,6 @@ export default function EditAwardPage({
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* Auto-Tag Rules */}
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<div className="flex items-start justify-between">
|
|
||||||
<div>
|
|
||||||
<CardTitle>Auto-Tag Rules</CardTitle>
|
|
||||||
<CardDescription>
|
|
||||||
Deterministic eligibility rules based on project metadata
|
|
||||||
</CardDescription>
|
|
||||||
</div>
|
|
||||||
<Button variant="outline" size="sm" onClick={addRule}>
|
|
||||||
<Plus className="mr-2 h-4 w-4" />
|
|
||||||
Add Rule
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="space-y-3">
|
|
||||||
{autoTagRules.length === 0 ? (
|
|
||||||
<div className="flex items-start gap-2 rounded-lg border border-dashed p-4 text-sm text-muted-foreground">
|
|
||||||
<Info className="h-4 w-4 mt-0.5 shrink-0" />
|
|
||||||
<p>
|
|
||||||
No rules defined. Add rules to automatically filter projects based on category, location, tags, or ocean issues.
|
|
||||||
Rules work together with the source round setting.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="space-y-3">
|
|
||||||
{autoTagRules.map((rule, index) => (
|
|
||||||
<div
|
|
||||||
key={rule.id}
|
|
||||||
className="flex items-start gap-3 rounded-lg border p-3"
|
|
||||||
>
|
|
||||||
<div className="flex-1 grid gap-3 sm:grid-cols-3">
|
|
||||||
<div className="space-y-1.5">
|
|
||||||
<Label className="text-xs">Field</Label>
|
|
||||||
<Select
|
|
||||||
value={rule.field}
|
|
||||||
onValueChange={(v) =>
|
|
||||||
updateRule(rule.id, {
|
|
||||||
field: v as AutoTagRule['field'],
|
|
||||||
})
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<SelectTrigger className="h-9">
|
|
||||||
<SelectValue />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="competitionCategory">
|
|
||||||
Competition Category
|
|
||||||
</SelectItem>
|
|
||||||
<SelectItem value="country">Country</SelectItem>
|
|
||||||
<SelectItem value="geographicZone">
|
|
||||||
Geographic Zone
|
|
||||||
</SelectItem>
|
|
||||||
<SelectItem value="tags">Tags</SelectItem>
|
|
||||||
<SelectItem value="oceanIssue">Ocean Issue</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-1.5">
|
|
||||||
<Label className="text-xs">Operator</Label>
|
|
||||||
<Select
|
|
||||||
value={rule.operator}
|
|
||||||
onValueChange={(v) =>
|
|
||||||
updateRule(rule.id, {
|
|
||||||
operator: v as AutoTagRule['operator'],
|
|
||||||
})
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<SelectTrigger className="h-9">
|
|
||||||
<SelectValue />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="equals">Equals</SelectItem>
|
|
||||||
<SelectItem value="contains">Contains</SelectItem>
|
|
||||||
<SelectItem value="in">In (comma-separated)</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-1.5">
|
|
||||||
<Label className="text-xs">Value</Label>
|
|
||||||
<Input
|
|
||||||
className="h-9"
|
|
||||||
value={rule.value}
|
|
||||||
onChange={(e) =>
|
|
||||||
updateRule(rule.id, { value: e.target.value })
|
|
||||||
}
|
|
||||||
placeholder={
|
|
||||||
rule.operator === 'in'
|
|
||||||
? 'value1,value2,value3'
|
|
||||||
: 'Enter value...'
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
className="h-9 w-9 shrink-0"
|
|
||||||
onClick={() => removeRule(rule.id)}
|
|
||||||
>
|
|
||||||
<X className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{autoTagRules.length > 0 && (
|
|
||||||
<div className="flex items-start gap-2 rounded-lg bg-muted p-3 text-xs text-muted-foreground">
|
|
||||||
<Info className="h-3 w-3 mt-0.5 shrink-0" />
|
|
||||||
<p>
|
|
||||||
<strong>How it works:</strong> Filter from{' '}
|
|
||||||
<Badge variant="outline" className="mx-1">
|
|
||||||
{evaluationRoundId
|
|
||||||
? competition?.rounds?.find((r) => r.id === evaluationRoundId)
|
|
||||||
?.name || 'Selected Round'
|
|
||||||
: 'All Projects'}
|
|
||||||
</Badge>
|
|
||||||
, where ALL rules match (AND logic). Projects matching these deterministic rules will be marked eligible.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* Voting Window Card */}
|
{/* Voting Window Card */}
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
|
|||||||
@@ -89,6 +89,8 @@ import {
|
|||||||
Vote,
|
Vote,
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
AlertCircle,
|
AlertCircle,
|
||||||
|
Layers,
|
||||||
|
Info,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
|
|
||||||
const STATUS_COLORS: Record<string, 'default' | 'secondary' | 'destructive' | 'outline'> = {
|
const STATUS_COLORS: Record<string, 'default' | 'secondary' | 'destructive' | 'outline'> = {
|
||||||
@@ -151,6 +153,8 @@ export default function AwardDetailPage({
|
|||||||
const [projectSearchQuery, setProjectSearchQuery] = useState('')
|
const [projectSearchQuery, setProjectSearchQuery] = useState('')
|
||||||
const [expandedRows, setExpandedRows] = useState<Set<string>>(new Set())
|
const [expandedRows, setExpandedRows] = useState<Set<string>>(new Set())
|
||||||
const [activeTab, setActiveTab] = useState('eligibility')
|
const [activeTab, setActiveTab] = useState('eligibility')
|
||||||
|
const [addRoundOpen, setAddRoundOpen] = useState(false)
|
||||||
|
const [roundForm, setRoundForm] = useState({ name: '', roundType: 'EVALUATION' as string })
|
||||||
|
|
||||||
// Pagination for eligibility list
|
// Pagination for eligibility list
|
||||||
const [eligibilityPage, setEligibilityPage] = useState(1)
|
const [eligibilityPage, setEligibilityPage] = useState(1)
|
||||||
@@ -158,7 +162,7 @@ export default function AwardDetailPage({
|
|||||||
|
|
||||||
// Core queries — lazy-load tab-specific data based on activeTab
|
// Core queries — lazy-load tab-specific data based on activeTab
|
||||||
const { data: award, isLoading, refetch } =
|
const { data: award, isLoading, refetch } =
|
||||||
trpc.specialAward.get.useQuery({ id: awardId })
|
trpc.specialAward.get.useQuery({ id: awardId }, { refetchInterval: 30_000 })
|
||||||
const { data: eligibilityData, refetch: refetchEligibility } =
|
const { data: eligibilityData, refetch: refetchEligibility } =
|
||||||
trpc.specialAward.listEligible.useQuery({
|
trpc.specialAward.listEligible.useQuery({
|
||||||
awardId,
|
awardId,
|
||||||
@@ -175,6 +179,10 @@ export default function AwardDetailPage({
|
|||||||
trpc.specialAward.getVoteResults.useQuery({ awardId }, {
|
trpc.specialAward.getVoteResults.useQuery({ awardId }, {
|
||||||
enabled: activeTab === 'results',
|
enabled: activeTab === 'results',
|
||||||
})
|
})
|
||||||
|
const { data: awardRounds, refetch: refetchRounds } =
|
||||||
|
trpc.specialAward.listRounds.useQuery({ awardId }, {
|
||||||
|
enabled: activeTab === 'rounds',
|
||||||
|
})
|
||||||
|
|
||||||
// Deferred queries - only load when needed
|
// Deferred queries - only load when needed
|
||||||
const { data: allUsers } = trpc.user.list.useQuery(
|
const { data: allUsers } = trpc.user.list.useQuery(
|
||||||
@@ -258,6 +266,22 @@ export default function AwardDetailPage({
|
|||||||
const deleteAward = trpc.specialAward.delete.useMutation({
|
const deleteAward = trpc.specialAward.delete.useMutation({
|
||||||
onSuccess: () => utils.specialAward.list.invalidate(),
|
onSuccess: () => utils.specialAward.list.invalidate(),
|
||||||
})
|
})
|
||||||
|
const createRound = trpc.specialAward.createRound.useMutation({
|
||||||
|
onSuccess: () => {
|
||||||
|
refetchRounds()
|
||||||
|
setAddRoundOpen(false)
|
||||||
|
setRoundForm({ name: '', roundType: 'EVALUATION' })
|
||||||
|
toast.success('Round created')
|
||||||
|
},
|
||||||
|
onError: (err) => toast.error(err.message),
|
||||||
|
})
|
||||||
|
const deleteRound = trpc.specialAward.deleteRound.useMutation({
|
||||||
|
onSuccess: () => {
|
||||||
|
refetchRounds()
|
||||||
|
toast.success('Round deleted')
|
||||||
|
},
|
||||||
|
onError: (err) => toast.error(err.message),
|
||||||
|
})
|
||||||
|
|
||||||
const handleStatusChange = async (
|
const handleStatusChange = async (
|
||||||
status: 'DRAFT' | 'NOMINATIONS_OPEN' | 'VOTING_OPEN' | 'CLOSED' | 'ARCHIVED'
|
status: 'DRAFT' | 'NOMINATIONS_OPEN' | 'VOTING_OPEN' | 'CLOSED' | 'ARCHIVED'
|
||||||
@@ -414,7 +438,7 @@ export default function AwardDetailPage({
|
|||||||
</h1>
|
</h1>
|
||||||
<div className="flex items-center gap-2 mt-1">
|
<div className="flex items-center gap-2 mt-1">
|
||||||
<Badge variant={STATUS_COLORS[award.status] || 'secondary'}>
|
<Badge variant={STATUS_COLORS[award.status] || 'secondary'}>
|
||||||
{award.status.replace('_', ' ')}
|
{award.status.replace(/_/g, ' ')}
|
||||||
</Badge>
|
</Badge>
|
||||||
<span className="text-muted-foreground">
|
<span className="text-muted-foreground">
|
||||||
{award.program.year} Edition
|
{award.program.year} Edition
|
||||||
@@ -570,7 +594,7 @@ export default function AwardDetailPage({
|
|||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Evaluated</p>
|
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Evaluated</p>
|
||||||
<p className="text-2xl font-bold tabular-nums">{award._count.eligibilities}</p>
|
<p className="text-2xl font-bold tabular-nums">{(award as any).totalAssessed ?? award._count.eligibilities}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-blue-100 dark:bg-blue-950/40">
|
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-blue-100 dark:bg-blue-950/40">
|
||||||
<ListChecks className="h-5 w-5 text-blue-600 dark:text-blue-400" />
|
<ListChecks className="h-5 w-5 text-blue-600 dark:text-blue-400" />
|
||||||
@@ -619,6 +643,10 @@ export default function AwardDetailPage({
|
|||||||
<Users className="mr-2 h-4 w-4" />
|
<Users className="mr-2 h-4 w-4" />
|
||||||
Jurors ({award._count.jurors})
|
Jurors ({award._count.jurors})
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="rounds">
|
||||||
|
<Layers className="mr-2 h-4 w-4" />
|
||||||
|
Rounds {awardRounds ? `(${awardRounds.length})` : ''}
|
||||||
|
</TabsTrigger>
|
||||||
<TabsTrigger value="results">
|
<TabsTrigger value="results">
|
||||||
<BarChart3 className="mr-2 h-4 w-4" />
|
<BarChart3 className="mr-2 h-4 w-4" />
|
||||||
Results
|
Results
|
||||||
@@ -629,7 +657,7 @@ export default function AwardDetailPage({
|
|||||||
<TabsContent value="eligibility" className="space-y-4">
|
<TabsContent value="eligibility" className="space-y-4">
|
||||||
<div className="flex flex-col gap-3 sm:flex-row sm:justify-between sm:items-center">
|
<div className="flex flex-col gap-3 sm:flex-row sm:justify-between sm:items-center">
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
{award.eligibleCount} of {award._count.eligibilities} projects
|
{award.eligibleCount} of {(award as any).totalAssessed ?? award._count.eligibilities} projects
|
||||||
eligible
|
eligible
|
||||||
</p>
|
</p>
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
@@ -1083,6 +1111,199 @@ export default function AwardDetailPage({
|
|||||||
)}
|
)}
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
|
{/* Rounds Tab */}
|
||||||
|
<TabsContent value="rounds" className="space-y-4">
|
||||||
|
{award.eligibilityMode !== 'SEPARATE_POOL' && (
|
||||||
|
<div className="flex items-start gap-2 rounded-md border border-blue-200 bg-blue-50 p-3 text-blue-800 dark:border-blue-800 dark:bg-blue-950/30 dark:text-blue-300">
|
||||||
|
<Info className="h-4 w-4 mt-0.5 shrink-0" />
|
||||||
|
<p className="text-sm">
|
||||||
|
Rounds are used in <strong>Separate Pool</strong> mode to create a dedicated evaluation track for shortlisted projects.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{!award.competitionId && (
|
||||||
|
<div className="flex items-start gap-2 rounded-md border border-amber-200 bg-amber-50 p-3 text-amber-800 dark:border-amber-800 dark:bg-amber-950/30 dark:text-amber-300">
|
||||||
|
<AlertCircle className="h-4 w-4 mt-0.5 shrink-0" />
|
||||||
|
<p className="text-sm">
|
||||||
|
Link this award to a competition first before creating rounds.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||||
|
<h2 className="text-lg font-semibold">Award Rounds ({awardRounds?.length ?? 0})</h2>
|
||||||
|
<Dialog open={addRoundOpen} onOpenChange={setAddRoundOpen}>
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
<Button size="sm" variant="outline" disabled={!award.competitionId}>
|
||||||
|
<Plus className="h-4 w-4 mr-1" />
|
||||||
|
Add Round
|
||||||
|
</Button>
|
||||||
|
</DialogTrigger>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Create Award Round</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Add a new round to the "{award.name}" award evaluation track.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="grid gap-4 py-4">
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label htmlFor="round-name">Round Name</Label>
|
||||||
|
<Input
|
||||||
|
id="round-name"
|
||||||
|
placeholder="e.g. Award Evaluation"
|
||||||
|
value={roundForm.name}
|
||||||
|
onChange={(e) => setRoundForm({ ...roundForm, name: e.target.value })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label htmlFor="round-type">Round Type</Label>
|
||||||
|
<Select
|
||||||
|
value={roundForm.roundType}
|
||||||
|
onValueChange={(v) => setRoundForm({ ...roundForm, roundType: v })}
|
||||||
|
>
|
||||||
|
<SelectTrigger id="round-type">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="EVALUATION">Evaluation</SelectItem>
|
||||||
|
<SelectItem value="FILTERING">Filtering</SelectItem>
|
||||||
|
<SelectItem value="SUBMISSION">Submission</SelectItem>
|
||||||
|
<SelectItem value="MENTORING">Mentoring</SelectItem>
|
||||||
|
<SelectItem value="LIVE_FINAL">Live Final</SelectItem>
|
||||||
|
<SelectItem value="DELIBERATION">Deliberation</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => setAddRoundOpen(false)}>Cancel</Button>
|
||||||
|
<Button
|
||||||
|
onClick={() => createRound.mutate({
|
||||||
|
awardId,
|
||||||
|
name: roundForm.name.trim(),
|
||||||
|
roundType: roundForm.roundType as any,
|
||||||
|
})}
|
||||||
|
disabled={!roundForm.name.trim() || createRound.isPending}
|
||||||
|
>
|
||||||
|
{createRound.isPending ? (
|
||||||
|
<><Loader2 className="mr-2 h-4 w-4 animate-spin" />Creating...</>
|
||||||
|
) : 'Create Round'}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!awardRounds ? (
|
||||||
|
<div className="grid gap-3 md:grid-cols-2 lg:grid-cols-3">
|
||||||
|
{[1, 2, 3].map((i) => (
|
||||||
|
<Skeleton key={i} className="h-32 rounded-lg" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : awardRounds.length === 0 ? (
|
||||||
|
<Card className="border-dashed">
|
||||||
|
<CardContent className="py-8 text-center text-sm text-muted-foreground">
|
||||||
|
No rounds yet. Create your first award round to build an evaluation track.
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
) : (
|
||||||
|
<div className="grid gap-3 md:grid-cols-2 lg:grid-cols-3">
|
||||||
|
{awardRounds.map((round: any, index: number) => {
|
||||||
|
const projectCount = round._count?.projectRoundStates ?? 0
|
||||||
|
const assignmentCount = round._count?.assignments ?? 0
|
||||||
|
const statusLabel = round.status.replace('ROUND_', '')
|
||||||
|
const statusColors: Record<string, string> = {
|
||||||
|
DRAFT: 'bg-gray-100 text-gray-600',
|
||||||
|
ACTIVE: 'bg-emerald-100 text-emerald-700',
|
||||||
|
CLOSED: 'bg-blue-100 text-blue-700',
|
||||||
|
ARCHIVED: 'bg-muted text-muted-foreground',
|
||||||
|
}
|
||||||
|
const roundTypeColors: Record<string, string> = {
|
||||||
|
EVALUATION: 'bg-violet-100 text-violet-700',
|
||||||
|
FILTERING: 'bg-amber-100 text-amber-700',
|
||||||
|
SUBMISSION: 'bg-blue-100 text-blue-700',
|
||||||
|
MENTORING: 'bg-teal-100 text-teal-700',
|
||||||
|
LIVE_FINAL: 'bg-rose-100 text-rose-700',
|
||||||
|
DELIBERATION: 'bg-indigo-100 text-indigo-700',
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<Card key={round.id} className="hover:shadow-md transition-shadow h-full">
|
||||||
|
<CardContent className="pt-4 pb-3 space-y-3">
|
||||||
|
<div className="flex items-start gap-2.5">
|
||||||
|
<div className="flex h-7 w-7 items-center justify-center rounded-full bg-muted text-xs font-bold shrink-0 mt-0.5">
|
||||||
|
{index + 1}
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<Link href={`/admin/rounds/${round.id}` as any} className="text-sm font-semibold truncate hover:underline">
|
||||||
|
{round.name}
|
||||||
|
</Link>
|
||||||
|
<div className="flex flex-wrap gap-1.5 mt-1">
|
||||||
|
<Badge variant="secondary" className={`text-[10px] ${roundTypeColors[round.roundType] ?? 'bg-gray-100 text-gray-700'}`}>
|
||||||
|
{round.roundType.replace('_', ' ')}
|
||||||
|
</Badge>
|
||||||
|
<Badge variant="outline" className={`text-[10px] ${statusColors[statusLabel]}`}>
|
||||||
|
{statusLabel}
|
||||||
|
</Badge>
|
||||||
|
{index === 0 && (
|
||||||
|
<Badge variant="outline" className="text-[10px] border-amber-300 bg-amber-50 text-amber-700">
|
||||||
|
Entry point
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-2 text-xs">
|
||||||
|
<div className="flex items-center gap-1.5 text-muted-foreground">
|
||||||
|
<Layers className="h-3.5 w-3.5" />
|
||||||
|
<span>{projectCount} project{projectCount !== 1 ? 's' : ''}</span>
|
||||||
|
</div>
|
||||||
|
{assignmentCount > 0 && (
|
||||||
|
<div className="flex items-center gap-1.5 text-muted-foreground">
|
||||||
|
<ListChecks className="h-3.5 w-3.5" />
|
||||||
|
<span>{assignmentCount} assignment{assignmentCount !== 1 ? 's' : ''}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{round.status === 'ROUND_DRAFT' && (
|
||||||
|
<div className="flex justify-end pt-1">
|
||||||
|
<AlertDialog>
|
||||||
|
<AlertDialogTrigger asChild>
|
||||||
|
<Button variant="ghost" size="sm" className="text-destructive hover:text-destructive">
|
||||||
|
<Trash2 className="h-3.5 w-3.5 mr-1" />
|
||||||
|
Delete
|
||||||
|
</Button>
|
||||||
|
</AlertDialogTrigger>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>Delete Round</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
This will permanently delete "{round.name}". This cannot be undone.
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||||
|
<AlertDialogAction
|
||||||
|
onClick={() => deleteRound.mutate({ roundId: round.id })}
|
||||||
|
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
{/* Results Tab */}
|
{/* Results Tab */}
|
||||||
<TabsContent value="results" className="space-y-4">
|
<TabsContent value="results" className="space-y-4">
|
||||||
{voteResults && voteResults.results.length > 0 ? (() => {
|
{voteResults && voteResults.results.length > 0 ? (() => {
|
||||||
|
|||||||
@@ -40,7 +40,10 @@ const SCORING_LABELS: Record<string, string> = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function AwardsListPage() {
|
export default function AwardsListPage() {
|
||||||
const { data: awards, isLoading } = trpc.specialAward.list.useQuery({})
|
const { data: awards, isLoading } = trpc.specialAward.list.useQuery(
|
||||||
|
{},
|
||||||
|
{ refetchInterval: 30_000 }
|
||||||
|
)
|
||||||
|
|
||||||
const [search, setSearch] = useState('')
|
const [search, setSearch] = useState('')
|
||||||
const debouncedSearch = useDebounce(search, 300)
|
const debouncedSearch = useDebounce(search, 300)
|
||||||
@@ -168,7 +171,7 @@ export default function AwardsListPage() {
|
|||||||
{award.name}
|
{award.name}
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<Badge variant={STATUS_COLORS[award.status] || 'secondary'}>
|
<Badge variant={STATUS_COLORS[award.status] || 'secondary'}>
|
||||||
{award.status.replace('_', ' ')}
|
{award.status.replace(/_/g, ' ')}
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
{award.description && (
|
{award.description && (
|
||||||
|
|||||||
@@ -2,7 +2,8 @@
|
|||||||
|
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { useParams, useRouter } from 'next/navigation'
|
import { useParams, useRouter } from 'next/navigation'
|
||||||
import { ArrowLeft, PlayCircle } from 'lucide-react'
|
import { ArrowLeft, Loader2, PlayCircle, Zap } from 'lucide-react'
|
||||||
|
import { toast } from 'sonner'
|
||||||
import { trpc } from '@/lib/trpc/client'
|
import { trpc } from '@/lib/trpc/client'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||||
@@ -26,13 +27,30 @@ export default function AssignmentsDashboardPage() {
|
|||||||
const [selectedRoundId, setSelectedRoundId] = useState<string>('')
|
const [selectedRoundId, setSelectedRoundId] = useState<string>('')
|
||||||
const [previewSheetOpen, setPreviewSheetOpen] = useState(false)
|
const [previewSheetOpen, setPreviewSheetOpen] = useState(false)
|
||||||
|
|
||||||
|
const aiAssignmentMutation = trpc.roundAssignment.aiPreview.useMutation({
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success('AI assignments ready!', {
|
||||||
|
action: { label: 'Review', onClick: () => setPreviewSheetOpen(true) },
|
||||||
|
duration: 10000,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
onError: (err) => toast.error(`AI generation failed: ${err.message}`),
|
||||||
|
})
|
||||||
|
|
||||||
const { data: competition, isLoading: isLoadingCompetition } = trpc.competition.getById.useQuery({
|
const { data: competition, isLoading: isLoadingCompetition } = trpc.competition.getById.useQuery({
|
||||||
id: competitionId,
|
id: competitionId,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const { data: selectedRound } = trpc.round.getById.useQuery(
|
||||||
|
{ id: selectedRoundId },
|
||||||
|
{ enabled: !!selectedRoundId }
|
||||||
|
)
|
||||||
|
|
||||||
|
const requiredReviews = (selectedRound?.configJson as Record<string, unknown>)?.requiredReviewsPerProject as number || 3
|
||||||
|
|
||||||
const { data: unassignedQueue, isLoading: isLoadingQueue } =
|
const { data: unassignedQueue, isLoading: isLoadingQueue } =
|
||||||
trpc.roundAssignment.unassignedQueue.useQuery(
|
trpc.roundAssignment.unassignedQueue.useQuery(
|
||||||
{ roundId: selectedRoundId, requiredReviews: 3 },
|
{ roundId: selectedRoundId, requiredReviews },
|
||||||
{ enabled: !!selectedRoundId }
|
{ enabled: !!selectedRoundId }
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -51,7 +69,18 @@ export default function AssignmentsDashboardPage() {
|
|||||||
if (!competition) {
|
if (!competition) {
|
||||||
return (
|
return (
|
||||||
<div className="container mx-auto space-y-6 p-4 sm:p-6">
|
<div className="container mx-auto space-y-6 p-4 sm:p-6">
|
||||||
<p>Competition not found</p>
|
<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>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -97,11 +126,24 @@ export default function AssignmentsDashboardPage() {
|
|||||||
|
|
||||||
{selectedRoundId && (
|
{selectedRoundId && (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="flex justify-end">
|
<div className="flex justify-end gap-2">
|
||||||
<Button onClick={() => setPreviewSheetOpen(true)}>
|
<Button
|
||||||
<PlayCircle className="mr-2 h-4 w-4" />
|
onClick={() => {
|
||||||
Generate Assignments
|
aiAssignmentMutation.mutate({ roundId: selectedRoundId, requiredReviews })
|
||||||
|
}}
|
||||||
|
disabled={aiAssignmentMutation.isPending}
|
||||||
|
>
|
||||||
|
{aiAssignmentMutation.isPending ? (
|
||||||
|
<><Loader2 className="mr-2 h-4 w-4 animate-spin" />Generating...</>
|
||||||
|
) : (
|
||||||
|
<><Zap className="mr-2 h-4 w-4" />{aiAssignmentMutation.data ? 'Regenerate' : 'Generate with AI'}</>
|
||||||
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
|
{aiAssignmentMutation.data && (
|
||||||
|
<Button variant="outline" onClick={() => setPreviewSheetOpen(true)}>
|
||||||
|
Review Assignments
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Tabs defaultValue="coverage" className="w-full">
|
<Tabs defaultValue="coverage" className="w-full">
|
||||||
@@ -111,7 +153,7 @@ export default function AssignmentsDashboardPage() {
|
|||||||
</TabsList>
|
</TabsList>
|
||||||
|
|
||||||
<TabsContent value="coverage" className="mt-6">
|
<TabsContent value="coverage" className="mt-6">
|
||||||
<CoverageReport roundId={selectedRoundId} />
|
<CoverageReport roundId={selectedRoundId} requiredReviews={requiredReviews} />
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
<TabsContent value="unassigned" className="mt-6">
|
<TabsContent value="unassigned" className="mt-6">
|
||||||
@@ -119,7 +161,7 @@ export default function AssignmentsDashboardPage() {
|
|||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Unassigned Projects</CardTitle>
|
<CardTitle>Unassigned Projects</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
Projects with fewer than 3 assignments
|
Projects with fewer than {requiredReviews} assignments
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
@@ -143,7 +185,7 @@ export default function AssignmentsDashboardPage() {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-sm text-muted-foreground">
|
<div className="text-sm text-muted-foreground">
|
||||||
{project.assignmentCount || 0} / 3 assignments
|
{project.assignmentCount || 0} / {requiredReviews} assignments
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
@@ -162,6 +204,11 @@ export default function AssignmentsDashboardPage() {
|
|||||||
roundId={selectedRoundId}
|
roundId={selectedRoundId}
|
||||||
open={previewSheetOpen}
|
open={previewSheetOpen}
|
||||||
onOpenChange={setPreviewSheetOpen}
|
onOpenChange={setPreviewSheetOpen}
|
||||||
|
requiredReviews={requiredReviews}
|
||||||
|
aiResult={aiAssignmentMutation.data ?? null}
|
||||||
|
isAIGenerating={aiAssignmentMutation.isPending}
|
||||||
|
onGenerateAI={() => aiAssignmentMutation.mutate({ roundId: selectedRoundId, requiredReviews })}
|
||||||
|
onResetAI={() => aiAssignmentMutation.reset()}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -22,8 +22,10 @@ export default function NewAwardPage({ params: paramsPromise }: { params: Promis
|
|||||||
const [formData, setFormData] = useState({
|
const [formData, setFormData] = useState({
|
||||||
name: '',
|
name: '',
|
||||||
description: '',
|
description: '',
|
||||||
|
criteriaText: '',
|
||||||
useAiEligibility: false,
|
useAiEligibility: false,
|
||||||
scoringMode: 'PICK_WINNER' as 'PICK_WINNER' | 'RANKED' | 'SCORED'
|
scoringMode: 'PICK_WINNER' as 'PICK_WINNER' | 'RANKED' | 'SCORED',
|
||||||
|
maxRankedPicks: '3',
|
||||||
});
|
});
|
||||||
|
|
||||||
const { data: competition } = trpc.competition.getById.useQuery({
|
const { data: competition } = trpc.competition.getById.useQuery({
|
||||||
@@ -60,10 +62,13 @@ export default function NewAwardPage({ params: paramsPromise }: { params: Promis
|
|||||||
|
|
||||||
createMutation.mutate({
|
createMutation.mutate({
|
||||||
programId: competition.programId,
|
programId: competition.programId,
|
||||||
|
competitionId: params.competitionId,
|
||||||
name: formData.name.trim(),
|
name: formData.name.trim(),
|
||||||
description: formData.description.trim() || undefined,
|
description: formData.description.trim() || undefined,
|
||||||
|
criteriaText: formData.criteriaText.trim() || undefined,
|
||||||
scoringMode: formData.scoringMode,
|
scoringMode: formData.scoringMode,
|
||||||
useAiEligibility: formData.useAiEligibility
|
useAiEligibility: formData.useAiEligibility,
|
||||||
|
maxRankedPicks: formData.scoringMode === 'RANKED' ? parseInt(formData.maxRankedPicks) : undefined,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -113,22 +118,17 @@ export default function NewAwardPage({ params: paramsPromise }: { params: Promis
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="scoringMode">Scoring Mode</Label>
|
<Label htmlFor="criteriaText">Eligibility Criteria</Label>
|
||||||
<Select
|
<Textarea
|
||||||
value={formData.scoringMode}
|
id="criteriaText"
|
||||||
onValueChange={(value) =>
|
value={formData.criteriaText}
|
||||||
setFormData({ ...formData, scoringMode: value as 'PICK_WINNER' | 'RANKED' | 'SCORED' })
|
onChange={(e) => setFormData({ ...formData, criteriaText: e.target.value })}
|
||||||
}
|
placeholder="Describe the criteria in plain language. AI will interpret this to evaluate project eligibility."
|
||||||
>
|
rows={4}
|
||||||
<SelectTrigger id="scoringMode">
|
/>
|
||||||
<SelectValue />
|
<p className="text-xs text-muted-foreground">
|
||||||
</SelectTrigger>
|
This text will be used by AI to determine which projects are eligible for this award.
|
||||||
<SelectContent>
|
</p>
|
||||||
<SelectItem value="PICK_WINNER">Pick Winner</SelectItem>
|
|
||||||
<SelectItem value="RANKED">Ranked</SelectItem>
|
|
||||||
<SelectItem value="SCORED">Scored</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center space-x-2">
|
<div className="flex items-center space-x-2">
|
||||||
@@ -144,6 +144,41 @@ export default function NewAwardPage({ params: paramsPromise }: { params: Promis
|
|||||||
</Label>
|
</Label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="scoringMode">Scoring Mode</Label>
|
||||||
|
<Select
|
||||||
|
value={formData.scoringMode}
|
||||||
|
onValueChange={(value) =>
|
||||||
|
setFormData({ ...formData, scoringMode: value as 'PICK_WINNER' | 'RANKED' | 'SCORED' })
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SelectTrigger id="scoringMode">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="PICK_WINNER">Pick Winner — Each juror picks 1</SelectItem>
|
||||||
|
<SelectItem value="RANKED">Ranked — Each juror ranks top N</SelectItem>
|
||||||
|
<SelectItem value="SCORED">Scored — Use evaluation form</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{formData.scoringMode === 'RANKED' && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="maxPicks">Max Ranked Picks</Label>
|
||||||
|
<Input
|
||||||
|
id="maxPicks"
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
max="20"
|
||||||
|
value={formData.maxRankedPicks}
|
||||||
|
onChange={(e) => setFormData({ ...formData, maxRankedPicks: e.target.value })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-col-reverse gap-3 sm:flex-row sm:justify-end">
|
<div className="flex flex-col-reverse gap-3 sm:flex-row sm:justify-end">
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@@ -13,16 +13,34 @@ import type { Route } from 'next';
|
|||||||
export default function AwardsPage({ params: paramsPromise }: { params: Promise<{ competitionId: string }> }) {
|
export default function AwardsPage({ params: paramsPromise }: { params: Promise<{ competitionId: string }> }) {
|
||||||
const params = use(paramsPromise);
|
const params = use(paramsPromise);
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { data: competition } = trpc.competition.getById.useQuery({
|
const { data: competition, isError: isCompError } = trpc.competition.getById.useQuery({
|
||||||
id: params.competitionId
|
id: params.competitionId
|
||||||
});
|
});
|
||||||
|
|
||||||
const { data: awards, isLoading } = trpc.specialAward.list.useQuery({
|
const { data: awards, isLoading, isError: isAwardsError } = trpc.specialAward.list.useQuery({
|
||||||
programId: competition?.programId
|
programId: competition?.programId
|
||||||
}, {
|
}, {
|
||||||
enabled: !!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) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { use } from 'react';
|
import { use, useMemo } from 'react';
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
import { trpc } from '@/lib/trpc/client';
|
import { trpc } from '@/lib/trpc/client';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
@@ -12,6 +12,30 @@ import { toast } from 'sonner';
|
|||||||
import { ResultsPanel } from '@/components/admin/deliberation/results-panel';
|
import { ResultsPanel } from '@/components/admin/deliberation/results-panel';
|
||||||
import type { Route } from 'next';
|
import type { Route } from 'next';
|
||||||
|
|
||||||
|
const STATUS_LABELS: Record<string, string> = {
|
||||||
|
DELIB_OPEN: 'Open',
|
||||||
|
VOTING: 'Voting',
|
||||||
|
TALLYING: 'Tallying',
|
||||||
|
RUNOFF: 'Runoff',
|
||||||
|
DELIB_LOCKED: 'Locked',
|
||||||
|
};
|
||||||
|
const STATUS_VARIANTS: Record<string, 'default' | 'secondary' | 'destructive' | 'outline'> = {
|
||||||
|
DELIB_OPEN: 'outline',
|
||||||
|
VOTING: 'default',
|
||||||
|
TALLYING: 'secondary',
|
||||||
|
RUNOFF: 'secondary',
|
||||||
|
DELIB_LOCKED: 'secondary',
|
||||||
|
};
|
||||||
|
const CATEGORY_LABELS: Record<string, string> = {
|
||||||
|
STARTUP: 'Startup',
|
||||||
|
BUSINESS_CONCEPT: 'Business Concept',
|
||||||
|
};
|
||||||
|
const TIE_BREAK_LABELS: Record<string, string> = {
|
||||||
|
TIE_RUNOFF: 'Runoff Vote',
|
||||||
|
TIE_ADMIN_DECIDES: 'Admin Decides',
|
||||||
|
SCORE_FALLBACK: 'Score Fallback',
|
||||||
|
};
|
||||||
|
|
||||||
export default function DeliberationSessionPage({
|
export default function DeliberationSessionPage({
|
||||||
params: paramsPromise
|
params: paramsPromise
|
||||||
}: {
|
}: {
|
||||||
@@ -21,9 +45,10 @@ export default function DeliberationSessionPage({
|
|||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const utils = trpc.useUtils();
|
const utils = trpc.useUtils();
|
||||||
|
|
||||||
const { data: session, isLoading } = trpc.deliberation.getSession.useQuery({
|
const { data: session, isLoading } = trpc.deliberation.getSession.useQuery(
|
||||||
sessionId: params.sessionId
|
{ sessionId: params.sessionId },
|
||||||
});
|
{ refetchInterval: 10_000 }
|
||||||
|
);
|
||||||
|
|
||||||
const openVotingMutation = trpc.deliberation.openVoting.useMutation({
|
const openVotingMutation = trpc.deliberation.openVoting.useMutation({
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
@@ -45,6 +70,12 @@ export default function DeliberationSessionPage({
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Derive which participants have voted from the votes array
|
||||||
|
const voterUserIds = useMemo(() => {
|
||||||
|
if (!session?.votes) return new Set<string>();
|
||||||
|
return new Set(session.votes.map((v: any) => v.juryMember?.user?.id).filter(Boolean));
|
||||||
|
}, [session?.votes]);
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
@@ -90,10 +121,10 @@ export default function DeliberationSessionPage({
|
|||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<h1 className="text-3xl font-bold">Deliberation Session</h1>
|
<h1 className="text-3xl font-bold">Deliberation Session</h1>
|
||||||
<Badge>{session.status}</Badge>
|
<Badge variant={STATUS_VARIANTS[session.status] ?? 'outline'}>{STATUS_LABELS[session.status] ?? session.status}</Badge>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-muted-foreground">
|
<p className="text-muted-foreground">
|
||||||
{session.round?.name} - {session.category}
|
{session.round?.name} - {CATEGORY_LABELS[session.category] ?? session.category}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -121,7 +152,7 @@ export default function DeliberationSessionPage({
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm font-medium text-muted-foreground">Tie Break Method</p>
|
<p className="text-sm font-medium text-muted-foreground">Tie Break Method</p>
|
||||||
<p className="mt-1">{session.tieBreakMethod}</p>
|
<p className="mt-1">{TIE_BREAK_LABELS[session.tieBreakMethod] ?? session.tieBreakMethod}</p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm font-medium text-muted-foreground">
|
<p className="text-sm font-medium text-muted-foreground">
|
||||||
@@ -149,11 +180,11 @@ export default function DeliberationSessionPage({
|
|||||||
className="flex items-center justify-between rounded-lg border p-3"
|
className="flex items-center justify-between rounded-lg border p-3"
|
||||||
>
|
>
|
||||||
<div>
|
<div>
|
||||||
<p className="font-medium">{participant.user?.name}</p>
|
<p className="font-medium">{participant.user?.user?.name ?? 'Unknown'}</p>
|
||||||
<p className="text-sm text-muted-foreground">{participant.user?.email}</p>
|
<p className="text-sm text-muted-foreground">{participant.user?.user?.email}</p>
|
||||||
</div>
|
</div>
|
||||||
<Badge variant={participant.hasVoted ? 'default' : 'outline'}>
|
<Badge variant={voterUserIds.has(participant.user?.user?.id) ? 'default' : 'outline'}>
|
||||||
{participant.hasVoted ? 'Voted' : 'Pending'}
|
{voterUserIds.has(participant.user?.user?.id) ? 'Voted' : 'Pending'}
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
@@ -183,7 +214,7 @@ export default function DeliberationSessionPage({
|
|||||||
variant="destructive"
|
variant="destructive"
|
||||||
onClick={() => closeVotingMutation.mutate({ sessionId: params.sessionId })}
|
onClick={() => closeVotingMutation.mutate({ sessionId: params.sessionId })}
|
||||||
disabled={
|
disabled={
|
||||||
closeVotingMutation.isPending || session.status !== 'DELIB_VOTING'
|
closeVotingMutation.isPending || session.status !== 'VOTING'
|
||||||
}
|
}
|
||||||
className="flex-1"
|
className="flex-1"
|
||||||
>
|
>
|
||||||
@@ -204,9 +235,9 @@ export default function DeliberationSessionPage({
|
|||||||
key={participant.id}
|
key={participant.id}
|
||||||
className="flex items-center justify-between rounded-lg border p-3"
|
className="flex items-center justify-between rounded-lg border p-3"
|
||||||
>
|
>
|
||||||
<span>{participant.user?.name}</span>
|
<span>{participant.user?.user?.name ?? 'Unknown'}</span>
|
||||||
<Badge variant={participant.hasVoted ? 'default' : 'secondary'}>
|
<Badge variant={voterUserIds.has(participant.user?.user?.id) ? 'default' : 'secondary'}>
|
||||||
{participant.hasVoted ? 'Submitted' : 'Not Voted'}
|
{voterUserIds.has(participant.user?.user?.id) ? 'Submitted' : 'Not Voted'}
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ export default function DeliberationListPage({
|
|||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const utils = trpc.useUtils();
|
const utils = trpc.useUtils();
|
||||||
const [createDialogOpen, setCreateDialogOpen] = useState(false);
|
const [createDialogOpen, setCreateDialogOpen] = useState(false);
|
||||||
|
const [selectedJuryGroupId, setSelectedJuryGroupId] = useState('');
|
||||||
const [formData, setFormData] = useState({
|
const [formData, setFormData] = useState({
|
||||||
roundId: '',
|
roundId: '',
|
||||||
category: 'STARTUP' as 'STARTUP' | 'BUSINESS_CONCEPT',
|
category: 'STARTUP' as 'STARTUP' | 'BUSINESS_CONCEPT',
|
||||||
@@ -42,20 +43,29 @@ export default function DeliberationListPage({
|
|||||||
participantUserIds: [] as string[]
|
participantUserIds: [] as string[]
|
||||||
});
|
});
|
||||||
|
|
||||||
const { data: sessions = [], isLoading } = trpc.deliberation.listSessions.useQuery(
|
const { data: sessions = [], isLoading, isError: isSessionsError } = trpc.deliberation.listSessions.useQuery(
|
||||||
{ competitionId: params.competitionId },
|
{ competitionId: params.competitionId },
|
||||||
{ enabled: !!params.competitionId }
|
{ enabled: !!params.competitionId }
|
||||||
);
|
);
|
||||||
|
|
||||||
// Get rounds for this competition
|
// Get rounds for this competition
|
||||||
const { data: competition } = trpc.competition.getById.useQuery(
|
const { data: competition, isError: isCompError } = trpc.competition.getById.useQuery(
|
||||||
{ id: params.competitionId },
|
{ id: params.competitionId },
|
||||||
{ enabled: !!params.competitionId }
|
{ enabled: !!params.competitionId }
|
||||||
);
|
);
|
||||||
const rounds = competition?.rounds || [];
|
const rounds = competition?.rounds || [];
|
||||||
|
|
||||||
// TODO: Add getJuryMembers endpoint if needed for participant selection
|
// Jury groups & members for participant selection
|
||||||
const juryMembers: any[] = [];
|
const { data: juryGroups = [] } = trpc.juryGroup.list.useQuery(
|
||||||
|
{ competitionId: params.competitionId },
|
||||||
|
{ enabled: !!params.competitionId }
|
||||||
|
);
|
||||||
|
|
||||||
|
const { data: selectedJuryGroup } = trpc.juryGroup.getById.useQuery(
|
||||||
|
{ id: selectedJuryGroupId },
|
||||||
|
{ enabled: !!selectedJuryGroupId }
|
||||||
|
);
|
||||||
|
const juryMembers = selectedJuryGroup?.members ?? [];
|
||||||
|
|
||||||
const createSessionMutation = trpc.deliberation.createSession.useMutation({
|
const createSessionMutation = trpc.deliberation.createSession.useMutation({
|
||||||
onSuccess: (data) => {
|
onSuccess: (data) => {
|
||||||
@@ -76,6 +86,10 @@ export default function DeliberationListPage({
|
|||||||
toast.error('Please select a round');
|
toast.error('Please select a round');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (formData.participantUserIds.length === 0) {
|
||||||
|
toast.error('Please select at least one participant');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
createSessionMutation.mutate({
|
createSessionMutation.mutate({
|
||||||
competitionId: params.competitionId,
|
competitionId: params.competitionId,
|
||||||
@@ -92,12 +106,38 @@ export default function DeliberationListPage({
|
|||||||
const getStatusBadge = (status: string) => {
|
const getStatusBadge = (status: string) => {
|
||||||
const variants: Record<string, 'default' | 'secondary' | 'destructive' | 'outline'> = {
|
const variants: Record<string, 'default' | 'secondary' | 'destructive' | 'outline'> = {
|
||||||
DELIB_OPEN: 'outline',
|
DELIB_OPEN: 'outline',
|
||||||
DELIB_VOTING: 'default',
|
VOTING: 'default',
|
||||||
DELIB_TALLYING: 'secondary',
|
TALLYING: 'secondary',
|
||||||
DELIB_LOCKED: 'destructive'
|
RUNOFF: 'secondary',
|
||||||
|
DELIB_LOCKED: 'secondary',
|
||||||
};
|
};
|
||||||
return <Badge variant={variants[status] || 'outline'}>{status}</Badge>;
|
const labels: Record<string, string> = {
|
||||||
|
DELIB_OPEN: 'Open',
|
||||||
|
VOTING: 'Voting',
|
||||||
|
TALLYING: 'Tallying',
|
||||||
|
RUNOFF: 'Runoff',
|
||||||
|
DELIB_LOCKED: 'Locked',
|
||||||
};
|
};
|
||||||
|
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) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
@@ -151,7 +191,7 @@ export default function DeliberationListPage({
|
|||||||
<div className="flex items-start justify-between">
|
<div className="flex items-start justify-between">
|
||||||
<div>
|
<div>
|
||||||
<CardTitle>
|
<CardTitle>
|
||||||
{session.round?.name} - {session.category}
|
{session.round?.name} - {session.category === 'BUSINESS_CONCEPT' ? 'Business Concept' : session.category === 'STARTUP' ? 'Startup' : session.category}
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<CardDescription className="mt-1">
|
<CardDescription className="mt-1">
|
||||||
{session.mode === 'SINGLE_WINNER_VOTE' ? 'Single Winner Vote' : 'Full Ranking'}
|
{session.mode === 'SINGLE_WINNER_VOTE' ? 'Single Winner Vote' : 'Full Ranking'}
|
||||||
@@ -164,7 +204,7 @@ export default function DeliberationListPage({
|
|||||||
<div className="flex flex-wrap gap-2 text-sm text-muted-foreground">
|
<div className="flex flex-wrap gap-2 text-sm text-muted-foreground">
|
||||||
<span>{session.participants?.length || 0} participants</span>
|
<span>{session.participants?.length || 0} participants</span>
|
||||||
<span>•</span>
|
<span>•</span>
|
||||||
<span>Tie break: {session.tieBreakMethod}</span>
|
<span>Tie break: {session.tieBreakMethod === 'TIE_RUNOFF' ? 'Runoff Vote' : session.tieBreakMethod === 'TIE_ADMIN_DECIDES' ? 'Admin Decides' : session.tieBreakMethod === 'SCORE_FALLBACK' ? 'Score Fallback' : session.tieBreakMethod}</span>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -273,6 +313,78 @@ export default function DeliberationListPage({
|
|||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Participant Selection */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="juryGroup">Jury Group *</Label>
|
||||||
|
<Select
|
||||||
|
value={selectedJuryGroupId}
|
||||||
|
onValueChange={(value) => {
|
||||||
|
setSelectedJuryGroupId(value);
|
||||||
|
setFormData({ ...formData, participantUserIds: [] });
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SelectTrigger id="juryGroup">
|
||||||
|
<SelectValue placeholder="Select jury group" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{juryGroups.map((group: any) => (
|
||||||
|
<SelectItem key={group.id} value={group.id}>
|
||||||
|
{group.name} ({group._count?.members ?? 0} members)
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{juryMembers.length > 0 && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Label>Participants ({formData.participantUserIds.length}/{juryMembers.length})</Label>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => {
|
||||||
|
const allIds = juryMembers.map((m: any) => m.user.id);
|
||||||
|
const allSelected = allIds.every((id: string) => formData.participantUserIds.includes(id));
|
||||||
|
setFormData({
|
||||||
|
...formData,
|
||||||
|
participantUserIds: allSelected ? [] : allIds,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{juryMembers.every((m: any) => formData.participantUserIds.includes(m.user.id))
|
||||||
|
? 'Deselect All'
|
||||||
|
: 'Select All'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div className="max-h-48 space-y-2 overflow-y-auto rounded-md border p-3">
|
||||||
|
{juryMembers.map((member: any) => (
|
||||||
|
<div key={member.id} className="flex items-center space-x-2">
|
||||||
|
<Checkbox
|
||||||
|
id={`member-${member.user.id}`}
|
||||||
|
checked={formData.participantUserIds.includes(member.user.id)}
|
||||||
|
onCheckedChange={(checked) => {
|
||||||
|
setFormData({
|
||||||
|
...formData,
|
||||||
|
participantUserIds: checked
|
||||||
|
? [...formData.participantUserIds, member.user.id]
|
||||||
|
: formData.participantUserIds.filter((id: string) => id !== member.user.id),
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Label htmlFor={`member-${member.user.id}`} className="flex-1 font-normal">
|
||||||
|
{member.user.name || member.user.email}
|
||||||
|
<span className="ml-2 text-xs text-muted-foreground">
|
||||||
|
{member.role === 'CHAIR' ? 'Chair' : member.role === 'OBSERVER' ? 'Observer' : 'Member'}
|
||||||
|
</span>
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<div className="flex items-center space-x-2">
|
<div className="flex items-center space-x-2">
|
||||||
<Checkbox
|
<Checkbox
|
||||||
|
|||||||
@@ -15,7 +15,10 @@ export default function JuryGroupDetailPage() {
|
|||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const juryGroupId = params.juryGroupId as string
|
const juryGroupId = params.juryGroupId as string
|
||||||
|
|
||||||
const { data: juryGroup, isLoading } = trpc.juryGroup.getById.useQuery({ id: juryGroupId })
|
const { data: juryGroup, isLoading } = trpc.juryGroup.getById.useQuery(
|
||||||
|
{ id: juryGroupId },
|
||||||
|
{ refetchInterval: 30_000 }
|
||||||
|
)
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -40,13 +40,15 @@ import {
|
|||||||
ChevronDown,
|
ChevronDown,
|
||||||
Layers,
|
Layers,
|
||||||
Users,
|
Users,
|
||||||
FileBox,
|
FolderKanban,
|
||||||
ClipboardList,
|
ClipboardList,
|
||||||
Settings,
|
Settings,
|
||||||
MoreHorizontal,
|
MoreHorizontal,
|
||||||
Archive,
|
Archive,
|
||||||
Loader2,
|
Loader2,
|
||||||
Plus,
|
Plus,
|
||||||
|
CalendarDays,
|
||||||
|
Radio,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { CompetitionTimeline } from '@/components/admin/competition/competition-timeline'
|
import { CompetitionTimeline } from '@/components/admin/competition/competition-timeline'
|
||||||
|
|
||||||
@@ -103,9 +105,10 @@ export default function CompetitionDetailPage() {
|
|||||||
roundType: '' as string,
|
roundType: '' as string,
|
||||||
})
|
})
|
||||||
|
|
||||||
const { data: competition, isLoading } = trpc.competition.getById.useQuery({
|
const { data: competition, isLoading } = trpc.competition.getById.useQuery(
|
||||||
id: competitionId,
|
{ id: competitionId },
|
||||||
})
|
{ refetchInterval: 30_000 }
|
||||||
|
)
|
||||||
|
|
||||||
const updateMutation = trpc.competition.update.useMutation({
|
const updateMutation = trpc.competition.update.useMutation({
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
@@ -283,7 +286,7 @@ export default function CompetitionDetailPage() {
|
|||||||
<Layers className="h-4 w-4 text-blue-500" />
|
<Layers className="h-4 w-4 text-blue-500" />
|
||||||
<span className="text-sm font-medium">Rounds</span>
|
<span className="text-sm font-medium">Rounds</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-2xl font-bold mt-1">{competition.rounds.length}</p>
|
<p className="text-2xl font-bold mt-1">{competition.rounds.filter((r: any) => !r.specialAwardId).length}</p>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
<Card>
|
<Card>
|
||||||
@@ -298,10 +301,12 @@ export default function CompetitionDetailPage() {
|
|||||||
<Card>
|
<Card>
|
||||||
<CardContent className="pt-4 pb-3">
|
<CardContent className="pt-4 pb-3">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<FileBox className="h-4 w-4 text-emerald-500" />
|
<FolderKanban className="h-4 w-4 text-emerald-500" />
|
||||||
<span className="text-sm font-medium">Windows</span>
|
<span className="text-sm font-medium">Projects</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-2xl font-bold mt-1">{competition.submissionWindows.length}</p>
|
<p className="text-2xl font-bold mt-1">
|
||||||
|
{(competition as any).distinctProjectCount ?? 0}
|
||||||
|
</p>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
<Card>
|
<Card>
|
||||||
@@ -328,61 +333,127 @@ export default function CompetitionDetailPage() {
|
|||||||
<TabsContent value="overview" className="space-y-6">
|
<TabsContent value="overview" className="space-y-6">
|
||||||
<CompetitionTimeline
|
<CompetitionTimeline
|
||||||
competitionId={competitionId}
|
competitionId={competitionId}
|
||||||
rounds={competition.rounds}
|
rounds={competition.rounds.filter((r: any) => !r.specialAwardId)}
|
||||||
/>
|
/>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
{/* Rounds Tab */}
|
{/* Rounds Tab */}
|
||||||
<TabsContent value="rounds" className="space-y-4">
|
<TabsContent value="rounds" className="space-y-4">
|
||||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||||
<h2 className="text-lg font-semibold">Rounds ({competition.rounds.length})</h2>
|
<h2 className="text-lg font-semibold">Rounds ({competition.rounds.filter((r: any) => !r.specialAwardId).length})</h2>
|
||||||
<Button size="sm" variant="outline" className="w-full sm:w-auto" onClick={() => setAddRoundOpen(true)}>
|
<Button size="sm" variant="outline" className="w-full sm:w-auto" onClick={() => setAddRoundOpen(true)}>
|
||||||
<Plus className="h-4 w-4 mr-1" />
|
<Plus className="h-4 w-4 mr-1" />
|
||||||
Add Round
|
Add Round
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{competition.rounds.length === 0 ? (
|
{competition.rounds.filter((r: any) => !r.specialAwardId).length === 0 ? (
|
||||||
<Card className="border-dashed">
|
<Card className="border-dashed">
|
||||||
<CardContent className="py-8 text-center text-sm text-muted-foreground">
|
<CardContent className="py-8 text-center text-sm text-muted-foreground">
|
||||||
No rounds configured. Add rounds to define the competition flow.
|
No rounds configured. Add rounds to define the competition flow.
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-2">
|
<div className="grid gap-3 md:grid-cols-2 lg:grid-cols-3">
|
||||||
{competition.rounds.map((round, index) => (
|
{competition.rounds.filter((r: any) => !r.specialAwardId).map((round: any, index: number) => {
|
||||||
|
const projectCount = round._count?.projectRoundStates ?? 0
|
||||||
|
const assignmentCount = round._count?.assignments ?? 0
|
||||||
|
const statusLabel = round.status.replace('ROUND_', '')
|
||||||
|
const statusColors: Record<string, string> = {
|
||||||
|
DRAFT: 'bg-gray-100 text-gray-600',
|
||||||
|
ACTIVE: 'bg-emerald-100 text-emerald-700',
|
||||||
|
CLOSED: 'bg-blue-100 text-blue-700',
|
||||||
|
ARCHIVED: 'bg-muted text-muted-foreground',
|
||||||
|
}
|
||||||
|
return (
|
||||||
<Link
|
<Link
|
||||||
key={round.id}
|
key={round.id}
|
||||||
href={`/admin/competitions/${competitionId}/rounds/${round.id}` as Route}
|
href={`/admin/rounds/${round.id}` as Route}
|
||||||
>
|
>
|
||||||
<Card className="hover:shadow-sm transition-shadow cursor-pointer">
|
<Card className="hover:shadow-md transition-shadow cursor-pointer h-full">
|
||||||
<CardContent className="flex items-center gap-3 py-3">
|
<CardContent className="pt-4 pb-3 space-y-3">
|
||||||
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-muted text-sm font-bold shrink-0">
|
{/* Top: number + name + badges */}
|
||||||
|
<div className="flex items-start gap-2.5">
|
||||||
|
<div className="flex h-7 w-7 items-center justify-center rounded-full bg-muted text-xs font-bold shrink-0 mt-0.5">
|
||||||
{index + 1}
|
{index + 1}
|
||||||
</div>
|
</div>
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<p className="text-sm font-medium truncate">{round.name}</p>
|
<p className="text-sm font-semibold truncate">{round.name}</p>
|
||||||
<p className="text-xs text-muted-foreground font-mono">{round.slug}</p>
|
<div className="flex flex-wrap gap-1.5 mt-1">
|
||||||
</div>
|
|
||||||
<Badge
|
<Badge
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
className={cn(
|
className={cn(
|
||||||
'text-[10px] shrink-0',
|
'text-[10px]',
|
||||||
roundTypeColors[round.roundType] ?? 'bg-gray-100 text-gray-700'
|
roundTypeColors[round.roundType] ?? 'bg-gray-100 text-gray-700'
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{round.roundType.replace('_', ' ')}
|
{round.roundType.replace(/_/g, ' ')}
|
||||||
</Badge>
|
</Badge>
|
||||||
<Badge
|
<Badge
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className="text-[10px] shrink-0 hidden sm:inline-flex"
|
className={cn('text-[10px]', statusColors[statusLabel])}
|
||||||
>
|
>
|
||||||
{round.status.replace('ROUND_', '')}
|
{statusLabel}
|
||||||
</Badge>
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Stats row */}
|
||||||
|
<div className="grid grid-cols-2 gap-2 text-xs">
|
||||||
|
<div className="flex items-center gap-1.5 text-muted-foreground">
|
||||||
|
<Layers className="h-3.5 w-3.5" />
|
||||||
|
<span>{projectCount} project{projectCount !== 1 ? 's' : ''}</span>
|
||||||
|
</div>
|
||||||
|
{(round.roundType === 'EVALUATION' || round.roundType === 'FILTERING') && (
|
||||||
|
<div className="flex items-center gap-1.5 text-muted-foreground">
|
||||||
|
<ClipboardList className="h-3.5 w-3.5" />
|
||||||
|
<span>{assignmentCount} assignment{assignmentCount !== 1 ? 's' : ''}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Dates */}
|
||||||
|
{(round.windowOpenAt || round.windowCloseAt) && (
|
||||||
|
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||||
|
<CalendarDays className="h-3.5 w-3.5 shrink-0" />
|
||||||
|
<span>
|
||||||
|
{round.windowOpenAt
|
||||||
|
? new Date(round.windowOpenAt).toLocaleDateString()
|
||||||
|
: '?'}
|
||||||
|
{' \u2014 '}
|
||||||
|
{round.windowCloseAt
|
||||||
|
? new Date(round.windowCloseAt).toLocaleDateString()
|
||||||
|
: '?'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Jury group */}
|
||||||
|
{round.juryGroup && (
|
||||||
|
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||||
|
<Users className="h-3.5 w-3.5 shrink-0" />
|
||||||
|
<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>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</Link>
|
</Link>
|
||||||
))}
|
)
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
@@ -454,9 +525,6 @@ export default function CompetitionDetailPage() {
|
|||||||
<div>
|
<div>
|
||||||
<label className="text-sm font-medium text-muted-foreground">Notifications</label>
|
<label className="text-sm font-medium text-muted-foreground">Notifications</label>
|
||||||
<div className="flex flex-wrap gap-2 mt-1">
|
<div className="flex flex-wrap gap-2 mt-1">
|
||||||
{competition.notifyOnRoundAdvance && (
|
|
||||||
<Badge variant="secondary" className="text-[10px]">Round Advance</Badge>
|
|
||||||
)}
|
|
||||||
{competition.notifyOnDeadlineApproach && (
|
{competition.notifyOnDeadlineApproach && (
|
||||||
<Badge variant="secondary" className="text-[10px]">Deadline Approach</Badge>
|
<Badge variant="secondary" className="text-[10px]">Deadline Approach</Badge>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,178 +0,0 @@
|
|||||||
'use client'
|
|
||||||
|
|
||||||
import { useState } from 'react'
|
|
||||||
import { useParams } from 'next/navigation'
|
|
||||||
import Link from 'next/link'
|
|
||||||
import type { Route } from 'next'
|
|
||||||
import { trpc } from '@/lib/trpc/client'
|
|
||||||
import { toast } from 'sonner'
|
|
||||||
import { Button } from '@/components/ui/button'
|
|
||||||
import { Card, CardContent } from '@/components/ui/card'
|
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
|
||||||
import { Skeleton } from '@/components/ui/skeleton'
|
|
||||||
import { Badge } from '@/components/ui/badge'
|
|
||||||
import { ArrowLeft, Save, Loader2 } from 'lucide-react'
|
|
||||||
import { RoundConfigForm } from '@/components/admin/competition/round-config-form'
|
|
||||||
import { ProjectStatesTable } from '@/components/admin/round/project-states-table'
|
|
||||||
import { SubmissionWindowManager } from '@/components/admin/round/submission-window-manager'
|
|
||||||
|
|
||||||
const roundTypeColors: Record<string, string> = {
|
|
||||||
INTAKE: 'bg-gray-100 text-gray-700',
|
|
||||||
FILTERING: 'bg-amber-100 text-amber-700',
|
|
||||||
EVALUATION: 'bg-blue-100 text-blue-700',
|
|
||||||
SUBMISSION: 'bg-purple-100 text-purple-700',
|
|
||||||
MENTORING: 'bg-teal-100 text-teal-700',
|
|
||||||
LIVE_FINAL: 'bg-red-100 text-red-700',
|
|
||||||
DELIBERATION: 'bg-indigo-100 text-indigo-700',
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function RoundDetailPage() {
|
|
||||||
const params = useParams()
|
|
||||||
const competitionId = params.competitionId as string
|
|
||||||
const roundId = params.roundId as string
|
|
||||||
|
|
||||||
const [config, setConfig] = useState<Record<string, unknown>>({})
|
|
||||||
const [hasChanges, setHasChanges] = useState(false)
|
|
||||||
|
|
||||||
const utils = trpc.useUtils()
|
|
||||||
|
|
||||||
const { data: round, isLoading } = trpc.round.getById.useQuery({ id: roundId })
|
|
||||||
|
|
||||||
// Update local config when round data changes
|
|
||||||
if (round && !hasChanges) {
|
|
||||||
const roundConfig = (round.configJson as Record<string, unknown>) ?? {}
|
|
||||||
if (JSON.stringify(roundConfig) !== JSON.stringify(config)) {
|
|
||||||
setConfig(roundConfig)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const updateMutation = trpc.round.update.useMutation({
|
|
||||||
onSuccess: () => {
|
|
||||||
utils.round.getById.invalidate({ id: roundId })
|
|
||||||
toast.success('Round configuration saved')
|
|
||||||
setHasChanges(false)
|
|
||||||
},
|
|
||||||
onError: (err) => toast.error(err.message),
|
|
||||||
})
|
|
||||||
|
|
||||||
const handleConfigChange = (newConfig: Record<string, unknown>) => {
|
|
||||||
setConfig(newConfig)
|
|
||||||
setHasChanges(true)
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleSave = () => {
|
|
||||||
updateMutation.mutate({
|
|
||||||
id: roundId,
|
|
||||||
configJson: config,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isLoading) {
|
|
||||||
return (
|
|
||||||
<div className="space-y-6">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<Skeleton className="h-8 w-8" />
|
|
||||||
<div>
|
|
||||||
<Skeleton className="h-6 w-48" />
|
|
||||||
<Skeleton className="h-4 w-32 mt-1" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<Skeleton className="h-10 w-full" />
|
|
||||||
<Skeleton className="h-64 w-full" />
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!round) {
|
|
||||||
return (
|
|
||||||
<div className="space-y-6">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<Link href={`/admin/competitions/${competitionId}` as Route}>
|
|
||||||
<Button variant="ghost" size="icon" className="h-8 w-8" aria-label="Back to competition details">
|
|
||||||
<ArrowLeft className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
</Link>
|
|
||||||
<div>
|
|
||||||
<h1 className="text-xl font-bold">Round Not Found</h1>
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
The requested round does not exist
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-6">
|
|
||||||
{/* Header */}
|
|
||||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
|
||||||
<div className="flex items-start gap-3 min-w-0">
|
|
||||||
<Link href={`/admin/competitions/${competitionId}` as Route} className="mt-1 shrink-0">
|
|
||||||
<Button variant="ghost" size="icon" className="h-8 w-8" aria-label="Back to competition details">
|
|
||||||
<ArrowLeft className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
</Link>
|
|
||||||
<div className="min-w-0">
|
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
|
||||||
<h1 className="text-xl font-bold truncate">{round.name}</h1>
|
|
||||||
<Badge
|
|
||||||
variant="secondary"
|
|
||||||
className={roundTypeColors[round.roundType] ?? 'bg-gray-100 text-gray-700'}
|
|
||||||
>
|
|
||||||
{round.roundType.replace('_', ' ')}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
<p className="text-sm text-muted-foreground font-mono">{round.slug}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center gap-2 shrink-0">
|
|
||||||
{hasChanges && (
|
|
||||||
<Button
|
|
||||||
variant="default"
|
|
||||||
size="sm"
|
|
||||||
onClick={handleSave}
|
|
||||||
disabled={updateMutation.isPending}
|
|
||||||
>
|
|
||||||
{updateMutation.isPending ? (
|
|
||||||
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
|
||||||
) : (
|
|
||||||
<Save className="h-4 w-4 mr-2" />
|
|
||||||
)}
|
|
||||||
Save Changes
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Tabs */}
|
|
||||||
<Tabs defaultValue="config" className="space-y-4">
|
|
||||||
<TabsList className="w-full sm:w-auto overflow-x-auto">
|
|
||||||
<TabsTrigger value="config">Configuration</TabsTrigger>
|
|
||||||
<TabsTrigger value="projects">Projects</TabsTrigger>
|
|
||||||
<TabsTrigger value="windows">Submission Windows</TabsTrigger>
|
|
||||||
</TabsList>
|
|
||||||
|
|
||||||
{/* Config Tab */}
|
|
||||||
<TabsContent value="config" className="space-y-4">
|
|
||||||
<RoundConfigForm
|
|
||||||
roundType={round.roundType}
|
|
||||||
config={config}
|
|
||||||
onChange={handleConfigChange}
|
|
||||||
/>
|
|
||||||
</TabsContent>
|
|
||||||
|
|
||||||
{/* Projects Tab */}
|
|
||||||
<TabsContent value="projects" className="space-y-4">
|
|
||||||
<ProjectStatesTable competitionId={competitionId} roundId={roundId} />
|
|
||||||
</TabsContent>
|
|
||||||
|
|
||||||
{/* Submission Windows Tab */}
|
|
||||||
<TabsContent value="windows" className="space-y-4">
|
|
||||||
<SubmissionWindowManager competitionId={competitionId} roundId={roundId} />
|
|
||||||
</TabsContent>
|
|
||||||
</Tabs>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -53,7 +53,7 @@ export default function CompetitionListPage() {
|
|||||||
|
|
||||||
const { data: competitions, isLoading } = trpc.competition.list.useQuery(
|
const { data: competitions, isLoading } = trpc.competition.list.useQuery(
|
||||||
{ programId: programId! },
|
{ programId: programId! },
|
||||||
{ enabled: !!programId }
|
{ enabled: !!programId, refetchInterval: 30_000 }
|
||||||
)
|
)
|
||||||
|
|
||||||
if (!programId) {
|
if (!programId) {
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -33,7 +33,6 @@ import {
|
|||||||
DialogTitle,
|
DialogTitle,
|
||||||
} from '@/components/ui/dialog'
|
} from '@/components/ui/dialog'
|
||||||
import { Textarea } from '@/components/ui/textarea'
|
import { Textarea } from '@/components/ui/textarea'
|
||||||
import { Switch } from '@/components/ui/switch'
|
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||||
import {
|
import {
|
||||||
Table,
|
Table,
|
||||||
@@ -523,11 +522,6 @@ function SettingsForm({ group, onSave, isPending }: SettingsFormProps) {
|
|||||||
name: group.name,
|
name: group.name,
|
||||||
description: group.description || '',
|
description: group.description || '',
|
||||||
defaultMaxAssignments: group.defaultMaxAssignments,
|
defaultMaxAssignments: group.defaultMaxAssignments,
|
||||||
defaultCapMode: group.defaultCapMode,
|
|
||||||
softCapBuffer: group.softCapBuffer,
|
|
||||||
categoryQuotasEnabled: group.categoryQuotasEnabled,
|
|
||||||
allowJurorCapAdjustment: group.allowJurorCapAdjustment,
|
|
||||||
allowJurorRatioAdjustment: group.allowJurorRatioAdjustment,
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const handleSubmit = (e: React.FormEvent) => {
|
const handleSubmit = (e: React.FormEvent) => {
|
||||||
@@ -562,100 +556,21 @@ function SettingsForm({ group, onSave, isPending }: SettingsFormProps) {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid gap-4 sm:grid-cols-2">
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label>Default Max Assignments</Label>
|
<Label>Default Max Assignments</Label>
|
||||||
<Input
|
<Input
|
||||||
type="number"
|
type="number"
|
||||||
min="1"
|
min="1"
|
||||||
|
max="50"
|
||||||
value={formData.defaultMaxAssignments}
|
value={formData.defaultMaxAssignments}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
setFormData({ ...formData, defaultMaxAssignments: parseInt(e.target.value, 10) })
|
setFormData({ ...formData, defaultMaxAssignments: parseInt(e.target.value, 10) || 15 })
|
||||||
}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label>Cap Mode</Label>
|
|
||||||
<Select
|
|
||||||
value={formData.defaultCapMode}
|
|
||||||
onValueChange={(v) => setFormData({ ...formData, defaultCapMode: v })}
|
|
||||||
>
|
|
||||||
<SelectTrigger>
|
|
||||||
<SelectValue />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="HARD">Hard Cap</SelectItem>
|
|
||||||
<SelectItem value="SOFT">Soft Cap</SelectItem>
|
|
||||||
<SelectItem value="NONE">No Cap</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{formData.defaultCapMode === 'SOFT' && (
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label>Soft Cap Buffer</Label>
|
|
||||||
<Input
|
|
||||||
type="number"
|
|
||||||
min="0"
|
|
||||||
value={formData.softCapBuffer}
|
|
||||||
onChange={(e) =>
|
|
||||||
setFormData({ ...formData, softCapBuffer: parseInt(e.target.value, 10) })
|
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
Number of assignments allowed above the cap when in soft mode
|
Suggested cap for new members. Per-member overrides and juror self-service preferences take priority.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="space-y-3 border-t pt-4">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div className="space-y-0.5">
|
|
||||||
<Label>Category Quotas Enabled</Label>
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
Enable category-based assignment quotas
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<Switch
|
|
||||||
checked={formData.categoryQuotasEnabled}
|
|
||||||
onCheckedChange={(checked) =>
|
|
||||||
setFormData({ ...formData, categoryQuotasEnabled: checked })
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div className="space-y-0.5">
|
|
||||||
<Label>Allow Juror Cap Adjustment</Label>
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
Allow jurors to set their own assignment cap during onboarding
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<Switch
|
|
||||||
checked={formData.allowJurorCapAdjustment}
|
|
||||||
onCheckedChange={(checked) =>
|
|
||||||
setFormData({ ...formData, allowJurorCapAdjustment: checked })
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div className="space-y-0.5">
|
|
||||||
<Label>Allow Juror Ratio Adjustment</Label>
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
Allow jurors to set their own startup/concept ratio during onboarding
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<Switch
|
|
||||||
checked={formData.allowJurorRatioAdjustment}
|
|
||||||
onCheckedChange={(checked) =>
|
|
||||||
setFormData({ ...formData, allowJurorRatioAdjustment: checked })
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Button type="submit" disabled={isPending} className="w-full sm:w-auto">
|
<Button type="submit" disabled={isPending} className="w-full sm:w-auto">
|
||||||
{isPending ? (
|
{isPending ? (
|
||||||
|
|||||||
@@ -58,6 +58,7 @@ import {
|
|||||||
export default function MemberDetailPage() {
|
export default function MemberDetailPage() {
|
||||||
const params = useParams()
|
const params = useParams()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
const utils = trpc.useUtils()
|
||||||
const userId = params.id as string
|
const userId = params.id as string
|
||||||
|
|
||||||
const { data: user, isLoading, error, refetch } = trpc.user.get.useQuery({ id: userId })
|
const { data: user, isLoading, error, refetch } = trpc.user.get.useQuery({ id: userId })
|
||||||
@@ -103,6 +104,8 @@ export default function MemberDetailPage() {
|
|||||||
expertiseTags,
|
expertiseTags,
|
||||||
maxAssignments: maxAssignments ? parseInt(maxAssignments) : null,
|
maxAssignments: maxAssignments ? parseInt(maxAssignments) : null,
|
||||||
})
|
})
|
||||||
|
utils.user.get.invalidate({ id: userId })
|
||||||
|
utils.user.list.invalidate()
|
||||||
toast.success('Member updated successfully')
|
toast.success('Member updated successfully')
|
||||||
router.push('/admin/members')
|
router.push('/admin/members')
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -115,6 +118,7 @@ export default function MemberDetailPage() {
|
|||||||
await sendInvitation.mutateAsync({ userId })
|
await sendInvitation.mutateAsync({ userId })
|
||||||
toast.success('Invitation email sent successfully')
|
toast.success('Invitation email sent successfully')
|
||||||
refetch()
|
refetch()
|
||||||
|
utils.user.list.invalidate()
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
toast.error(error instanceof Error ? error.message : 'Failed to send invitation')
|
toast.error(error instanceof Error ? error.message : 'Failed to send invitation')
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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')
|
|
||||||
}
|
|
||||||
@@ -79,7 +79,7 @@ const ROLES = ['JURY_MEMBER', 'MENTOR', 'OBSERVER', 'APPLICANT', 'PROGRAM_ADMIN'
|
|||||||
export default function MessagesPage() {
|
export default function MessagesPage() {
|
||||||
const [recipientType, setRecipientType] = useState<RecipientType>('ALL')
|
const [recipientType, setRecipientType] = useState<RecipientType>('ALL')
|
||||||
const [selectedRole, setSelectedRole] = useState('')
|
const [selectedRole, setSelectedRole] = useState('')
|
||||||
const [roundId, setStageId] = useState('')
|
const [roundId, setRoundId] = useState('')
|
||||||
const [selectedProgramId, setSelectedProgramId] = useState('')
|
const [selectedProgramId, setSelectedProgramId] = useState('')
|
||||||
const [selectedUserId, setSelectedUserId] = useState('')
|
const [selectedUserId, setSelectedUserId] = useState('')
|
||||||
const [subject, setSubject] = useState('')
|
const [subject, setSubject] = useState('')
|
||||||
@@ -104,9 +104,10 @@ export default function MessagesPage() {
|
|||||||
{ enabled: recipientType === 'USER' }
|
{ enabled: recipientType === 'USER' }
|
||||||
)
|
)
|
||||||
|
|
||||||
// Fetch sent messages for history
|
// Fetch sent messages for history (messages sent BY this admin)
|
||||||
const { data: sentMessages, isLoading: loadingSent } = trpc.message.inbox.useQuery(
|
const { data: sentMessages, isLoading: loadingSent } = trpc.message.sent.useQuery(
|
||||||
{ page: 1, pageSize: 50 }
|
{ page: 1, pageSize: 50 },
|
||||||
|
{ refetchInterval: 30_000 }
|
||||||
)
|
)
|
||||||
|
|
||||||
const sendMutation = trpc.message.send.useMutation({
|
const sendMutation = trpc.message.send.useMutation({
|
||||||
@@ -114,7 +115,7 @@ export default function MessagesPage() {
|
|||||||
const count = (data as Record<string, unknown>)?.recipientCount || ''
|
const count = (data as Record<string, unknown>)?.recipientCount || ''
|
||||||
toast.success(`Message sent successfully${count ? ` to ${count} recipients` : ''}`)
|
toast.success(`Message sent successfully${count ? ` to ${count} recipients` : ''}`)
|
||||||
resetForm()
|
resetForm()
|
||||||
utils.message.inbox.invalidate()
|
utils.message.sent.invalidate()
|
||||||
},
|
},
|
||||||
onError: (e) => toast.error(e.message),
|
onError: (e) => toast.error(e.message),
|
||||||
})
|
})
|
||||||
@@ -124,7 +125,7 @@ export default function MessagesPage() {
|
|||||||
setBody('')
|
setBody('')
|
||||||
setSelectedTemplateId('')
|
setSelectedTemplateId('')
|
||||||
setSelectedRole('')
|
setSelectedRole('')
|
||||||
setStageId('')
|
setRoundId('')
|
||||||
setSelectedProgramId('')
|
setSelectedProgramId('')
|
||||||
setSelectedUserId('')
|
setSelectedUserId('')
|
||||||
setIsScheduled(false)
|
setIsScheduled(false)
|
||||||
@@ -218,7 +219,7 @@ export default function MessagesPage() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (recipientType === 'ROUND_JURY' && !roundId) {
|
if (recipientType === 'ROUND_JURY' && !roundId) {
|
||||||
toast.error('Please select a stage')
|
toast.error('Please select a round')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (recipientType === 'PROGRAM_TEAM' && !selectedProgramId) {
|
if (recipientType === 'PROGRAM_TEAM' && !selectedProgramId) {
|
||||||
@@ -295,7 +296,7 @@ export default function MessagesPage() {
|
|||||||
onValueChange={(v) => {
|
onValueChange={(v) => {
|
||||||
setRecipientType(v as RecipientType)
|
setRecipientType(v as RecipientType)
|
||||||
setSelectedRole('')
|
setSelectedRole('')
|
||||||
setStageId('')
|
setRoundId('')
|
||||||
setSelectedProgramId('')
|
setSelectedProgramId('')
|
||||||
setSelectedUserId('')
|
setSelectedUserId('')
|
||||||
}}
|
}}
|
||||||
@@ -334,10 +335,10 @@ export default function MessagesPage() {
|
|||||||
|
|
||||||
{recipientType === 'ROUND_JURY' && (
|
{recipientType === 'ROUND_JURY' && (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label>Select Stage</Label>
|
<Label>Select Round</Label>
|
||||||
<Select value={roundId} onValueChange={setStageId}>
|
<Select value={roundId} onValueChange={setRoundId}>
|
||||||
<SelectTrigger>
|
<SelectTrigger>
|
||||||
<SelectValue placeholder="Choose a stage..." />
|
<SelectValue placeholder="Choose a round..." />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{rounds?.map((round) => (
|
{rounds?.map((round) => (
|
||||||
@@ -564,56 +565,73 @@ export default function MessagesPage() {
|
|||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableHead>Subject</TableHead>
|
<TableHead>Subject</TableHead>
|
||||||
<TableHead className="hidden md:table-cell">From</TableHead>
|
<TableHead className="hidden md:table-cell">Recipients</TableHead>
|
||||||
<TableHead className="hidden md:table-cell">Channel</TableHead>
|
<TableHead className="hidden md:table-cell">Channels</TableHead>
|
||||||
<TableHead className="hidden lg:table-cell">Status</TableHead>
|
<TableHead className="hidden lg:table-cell">Status</TableHead>
|
||||||
<TableHead className="text-right">Date</TableHead>
|
<TableHead className="text-right">Date</TableHead>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{sentMessages.items.map((item: Record<string, unknown>) => {
|
{sentMessages.items.map((msg: any) => {
|
||||||
const msg = item.message as Record<string, unknown> | undefined
|
const channels = (msg.deliveryChannels as string[]) || []
|
||||||
const sender = msg?.sender as Record<string, unknown> | undefined
|
const recipientCount = msg._count?.recipients ?? 0
|
||||||
const channel = String(item.channel || 'EMAIL')
|
const isSent = !!msg.sentAt
|
||||||
const isRead = !!item.isRead
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TableRow key={String(item.id)}>
|
<TableRow key={msg.id}>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<div className="flex items-center gap-2">
|
<span className="font-medium">
|
||||||
{!isRead && (
|
{msg.subject || 'No subject'}
|
||||||
<div className="h-2 w-2 rounded-full bg-primary shrink-0" />
|
|
||||||
)}
|
|
||||||
<span className={isRead ? 'text-muted-foreground' : 'font-medium'}>
|
|
||||||
{String(msg?.subject || 'No subject')}
|
|
||||||
</span>
|
</span>
|
||||||
</div>
|
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="hidden md:table-cell text-sm text-muted-foreground">
|
<TableCell className="hidden md:table-cell text-sm text-muted-foreground">
|
||||||
{String(sender?.name || sender?.email || 'System')}
|
{msg.recipientType === 'ALL'
|
||||||
|
? 'All users'
|
||||||
|
: msg.recipientType === 'ROLE'
|
||||||
|
? `By role`
|
||||||
|
: msg.recipientType === 'ROUND_JURY'
|
||||||
|
? 'Round jury'
|
||||||
|
: msg.recipientType === 'USER'
|
||||||
|
? `${recipientCount || 1} user${recipientCount > 1 ? 's' : ''}`
|
||||||
|
: msg.recipientType}
|
||||||
|
{recipientCount > 0 && ` (${recipientCount})`}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="hidden md:table-cell">
|
<TableCell className="hidden md:table-cell">
|
||||||
|
<div className="flex gap-1">
|
||||||
|
{channels.includes('EMAIL') && (
|
||||||
<Badge variant="outline" className="text-xs">
|
<Badge variant="outline" className="text-xs">
|
||||||
{channel === 'EMAIL' ? (
|
<Mail className="mr-1 h-3 w-3" />Email
|
||||||
<><Mail className="mr-1 h-3 w-3" />Email</>
|
|
||||||
) : (
|
|
||||||
<><Bell className="mr-1 h-3 w-3" />In-App</>
|
|
||||||
)}
|
|
||||||
</Badge>
|
</Badge>
|
||||||
|
)}
|
||||||
|
{channels.includes('IN_APP') && (
|
||||||
|
<Badge variant="outline" className="text-xs">
|
||||||
|
<Bell className="mr-1 h-3 w-3" />In-App
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="hidden lg:table-cell">
|
<TableCell className="hidden lg:table-cell">
|
||||||
{isRead ? (
|
{isSent ? (
|
||||||
<Badge variant="secondary" className="text-xs">
|
<Badge variant="secondary" className="text-xs">
|
||||||
<CheckCircle2 className="mr-1 h-3 w-3" />
|
<CheckCircle2 className="mr-1 h-3 w-3" />
|
||||||
Read
|
Sent
|
||||||
|
</Badge>
|
||||||
|
) : msg.scheduledAt ? (
|
||||||
|
<Badge variant="default" className="text-xs">
|
||||||
|
<Clock className="mr-1 h-3 w-3" />
|
||||||
|
Scheduled
|
||||||
</Badge>
|
</Badge>
|
||||||
) : (
|
) : (
|
||||||
<Badge variant="default" className="text-xs">New</Badge>
|
<Badge variant="outline" className="text-xs">
|
||||||
|
Draft
|
||||||
|
</Badge>
|
||||||
)}
|
)}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="text-right text-sm text-muted-foreground">
|
<TableCell className="text-right text-sm text-muted-foreground">
|
||||||
{msg?.createdAt
|
{msg.sentAt
|
||||||
? formatDate(msg.createdAt as string | Date)
|
? formatDate(msg.sentAt)
|
||||||
|
: msg.scheduledAt
|
||||||
|
? formatDate(msg.scheduledAt)
|
||||||
: ''}
|
: ''}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ export default async function AdminDashboardPage({ searchParams }: PageProps) {
|
|||||||
|
|
||||||
if (!editionId) {
|
if (!editionId) {
|
||||||
const defaultEdition = await prisma.program.findFirst({
|
const defaultEdition = await prisma.program.findFirst({
|
||||||
where: { status: 'ACTIVE' },
|
where: { status: 'ACTIVE', isTest: false },
|
||||||
orderBy: { year: 'desc' },
|
orderBy: { year: 'desc' },
|
||||||
select: { id: true },
|
select: { id: true },
|
||||||
})
|
})
|
||||||
@@ -38,6 +38,7 @@ export default async function AdminDashboardPage({ searchParams }: PageProps) {
|
|||||||
|
|
||||||
if (!editionId) {
|
if (!editionId) {
|
||||||
const anyEdition = await prisma.program.findFirst({
|
const anyEdition = await prisma.program.findFirst({
|
||||||
|
where: { isTest: false },
|
||||||
orderBy: { year: 'desc' },
|
orderBy: { year: 'desc' },
|
||||||
select: { id: true },
|
select: { id: true },
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ import {
|
|||||||
TableHeader,
|
TableHeader,
|
||||||
TableRow,
|
TableRow,
|
||||||
} from '@/components/ui/table'
|
} from '@/components/ui/table'
|
||||||
import { ArrowLeft, Pencil, Plus } from 'lucide-react'
|
import { ArrowLeft, GraduationCap, Pencil, Plus } from 'lucide-react'
|
||||||
import { formatDateOnly } from '@/lib/utils'
|
import { formatDateOnly } from '@/lib/utils'
|
||||||
|
|
||||||
interface ProgramDetailPageProps {
|
interface ProgramDetailPageProps {
|
||||||
@@ -65,6 +65,13 @@ export default async function ProgramDetailPage({ params }: ProgramDetailPagePro
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<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>
|
<Button variant="outline" asChild>
|
||||||
<Link href={`/admin/programs/${id}/edit`}>
|
<Link href={`/admin/programs/${id}/edit`}>
|
||||||
<Pencil className="mr-2 h-4 w-4" />
|
<Pencil className="mr-2 h-4 w-4" />
|
||||||
@@ -72,6 +79,7 @@ export default async function ProgramDetailPage({ params }: ProgramDetailPagePro
|
|||||||
</Link>
|
</Link>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{program.description && (
|
{program.description && (
|
||||||
<Card>
|
<Card>
|
||||||
@@ -108,7 +116,7 @@ export default async function ProgramDetailPage({ params }: ProgramDetailPagePro
|
|||||||
<Table>
|
<Table>
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableHead>Stage</TableHead>
|
<TableHead>Round</TableHead>
|
||||||
<TableHead>Status</TableHead>
|
<TableHead>Status</TableHead>
|
||||||
<TableHead>Projects</TableHead>
|
<TableHead>Projects</TableHead>
|
||||||
<TableHead>Assignments</TableHead>
|
<TableHead>Assignments</TableHead>
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ import { formatDateOnly } from '@/lib/utils'
|
|||||||
|
|
||||||
async function ProgramsContent() {
|
async function ProgramsContent() {
|
||||||
const programs = await prisma.program.findMany({
|
const programs = await prisma.program.findMany({
|
||||||
// Note: PROGRAM_ADMIN filtering should be handled via middleware or a separate relation
|
where: { isTest: false },
|
||||||
include: {
|
include: {
|
||||||
competitions: {
|
competitions: {
|
||||||
include: {
|
include: {
|
||||||
|
|||||||
@@ -21,7 +21,6 @@ import {
|
|||||||
Bot,
|
Bot,
|
||||||
Loader2,
|
Loader2,
|
||||||
Users,
|
Users,
|
||||||
User,
|
|
||||||
Check,
|
Check,
|
||||||
RefreshCw,
|
RefreshCw,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
@@ -338,24 +337,6 @@ function MentorAssignmentContent({ projectId }: { projectId: string }) {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* Manual Assignment */}
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle className="text-lg flex items-center gap-2">
|
|
||||||
<User className="h-5 w-5" />
|
|
||||||
Manual Assignment
|
|
||||||
</CardTitle>
|
|
||||||
<CardDescription>
|
|
||||||
Search and select a mentor manually
|
|
||||||
</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
Use the AI suggestions above or search for a specific user in the Users section
|
|
||||||
to assign them as a mentor manually.
|
|
||||||
</p>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { Suspense, use } from 'react'
|
import { Suspense, use, useState } from 'react'
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
import type { Route } from 'next'
|
import type { Route } from 'next'
|
||||||
import { trpc } from '@/lib/trpc/client'
|
import { trpc } from '@/lib/trpc/client'
|
||||||
@@ -28,6 +28,13 @@ import { FileUpload } from '@/components/shared/file-upload'
|
|||||||
import { ProjectLogoWithUrl } from '@/components/shared/project-logo-with-url'
|
import { ProjectLogoWithUrl } from '@/components/shared/project-logo-with-url'
|
||||||
import { UserAvatar } from '@/components/shared/user-avatar'
|
import { UserAvatar } from '@/components/shared/user-avatar'
|
||||||
import { EvaluationSummaryCard } from '@/components/admin/evaluation-summary-card'
|
import { EvaluationSummaryCard } from '@/components/admin/evaluation-summary-card'
|
||||||
|
import {
|
||||||
|
Sheet,
|
||||||
|
SheetContent,
|
||||||
|
SheetDescription,
|
||||||
|
SheetHeader,
|
||||||
|
SheetTitle,
|
||||||
|
} from '@/components/ui/sheet'
|
||||||
import { AnimatedCard } from '@/components/shared/animated-container'
|
import { AnimatedCard } from '@/components/shared/animated-container'
|
||||||
import {
|
import {
|
||||||
ArrowLeft,
|
ArrowLeft,
|
||||||
@@ -36,9 +43,6 @@ import {
|
|||||||
Users,
|
Users,
|
||||||
FileText,
|
FileText,
|
||||||
Calendar,
|
Calendar,
|
||||||
CheckCircle2,
|
|
||||||
XCircle,
|
|
||||||
Circle,
|
|
||||||
Clock,
|
Clock,
|
||||||
BarChart3,
|
BarChart3,
|
||||||
ThumbsUp,
|
ThumbsUp,
|
||||||
@@ -49,7 +53,12 @@ import {
|
|||||||
Heart,
|
Heart,
|
||||||
Crown,
|
Crown,
|
||||||
UserPlus,
|
UserPlus,
|
||||||
|
Loader2,
|
||||||
|
ScanSearch,
|
||||||
|
Eye,
|
||||||
|
MessageSquare,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
|
import { toast } from 'sonner'
|
||||||
import { formatDate, formatDateOnly } from '@/lib/utils'
|
import { formatDate, formatDateOnly } from '@/lib/utils'
|
||||||
|
|
||||||
interface PageProps {
|
interface PageProps {
|
||||||
@@ -76,9 +85,10 @@ const evalStatusColors: Record<string, 'default' | 'secondary' | 'destructive' |
|
|||||||
|
|
||||||
function ProjectDetailContent({ projectId }: { projectId: string }) {
|
function ProjectDetailContent({ projectId }: { projectId: string }) {
|
||||||
// Fetch project + assignments + stats in a single combined query
|
// Fetch project + assignments + stats in a single combined query
|
||||||
const { data: fullDetail, isLoading } = trpc.project.getFullDetail.useQuery({
|
const { data: fullDetail, isLoading } = trpc.project.getFullDetail.useQuery(
|
||||||
id: projectId,
|
{ id: projectId },
|
||||||
})
|
{ refetchInterval: 30_000 }
|
||||||
|
)
|
||||||
|
|
||||||
const project = fullDetail?.project
|
const project = fullDetail?.project
|
||||||
const assignments = fullDetail?.assignments
|
const assignments = fullDetail?.assignments
|
||||||
@@ -105,16 +115,19 @@ function ProjectDetailContent({ projectId }: { projectId: string }) {
|
|||||||
// Extract all rounds from the competition
|
// Extract all rounds from the competition
|
||||||
const competitionRounds = competition?.rounds || []
|
const competitionRounds = competition?.rounds || []
|
||||||
|
|
||||||
// Fetch requirements for each round
|
// Fetch requirements for all rounds in a single query (avoids dynamic hook violation)
|
||||||
const requirementQueries = competitionRounds.map((round: { id: string; name: string }) =>
|
const roundIds = competitionRounds.map((r: { id: string }) => r.id)
|
||||||
trpc.file.listRequirements.useQuery({ roundId: round.id })
|
const { data: allRequirements = [] } = trpc.file.listRequirementsByRounds.useQuery(
|
||||||
|
{ roundIds },
|
||||||
|
{ enabled: roundIds.length > 0 }
|
||||||
)
|
)
|
||||||
|
|
||||||
// Combine requirements from all rounds
|
|
||||||
const allRequirements = requirementQueries.flatMap((q: { data?: unknown[] }) => q.data || [])
|
|
||||||
|
|
||||||
const utils = trpc.useUtils()
|
const utils = trpc.useUtils()
|
||||||
|
|
||||||
|
// State for evaluation detail sheet
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
const [selectedEvalAssignment, setSelectedEvalAssignment] = useState<any>(null)
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return <ProjectDetailSkeleton />
|
return <ProjectDetailSkeleton />
|
||||||
}
|
}
|
||||||
@@ -530,6 +543,8 @@ function ProjectDetailContent({ projectId }: { projectId: string }) {
|
|||||||
<AnimatedCard index={4}>
|
<AnimatedCard index={4}>
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
<CardTitle className="flex items-center gap-2.5 text-lg">
|
<CardTitle className="flex items-center gap-2.5 text-lg">
|
||||||
<div className="rounded-lg bg-rose-500/10 p-1.5">
|
<div className="rounded-lg bg-rose-500/10 p-1.5">
|
||||||
<FileText className="h-4 w-4 text-rose-500" />
|
<FileText className="h-4 w-4 text-rose-500" />
|
||||||
@@ -539,107 +554,14 @@ function ProjectDetailContent({ projectId }: { projectId: string }) {
|
|||||||
<CardDescription>
|
<CardDescription>
|
||||||
Project documents and materials organized by competition round
|
Project documents and materials organized by competition round
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
|
</div>
|
||||||
|
<AnalyzeDocumentsButton projectId={projectId} onComplete={() => utils.file.listByProject.invalidate({ projectId })} />
|
||||||
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-6">
|
<CardContent className="space-y-6">
|
||||||
{/* Requirements organized by round */}
|
{/* File upload */}
|
||||||
{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 */}
|
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm font-semibold mb-3">
|
<p className="text-sm font-semibold mb-3">Upload Files</p>
|
||||||
{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>
|
|
||||||
<FileUpload
|
<FileUpload
|
||||||
projectId={projectId}
|
projectId={projectId}
|
||||||
availableRounds={competitionRounds?.map((r: any) => ({ id: r.id, name: r.name }))}
|
availableRounds={competitionRounds?.map((r: any) => ({ id: r.id, name: r.name }))}
|
||||||
@@ -653,8 +575,6 @@ function ProjectDetailContent({ projectId }: { projectId: string }) {
|
|||||||
{files && files.length > 0 && (
|
{files && files.length > 0 && (
|
||||||
<>
|
<>
|
||||||
<Separator />
|
<Separator />
|
||||||
<div>
|
|
||||||
<p className="text-sm font-semibold mb-3">All Uploaded Files</p>
|
|
||||||
<FileViewer
|
<FileViewer
|
||||||
projectId={projectId}
|
projectId={projectId}
|
||||||
files={files.map((f) => ({
|
files={files.map((f) => ({
|
||||||
@@ -665,9 +585,20 @@ function ProjectDetailContent({ projectId }: { projectId: string }) {
|
|||||||
size: f.size,
|
size: f.size,
|
||||||
bucket: f.bucket,
|
bucket: f.bucket,
|
||||||
objectKey: f.objectKey,
|
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>
|
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
@@ -709,11 +640,20 @@ function ProjectDetailContent({ projectId }: { projectId: string }) {
|
|||||||
<TableHead>Status</TableHead>
|
<TableHead>Status</TableHead>
|
||||||
<TableHead>Score</TableHead>
|
<TableHead>Score</TableHead>
|
||||||
<TableHead>Decision</TableHead>
|
<TableHead>Decision</TableHead>
|
||||||
|
<TableHead className="w-10"></TableHead>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{assignments.map((assignment) => (
|
{assignments.map((assignment) => (
|
||||||
<TableRow key={assignment.id}>
|
<TableRow
|
||||||
|
key={assignment.id}
|
||||||
|
className={assignment.evaluation?.status === 'SUBMITTED' ? 'cursor-pointer hover:bg-muted/50' : ''}
|
||||||
|
onClick={() => {
|
||||||
|
if (assignment.evaluation?.status === 'SUBMITTED') {
|
||||||
|
setSelectedEvalAssignment(assignment)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<UserAvatar
|
<UserAvatar
|
||||||
@@ -787,6 +727,11 @@ function ProjectDetailContent({ projectId }: { projectId: string }) {
|
|||||||
<span className="text-muted-foreground">-</span>
|
<span className="text-muted-foreground">-</span>
|
||||||
)}
|
)}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{assignment.evaluation?.status === 'SUBMITTED' && (
|
||||||
|
<Eye className="h-4 w-4 text-muted-foreground" />
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
))}
|
))}
|
||||||
</TableBody>
|
</TableBody>
|
||||||
@@ -796,6 +741,13 @@ function ProjectDetailContent({ projectId }: { projectId: string }) {
|
|||||||
</AnimatedCard>
|
</AnimatedCard>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Evaluation Detail Sheet */}
|
||||||
|
<EvaluationDetailSheet
|
||||||
|
assignment={selectedEvalAssignment}
|
||||||
|
open={!!selectedEvalAssignment}
|
||||||
|
onOpenChange={(open) => { if (!open) setSelectedEvalAssignment(null) }}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* AI Evaluation Summary */}
|
{/* AI Evaluation Summary */}
|
||||||
{assignments && assignments.length > 0 && stats && stats.totalEvaluations > 0 && (
|
{assignments && assignments.length > 0 && stats && stats.totalEvaluations > 0 && (
|
||||||
<EvaluationSummaryCard
|
<EvaluationSummaryCard
|
||||||
@@ -848,6 +800,203 @@ function ProjectDetailSkeleton() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function AnalyzeDocumentsButton({ projectId, onComplete }: { projectId: string; onComplete: () => void }) {
|
||||||
|
const analyzeMutation = trpc.file.analyzeProjectFiles.useMutation({
|
||||||
|
onSuccess: (result) => {
|
||||||
|
toast.success(
|
||||||
|
`Analyzed ${result.analyzed} file${result.analyzed !== 1 ? 's' : ''}${result.failed > 0 ? ` (${result.failed} failed)` : ''}`
|
||||||
|
)
|
||||||
|
onComplete()
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
toast.error(error.message || 'Analysis failed')
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => analyzeMutation.mutate({ projectId })}
|
||||||
|
disabled={analyzeMutation.isPending}
|
||||||
|
>
|
||||||
|
{analyzeMutation.isPending ? (
|
||||||
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<ScanSearch className="mr-2 h-4 w-4" />
|
||||||
|
)}
|
||||||
|
{analyzeMutation.isPending ? 'Analyzing...' : 'Analyze Documents'}
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function EvaluationDetailSheet({
|
||||||
|
assignment,
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
}: {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
assignment: any
|
||||||
|
open: boolean
|
||||||
|
onOpenChange: (open: boolean) => void
|
||||||
|
}) {
|
||||||
|
if (!assignment?.evaluation) return null
|
||||||
|
|
||||||
|
const ev = assignment.evaluation
|
||||||
|
const criterionScores = (ev.criterionScoresJson || {}) as Record<string, number | boolean | string>
|
||||||
|
const hasScores = Object.keys(criterionScores).length > 0
|
||||||
|
|
||||||
|
// Try to get the evaluation form for labels
|
||||||
|
const roundId = assignment.roundId as string | undefined
|
||||||
|
const { data: activeForm } = trpc.evaluation.getStageForm.useQuery(
|
||||||
|
{ roundId: roundId ?? '' },
|
||||||
|
{ enabled: !!roundId }
|
||||||
|
)
|
||||||
|
|
||||||
|
// Build label lookup from form criteria
|
||||||
|
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,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Sheet open={open} onOpenChange={onOpenChange}>
|
||||||
|
<SheetContent className="sm:max-w-lg overflow-y-auto">
|
||||||
|
<SheetHeader>
|
||||||
|
<SheetTitle className="flex items-center gap-2">
|
||||||
|
<UserAvatar user={assignment.user} avatarUrl={assignment.user.avatarUrl} size="sm" />
|
||||||
|
{assignment.user.name || assignment.user.email}
|
||||||
|
</SheetTitle>
|
||||||
|
<SheetDescription>
|
||||||
|
{ev.submittedAt
|
||||||
|
? `Submitted ${formatDate(ev.submittedAt)}`
|
||||||
|
: 'Evaluation details'}
|
||||||
|
</SheetDescription>
|
||||||
|
</SheetHeader>
|
||||||
|
|
||||||
|
<div className="space-y-6 mt-6">
|
||||||
|
{/* Global stats */}
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div className="p-3 rounded-lg bg-muted">
|
||||||
|
<p className="text-xs text-muted-foreground">Score</p>
|
||||||
|
<p className="text-2xl font-bold">
|
||||||
|
{ev.globalScore !== null ? `${ev.globalScore}/10` : '-'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="p-3 rounded-lg bg-muted">
|
||||||
|
<p className="text-xs text-muted-foreground">Decision</p>
|
||||||
|
<div className="mt-1">
|
||||||
|
{ev.binaryDecision !== null ? (
|
||||||
|
ev.binaryDecision ? (
|
||||||
|
<div className="flex items-center gap-1.5 text-emerald-600">
|
||||||
|
<ThumbsUp className="h-5 w-5" />
|
||||||
|
<span className="font-semibold">Yes</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex items-center gap-1.5 text-red-600">
|
||||||
|
<ThumbsDown className="h-5 w-5" />
|
||||||
|
<span className="font-semibold">No</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
) : (
|
||||||
|
<span className="text-2xl font-bold">-</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Criterion Scores */}
|
||||||
|
{hasScores && (
|
||||||
|
<div>
|
||||||
|
<h4 className="text-sm font-medium mb-3 flex items-center gap-2">
|
||||||
|
<BarChart3 className="h-4 w-4" />
|
||||||
|
Criterion Scores
|
||||||
|
</h4>
|
||||||
|
<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 p-2.5 rounded-lg border">
|
||||||
|
<span className="text-sm">{label}</span>
|
||||||
|
{value === true ? (
|
||||||
|
<Badge className="bg-emerald-100 text-emerald-700 border-emerald-200" variant="outline">
|
||||||
|
<ThumbsUp className="mr-1 h-3 w-3" />
|
||||||
|
{meta?.trueLabel || 'Yes'}
|
||||||
|
</Badge>
|
||||||
|
) : (
|
||||||
|
<Badge className="bg-red-100 text-red-700 border-red-200" 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">
|
||||||
|
<span className="text-sm font-medium">{label}</span>
|
||||||
|
<div className="text-sm text-muted-foreground p-2.5 rounded-lg border bg-muted/50 whitespace-pre-wrap">
|
||||||
|
{typeof value === 'string' ? value : String(value)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Numeric
|
||||||
|
return (
|
||||||
|
<div key={key} className="flex items-center gap-3 p-2.5 rounded-lg border">
|
||||||
|
<span className="text-sm flex-1 truncate">{label}</span>
|
||||||
|
<div className="flex items-center gap-2 shrink-0">
|
||||||
|
<div className="w-20 h-2 rounded-full bg-muted overflow-hidden">
|
||||||
|
<div
|
||||||
|
className="h-full rounded-full bg-primary"
|
||||||
|
style={{ width: `${(Number(value) / 10) * 100}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<span className="text-sm font-bold tabular-nums w-8 text-right">
|
||||||
|
{typeof value === 'number' ? value : '-'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Feedback Text */}
|
||||||
|
{ev.feedbackText && (
|
||||||
|
<div>
|
||||||
|
<h4 className="text-sm font-medium mb-2 flex items-center gap-2">
|
||||||
|
<MessageSquare className="h-4 w-4" />
|
||||||
|
Feedback
|
||||||
|
</h4>
|
||||||
|
<div className="text-sm text-muted-foreground p-3 rounded-lg border bg-muted/30 whitespace-pre-wrap leading-relaxed">
|
||||||
|
{ev.feedbackText}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</SheetContent>
|
||||||
|
</Sheet>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export default function ProjectDetailPage({ params }: PageProps) {
|
export default function ProjectDetailPage({ params }: PageProps) {
|
||||||
const { id } = use(params)
|
const { id } = use(params)
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useState, useCallback, useRef } from 'react'
|
import { useState, useCallback, useRef, useEffect, useMemo } from 'react'
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
import { trpc } from '@/lib/trpc/client'
|
import { trpc } from '@/lib/trpc/client'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
@@ -46,6 +46,8 @@ import {
|
|||||||
Loader2,
|
Loader2,
|
||||||
FileUp,
|
FileUp,
|
||||||
AlertCircle,
|
AlertCircle,
|
||||||
|
ExternalLink,
|
||||||
|
Trash2,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { cn, formatFileSize } from '@/lib/utils'
|
import { cn, formatFileSize } from '@/lib/utils'
|
||||||
import { Pagination } from '@/components/shared/pagination'
|
import { Pagination } from '@/components/shared/pagination'
|
||||||
@@ -60,7 +62,7 @@ type UploadState = {
|
|||||||
type UploadMap = Record<string, UploadState>
|
type UploadMap = Record<string, UploadState>
|
||||||
|
|
||||||
export default function BulkUploadPage() {
|
export default function BulkUploadPage() {
|
||||||
const [windowId, setWindowId] = useState('')
|
const [roundId, setRoundId] = useState('')
|
||||||
const [search, setSearch] = useState('')
|
const [search, setSearch] = useState('')
|
||||||
const [debouncedSearch, setDebouncedSearch] = useState('')
|
const [debouncedSearch, setDebouncedSearch] = useState('')
|
||||||
const [statusFilter, setStatusFilter] = useState<'all' | 'missing' | 'complete'>('all')
|
const [statusFilter, setStatusFilter] = useState<'all' | 'missing' | 'complete'>('all')
|
||||||
@@ -77,12 +79,13 @@ export default function BulkUploadPage() {
|
|||||||
label: string
|
label: string
|
||||||
mimeTypes: string[]
|
mimeTypes: string[]
|
||||||
required: boolean
|
required: boolean
|
||||||
file: { id: string; fileName: string } | null
|
file: { id: string; fileName: string; bucket: string; objectKey: string } | null
|
||||||
}>
|
}>
|
||||||
} | null>(null)
|
} | null>(null)
|
||||||
const [bulkFiles, setBulkFiles] = useState<Record<string, File | null>>({})
|
const [bulkFiles, setBulkFiles] = useState<Record<string, File | null>>({})
|
||||||
|
|
||||||
const fileInputRefs = useRef<Record<string, HTMLInputElement | null>>({})
|
const fileInputRefs = useRef<Record<string, HTMLInputElement | null>>({})
|
||||||
|
const utils = trpc.useUtils()
|
||||||
|
|
||||||
// Debounce search
|
// Debounce search
|
||||||
const searchTimer = useRef<ReturnType<typeof setTimeout>>(undefined)
|
const searchTimer = useRef<ReturnType<typeof setTimeout>>(undefined)
|
||||||
@@ -96,20 +99,84 @@ export default function BulkUploadPage() {
|
|||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
// Queries
|
// Queries
|
||||||
const { data: windows, isLoading: windowsLoading } = trpc.file.listSubmissionWindows.useQuery()
|
const { data: rounds, isLoading: roundsLoading } = trpc.file.listRoundsForBulkUpload.useQuery()
|
||||||
|
|
||||||
const { data, isLoading, refetch } = trpc.file.listProjectsWithUploadStatus.useQuery(
|
const { data, isLoading, refetch } = trpc.file.listProjectsByRoundRequirements.useQuery(
|
||||||
{
|
{
|
||||||
submissionWindowId: windowId,
|
roundId,
|
||||||
search: debouncedSearch || undefined,
|
search: debouncedSearch || undefined,
|
||||||
status: statusFilter,
|
status: statusFilter,
|
||||||
page,
|
page,
|
||||||
pageSize: perPage,
|
pageSize: perPage,
|
||||||
},
|
},
|
||||||
{ enabled: !!windowId }
|
{ enabled: !!roundId }
|
||||||
)
|
)
|
||||||
|
|
||||||
const uploadMutation = trpc.file.adminUploadForRequirement.useMutation()
|
// Collect all files from current data for existence verification
|
||||||
|
const filesToVerify = useMemo(() => {
|
||||||
|
if (!data?.projects) return []
|
||||||
|
const files: { bucket: string; objectKey: string }[] = []
|
||||||
|
for (const row of data.projects) {
|
||||||
|
for (const req of row.requirements) {
|
||||||
|
if (req.file?.bucket && req.file?.objectKey) {
|
||||||
|
files.push({ bucket: req.file.bucket, objectKey: req.file.objectKey })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return files
|
||||||
|
}, [data])
|
||||||
|
|
||||||
|
// Verify files actually exist in storage
|
||||||
|
const { data: fileExistence } = trpc.file.verifyFilesExist.useQuery(
|
||||||
|
{ files: filesToVerify },
|
||||||
|
{ enabled: filesToVerify.length > 0, staleTime: 30_000 }
|
||||||
|
)
|
||||||
|
|
||||||
|
// Track which files are missing from storage (objectKey → true means missing)
|
||||||
|
const missingFiles = useMemo(() => {
|
||||||
|
if (!fileExistence) return new Set<string>()
|
||||||
|
const missing = new Set<string>()
|
||||||
|
for (const [key, exists] of Object.entries(fileExistence)) {
|
||||||
|
if (!exists) missing.add(key)
|
||||||
|
}
|
||||||
|
return missing
|
||||||
|
}, [fileExistence])
|
||||||
|
|
||||||
|
// Open file in new tab via presigned URL
|
||||||
|
const handleViewFile = useCallback(
|
||||||
|
async (bucket: string, objectKey: string) => {
|
||||||
|
try {
|
||||||
|
const { url } = await utils.file.getDownloadUrl.fetch({ bucket, objectKey })
|
||||||
|
window.open(url, '_blank')
|
||||||
|
} catch {
|
||||||
|
toast.error('Failed to open file. It may have been deleted from storage.')
|
||||||
|
refetch()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[utils, refetch]
|
||||||
|
)
|
||||||
|
|
||||||
|
// Delete a file
|
||||||
|
const deleteMutation = trpc.file.delete.useMutation({
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success('File removed')
|
||||||
|
refetch()
|
||||||
|
},
|
||||||
|
onError: (err) => {
|
||||||
|
toast.error(`Failed to remove file: ${err.message}`)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const handleDeleteFile = useCallback(
|
||||||
|
(fileId: string) => {
|
||||||
|
if (confirm('Remove this uploaded file?')) {
|
||||||
|
deleteMutation.mutate({ id: fileId })
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[deleteMutation]
|
||||||
|
)
|
||||||
|
|
||||||
|
const uploadMutation = trpc.file.adminUploadForRoundRequirement.useMutation()
|
||||||
|
|
||||||
// Upload a single file for a project requirement
|
// Upload a single file for a project requirement
|
||||||
const uploadFileForRequirement = useCallback(
|
const uploadFileForRequirement = useCallback(
|
||||||
@@ -117,7 +184,7 @@ export default function BulkUploadPage() {
|
|||||||
projectId: string,
|
projectId: string,
|
||||||
requirementId: string,
|
requirementId: string,
|
||||||
file: File,
|
file: File,
|
||||||
submissionWindowId: string
|
targetRoundId: string
|
||||||
) => {
|
) => {
|
||||||
const key = `${projectId}:${requirementId}`
|
const key = `${projectId}:${requirementId}`
|
||||||
setUploads((prev) => ({
|
setUploads((prev) => ({
|
||||||
@@ -131,8 +198,8 @@ export default function BulkUploadPage() {
|
|||||||
fileName: file.name,
|
fileName: file.name,
|
||||||
mimeType: file.type || 'application/octet-stream',
|
mimeType: file.type || 'application/octet-stream',
|
||||||
size: file.size,
|
size: file.size,
|
||||||
submissionWindowId,
|
roundId: targetRoundId,
|
||||||
submissionFileRequirementId: requirementId,
|
requirementId,
|
||||||
})
|
})
|
||||||
|
|
||||||
// XHR upload with progress
|
// XHR upload with progress
|
||||||
@@ -186,18 +253,18 @@ export default function BulkUploadPage() {
|
|||||||
}
|
}
|
||||||
input.onchange = (e) => {
|
input.onchange = (e) => {
|
||||||
const file = (e.target as HTMLInputElement).files?.[0]
|
const file = (e.target as HTMLInputElement).files?.[0]
|
||||||
if (file && windowId) {
|
if (file && roundId) {
|
||||||
uploadFileForRequirement(projectId, requirementId, file, windowId)
|
uploadFileForRequirement(projectId, requirementId, file, roundId)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
input.click()
|
input.click()
|
||||||
},
|
},
|
||||||
[windowId, uploadFileForRequirement]
|
[roundId, uploadFileForRequirement]
|
||||||
)
|
)
|
||||||
|
|
||||||
// Handle bulk row upload
|
// Handle bulk row upload
|
||||||
const handleBulkUploadAll = useCallback(async () => {
|
const handleBulkUploadAll = useCallback(async () => {
|
||||||
if (!bulkProject || !windowId) return
|
if (!bulkProject || !roundId) return
|
||||||
|
|
||||||
const entries = Object.entries(bulkFiles).filter(
|
const entries = Object.entries(bulkFiles).filter(
|
||||||
([, file]) => file !== null
|
([, file]) => file !== null
|
||||||
@@ -211,14 +278,14 @@ export default function BulkUploadPage() {
|
|||||||
// Upload all in parallel
|
// Upload all in parallel
|
||||||
await Promise.allSettled(
|
await Promise.allSettled(
|
||||||
entries.map(([reqId, file]) =>
|
entries.map(([reqId, file]) =>
|
||||||
uploadFileForRequirement(bulkProject.id, reqId, file, windowId)
|
uploadFileForRequirement(bulkProject.id, reqId, file, roundId)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
setBulkProject(null)
|
setBulkProject(null)
|
||||||
setBulkFiles({})
|
setBulkFiles({})
|
||||||
toast.success('Bulk upload complete')
|
toast.success('Bulk upload complete')
|
||||||
}, [bulkProject, bulkFiles, windowId, uploadFileForRequirement])
|
}, [bulkProject, bulkFiles, roundId, uploadFileForRequirement])
|
||||||
|
|
||||||
const progressPercent =
|
const progressPercent =
|
||||||
data && data.totalProjects > 0
|
data && data.totalProjects > 0
|
||||||
@@ -242,32 +309,37 @@ export default function BulkUploadPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Window Selector */}
|
{/* Round Selector */}
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="text-base">Submission Window</CardTitle>
|
<CardTitle className="text-base">Round</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
{windowsLoading ? (
|
{roundsLoading ? (
|
||||||
<Skeleton className="h-10 w-full" />
|
<Skeleton className="h-10 w-full" />
|
||||||
|
) : !rounds || rounds.length === 0 ? (
|
||||||
|
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||||
|
<AlertCircle className="h-4 w-4" />
|
||||||
|
<span>No rounds have file requirements configured. Add file requirements to a round first.</span>
|
||||||
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<Select
|
<Select
|
||||||
value={windowId}
|
value={roundId}
|
||||||
onValueChange={(v) => {
|
onValueChange={(v) => {
|
||||||
setWindowId(v)
|
setRoundId(v)
|
||||||
setPage(1)
|
setPage(1)
|
||||||
setUploads({})
|
setUploads({})
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<SelectTrigger>
|
<SelectTrigger>
|
||||||
<SelectValue placeholder="Select a submission window..." />
|
<SelectValue placeholder="Select a round..." />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{windows?.map((w) => (
|
{rounds.map((r) => (
|
||||||
<SelectItem key={w.id} value={w.id}>
|
<SelectItem key={r.id} value={r.id}>
|
||||||
{w.competition.program.name} {w.competition.program.year} — {w.name}{' '}
|
{r.competition.program.name} {r.competition.program.year} — {r.name}{' '}
|
||||||
({w.fileRequirements.length} requirement
|
({r.fileRequirements.length} requirement
|
||||||
{w.fileRequirements.length !== 1 ? 's' : ''})
|
{r.fileRequirements.length !== 1 ? 's' : ''})
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
))}
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
@@ -276,8 +348,8 @@ export default function BulkUploadPage() {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* Content (only if window selected) */}
|
{/* Content (only if round selected) */}
|
||||||
{windowId && data && (
|
{roundId && data && (
|
||||||
<>
|
<>
|
||||||
{/* Progress Summary */}
|
{/* Progress Summary */}
|
||||||
<Card>
|
<Card>
|
||||||
@@ -385,7 +457,7 @@ export default function BulkUploadPage() {
|
|||||||
<TableBody>
|
<TableBody>
|
||||||
{data.projects.map((row) => {
|
{data.projects.map((row) => {
|
||||||
const missingRequired = row.requirements.filter(
|
const missingRequired = row.requirements.filter(
|
||||||
(r) => r.required && !r.file
|
(r) => r.required && (!r.file || (r.file?.objectKey && missingFiles.has(r.file.objectKey)))
|
||||||
)
|
)
|
||||||
return (
|
return (
|
||||||
<TableRow
|
<TableRow
|
||||||
@@ -441,12 +513,57 @@ export default function BulkUploadPage() {
|
|||||||
Retry
|
Retry
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
) : req.file && req.file.objectKey && missingFiles.has(req.file.objectKey) ? (
|
||||||
|
<div className="flex flex-col items-center gap-1">
|
||||||
|
<AlertCircle className="h-4 w-4 text-amber-500" />
|
||||||
|
<span className="text-[10px] text-amber-600 font-medium">Missing</span>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="h-6 px-2 text-[10px]"
|
||||||
|
onClick={() =>
|
||||||
|
handleCellUpload(
|
||||||
|
row.project.id,
|
||||||
|
req.requirementId,
|
||||||
|
req.mimeTypes
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Re-upload
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
) : req.file || uploadState?.status === 'complete' ? (
|
) : req.file || uploadState?.status === 'complete' ? (
|
||||||
<div className="flex flex-col items-center gap-1">
|
<div className="flex flex-col items-center gap-1">
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
<CheckCircle2 className="h-4 w-4 text-green-600" />
|
<CheckCircle2 className="h-4 w-4 text-green-600" />
|
||||||
|
{req.file && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="text-muted-foreground hover:text-destructive transition-colors cursor-pointer"
|
||||||
|
title="Remove file"
|
||||||
|
onClick={() => handleDeleteFile(req.file!.id)}
|
||||||
|
disabled={deleteMutation.isPending}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-3 w-3" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{req.file?.bucket && req.file?.objectKey ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="text-[10px] text-teal-600 hover:text-teal-800 hover:underline truncate max-w-[120px] flex items-center gap-0.5 cursor-pointer"
|
||||||
|
onClick={() =>
|
||||||
|
handleViewFile(req.file!.bucket, req.file!.objectKey)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{req.file.fileName}
|
||||||
|
<ExternalLink className="h-2.5 w-2.5 shrink-0" />
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
<span className="text-[10px] text-muted-foreground truncate max-w-[120px]">
|
<span className="text-[10px] text-muted-foreground truncate max-w-[120px]">
|
||||||
{req.file?.fileName ?? 'Uploaded'}
|
{req.file?.fileName ?? 'Uploaded'}
|
||||||
</span>
|
</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@@ -92,7 +92,7 @@ function ImportPageContent() {
|
|||||||
Create a competition with rounds before importing projects
|
Create a competition with rounds before importing projects
|
||||||
</p>
|
</p>
|
||||||
<Button asChild className="mt-4">
|
<Button asChild className="mt-4">
|
||||||
<Link href="/admin/competitions">View Competitions</Link>
|
<Link href="/admin/rounds">View Rounds</Link>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -366,8 +366,9 @@ export default function ProjectsPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handleCloseTaggingDialog = () => {
|
const handleCloseTaggingDialog = () => {
|
||||||
if (!taggingInProgress) {
|
|
||||||
setAiTagDialogOpen(false)
|
setAiTagDialogOpen(false)
|
||||||
|
// Only reset job state if not in progress (preserve polling for background jobs)
|
||||||
|
if (!taggingInProgress) {
|
||||||
setActiveTaggingJobId(null)
|
setActiveTaggingJobId(null)
|
||||||
setSelectedRoundForTagging('')
|
setSelectedRoundForTagging('')
|
||||||
setSelectedProgramForTagging('')
|
setSelectedProgramForTagging('')
|
||||||
@@ -618,9 +619,22 @@ export default function ProjectsPage() {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
<Button variant="outline" onClick={() => setAiTagDialogOpen(true)}>
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => setAiTagDialogOpen(true)}
|
||||||
|
className={taggingInProgress ? 'border-amber-400 bg-amber-50 dark:bg-amber-950/20' : ''}
|
||||||
|
>
|
||||||
|
{taggingInProgress ? (
|
||||||
|
<Loader2 className="mr-2 h-4 w-4 animate-spin text-amber-600" />
|
||||||
|
) : (
|
||||||
<Bot className="mr-2 h-4 w-4" />
|
<Bot className="mr-2 h-4 w-4" />
|
||||||
|
)}
|
||||||
AI Tags
|
AI Tags
|
||||||
|
{taggingInProgress && (
|
||||||
|
<span className="ml-1.5 text-[10px] text-amber-600 font-medium">
|
||||||
|
{taggingProgressPercent}%
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="outline" asChild>
|
<Button variant="outline" asChild>
|
||||||
<Link href="/admin/projects/pool">
|
<Link href="/admin/projects/pool">
|
||||||
@@ -697,13 +711,7 @@ export default function ProjectsPage() {
|
|||||||
{data && data.projects.length > 0 && (
|
{data && data.projects.length > 0 && (
|
||||||
<div className="flex items-center justify-between gap-4">
|
<div className="flex items-center justify-between gap-4">
|
||||||
<div className="flex flex-wrap items-center gap-2 text-sm">
|
<div className="flex flex-wrap items-center gap-2 text-sm">
|
||||||
{Object.entries(
|
{Object.entries(data.statusCounts ?? {})
|
||||||
data.projects.reduce<Record<string, number>>((acc, p) => {
|
|
||||||
const s = p.status ?? 'SUBMITTED'
|
|
||||||
acc[s] = (acc[s] || 0) + 1
|
|
||||||
return acc
|
|
||||||
}, {})
|
|
||||||
)
|
|
||||||
.sort(([a], [b]) => {
|
.sort(([a], [b]) => {
|
||||||
const order = ['SUBMITTED', 'ELIGIBLE', 'ASSIGNED', 'SEMIFINALIST', 'FINALIST', 'WINNER', 'REJECTED', 'WITHDRAWN']
|
const order = ['SUBMITTED', 'ELIGIBLE', 'ASSIGNED', 'SEMIFINALIST', 'FINALIST', 'WINNER', 'REJECTED', 'WITHDRAWN']
|
||||||
return order.indexOf(a) - order.indexOf(b)
|
return order.indexOf(a) - order.indexOf(b)
|
||||||
@@ -856,7 +864,7 @@ export default function ProjectsPage() {
|
|||||||
</TableHead>
|
</TableHead>
|
||||||
<TableHead className="min-w-[280px]">Project</TableHead>
|
<TableHead className="min-w-[280px]">Project</TableHead>
|
||||||
<TableHead>Category</TableHead>
|
<TableHead>Category</TableHead>
|
||||||
<TableHead>Stage</TableHead>
|
<TableHead>Program</TableHead>
|
||||||
<TableHead>Tags</TableHead>
|
<TableHead>Tags</TableHead>
|
||||||
<TableHead>Assignments</TableHead>
|
<TableHead>Assignments</TableHead>
|
||||||
<TableHead>Status</TableHead>
|
<TableHead>Status</TableHead>
|
||||||
@@ -899,17 +907,8 @@ export default function ProjectsPage() {
|
|||||||
const code = normalizeCountryToCode(project.country)
|
const code = normalizeCountryToCode(project.country)
|
||||||
const flag = code ? getCountryFlag(code) : null
|
const flag = code ? getCountryFlag(code) : null
|
||||||
const name = code ? getCountryName(code) : project.country
|
const name = code ? getCountryName(code) : project.country
|
||||||
return flag ? (
|
return (
|
||||||
<TooltipProvider delayDuration={200}>
|
<span className="text-xs text-muted-foreground/70"> · {flag && <span className="text-sm">{flag}</span>} {name}</span>
|
||||||
<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>
|
|
||||||
)
|
)
|
||||||
})()}
|
})()}
|
||||||
</p>
|
</p>
|
||||||
@@ -1057,7 +1056,7 @@ export default function ProjectsPage() {
|
|||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-3">
|
<CardContent className="space-y-3">
|
||||||
<div className="flex items-center justify-between text-sm">
|
<div className="flex items-center justify-between text-sm">
|
||||||
<span className="text-muted-foreground">Stage</span>
|
<span className="text-muted-foreground">Program</span>
|
||||||
<span>{project.program?.name ?? 'Unassigned'}</span>
|
<span>{project.program?.name ?? 'Unassigned'}</span>
|
||||||
</div>
|
</div>
|
||||||
{project.competitionCategory && (
|
{project.competitionCategory && (
|
||||||
@@ -1168,17 +1167,8 @@ export default function ProjectsPage() {
|
|||||||
const code = normalizeCountryToCode(project.country)
|
const code = normalizeCountryToCode(project.country)
|
||||||
const flag = code ? getCountryFlag(code) : null
|
const flag = code ? getCountryFlag(code) : null
|
||||||
const name = code ? getCountryName(code) : project.country
|
const name = code ? getCountryName(code) : project.country
|
||||||
return flag ? (
|
return (
|
||||||
<TooltipProvider delayDuration={200}>
|
<span className="text-xs text-muted-foreground/70"> · {flag && <span className="text-sm">{flag}</span>} {name}</span>
|
||||||
<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>
|
|
||||||
)
|
)
|
||||||
})()}
|
})()}
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
@@ -1833,9 +1823,8 @@ export default function ProjectsPage() {
|
|||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={handleCloseTaggingDialog}
|
onClick={handleCloseTaggingDialog}
|
||||||
disabled={taggingInProgress}
|
|
||||||
>
|
>
|
||||||
Cancel
|
{taggingInProgress ? 'Run in Background' : 'Cancel'}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
onClick={handleStartTagging}
|
onClick={handleStartTagging}
|
||||||
|
|||||||
@@ -1,11 +1,14 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useState } from 'react'
|
import { useState, useEffect, useMemo } from 'react'
|
||||||
|
import { useSearchParams } from 'next/navigation'
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
import type { Route } from 'next'
|
import type { Route } from 'next'
|
||||||
import { trpc } from '@/lib/trpc/client'
|
import { trpc } from '@/lib/trpc/client'
|
||||||
|
import { useEdition } from '@/contexts/edition-context'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Checkbox } from '@/components/ui/checkbox'
|
import { Checkbox } from '@/components/ui/checkbox'
|
||||||
|
import { Switch } from '@/components/ui/switch'
|
||||||
import {
|
import {
|
||||||
Select,
|
Select,
|
||||||
SelectContent,
|
SelectContent,
|
||||||
@@ -23,41 +26,87 @@ import {
|
|||||||
} from '@/components/ui/dialog'
|
} from '@/components/ui/dialog'
|
||||||
import { Badge } from '@/components/ui/badge'
|
import { Badge } from '@/components/ui/badge'
|
||||||
import { Input } from '@/components/ui/input'
|
import { Input } from '@/components/ui/input'
|
||||||
import { Card } from '@/components/ui/card'
|
import { Card, CardContent } from '@/components/ui/card'
|
||||||
import { Skeleton } from '@/components/ui/skeleton'
|
import { Skeleton } from '@/components/ui/skeleton'
|
||||||
|
import { getCountryName, getCountryFlag, normalizeCountryToCode } from '@/lib/countries'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
import { ArrowLeft, ChevronLeft, ChevronRight, Loader2 } from 'lucide-react'
|
import { ArrowLeft, ChevronLeft, ChevronRight, Loader2, X, Layers, Info } from 'lucide-react'
|
||||||
|
|
||||||
|
const roundTypeColors: Record<string, string> = {
|
||||||
|
INTAKE: 'bg-gray-100 text-gray-700',
|
||||||
|
FILTERING: 'bg-amber-100 text-amber-700',
|
||||||
|
EVALUATION: 'bg-blue-100 text-blue-700',
|
||||||
|
SUBMISSION: 'bg-purple-100 text-purple-700',
|
||||||
|
MENTORING: 'bg-teal-100 text-teal-700',
|
||||||
|
LIVE_FINAL: 'bg-red-100 text-red-700',
|
||||||
|
DELIBERATION: 'bg-indigo-100 text-indigo-700',
|
||||||
|
}
|
||||||
|
|
||||||
export default function ProjectPoolPage() {
|
export default function ProjectPoolPage() {
|
||||||
const [selectedProgramId, setSelectedProgramId] = useState<string>('')
|
const searchParams = useSearchParams()
|
||||||
|
const { currentEdition, isLoading: editionLoading } = useEdition()
|
||||||
|
|
||||||
|
// URL params for deep-linking context
|
||||||
|
const urlRoundId = searchParams.get('roundId') || ''
|
||||||
|
const urlCompetitionId = searchParams.get('competitionId') || ''
|
||||||
|
|
||||||
|
// Auto-select programId from edition
|
||||||
|
const programId = currentEdition?.id || ''
|
||||||
|
|
||||||
const [selectedProjects, setSelectedProjects] = useState<string[]>([])
|
const [selectedProjects, setSelectedProjects] = useState<string[]>([])
|
||||||
const [assignDialogOpen, setAssignDialogOpen] = useState(false)
|
const [assignDialogOpen, setAssignDialogOpen] = useState(false)
|
||||||
const [assignAllDialogOpen, setAssignAllDialogOpen] = useState(false)
|
const [assignAllDialogOpen, setAssignAllDialogOpen] = useState(false)
|
||||||
const [targetRoundId, setTargetRoundId] = useState<string>('')
|
const [targetRoundId, setTargetRoundId] = useState<string>(urlRoundId)
|
||||||
const [searchQuery, setSearchQuery] = useState('')
|
const [searchQuery, setSearchQuery] = useState('')
|
||||||
const [categoryFilter, setCategoryFilter] = useState<'STARTUP' | 'BUSINESS_CONCEPT' | 'all'>('all')
|
const [categoryFilter, setCategoryFilter] = useState<'STARTUP' | 'BUSINESS_CONCEPT' | 'all'>('all')
|
||||||
|
const [showUnassignedOnly, setShowUnassignedOnly] = useState(false)
|
||||||
const [currentPage, setCurrentPage] = useState(1)
|
const [currentPage, setCurrentPage] = useState(1)
|
||||||
const perPage = 50
|
const perPage = 50
|
||||||
|
|
||||||
const { data: programs } = trpc.program.list.useQuery({ status: 'ACTIVE' })
|
// Pre-select target round from URL param
|
||||||
|
useEffect(() => {
|
||||||
|
if (urlRoundId) setTargetRoundId(urlRoundId)
|
||||||
|
}, [urlRoundId])
|
||||||
|
|
||||||
const { data: poolData, isLoading: isLoadingPool, refetch } = trpc.projectPool.listUnassigned.useQuery(
|
const { data: poolData, isLoading: isLoadingPool, refetch } = trpc.projectPool.listUnassigned.useQuery(
|
||||||
{
|
{
|
||||||
programId: selectedProgramId,
|
programId,
|
||||||
competitionCategory: categoryFilter === 'all' ? undefined : categoryFilter,
|
competitionCategory: categoryFilter === 'all' ? undefined : categoryFilter,
|
||||||
search: searchQuery || undefined,
|
search: searchQuery || undefined,
|
||||||
|
unassignedOnly: showUnassignedOnly,
|
||||||
|
excludeRoundId: urlRoundId || undefined,
|
||||||
page: currentPage,
|
page: currentPage,
|
||||||
perPage,
|
perPage,
|
||||||
},
|
},
|
||||||
{ enabled: !!selectedProgramId }
|
{ enabled: !!programId }
|
||||||
)
|
)
|
||||||
|
|
||||||
// Load rounds from program (program.get returns rounds from all competitions)
|
// Load rounds from program (flattened from all competitions, now with competitionId)
|
||||||
const { data: programData, isLoading: isLoadingRounds } = trpc.program.get.useQuery(
|
const { data: programData, isLoading: isLoadingRounds } = trpc.program.get.useQuery(
|
||||||
{ id: selectedProgramId },
|
{ id: programId },
|
||||||
{ enabled: !!selectedProgramId }
|
{ enabled: !!programId }
|
||||||
)
|
)
|
||||||
const rounds = (programData?.rounds || []) as Array<{ id: string; name: string; roundType: string; sortOrder: number }>
|
|
||||||
|
// Get round name for context banner
|
||||||
|
const allRounds = useMemo(() => {
|
||||||
|
return (programData?.rounds || []) as Array<{
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
competitionId: string
|
||||||
|
status: string
|
||||||
|
_count: { projects: number; assignments: number }
|
||||||
|
}>
|
||||||
|
}, [programData])
|
||||||
|
|
||||||
|
// Filter rounds by competitionId if URL param is set
|
||||||
|
const filteredRounds = useMemo(() => {
|
||||||
|
if (urlCompetitionId) {
|
||||||
|
return allRounds.filter((r) => r.competitionId === urlCompetitionId)
|
||||||
|
}
|
||||||
|
return allRounds
|
||||||
|
}, [allRounds, urlCompetitionId])
|
||||||
|
|
||||||
|
const contextRound = urlRoundId ? allRounds.find((r) => r.id === urlRoundId) : null
|
||||||
|
|
||||||
const utils = trpc.useUtils()
|
const utils = trpc.useUtils()
|
||||||
|
|
||||||
@@ -68,7 +117,7 @@ export default function ProjectPoolPage() {
|
|||||||
toast.success(`Assigned ${result.assignedCount} project${result.assignedCount !== 1 ? 's' : ''} to round`)
|
toast.success(`Assigned ${result.assignedCount} project${result.assignedCount !== 1 ? 's' : ''} to round`)
|
||||||
setSelectedProjects([])
|
setSelectedProjects([])
|
||||||
setAssignDialogOpen(false)
|
setAssignDialogOpen(false)
|
||||||
setTargetRoundId('')
|
setTargetRoundId(urlRoundId)
|
||||||
refetch()
|
refetch()
|
||||||
},
|
},
|
||||||
onError: (error: unknown) => {
|
onError: (error: unknown) => {
|
||||||
@@ -83,7 +132,7 @@ export default function ProjectPoolPage() {
|
|||||||
toast.success(`Assigned all ${result.assignedCount} projects to round`)
|
toast.success(`Assigned all ${result.assignedCount} projects to round`)
|
||||||
setSelectedProjects([])
|
setSelectedProjects([])
|
||||||
setAssignAllDialogOpen(false)
|
setAssignAllDialogOpen(false)
|
||||||
setTargetRoundId('')
|
setTargetRoundId(urlRoundId)
|
||||||
refetch()
|
refetch()
|
||||||
},
|
},
|
||||||
onError: (error: unknown) => {
|
onError: (error: unknown) => {
|
||||||
@@ -102,11 +151,12 @@ export default function ProjectPoolPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handleAssignAll = () => {
|
const handleAssignAll = () => {
|
||||||
if (!targetRoundId || !selectedProgramId) return
|
if (!targetRoundId || !programId) return
|
||||||
assignAllMutation.mutate({
|
assignAllMutation.mutate({
|
||||||
programId: selectedProgramId,
|
programId,
|
||||||
roundId: targetRoundId,
|
roundId: targetRoundId,
|
||||||
competitionCategory: categoryFilter === 'all' ? undefined : categoryFilter,
|
competitionCategory: categoryFilter === 'all' ? undefined : categoryFilter,
|
||||||
|
unassignedOnly: showUnassignedOnly,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -134,6 +184,16 @@ export default function ProjectPoolPage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (editionLoading) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<Skeleton className="h-10 w-64" />
|
||||||
|
<Skeleton className="h-20 w-full" />
|
||||||
|
<Skeleton className="h-96 w-full" />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
@@ -143,37 +203,47 @@ export default function ProjectPoolPage() {
|
|||||||
<ArrowLeft className="h-4 w-4" />
|
<ArrowLeft className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</Link>
|
</Link>
|
||||||
<div>
|
<div className="flex-1">
|
||||||
<h1 className="text-2xl font-semibold">Project Pool</h1>
|
<h1 className="text-2xl font-semibold">Project Pool</h1>
|
||||||
<p className="text-muted-foreground">
|
<p className="text-muted-foreground">
|
||||||
Assign unassigned projects to competition rounds
|
{currentEdition
|
||||||
|
? `${currentEdition.name} ${currentEdition.year} \u2014 ${poolData?.total ?? '...'} projects`
|
||||||
|
: 'No edition selected'}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Context banner when coming from a round */}
|
||||||
|
{contextRound && (
|
||||||
|
<Card className="border-blue-200 bg-blue-50/50">
|
||||||
|
<CardContent className="py-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Info className="h-4 w-4 text-blue-600 shrink-0" />
|
||||||
|
<p className="text-sm">
|
||||||
|
Assigning to <span className="font-semibold">{contextRound.name}</span>
|
||||||
|
{' \u2014 '}
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
projects already in this round are hidden
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Link
|
||||||
|
href={`/admin/rounds/${urlRoundId}` as Route}
|
||||||
|
>
|
||||||
|
<Button variant="outline" size="sm" className="shrink-0">
|
||||||
|
<ArrowLeft className="h-3.5 w-3.5 mr-1" />
|
||||||
|
Back to Round
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Filters */}
|
{/* Filters */}
|
||||||
<Card className="p-4">
|
<Card className="p-4">
|
||||||
<div className="flex flex-col gap-4 md:flex-row md:items-end">
|
<div className="flex flex-col gap-4 md:flex-row md:items-end">
|
||||||
<div className="flex-1 space-y-2">
|
|
||||||
<label className="text-sm font-medium">Program</label>
|
|
||||||
<Select value={selectedProgramId} onValueChange={(value) => {
|
|
||||||
setSelectedProgramId(value)
|
|
||||||
setSelectedProjects([])
|
|
||||||
setCurrentPage(1)
|
|
||||||
}}>
|
|
||||||
<SelectTrigger>
|
|
||||||
<SelectValue placeholder="Select program..." />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
{programs?.map((program) => (
|
|
||||||
<SelectItem key={program.id} value={program.id}>
|
|
||||||
{program.name} {program.year}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex-1 space-y-2">
|
<div className="flex-1 space-y-2">
|
||||||
<label className="text-sm font-medium">Category</label>
|
<label className="text-sm font-medium">Category</label>
|
||||||
<Select value={categoryFilter} onValueChange={(value: string) => {
|
<Select value={categoryFilter} onValueChange={(value: string) => {
|
||||||
@@ -202,14 +272,29 @@ export default function ProjectPoolPage() {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2 pb-0.5">
|
||||||
|
<Switch
|
||||||
|
id="unassigned-only"
|
||||||
|
checked={showUnassignedOnly}
|
||||||
|
onCheckedChange={(checked) => {
|
||||||
|
setShowUnassignedOnly(checked)
|
||||||
|
setCurrentPage(1)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<label htmlFor="unassigned-only" className="text-sm font-medium cursor-pointer whitespace-nowrap">
|
||||||
|
Unassigned only
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* Action bar */}
|
{/* Action bar */}
|
||||||
{selectedProgramId && poolData && poolData.total > 0 && (
|
{programId && poolData && poolData.total > 0 && (
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between flex-wrap gap-2">
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
<span className="font-medium text-foreground">{poolData.total}</span> unassigned project{poolData.total !== 1 ? 's' : ''}
|
<span className="font-medium text-foreground">{poolData.total}</span> project{poolData.total !== 1 ? 's' : ''}
|
||||||
|
{showUnassignedOnly && ' (unassigned only)'}
|
||||||
</p>
|
</p>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
{selectedProjects.length > 0 && (
|
{selectedProjects.length > 0 && (
|
||||||
@@ -229,7 +314,7 @@ export default function ProjectPoolPage() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Projects Table */}
|
{/* Projects Table */}
|
||||||
{selectedProgramId && (
|
{programId ? (
|
||||||
<>
|
<>
|
||||||
{isLoadingPool ? (
|
{isLoadingPool ? (
|
||||||
<Card className="p-4">
|
<Card className="p-4">
|
||||||
@@ -246,7 +331,7 @@ export default function ProjectPoolPage() {
|
|||||||
<table className="w-full">
|
<table className="w-full">
|
||||||
<thead className="border-b">
|
<thead className="border-b">
|
||||||
<tr className="text-sm">
|
<tr className="text-sm">
|
||||||
<th className="p-3 text-left">
|
<th className="p-3 text-left w-[40px]">
|
||||||
<Checkbox
|
<Checkbox
|
||||||
checked={poolData.projects.length > 0 && selectedProjects.length === poolData.projects.length}
|
checked={poolData.projects.length > 0 && selectedProjects.length === poolData.projects.length}
|
||||||
onCheckedChange={toggleSelectAll}
|
onCheckedChange={toggleSelectAll}
|
||||||
@@ -254,6 +339,7 @@ export default function ProjectPoolPage() {
|
|||||||
</th>
|
</th>
|
||||||
<th className="p-3 text-left font-medium">Project</th>
|
<th className="p-3 text-left font-medium">Project</th>
|
||||||
<th className="p-3 text-left font-medium">Category</th>
|
<th className="p-3 text-left font-medium">Category</th>
|
||||||
|
<th className="p-3 text-left font-medium">Rounds</th>
|
||||||
<th className="p-3 text-left font-medium">Country</th>
|
<th className="p-3 text-left font-medium">Country</th>
|
||||||
<th className="p-3 text-left font-medium">Submitted</th>
|
<th className="p-3 text-left font-medium">Submitted</th>
|
||||||
<th className="p-3 text-left font-medium">Quick Assign</th>
|
<th className="p-3 text-left font-medium">Quick Assign</th>
|
||||||
@@ -279,13 +365,35 @@ export default function ProjectPoolPage() {
|
|||||||
</td>
|
</td>
|
||||||
<td className="p-3">
|
<td className="p-3">
|
||||||
{project.competitionCategory && (
|
{project.competitionCategory && (
|
||||||
<Badge variant="outline">
|
<Badge variant="outline" className="text-xs">
|
||||||
{project.competitionCategory === 'STARTUP' ? 'Startup' : 'Business Concept'}
|
{project.competitionCategory === 'STARTUP' ? 'Startup' : 'Business Concept'}
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
</td>
|
</td>
|
||||||
|
<td className="p-3">
|
||||||
|
{(project as any).projectRoundStates?.length > 0 ? (
|
||||||
|
<div className="flex flex-wrap gap-1">
|
||||||
|
{(project as any).projectRoundStates.map((prs: any) => (
|
||||||
|
<Badge
|
||||||
|
key={prs.roundId}
|
||||||
|
variant="secondary"
|
||||||
|
className={`text-[10px] ${roundTypeColors[prs.round?.roundType] || 'bg-gray-100 text-gray-700'}`}
|
||||||
|
>
|
||||||
|
{prs.round?.name || 'Round'}
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<span className="text-xs text-muted-foreground">None</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
<td className="p-3 text-sm text-muted-foreground">
|
<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>
|
||||||
<td className="p-3 text-sm text-muted-foreground">
|
<td className="p-3 text-sm text-muted-foreground">
|
||||||
{project.submittedAt
|
{project.submittedAt
|
||||||
@@ -304,7 +412,7 @@ export default function ProjectPoolPage() {
|
|||||||
<SelectValue placeholder="Assign to round..." />
|
<SelectValue placeholder="Assign to round..." />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{rounds.map((round) => (
|
{filteredRounds.map((round) => (
|
||||||
<SelectItem key={round.id} value={round.id}>
|
<SelectItem key={round.id} value={round.id}>
|
||||||
{round.name}
|
{round.name}
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
@@ -351,15 +459,22 @@ export default function ProjectPoolPage() {
|
|||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<Card className="p-8 text-center text-muted-foreground">
|
<Card className="p-8 text-center text-muted-foreground">
|
||||||
No unassigned projects found for this program
|
<div className="flex flex-col items-center gap-3">
|
||||||
|
<Layers className="h-8 w-8 text-muted-foreground/50" />
|
||||||
|
<p>
|
||||||
|
{showUnassignedOnly
|
||||||
|
? 'No unassigned projects found'
|
||||||
|
: urlRoundId
|
||||||
|
? 'All projects are already assigned to this round'
|
||||||
|
: 'No projects found for this program'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
) : (
|
||||||
|
|
||||||
{!selectedProgramId && (
|
|
||||||
<Card className="p-8 text-center text-muted-foreground">
|
<Card className="p-8 text-center text-muted-foreground">
|
||||||
Select a program to view unassigned projects
|
No edition selected. Please select an edition from the sidebar.
|
||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -378,7 +493,7 @@ export default function ProjectPoolPage() {
|
|||||||
<SelectValue placeholder="Select round..." />
|
<SelectValue placeholder="Select round..." />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{rounds.map((round) => (
|
{filteredRounds.map((round) => (
|
||||||
<SelectItem key={round.id} value={round.id}>
|
<SelectItem key={round.id} value={round.id}>
|
||||||
{round.name}
|
{round.name}
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
@@ -405,9 +520,9 @@ export default function ProjectPoolPage() {
|
|||||||
<Dialog open={assignAllDialogOpen} onOpenChange={setAssignAllDialogOpen}>
|
<Dialog open={assignAllDialogOpen} onOpenChange={setAssignAllDialogOpen}>
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Assign All Unassigned Projects</DialogTitle>
|
<DialogTitle>Assign All Projects</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
This will assign all {poolData?.total || 0}{categoryFilter !== 'all' ? ` ${categoryFilter === 'STARTUP' ? 'Startup' : 'Business Concept'}` : ''} unassigned projects to a round in one operation.
|
This will assign all {poolData?.total || 0}{categoryFilter !== 'all' ? ` ${categoryFilter === 'STARTUP' ? 'Startup' : 'Business Concept'}` : ''}{showUnassignedOnly ? ' unassigned' : ''} projects to a round in one operation.
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<div className="space-y-4 py-4">
|
<div className="space-y-4 py-4">
|
||||||
@@ -416,7 +531,7 @@ export default function ProjectPoolPage() {
|
|||||||
<SelectValue placeholder="Select round..." />
|
<SelectValue placeholder="Select round..." />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{rounds.map((round) => (
|
{filteredRounds.map((round) => (
|
||||||
<SelectItem key={round.id} value={round.id}>
|
<SelectItem key={round.id} value={round.id}>
|
||||||
{round.name}
|
{round.name}
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useState } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
import type { Route } from 'next'
|
|
||||||
import { trpc } from '@/lib/trpc/client'
|
import { trpc } from '@/lib/trpc/client'
|
||||||
import {
|
import {
|
||||||
Card,
|
Card,
|
||||||
@@ -72,9 +71,11 @@ function ReportsOverview() {
|
|||||||
// Project reporting scope (default: latest program, all rounds)
|
// Project reporting scope (default: latest program, all rounds)
|
||||||
const [selectedValue, setSelectedValue] = useState<string | null>(null)
|
const [selectedValue, setSelectedValue] = useState<string | null>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
if (programs?.length && !selectedValue) {
|
if (programs?.length && !selectedValue) {
|
||||||
setSelectedValue(`all:${programs[0].id}`)
|
setSelectedValue(`all:${programs[0].id}`)
|
||||||
}
|
}
|
||||||
|
}, [programs, selectedValue])
|
||||||
|
|
||||||
const scopeInput = parseSelection(selectedValue)
|
const scopeInput = parseSelection(selectedValue)
|
||||||
const hasScope = !!scopeInput.roundId || !!scopeInput.programId
|
const hasScope = !!scopeInput.roundId || !!scopeInput.programId
|
||||||
@@ -110,7 +111,7 @@ function ReportsOverview() {
|
|||||||
const activeRounds = dashStats?.activeRoundCount ?? rounds.filter((r: { status: string }) => r.status === 'ROUND_ACTIVE').length
|
const activeRounds = dashStats?.activeRoundCount ?? rounds.filter((r: { status: string }) => r.status === 'ROUND_ACTIVE').length
|
||||||
const jurorCount = dashStats?.jurorCount ?? 0
|
const jurorCount = dashStats?.jurorCount ?? 0
|
||||||
const submittedEvaluations = dashStats?.submittedEvaluations ?? 0
|
const submittedEvaluations = dashStats?.submittedEvaluations ?? 0
|
||||||
const totalEvaluations = dashStats?.totalEvaluations ?? 0
|
const totalAssignments = dashStats?.totalAssignments ?? 0
|
||||||
const completionRate = dashStats?.completionRate ?? 0
|
const completionRate = dashStats?.completionRate ?? 0
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -178,7 +179,7 @@ function ReportsOverview() {
|
|||||||
<p className="text-sm font-medium text-muted-foreground">Evaluations</p>
|
<p className="text-sm font-medium text-muted-foreground">Evaluations</p>
|
||||||
<p className="text-2xl font-bold mt-1">{submittedEvaluations}</p>
|
<p className="text-2xl font-bold mt-1">{submittedEvaluations}</p>
|
||||||
<p className="text-xs text-muted-foreground mt-1">
|
<p className="text-xs text-muted-foreground mt-1">
|
||||||
{totalEvaluations > 0
|
{totalAssignments > 0
|
||||||
? `${completionRate}% completion rate`
|
? `${completionRate}% completion rate`
|
||||||
: 'No assignments yet'}
|
: 'No assignments yet'}
|
||||||
</p>
|
</p>
|
||||||
@@ -355,14 +356,14 @@ function ReportsOverview() {
|
|||||||
<TableCell>
|
<TableCell>
|
||||||
<Badge
|
<Badge
|
||||||
variant={
|
variant={
|
||||||
round.status === 'ACTIVE'
|
round.status === 'ROUND_ACTIVE'
|
||||||
? 'default'
|
? 'default'
|
||||||
: round.status === 'CLOSED'
|
: round.status === 'ROUND_CLOSED'
|
||||||
? 'secondary'
|
? 'secondary'
|
||||||
: 'outline'
|
: 'outline'
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{round.status}
|
{round.status?.replace('ROUND_', '') || round.status}
|
||||||
</Badge>
|
</Badge>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="text-right">
|
<TableCell className="text-right">
|
||||||
@@ -418,9 +419,11 @@ function StageAnalytics() {
|
|||||||
) || []
|
) || []
|
||||||
|
|
||||||
// Set default selected stage
|
// Set default selected stage
|
||||||
|
useEffect(() => {
|
||||||
if (rounds.length && !selectedValue) {
|
if (rounds.length && !selectedValue) {
|
||||||
setSelectedValue(rounds[0].id)
|
setSelectedValue(rounds[0].id)
|
||||||
}
|
}
|
||||||
|
}, [rounds.length, selectedValue])
|
||||||
|
|
||||||
const queryInput = parseSelection(selectedValue)
|
const queryInput = parseSelection(selectedValue)
|
||||||
const hasSelection = !!queryInput.roundId || !!queryInput.programId
|
const hasSelection = !!queryInput.roundId || !!queryInput.programId
|
||||||
@@ -529,9 +532,9 @@ function StageAnalytics() {
|
|||||||
<Skeleton className="h-[350px]" />
|
<Skeleton className="h-[350px]" />
|
||||||
) : scoreDistribution ? (
|
) : scoreDistribution ? (
|
||||||
<ScoreDistributionChart
|
<ScoreDistributionChart
|
||||||
data={scoreDistribution.distribution}
|
data={scoreDistribution.distribution ?? []}
|
||||||
averageScore={scoreDistribution.averageScore}
|
averageScore={scoreDistribution.averageScore ?? 0}
|
||||||
totalScores={scoreDistribution.totalScores}
|
totalScores={scoreDistribution.totalScores ?? 0}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
@@ -654,7 +657,7 @@ function CrossStageTab() {
|
|||||||
className="cursor-pointer text-sm py-1.5 px-3"
|
className="cursor-pointer text-sm py-1.5 px-3"
|
||||||
onClick={() => toggleRound(stage.id)}
|
onClick={() => toggleRound(stage.id)}
|
||||||
>
|
>
|
||||||
{stage.programName} - {stage.name}
|
{stage.name}
|
||||||
</Badge>
|
</Badge>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
@@ -702,9 +705,11 @@ function JurorConsistencyTab() {
|
|||||||
((p.stages ?? []) as Array<{ id: string; name: string }>).map((s: { id: string; name: string }) => ({ id: s.id, name: s.name, programId: p.id, programName: `${p.year} Edition` }))
|
((p.stages ?? []) as Array<{ id: string; name: string }>).map((s: { id: string; name: string }) => ({ id: s.id, name: s.name, programId: p.id, programName: `${p.year} Edition` }))
|
||||||
) || []
|
) || []
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
if (stages.length && !selectedValue) {
|
if (stages.length && !selectedValue) {
|
||||||
setSelectedValue(stages[0].id)
|
setSelectedValue(stages[0].id)
|
||||||
}
|
}
|
||||||
|
}, [stages.length, selectedValue])
|
||||||
|
|
||||||
const queryInput = parseSelection(selectedValue)
|
const queryInput = parseSelection(selectedValue)
|
||||||
const hasSelection = !!queryInput.roundId || !!queryInput.programId
|
const hasSelection = !!queryInput.roundId || !!queryInput.programId
|
||||||
@@ -735,7 +740,7 @@ function JurorConsistencyTab() {
|
|||||||
))}
|
))}
|
||||||
{stages.map((stage) => (
|
{stages.map((stage) => (
|
||||||
<SelectItem key={stage.id} value={stage.id}>
|
<SelectItem key={stage.id} value={stage.id}>
|
||||||
{stage.programName} - {stage.name}
|
{stage.name}
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
))}
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
@@ -774,9 +779,11 @@ function DiversityTab() {
|
|||||||
((p.stages ?? []) as Array<{ id: string; name: string }>).map((s: { id: string; name: string }) => ({ id: s.id, name: s.name, programId: p.id, programName: `${p.year} Edition` }))
|
((p.stages ?? []) as Array<{ id: string; name: string }>).map((s: { id: string; name: string }) => ({ id: s.id, name: s.name, programId: p.id, programName: `${p.year} Edition` }))
|
||||||
) || []
|
) || []
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
if (stages.length && !selectedValue) {
|
if (stages.length && !selectedValue) {
|
||||||
setSelectedValue(stages[0].id)
|
setSelectedValue(stages[0].id)
|
||||||
}
|
}
|
||||||
|
}, [stages.length, selectedValue])
|
||||||
|
|
||||||
const queryInput = parseSelection(selectedValue)
|
const queryInput = parseSelection(selectedValue)
|
||||||
const hasSelection = !!queryInput.roundId || !!queryInput.programId
|
const hasSelection = !!queryInput.roundId || !!queryInput.programId
|
||||||
@@ -807,7 +814,7 @@ function DiversityTab() {
|
|||||||
))}
|
))}
|
||||||
{stages.map((stage) => (
|
{stages.map((stage) => (
|
||||||
<SelectItem key={stage.id} value={stage.id}>
|
<SelectItem key={stage.id} value={stage.id}>
|
||||||
{stage.programName} - {stage.name}
|
{stage.name}
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
))}
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
@@ -831,6 +838,97 @@ function DiversityTab() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function RoundPipelineTab() {
|
||||||
|
const { data: programs, isLoading } = trpc.program.list.useQuery({ includeStages: true })
|
||||||
|
|
||||||
|
const rounds = programs?.flatMap(p =>
|
||||||
|
((p.stages ?? []) as Array<{ id: string; name: string; status: string; type?: string }>).map((s) => ({
|
||||||
|
...s,
|
||||||
|
programId: p.id,
|
||||||
|
programName: `${p.year} Edition`,
|
||||||
|
}))
|
||||||
|
) || []
|
||||||
|
|
||||||
|
const roundIds = rounds.map(r => r.id)
|
||||||
|
|
||||||
|
const { data: comparison, isLoading: comparisonLoading } =
|
||||||
|
trpc.analytics.getCrossRoundComparison.useQuery(
|
||||||
|
{ roundIds },
|
||||||
|
{ enabled: roundIds.length >= 2 }
|
||||||
|
)
|
||||||
|
|
||||||
|
if (isLoading || comparisonLoading) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{[1, 2, 3].map(i => <Skeleton key={i} className="h-24" />)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!rounds.length) {
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
|
||||||
|
<Layers className="h-12 w-12 text-muted-foreground/50" />
|
||||||
|
<p className="mt-2 font-medium">No rounds available</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const comparisonMap = new Map(
|
||||||
|
(comparison ?? []).map((c: any) => [c.roundId, c])
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<div className="rounded-lg bg-violet-500/10 p-1.5">
|
||||||
|
<Layers className="h-4 w-4 text-violet-600" />
|
||||||
|
</div>
|
||||||
|
Round Pipeline
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>Project flow across competition rounds</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="space-y-3">
|
||||||
|
{rounds.map((round, idx) => {
|
||||||
|
const stats = comparisonMap.get(round.id) as any
|
||||||
|
return (
|
||||||
|
<div key={round.id} className="flex items-center gap-4">
|
||||||
|
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-muted text-sm font-medium">
|
||||||
|
{idx + 1}
|
||||||
|
</div>
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="font-medium">{round.name}</p>
|
||||||
|
<p className="text-xs text-muted-foreground">{round.programName}</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-4 text-sm">
|
||||||
|
<span className="tabular-nums">{stats?.projectCount ?? 0} projects</span>
|
||||||
|
<span className="tabular-nums">{stats?.evaluationCount ?? 0} evals</span>
|
||||||
|
<Badge variant={round.status === 'ROUND_ACTIVE' ? 'default' : round.status === 'ROUND_CLOSED' ? 'secondary' : 'outline'}>
|
||||||
|
{round.status?.replace('ROUND_', '') ?? 'DRAFT'}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{stats?.completionRate != null && (
|
||||||
|
<Progress value={stats.completionRate} className="mt-2 h-2" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export default function ReportsPage() {
|
export default function ReportsPage() {
|
||||||
const [pdfStageId, setPdfStageId] = useState<string | null>(null)
|
const [pdfStageId, setPdfStageId] = useState<string | null>(null)
|
||||||
|
|
||||||
@@ -839,9 +937,11 @@ export default function ReportsPage() {
|
|||||||
((p.stages ?? []) as Array<{ id: string; name: string }>).map((s: { id: string; name: string }) => ({ id: s.id, name: s.name, programName: `${p.year} Edition` }))
|
((p.stages ?? []) as Array<{ id: string; name: string }>).map((s: { id: string; name: string }) => ({ id: s.id, name: s.name, programName: `${p.year} Edition` }))
|
||||||
) || []
|
) || []
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
if (pdfStages.length && !pdfStageId) {
|
if (pdfStages.length && !pdfStageId) {
|
||||||
setPdfStageId(pdfStages[0].id)
|
setPdfStageId(pdfStages[0].id)
|
||||||
}
|
}
|
||||||
|
}, [pdfStages.length, pdfStageId])
|
||||||
|
|
||||||
const selectedPdfStage = pdfStages.find((r) => r.id === pdfStageId)
|
const selectedPdfStage = pdfStages.find((r) => r.id === pdfStageId)
|
||||||
|
|
||||||
@@ -879,11 +979,9 @@ export default function ReportsPage() {
|
|||||||
<Globe className="h-4 w-4" />
|
<Globe className="h-4 w-4" />
|
||||||
Diversity
|
Diversity
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
<TabsTrigger value="pipeline" className="gap-2" asChild>
|
<TabsTrigger value="pipeline" className="gap-2">
|
||||||
<Link href={"/admin/reports/stages" as Route}>
|
|
||||||
<Layers className="h-4 w-4" />
|
<Layers className="h-4 w-4" />
|
||||||
By Round
|
By Round
|
||||||
</Link>
|
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
</TabsList>
|
</TabsList>
|
||||||
<div className="flex items-center gap-2 w-full sm:w-auto justify-between sm:justify-end">
|
<div className="flex items-center gap-2 w-full sm:w-auto justify-between sm:justify-end">
|
||||||
@@ -894,7 +992,7 @@ export default function ReportsPage() {
|
|||||||
<SelectContent>
|
<SelectContent>
|
||||||
{pdfStages.map((stage) => (
|
{pdfStages.map((stage) => (
|
||||||
<SelectItem key={stage.id} value={stage.id}>
|
<SelectItem key={stage.id} value={stage.id}>
|
||||||
{stage.programName} - {stage.name}
|
{stage.name}
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
))}
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
@@ -928,6 +1026,10 @@ export default function ReportsPage() {
|
|||||||
<TabsContent value="diversity">
|
<TabsContent value="diversity">
|
||||||
<DiversityTab />
|
<DiversityTab />
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="pipeline">
|
||||||
|
<RoundPipelineTab />
|
||||||
|
</TabsContent>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -9,7 +9,7 @@ import { Skeleton } from '@/components/ui/skeleton'
|
|||||||
import { SettingsContent } from '@/components/settings/settings-content'
|
import { SettingsContent } from '@/components/settings/settings-content'
|
||||||
|
|
||||||
// Categories that only super admins can access
|
// Categories that only super admins can access
|
||||||
const SUPER_ADMIN_CATEGORIES = new Set(['AI', 'EMAIL', 'STORAGE', 'SECURITY'])
|
const SUPER_ADMIN_CATEGORIES = new Set(['AI', 'EMAIL', 'STORAGE', 'SECURITY', 'WHATSAPP'])
|
||||||
|
|
||||||
async function SettingsLoader({ isSuperAdmin }: { isSuperAdmin: boolean }) {
|
async function SettingsLoader({ isSuperAdmin }: { isSuperAdmin: boolean }) {
|
||||||
const settings = await prisma.systemSettings.findMany({
|
const settings = await prisma.systemSettings.findMany({
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ export default async function AdminLayout({
|
|||||||
|
|
||||||
// Fetch all editions (programs) for the edition selector
|
// Fetch all editions (programs) for the edition selector
|
||||||
const editions = await prisma.program.findMany({
|
const editions = await prisma.program.findMany({
|
||||||
|
where: { isTest: false },
|
||||||
select: {
|
select: {
|
||||||
id: true,
|
id: true,
|
||||||
name: true,
|
name: true,
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { redirect } from 'next/navigation'
|
import { redirect } from 'next/navigation'
|
||||||
import Image from 'next/image'
|
import Image from 'next/image'
|
||||||
import { auth } from '@/lib/auth'
|
import { auth } from '@/lib/auth'
|
||||||
|
import { prisma } from '@/lib/prisma'
|
||||||
|
|
||||||
export default async function AuthLayout({
|
export default async function AuthLayout({
|
||||||
children,
|
children,
|
||||||
@@ -18,6 +19,13 @@ export default async function AuthLayout({
|
|||||||
// Redirect logged-in users to their dashboard
|
// Redirect logged-in users to their dashboard
|
||||||
// But NOT if they still need to set their password
|
// But NOT if they still need to set their password
|
||||||
if (session?.user && !session.user.mustSetPassword) {
|
if (session?.user && !session.user.mustSetPassword) {
|
||||||
|
// Verify user still exists in DB (handles deleted accounts with stale sessions)
|
||||||
|
const dbUser = await prisma.user.findUnique({
|
||||||
|
where: { id: session.user.id },
|
||||||
|
select: { id: true },
|
||||||
|
})
|
||||||
|
|
||||||
|
if (dbUser) {
|
||||||
const role = session.user.role
|
const role = session.user.role
|
||||||
if (role === 'SUPER_ADMIN' || role === 'PROGRAM_ADMIN') {
|
if (role === 'SUPER_ADMIN' || role === 'PROGRAM_ADMIN') {
|
||||||
redirect('/admin')
|
redirect('/admin')
|
||||||
@@ -29,6 +37,8 @@ export default async function AuthLayout({
|
|||||||
redirect('/mentor')
|
redirect('/mentor')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// If user doesn't exist in DB, fall through and show auth page
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen flex flex-col">
|
<div className="min-h-screen flex flex-col">
|
||||||
|
|||||||
@@ -51,10 +51,9 @@ type JuryPref = {
|
|||||||
juryGroupMemberId: string
|
juryGroupMemberId: string
|
||||||
juryGroupName: string
|
juryGroupName: string
|
||||||
currentCap: number
|
currentCap: number
|
||||||
allowCapAdjustment: boolean
|
|
||||||
allowRatioAdjustment: boolean
|
|
||||||
selfServiceCap: number | null
|
selfServiceCap: number | null
|
||||||
selfServiceRatio: number | null
|
selfServiceRatio: number | null
|
||||||
|
preferredStartupRatio: number | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function OnboardingPage() {
|
export default function OnboardingPage() {
|
||||||
@@ -221,7 +220,7 @@ export default function OnboardingPage() {
|
|||||||
// Show loading while session hydrates or fetching user data
|
// Show loading while session hydrates or fetching user data
|
||||||
if (sessionStatus === 'loading' || userLoading || !initialized) {
|
if (sessionStatus === 'loading' || userLoading || !initialized) {
|
||||||
return (
|
return (
|
||||||
<div className="absolute inset-0 -m-4 flex items-center justify-center p-4 md:p-8 bg-gradient-to-br from-[#053d57] to-[#557f8c]">
|
<div className="absolute inset-0 -m-4 flex items-center justify-center p-4 md:p-8 bg-[#053d57] bg-[url('https://s3.monaco-opc.com/public/ocean.png')] bg-cover bg-center bg-no-repeat">
|
||||||
<AnimatedCard>
|
<AnimatedCard>
|
||||||
<Card className="w-full max-w-lg shadow-2xl overflow-hidden">
|
<Card className="w-full max-w-lg shadow-2xl overflow-hidden">
|
||||||
<div className="h-1 w-full bg-gradient-to-r from-brand-blue via-brand-teal to-brand-blue" />
|
<div className="h-1 w-full bg-gradient-to-r from-brand-blue via-brand-teal to-brand-blue" />
|
||||||
@@ -236,9 +235,9 @@ export default function OnboardingPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="absolute inset-0 -m-4 flex items-center justify-center p-4 md:p-8 bg-gradient-to-br from-[#053d57] to-[#557f8c]">
|
<div className="absolute inset-0 -m-4 flex items-center justify-center p-4 md:p-8 bg-[#053d57] bg-[url('https://s3.monaco-opc.com/public/ocean.png')] bg-cover bg-center bg-no-repeat">
|
||||||
<AnimatedCard>
|
<AnimatedCard>
|
||||||
<Card className="w-full max-w-lg max-h-[85vh] overflow-y-auto overflow-hidden shadow-2xl">
|
<Card className="w-full max-w-lg max-h-[85vh] overflow-y-auto overflow-x-hidden shadow-2xl">
|
||||||
<div className="h-1 w-full bg-gradient-to-r from-brand-blue via-brand-teal to-brand-blue" />
|
<div className="h-1 w-full bg-gradient-to-r from-brand-blue via-brand-teal to-brand-blue" />
|
||||||
{/* Progress indicator */}
|
{/* Progress indicator */}
|
||||||
<div className="px-6 pt-6">
|
<div className="px-6 pt-6">
|
||||||
@@ -530,13 +529,12 @@ export default function OnboardingPage() {
|
|||||||
{juryMemberships.map((m) => {
|
{juryMemberships.map((m) => {
|
||||||
const pref = juryPrefs.get(m.juryGroupMemberId) ?? {}
|
const pref = juryPrefs.get(m.juryGroupMemberId) ?? {}
|
||||||
const capValue = pref.cap ?? m.selfServiceCap ?? m.currentCap
|
const capValue = pref.cap ?? m.selfServiceCap ?? m.currentCap
|
||||||
const ratioValue = pref.ratio ?? m.selfServiceRatio ?? 0.5
|
const ratioValue = pref.ratio ?? m.selfServiceRatio ?? m.preferredStartupRatio ?? 0.5
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={m.juryGroupMemberId} className="rounded-lg border p-4 space-y-4">
|
<div key={m.juryGroupMemberId} className="rounded-lg border p-4 space-y-4">
|
||||||
<h4 className="font-medium text-sm">{m.juryGroupName}</h4>
|
<h4 className="font-medium text-sm">{m.juryGroupName}</h4>
|
||||||
|
|
||||||
{m.allowCapAdjustment && (
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label className="text-xs text-muted-foreground">
|
<Label className="text-xs text-muted-foreground">
|
||||||
Maximum assignments: {capValue}
|
Maximum assignments: {capValue}
|
||||||
@@ -551,19 +549,17 @@ export default function OnboardingPage() {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
min={1}
|
min={1}
|
||||||
max={m.currentCap}
|
max={50}
|
||||||
step={1}
|
step={1}
|
||||||
/>
|
/>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
Admin default: {m.currentCap}. You may reduce this to match your availability.
|
Admin suggestion: {m.currentCap}. Adjust to match your availability.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
|
|
||||||
{m.allowRatioAdjustment && (
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label className="text-xs text-muted-foreground">
|
<Label className="text-xs text-muted-foreground">
|
||||||
Startup vs Business Concept ratio: {Math.round(ratioValue * 100)}% / {Math.round((1 - ratioValue) * 100)}%
|
Category preference: {Math.round(ratioValue * 100)}% Startups / {Math.round((1 - ratioValue) * 100)}% Business Concepts
|
||||||
</Label>
|
</Label>
|
||||||
<Slider
|
<Slider
|
||||||
value={[ratioValue * 100]}
|
value={[ratioValue * 100]}
|
||||||
@@ -576,14 +572,16 @@ export default function OnboardingPage() {
|
|||||||
}
|
}
|
||||||
min={0}
|
min={0}
|
||||||
max={100}
|
max={100}
|
||||||
step={5}
|
step={10}
|
||||||
/>
|
/>
|
||||||
<div className="flex justify-between text-xs text-muted-foreground">
|
<div className="flex justify-between text-xs text-muted-foreground">
|
||||||
<span>More Business Concepts</span>
|
<span>More Business Concepts</span>
|
||||||
<span>More Startups</span>
|
<span>More Startups</span>
|
||||||
</div>
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground/70 italic">
|
||||||
|
This is a preference, not a guarantee. Due to the number of projects, the system will try to match your preference but exact ratios cannot be ensured.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -17,12 +17,12 @@ export default function JuryRoundDetailPage() {
|
|||||||
|
|
||||||
const { data: assignments, isLoading } = trpc.roundAssignment.getMyAssignments.useQuery(
|
const { data: assignments, isLoading } = trpc.roundAssignment.getMyAssignments.useQuery(
|
||||||
{ roundId },
|
{ roundId },
|
||||||
{ enabled: !!roundId }
|
{ enabled: !!roundId, refetchInterval: 30_000 }
|
||||||
)
|
)
|
||||||
|
|
||||||
const { data: round } = trpc.round.getById.useQuery(
|
const { data: round } = trpc.round.getById.useQuery(
|
||||||
{ id: roundId },
|
{ id: roundId },
|
||||||
{ enabled: !!roundId }
|
{ enabled: !!roundId, refetchInterval: 30_000 }
|
||||||
)
|
)
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
|
|||||||
@@ -1,27 +1,22 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { use, useState, useEffect } from 'react'
|
import { use, useState, useEffect, useRef, useCallback } from 'react'
|
||||||
import { useRouter } from 'next/navigation'
|
import { useRouter } from 'next/navigation'
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
import type { Route } from 'next'
|
import type { Route } from 'next'
|
||||||
import { trpc } from '@/lib/trpc/client'
|
import { trpc } from '@/lib/trpc/client'
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Input } from '@/components/ui/input'
|
import { Slider } from '@/components/ui/slider'
|
||||||
import { Textarea } from '@/components/ui/textarea'
|
import { Textarea } from '@/components/ui/textarea'
|
||||||
import { Label } from '@/components/ui/label'
|
import { Label } from '@/components/ui/label'
|
||||||
import { Skeleton } from '@/components/ui/skeleton'
|
import { Skeleton } from '@/components/ui/skeleton'
|
||||||
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group'
|
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group'
|
||||||
import {
|
import { cn } from '@/lib/utils'
|
||||||
Dialog,
|
import { MultiWindowDocViewer } from '@/components/jury/multi-window-doc-viewer'
|
||||||
DialogContent,
|
import { Badge } from '@/components/ui/badge'
|
||||||
DialogDescription,
|
import { COIDeclarationDialog } from '@/components/forms/coi-declaration-dialog'
|
||||||
DialogFooter,
|
import { ArrowLeft, Save, Send, AlertCircle, ThumbsUp, ThumbsDown, Clock, CheckCircle2, ShieldAlert } from 'lucide-react'
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
} from '@/components/ui/dialog'
|
|
||||||
import { Checkbox } from '@/components/ui/checkbox'
|
|
||||||
import { ArrowLeft, Save, Send, AlertCircle, ThumbsUp, ThumbsDown } from 'lucide-react'
|
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
import type { EvaluationConfig } from '@/types/competition-configs'
|
import type { EvaluationConfig } from '@/types/competition-configs'
|
||||||
|
|
||||||
@@ -35,15 +30,19 @@ export default function JuryEvaluatePage({ params: paramsPromise }: PageProps) {
|
|||||||
const { roundId, projectId } = params
|
const { roundId, projectId } = params
|
||||||
const utils = trpc.useUtils()
|
const utils = trpc.useUtils()
|
||||||
|
|
||||||
const [showCOIDialog, setShowCOIDialog] = useState(true)
|
// Evaluation form state — stores all criterion values (numeric, boolean, text)
|
||||||
const [coiAccepted, setCoiAccepted] = useState(false)
|
const [criteriaValues, setCriteriaValues] = useState<Record<string, number | boolean | string>>({})
|
||||||
|
|
||||||
// Evaluation form state
|
|
||||||
const [criteriaScores, setCriteriaScores] = useState<Record<string, number>>({})
|
|
||||||
const [globalScore, setGlobalScore] = useState('')
|
const [globalScore, setGlobalScore] = useState('')
|
||||||
const [binaryDecision, setBinaryDecision] = useState<'accept' | 'reject' | ''>('')
|
const [binaryDecision, setBinaryDecision] = useState<'accept' | 'reject' | ''>('')
|
||||||
const [feedbackText, setFeedbackText] = useState('')
|
const [feedbackText, setFeedbackText] = useState('')
|
||||||
|
|
||||||
|
// Track dirty state for autosave
|
||||||
|
const isDirtyRef = useRef(false)
|
||||||
|
const evaluationIdRef = useRef<string | null>(null)
|
||||||
|
const isSubmittedRef = useRef(false)
|
||||||
|
const autosaveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||||
|
const [lastSavedAt, setLastSavedAt] = useState<Date | null>(null)
|
||||||
|
|
||||||
// Fetch project
|
// Fetch project
|
||||||
const { data: project } = trpc.project.get.useQuery(
|
const { data: project } = trpc.project.get.useQuery(
|
||||||
{ id: projectId },
|
{ id: projectId },
|
||||||
@@ -70,20 +69,36 @@ export default function JuryEvaluatePage({ params: paramsPromise }: PageProps) {
|
|||||||
{ enabled: !!myAssignment?.id }
|
{ enabled: !!myAssignment?.id }
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// COI (Conflict of Interest) check
|
||||||
|
const { data: coiStatus, isLoading: coiLoading } = trpc.evaluation.getCOIStatus.useQuery(
|
||||||
|
{ assignmentId: myAssignment?.id ?? '' },
|
||||||
|
{ enabled: !!myAssignment?.id }
|
||||||
|
)
|
||||||
|
const [coiCompleted, setCOICompleted] = useState(false)
|
||||||
|
const [coiHasConflict, setCOIHasConflict] = useState(false)
|
||||||
|
|
||||||
|
// Fetch the active evaluation form for this round
|
||||||
|
const { data: activeForm } = trpc.evaluation.getStageForm.useQuery(
|
||||||
|
{ roundId },
|
||||||
|
{ enabled: !!roundId }
|
||||||
|
)
|
||||||
|
|
||||||
// Start evaluation mutation (creates draft)
|
// Start evaluation mutation (creates draft)
|
||||||
const startMutation = trpc.evaluation.start.useMutation()
|
const startMutation = trpc.evaluation.start.useMutation()
|
||||||
|
|
||||||
// Autosave mutation
|
// Autosave mutation (silent)
|
||||||
const autosaveMutation = trpc.evaluation.autosave.useMutation({
|
const autosaveMutation = trpc.evaluation.autosave.useMutation({
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
toast.success('Draft saved', { duration: 1500 })
|
isDirtyRef.current = false
|
||||||
|
setLastSavedAt(new Date())
|
||||||
},
|
},
|
||||||
onError: (err) => toast.error(err.message),
|
|
||||||
})
|
})
|
||||||
|
|
||||||
// Submit mutation
|
// Submit mutation
|
||||||
const submitMutation = trpc.evaluation.submit.useMutation({
|
const submitMutation = trpc.evaluation.submit.useMutation({
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
|
isSubmittedRef.current = true
|
||||||
|
isDirtyRef.current = false
|
||||||
utils.roundAssignment.getMyAssignments.invalidate()
|
utils.roundAssignment.getMyAssignments.invalidate()
|
||||||
utils.evaluation.get.invalidate()
|
utils.evaluation.get.invalidate()
|
||||||
toast.success('Evaluation submitted successfully')
|
toast.success('Evaluation submitted successfully')
|
||||||
@@ -92,15 +107,24 @@ export default function JuryEvaluatePage({ params: paramsPromise }: PageProps) {
|
|||||||
onError: (err) => toast.error(err.message),
|
onError: (err) => toast.error(err.message),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Track evaluation ID
|
||||||
|
useEffect(() => {
|
||||||
|
if (existingEvaluation?.id) {
|
||||||
|
evaluationIdRef.current = existingEvaluation.id
|
||||||
|
}
|
||||||
|
}, [existingEvaluation?.id])
|
||||||
|
|
||||||
// Load existing evaluation data
|
// Load existing evaluation data
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (existingEvaluation) {
|
if (existingEvaluation) {
|
||||||
if (existingEvaluation.criterionScoresJson) {
|
if (existingEvaluation.criterionScoresJson) {
|
||||||
const scores: Record<string, number> = {}
|
const values: Record<string, number | boolean | string> = {}
|
||||||
Object.entries(existingEvaluation.criterionScoresJson).forEach(([key, value]) => {
|
Object.entries(existingEvaluation.criterionScoresJson).forEach(([key, value]) => {
|
||||||
scores[key] = typeof value === 'number' ? value : 0
|
if (typeof value === 'number' || typeof value === 'boolean' || typeof value === 'string') {
|
||||||
|
values[key] = value
|
||||||
|
}
|
||||||
})
|
})
|
||||||
setCriteriaScores(scores)
|
setCriteriaValues(values)
|
||||||
}
|
}
|
||||||
if (existingEvaluation.globalScore) {
|
if (existingEvaluation.globalScore) {
|
||||||
setGlobalScore(existingEvaluation.globalScore.toString())
|
setGlobalScore(existingEvaluation.globalScore.toString())
|
||||||
@@ -111,24 +135,139 @@ export default function JuryEvaluatePage({ params: paramsPromise }: PageProps) {
|
|||||||
if (existingEvaluation.feedbackText) {
|
if (existingEvaluation.feedbackText) {
|
||||||
setFeedbackText(existingEvaluation.feedbackText)
|
setFeedbackText(existingEvaluation.feedbackText)
|
||||||
}
|
}
|
||||||
|
isDirtyRef.current = false
|
||||||
}
|
}
|
||||||
}, [existingEvaluation])
|
}, [existingEvaluation])
|
||||||
|
|
||||||
// Parse evaluation config from round
|
// Parse evaluation config from round
|
||||||
const evalConfig: EvaluationConfig | null = round?.configJson as EvaluationConfig | null
|
const evalConfig: EvaluationConfig | null = round?.configJson as EvaluationConfig | null
|
||||||
const scoringMode = evalConfig?.scoringMode ?? 'global'
|
const scoringMode = evalConfig?.scoringMode ?? 'criteria'
|
||||||
const requireFeedback = evalConfig?.requireFeedback ?? true
|
const requireFeedback = evalConfig?.requireFeedback ?? true
|
||||||
const feedbackMinLength = evalConfig?.feedbackMinLength ?? 10
|
const feedbackMinLength = evalConfig?.feedbackMinLength ?? 10
|
||||||
|
|
||||||
// Get criteria from evaluation form
|
// Parse criteria from the active form
|
||||||
const criteria = existingEvaluation?.form?.criteriaJson as Array<{
|
const criteria = (activeForm?.criteriaJson ?? []).map((c) => {
|
||||||
id: string
|
const type = (c as any).type || 'numeric'
|
||||||
label: string
|
let minScore = 1
|
||||||
description?: string
|
let maxScore = 10
|
||||||
weight?: number
|
if (type === 'numeric' && c.scale) {
|
||||||
minScore?: number
|
const parts = c.scale.split('-').map(Number)
|
||||||
maxScore?: number
|
if (parts.length === 2 && !isNaN(parts[0]) && !isNaN(parts[1])) {
|
||||||
}> | undefined
|
minScore = parts[0]
|
||||||
|
maxScore = parts[1]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
id: c.id,
|
||||||
|
label: c.label,
|
||||||
|
description: c.description,
|
||||||
|
type: type as 'numeric' | 'text' | 'boolean' | 'section_header',
|
||||||
|
weight: c.weight,
|
||||||
|
minScore,
|
||||||
|
maxScore,
|
||||||
|
required: (c as any).required ?? true,
|
||||||
|
trueLabel: (c as any).trueLabel || 'Yes',
|
||||||
|
falseLabel: (c as any).falseLabel || 'No',
|
||||||
|
maxLength: (c as any).maxLength || 1000,
|
||||||
|
placeholder: (c as any).placeholder || '',
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Build current form data for autosave
|
||||||
|
const buildSavePayload = useCallback(() => {
|
||||||
|
return {
|
||||||
|
criterionScoresJson: scoringMode === 'criteria' ? criteriaValues : undefined,
|
||||||
|
globalScore: scoringMode === 'global' && globalScore ? parseInt(globalScore, 10) : null,
|
||||||
|
binaryDecision: scoringMode === 'binary' && binaryDecision ? binaryDecision === 'accept' : null,
|
||||||
|
feedbackText: feedbackText || null,
|
||||||
|
}
|
||||||
|
}, [scoringMode, criteriaValues, globalScore, binaryDecision, feedbackText])
|
||||||
|
|
||||||
|
// Perform autosave
|
||||||
|
const performAutosave = useCallback(async () => {
|
||||||
|
if (!isDirtyRef.current || isSubmittedRef.current) return
|
||||||
|
if (existingEvaluation?.status === 'SUBMITTED') return
|
||||||
|
|
||||||
|
let evalId = evaluationIdRef.current
|
||||||
|
if (!evalId && myAssignment) {
|
||||||
|
try {
|
||||||
|
const newEval = await startMutation.mutateAsync({ assignmentId: myAssignment.id })
|
||||||
|
evalId = newEval.id
|
||||||
|
evaluationIdRef.current = evalId
|
||||||
|
} catch {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!evalId) return
|
||||||
|
|
||||||
|
autosaveMutation.mutate({ id: evalId, ...buildSavePayload() })
|
||||||
|
}, [myAssignment, existingEvaluation?.status, startMutation, autosaveMutation, buildSavePayload])
|
||||||
|
|
||||||
|
// Debounced autosave: save 3 seconds after last change
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isDirtyRef.current) return
|
||||||
|
|
||||||
|
if (autosaveTimerRef.current) {
|
||||||
|
clearTimeout(autosaveTimerRef.current)
|
||||||
|
}
|
||||||
|
|
||||||
|
autosaveTimerRef.current = setTimeout(() => {
|
||||||
|
performAutosave()
|
||||||
|
}, 3000)
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (autosaveTimerRef.current) {
|
||||||
|
clearTimeout(autosaveTimerRef.current)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [criteriaValues, globalScore, binaryDecision, feedbackText, performAutosave])
|
||||||
|
|
||||||
|
// Save on page leave (beforeunload)
|
||||||
|
useEffect(() => {
|
||||||
|
const handleBeforeUnload = () => {
|
||||||
|
if (isDirtyRef.current && !isSubmittedRef.current && evaluationIdRef.current) {
|
||||||
|
const payload = JSON.stringify({
|
||||||
|
id: evaluationIdRef.current,
|
||||||
|
...buildSavePayload(),
|
||||||
|
})
|
||||||
|
navigator.sendBeacon?.('/api/trpc/evaluation.autosave', payload)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener('beforeunload', handleBeforeUnload)
|
||||||
|
return () => window.removeEventListener('beforeunload', handleBeforeUnload)
|
||||||
|
}, [buildSavePayload])
|
||||||
|
|
||||||
|
// Save on component unmount (navigating away within the app)
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (isDirtyRef.current && !isSubmittedRef.current && evaluationIdRef.current) {
|
||||||
|
performAutosave()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// Mark dirty when form values change
|
||||||
|
const handleCriterionChange = (key: string, value: number | boolean | string) => {
|
||||||
|
setCriteriaValues((prev) => ({ ...prev, [key]: value }))
|
||||||
|
isDirtyRef.current = true
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleGlobalScoreChange = (value: string) => {
|
||||||
|
setGlobalScore(value)
|
||||||
|
isDirtyRef.current = true
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleBinaryChange = (value: 'accept' | 'reject') => {
|
||||||
|
setBinaryDecision(value)
|
||||||
|
isDirtyRef.current = true
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleFeedbackChange = (value: string) => {
|
||||||
|
setFeedbackText(value)
|
||||||
|
isDirtyRef.current = true
|
||||||
|
}
|
||||||
|
|
||||||
const handleSaveDraft = async () => {
|
const handleSaveDraft = async () => {
|
||||||
if (!myAssignment) {
|
if (!myAssignment) {
|
||||||
@@ -136,21 +275,21 @@ export default function JuryEvaluatePage({ params: paramsPromise }: PageProps) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create evaluation if it doesn't exist
|
let evaluationId = evaluationIdRef.current
|
||||||
let evaluationId = existingEvaluation?.id
|
|
||||||
if (!evaluationId) {
|
if (!evaluationId) {
|
||||||
const newEval = await startMutation.mutateAsync({ assignmentId: myAssignment.id })
|
const newEval = await startMutation.mutateAsync({ assignmentId: myAssignment.id })
|
||||||
evaluationId = newEval.id
|
evaluationId = newEval.id
|
||||||
|
evaluationIdRef.current = evaluationId
|
||||||
}
|
}
|
||||||
|
|
||||||
// Autosave current state
|
autosaveMutation.mutate(
|
||||||
autosaveMutation.mutate({
|
{ id: evaluationId, ...buildSavePayload() },
|
||||||
id: evaluationId,
|
{ onSuccess: () => {
|
||||||
criterionScoresJson: scoringMode === 'criteria' ? criteriaScores : undefined,
|
isDirtyRef.current = false
|
||||||
globalScore: scoringMode === 'global' && globalScore ? parseInt(globalScore, 10) : null,
|
setLastSavedAt(new Date())
|
||||||
binaryDecision: scoringMode === 'binary' && binaryDecision ? binaryDecision === 'accept' : null,
|
toast.success('Draft saved', { duration: 1500 })
|
||||||
feedbackText: feedbackText || null,
|
}}
|
||||||
})
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleSubmit = async () => {
|
const handleSubmit = async () => {
|
||||||
@@ -159,17 +298,23 @@ export default function JuryEvaluatePage({ params: paramsPromise }: PageProps) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validation based on scoring mode
|
// Validation for criteria mode
|
||||||
if (scoringMode === 'criteria') {
|
if (scoringMode === 'criteria') {
|
||||||
if (!criteria || criteria.length === 0) {
|
const requiredCriteria = criteria.filter((c) =>
|
||||||
toast.error('No criteria found for this evaluation')
|
c.type !== 'section_header' && c.required
|
||||||
|
)
|
||||||
|
for (const c of requiredCriteria) {
|
||||||
|
const val = criteriaValues[c.id]
|
||||||
|
if (c.type === 'numeric' && (val === undefined || val === null)) {
|
||||||
|
toast.error(`Please score "${c.label}"`)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const requiredCriteria = evalConfig?.requireAllCriteriaScored !== false
|
if (c.type === 'boolean' && val === undefined) {
|
||||||
if (requiredCriteria) {
|
toast.error(`Please answer "${c.label}"`)
|
||||||
const allScored = criteria.every((c) => criteriaScores[c.id] !== undefined)
|
return
|
||||||
if (!allScored) {
|
}
|
||||||
toast.error('Please score all criteria')
|
if (c.type === 'text' && (!val || (typeof val === 'string' && !val.trim()))) {
|
||||||
|
toast.error(`Please fill in "${c.label}"`)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -197,72 +342,43 @@ export default function JuryEvaluatePage({ params: paramsPromise }: PageProps) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create evaluation if needed
|
let evaluationId = evaluationIdRef.current
|
||||||
let evaluationId = existingEvaluation?.id
|
|
||||||
if (!evaluationId) {
|
if (!evaluationId) {
|
||||||
const newEval = await startMutation.mutateAsync({ assignmentId: myAssignment.id })
|
const newEval = await startMutation.mutateAsync({ assignmentId: myAssignment.id })
|
||||||
evaluationId = newEval.id
|
evaluationId = newEval.id
|
||||||
|
evaluationIdRef.current = evaluationId
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compute a weighted global score from numeric criteria for the global score field
|
||||||
|
const numericCriteria = criteria.filter((c) => c.type === 'numeric')
|
||||||
|
let computedGlobalScore = 5
|
||||||
|
if (scoringMode === 'criteria' && numericCriteria.length > 0) {
|
||||||
|
let totalWeight = 0
|
||||||
|
let weightedSum = 0
|
||||||
|
for (const c of numericCriteria) {
|
||||||
|
const val = criteriaValues[c.id]
|
||||||
|
if (typeof val === 'number') {
|
||||||
|
const w = c.weight ?? 1
|
||||||
|
// Normalize to 1-10 scale
|
||||||
|
const normalized = ((val - c.minScore) / (c.maxScore - c.minScore)) * 9 + 1
|
||||||
|
weightedSum += normalized * w
|
||||||
|
totalWeight += w
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (totalWeight > 0) {
|
||||||
|
computedGlobalScore = Math.round(weightedSum / totalWeight)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Submit
|
|
||||||
submitMutation.mutate({
|
submitMutation.mutate({
|
||||||
id: evaluationId,
|
id: evaluationId,
|
||||||
criterionScoresJson: scoringMode === 'criteria' ? criteriaScores : {},
|
criterionScoresJson: scoringMode === 'criteria' ? criteriaValues : {},
|
||||||
globalScore: scoringMode === 'global' ? parseInt(globalScore, 10) : 5,
|
globalScore: scoringMode === 'global' ? parseInt(globalScore, 10) : computedGlobalScore,
|
||||||
binaryDecision: scoringMode === 'binary' ? binaryDecision === 'accept' : true,
|
binaryDecision: scoringMode === 'binary' ? binaryDecision === 'accept' : true,
|
||||||
feedbackText: feedbackText || 'No feedback provided',
|
feedbackText: feedbackText || 'No feedback provided',
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// COI Dialog
|
|
||||||
if (!coiAccepted && showCOIDialog && evalConfig?.coiRequired !== false) {
|
|
||||||
return (
|
|
||||||
<Dialog open={showCOIDialog} onOpenChange={setShowCOIDialog}>
|
|
||||||
<DialogContent>
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>Conflict of Interest Declaration</DialogTitle>
|
|
||||||
<DialogDescription className="space-y-3 pt-2">
|
|
||||||
<p>
|
|
||||||
Before evaluating this project, you must confirm that you have no conflict of
|
|
||||||
interest.
|
|
||||||
</p>
|
|
||||||
<p>
|
|
||||||
A conflict of interest exists if you have a personal, professional, or financial
|
|
||||||
relationship with the project team that could influence your judgment.
|
|
||||||
</p>
|
|
||||||
</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
<div className="flex items-start gap-3 py-4">
|
|
||||||
<Checkbox
|
|
||||||
id="coi"
|
|
||||||
checked={coiAccepted}
|
|
||||||
onCheckedChange={(checked) => setCoiAccepted(checked as boolean)}
|
|
||||||
/>
|
|
||||||
<Label htmlFor="coi" className="text-sm leading-relaxed cursor-pointer">
|
|
||||||
I confirm that I have no conflict of interest with this project and can provide an
|
|
||||||
unbiased evaluation.
|
|
||||||
</Label>
|
|
||||||
</div>
|
|
||||||
<DialogFooter>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
onClick={() => router.push(`/jury/competitions/${roundId}` as Route)}
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
onClick={() => setShowCOIDialog(false)}
|
|
||||||
disabled={!coiAccepted}
|
|
||||||
className="bg-brand-blue hover:bg-brand-blue-light"
|
|
||||||
>
|
|
||||||
Continue to Evaluation
|
|
||||||
</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!round || !project) {
|
if (!round || !project) {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
@@ -278,6 +394,123 @@ export default function JuryEvaluatePage({ params: paramsPromise }: PageProps) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// COI config
|
||||||
|
const coiRequired = evalConfig?.coiRequired ?? true
|
||||||
|
|
||||||
|
// Determine COI state: declared via server or just completed in this session
|
||||||
|
// coiStatus is null when no COI record exists, truthy when declared
|
||||||
|
const coiDeclared = coiCompleted || (coiStatus != null)
|
||||||
|
const coiConflict = coiHasConflict || (coiStatus?.hasConflict ?? false)
|
||||||
|
|
||||||
|
// Check if round is active
|
||||||
|
const isRoundActive = round.status === 'ROUND_ACTIVE'
|
||||||
|
|
||||||
|
if (!isRoundActive) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<Button variant="ghost" size="sm" asChild>
|
||||||
|
<Link href={`/jury/competitions/${roundId}/projects/${projectId}` as Route}>
|
||||||
|
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||||
|
Back to Project
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<Card className="border-l-4 border-l-amber-500">
|
||||||
|
<CardContent className="flex items-start gap-4 p-6">
|
||||||
|
<div className="rounded-xl bg-amber-50 dark:bg-amber-950/40 p-3">
|
||||||
|
<Clock className="h-6 w-6 text-amber-600" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h2 className="text-lg font-semibold">Evaluation Not Available</h2>
|
||||||
|
<p className="text-sm text-muted-foreground mt-1">
|
||||||
|
This round is not currently active. Evaluations can only be submitted during an active round.
|
||||||
|
</p>
|
||||||
|
<Button variant="outline" size="sm" className="mt-4" asChild>
|
||||||
|
<Link href={`/jury/competitions/${roundId}/projects/${projectId}` as Route}>
|
||||||
|
View Project Details
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// COI gate: if COI is required, not yet declared, and we have an assignment
|
||||||
|
if (coiRequired && myAssignment && !coiLoading && !coiDeclared) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<Button variant="ghost" size="sm" asChild>
|
||||||
|
<Link href={`/jury/competitions/${roundId}/projects/${projectId}` as Route}>
|
||||||
|
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||||
|
Back to Project
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold tracking-tight text-brand-blue dark:text-foreground">
|
||||||
|
Evaluate Project
|
||||||
|
</h1>
|
||||||
|
<p className="text-muted-foreground mt-1">{project.title}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<COIDeclarationDialog
|
||||||
|
open={true}
|
||||||
|
assignmentId={myAssignment.id}
|
||||||
|
projectTitle={project.title}
|
||||||
|
onComplete={(hasConflict) => {
|
||||||
|
setCOICompleted(true)
|
||||||
|
setCOIHasConflict(hasConflict)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// COI conflict declared — block evaluation
|
||||||
|
if (coiRequired && coiConflict) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<Button variant="ghost" size="sm" asChild>
|
||||||
|
<Link href={`/jury/competitions/${roundId}/projects/${projectId}` as Route}>
|
||||||
|
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||||
|
Back to Project
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold tracking-tight text-brand-blue dark:text-foreground">
|
||||||
|
Evaluate Project
|
||||||
|
</h1>
|
||||||
|
<p className="text-muted-foreground mt-1">{project.title}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Card className="border-l-4 border-l-amber-500">
|
||||||
|
<CardContent className="flex items-start gap-4 p-6">
|
||||||
|
<div className="rounded-xl bg-amber-50 dark:bg-amber-950/40 p-3">
|
||||||
|
<ShieldAlert className="h-6 w-6 text-amber-600" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h2 className="text-lg font-semibold">Conflict of Interest Declared</h2>
|
||||||
|
<p className="text-sm text-muted-foreground mt-1">
|
||||||
|
You declared a conflict of interest for this project. An administrator will
|
||||||
|
review your declaration. You cannot evaluate this project while the conflict
|
||||||
|
is under review.
|
||||||
|
</p>
|
||||||
|
<Button variant="outline" size="sm" className="mt-4" asChild>
|
||||||
|
<Link href={`/jury/competitions/${roundId}` as Route}>
|
||||||
|
Back to Round
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
@@ -291,9 +524,26 @@ export default function JuryEvaluatePage({ params: paramsPromise }: PageProps) {
|
|||||||
<h1 className="text-2xl font-bold tracking-tight text-brand-blue dark:text-foreground">
|
<h1 className="text-2xl font-bold tracking-tight text-brand-blue dark:text-foreground">
|
||||||
Evaluate Project
|
Evaluate Project
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-muted-foreground mt-1">{project.title}</p>
|
<div className="flex items-center gap-2 mt-1">
|
||||||
|
<p className="text-muted-foreground">{project.title}</p>
|
||||||
|
{project.competitionCategory && (
|
||||||
|
<Badge
|
||||||
|
variant="secondary"
|
||||||
|
className={
|
||||||
|
project.competitionCategory === 'STARTUP'
|
||||||
|
? 'bg-violet-100 text-violet-700 border-violet-200 dark:bg-violet-950 dark:text-violet-300'
|
||||||
|
: 'bg-sky-100 text-sky-700 border-sky-200 dark:bg-sky-950 dark:text-sky-300'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{project.competitionCategory === 'STARTUP' ? 'Startup' : 'Business Concept'}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Project Documents */}
|
||||||
|
<MultiWindowDocViewer roundId={roundId} projectId={projectId} />
|
||||||
|
|
||||||
<Card className="border-l-4 border-l-amber-500">
|
<Card className="border-l-4 border-l-amber-500">
|
||||||
<CardContent className="flex items-start gap-3 p-4">
|
<CardContent className="flex items-start gap-3 p-4">
|
||||||
@@ -302,7 +552,7 @@ export default function JuryEvaluatePage({ params: paramsPromise }: PageProps) {
|
|||||||
<p className="font-medium text-sm">Important Reminder</p>
|
<p className="font-medium text-sm">Important Reminder</p>
|
||||||
<p className="text-sm text-muted-foreground mt-1">
|
<p className="text-sm text-muted-foreground mt-1">
|
||||||
Your evaluation will be used to assess this project. Please provide thoughtful and
|
Your evaluation will be used to assess this project. Please provide thoughtful and
|
||||||
constructive feedback to help the team improve.
|
constructive feedback. Your progress is automatically saved as a draft.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
@@ -310,64 +560,218 @@ export default function JuryEvaluatePage({ params: paramsPromise }: PageProps) {
|
|||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
|
<div className="flex items-start justify-between">
|
||||||
|
<div>
|
||||||
<CardTitle>Evaluation Form</CardTitle>
|
<CardTitle>Evaluation Form</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
Provide your assessment using the {scoringMode} scoring method
|
{scoringMode === 'criteria'
|
||||||
|
? 'Complete all required fields below'
|
||||||
|
: `Provide your assessment using the ${scoringMode} scoring method`}
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
|
</div>
|
||||||
|
{lastSavedAt && (
|
||||||
|
<span className="text-xs text-muted-foreground flex items-center gap-1">
|
||||||
|
<CheckCircle2 className="h-3 w-3 text-emerald-500" />
|
||||||
|
Saved {lastSavedAt.toLocaleTimeString()}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-6">
|
<CardContent className="space-y-6">
|
||||||
{/* Criteria-based scoring */}
|
{/* Criteria-based scoring with mixed types */}
|
||||||
{scoringMode === 'criteria' && criteria && criteria.length > 0 && (
|
{scoringMode === 'criteria' && criteria && criteria.length > 0 && (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<h3 className="font-semibold">Criteria Scores</h3>
|
{criteria.map((criterion) => {
|
||||||
{criteria.map((criterion) => (
|
if (criterion.type === 'section_header') {
|
||||||
<div key={criterion.id} className="space-y-2 p-4 border rounded-lg">
|
return (
|
||||||
<Label htmlFor={criterion.id}>
|
<div key={criterion.id} className="border-b pb-2 pt-4 first:pt-0">
|
||||||
{criterion.label}
|
<h3 className="font-semibold text-lg">{criterion.label}</h3>
|
||||||
{evalConfig?.requireAllCriteriaScored !== false && (
|
{criterion.description && (
|
||||||
<span className="text-destructive ml-1">*</span>
|
<p className="text-sm text-muted-foreground mt-1">{criterion.description}</p>
|
||||||
)}
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (criterion.type === 'boolean') {
|
||||||
|
const currentValue = criteriaValues[criterion.id]
|
||||||
|
return (
|
||||||
|
<div key={criterion.id} className="space-y-3 p-4 border rounded-lg">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label className="text-base font-medium">
|
||||||
|
{criterion.label}
|
||||||
|
{criterion.required && <span className="text-destructive ml-1">*</span>}
|
||||||
</Label>
|
</Label>
|
||||||
{criterion.description && (
|
{criterion.description && (
|
||||||
<p className="text-xs text-muted-foreground">{criterion.description}</p>
|
<p className="text-sm text-muted-foreground">{criterion.description}</p>
|
||||||
)}
|
)}
|
||||||
<Input
|
|
||||||
id={criterion.id}
|
|
||||||
type="number"
|
|
||||||
min={criterion.minScore ?? 0}
|
|
||||||
max={criterion.maxScore ?? 10}
|
|
||||||
value={criteriaScores[criterion.id] ?? ''}
|
|
||||||
onChange={(e) =>
|
|
||||||
setCriteriaScores({
|
|
||||||
...criteriaScores,
|
|
||||||
[criterion.id]: parseInt(e.target.value, 10) || 0,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
placeholder={`Score (${criterion.minScore ?? 0}-${criterion.maxScore ?? 10})`}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleCriterionChange(criterion.id, true)}
|
||||||
|
className={cn(
|
||||||
|
'flex-1 h-12 rounded-lg border-2 flex items-center justify-center text-sm font-medium transition-all',
|
||||||
|
currentValue === true
|
||||||
|
? 'border-emerald-500 bg-emerald-50 text-emerald-700 dark:bg-emerald-950/40 dark:text-emerald-400'
|
||||||
|
: 'border-border hover:border-emerald-300 hover:bg-emerald-50/50'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<ThumbsUp className="mr-2 h-4 w-4" />
|
||||||
|
{criterion.trueLabel}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleCriterionChange(criterion.id, false)}
|
||||||
|
className={cn(
|
||||||
|
'flex-1 h-12 rounded-lg border-2 flex items-center justify-center text-sm font-medium transition-all',
|
||||||
|
currentValue === false
|
||||||
|
? 'border-red-500 bg-red-50 text-red-700 dark:bg-red-950/40 dark:text-red-400'
|
||||||
|
: 'border-border hover:border-red-300 hover:bg-red-50/50'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<ThumbsDown className="mr-2 h-4 w-4" />
|
||||||
|
{criterion.falseLabel}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (criterion.type === 'text') {
|
||||||
|
const currentValue = (criteriaValues[criterion.id] as string) || ''
|
||||||
|
return (
|
||||||
|
<div key={criterion.id} className="space-y-3 p-4 border rounded-lg">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label className="text-base font-medium">
|
||||||
|
{criterion.label}
|
||||||
|
{criterion.required && <span className="text-destructive ml-1">*</span>}
|
||||||
|
</Label>
|
||||||
|
{criterion.description && (
|
||||||
|
<p className="text-sm text-muted-foreground">{criterion.description}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<Textarea
|
||||||
|
value={currentValue}
|
||||||
|
onChange={(e) => handleCriterionChange(criterion.id, e.target.value)}
|
||||||
|
placeholder={criterion.placeholder || 'Enter your response...'}
|
||||||
|
rows={4}
|
||||||
|
maxLength={criterion.maxLength}
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground text-right">
|
||||||
|
{currentValue.length}/{criterion.maxLength}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default: numeric criterion
|
||||||
|
const min = criterion.minScore ?? 1
|
||||||
|
const max = criterion.maxScore ?? 10
|
||||||
|
const currentValue = criteriaValues[criterion.id]
|
||||||
|
const displayValue = typeof currentValue === 'number' ? currentValue : undefined
|
||||||
|
const sliderValue = typeof currentValue === 'number' ? currentValue : Math.ceil((min + max) / 2)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={criterion.id} className="space-y-3 p-4 border rounded-lg">
|
||||||
|
<div className="flex items-start justify-between gap-4">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label className="text-base font-medium">
|
||||||
|
{criterion.label}
|
||||||
|
{criterion.required && <span className="text-destructive ml-1">*</span>}
|
||||||
|
</Label>
|
||||||
|
{criterion.description && (
|
||||||
|
<p className="text-sm text-muted-foreground">{criterion.description}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<span className="shrink-0 rounded-md bg-muted px-2.5 py-1 text-sm font-bold tabular-nums">
|
||||||
|
{displayValue !== undefined ? displayValue : '\u2014'}/{max}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-xs text-muted-foreground w-4">{min}</span>
|
||||||
|
<Slider
|
||||||
|
min={min}
|
||||||
|
max={max}
|
||||||
|
step={1}
|
||||||
|
value={[sliderValue]}
|
||||||
|
onValueChange={(v) => handleCriterionChange(criterion.id, v[0])}
|
||||||
|
className="flex-1"
|
||||||
|
/>
|
||||||
|
<span className="text-xs text-muted-foreground w-4">{max}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-1 flex-wrap">
|
||||||
|
{Array.from({ length: max - min + 1 }, (_, i) => i + min).map((num) => (
|
||||||
|
<button
|
||||||
|
key={num}
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleCriterionChange(criterion.id, num)}
|
||||||
|
className={cn(
|
||||||
|
'w-9 h-9 rounded-md text-sm font-medium transition-colors',
|
||||||
|
displayValue !== undefined && displayValue === num
|
||||||
|
? 'bg-primary text-primary-foreground'
|
||||||
|
: displayValue !== undefined && displayValue > num
|
||||||
|
? 'bg-primary/20 text-primary'
|
||||||
|
: 'bg-muted hover:bg-muted/80'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{num}
|
||||||
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Global scoring */}
|
{/* Global scoring */}
|
||||||
{scoringMode === 'global' && (
|
{scoringMode === 'global' && (
|
||||||
<div className="space-y-2">
|
<div className="space-y-3">
|
||||||
<Label htmlFor="globalScore">
|
<div className="flex items-center justify-between">
|
||||||
|
<Label>
|
||||||
Overall Score <span className="text-destructive">*</span>
|
Overall Score <span className="text-destructive">*</span>
|
||||||
</Label>
|
</Label>
|
||||||
<Input
|
<span className="rounded-md bg-muted px-2.5 py-1 text-sm font-bold tabular-nums">
|
||||||
id="globalScore"
|
{globalScore || '\u2014'}/10
|
||||||
type="number"
|
</span>
|
||||||
min="1"
|
</div>
|
||||||
max="10"
|
<div className="flex items-center gap-2">
|
||||||
value={globalScore}
|
<span className="text-xs text-muted-foreground">1</span>
|
||||||
onChange={(e) => setGlobalScore(e.target.value)}
|
<Slider
|
||||||
placeholder="Enter score (1-10)"
|
min={1}
|
||||||
|
max={10}
|
||||||
|
step={1}
|
||||||
|
value={[globalScore ? parseInt(globalScore, 10) : 5]}
|
||||||
|
onValueChange={(v) => handleGlobalScoreChange(v[0].toString())}
|
||||||
|
className="flex-1"
|
||||||
/>
|
/>
|
||||||
<p className="text-xs text-muted-foreground">
|
<span className="text-xs text-muted-foreground">10</span>
|
||||||
Provide a score from 1 to 10 based on your overall assessment
|
</div>
|
||||||
</p>
|
<div className="flex gap-1 flex-wrap">
|
||||||
|
{[1, 2, 3, 4, 5, 6, 7, 8, 9, 10].map((num) => {
|
||||||
|
const current = globalScore ? parseInt(globalScore, 10) : 0
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={num}
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleGlobalScoreChange(num.toString())}
|
||||||
|
className={cn(
|
||||||
|
'w-9 h-9 rounded-md text-sm font-medium transition-colors',
|
||||||
|
current === num
|
||||||
|
? 'bg-primary text-primary-foreground'
|
||||||
|
: current > num
|
||||||
|
? 'bg-primary/20 text-primary'
|
||||||
|
: 'bg-muted hover:bg-muted/80'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{num}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -377,7 +781,7 @@ export default function JuryEvaluatePage({ params: paramsPromise }: PageProps) {
|
|||||||
<Label>
|
<Label>
|
||||||
Decision <span className="text-destructive">*</span>
|
Decision <span className="text-destructive">*</span>
|
||||||
</Label>
|
</Label>
|
||||||
<RadioGroup value={binaryDecision} onValueChange={(v) => setBinaryDecision(v as 'accept' | 'reject')}>
|
<RadioGroup value={binaryDecision} onValueChange={(v) => handleBinaryChange(v as 'accept' | 'reject')}>
|
||||||
<div className="flex items-center space-x-2 p-4 border rounded-lg hover:bg-emerald-50/50">
|
<div className="flex items-center space-x-2 p-4 border rounded-lg hover:bg-emerald-50/50">
|
||||||
<RadioGroupItem value="accept" id="accept" />
|
<RadioGroupItem value="accept" id="accept" />
|
||||||
<Label htmlFor="accept" className="flex items-center gap-2 cursor-pointer flex-1">
|
<Label htmlFor="accept" className="flex items-center gap-2 cursor-pointer flex-1">
|
||||||
@@ -399,13 +803,13 @@ export default function JuryEvaluatePage({ params: paramsPromise }: PageProps) {
|
|||||||
{/* Feedback */}
|
{/* Feedback */}
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="feedbackText">
|
<Label htmlFor="feedbackText">
|
||||||
Feedback
|
General Comment / Feedback
|
||||||
{requireFeedback && <span className="text-destructive ml-1">*</span>}
|
{requireFeedback && <span className="text-destructive ml-1">*</span>}
|
||||||
</Label>
|
</Label>
|
||||||
<Textarea
|
<Textarea
|
||||||
id="feedbackText"
|
id="feedbackText"
|
||||||
value={feedbackText}
|
value={feedbackText}
|
||||||
onChange={(e) => setFeedbackText(e.target.value)}
|
onChange={(e) => handleFeedbackChange(e.target.value)}
|
||||||
placeholder="Provide your feedback on the project..."
|
placeholder="Provide your feedback on the project..."
|
||||||
rows={8}
|
rows={8}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -9,8 +9,7 @@ import { Button } from '@/components/ui/button'
|
|||||||
import { Badge } from '@/components/ui/badge'
|
import { Badge } from '@/components/ui/badge'
|
||||||
import { Skeleton } from '@/components/ui/skeleton'
|
import { Skeleton } from '@/components/ui/skeleton'
|
||||||
import { MultiWindowDocViewer } from '@/components/jury/multi-window-doc-viewer'
|
import { MultiWindowDocViewer } from '@/components/jury/multi-window-doc-viewer'
|
||||||
import { ArrowLeft, FileText, Users, MapPin, Target } from 'lucide-react'
|
import { ArrowLeft, FileText, Users, MapPin, Target, Tag } from 'lucide-react'
|
||||||
import { toast } from 'sonner'
|
|
||||||
|
|
||||||
export default function JuryProjectDetailPage() {
|
export default function JuryProjectDetailPage() {
|
||||||
const params = useParams()
|
const params = useParams()
|
||||||
@@ -22,6 +21,14 @@ export default function JuryProjectDetailPage() {
|
|||||||
{ enabled: !!projectId }
|
{ enabled: !!projectId }
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const { data: round } = trpc.round.getById.useQuery(
|
||||||
|
{ id: roundId },
|
||||||
|
{ enabled: !!roundId }
|
||||||
|
)
|
||||||
|
|
||||||
|
// Round status is the primary gate for evaluations
|
||||||
|
const isVotingOpen = round?.status === 'ROUND_ACTIVE'
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
@@ -71,34 +78,75 @@ export default function JuryProjectDetailPage() {
|
|||||||
<p className="text-muted-foreground mt-1">{project.teamName}</p>
|
<p className="text-muted-foreground mt-1">{project.teamName}</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
{isVotingOpen ? (
|
||||||
<Button asChild className="bg-brand-blue hover:bg-brand-blue-light">
|
<Button asChild className="bg-brand-blue hover:bg-brand-blue-light">
|
||||||
<Link href={`/jury/competitions/${roundId}/projects/${projectId}/evaluate` as Route}>
|
<Link href={`/jury/competitions/${roundId}/projects/${projectId}/evaluate` as Route}>
|
||||||
<Target className="mr-2 h-4 w-4" />
|
<Target className="mr-2 h-4 w-4" />
|
||||||
Evaluate Project
|
Evaluate Project
|
||||||
</Link>
|
</Link>
|
||||||
</Button>
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Badge variant="outline" className="text-muted-foreground">
|
||||||
|
Voting not open
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-6">
|
<CardContent className="space-y-6">
|
||||||
{/* Project metadata */}
|
{/* Project metadata */}
|
||||||
<div className="flex flex-wrap gap-3">
|
<div className="flex flex-wrap gap-3">
|
||||||
|
{project.competitionCategory && (
|
||||||
|
<Badge
|
||||||
|
variant="secondary"
|
||||||
|
className={
|
||||||
|
project.competitionCategory === 'STARTUP'
|
||||||
|
? 'bg-violet-100 text-violet-700 border-violet-200 dark:bg-violet-950 dark:text-violet-300'
|
||||||
|
: 'bg-sky-100 text-sky-700 border-sky-200 dark:bg-sky-950 dark:text-sky-300'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{project.competitionCategory === 'STARTUP' ? 'Startup' : 'Business Concept'}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
{project.country && (
|
{project.country && (
|
||||||
<Badge variant="outline" className="gap-1">
|
<Badge variant="outline" className="gap-1">
|
||||||
<MapPin className="h-3 w-3" />
|
<MapPin className="h-3 w-3" />
|
||||||
{project.country}
|
{project.country}
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
{project.competitionCategory && (
|
</div>
|
||||||
<Badge variant="outline">{project.competitionCategory}</Badge>
|
|
||||||
|
{/* Project tags */}
|
||||||
|
{((project.projectTags && project.projectTags.length > 0) ||
|
||||||
|
(project.tags && project.tags.length > 0)) && (
|
||||||
|
<div>
|
||||||
|
<h3 className="font-semibold mb-2 flex items-center gap-2">
|
||||||
|
<Tag className="h-4 w-4" />
|
||||||
|
Tags
|
||||||
|
</h3>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{project.projectTags && project.projectTags.length > 0
|
||||||
|
? project.projectTags.map((pt: any) => (
|
||||||
|
<Badge
|
||||||
|
key={pt.id}
|
||||||
|
variant="secondary"
|
||||||
|
style={pt.tag.color ? { backgroundColor: pt.tag.color + '20', borderColor: pt.tag.color, color: pt.tag.color } : undefined}
|
||||||
|
className="text-xs"
|
||||||
|
>
|
||||||
|
{pt.tag.name}
|
||||||
|
{pt.tag.category && (
|
||||||
|
<span className="ml-1 opacity-60">({pt.tag.category})</span>
|
||||||
)}
|
)}
|
||||||
{project.tags && project.tags.length > 0 && (
|
</Badge>
|
||||||
project.tags.slice(0, 3).map((tag: string) => (
|
))
|
||||||
<Badge key={tag} variant="secondary">
|
: project.tags.map((tag: string) => (
|
||||||
|
<Badge key={tag} variant="secondary" className="text-xs">
|
||||||
{tag}
|
{tag}
|
||||||
</Badge>
|
</Badge>
|
||||||
))
|
))
|
||||||
)}
|
}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Description */}
|
{/* Description */}
|
||||||
{project.description && (
|
{project.description && (
|
||||||
|
|||||||
@@ -12,9 +12,10 @@ export default function JuryDeliberationPage({ params: paramsPromise }: { params
|
|||||||
const params = use(paramsPromise);
|
const params = use(paramsPromise);
|
||||||
const utils = trpc.useUtils();
|
const utils = trpc.useUtils();
|
||||||
|
|
||||||
const { data: session, isLoading } = trpc.deliberation.getSession.useQuery({
|
const { data: session, isLoading } = trpc.deliberation.getSession.useQuery(
|
||||||
sessionId: params.sessionId
|
{ sessionId: params.sessionId },
|
||||||
});
|
{ refetchInterval: 10_000 },
|
||||||
|
);
|
||||||
|
|
||||||
const submitVoteMutation = trpc.deliberation.submitVote.useMutation({
|
const submitVoteMutation = trpc.deliberation.submitVote.useMutation({
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
@@ -30,7 +31,7 @@ export default function JuryDeliberationPage({ params: paramsPromise }: { params
|
|||||||
votes.forEach((vote) => {
|
votes.forEach((vote) => {
|
||||||
submitVoteMutation.mutate({
|
submitVoteMutation.mutate({
|
||||||
sessionId: params.sessionId,
|
sessionId: params.sessionId,
|
||||||
juryMemberId: session?.currentUser?.id || '',
|
juryMemberId: '', // TODO: resolve current user's jury member ID from session participants
|
||||||
projectId: vote.projectId,
|
projectId: vote.projectId,
|
||||||
rank: vote.rank,
|
rank: vote.rank,
|
||||||
isWinnerPick: vote.isWinnerPick
|
isWinnerPick: vote.isWinnerPick
|
||||||
@@ -62,9 +63,9 @@ export default function JuryDeliberationPage({ params: paramsPromise }: { params
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const hasVoted = session.currentUser?.hasVoted;
|
const hasVoted = false; // TODO: check if current user has voted in this session
|
||||||
|
|
||||||
if (session.status !== 'DELIB_VOTING') {
|
if (session.status !== 'VOTING') {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<Card>
|
<Card>
|
||||||
@@ -78,7 +79,7 @@ export default function JuryDeliberationPage({ params: paramsPromise }: { params
|
|||||||
<p className="text-muted-foreground">
|
<p className="text-muted-foreground">
|
||||||
{session.status === 'DELIB_OPEN'
|
{session.status === 'DELIB_OPEN'
|
||||||
? 'Voting has not started yet. Please wait for the admin to open voting.'
|
? 'Voting has not started yet. Please wait for the admin to open voting.'
|
||||||
: session.status === 'DELIB_TALLYING'
|
: session.status === 'TALLYING'
|
||||||
? 'Voting is closed. Results are being tallied.'
|
? 'Voting is closed. Results are being tallied.'
|
||||||
: 'This session is locked.'}
|
: 'This session is locked.'}
|
||||||
</p>
|
</p>
|
||||||
@@ -139,7 +140,7 @@ export default function JuryDeliberationPage({ params: paramsPromise }: { params
|
|||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<DeliberationRankingForm
|
<DeliberationRankingForm
|
||||||
projects={session.projects || []}
|
projects={session.results?.map((r) => r.project) ?? []}
|
||||||
mode={session.mode}
|
mode={session.mode}
|
||||||
onSubmit={handleSubmitVote}
|
onSubmit={handleSubmitVote}
|
||||||
disabled={submitVoteMutation.isPending}
|
disabled={submitVoteMutation.isPending}
|
||||||
|
|||||||
@@ -3,41 +3,62 @@
|
|||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
import type { Route } from 'next'
|
import type { Route } from 'next'
|
||||||
import { trpc } from '@/lib/trpc/client'
|
import { trpc } from '@/lib/trpc/client'
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||||
import { Badge } from '@/components/ui/badge'
|
import { Badge } from '@/components/ui/badge'
|
||||||
import { Skeleton } from '@/components/ui/skeleton'
|
import { Skeleton } from '@/components/ui/skeleton'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { ArrowLeft, ArrowRight, ClipboardList, Target } from 'lucide-react'
|
import { StatusBadge } from '@/components/shared/status-badge'
|
||||||
import { toast } from 'sonner'
|
import { ProjectLogo } from '@/components/shared/project-logo'
|
||||||
|
import {
|
||||||
|
ArrowLeft,
|
||||||
|
ArrowRight,
|
||||||
|
ClipboardList,
|
||||||
|
CheckCircle2,
|
||||||
|
Clock,
|
||||||
|
FileEdit,
|
||||||
|
} from 'lucide-react'
|
||||||
|
import { formatDateOnly, formatEnumLabel } from '@/lib/utils'
|
||||||
|
|
||||||
export default function JuryCompetitionsPage() {
|
export default function JuryAssignmentsPage() {
|
||||||
const { data: competitions, isLoading } = trpc.competition.getMyCompetitions.useQuery()
|
const { data: assignments, isLoading } = trpc.assignment.myAssignments.useQuery({})
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<Skeleton className="h-8 w-64" />
|
<Skeleton className="h-8 w-64" />
|
||||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
<div className="space-y-3">
|
||||||
{[1, 2, 3].map((i) => (
|
{[1, 2, 3, 4].map((i) => (
|
||||||
<Skeleton key={i} className="h-40" />
|
<Skeleton key={i} className="h-24 w-full rounded-xl" />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Group assignments by round
|
||||||
|
const byRound = new Map<string, { round: { id: string; name: string; roundType: string; status: string; windowCloseAt: Date | null }; items: typeof assignments }>()
|
||||||
|
for (const a of assignments ?? []) {
|
||||||
|
if (!a.round) continue
|
||||||
|
if (!byRound.has(a.round.id)) {
|
||||||
|
byRound.set(a.round.id, { round: a.round, items: [] })
|
||||||
|
}
|
||||||
|
byRound.get(a.round.id)!.items!.push(a)
|
||||||
|
}
|
||||||
|
|
||||||
|
const roundGroups = Array.from(byRound.values())
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold tracking-tight text-brand-blue dark:text-foreground">
|
<h1 className="text-2xl font-bold tracking-tight text-brand-blue dark:text-foreground">
|
||||||
My Competitions
|
My Assignments
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-muted-foreground mt-1">
|
<p className="text-muted-foreground mt-1">
|
||||||
View competitions and rounds you're assigned to
|
Projects assigned to you for evaluation
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Button variant="ghost" size="sm" asChild>
|
<Button variant="ghost" size="sm" asChild className="hidden md:inline-flex">
|
||||||
<Link href={'/jury' as Route}>
|
<Link href={'/jury' as Route}>
|
||||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||||
Back to Dashboard
|
Back to Dashboard
|
||||||
@@ -45,65 +66,101 @@ export default function JuryCompetitionsPage() {
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{!competitions || competitions.length === 0 ? (
|
{roundGroups.length === 0 ? (
|
||||||
<Card>
|
<Card>
|
||||||
<CardContent className="flex flex-col items-center justify-center py-12">
|
<CardContent className="flex flex-col items-center justify-center py-12">
|
||||||
<div className="rounded-2xl bg-brand-teal/10 p-4 mb-4">
|
<div className="rounded-2xl bg-brand-teal/10 p-4 mb-4">
|
||||||
<ClipboardList className="h-8 w-8 text-brand-teal/60" />
|
<ClipboardList className="h-8 w-8 text-brand-teal/60" />
|
||||||
</div>
|
</div>
|
||||||
<h2 className="text-xl font-semibold mb-2">No Competitions</h2>
|
<h2 className="text-xl font-semibold mb-2">No Assignments</h2>
|
||||||
<p className="text-muted-foreground text-center max-w-md">
|
<p className="text-muted-foreground text-center max-w-md">
|
||||||
You don't have any active competition assignments yet.
|
You don't have any assignments yet. Assignments will appear once an administrator assigns projects to you.
|
||||||
</p>
|
</p>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
) : (
|
) : (
|
||||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
<div className="space-y-6">
|
||||||
{competitions.map((competition) => {
|
{roundGroups.map(({ round, items }) => {
|
||||||
const activeRounds = competition.rounds?.filter(r => r.status !== 'ROUND_ARCHIVED') || []
|
const completed = (items ?? []).filter(
|
||||||
const totalRounds = competition.rounds?.length || 0
|
(a) => a.evaluation?.status === 'SUBMITTED'
|
||||||
|
).length
|
||||||
|
const total = items?.length ?? 0
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card key={competition.id} className="flex flex-col transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md">
|
<Card key={round.id}>
|
||||||
<CardHeader>
|
<CardHeader className="pb-3">
|
||||||
<div className="flex items-start justify-between">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
<div>
|
<CardTitle className="text-base">{round.name}</CardTitle>
|
||||||
<CardTitle className="text-lg">{competition.name}</CardTitle>
|
<Badge variant="secondary" className="text-xs shrink-0">
|
||||||
</div>
|
{formatEnumLabel(round.roundType)}
|
||||||
<Badge variant="secondary">
|
|
||||||
{totalRounds} round{totalRounds !== 1 ? 's' : ''}
|
|
||||||
</Badge>
|
</Badge>
|
||||||
|
{round.status !== 'ROUND_ACTIVE' && (
|
||||||
|
<Badge variant="outline" className="text-xs text-muted-foreground shrink-0">
|
||||||
|
{formatEnumLabel(round.status)}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
<span className="text-xs text-muted-foreground ml-auto shrink-0">
|
||||||
|
{completed}/{total} completed
|
||||||
|
</span>
|
||||||
|
{round.windowCloseAt && (
|
||||||
|
<Badge variant="outline" className="text-xs gap-1 shrink-0">
|
||||||
|
<Clock className="h-3 w-3" />
|
||||||
|
Due {formatDateOnly(round.windowCloseAt)}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="flex-1 flex flex-col space-y-4">
|
<CardContent>
|
||||||
|
<div className="divide-y">
|
||||||
|
{(items ?? []).map((assignment) => {
|
||||||
|
const project = assignment.project
|
||||||
|
const evalStatus = assignment.evaluation?.status
|
||||||
|
const isSubmitted = evalStatus === 'SUBMITTED'
|
||||||
|
const isDraft = evalStatus === 'DRAFT'
|
||||||
|
|
||||||
<div className="flex-1" />
|
return (
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
{activeRounds.length > 0 ? (
|
|
||||||
activeRounds.slice(0, 2).map((round) => (
|
|
||||||
<Link
|
<Link
|
||||||
key={round.id}
|
key={assignment.id}
|
||||||
href={`/jury/competitions/${round.id}` as Route}
|
href={`/jury/competitions/${round.id}/projects/${project.id}` as Route}
|
||||||
className="flex items-center justify-between p-3 rounded-lg border border-border/60 hover:border-brand-blue/30 hover:bg-brand-blue/5 transition-all group"
|
className="block"
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-2 flex-1 min-w-0">
|
<div className="flex items-center gap-3 py-3 px-1 transition-colors hover:bg-muted/40 rounded-lg group">
|
||||||
<Target className="h-4 w-4 text-brand-teal shrink-0" />
|
<ProjectLogo
|
||||||
<span className="text-sm font-medium truncate">{round.name}</span>
|
project={project}
|
||||||
|
size="sm"
|
||||||
|
fallback="initials"
|
||||||
|
/>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="text-sm font-medium truncate group-hover:text-brand-blue transition-colors">
|
||||||
|
{project.title}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-muted-foreground mt-0.5">
|
||||||
|
{[project.teamName, project.country].filter(Boolean).join(' \u00b7 ')}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<ArrowRight className="h-4 w-4 text-muted-foreground group-hover:text-brand-blue transition-colors shrink-0" />
|
<div className="flex items-center gap-2 shrink-0">
|
||||||
</Link>
|
{isSubmitted ? (
|
||||||
))
|
<Badge variant="success" className="gap-1">
|
||||||
|
<CheckCircle2 className="h-3 w-3" />
|
||||||
|
Submitted
|
||||||
|
</Badge>
|
||||||
|
) : isDraft ? (
|
||||||
|
<Badge variant="warning" className="gap-1">
|
||||||
|
<FileEdit className="h-3 w-3" />
|
||||||
|
Draft
|
||||||
|
</Badge>
|
||||||
) : (
|
) : (
|
||||||
<p className="text-sm text-muted-foreground text-center py-2">
|
<Badge variant="secondary" className="gap-1">
|
||||||
No active rounds
|
<Clock className="h-3 w-3" />
|
||||||
</p>
|
Pending
|
||||||
)}
|
</Badge>
|
||||||
{activeRounds.length > 2 && (
|
|
||||||
<p className="text-xs text-muted-foreground text-center">
|
|
||||||
+{activeRounds.length - 2} more round{activeRounds.length - 2 !== 1 ? 's' : ''}
|
|
||||||
</p>
|
|
||||||
)}
|
)}
|
||||||
|
<ArrowRight className="h-4 w-4 text-muted-foreground group-hover:text-brand-blue transition-colors" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
)
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { Suspense } from 'react'
|
|||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
import { auth } from '@/lib/auth'
|
import { auth } from '@/lib/auth'
|
||||||
import { prisma } from '@/lib/prisma'
|
import { prisma } from '@/lib/prisma'
|
||||||
|
import { AutoRefresh } from '@/components/shared/auto-refresh'
|
||||||
|
|
||||||
export const metadata: Metadata = { title: 'Jury Dashboard' }
|
export const metadata: Metadata = { title: 'Jury Dashboard' }
|
||||||
export const dynamic = 'force-dynamic'
|
export const dynamic = 'force-dynamic'
|
||||||
@@ -24,12 +25,12 @@ import {
|
|||||||
GitCompare,
|
GitCompare,
|
||||||
Zap,
|
Zap,
|
||||||
BarChart3,
|
BarChart3,
|
||||||
Target,
|
|
||||||
Waves,
|
Waves,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { formatDateOnly } from '@/lib/utils'
|
import { formatDateOnly } from '@/lib/utils'
|
||||||
import { CountdownTimer } from '@/components/shared/countdown-timer'
|
import { CountdownTimer } from '@/components/shared/countdown-timer'
|
||||||
import { AnimatedCard } from '@/components/shared/animated-container'
|
import { AnimatedCard } from '@/components/shared/animated-container'
|
||||||
|
import { JuryPreferencesBanner } from '@/components/jury/preferences-banner'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
function getGreeting(): string {
|
function getGreeting(): string {
|
||||||
@@ -47,8 +48,8 @@ async function JuryDashboardContent() {
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get assignments and grace periods in parallel
|
// Get assignments, grace periods, and feature flags in parallel
|
||||||
const [assignments, gracePeriods] = await Promise.all([
|
const [assignments, gracePeriods, compareFlag] = await Promise.all([
|
||||||
prisma.assignment.findMany({
|
prisma.assignment.findMany({
|
||||||
where: { userId },
|
where: { userId },
|
||||||
include: {
|
include: {
|
||||||
@@ -106,8 +107,11 @@ async function JuryDashboardContent() {
|
|||||||
extendedUntil: true,
|
extendedUntil: true,
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
|
prisma.systemSettings.findUnique({ where: { key: 'jury_compare_enabled' } }),
|
||||||
])
|
])
|
||||||
|
|
||||||
|
const juryCompareEnabled = compareFlag?.value === 'true'
|
||||||
|
|
||||||
// Calculate stats
|
// Calculate stats
|
||||||
const totalAssignments = assignments.length
|
const totalAssignments = assignments.length
|
||||||
const completedAssignments = assignments.filter(
|
const completedAssignments = assignments.filter(
|
||||||
@@ -186,36 +190,28 @@ async function JuryDashboardContent() {
|
|||||||
|
|
||||||
const stats = [
|
const stats = [
|
||||||
{
|
{
|
||||||
label: 'Total Assignments',
|
|
||||||
value: totalAssignments,
|
value: totalAssignments,
|
||||||
icon: ClipboardList,
|
label: 'Assigned',
|
||||||
accentColor: 'border-l-blue-500',
|
detail: 'Total projects',
|
||||||
iconBg: 'bg-blue-50 dark:bg-blue-950/40',
|
accent: 'text-brand-blue',
|
||||||
iconColor: 'text-blue-600 dark:text-blue-400',
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Completed',
|
|
||||||
value: completedAssignments,
|
value: completedAssignments,
|
||||||
icon: CheckCircle2,
|
label: 'Completed',
|
||||||
accentColor: 'border-l-emerald-500',
|
detail: `${completionRate.toFixed(0)}% done`,
|
||||||
iconBg: 'bg-emerald-50 dark:bg-emerald-950/40',
|
accent: 'text-emerald-600',
|
||||||
iconColor: 'text-emerald-600 dark:text-emerald-400',
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'In Progress',
|
|
||||||
value: inProgressAssignments,
|
value: inProgressAssignments,
|
||||||
icon: Clock,
|
label: 'In draft',
|
||||||
accentColor: 'border-l-amber-500',
|
detail: inProgressAssignments > 0 ? 'Work in progress' : 'None started',
|
||||||
iconBg: 'bg-amber-50 dark:bg-amber-950/40',
|
accent: inProgressAssignments > 0 ? 'text-amber-600' : 'text-emerald-600',
|
||||||
iconColor: 'text-amber-600 dark:text-amber-400',
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Pending',
|
|
||||||
value: pendingAssignments,
|
value: pendingAssignments,
|
||||||
icon: Target,
|
label: 'Pending',
|
||||||
accentColor: 'border-l-slate-400',
|
detail: pendingAssignments > 0 ? 'Not yet started' : 'All started',
|
||||||
iconBg: 'bg-slate-50 dark:bg-slate-800/50',
|
accent: pendingAssignments > 0 ? 'text-amber-600' : 'text-emerald-600',
|
||||||
iconColor: 'text-slate-500 dark:text-slate-400',
|
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -235,7 +231,7 @@ async function JuryDashboardContent() {
|
|||||||
Your project assignments will appear here once an administrator assigns them to you.
|
Your project assignments will appear here once an administrator assigns them to you.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid gap-3 sm:grid-cols-2 max-w-md mx-auto">
|
<div className={`grid gap-3 max-w-md mx-auto ${juryCompareEnabled ? 'sm:grid-cols-2' : ''}`}>
|
||||||
<Link
|
<Link
|
||||||
href="/jury/competitions"
|
href="/jury/competitions"
|
||||||
className="group flex items-center gap-3 rounded-xl border border-border/60 p-3 transition-all duration-200 hover:border-brand-blue/30 hover:bg-brand-blue/5 hover:-translate-y-0.5 hover:shadow-md dark:hover:border-brand-teal/30 dark:hover:bg-brand-teal/5"
|
className="group flex items-center gap-3 rounded-xl border border-border/60 p-3 transition-all duration-200 hover:border-brand-blue/30 hover:bg-brand-blue/5 hover:-translate-y-0.5 hover:shadow-md dark:hover:border-brand-teal/30 dark:hover:bg-brand-teal/5"
|
||||||
@@ -248,6 +244,7 @@ async function JuryDashboardContent() {
|
|||||||
<p className="text-xs text-muted-foreground">View evaluations</p>
|
<p className="text-xs text-muted-foreground">View evaluations</p>
|
||||||
</div>
|
</div>
|
||||||
</Link>
|
</Link>
|
||||||
|
{juryCompareEnabled && (
|
||||||
<Link
|
<Link
|
||||||
href="/jury/competitions"
|
href="/jury/competitions"
|
||||||
className="group flex items-center gap-3 rounded-xl border border-border/60 p-3 transition-all duration-200 hover:border-brand-teal/30 hover:bg-brand-teal/5 hover:-translate-y-0.5 hover:shadow-md"
|
className="group flex items-center gap-3 rounded-xl border border-border/60 p-3 transition-all duration-200 hover:border-brand-teal/30 hover:bg-brand-teal/5 hover:-translate-y-0.5 hover:shadow-md"
|
||||||
@@ -260,6 +257,7 @@ async function JuryDashboardContent() {
|
|||||||
<p className="text-xs text-muted-foreground">Side-by-side view</p>
|
<p className="text-xs text-muted-foreground">Side-by-side view</p>
|
||||||
</div>
|
</div>
|
||||||
</Link>
|
</Link>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -300,48 +298,34 @@ async function JuryDashboardContent() {
|
|||||||
</AnimatedCard>
|
</AnimatedCard>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Stats + Overall Completion in one row */}
|
{/* Stats — editorial strip */}
|
||||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-5">
|
<AnimatedCard index={1}>
|
||||||
{stats.map((stat, i) => (
|
{/* Mobile: compact horizontal data strip */}
|
||||||
<AnimatedCard key={stat.label} index={i + 1}>
|
<div className="flex items-baseline justify-between border-b border-t py-3 md:hidden">
|
||||||
<Card className={cn(
|
{stats.map((s, i) => (
|
||||||
'border-l-4 transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md',
|
<div key={i} className={`flex-1 text-center ${i > 0 ? 'border-l border-border/50' : ''}`}>
|
||||||
stat.accentColor,
|
<span className="text-xl font-bold tabular-nums tracking-tight">{s.value}</span>
|
||||||
)}>
|
<p className="text-[10px] font-medium uppercase tracking-wider text-muted-foreground mt-0.5">{s.label}</p>
|
||||||
<CardContent className="flex items-center gap-4 py-5 px-5">
|
|
||||||
<div className={cn('rounded-xl p-3', stat.iconBg)}>
|
|
||||||
<stat.icon className={cn('h-5 w-5', stat.iconColor)} />
|
|
||||||
</div>
|
</div>
|
||||||
<div>
|
|
||||||
<p className="text-2xl font-bold tabular-nums tracking-tight">{stat.value}</p>
|
|
||||||
<p className="text-sm text-muted-foreground font-medium">{stat.label}</p>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</AnimatedCard>
|
|
||||||
))}
|
))}
|
||||||
{/* Overall completion as 5th stat card */}
|
|
||||||
<AnimatedCard index={5}>
|
|
||||||
<Card className="border-l-4 border-l-brand-teal transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md">
|
|
||||||
<CardContent className="flex items-center gap-4 py-5 px-5">
|
|
||||||
<div className="rounded-xl p-3 bg-brand-blue/10 dark:bg-brand-blue/20">
|
|
||||||
<BarChart3 className="h-5 w-5 text-brand-blue dark:text-brand-teal" />
|
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 min-w-0">
|
|
||||||
<p className="text-2xl font-bold tabular-nums tracking-tight text-brand-blue dark:text-brand-teal">
|
{/* Desktop: editorial stat row */}
|
||||||
{completionRate.toFixed(0)}%
|
<div className="hidden md:block">
|
||||||
</p>
|
<div className="grid grid-cols-4 gap-px rounded-lg bg-border/40 overflow-hidden">
|
||||||
<div className="relative h-1.5 w-full overflow-hidden rounded-full bg-muted/60 mt-1">
|
{stats.map((s, i) => (
|
||||||
<div
|
<div
|
||||||
className="h-full rounded-full bg-gradient-to-r from-brand-teal to-brand-blue transition-all duration-500 ease-out"
|
key={i}
|
||||||
style={{ width: `${completionRate}%` }}
|
className="bg-background px-5 py-4 group hover:bg-muted/30 transition-colors"
|
||||||
/>
|
>
|
||||||
|
<span className="text-3xl font-bold tabular-nums tracking-tight">{s.value}</span>
|
||||||
|
<p className="text-xs font-medium text-muted-foreground mt-1">{s.label}</p>
|
||||||
|
<p className={`text-xs mt-0.5 ${s.accent}`}>{s.detail}</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</AnimatedCard>
|
</AnimatedCard>
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Main content -- two column layout */}
|
{/* Main content -- two column layout */}
|
||||||
<div className="grid gap-4 lg:grid-cols-12">
|
<div className="grid gap-4 lg:grid-cols-12">
|
||||||
@@ -373,12 +357,7 @@ async function JuryDashboardContent() {
|
|||||||
const evaluation = assignment.evaluation
|
const evaluation = assignment.evaluation
|
||||||
const isCompleted = evaluation?.status === 'SUBMITTED'
|
const isCompleted = evaluation?.status === 'SUBMITTED'
|
||||||
const isDraft = evaluation?.status === 'DRAFT'
|
const isDraft = evaluation?.status === 'DRAFT'
|
||||||
const isVotingOpen =
|
const isVotingOpen = assignment.round.status === 'ROUND_ACTIVE'
|
||||||
assignment.round.status === 'ROUND_ACTIVE' &&
|
|
||||||
assignment.round.windowOpenAt &&
|
|
||||||
assignment.round.windowCloseAt &&
|
|
||||||
new Date(assignment.round.windowOpenAt) <= now &&
|
|
||||||
new Date(assignment.round.windowCloseAt) >= now
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -472,7 +451,7 @@ async function JuryDashboardContent() {
|
|||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="grid gap-3 sm:grid-cols-2">
|
<div className={`grid gap-3 ${juryCompareEnabled ? 'sm:grid-cols-2' : ''}`}>
|
||||||
<Link
|
<Link
|
||||||
href="/jury/competitions"
|
href="/jury/competitions"
|
||||||
className="group flex items-center gap-4 rounded-xl border border-border/60 p-4 transition-all duration-200 hover:border-brand-blue/30 hover:bg-brand-blue/5 hover:-translate-y-0.5 hover:shadow-md dark:hover:border-brand-teal/30 dark:hover:bg-brand-teal/5"
|
className="group flex items-center gap-4 rounded-xl border border-border/60 p-4 transition-all duration-200 hover:border-brand-blue/30 hover:bg-brand-blue/5 hover:-translate-y-0.5 hover:shadow-md dark:hover:border-brand-teal/30 dark:hover:bg-brand-teal/5"
|
||||||
@@ -485,6 +464,7 @@ async function JuryDashboardContent() {
|
|||||||
<p className="text-xs text-muted-foreground mt-0.5">View and manage evaluations</p>
|
<p className="text-xs text-muted-foreground mt-0.5">View and manage evaluations</p>
|
||||||
</div>
|
</div>
|
||||||
</Link>
|
</Link>
|
||||||
|
{juryCompareEnabled && (
|
||||||
<Link
|
<Link
|
||||||
href="/jury/competitions"
|
href="/jury/competitions"
|
||||||
className="group flex items-center gap-4 rounded-xl border border-border/60 p-4 transition-all duration-200 hover:border-brand-teal/30 hover:bg-brand-teal/5 hover:-translate-y-0.5 hover:shadow-md"
|
className="group flex items-center gap-4 rounded-xl border border-border/60 p-4 transition-all duration-200 hover:border-brand-teal/30 hover:bg-brand-teal/5 hover:-translate-y-0.5 hover:shadow-md"
|
||||||
@@ -497,6 +477,7 @@ async function JuryDashboardContent() {
|
|||||||
<p className="text-xs text-muted-foreground mt-0.5">Side-by-side comparison</p>
|
<p className="text-xs text-muted-foreground mt-0.5">Side-by-side comparison</p>
|
||||||
</div>
|
</div>
|
||||||
</Link>
|
</Link>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -669,30 +650,25 @@ function DashboardSkeleton() {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{/* Stats skeleton */}
|
{/* Stats skeleton */}
|
||||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
<div className="flex items-baseline justify-between border-b border-t py-3 md:hidden">
|
||||||
{[...Array(4)].map((_, i) => (
|
{[...Array(4)].map((_, i) => (
|
||||||
<Card key={i} className="border-l-4 border-l-muted">
|
<div key={i} className={`flex-1 text-center ${i > 0 ? 'border-l border-border/50' : ''}`}>
|
||||||
<CardContent className="flex items-center gap-4 py-5 px-5">
|
<Skeleton className="h-6 w-8 mx-auto" />
|
||||||
<Skeleton className="h-11 w-11 rounded-xl" />
|
<Skeleton className="h-3 w-14 mx-auto mt-1" />
|
||||||
<div className="space-y-2">
|
</div>
|
||||||
<Skeleton className="h-7 w-12" />
|
))}
|
||||||
<Skeleton className="h-4 w-24" />
|
</div>
|
||||||
|
<div className="hidden md:block">
|
||||||
|
<div className="grid grid-cols-4 gap-px rounded-lg bg-border/40 overflow-hidden">
|
||||||
|
{[...Array(4)].map((_, i) => (
|
||||||
|
<div key={i} className="bg-background px-5 py-4">
|
||||||
|
<Skeleton className="h-9 w-12" />
|
||||||
|
<Skeleton className="h-4 w-20 mt-1" />
|
||||||
|
<Skeleton className="h-3 w-16 mt-1" />
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
{/* Progress bar skeleton */}
|
|
||||||
<Card className="overflow-hidden">
|
|
||||||
<div className="h-1 w-full bg-muted" />
|
|
||||||
<CardContent className="py-5 px-6">
|
|
||||||
<div className="flex items-center justify-between mb-3">
|
|
||||||
<Skeleton className="h-4 w-36" />
|
|
||||||
<Skeleton className="h-7 w-16" />
|
|
||||||
</div>
|
</div>
|
||||||
<Skeleton className="h-3 w-full rounded-full" />
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
{/* Two-column skeleton */}
|
{/* Two-column skeleton */}
|
||||||
<div className="grid gap-6 lg:grid-cols-12">
|
<div className="grid gap-6 lg:grid-cols-12">
|
||||||
<div className="lg:col-span-7">
|
<div className="lg:col-span-7">
|
||||||
@@ -757,10 +733,16 @@ export default async function JuryDashboardPage() {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Preferences banner (shown when juror has unconfirmed preferences) */}
|
||||||
|
<JuryPreferencesBanner />
|
||||||
|
|
||||||
{/* Content */}
|
{/* Content */}
|
||||||
<Suspense fallback={<DashboardSkeleton />}>
|
<Suspense fallback={<DashboardSkeleton />}>
|
||||||
<JuryDashboardContent />
|
<JuryDashboardContent />
|
||||||
</Suspense>
|
</Suspense>
|
||||||
|
|
||||||
|
{/* Auto-refresh every 30s so voting round changes appear promptly */}
|
||||||
|
<AutoRefresh intervalMs={30_000} />
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,7 +18,12 @@ export default async function JuryLayout({
|
|||||||
select: { onboardingCompletedAt: true },
|
select: { onboardingCompletedAt: true },
|
||||||
})
|
})
|
||||||
|
|
||||||
if (!user?.onboardingCompletedAt) {
|
if (!user) {
|
||||||
|
// User was deleted — session is stale, send to login
|
||||||
|
redirect('/login')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!user.onboardingCompletedAt) {
|
||||||
redirect('/onboarding')
|
redirect('/onboarding')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -19,7 +19,12 @@ export default async function MentorLayout({
|
|||||||
select: { onboardingCompletedAt: true },
|
select: { onboardingCompletedAt: true },
|
||||||
})
|
})
|
||||||
|
|
||||||
if (!user?.onboardingCompletedAt) {
|
if (!user) {
|
||||||
|
// User was deleted — session is stale, send to login
|
||||||
|
redirect('/login')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!user.onboardingCompletedAt) {
|
||||||
redirect('/onboarding')
|
redirect('/onboarding')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { requireRole } from '@/lib/auth-redirect'
|
import { requireRole } from '@/lib/auth-redirect'
|
||||||
import { ObserverNav } from '@/components/layouts/observer-nav'
|
import { ObserverNav } from '@/components/layouts/observer-nav'
|
||||||
|
import { EditionProvider } from '@/components/observer/observer-edition-context'
|
||||||
|
|
||||||
export default async function ObserverLayout({
|
export default async function ObserverLayout({
|
||||||
children,
|
children,
|
||||||
@@ -10,6 +11,7 @@ export default async function ObserverLayout({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-background">
|
<div className="min-h-screen bg-background">
|
||||||
|
<EditionProvider>
|
||||||
<ObserverNav
|
<ObserverNav
|
||||||
user={{
|
user={{
|
||||||
name: session.user.name,
|
name: session.user.name,
|
||||||
@@ -17,6 +19,7 @@ export default async function ObserverLayout({
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<main className="container-app py-6">{children}</main>
|
<main className="container-app py-6">{children}</main>
|
||||||
|
</EditionProvider>
|
||||||
</div>
|
</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'
|
'use client'
|
||||||
|
|
||||||
import { useState } from 'react'
|
import { useState, useEffect, Suspense } from 'react'
|
||||||
|
import { useSearchParams } from 'next/navigation'
|
||||||
import { trpc } from '@/lib/trpc/client'
|
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 { Skeleton } from '@/components/ui/skeleton'
|
||||||
import {
|
|
||||||
Table,
|
|
||||||
TableBody,
|
|
||||||
TableCell,
|
|
||||||
TableHead,
|
|
||||||
TableHeader,
|
|
||||||
TableRow,
|
|
||||||
} from '@/components/ui/table'
|
|
||||||
import {
|
import {
|
||||||
Select,
|
Select,
|
||||||
SelectContent,
|
SelectContent,
|
||||||
@@ -29,702 +13,258 @@ import {
|
|||||||
} from '@/components/ui/select'
|
} from '@/components/ui/select'
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||||
import {
|
import {
|
||||||
FileSpreadsheet,
|
|
||||||
BarChart3,
|
|
||||||
Users,
|
|
||||||
ClipboardList,
|
|
||||||
CheckCircle2,
|
|
||||||
TrendingUp,
|
|
||||||
GitCompare,
|
|
||||||
UserCheck,
|
|
||||||
Globe,
|
Globe,
|
||||||
|
LayoutDashboard,
|
||||||
|
Filter,
|
||||||
|
FolderOpen,
|
||||||
|
TrendingUp,
|
||||||
|
Users,
|
||||||
|
BarChart3,
|
||||||
|
Upload,
|
||||||
|
Presentation,
|
||||||
|
Vote,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { formatDateOnly } from '@/lib/utils'
|
import type { LucideIcon } from 'lucide-react'
|
||||||
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'
|
|
||||||
|
|
||||||
// Parse selection value: "all:programId" for edition-wide, or roundId
|
import { GlobalAnalyticsTab } from '@/components/observer/reports/global-analytics-tab'
|
||||||
function parseSelection(value: string | null): { roundId?: string; programId?: string } {
|
import { IntakeReportTabs } from '@/components/observer/reports/intake-report-tabs'
|
||||||
if (!value) return {}
|
import { FilteringReportTabs } from '@/components/observer/reports/filtering-report-tabs'
|
||||||
if (value.startsWith('all:')) return { programId: value.slice(4) }
|
import { EvaluationReportTabs } from '@/components/observer/reports/evaluation-report-tabs'
|
||||||
return { roundId: value }
|
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 }) {
|
type Stage = {
|
||||||
const { data: programs, isLoading } = trpc.program.list.useQuery({ includeStages: true })
|
id: string
|
||||||
|
name: string
|
||||||
const stages = programs?.flatMap(p =>
|
status: string
|
||||||
(p.stages as { id: string; name: string; status: string; windowCloseAt: Date | null; _count: { projects: number; assignments: number } }[]).map(s => ({
|
roundType: string
|
||||||
...s,
|
windowCloseAt: Date | null
|
||||||
programName: `${p.year} Edition`,
|
_count: { projects: number; assignments: number; evaluations: number }
|
||||||
}))
|
programId: string
|
||||||
) || []
|
programName: string
|
||||||
|
|
||||||
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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const totalProjects = 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 === 'STAGE_ACTIVE'
|
|
||||||
? 'default'
|
|
||||||
: stage.status === 'STAGE_CLOSED'
|
|
||||||
? 'secondary'
|
|
||||||
: 'outline'
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{stage.status === 'STAGE_ACTIVE' ? 'Active' : stage.status === 'STAGE_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 === 'STAGE_ACTIVE'
|
|
||||||
? 'default'
|
|
||||||
: stage.status === 'STAGE_CLOSED'
|
|
||||||
? 'secondary'
|
|
||||||
: 'outline'
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{stage.status === 'STAGE_ACTIVE' ? 'Active' : stage.status === 'STAGE_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 }) {
|
type TabDef = { value: string; label: string; icon: LucideIcon }
|
||||||
const queryInput = parseSelection(selectedValue)
|
|
||||||
const hasSelection = !!queryInput.roundId || !!queryInput.programId
|
|
||||||
|
|
||||||
const { data: scoreDistribution, isLoading: scoreLoading } =
|
function getRoundTabs(roundType: string): TabDef[] {
|
||||||
trpc.analytics.getScoreDistribution.useQuery(
|
switch (roundType) {
|
||||||
queryInput,
|
case 'INTAKE':
|
||||||
{ enabled: hasSelection }
|
return [{ value: 'overview', label: 'Overview', icon: LayoutDashboard }]
|
||||||
)
|
case 'FILTERING':
|
||||||
|
return [
|
||||||
const { data: timeline, isLoading: timelineLoading } =
|
{ value: 'screening', label: 'Screening', icon: Filter },
|
||||||
trpc.analytics.getEvaluationTimeline.useQuery(
|
]
|
||||||
queryInput,
|
case 'EVALUATION':
|
||||||
{ enabled: hasSelection }
|
return [
|
||||||
)
|
{ value: 'evaluation', label: 'Evaluation', icon: TrendingUp },
|
||||||
|
]
|
||||||
const { data: statusBreakdown, isLoading: statusLoading } =
|
case 'SUBMISSION':
|
||||||
trpc.analytics.getStatusBreakdown.useQuery(
|
return [{ value: 'overview', label: 'Overview', icon: Upload }]
|
||||||
queryInput,
|
case 'MENTORING':
|
||||||
{ enabled: hasSelection }
|
return [{ value: 'overview', label: 'Overview', icon: Users }]
|
||||||
)
|
case 'LIVE_FINAL':
|
||||||
|
return [{ value: 'session', label: 'Session', icon: Presentation }]
|
||||||
const { data: jurorWorkload, isLoading: workloadLoading } =
|
case 'DELIBERATION':
|
||||||
trpc.analytics.getJurorWorkload.useQuery(
|
return [
|
||||||
queryInput,
|
{ value: 'deliberation', label: 'Deliberation', icon: Vote },
|
||||||
{ enabled: hasSelection }
|
]
|
||||||
)
|
default:
|
||||||
|
return []
|
||||||
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]
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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 }) {
|
function RoundTypeContent({
|
||||||
const queryInput = parseSelection(selectedValue)
|
roundType,
|
||||||
const hasSelection = !!queryInput.roundId || !!queryInput.programId
|
roundId,
|
||||||
|
programId,
|
||||||
const { data: consistency, isLoading } =
|
stages,
|
||||||
trpc.analytics.getJurorConsistency.useQuery(
|
selectedValue,
|
||||||
queryInput,
|
}: {
|
||||||
{ enabled: hasSelection }
|
roundType: string
|
||||||
)
|
roundId: string
|
||||||
|
programId: string
|
||||||
if (isLoading) return <Skeleton className="h-[400px]" />
|
stages: Stage[]
|
||||||
|
selectedValue: string | null
|
||||||
if (!consistency) return null
|
}) {
|
||||||
|
switch (roundType) {
|
||||||
|
case 'INTAKE':
|
||||||
|
return <IntakeReportTabs roundId={roundId} programId={programId} />
|
||||||
|
case 'FILTERING':
|
||||||
|
return <FilteringReportTabs roundId={roundId} programId={programId} />
|
||||||
|
case 'EVALUATION':
|
||||||
return (
|
return (
|
||||||
<JurorConsistencyChart
|
<EvaluationReportTabs
|
||||||
data={consistency as {
|
roundId={roundId}
|
||||||
overallAverage: number
|
programId={programId}
|
||||||
jurors: Array<{
|
stages={stages}
|
||||||
userId: string; name: string; email: string
|
selectedValue={selectedValue}
|
||||||
evaluationCount: number; averageScore: number
|
|
||||||
stddev: number; deviationFromOverall: number; isOutlier: boolean
|
|
||||||
}>
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
|
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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function DiversityTab({ selectedValue }: { selectedValue: string }) {
|
function ReportsPageContent() {
|
||||||
const queryInput = parseSelection(selectedValue)
|
const searchParams = useSearchParams()
|
||||||
const hasSelection = !!queryInput.roundId || !!queryInput.programId
|
const roundFromUrl = searchParams.get('round')
|
||||||
|
const [selectedValue, setSelectedValue] = useState<string | null>(roundFromUrl)
|
||||||
const { data: diversity, isLoading } =
|
const [activeTab, setActiveTab] = useState<string | null>(null)
|
||||||
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)
|
|
||||||
|
|
||||||
const { data: programs, isLoading: stagesLoading } = trpc.program.list.useQuery({ includeStages: true })
|
const { data: programs, isLoading: stagesLoading } = trpc.program.list.useQuery({ includeStages: true })
|
||||||
|
|
||||||
const stages = programs?.flatMap(p =>
|
const stages: Stage[] = programs?.flatMap(p =>
|
||||||
(p.stages as { id: string; name: string; status: string; windowCloseAt: Date | null; _count: { projects: number; assignments: number } }[]).map(s => ({
|
((p.stages || []) as { id: string; name: string; status: string; roundType: string; windowCloseAt: Date | null; _count: { projects: number; assignments: number; evaluations: number } }[]).map(s => ({
|
||||||
...s,
|
...s,
|
||||||
programId: p.id,
|
programId: p.id,
|
||||||
programName: `${p.year} Edition`,
|
programName: `${p.year} Edition`,
|
||||||
}))
|
}))
|
||||||
) || []
|
) ?? []
|
||||||
|
|
||||||
// Set default selected stage
|
const allRoundIds = stages.map((s) => s.id)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
if (stages.length && !selectedValue) {
|
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 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 (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* Header */}
|
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-semibold tracking-tight">Reports</h1>
|
<h1 className="text-2xl font-semibold tracking-tight">Reports</h1>
|
||||||
<p className="text-muted-foreground">
|
<p className="text-muted-foreground">View evaluation progress and statistics</p>
|
||||||
View evaluation progress and statistics
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Stage Selector */}
|
|
||||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:gap-4">
|
<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 ? (
|
{stagesLoading ? (
|
||||||
<Skeleton className="h-10 w-full sm:w-[300px]" />
|
<Skeleton className="h-10 w-full sm:w-[300px]" />
|
||||||
) : stages.length > 0 ? (
|
) : stages.length > 0 ? (
|
||||||
<Select value={selectedValue || ''} onValueChange={setSelectedValue}>
|
<Select value={selectedValue || ''} onValueChange={setSelectedValue}>
|
||||||
<SelectTrigger className="w-full sm:w-[300px]">
|
<SelectTrigger className="w-full sm:w-[300px]">
|
||||||
<SelectValue placeholder="Select a stage" />
|
<SelectValue placeholder="Select a round" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{programs?.map((p) => (
|
{programs?.map((p) => (
|
||||||
<SelectItem key={`all:${p.id}`} value={`all:${p.id}`}>
|
<SelectItem key={`all:${p.id}`} value={`all:${p.id}`}>
|
||||||
{p.year} Edition — All Stages
|
{p.year} Edition — All Rounds
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
))}
|
))}
|
||||||
{stages.map((stage) => (
|
{stages.map((stage) => (
|
||||||
<SelectItem key={stage.id} value={stage.id}>
|
<SelectItem key={stage.id} value={stage.id}>
|
||||||
{stage.programName} - {stage.name}
|
{stage.name}{stage.roundType ? ` (${ROUND_TYPE_LABELS[stage.roundType] || stage.roundType})` : ''}
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
))}
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
) : (
|
) : (
|
||||||
<p className="text-sm text-muted-foreground">No stages available</p>
|
<p className="text-sm text-muted-foreground">No rounds available</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Tabs */}
|
{selectedValue && (
|
||||||
<Tabs defaultValue="overview" className="space-y-6">
|
<Tabs value={activeTab ?? allTabs[0]?.value ?? 'global'} onValueChange={setActiveTab} className="space-y-6">
|
||||||
<div className="flex items-center justify-between flex-wrap gap-4">
|
|
||||||
<TabsList>
|
<TabsList>
|
||||||
<TabsTrigger value="overview" className="gap-2">
|
{allTabs.map((tab) => (
|
||||||
<FileSpreadsheet className="h-4 w-4" />
|
<TabsTrigger key={tab.value} value={tab.value} className="gap-2">
|
||||||
Overview
|
<tab.icon className="h-4 w-4" />
|
||||||
</TabsTrigger>
|
{tab.label}
|
||||||
<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>
|
</TabsTrigger>
|
||||||
|
))}
|
||||||
</TabsList>
|
</TabsList>
|
||||||
{selectedValue && !selectedValue.startsWith('all:') && (
|
|
||||||
<ExportPdfButton
|
<TabsContent value="global">
|
||||||
roundId={selectedValue}
|
<GlobalAnalyticsTab
|
||||||
roundName={selectedRound?.name}
|
programId={programId}
|
||||||
programName={selectedRound?.programName}
|
roundIds={allRoundIds.length >= 2 ? allRoundIds : undefined}
|
||||||
/>
|
/>
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<TabsContent value="overview">
|
|
||||||
<OverviewTab selectedValue={selectedValue} />
|
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
<TabsContent value="analytics">
|
{/* Round-type-specific or "All Rounds" progress tab */}
|
||||||
{hasSelection ? (
|
{roundSpecificTabs.map((tab) => (
|
||||||
<AnalyticsTab selectedValue={selectedValue!} />
|
<TabsContent key={tab.value} value={tab.value}>
|
||||||
) : (
|
{isAllRounds ? (
|
||||||
<Card>
|
<EvaluationReportTabs
|
||||||
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
|
roundId=""
|
||||||
<BarChart3 className="h-12 w-12 text-muted-foreground/50" />
|
programId={programId}
|
||||||
<p className="mt-2 font-medium">Select a round</p>
|
stages={stages}
|
||||||
<p className="text-sm text-muted-foreground">
|
selectedValue={selectedValue}
|
||||||
Choose a round or edition from the dropdown above to view analytics
|
/>
|
||||||
</p>
|
) : selectedRound ? (
|
||||||
</CardContent>
|
<RoundTypeContent
|
||||||
</Card>
|
roundType={roundType}
|
||||||
)}
|
roundId={selectedRound.id}
|
||||||
</TabsContent>
|
programId={programId}
|
||||||
|
stages={stages}
|
||||||
<TabsContent value="cross-stage">
|
selectedValue={selectedValue}
|
||||||
<CrossStageTab />
|
/>
|
||||||
</TabsContent>
|
) : null}
|
||||||
|
|
||||||
<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>
|
</TabsContent>
|
||||||
|
))}
|
||||||
</Tabs>
|
</Tabs>
|
||||||
|
)}
|
||||||
</div>
|
</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()
|
const now = new Date()
|
||||||
|
|
||||||
// Delete projects where isDraft=true AND draftExpiresAt has passed
|
// Delete projects where isDraft=true AND draftExpiresAt has passed
|
||||||
|
// Exclude test projects — they are managed separately
|
||||||
const result = await prisma.project.deleteMany({
|
const result = await prisma.project.deleteMany({
|
||||||
where: {
|
where: {
|
||||||
|
isTest: false,
|
||||||
isDraft: true,
|
isDraft: true,
|
||||||
draftExpiresAt: {
|
draftExpiresAt: {
|
||||||
lt: now,
|
lt: now,
|
||||||
|
|||||||
@@ -43,6 +43,44 @@
|
|||||||
/* Source the JS config for extended theme values */
|
/* Source the JS config for extended theme values */
|
||||||
@config "../../tailwind.config.ts";
|
@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 variables - using CSS custom properties with Tailwind v4 @theme */
|
||||||
@theme {
|
@theme {
|
||||||
/* Container */
|
/* Container */
|
||||||
@@ -294,3 +332,46 @@
|
|||||||
background: hsl(var(--muted-foreground) / 0.5);
|
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 './globals.css'
|
||||||
import { Providers } from './providers'
|
import { Providers } from './providers'
|
||||||
import { Toaster } from 'sonner'
|
import { Toaster } from 'sonner'
|
||||||
|
import { ImpersonationBanner } from '@/components/shared/impersonation-banner'
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: {
|
title: {
|
||||||
@@ -22,7 +23,10 @@ export default function RootLayout({
|
|||||||
return (
|
return (
|
||||||
<html lang="en" suppressHydrationWarning>
|
<html lang="en" suppressHydrationWarning>
|
||||||
<body className="min-h-screen bg-background font-sans antialiased">
|
<body className="min-h-screen bg-background font-sans antialiased">
|
||||||
<Providers>{children}</Providers>
|
<Providers>
|
||||||
|
<ImpersonationBanner />
|
||||||
|
{children}
|
||||||
|
</Providers>
|
||||||
<Toaster
|
<Toaster
|
||||||
position="top-right"
|
position="top-right"
|
||||||
toastOptions={{
|
toastOptions={{
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -7,13 +7,14 @@ import { AlertCircle, CheckCircle2, Users } from 'lucide-react'
|
|||||||
|
|
||||||
interface CoverageReportProps {
|
interface CoverageReportProps {
|
||||||
roundId: string
|
roundId: string
|
||||||
|
requiredReviews?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export function CoverageReport({ roundId }: CoverageReportProps) {
|
export function CoverageReport({ roundId, requiredReviews = 3 }: CoverageReportProps) {
|
||||||
const { data: coverage, isLoading } = trpc.roundAssignment.coverageReport.useQuery({
|
const { data: coverage, isLoading } = trpc.roundAssignment.coverageReport.useQuery(
|
||||||
roundId,
|
{ roundId, requiredReviews },
|
||||||
requiredReviews: 3,
|
{ refetchInterval: 15_000 },
|
||||||
})
|
)
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
@@ -71,7 +72,7 @@ export function CoverageReport({ roundId }: CoverageReportProps) {
|
|||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="text-2xl font-bold">{unassignedCount}</div>
|
<div className="text-2xl font-bold">{unassignedCount}</div>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
Projects below 3 reviews
|
Projects below {requiredReviews} reviews
|
||||||
</p>
|
</p>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@@ -37,10 +37,9 @@ type RoundSummary = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function CompetitionTimeline({
|
export function CompetitionTimeline({
|
||||||
competitionId,
|
|
||||||
rounds,
|
rounds,
|
||||||
}: {
|
}: {
|
||||||
competitionId: string
|
competitionId?: string
|
||||||
rounds: RoundSummary[]
|
rounds: RoundSummary[]
|
||||||
}) {
|
}) {
|
||||||
if (rounds.length === 0) {
|
if (rounds.length === 0) {
|
||||||
@@ -70,7 +69,7 @@ export function CompetitionTimeline({
|
|||||||
return (
|
return (
|
||||||
<div key={round.id} className="flex items-start">
|
<div key={round.id} className="flex items-start">
|
||||||
<Link
|
<Link
|
||||||
href={`/admin/competitions/${competitionId}/rounds/${round.id}` as Route}
|
href={`/admin/rounds/${round.id}` as Route}
|
||||||
className="group flex flex-col items-center text-center w-32 shrink-0"
|
className="group flex flex-col items-center text-center w-32 shrink-0"
|
||||||
>
|
>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
@@ -116,7 +115,7 @@ export function CompetitionTimeline({
|
|||||||
return (
|
return (
|
||||||
<div key={round.id}>
|
<div key={round.id}>
|
||||||
<Link
|
<Link
|
||||||
href={`/admin/competitions/${competitionId}/rounds/${round.id}` as Route}
|
href={`/admin/rounds/${round.id}` as Route}
|
||||||
className="flex items-start gap-3 py-2 hover:bg-muted/50 rounded-md px-2 -mx-2 transition-colors"
|
className="flex items-start gap-3 py-2 hover:bg-muted/50 rounded-md px-2 -mx-2 transition-colors"
|
||||||
>
|
>
|
||||||
<div className="flex flex-col items-center shrink-0">
|
<div className="flex flex-col items-center shrink-0">
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ export function AdminOverrideDialog({
|
|||||||
|
|
||||||
const { data: session } = trpc.deliberation.getSession.useQuery(
|
const { data: session } = trpc.deliberation.getSession.useQuery(
|
||||||
{ sessionId },
|
{ sessionId },
|
||||||
{ enabled: open }
|
{ enabled: open, refetchInterval: 10_000 }
|
||||||
);
|
);
|
||||||
|
|
||||||
const adminDecideMutation = trpc.deliberation.adminDecide.useMutation({
|
const adminDecideMutation = trpc.deliberation.adminDecide.useMutation({
|
||||||
@@ -91,7 +91,7 @@ export function AdminOverrideDialog({
|
|||||||
<Label>Project Rankings</Label>
|
<Label>Project Rankings</Label>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{projectIds.map((projectId) => {
|
{projectIds.map((projectId) => {
|
||||||
const project = session?.projects?.find((p: any) => p.id === projectId);
|
const project = session?.results?.find((r) => r.project.id === projectId)?.project;
|
||||||
return (
|
return (
|
||||||
<div key={projectId} className="flex items-center gap-3">
|
<div key={projectId} className="flex items-center gap-3">
|
||||||
<Input
|
<Input
|
||||||
|
|||||||
@@ -18,8 +18,17 @@ export function ResultsPanel({ sessionId }: ResultsPanelProps) {
|
|||||||
const utils = trpc.useUtils();
|
const utils = trpc.useUtils();
|
||||||
const [overrideDialogOpen, setOverrideDialogOpen] = useState(false);
|
const [overrideDialogOpen, setOverrideDialogOpen] = useState(false);
|
||||||
|
|
||||||
const { data: session } = trpc.deliberation.getSession.useQuery({ sessionId });
|
const { data: session } = trpc.deliberation.getSession.useQuery(
|
||||||
const { data: aggregatedResults } = trpc.deliberation.aggregate.useQuery({ sessionId });
|
{ sessionId },
|
||||||
|
{ refetchInterval: 10_000 }
|
||||||
|
);
|
||||||
|
const { data: aggregatedResults } = trpc.deliberation.aggregate.useQuery(
|
||||||
|
{ sessionId },
|
||||||
|
{
|
||||||
|
refetchInterval: 10_000,
|
||||||
|
enabled: session?.status === 'TALLYING' || session?.status === 'RUNOFF' || session?.status === 'DELIB_LOCKED',
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
const initRunoffMutation = trpc.deliberation.initRunoff.useMutation({
|
const initRunoffMutation = trpc.deliberation.initRunoff.useMutation({
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
@@ -46,35 +55,33 @@ export function ResultsPanel({ sessionId }: ResultsPanelProps) {
|
|||||||
return (
|
return (
|
||||||
<Card>
|
<Card>
|
||||||
<CardContent className="p-12 text-center">
|
<CardContent className="p-12 text-center">
|
||||||
<p className="text-muted-foreground">No voting results yet</p>
|
<p className="text-muted-foreground">
|
||||||
|
{session?.status === 'DELIB_OPEN' || session?.status === 'VOTING'
|
||||||
|
? 'Voting has not been tallied yet'
|
||||||
|
: 'No voting results yet'}
|
||||||
|
</p>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Detect ties: check if two or more top-ranked candidates share the same totalScore
|
// Detect ties using the backend-computed flag, with client-side fallback
|
||||||
const hasTie = (() => {
|
const hasTie = aggregatedResults.hasTies ?? (() => {
|
||||||
const rankings = aggregatedResults.rankings as Array<{ totalScore?: number; projectId: string }> | undefined;
|
const rankings = aggregatedResults.rankings as Array<{ score?: number; projectId: string }> | undefined;
|
||||||
if (!rankings || rankings.length < 2) return false;
|
if (!rankings || rankings.length < 2) return false;
|
||||||
// Group projects by totalScore
|
|
||||||
const scoreGroups = new Map<number, string[]>();
|
const scoreGroups = new Map<number, string[]>();
|
||||||
for (const r of rankings) {
|
for (const r of rankings) {
|
||||||
const score = r.totalScore ?? 0;
|
const score = r.score ?? 0;
|
||||||
const group = scoreGroups.get(score) || [];
|
const group = scoreGroups.get(score) || [];
|
||||||
group.push(r.projectId);
|
group.push(r.projectId);
|
||||||
scoreGroups.set(score, group);
|
scoreGroups.set(score, group);
|
||||||
}
|
}
|
||||||
// A tie exists if the highest score is shared by 2+ projects
|
|
||||||
const topScore = Math.max(...scoreGroups.keys());
|
const topScore = Math.max(...scoreGroups.keys());
|
||||||
const topGroup = scoreGroups.get(topScore);
|
const topGroup = scoreGroups.get(topScore);
|
||||||
return (topGroup?.length ?? 0) >= 2;
|
return (topGroup?.length ?? 0) >= 2;
|
||||||
})();
|
})();
|
||||||
const tiedProjectIds = hasTie
|
const tiedProjectIds = aggregatedResults.tiedProjectIds ?? [];
|
||||||
? (aggregatedResults.rankings as Array<{ totalScore?: number; projectId: string }>)
|
const canFinalize = session?.status === 'TALLYING' && !hasTie;
|
||||||
.filter((r) => r.totalScore === (aggregatedResults.rankings as Array<{ totalScore?: number }>)[0]?.totalScore)
|
|
||||||
.map((r) => r.projectId)
|
|
||||||
: [];
|
|
||||||
const canFinalize = session?.status === 'DELIB_TALLYING' && !hasTie;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
@@ -95,17 +102,17 @@ export function ResultsPanel({ sessionId }: ResultsPanelProps) {
|
|||||||
>
|
>
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-primary/10 font-bold">
|
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-primary/10 font-bold">
|
||||||
#{index + 1}
|
#{result.rank ?? index + 1}
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p className="font-medium">{result.projectTitle}</p>
|
<p className="font-medium">{result.projectTitle ?? result.projectId}</p>
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
{result.votes} votes • {result.averageRank?.toFixed(2)} avg rank
|
{result.voteCount} votes
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Badge variant="outline" className="text-lg">
|
<Badge variant="outline" className="text-lg">
|
||||||
{result.totalScore?.toFixed(1) || 0}
|
{result.score?.toFixed?.(1) ?? 0}
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useState } from 'react'
|
|
||||||
import { trpc } from '@/lib/trpc/client'
|
import { trpc } from '@/lib/trpc/client'
|
||||||
import {
|
import {
|
||||||
Card,
|
Card,
|
||||||
@@ -42,10 +41,21 @@ interface EvaluationSummaryCardProps {
|
|||||||
roundId: string
|
roundId: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface BooleanStats {
|
||||||
|
yesCount: number
|
||||||
|
noCount: number
|
||||||
|
total: number
|
||||||
|
yesPercent: number
|
||||||
|
trueLabel: string
|
||||||
|
falseLabel: string
|
||||||
|
}
|
||||||
|
|
||||||
interface ScoringPatterns {
|
interface ScoringPatterns {
|
||||||
averageGlobalScore: number | null
|
averageGlobalScore: number | null
|
||||||
consensus: number
|
consensus: number
|
||||||
criterionAverages: Record<string, number>
|
criterionAverages: Record<string, number>
|
||||||
|
booleanCriteria?: Record<string, BooleanStats>
|
||||||
|
textResponses?: Record<string, string[]>
|
||||||
evaluatorCount: number
|
evaluatorCount: number
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -74,8 +84,6 @@ export function EvaluationSummaryCard({
|
|||||||
projectId,
|
projectId,
|
||||||
roundId,
|
roundId,
|
||||||
}: EvaluationSummaryCardProps) {
|
}: EvaluationSummaryCardProps) {
|
||||||
const [isGenerating, setIsGenerating] = useState(false)
|
|
||||||
|
|
||||||
const {
|
const {
|
||||||
data: summary,
|
data: summary,
|
||||||
isLoading,
|
isLoading,
|
||||||
@@ -86,19 +94,18 @@ export function EvaluationSummaryCard({
|
|||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
toast.success('AI summary generated successfully')
|
toast.success('AI summary generated successfully')
|
||||||
refetch()
|
refetch()
|
||||||
setIsGenerating(false)
|
|
||||||
},
|
},
|
||||||
onError: (error) => {
|
onError: (error) => {
|
||||||
toast.error(error.message || 'Failed to generate summary')
|
toast.error(error.message || 'Failed to generate summary')
|
||||||
setIsGenerating(false)
|
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
const handleGenerate = () => {
|
const handleGenerate = () => {
|
||||||
setIsGenerating(true)
|
|
||||||
generateMutation.mutate({ projectId, roundId })
|
generateMutation.mutate({ projectId, roundId })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const isGenerating = generateMutation.isPending
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<Card>
|
<Card>
|
||||||
@@ -296,10 +303,10 @@ export function EvaluationSummaryCard({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Criterion Averages */}
|
{/* Criterion Averages (Numeric) */}
|
||||||
{Object.keys(patterns.criterionAverages).length > 0 && (
|
{Object.keys(patterns.criterionAverages).length > 0 && (
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm font-medium mb-2">Criterion Averages</p>
|
<p className="text-sm font-medium mb-2">Score Averages</p>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{Object.entries(patterns.criterionAverages).map(([label, avg]) => (
|
{Object.entries(patterns.criterionAverages).map(([label, avg]) => (
|
||||||
<div key={label} className="flex items-center gap-3">
|
<div key={label} className="flex items-center gap-3">
|
||||||
@@ -323,6 +330,69 @@ export function EvaluationSummaryCard({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Boolean Criteria (Yes/No) */}
|
||||||
|
{patterns.booleanCriteria && Object.keys(patterns.booleanCriteria).length > 0 && (
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium mb-2">Yes/No Decisions</p>
|
||||||
|
<div className="space-y-3">
|
||||||
|
{Object.entries(patterns.booleanCriteria).map(([label, stats]) => (
|
||||||
|
<div key={label} className="space-y-1">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-sm text-muted-foreground truncate">
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-muted-foreground flex-shrink-0 ml-2">
|
||||||
|
{stats.yesCount} {stats.trueLabel} / {stats.noCount} {stats.falseLabel}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex h-2 rounded-full overflow-hidden bg-muted">
|
||||||
|
{stats.yesCount > 0 && (
|
||||||
|
<div
|
||||||
|
className="h-full bg-emerald-500 transition-all"
|
||||||
|
style={{ width: `${stats.yesPercent}%` }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{stats.noCount > 0 && (
|
||||||
|
<div
|
||||||
|
className="h-full bg-red-400 transition-all"
|
||||||
|
style={{ width: `${100 - stats.yesPercent}%` }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between text-xs">
|
||||||
|
<span className="text-emerald-600">{stats.yesPercent}% {stats.trueLabel}</span>
|
||||||
|
<span className="text-red-500">{100 - stats.yesPercent}% {stats.falseLabel}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Text Responses */}
|
||||||
|
{patterns.textResponses && Object.keys(patterns.textResponses).length > 0 && (
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium mb-2">Text Responses</p>
|
||||||
|
<div className="space-y-3">
|
||||||
|
{Object.entries(patterns.textResponses).map(([label, responses]) => (
|
||||||
|
<div key={label} className="space-y-1.5">
|
||||||
|
<p className="text-sm text-muted-foreground">{label}</p>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
{responses.map((text, i) => (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
className="text-sm p-2 rounded border bg-muted/50 whitespace-pre-wrap"
|
||||||
|
>
|
||||||
|
{text}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Recommendation */}
|
{/* Recommendation */}
|
||||||
{summaryData.recommendation && (
|
{summaryData.recommendation && (
|
||||||
<div className="p-3 rounded-lg bg-blue-500/10 border border-blue-200">
|
<div className="p-3 rounded-lg bg-blue-500/10 border border-blue-200">
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { Search } from 'lucide-react'
|
import { Search, UserPlus, Mail } from 'lucide-react'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
import { trpc } from '@/lib/trpc/client'
|
import { trpc } from '@/lib/trpc/client'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
@@ -15,6 +15,8 @@ import {
|
|||||||
} from '@/components/ui/dialog'
|
} from '@/components/ui/dialog'
|
||||||
import { Input } from '@/components/ui/input'
|
import { Input } from '@/components/ui/input'
|
||||||
import { Label } from '@/components/ui/label'
|
import { Label } from '@/components/ui/label'
|
||||||
|
import { Slider } from '@/components/ui/slider'
|
||||||
|
import { Textarea } from '@/components/ui/textarea'
|
||||||
import {
|
import {
|
||||||
Select,
|
Select,
|
||||||
SelectContent,
|
SelectContent,
|
||||||
@@ -22,6 +24,7 @@ import {
|
|||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from '@/components/ui/select'
|
} from '@/components/ui/select'
|
||||||
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||||
|
|
||||||
interface AddMemberDialogProps {
|
interface AddMemberDialogProps {
|
||||||
juryGroupId: string
|
juryGroupId: string
|
||||||
@@ -30,10 +33,26 @@ interface AddMemberDialogProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function AddMemberDialog({ juryGroupId, open, onOpenChange }: AddMemberDialogProps) {
|
export function AddMemberDialog({ juryGroupId, open, onOpenChange }: AddMemberDialogProps) {
|
||||||
|
const [tab, setTab] = useState<'search' | 'invite'>('search')
|
||||||
|
|
||||||
|
// Search existing user state
|
||||||
const [searchQuery, setSearchQuery] = useState('')
|
const [searchQuery, setSearchQuery] = useState('')
|
||||||
const [selectedUserId, setSelectedUserId] = useState<string>('')
|
const [selectedUserId, setSelectedUserId] = useState<string>('')
|
||||||
const [role, setRole] = useState<'CHAIR' | 'MEMBER' | 'OBSERVER'>('MEMBER')
|
|
||||||
const [maxAssignments, setMaxAssignments] = useState<string>('')
|
const [maxAssignments, setMaxAssignments] = useState<string>('')
|
||||||
|
const [capMode, setCapMode] = useState<string>('')
|
||||||
|
const [role, setRole] = useState<string>('MEMBER')
|
||||||
|
const [startupRatio, setStartupRatio] = useState<number | null>(null)
|
||||||
|
const [availabilityNotes, setAvailabilityNotes] = useState('')
|
||||||
|
|
||||||
|
// Invite new user state
|
||||||
|
const [inviteName, setInviteName] = useState('')
|
||||||
|
const [inviteEmail, setInviteEmail] = useState('')
|
||||||
|
const [inviteMaxAssignments, setInviteMaxAssignments] = useState<string>('')
|
||||||
|
const [inviteCapMode, setInviteCapMode] = useState<string>('')
|
||||||
|
const [inviteRole, setInviteRole] = useState<string>('MEMBER')
|
||||||
|
const [inviteStartupRatio, setInviteStartupRatio] = useState<number | null>(null)
|
||||||
|
const [inviteAvailabilityNotes, setInviteAvailabilityNotes] = useState('')
|
||||||
|
const [inviteExpertise, setInviteExpertise] = useState('')
|
||||||
|
|
||||||
const utils = trpc.useUtils()
|
const utils = trpc.useUtils()
|
||||||
|
|
||||||
@@ -44,7 +63,7 @@ export function AddMemberDialog({ juryGroupId, open, onOpenChange }: AddMemberDi
|
|||||||
|
|
||||||
const users = userResponse?.users || []
|
const users = userResponse?.users || []
|
||||||
|
|
||||||
const { mutate: addMember, isPending } = trpc.juryGroup.addMember.useMutation({
|
const { mutate: addMember, isPending: isAdding } = trpc.juryGroup.addMember.useMutation({
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
utils.juryGroup.getById.invalidate({ id: juryGroupId })
|
utils.juryGroup.getById.invalidate({ id: juryGroupId })
|
||||||
toast.success('Member added successfully')
|
toast.success('Member added successfully')
|
||||||
@@ -56,14 +75,56 @@ export function AddMemberDialog({ juryGroupId, open, onOpenChange }: AddMemberDi
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const { mutate: createUser, isPending: isCreating } = trpc.user.create.useMutation({
|
||||||
|
onSuccess: (newUser) => {
|
||||||
|
// Immediately add the newly created user to the jury group
|
||||||
|
addMember({
|
||||||
|
juryGroupId,
|
||||||
|
userId: newUser.id,
|
||||||
|
role: inviteRole as 'CHAIR' | 'MEMBER' | 'OBSERVER',
|
||||||
|
maxAssignmentsOverride: inviteMaxAssignments ? parseInt(inviteMaxAssignments, 10) : null,
|
||||||
|
capModeOverride: inviteCapMode && inviteCapMode !== 'DEFAULT' ? (inviteCapMode as 'HARD' | 'SOFT' | 'NONE') : null,
|
||||||
|
preferredStartupRatio: inviteStartupRatio,
|
||||||
|
availabilityNotes: inviteAvailabilityNotes.trim() || null,
|
||||||
|
})
|
||||||
|
// Send invitation email
|
||||||
|
sendInvitation({ userId: newUser.id, juryGroupId })
|
||||||
|
},
|
||||||
|
onError: (err) => {
|
||||||
|
toast.error(err.message)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const { mutate: sendInvitation } = trpc.user.sendInvitation.useMutation({
|
||||||
|
onSuccess: (result) => {
|
||||||
|
toast.success(`Invitation sent to ${result.email}`)
|
||||||
|
utils.user.list.invalidate()
|
||||||
|
},
|
||||||
|
onError: (err) => {
|
||||||
|
// Don't block — user was created and added, just invitation failed
|
||||||
|
toast.error(`Member added but invitation email failed: ${err.message}`)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
const resetForm = () => {
|
const resetForm = () => {
|
||||||
setSearchQuery('')
|
setSearchQuery('')
|
||||||
setSelectedUserId('')
|
setSelectedUserId('')
|
||||||
setRole('MEMBER')
|
|
||||||
setMaxAssignments('')
|
setMaxAssignments('')
|
||||||
|
setCapMode('')
|
||||||
|
setRole('MEMBER')
|
||||||
|
setStartupRatio(null)
|
||||||
|
setAvailabilityNotes('')
|
||||||
|
setInviteName('')
|
||||||
|
setInviteEmail('')
|
||||||
|
setInviteMaxAssignments('')
|
||||||
|
setInviteCapMode('')
|
||||||
|
setInviteRole('MEMBER')
|
||||||
|
setInviteStartupRatio(null)
|
||||||
|
setInviteAvailabilityNotes('')
|
||||||
|
setInviteExpertise('')
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleSubmit = (e: React.FormEvent) => {
|
const handleSearchSubmit = (e: React.FormEvent) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
|
|
||||||
if (!selectedUserId) {
|
if (!selectedUserId) {
|
||||||
@@ -74,22 +135,63 @@ export function AddMemberDialog({ juryGroupId, open, onOpenChange }: AddMemberDi
|
|||||||
addMember({
|
addMember({
|
||||||
juryGroupId,
|
juryGroupId,
|
||||||
userId: selectedUserId,
|
userId: selectedUserId,
|
||||||
role,
|
role: role as 'CHAIR' | 'MEMBER' | 'OBSERVER',
|
||||||
maxAssignmentsOverride: maxAssignments ? parseInt(maxAssignments, 10) : null,
|
maxAssignmentsOverride: maxAssignments ? parseInt(maxAssignments, 10) : null,
|
||||||
|
capModeOverride: capMode && capMode !== 'DEFAULT' ? (capMode as 'HARD' | 'SOFT' | 'NONE') : null,
|
||||||
|
preferredStartupRatio: startupRatio,
|
||||||
|
availabilityNotes: availabilityNotes.trim() || null,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleInviteSubmit = (e: React.FormEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
|
||||||
|
if (!inviteEmail.trim()) {
|
||||||
|
toast.error('Please enter an email address')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const expertiseTags = inviteExpertise
|
||||||
|
.split(',')
|
||||||
|
.map((t) => t.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
|
||||||
|
createUser({
|
||||||
|
email: inviteEmail.trim(),
|
||||||
|
name: inviteName.trim() || undefined,
|
||||||
|
role: 'JURY_MEMBER',
|
||||||
|
expertiseTags: expertiseTags.length > 0 ? expertiseTags : undefined,
|
||||||
|
maxAssignments: inviteMaxAssignments ? parseInt(inviteMaxAssignments, 10) : undefined,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const isPending = isAdding || isCreating
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
<DialogContent className="sm:max-w-md">
|
<DialogContent className="sm:max-w-lg">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Add Member to Jury Group</DialogTitle>
|
<DialogTitle>Add Member to Jury Group</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
Search for a user and assign them to this jury group
|
Search for an existing user or invite a new juror to the platform
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<form onSubmit={handleSubmit} className="space-y-4">
|
<Tabs value={tab} onValueChange={(v) => setTab(v as 'search' | 'invite')}>
|
||||||
|
<TabsList className="grid w-full grid-cols-2">
|
||||||
|
<TabsTrigger value="search" className="flex items-center gap-2">
|
||||||
|
<Search className="h-3.5 w-3.5" />
|
||||||
|
Search Existing
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="invite" className="flex items-center gap-2">
|
||||||
|
<Mail className="h-3.5 w-3.5" />
|
||||||
|
Invite New
|
||||||
|
</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
|
||||||
|
{/* Search existing user tab */}
|
||||||
|
<TabsContent value="search">
|
||||||
|
<form onSubmit={handleSearchSubmit} className="space-y-4 pt-2">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="search">Search User</Label>
|
<Label htmlFor="search">Search User</Label>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
@@ -127,9 +229,10 @@ export function AddMemberDialog({ juryGroupId, open, onOpenChange }: AddMemberDi
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="role">Role</Label>
|
<Label htmlFor="role">Role</Label>
|
||||||
<Select value={role} onValueChange={(val) => setRole(val as any)}>
|
<Select value={role} onValueChange={setRole}>
|
||||||
<SelectTrigger id="role">
|
<SelectTrigger id="role">
|
||||||
<SelectValue />
|
<SelectValue />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
@@ -140,6 +243,21 @@ export function AddMemberDialog({ juryGroupId, open, onOpenChange }: AddMemberDi
|
|||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="capMode">Cap Mode</Label>
|
||||||
|
<Select value={capMode || 'DEFAULT'} onValueChange={setCapMode}>
|
||||||
|
<SelectTrigger id="capMode">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="DEFAULT">Group Default</SelectItem>
|
||||||
|
<SelectItem value="HARD">Hard Cap</SelectItem>
|
||||||
|
<SelectItem value="SOFT">Soft Cap</SelectItem>
|
||||||
|
<SelectItem value="NONE">No Cap</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="maxAssignments">Max Assignments Override (optional)</Label>
|
<Label htmlFor="maxAssignments">Max Assignments Override (optional)</Label>
|
||||||
@@ -153,15 +271,183 @@ export function AddMemberDialog({ juryGroupId, open, onOpenChange }: AddMemberDi
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Category Preference</Label>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<span className="text-xs text-muted-foreground w-20 shrink-0">Startup</span>
|
||||||
|
<Slider
|
||||||
|
value={[startupRatio !== null ? startupRatio * 100 : 50]}
|
||||||
|
onValueChange={([v]) => setStartupRatio(v / 100)}
|
||||||
|
min={0}
|
||||||
|
max={100}
|
||||||
|
step={10}
|
||||||
|
className="flex-1"
|
||||||
|
/>
|
||||||
|
<span className="text-xs text-muted-foreground w-20 shrink-0 text-right">Concept</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{startupRatio !== null
|
||||||
|
? `~${Math.round(startupRatio * 100)}% startups / ~${Math.round((1 - startupRatio) * 100)}% concepts`
|
||||||
|
: 'No preference set (balanced distribution)'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="availabilityNotes">Availability Notes (optional)</Label>
|
||||||
|
<Textarea
|
||||||
|
id="availabilityNotes"
|
||||||
|
placeholder="e.g. Available only in March, limited to 5 reviews/week..."
|
||||||
|
rows={2}
|
||||||
|
value={availabilityNotes}
|
||||||
|
onChange={(e) => setAvailabilityNotes(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
|
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
<Button type="submit" disabled={isPending || !selectedUserId}>
|
<Button type="submit" disabled={isPending || !selectedUserId}>
|
||||||
{isPending ? 'Adding...' : 'Add Member'}
|
{isAdding ? 'Adding...' : 'Add Member'}
|
||||||
</Button>
|
</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</form>
|
</form>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
{/* Invite new user tab */}
|
||||||
|
<TabsContent value="invite">
|
||||||
|
<form onSubmit={handleInviteSubmit} className="space-y-4 pt-2">
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="inviteName">Full Name</Label>
|
||||||
|
<Input
|
||||||
|
id="inviteName"
|
||||||
|
placeholder="Jane Doe"
|
||||||
|
value={inviteName}
|
||||||
|
onChange={(e) => setInviteName(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="inviteEmail">
|
||||||
|
Email <span className="text-destructive">*</span>
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="inviteEmail"
|
||||||
|
type="email"
|
||||||
|
placeholder="jane@example.com"
|
||||||
|
required
|
||||||
|
value={inviteEmail}
|
||||||
|
onChange={(e) => setInviteEmail(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="inviteRole">Role</Label>
|
||||||
|
<Select value={inviteRole} onValueChange={setInviteRole}>
|
||||||
|
<SelectTrigger id="inviteRole">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="MEMBER">Member</SelectItem>
|
||||||
|
<SelectItem value="CHAIR">Chair</SelectItem>
|
||||||
|
<SelectItem value="OBSERVER">Observer</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="inviteCapMode">Cap Mode</Label>
|
||||||
|
<Select value={inviteCapMode || 'DEFAULT'} onValueChange={setInviteCapMode}>
|
||||||
|
<SelectTrigger id="inviteCapMode">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="DEFAULT">Group Default</SelectItem>
|
||||||
|
<SelectItem value="HARD">Hard Cap</SelectItem>
|
||||||
|
<SelectItem value="SOFT">Soft Cap</SelectItem>
|
||||||
|
<SelectItem value="NONE">No Cap</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="inviteMaxAssignments">Max Assignments Override (optional)</Label>
|
||||||
|
<Input
|
||||||
|
id="inviteMaxAssignments"
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
placeholder="Leave empty to use group default"
|
||||||
|
value={inviteMaxAssignments}
|
||||||
|
onChange={(e) => setInviteMaxAssignments(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Category Preference</Label>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<span className="text-xs text-muted-foreground w-20 shrink-0">Startup</span>
|
||||||
|
<Slider
|
||||||
|
value={[inviteStartupRatio !== null ? inviteStartupRatio * 100 : 50]}
|
||||||
|
onValueChange={([v]) => setInviteStartupRatio(v / 100)}
|
||||||
|
min={0}
|
||||||
|
max={100}
|
||||||
|
step={10}
|
||||||
|
className="flex-1"
|
||||||
|
/>
|
||||||
|
<span className="text-xs text-muted-foreground w-20 shrink-0 text-right">Concept</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{inviteStartupRatio !== null
|
||||||
|
? `~${Math.round(inviteStartupRatio * 100)}% startups / ~${Math.round((1 - inviteStartupRatio) * 100)}% concepts`
|
||||||
|
: 'No preference set (balanced distribution)'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="inviteAvailabilityNotes">Availability Notes (optional)</Label>
|
||||||
|
<Textarea
|
||||||
|
id="inviteAvailabilityNotes"
|
||||||
|
placeholder="e.g. Available only in March, limited to 5 reviews/week..."
|
||||||
|
rows={2}
|
||||||
|
value={inviteAvailabilityNotes}
|
||||||
|
onChange={(e) => setInviteAvailabilityNotes(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="inviteExpertise">Expertise Tags (optional)</Label>
|
||||||
|
<Input
|
||||||
|
id="inviteExpertise"
|
||||||
|
placeholder="marine biology, policy, finance"
|
||||||
|
value={inviteExpertise}
|
||||||
|
onChange={(e) => setInviteExpertise(e.target.value)}
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Comma-separated tags for smart assignment matching
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-md border border-blue-200 bg-blue-50 px-3 py-2">
|
||||||
|
<p className="text-xs text-blue-700">
|
||||||
|
<UserPlus className="mr-1 inline h-3 w-3" />
|
||||||
|
This will create a new user account and send an invitation email to join the platform as a jury member.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" disabled={isPending || !inviteEmail.trim()}>
|
||||||
|
{isCreating || isAdding ? 'Creating & Inviting...' : 'Create & Invite'}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</form>
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useState } from 'react'
|
import { useState, useRef, useEffect } from 'react'
|
||||||
import { Trash2, UserPlus } from 'lucide-react'
|
import { Trash2, UserPlus, Pencil } from 'lucide-react'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
import { trpc } from '@/lib/trpc/client'
|
import { trpc } from '@/lib/trpc/client'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Input } from '@/components/ui/input'
|
||||||
import {
|
import {
|
||||||
Table,
|
Table,
|
||||||
TableBody,
|
TableBody,
|
||||||
@@ -29,14 +30,15 @@ import { AddMemberDialog } from './add-member-dialog'
|
|||||||
interface JuryMember {
|
interface JuryMember {
|
||||||
id: string
|
id: string
|
||||||
userId: string
|
userId: string
|
||||||
role: string
|
role?: string
|
||||||
user: {
|
user: {
|
||||||
id: string
|
id: string
|
||||||
name: string | null
|
name: string | null
|
||||||
email: string
|
email: string
|
||||||
}
|
}
|
||||||
maxAssignmentsOverride: number | null
|
maxAssignmentsOverride?: number | null
|
||||||
preferredStartupRatio: number | null
|
capModeOverride?: string | null
|
||||||
|
preferredStartupRatio?: number | null
|
||||||
}
|
}
|
||||||
|
|
||||||
interface JuryMembersTableProps {
|
interface JuryMembersTableProps {
|
||||||
@@ -44,6 +46,79 @@ interface JuryMembersTableProps {
|
|||||||
members: JuryMember[]
|
members: JuryMember[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function InlineCapEdit({
|
||||||
|
memberId,
|
||||||
|
currentValue,
|
||||||
|
juryGroupId,
|
||||||
|
}: {
|
||||||
|
memberId: string
|
||||||
|
currentValue: number | null | undefined
|
||||||
|
juryGroupId: string
|
||||||
|
}) {
|
||||||
|
const [editing, setEditing] = useState(false)
|
||||||
|
const [value, setValue] = useState(currentValue?.toString() ?? '')
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null)
|
||||||
|
const utils = trpc.useUtils()
|
||||||
|
|
||||||
|
const { mutate: updateMember, isPending } = trpc.juryGroup.updateMember.useMutation({
|
||||||
|
onSuccess: () => {
|
||||||
|
utils.juryGroup.getById.invalidate({ id: juryGroupId })
|
||||||
|
toast.success('Cap updated')
|
||||||
|
setEditing(false)
|
||||||
|
},
|
||||||
|
onError: (err) => toast.error(err.message),
|
||||||
|
})
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (editing) inputRef.current?.focus()
|
||||||
|
}, [editing])
|
||||||
|
|
||||||
|
const save = () => {
|
||||||
|
const trimmed = value.trim()
|
||||||
|
const newVal = trimmed === '' ? null : parseInt(trimmed, 10)
|
||||||
|
if (newVal !== null && (isNaN(newVal) || newVal < 1)) {
|
||||||
|
toast.error('Enter a positive number or leave empty for no cap')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (newVal === (currentValue ?? null)) {
|
||||||
|
setEditing(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
updateMember({ id: memberId, maxAssignmentsOverride: newVal })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (editing) {
|
||||||
|
return (
|
||||||
|
<Input
|
||||||
|
ref={inputRef}
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
className="h-7 w-20 text-xs"
|
||||||
|
value={value}
|
||||||
|
placeholder="∞"
|
||||||
|
disabled={isPending}
|
||||||
|
onChange={(e) => setValue(e.target.value)}
|
||||||
|
onBlur={save}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter') save()
|
||||||
|
if (e.key === 'Escape') { setValue(currentValue?.toString() ?? ''); setEditing(false) }
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="inline-flex items-center gap-1.5 rounded px-1.5 py-0.5 text-sm hover:bg-muted transition-colors group"
|
||||||
|
onClick={() => { setValue(currentValue?.toString() ?? ''); setEditing(true) }}
|
||||||
|
>
|
||||||
|
<span>{currentValue ?? '∞'}</span>
|
||||||
|
<Pencil className="h-3 w-3 text-muted-foreground opacity-0 group-hover:opacity-100 transition-opacity" />
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export function JuryMembersTable({ juryGroupId, members }: JuryMembersTableProps) {
|
export function JuryMembersTable({ juryGroupId, members }: JuryMembersTableProps) {
|
||||||
const [addDialogOpen, setAddDialogOpen] = useState(false)
|
const [addDialogOpen, setAddDialogOpen] = useState(false)
|
||||||
const [removingMemberId, setRemovingMemberId] = useState<string | null>(null)
|
const [removingMemberId, setRemovingMemberId] = useState<string | null>(null)
|
||||||
@@ -79,16 +154,17 @@ export function JuryMembersTable({ juryGroupId, members }: JuryMembersTableProps
|
|||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableHead>Name</TableHead>
|
<TableHead>Name</TableHead>
|
||||||
|
<TableHead className="hidden sm:table-cell">Role</TableHead>
|
||||||
<TableHead>Email</TableHead>
|
<TableHead>Email</TableHead>
|
||||||
<TableHead className="hidden md:table-cell">Role</TableHead>
|
|
||||||
<TableHead className="hidden sm:table-cell">Max Assignments</TableHead>
|
<TableHead className="hidden sm:table-cell">Max Assignments</TableHead>
|
||||||
|
<TableHead className="hidden lg:table-cell">Cap Mode</TableHead>
|
||||||
<TableHead>Actions</TableHead>
|
<TableHead>Actions</TableHead>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{members.length === 0 ? (
|
{members.length === 0 ? (
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableCell colSpan={5} className="text-center text-muted-foreground">
|
<TableCell colSpan={6} className="text-center text-muted-foreground">
|
||||||
No members yet. Add members to get started.
|
No members yet. Add members to get started.
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
@@ -98,16 +174,29 @@ export function JuryMembersTable({ juryGroupId, members }: JuryMembersTableProps
|
|||||||
<TableCell className="font-medium">
|
<TableCell className="font-medium">
|
||||||
{member.user.name || 'Unnamed User'}
|
{member.user.name || 'Unnamed User'}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
|
<TableCell className="hidden sm:table-cell">
|
||||||
|
<Badge variant="outline" className="text-[10px] capitalize">
|
||||||
|
{member.role?.toLowerCase().replace('_', ' ') || 'member'}
|
||||||
|
</Badge>
|
||||||
|
</TableCell>
|
||||||
<TableCell className="text-sm text-muted-foreground">
|
<TableCell className="text-sm text-muted-foreground">
|
||||||
{member.user.email}
|
{member.user.email}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="hidden md:table-cell">
|
|
||||||
<Badge variant={member.role === 'CHAIR' ? 'default' : 'secondary'}>
|
|
||||||
{member.role}
|
|
||||||
</Badge>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="hidden sm:table-cell">
|
<TableCell className="hidden sm:table-cell">
|
||||||
{member.maxAssignmentsOverride ?? '—'}
|
<InlineCapEdit
|
||||||
|
memberId={member.id}
|
||||||
|
currentValue={member.maxAssignmentsOverride}
|
||||||
|
juryGroupId={juryGroupId}
|
||||||
|
/>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="hidden lg:table-cell">
|
||||||
|
{member.capModeOverride ? (
|
||||||
|
<Badge variant="outline" className="text-[10px]">
|
||||||
|
{member.capModeOverride}
|
||||||
|
</Badge>
|
||||||
|
) : (
|
||||||
|
<span className="text-muted-foreground text-xs">Group default</span>
|
||||||
|
)}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { trpc } from '@/lib/trpc/client';
|
|||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { ChevronLeft, ChevronRight, Play, Square, Timer } from 'lucide-react';
|
import { ChevronLeft, ChevronRight, Play, Square, Pause, Timer } from 'lucide-react';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
interface LiveControlPanelProps {
|
interface LiveControlPanelProps {
|
||||||
@@ -15,18 +15,36 @@ interface LiveControlPanelProps {
|
|||||||
|
|
||||||
export function LiveControlPanel({ roundId, competitionId }: LiveControlPanelProps) {
|
export function LiveControlPanel({ roundId, competitionId }: LiveControlPanelProps) {
|
||||||
const utils = trpc.useUtils();
|
const utils = trpc.useUtils();
|
||||||
const [timerSeconds, setTimerSeconds] = useState(300); // 5 minutes default
|
const [timerSeconds, setTimerSeconds] = useState(300);
|
||||||
const [isTimerRunning, setIsTimerRunning] = useState(false);
|
const [isTimerRunning, setIsTimerRunning] = useState(false);
|
||||||
|
|
||||||
const { data: cursor } = trpc.live.getCursor.useQuery({ roundId });
|
const { data: cursor } = trpc.live.getCursor.useQuery(
|
||||||
// TODO: Add getScores to live router
|
{ roundId },
|
||||||
const scores: any[] = [];
|
{ refetchInterval: 5000 }
|
||||||
|
);
|
||||||
|
|
||||||
// TODO: Implement cursor mutation
|
const jumpMutation = trpc.live.jump.useMutation({
|
||||||
const moveCursorMutation = {
|
onSuccess: () => {
|
||||||
mutate: () => {},
|
utils.live.getCursor.invalidate({ roundId });
|
||||||
isPending: false
|
},
|
||||||
};
|
onError: (err) => toast.error(err.message),
|
||||||
|
});
|
||||||
|
|
||||||
|
const pauseMutation = trpc.live.pause.useMutation({
|
||||||
|
onSuccess: () => {
|
||||||
|
utils.live.getCursor.invalidate({ roundId });
|
||||||
|
toast.success('Live session paused');
|
||||||
|
},
|
||||||
|
onError: (err) => toast.error(err.message),
|
||||||
|
});
|
||||||
|
|
||||||
|
const resumeMutation = trpc.live.resume.useMutation({
|
||||||
|
onSuccess: () => {
|
||||||
|
utils.live.getCursor.invalidate({ roundId });
|
||||||
|
toast.success('Live session resumed');
|
||||||
|
},
|
||||||
|
onError: (err) => toast.error(err.message),
|
||||||
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isTimerRunning) return;
|
if (!isTimerRunning) return;
|
||||||
@@ -44,14 +62,24 @@ export function LiveControlPanel({ roundId, competitionId }: LiveControlPanelPro
|
|||||||
return () => clearInterval(interval);
|
return () => clearInterval(interval);
|
||||||
}, [isTimerRunning]);
|
}, [isTimerRunning]);
|
||||||
|
|
||||||
|
const currentIndex = cursor?.activeOrderIndex ?? 0;
|
||||||
|
const totalProjects = cursor?.totalProjects ?? 0;
|
||||||
|
const isNavigating = jumpMutation.isPending;
|
||||||
|
|
||||||
const handlePrevious = () => {
|
const handlePrevious = () => {
|
||||||
// TODO: Implement previous navigation
|
if (currentIndex <= 0) {
|
||||||
toast.info('Previous navigation not yet implemented');
|
toast.info('Already at the first project');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
jumpMutation.mutate({ roundId, index: currentIndex - 1 });
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleNext = () => {
|
const handleNext = () => {
|
||||||
// TODO: Implement next navigation
|
if (currentIndex >= totalProjects - 1) {
|
||||||
toast.info('Next navigation not yet implemented');
|
toast.info('Already at the last project');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
jumpMutation.mutate({ roundId, index: currentIndex + 1 });
|
||||||
};
|
};
|
||||||
|
|
||||||
const formatTime = (seconds: number) => {
|
const formatTime = (seconds: number) => {
|
||||||
@@ -67,12 +95,17 @@ export function LiveControlPanel({ roundId, competitionId }: LiveControlPanelPro
|
|||||||
<CardHeader>
|
<CardHeader>
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<CardTitle>Current Project</CardTitle>
|
<CardTitle>Current Project</CardTitle>
|
||||||
<div className="flex gap-2">
|
<div className="flex items-center gap-2">
|
||||||
|
{cursor && (
|
||||||
|
<span className="text-sm text-muted-foreground tabular-nums">
|
||||||
|
{currentIndex + 1} / {totalProjects}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="icon"
|
size="icon"
|
||||||
onClick={handlePrevious}
|
onClick={handlePrevious}
|
||||||
disabled={moveCursorMutation.isPending}
|
disabled={isNavigating || currentIndex <= 0}
|
||||||
>
|
>
|
||||||
<ChevronLeft className="h-4 w-4" />
|
<ChevronLeft className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
@@ -80,7 +113,7 @@ export function LiveControlPanel({ roundId, competitionId }: LiveControlPanelPro
|
|||||||
variant="outline"
|
variant="outline"
|
||||||
size="icon"
|
size="icon"
|
||||||
onClick={handleNext}
|
onClick={handleNext}
|
||||||
disabled={moveCursorMutation.isPending}
|
disabled={isNavigating || currentIndex >= totalProjects - 1}
|
||||||
>
|
>
|
||||||
<ChevronRight className="h-4 w-4" />
|
<ChevronRight className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
@@ -92,13 +125,24 @@ export function LiveControlPanel({ roundId, competitionId }: LiveControlPanelPro
|
|||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-2xl font-bold">{cursor.activeProject.title}</h3>
|
<h3 className="text-2xl font-bold">{cursor.activeProject.title}</h3>
|
||||||
|
{cursor.activeProject.teamName && (
|
||||||
|
<p className="text-muted-foreground">{cursor.activeProject.teamName}</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-sm text-muted-foreground">
|
{cursor.activeProject.tags && (cursor.activeProject.tags as string[]).length > 0 && (
|
||||||
Total projects: {cursor.totalProjects}
|
<div className="flex flex-wrap gap-1">
|
||||||
|
{(cursor.activeProject.tags as string[]).map((tag: string) => (
|
||||||
|
<Badge key={tag} variant="secondary" className="text-xs">
|
||||||
|
{tag}
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<p className="text-muted-foreground">No project selected</p>
|
<p className="text-muted-foreground">
|
||||||
|
{cursor ? 'No project selected' : 'No live session active for this round'}
|
||||||
|
</p>
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -144,48 +188,48 @@ export function LiveControlPanel({ roundId, competitionId }: LiveControlPanelPro
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* Voting Controls */}
|
{/* Session Controls */}
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Voting Controls</CardTitle>
|
<CardTitle>Session Controls</CardTitle>
|
||||||
<CardDescription>Manage jury and audience voting</CardDescription>
|
<CardDescription>Pause or resume the live presentation</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-3">
|
<CardContent className="space-y-3">
|
||||||
<Button className="w-full" variant="default">
|
{cursor?.isPaused ? (
|
||||||
<Play className="mr-2 h-4 w-4" />
|
<Button
|
||||||
Open Jury Voting
|
className="w-full"
|
||||||
</Button>
|
onClick={() => resumeMutation.mutate({ roundId })}
|
||||||
<Button className="w-full" variant="outline">
|
disabled={resumeMutation.isPending}
|
||||||
Close Voting
|
|
||||||
</Button>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* Scores Display */}
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>Live Scores</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
{scores && scores.length > 0 ? (
|
|
||||||
<div className="space-y-2">
|
|
||||||
{scores.map((score: any, index: number) => (
|
|
||||||
<div
|
|
||||||
key={score.projectId}
|
|
||||||
className="flex items-center justify-between rounded-lg border p-3"
|
|
||||||
>
|
>
|
||||||
<div>
|
<Play className="mr-2 h-4 w-4" />
|
||||||
<p className="font-medium">
|
{resumeMutation.isPending ? 'Resuming...' : 'Resume Session'}
|
||||||
#{index + 1} {score.projectTitle}
|
</Button>
|
||||||
</p>
|
) : (
|
||||||
<p className="text-sm text-muted-foreground">{score.votes} votes</p>
|
<Button
|
||||||
</div>
|
className="w-full"
|
||||||
<Badge variant="outline">{score.totalScore.toFixed(1)}</Badge>
|
variant="outline"
|
||||||
|
onClick={() => pauseMutation.mutate({ roundId })}
|
||||||
|
disabled={pauseMutation.isPending || !cursor}
|
||||||
|
>
|
||||||
|
<Pause className="mr-2 h-4 w-4" />
|
||||||
|
{pauseMutation.isPending ? 'Pausing...' : 'Pause Session'}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{cursor?.isPaused && (
|
||||||
|
<Badge variant="destructive" className="w-full justify-center py-1">
|
||||||
|
Session Paused
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
{cursor?.openCohorts && cursor.openCohorts.length > 0 && (
|
||||||
|
<div className="rounded-lg border p-3">
|
||||||
|
<p className="text-sm font-medium mb-2">Open Voting Windows</p>
|
||||||
|
{cursor.openCohorts.map((cohort: any) => (
|
||||||
|
<div key={cohort.id} className="flex items-center justify-between text-sm">
|
||||||
|
<span>{cohort.name}</span>
|
||||||
|
<Badge variant="outline">{cohort.votingMode}</Badge>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
|
||||||
<p className="text-center text-muted-foreground">No scores yet</p>
|
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@@ -53,6 +53,9 @@ const statusColors: Record<string, 'default' | 'success' | 'secondary' | 'destru
|
|||||||
|
|
||||||
const statusLabels: Record<string, string> = {
|
const statusLabels: Record<string, string> = {
|
||||||
NONE: 'Not Invited',
|
NONE: 'Not Invited',
|
||||||
|
INVITED: 'Invited',
|
||||||
|
ACTIVE: 'Active',
|
||||||
|
SUSPENDED: 'Suspended',
|
||||||
}
|
}
|
||||||
|
|
||||||
const roleColors: Record<string, 'default' | 'outline' | 'secondary'> = {
|
const roleColors: Record<string, 'default' | 'outline' | 'secondary'> = {
|
||||||
|
|||||||
@@ -1,166 +0,0 @@
|
|||||||
'use client'
|
|
||||||
|
|
||||||
import { useState, useCallback } from 'react'
|
|
||||||
import { trpc } from '@/lib/trpc/client'
|
|
||||||
import { Button } from '@/components/ui/button'
|
|
||||||
import { FileDown, Loader2 } from 'lucide-react'
|
|
||||||
import { toast } from 'sonner'
|
|
||||||
import {
|
|
||||||
createReportDocument,
|
|
||||||
addCoverPage,
|
|
||||||
addPageBreak,
|
|
||||||
addHeader,
|
|
||||||
addSectionTitle,
|
|
||||||
addStatCards,
|
|
||||||
addTable,
|
|
||||||
addAllPageFooters,
|
|
||||||
savePdf,
|
|
||||||
} from '@/lib/pdf-generator'
|
|
||||||
|
|
||||||
interface PdfReportProps {
|
|
||||||
roundId: string
|
|
||||||
sections: string[]
|
|
||||||
}
|
|
||||||
|
|
||||||
export function PdfReportGenerator({ roundId, sections }: PdfReportProps) {
|
|
||||||
const [generating, setGenerating] = useState(false)
|
|
||||||
|
|
||||||
const { refetch } = trpc.export.getReportData.useQuery(
|
|
||||||
{ roundId, sections },
|
|
||||||
{ enabled: false }
|
|
||||||
)
|
|
||||||
|
|
||||||
const handleGenerate = useCallback(async () => {
|
|
||||||
setGenerating(true)
|
|
||||||
toast.info('Generating PDF report...')
|
|
||||||
|
|
||||||
try {
|
|
||||||
const result = await refetch()
|
|
||||||
if (!result.data) {
|
|
||||||
toast.error('Failed to fetch report data')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = result.data as Record<string, unknown>
|
|
||||||
const rName = String(data.roundName || 'Report')
|
|
||||||
const pName = String(data.programName || '')
|
|
||||||
|
|
||||||
// 1. Create document
|
|
||||||
const doc = await createReportDocument()
|
|
||||||
|
|
||||||
// 2. Cover page
|
|
||||||
await addCoverPage(doc, {
|
|
||||||
title: 'Round Report',
|
|
||||||
subtitle: `${pName} ${data.programYear ? `(${data.programYear})` : ''}`.trim(),
|
|
||||||
roundName: rName,
|
|
||||||
programName: pName,
|
|
||||||
})
|
|
||||||
|
|
||||||
// 3. Summary
|
|
||||||
const summary = data.summary as Record<string, unknown> | undefined
|
|
||||||
if (summary) {
|
|
||||||
addPageBreak(doc)
|
|
||||||
await addHeader(doc, rName)
|
|
||||||
let y = addSectionTitle(doc, 'Summary', 28)
|
|
||||||
|
|
||||||
y = addStatCards(doc, [
|
|
||||||
{ label: 'Projects', value: String(summary.projectCount ?? 0) },
|
|
||||||
{ label: 'Evaluations', value: String(summary.evaluationCount ?? 0) },
|
|
||||||
{
|
|
||||||
label: 'Avg Score',
|
|
||||||
value: summary.averageScore != null
|
|
||||||
? Number(summary.averageScore).toFixed(1)
|
|
||||||
: '--',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Completion',
|
|
||||||
value: summary.completionRate != null
|
|
||||||
? `${Number(summary.completionRate).toFixed(0)}%`
|
|
||||||
: '--',
|
|
||||||
},
|
|
||||||
], y)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 4. Rankings
|
|
||||||
const rankings = data.rankings as Array<Record<string, unknown>> | undefined
|
|
||||||
if (rankings && rankings.length > 0) {
|
|
||||||
addPageBreak(doc)
|
|
||||||
await addHeader(doc, rName)
|
|
||||||
let y = addSectionTitle(doc, 'Project Rankings', 28)
|
|
||||||
|
|
||||||
const headers = ['#', 'Project', 'Team', 'Avg Score', 'Evaluations', 'Yes %']
|
|
||||||
const rows = rankings.map((r, i) => [
|
|
||||||
i + 1,
|
|
||||||
String(r.title ?? ''),
|
|
||||||
String(r.teamName ?? ''),
|
|
||||||
r.averageScore != null ? Number(r.averageScore).toFixed(2) : '-',
|
|
||||||
String(r.evaluationCount ?? 0),
|
|
||||||
r.yesPercentage != null ? `${Number(r.yesPercentage).toFixed(0)}%` : '-',
|
|
||||||
])
|
|
||||||
|
|
||||||
y = addTable(doc, headers, rows, y)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 5. Juror stats
|
|
||||||
const jurorStats = data.jurorStats as Array<Record<string, unknown>> | undefined
|
|
||||||
if (jurorStats && jurorStats.length > 0) {
|
|
||||||
addPageBreak(doc)
|
|
||||||
await addHeader(doc, rName)
|
|
||||||
let y = addSectionTitle(doc, 'Juror Statistics', 28)
|
|
||||||
|
|
||||||
const headers = ['Juror', 'Assigned', 'Completed', 'Completion %', 'Avg Score']
|
|
||||||
const rows = jurorStats.map((j) => [
|
|
||||||
String(j.name ?? ''),
|
|
||||||
String(j.assigned ?? 0),
|
|
||||||
String(j.completed ?? 0),
|
|
||||||
`${Number(j.completionRate ?? 0).toFixed(0)}%`,
|
|
||||||
j.averageScore != null ? Number(j.averageScore).toFixed(2) : '-',
|
|
||||||
])
|
|
||||||
|
|
||||||
y = addTable(doc, headers, rows, y)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 6. Criteria breakdown
|
|
||||||
const criteriaBreakdown = data.criteriaBreakdown as Array<Record<string, unknown>> | undefined
|
|
||||||
if (criteriaBreakdown && criteriaBreakdown.length > 0) {
|
|
||||||
addPageBreak(doc)
|
|
||||||
await addHeader(doc, rName)
|
|
||||||
let y = addSectionTitle(doc, 'Criteria Breakdown', 28)
|
|
||||||
|
|
||||||
const headers = ['Criterion', 'Avg Score', 'Responses']
|
|
||||||
const rows = criteriaBreakdown.map((c) => [
|
|
||||||
String(c.label ?? ''),
|
|
||||||
c.averageScore != null ? Number(c.averageScore).toFixed(2) : '-',
|
|
||||||
String(c.count ?? 0),
|
|
||||||
])
|
|
||||||
|
|
||||||
y = addTable(doc, headers, rows, y)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 7. Footers
|
|
||||||
addAllPageFooters(doc)
|
|
||||||
|
|
||||||
// 8. Save
|
|
||||||
const dateStr = new Date().toISOString().split('T')[0]
|
|
||||||
savePdf(doc, `MOPC-Report-${rName.replace(/\s+/g, '-')}-${dateStr}.pdf`)
|
|
||||||
|
|
||||||
toast.success('PDF report downloaded successfully')
|
|
||||||
} catch (err) {
|
|
||||||
console.error('PDF generation error:', err)
|
|
||||||
toast.error('Failed to generate PDF report')
|
|
||||||
} finally {
|
|
||||||
setGenerating(false)
|
|
||||||
}
|
|
||||||
}, [refetch])
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Button variant="outline" onClick={handleGenerate} disabled={generating}>
|
|
||||||
{generating ? (
|
|
||||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
|
||||||
) : (
|
|
||||||
<FileDown className="mr-2 h-4 w-4" />
|
|
||||||
)}
|
|
||||||
{generating ? 'Generating...' : 'Export PDF Report'}
|
|
||||||
</Button>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -31,15 +31,21 @@ export function ResultLockControls({ competitionId, roundId, category }: ResultL
|
|||||||
const [unlockDialogOpen, setUnlockDialogOpen] = useState(false);
|
const [unlockDialogOpen, setUnlockDialogOpen] = useState(false);
|
||||||
const [unlockReason, setUnlockReason] = useState('');
|
const [unlockReason, setUnlockReason] = useState('');
|
||||||
|
|
||||||
const { data: lockStatus } = trpc.resultLock.isLocked.useQuery({
|
const { data: lockStatus } = trpc.resultLock.isLocked.useQuery(
|
||||||
competitionId,
|
{ competitionId, roundId, category },
|
||||||
roundId,
|
{ refetchInterval: 15_000 }
|
||||||
category
|
);
|
||||||
});
|
|
||||||
|
|
||||||
const { data: history } = trpc.resultLock.history.useQuery({
|
const { data: history } = trpc.resultLock.history.useQuery(
|
||||||
competitionId
|
{ competitionId },
|
||||||
});
|
{ refetchInterval: 15_000 }
|
||||||
|
);
|
||||||
|
|
||||||
|
// Fetch project rankings for the snapshot
|
||||||
|
const { data: projectRankings } = trpc.analytics.getProjectRankings.useQuery(
|
||||||
|
{ roundId, limit: 5000 },
|
||||||
|
{ enabled: !!roundId }
|
||||||
|
);
|
||||||
|
|
||||||
const lockMutation = trpc.resultLock.lock.useMutation({
|
const lockMutation = trpc.resultLock.lock.useMutation({
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
@@ -67,11 +73,25 @@ export function ResultLockControls({ competitionId, roundId, category }: ResultL
|
|||||||
});
|
});
|
||||||
|
|
||||||
const handleLock = () => {
|
const handleLock = () => {
|
||||||
|
const snapshot = {
|
||||||
|
lockedAt: new Date().toISOString(),
|
||||||
|
category,
|
||||||
|
roundId,
|
||||||
|
rankings: (projectRankings ?? []).map((p: any) => ({
|
||||||
|
projectId: p.id,
|
||||||
|
title: p.title,
|
||||||
|
teamName: p.teamName,
|
||||||
|
averageScore: p.averageScore,
|
||||||
|
evaluationCount: p.evaluationCount,
|
||||||
|
status: p.status,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
|
||||||
lockMutation.mutate({
|
lockMutation.mutate({
|
||||||
competitionId,
|
competitionId,
|
||||||
roundId,
|
roundId,
|
||||||
category,
|
category,
|
||||||
resultSnapshot: {} // This would contain the actual results snapshot
|
resultSnapshot: snapshot,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
375
src/components/admin/round/award-shortlist.tsx
Normal file
375
src/components/admin/round/award-shortlist.tsx
Normal file
@@ -0,0 +1,375 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { trpc } from '@/lib/trpc/client'
|
||||||
|
import { toast } from 'sonner'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Badge } from '@/components/ui/badge'
|
||||||
|
import { Checkbox } from '@/components/ui/checkbox'
|
||||||
|
import { Progress } from '@/components/ui/progress'
|
||||||
|
import { Skeleton } from '@/components/ui/skeleton'
|
||||||
|
import {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogTitle,
|
||||||
|
AlertDialogTrigger,
|
||||||
|
} from '@/components/ui/alert-dialog'
|
||||||
|
import {
|
||||||
|
Collapsible,
|
||||||
|
CollapsibleContent,
|
||||||
|
CollapsibleTrigger,
|
||||||
|
} from '@/components/ui/collapsible'
|
||||||
|
import {
|
||||||
|
ChevronDown,
|
||||||
|
ChevronUp,
|
||||||
|
Loader2,
|
||||||
|
CheckCircle2,
|
||||||
|
Play,
|
||||||
|
Trophy,
|
||||||
|
AlertTriangle,
|
||||||
|
} from 'lucide-react'
|
||||||
|
|
||||||
|
type AwardShortlistProps = {
|
||||||
|
awardId: string
|
||||||
|
roundId: string
|
||||||
|
awardName: string
|
||||||
|
criteriaText?: string | null
|
||||||
|
eligibilityMode: string
|
||||||
|
shortlistSize: number
|
||||||
|
jobStatus?: string | null
|
||||||
|
jobTotal?: number | null
|
||||||
|
jobDone?: number | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AwardShortlist({
|
||||||
|
awardId,
|
||||||
|
roundId,
|
||||||
|
awardName,
|
||||||
|
criteriaText,
|
||||||
|
eligibilityMode,
|
||||||
|
shortlistSize,
|
||||||
|
jobStatus,
|
||||||
|
jobTotal,
|
||||||
|
jobDone,
|
||||||
|
}: AwardShortlistProps) {
|
||||||
|
const [expanded, setExpanded] = useState(false)
|
||||||
|
const utils = trpc.useUtils()
|
||||||
|
|
||||||
|
const isRunning = jobStatus === 'PENDING' || jobStatus === 'PROCESSING'
|
||||||
|
|
||||||
|
const { data: shortlist, isLoading: isLoadingShortlist } = trpc.specialAward.listShortlist.useQuery(
|
||||||
|
{ awardId, perPage: 100 },
|
||||||
|
{ enabled: expanded && !isRunning }
|
||||||
|
)
|
||||||
|
|
||||||
|
const { data: jobPoll } = trpc.specialAward.getEligibilityJobStatus.useQuery(
|
||||||
|
{ awardId },
|
||||||
|
{ enabled: isRunning, refetchInterval: 3000 }
|
||||||
|
)
|
||||||
|
|
||||||
|
const runMutation = trpc.specialAward.runEligibilityForRound.useMutation({
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success('Eligibility evaluation started')
|
||||||
|
utils.specialAward.getEligibilityJobStatus.invalidate({ awardId })
|
||||||
|
utils.specialAward.listForRound.invalidate({ roundId })
|
||||||
|
},
|
||||||
|
onError: (err) => toast.error(`Failed: ${err.message}`),
|
||||||
|
})
|
||||||
|
|
||||||
|
const toggleMutation = trpc.specialAward.toggleShortlisted.useMutation({
|
||||||
|
onSuccess: (data) => {
|
||||||
|
utils.specialAward.listShortlist.invalidate({ awardId })
|
||||||
|
utils.specialAward.listForRound.invalidate({ roundId })
|
||||||
|
toast.success(data.shortlisted ? 'Added to shortlist' : 'Removed from shortlist')
|
||||||
|
},
|
||||||
|
onError: (err) => toast.error(`Failed: ${err.message}`),
|
||||||
|
})
|
||||||
|
|
||||||
|
const bulkToggleMutation = trpc.specialAward.bulkToggleShortlisted.useMutation({
|
||||||
|
onSuccess: (data) => {
|
||||||
|
utils.specialAward.listShortlist.invalidate({ awardId })
|
||||||
|
utils.specialAward.listForRound.invalidate({ roundId })
|
||||||
|
toast.success(`${data.updated} projects ${data.shortlisted ? 'added to' : 'removed from'} shortlist`)
|
||||||
|
},
|
||||||
|
onError: (err) => toast.error(`Failed: ${err.message}`),
|
||||||
|
})
|
||||||
|
|
||||||
|
const { data: awardRounds } = trpc.specialAward.listRounds.useQuery(
|
||||||
|
{ awardId },
|
||||||
|
{ enabled: expanded && eligibilityMode === 'SEPARATE_POOL' }
|
||||||
|
)
|
||||||
|
const hasAwardRounds = (awardRounds?.length ?? 0) > 0
|
||||||
|
|
||||||
|
const confirmMutation = trpc.specialAward.confirmShortlist.useMutation({
|
||||||
|
onSuccess: (data) => {
|
||||||
|
utils.specialAward.listShortlist.invalidate({ awardId })
|
||||||
|
utils.specialAward.listForRound.invalidate({ roundId })
|
||||||
|
toast.success(
|
||||||
|
`Confirmed ${data.confirmedCount} projects` +
|
||||||
|
(data.routedCount > 0 ? ` — ${data.routedCount} routed to award track` : '')
|
||||||
|
)
|
||||||
|
},
|
||||||
|
onError: (err) => toast.error(`Failed: ${err.message}`),
|
||||||
|
})
|
||||||
|
|
||||||
|
const currentJobStatus = jobPoll?.eligibilityJobStatus ?? jobStatus
|
||||||
|
const currentJobDone = jobPoll?.eligibilityJobDone ?? jobDone
|
||||||
|
const currentJobTotal = jobPoll?.eligibilityJobTotal ?? jobTotal
|
||||||
|
const jobProgress = currentJobTotal && currentJobTotal > 0
|
||||||
|
? Math.round(((currentJobDone ?? 0) / currentJobTotal) * 100)
|
||||||
|
: 0
|
||||||
|
|
||||||
|
const shortlistedCount = shortlist?.eligibilities?.filter((e) => e.shortlisted).length ?? 0
|
||||||
|
const allShortlisted = shortlist && shortlist.eligibilities.length > 0 && shortlist.eligibilities.every((e) => e.shortlisted)
|
||||||
|
const someShortlisted = shortlistedCount > 0 && !allShortlisted
|
||||||
|
|
||||||
|
|
||||||
|
const handleBulkToggle = () => {
|
||||||
|
if (!shortlist) return
|
||||||
|
const projectIds = shortlist.eligibilities.map((e) => e.project.id)
|
||||||
|
const newValue = !allShortlisted
|
||||||
|
bulkToggleMutation.mutate({ awardId, projectIds, shortlisted: newValue })
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Collapsible open={expanded} onOpenChange={setExpanded}>
|
||||||
|
<div className="border rounded-lg">
|
||||||
|
<CollapsibleTrigger asChild>
|
||||||
|
<button className="w-full flex items-center justify-between p-4 hover:bg-muted/50 transition-colors text-left">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Trophy className="h-5 w-5 text-amber-600" />
|
||||||
|
<div>
|
||||||
|
<h4 className="font-semibold text-sm">{awardName}</h4>
|
||||||
|
{criteriaText && (
|
||||||
|
<p className="text-xs text-muted-foreground line-clamp-1 max-w-md">
|
||||||
|
{criteriaText}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Badge variant="outline" className={eligibilityMode === 'SEPARATE_POOL'
|
||||||
|
? 'bg-purple-50 text-purple-700 border-purple-200'
|
||||||
|
: 'bg-blue-50 text-blue-700 border-blue-200'
|
||||||
|
}>
|
||||||
|
{eligibilityMode === 'SEPARATE_POOL' ? 'Separate Pool' : 'Main Pool'}
|
||||||
|
</Badge>
|
||||||
|
{currentJobStatus === 'COMPLETED' && (
|
||||||
|
<Badge variant="outline" className="bg-green-50 text-green-700 border-green-200">
|
||||||
|
<CheckCircle2 className="h-3 w-3 mr-1" />
|
||||||
|
Evaluated
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
{expanded ? <ChevronUp className="h-4 w-4" /> : <ChevronDown className="h-4 w-4" />}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
</CollapsibleTrigger>
|
||||||
|
|
||||||
|
<CollapsibleContent>
|
||||||
|
<div className="border-t p-4 space-y-4">
|
||||||
|
{/* Job controls */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="text-sm text-muted-foreground">
|
||||||
|
Evaluate PASSED projects against this award's criteria
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
onClick={() => runMutation.mutate({ awardId, roundId })}
|
||||||
|
disabled={runMutation.isPending || isRunning}
|
||||||
|
>
|
||||||
|
{isRunning ? (
|
||||||
|
<><Loader2 className="h-4 w-4 mr-2 animate-spin" />Processing...</>
|
||||||
|
) : runMutation.isPending ? (
|
||||||
|
<><Loader2 className="h-4 w-4 mr-2 animate-spin" />Starting...</>
|
||||||
|
) : currentJobStatus === 'COMPLETED' ? (
|
||||||
|
<><Play className="h-4 w-4 mr-2" />Re-evaluate</>
|
||||||
|
) : (
|
||||||
|
<><Play className="h-4 w-4 mr-2" />Run Eligibility</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Progress bar */}
|
||||||
|
{isRunning && currentJobTotal && currentJobTotal > 0 && (
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Progress value={jobProgress} className="h-2" />
|
||||||
|
<p className="text-xs text-muted-foreground text-right">
|
||||||
|
{currentJobDone ?? 0} / {currentJobTotal} projects
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Shortlist table */}
|
||||||
|
{expanded && currentJobStatus === 'COMPLETED' && (
|
||||||
|
<>
|
||||||
|
{isLoadingShortlist ? (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{[1, 2, 3].map((i) => <Skeleton key={i} className="h-12 w-full" />)}
|
||||||
|
</div>
|
||||||
|
) : shortlist && shortlist.eligibilities.length > 0 ? (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<p className="text-sm font-medium">
|
||||||
|
{shortlist.total} eligible projects
|
||||||
|
{shortlistedCount > 0 && (
|
||||||
|
<span className="text-muted-foreground ml-1">
|
||||||
|
({shortlistedCount} shortlisted)
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
{shortlistedCount > 0 && (
|
||||||
|
<AlertDialog>
|
||||||
|
<AlertDialogTrigger asChild>
|
||||||
|
<Button size="sm" variant="default">
|
||||||
|
<CheckCircle2 className="h-4 w-4 mr-2" />
|
||||||
|
Confirm Shortlist ({shortlistedCount})
|
||||||
|
</Button>
|
||||||
|
</AlertDialogTrigger>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>Confirm Shortlist</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription asChild>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<p>
|
||||||
|
{eligibilityMode === 'SEPARATE_POOL'
|
||||||
|
? `This will confirm ${shortlistedCount} projects for the "${awardName}" award track. Projects will be routed to the award's rounds for separate evaluation.`
|
||||||
|
: `This will confirm ${shortlistedCount} projects as eligible for the "${awardName}" award. Projects remain in the main competition pool.`
|
||||||
|
}
|
||||||
|
</p>
|
||||||
|
{eligibilityMode === 'SEPARATE_POOL' && !hasAwardRounds && (
|
||||||
|
<div className="flex items-start gap-2 rounded-md border border-amber-200 bg-amber-50 p-3 text-amber-800 dark:border-amber-800 dark:bg-amber-950/30 dark:text-amber-300">
|
||||||
|
<AlertTriangle className="h-4 w-4 mt-0.5 shrink-0" />
|
||||||
|
<p className="text-sm">
|
||||||
|
No award rounds have been created yet. Projects will be confirmed but <strong>not routed</strong> to an evaluation track. Create rounds on the award page first.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||||
|
<AlertDialogAction
|
||||||
|
onClick={() => confirmMutation.mutate({ awardId })}
|
||||||
|
disabled={confirmMutation.isPending}
|
||||||
|
>
|
||||||
|
{confirmMutation.isPending ? 'Confirming...' : 'Confirm'}
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="border rounded-md overflow-hidden">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead className="bg-muted/50">
|
||||||
|
<tr>
|
||||||
|
<th className="px-3 py-2 text-left w-8">#</th>
|
||||||
|
<th className="px-3 py-2 text-left">Project</th>
|
||||||
|
<th className="px-3 py-2 text-left w-24">Score</th>
|
||||||
|
<th className="px-3 py-2 text-left min-w-[300px]">Reasoning</th>
|
||||||
|
<th className="px-3 py-2 text-center w-20">
|
||||||
|
<div className="flex items-center justify-center gap-1">
|
||||||
|
<Checkbox
|
||||||
|
checked={allShortlisted ? true : someShortlisted ? 'indeterminate' : false}
|
||||||
|
onCheckedChange={handleBulkToggle}
|
||||||
|
disabled={bulkToggleMutation.isPending}
|
||||||
|
aria-label="Select all"
|
||||||
|
/>
|
||||||
|
<span className="text-xs">All</span>
|
||||||
|
</div>
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{shortlist.eligibilities.map((e, i) => {
|
||||||
|
const reasoning = (e.aiReasoningJson as Record<string, unknown>)?.reasoning as string | undefined
|
||||||
|
const isTop5 = i < shortlistSize
|
||||||
|
return (
|
||||||
|
<tr key={e.id} className={`border-t ${isTop5 ? 'bg-amber-50/50' : ''}`}>
|
||||||
|
<td className="px-3 py-2 text-muted-foreground font-mono">
|
||||||
|
{isTop5 ? (
|
||||||
|
<span className="text-amber-600 font-semibold">{i + 1}</span>
|
||||||
|
) : (
|
||||||
|
i + 1
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2">
|
||||||
|
<div>
|
||||||
|
<p className={`font-medium ${isTop5 ? 'text-amber-900' : ''}`}>
|
||||||
|
{e.project.title}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{[e.project.teamName, e.project.country, e.project.competitionCategory].filter(Boolean).join(', ') || '—'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Progress
|
||||||
|
value={e.qualityScore ?? 0}
|
||||||
|
className="h-2 w-16"
|
||||||
|
/>
|
||||||
|
<span className="text-xs font-mono font-medium">
|
||||||
|
{e.qualityScore ?? 0}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2">
|
||||||
|
{reasoning ? (
|
||||||
|
<p className="text-xs text-muted-foreground whitespace-pre-wrap leading-relaxed">
|
||||||
|
{reasoning}
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<span className="text-xs text-muted-foreground">—</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2 text-center">
|
||||||
|
<Checkbox
|
||||||
|
checked={e.shortlisted}
|
||||||
|
onCheckedChange={() =>
|
||||||
|
toggleMutation.mutate({ awardId, projectId: e.project.id })
|
||||||
|
}
|
||||||
|
disabled={toggleMutation.isPending}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-muted-foreground text-center py-4">
|
||||||
|
No eligible projects found
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Not yet evaluated */}
|
||||||
|
{expanded && !currentJobStatus && (
|
||||||
|
<p className="text-sm text-muted-foreground text-center py-4">
|
||||||
|
Click "Run Eligibility" to evaluate projects against this award's criteria
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Failed */}
|
||||||
|
{currentJobStatus === 'FAILED' && (
|
||||||
|
<p className="text-sm text-red-600 text-center py-2">
|
||||||
|
Eligibility evaluation failed. Try again.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CollapsibleContent>
|
||||||
|
</div>
|
||||||
|
</Collapsible>
|
||||||
|
)
|
||||||
|
}
|
||||||
403
src/components/admin/round/file-requirements-editor.tsx
Normal file
403
src/components/admin/round/file-requirements-editor.tsx
Normal file
@@ -0,0 +1,403 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { trpc } from '@/lib/trpc/client'
|
||||||
|
import { toast } from 'sonner'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||||
|
import { Badge } from '@/components/ui/badge'
|
||||||
|
import { Input } from '@/components/ui/input'
|
||||||
|
import { Textarea } from '@/components/ui/textarea'
|
||||||
|
import { Checkbox } from '@/components/ui/checkbox'
|
||||||
|
import { Skeleton } from '@/components/ui/skeleton'
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from '@/components/ui/dialog'
|
||||||
|
import {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogTitle,
|
||||||
|
AlertDialogTrigger,
|
||||||
|
} from '@/components/ui/alert-dialog'
|
||||||
|
import {
|
||||||
|
Loader2,
|
||||||
|
Plus,
|
||||||
|
Pencil,
|
||||||
|
Trash2,
|
||||||
|
FileText,
|
||||||
|
FileCheck,
|
||||||
|
FileQuestion,
|
||||||
|
} from 'lucide-react'
|
||||||
|
|
||||||
|
type FileRequirementsEditorProps = {
|
||||||
|
roundId: string
|
||||||
|
windowOpenAt?: Date | string | null
|
||||||
|
windowCloseAt?: Date | string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
type FormState = {
|
||||||
|
name: string
|
||||||
|
description: string
|
||||||
|
acceptedMimeTypes: string[]
|
||||||
|
maxSizeMB: string
|
||||||
|
isRequired: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const emptyForm: FormState = {
|
||||||
|
name: '',
|
||||||
|
description: '',
|
||||||
|
acceptedMimeTypes: [],
|
||||||
|
maxSizeMB: '',
|
||||||
|
isRequired: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
const MIME_TYPE_OPTIONS: { label: string; value: string }[] = [
|
||||||
|
{ label: 'PDF', value: 'application/pdf' },
|
||||||
|
{ label: 'Images', value: 'image/*' },
|
||||||
|
{ label: 'Video', value: 'video/*' },
|
||||||
|
{ label: 'Word', value: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document' },
|
||||||
|
{ label: 'Excel', value: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' },
|
||||||
|
{ label: 'PowerPoint', value: 'application/vnd.openxmlformats-officedocument.presentationml.presentation' },
|
||||||
|
]
|
||||||
|
|
||||||
|
function getMimeLabel(mime: string): string {
|
||||||
|
const preset = MIME_TYPE_OPTIONS.find((p) => p.value === mime)
|
||||||
|
if (preset) return preset.label
|
||||||
|
if (mime.endsWith('/*')) return mime.replace('/*', '')
|
||||||
|
return mime
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FileRequirementsEditor({ roundId, windowOpenAt, windowCloseAt }: FileRequirementsEditorProps) {
|
||||||
|
const [dialogOpen, setDialogOpen] = useState(false)
|
||||||
|
const [editingId, setEditingId] = useState<string | null>(null)
|
||||||
|
const [form, setForm] = useState<FormState>(emptyForm)
|
||||||
|
|
||||||
|
const utils = trpc.useUtils()
|
||||||
|
|
||||||
|
const { data: requirements, isLoading } = trpc.file.listRequirements.useQuery({ roundId })
|
||||||
|
|
||||||
|
const createMutation = trpc.file.createRequirement.useMutation({
|
||||||
|
onSuccess: () => {
|
||||||
|
utils.file.listRequirements.invalidate({ roundId })
|
||||||
|
toast.success('Requirement added')
|
||||||
|
closeDialog()
|
||||||
|
},
|
||||||
|
onError: (err) => toast.error(err.message),
|
||||||
|
})
|
||||||
|
|
||||||
|
const updateMutation = trpc.file.updateRequirement.useMutation({
|
||||||
|
onSuccess: () => {
|
||||||
|
utils.file.listRequirements.invalidate({ roundId })
|
||||||
|
toast.success('Requirement updated')
|
||||||
|
closeDialog()
|
||||||
|
},
|
||||||
|
onError: (err) => toast.error(err.message),
|
||||||
|
})
|
||||||
|
|
||||||
|
const deleteMutation = trpc.file.deleteRequirement.useMutation({
|
||||||
|
onSuccess: () => {
|
||||||
|
utils.file.listRequirements.invalidate({ roundId })
|
||||||
|
toast.success('Requirement removed')
|
||||||
|
},
|
||||||
|
onError: (err) => toast.error(err.message),
|
||||||
|
})
|
||||||
|
|
||||||
|
const closeDialog = () => {
|
||||||
|
setDialogOpen(false)
|
||||||
|
setEditingId(null)
|
||||||
|
setForm(emptyForm)
|
||||||
|
}
|
||||||
|
|
||||||
|
const openCreateDialog = () => {
|
||||||
|
setForm(emptyForm)
|
||||||
|
setEditingId(null)
|
||||||
|
setDialogOpen(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
const openEditDialog = (req: any) => {
|
||||||
|
setForm({
|
||||||
|
name: req.name,
|
||||||
|
description: req.description || '',
|
||||||
|
acceptedMimeTypes: req.acceptedMimeTypes || [],
|
||||||
|
maxSizeMB: req.maxSizeMB?.toString() || '',
|
||||||
|
isRequired: req.isRequired ?? true,
|
||||||
|
})
|
||||||
|
setEditingId(req.id)
|
||||||
|
setDialogOpen(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
const toggleMimeType = (mime: string) => {
|
||||||
|
setForm((prev) => ({
|
||||||
|
...prev,
|
||||||
|
acceptedMimeTypes: prev.acceptedMimeTypes.includes(mime)
|
||||||
|
? prev.acceptedMimeTypes.filter((m) => m !== mime)
|
||||||
|
: [...prev.acceptedMimeTypes, mime],
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSubmit = () => {
|
||||||
|
const maxSize = form.maxSizeMB ? parseInt(form.maxSizeMB, 10) : undefined
|
||||||
|
|
||||||
|
if (editingId) {
|
||||||
|
updateMutation.mutate({
|
||||||
|
id: editingId,
|
||||||
|
name: form.name,
|
||||||
|
description: form.description || null,
|
||||||
|
acceptedMimeTypes: form.acceptedMimeTypes,
|
||||||
|
maxSizeMB: maxSize ?? null,
|
||||||
|
isRequired: form.isRequired,
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
createMutation.mutate({
|
||||||
|
roundId,
|
||||||
|
name: form.name,
|
||||||
|
description: form.description || undefined,
|
||||||
|
acceptedMimeTypes: form.acceptedMimeTypes,
|
||||||
|
maxSizeMB: maxSize,
|
||||||
|
isRequired: form.isRequired,
|
||||||
|
sortOrder: (requirements?.length ?? 0),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const isSaving = createMutation.isPending || updateMutation.isPending
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<Skeleton className="h-10 w-full" />
|
||||||
|
{[1, 2, 3].map((i) => <Skeleton key={i} className="h-20 w-full" />)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Submission period info */}
|
||||||
|
<Card>
|
||||||
|
<CardContent className="pt-4 pb-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium">Submission Period</p>
|
||||||
|
<p className="text-xs text-muted-foreground mt-0.5">
|
||||||
|
Applicants can upload documents during the round's active window
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="text-right text-sm">
|
||||||
|
{windowOpenAt || windowCloseAt ? (
|
||||||
|
<>
|
||||||
|
<p className="font-medium">
|
||||||
|
{windowOpenAt ? new Date(windowOpenAt).toLocaleDateString() : 'No start'} —{' '}
|
||||||
|
{windowCloseAt ? new Date(windowCloseAt).toLocaleDateString() : 'No deadline'}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Set in the Config tab under round time windows
|
||||||
|
</p>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<p className="text-muted-foreground">No dates configured — set in Config tab</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Requirements list */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<CardTitle className="text-base">Required Documents</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Define what files applicants must submit for this round
|
||||||
|
</CardDescription>
|
||||||
|
</div>
|
||||||
|
<Button size="sm" onClick={openCreateDialog}>
|
||||||
|
<Plus className="h-4 w-4 mr-1.5" />
|
||||||
|
Add Requirement
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{!requirements || requirements.length === 0 ? (
|
||||||
|
<div className="flex flex-col items-center justify-center py-10 text-center">
|
||||||
|
<div className="rounded-full bg-muted p-4 mb-4">
|
||||||
|
<FileText className="h-8 w-8 text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
<p className="text-sm font-medium">No Document Requirements</p>
|
||||||
|
<p className="text-xs text-muted-foreground mt-1 max-w-sm">
|
||||||
|
Add requirements to specify what documents applicants must upload during this round.
|
||||||
|
</p>
|
||||||
|
<Button size="sm" variant="outline" className="mt-4" onClick={openCreateDialog}>
|
||||||
|
<Plus className="h-4 w-4 mr-1.5" />
|
||||||
|
Add First Requirement
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{requirements.map((req: any) => (
|
||||||
|
<div
|
||||||
|
key={req.id}
|
||||||
|
className="flex items-start gap-3 p-3 rounded-lg border hover:bg-muted/30 transition-colors"
|
||||||
|
>
|
||||||
|
<div className="mt-0.5 text-muted-foreground">
|
||||||
|
{req.isRequired ? (
|
||||||
|
<FileCheck className="h-4 w-4 text-blue-500" />
|
||||||
|
) : (
|
||||||
|
<FileQuestion className="h-4 w-4 text-gray-400" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<p className="text-sm font-medium">{req.name}</p>
|
||||||
|
<Badge variant={req.isRequired ? 'default' : 'secondary'} className="text-[10px]">
|
||||||
|
{req.isRequired ? 'Required' : 'Optional'}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
{req.description && (
|
||||||
|
<p className="text-xs text-muted-foreground mt-0.5">{req.description}</p>
|
||||||
|
)}
|
||||||
|
<div className="flex flex-wrap gap-1 mt-1.5">
|
||||||
|
{req.acceptedMimeTypes?.length > 0 ? (
|
||||||
|
req.acceptedMimeTypes.map((t: string) => (
|
||||||
|
<Badge key={t} variant="outline" className="text-[10px]">
|
||||||
|
{getMimeLabel(t)}
|
||||||
|
</Badge>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<Badge variant="outline" className="text-[10px]">
|
||||||
|
Any file type
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
{req.maxSizeMB && (
|
||||||
|
<Badge variant="outline" className="text-[10px]">
|
||||||
|
Max {req.maxSizeMB} MB
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1 shrink-0">
|
||||||
|
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={() => openEditDialog(req)}>
|
||||||
|
<Pencil className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
<AlertDialog>
|
||||||
|
<AlertDialogTrigger asChild>
|
||||||
|
<Button variant="ghost" size="icon" className="h-7 w-7 text-destructive hover:text-destructive">
|
||||||
|
<Trash2 className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
</AlertDialogTrigger>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>Delete requirement?</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
This will remove "{req.name}" from the round. Previously uploaded files will not be deleted.
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||||
|
<AlertDialogAction
|
||||||
|
onClick={() => deleteMutation.mutate({ id: req.id })}
|
||||||
|
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Create / Edit Dialog */}
|
||||||
|
<Dialog open={dialogOpen} onOpenChange={(open) => { if (!open) closeDialog() }}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{editingId ? 'Edit Requirement' : 'Add Document Requirement'}</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
{editingId
|
||||||
|
? 'Update the document requirement details.'
|
||||||
|
: 'Define a new document that applicants must submit.'}
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-medium">Name</label>
|
||||||
|
<Input
|
||||||
|
placeholder="e.g. Business Plan, Pitch Deck, Financial Projections"
|
||||||
|
value={form.name}
|
||||||
|
onChange={(e) => setForm((f) => ({ ...f, name: e.target.value }))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-medium">Description</label>
|
||||||
|
<Textarea
|
||||||
|
placeholder="Describe what this document should contain..."
|
||||||
|
rows={3}
|
||||||
|
value={form.description}
|
||||||
|
onChange={(e) => setForm((f) => ({ ...f, description: e.target.value }))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-medium">Accepted File Types</label>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{MIME_TYPE_OPTIONS.map((opt) => (
|
||||||
|
<Badge
|
||||||
|
key={opt.value}
|
||||||
|
variant={form.acceptedMimeTypes.includes(opt.value) ? 'default' : 'outline'}
|
||||||
|
className="cursor-pointer select-none"
|
||||||
|
onClick={() => toggleMimeType(opt.value)}
|
||||||
|
>
|
||||||
|
{opt.label}
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Select one or more file types. Leave empty to accept any file type.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-medium">Max File Size (MB)</label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
placeholder="e.g. 50"
|
||||||
|
value={form.maxSizeMB}
|
||||||
|
onChange={(e) => setForm((f) => ({ ...f, maxSizeMB: e.target.value }))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Checkbox
|
||||||
|
id="isRequired"
|
||||||
|
checked={form.isRequired}
|
||||||
|
onCheckedChange={(checked) => setForm((f) => ({ ...f, isRequired: !!checked }))}
|
||||||
|
/>
|
||||||
|
<label htmlFor="isRequired" className="text-sm">
|
||||||
|
Required document (applicant must upload to proceed)
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={closeDialog}>Cancel</Button>
|
||||||
|
<Button onClick={handleSubmit} disabled={isSaving || !form.name.trim()}>
|
||||||
|
{isSaving && <Loader2 className="h-4 w-4 mr-2 animate-spin" />}
|
||||||
|
{editingId ? 'Update' : 'Add Requirement'}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
1976
src/components/admin/round/filtering-dashboard.tsx
Normal file
1976
src/components/admin/round/filtering-dashboard.tsx
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,7 +1,78 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
import { useState, useCallback, useMemo } from 'react'
|
||||||
import { Layers } from 'lucide-react'
|
import { trpc } from '@/lib/trpc/client'
|
||||||
|
import { toast } from 'sonner'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Card, CardContent } from '@/components/ui/card'
|
||||||
|
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,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from '@/components/ui/select'
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from '@/components/ui/dialog'
|
||||||
|
import {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogTitle,
|
||||||
|
} from '@/components/ui/alert-dialog'
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuSeparator,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from '@/components/ui/dropdown-menu'
|
||||||
|
import {
|
||||||
|
Loader2,
|
||||||
|
MoreHorizontal,
|
||||||
|
ArrowRight,
|
||||||
|
XCircle,
|
||||||
|
CheckCircle2,
|
||||||
|
Clock,
|
||||||
|
Play,
|
||||||
|
LogOut,
|
||||||
|
Layers,
|
||||||
|
Trash2,
|
||||||
|
Plus,
|
||||||
|
Search,
|
||||||
|
ExternalLink,
|
||||||
|
} from 'lucide-react'
|
||||||
|
import Link from 'next/link'
|
||||||
|
import type { Route } from 'next'
|
||||||
|
|
||||||
|
const PROJECT_STATES = ['PENDING', 'IN_PROGRESS', 'PASSED', 'REJECTED', 'COMPLETED', 'WITHDRAWN'] as const
|
||||||
|
type ProjectState = (typeof PROJECT_STATES)[number]
|
||||||
|
|
||||||
|
const stateConfig: Record<ProjectState, { label: string; color: string; icon: React.ElementType }> = {
|
||||||
|
PENDING: { label: 'Pending', color: 'bg-gray-100 text-gray-700 border-gray-200', icon: Clock },
|
||||||
|
IN_PROGRESS: { label: 'In Progress', color: 'bg-blue-100 text-blue-700 border-blue-200', icon: Play },
|
||||||
|
PASSED: { label: 'Passed', color: 'bg-green-100 text-green-700 border-green-200', icon: CheckCircle2 },
|
||||||
|
REJECTED: { label: 'Rejected', color: 'bg-red-100 text-red-700 border-red-200', icon: XCircle },
|
||||||
|
COMPLETED: { label: 'Completed', color: 'bg-emerald-100 text-emerald-700 border-emerald-200', icon: CheckCircle2 },
|
||||||
|
WITHDRAWN: { label: 'Withdrawn', color: 'bg-orange-100 text-orange-700 border-orange-200', icon: LogOut },
|
||||||
|
}
|
||||||
|
|
||||||
type ProjectStatesTableProps = {
|
type ProjectStatesTableProps = {
|
||||||
competitionId: string
|
competitionId: string
|
||||||
@@ -9,25 +80,889 @@ type ProjectStatesTableProps = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function ProjectStatesTable({ competitionId, roundId }: ProjectStatesTableProps) {
|
export function ProjectStatesTable({ competitionId, roundId }: ProjectStatesTableProps) {
|
||||||
|
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set())
|
||||||
|
const [stateFilter, setStateFilter] = useState<string>('ALL')
|
||||||
|
const [searchQuery, setSearchQuery] = useState('')
|
||||||
|
const [batchDialogOpen, setBatchDialogOpen] = useState(false)
|
||||||
|
const [batchNewState, setBatchNewState] = useState<ProjectState>('PASSED')
|
||||||
|
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()
|
||||||
|
|
||||||
|
const poolLink = `/admin/projects/pool?roundId=${roundId}&competitionId=${competitionId}` as Route
|
||||||
|
|
||||||
|
const { data: projectStates, isLoading } = trpc.roundEngine.getProjectStates.useQuery(
|
||||||
|
{ roundId },
|
||||||
|
{ refetchInterval: 15_000 },
|
||||||
|
)
|
||||||
|
|
||||||
|
const transitionMutation = trpc.roundEngine.transitionProject.useMutation({
|
||||||
|
onSuccess: () => {
|
||||||
|
utils.roundEngine.getProjectStates.invalidate({ roundId })
|
||||||
|
toast.success('Project state updated')
|
||||||
|
},
|
||||||
|
onError: (err) => toast.error(err.message),
|
||||||
|
})
|
||||||
|
|
||||||
|
const batchTransitionMutation = trpc.roundEngine.batchTransition.useMutation({
|
||||||
|
onSuccess: (data) => {
|
||||||
|
utils.roundEngine.getProjectStates.invalidate({ roundId })
|
||||||
|
setSelectedIds(new Set())
|
||||||
|
setBatchDialogOpen(false)
|
||||||
|
toast.success(`${data.succeeded.length} projects updated${data.failed.length > 0 ? `, ${data.failed.length} failed` : ''}`)
|
||||||
|
},
|
||||||
|
onError: (err) => toast.error(err.message),
|
||||||
|
})
|
||||||
|
|
||||||
|
const removeMutation = trpc.roundEngine.removeFromRound.useMutation({
|
||||||
|
onSuccess: (data) => {
|
||||||
|
utils.roundEngine.getProjectStates.invalidate({ roundId })
|
||||||
|
setRemoveConfirmId(null)
|
||||||
|
toast.success(`Removed from ${data.removedFromRounds} round(s)`)
|
||||||
|
},
|
||||||
|
onError: (err) => toast.error(err.message),
|
||||||
|
})
|
||||||
|
|
||||||
|
const batchRemoveMutation = trpc.roundEngine.batchRemoveFromRound.useMutation({
|
||||||
|
onSuccess: (data) => {
|
||||||
|
utils.roundEngine.getProjectStates.invalidate({ roundId })
|
||||||
|
setSelectedIds(new Set())
|
||||||
|
setBatchRemoveOpen(false)
|
||||||
|
toast.success(`${data.removedCount} project(s) removed from this round and later rounds`)
|
||||||
|
},
|
||||||
|
onError: (err) => toast.error(err.message),
|
||||||
|
})
|
||||||
|
|
||||||
|
const handleTransition = (projectId: string, newState: ProjectState) => {
|
||||||
|
transitionMutation.mutate({ projectId, roundId, newState })
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleBatchTransition = () => {
|
||||||
|
batchTransitionMutation.mutate({
|
||||||
|
projectIds: Array.from(selectedIds),
|
||||||
|
roundId,
|
||||||
|
newState: batchNewState,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const toggleSelect = (id: string) => {
|
||||||
|
setSelectedIds((prev) => {
|
||||||
|
const next = new Set(prev)
|
||||||
|
if (next.has(id)) next.delete(id)
|
||||||
|
else next.add(id)
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply state filter first, then search filter
|
||||||
|
const filtered = useMemo(() => {
|
||||||
|
let result = projectStates ?? []
|
||||||
|
if (stateFilter !== 'ALL') {
|
||||||
|
result = result.filter((ps: any) => ps.state === stateFilter)
|
||||||
|
}
|
||||||
|
if (searchQuery.trim()) {
|
||||||
|
const q = searchQuery.toLowerCase()
|
||||||
|
result = result.filter((ps: any) =>
|
||||||
|
(ps.project?.title || '').toLowerCase().includes(q) ||
|
||||||
|
(ps.project?.teamName || '').toLowerCase().includes(q)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}, [projectStates, stateFilter, searchQuery])
|
||||||
|
|
||||||
|
const toggleSelectAll = useCallback(() => {
|
||||||
|
const ids = filtered.map((ps: any) => ps.projectId)
|
||||||
|
const allSelected = ids.length > 0 && ids.every((id: string) => selectedIds.has(id))
|
||||||
|
if (allSelected) {
|
||||||
|
setSelectedIds((prev) => {
|
||||||
|
const next = new Set(prev)
|
||||||
|
ids.forEach((id: string) => next.delete(id))
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
setSelectedIds((prev) => {
|
||||||
|
const next = new Set(prev)
|
||||||
|
ids.forEach((id: string) => next.add(id))
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}, [filtered, selectedIds])
|
||||||
|
|
||||||
|
// State counts
|
||||||
|
const counts = projectStates?.reduce((acc: Record<string, number>, ps: any) => {
|
||||||
|
acc[ps.state] = (acc[ps.state] || 0) + 1
|
||||||
|
return acc
|
||||||
|
}, {} as Record<string, number>) ?? {}
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<Skeleton className="h-10 w-full" />
|
||||||
|
{[1, 2, 3, 4, 5].map((i) => (
|
||||||
|
<Skeleton key={i} className="h-14 w-full" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!projectStates || projectStates.length === 0) {
|
||||||
return (
|
return (
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardContent className="py-12">
|
||||||
<CardTitle className="text-base">Project States</CardTitle>
|
<div className="flex flex-col items-center justify-center text-center">
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
Projects participating in this round
|
|
||||||
</p>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<div className="flex flex-col items-center justify-center py-12 text-center">
|
|
||||||
<div className="rounded-full bg-muted p-4 mb-4">
|
<div className="rounded-full bg-muted p-4 mb-4">
|
||||||
<Layers className="h-8 w-8 text-muted-foreground" />
|
<Layers className="h-8 w-8 text-muted-foreground" />
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm font-medium">No Active Projects</p>
|
<p className="text-sm font-medium">No Projects in This Round</p>
|
||||||
<p className="text-xs text-muted-foreground mt-1">
|
<p className="text-xs text-muted-foreground mt-1 max-w-sm">
|
||||||
Project states will appear here when the round is active
|
Assign projects from the Project Pool to this round to get started.
|
||||||
</p>
|
</p>
|
||||||
|
<Link href={poolLink}>
|
||||||
|
<Button size="sm" className="mt-4">
|
||||||
|
<Plus className="h-4 w-4 mr-1.5" />
|
||||||
|
Go to Project Pool
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
)
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Top bar: search + filters + add buttons */}
|
||||||
|
<div className="flex items-center justify-between gap-4 flex-wrap">
|
||||||
|
<div className="flex items-center gap-3 flex-1 min-w-0">
|
||||||
|
<div className="relative w-64">
|
||||||
|
<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 projects..."
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
|
className="pl-8 h-8 text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-1.5">
|
||||||
|
<button
|
||||||
|
onClick={() => { setStateFilter('ALL'); setSelectedIds(new Set()) }}
|
||||||
|
className={`text-xs px-3 py-1.5 rounded-full border transition-colors ${
|
||||||
|
stateFilter === 'ALL'
|
||||||
|
? 'bg-foreground text-background border-foreground'
|
||||||
|
: 'bg-muted text-muted-foreground border-transparent hover:border-border'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
All ({projectStates.length})
|
||||||
|
</button>
|
||||||
|
{PROJECT_STATES.map((state) => {
|
||||||
|
const count = counts[state] || 0
|
||||||
|
if (count === 0) return null
|
||||||
|
const cfg = stateConfig[state]
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={state}
|
||||||
|
onClick={() => { setStateFilter(state); setSelectedIds(new Set()) }}
|
||||||
|
className={`text-xs px-3 py-1.5 rounded-full border transition-colors ${
|
||||||
|
stateFilter === state
|
||||||
|
? cfg.color + ' border-current'
|
||||||
|
: 'bg-muted text-muted-foreground border-transparent hover:border-border'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{cfg.label} ({count})
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button size="sm" variant="outline" onClick={() => { setAddProjectOpen(true) }}>
|
||||||
|
<Plus className="h-4 w-4 mr-1.5" />
|
||||||
|
Add Project
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Search results count */}
|
||||||
|
{searchQuery.trim() && (
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Showing {filtered.length} of {projectStates.length} projects matching "{searchQuery}"
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Bulk actions bar */}
|
||||||
|
{selectedIds.size > 0 && (
|
||||||
|
<div className="flex items-center gap-3 p-3 rounded-lg bg-muted/50 border">
|
||||||
|
<span className="text-sm font-medium">{selectedIds.size} selected</span>
|
||||||
|
<div className="flex items-center gap-2 ml-auto">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setBatchDialogOpen(true)}
|
||||||
|
>
|
||||||
|
<ArrowRight className="h-3.5 w-3.5 mr-1.5" />
|
||||||
|
Change State
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="text-destructive border-destructive/30 hover:bg-destructive/10"
|
||||||
|
onClick={() => setBatchRemoveOpen(true)}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-3.5 w-3.5 mr-1.5" />
|
||||||
|
Remove from Round
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setSelectedIds(new Set())}
|
||||||
|
>
|
||||||
|
Clear
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Table */}
|
||||||
|
<div className="border rounded-lg overflow-hidden">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="grid grid-cols-[40px_1fr_140px_160px_120px_100px_48px] gap-2 px-4 py-2.5 bg-muted/40 text-xs font-medium text-muted-foreground border-b">
|
||||||
|
<div>
|
||||||
|
<Checkbox
|
||||||
|
checked={filtered.length > 0 && filtered.every((ps: any) => selectedIds.has(ps.projectId))}
|
||||||
|
onCheckedChange={toggleSelectAll}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>Project</div>
|
||||||
|
<div>Category</div>
|
||||||
|
<div>Country</div>
|
||||||
|
<div>State</div>
|
||||||
|
<div>Entered</div>
|
||||||
|
<div />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Rows */}
|
||||||
|
{filtered.map((ps: any) => {
|
||||||
|
const cfg = stateConfig[ps.state as ProjectState] || stateConfig.PENDING
|
||||||
|
const StateIcon = cfg.icon
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={ps.id}
|
||||||
|
className="grid grid-cols-[40px_1fr_140px_160px_120px_100px_48px] gap-2 px-4 py-3 items-center border-b last:border-b-0 hover:bg-muted/30 text-sm"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<Checkbox
|
||||||
|
checked={selectedIds.has(ps.projectId)}
|
||||||
|
onCheckedChange={() => toggleSelect(ps.projectId)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<Link
|
||||||
|
href={`/admin/projects/${ps.projectId}` as Route}
|
||||||
|
className="font-medium truncate block hover:underline text-foreground"
|
||||||
|
>
|
||||||
|
{ps.project?.title || 'Unknown'}
|
||||||
|
</Link>
|
||||||
|
<p className="text-xs text-muted-foreground truncate">{ps.project?.teamName}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Badge variant="outline" className="text-xs">
|
||||||
|
{ps.project?.competitionCategory || '—'}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-muted-foreground truncate">
|
||||||
|
{ps.project?.country || '—'}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Badge variant="outline" className={`text-xs ${cfg.color}`}>
|
||||||
|
<StateIcon className="h-3 w-3 mr-1" />
|
||||||
|
{cfg.label}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-muted-foreground">
|
||||||
|
{ps.enteredAt ? new Date(ps.enteredAt).toLocaleDateString() : '—'}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button variant="ghost" size="icon" className="h-7 w-7">
|
||||||
|
<MoreHorizontal className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end">
|
||||||
|
<DropdownMenuItem asChild>
|
||||||
|
<Link href={`/admin/projects/${ps.projectId}` as Route}>
|
||||||
|
<ExternalLink className="h-3.5 w-3.5 mr-2" />
|
||||||
|
View Project
|
||||||
|
</Link>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
{PROJECT_STATES.filter((s) => s !== ps.state).map((state) => {
|
||||||
|
const sCfg = stateConfig[state]
|
||||||
|
return (
|
||||||
|
<DropdownMenuItem
|
||||||
|
key={state}
|
||||||
|
onClick={() => handleTransition(ps.projectId, state)}
|
||||||
|
disabled={transitionMutation.isPending}
|
||||||
|
>
|
||||||
|
<sCfg.icon className="h-3.5 w-3.5 mr-2" />
|
||||||
|
Move to {sCfg.label}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={() => setRemoveConfirmId(ps.projectId)}
|
||||||
|
className="text-destructive focus:text-destructive"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-3.5 w-3.5 mr-2" />
|
||||||
|
Remove from Round
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
|
||||||
|
{filtered.length === 0 && searchQuery.trim() && (
|
||||||
|
<div className="px-4 py-8 text-center text-sm text-muted-foreground">
|
||||||
|
No projects match "{searchQuery}"
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Quick Add Dialog (legacy, kept for empty state) */}
|
||||||
|
<QuickAddDialog
|
||||||
|
open={quickAddOpen}
|
||||||
|
onOpenChange={setQuickAddOpen}
|
||||||
|
roundId={roundId}
|
||||||
|
competitionId={competitionId}
|
||||||
|
onAssigned={() => {
|
||||||
|
utils.roundEngine.getProjectStates.invalidate({ roundId })
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 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>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>Remove project from this round?</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
The project will be removed from this round and all subsequent rounds.
|
||||||
|
It will remain in any prior rounds it was already assigned to.
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||||
|
<AlertDialogAction
|
||||||
|
onClick={() => {
|
||||||
|
if (removeConfirmId) {
|
||||||
|
removeMutation.mutate({ projectId: removeConfirmId, roundId })
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||||
|
disabled={removeMutation.isPending}
|
||||||
|
>
|
||||||
|
{removeMutation.isPending && <Loader2 className="h-4 w-4 mr-2 animate-spin" />}
|
||||||
|
Remove
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
|
||||||
|
{/* Batch Remove Confirmation */}
|
||||||
|
<AlertDialog open={batchRemoveOpen} onOpenChange={setBatchRemoveOpen}>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>Remove {selectedIds.size} projects from this round?</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
These projects will be removed from this round and all subsequent rounds in the competition.
|
||||||
|
They will remain in any prior rounds they were already assigned to.
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||||
|
<AlertDialogAction
|
||||||
|
onClick={() => {
|
||||||
|
batchRemoveMutation.mutate({
|
||||||
|
projectIds: Array.from(selectedIds),
|
||||||
|
roundId,
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||||
|
disabled={batchRemoveMutation.isPending}
|
||||||
|
>
|
||||||
|
{batchRemoveMutation.isPending && <Loader2 className="h-4 w-4 mr-2 animate-spin" />}
|
||||||
|
Remove {selectedIds.size} Projects
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
|
||||||
|
{/* Batch Transition Dialog */}
|
||||||
|
<Dialog open={batchDialogOpen} onOpenChange={setBatchDialogOpen}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Change State for {selectedIds.size} Projects</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
All selected projects will be moved to the new state.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<label className="text-sm font-medium">New State</label>
|
||||||
|
<Select value={batchNewState} onValueChange={(v) => setBatchNewState(v as ProjectState)}>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{PROJECT_STATES.map((state) => (
|
||||||
|
<SelectItem key={state} value={state}>
|
||||||
|
{stateConfig[state].label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => setBatchDialogOpen(false)}>Cancel</Button>
|
||||||
|
<Button
|
||||||
|
onClick={handleBatchTransition}
|
||||||
|
disabled={batchTransitionMutation.isPending}
|
||||||
|
>
|
||||||
|
{batchTransitionMutation.isPending && <Loader2 className="h-4 w-4 mr-2 animate-spin" />}
|
||||||
|
Update {selectedIds.size} Projects
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Quick Add Dialog — inline search + assign projects to this round without leaving the page.
|
||||||
|
*/
|
||||||
|
function QuickAddDialog({
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
roundId,
|
||||||
|
competitionId,
|
||||||
|
onAssigned,
|
||||||
|
}: {
|
||||||
|
open: boolean
|
||||||
|
onOpenChange: (open: boolean) => void
|
||||||
|
roundId: string
|
||||||
|
competitionId: string
|
||||||
|
onAssigned: () => void
|
||||||
|
}) {
|
||||||
|
const [search, setSearch] = useState('')
|
||||||
|
const [addingIds, setAddingIds] = useState<Set<string>>(new Set())
|
||||||
|
|
||||||
|
// Get the competition to find programId
|
||||||
|
const { data: competition } = trpc.competition.getById.useQuery(
|
||||||
|
{ id: competitionId },
|
||||||
|
{ enabled: open && !!competitionId },
|
||||||
|
)
|
||||||
|
|
||||||
|
const programId = (competition as any)?.programId || ''
|
||||||
|
|
||||||
|
const { data: poolResults, isLoading } = trpc.projectPool.listUnassigned.useQuery(
|
||||||
|
{
|
||||||
|
programId,
|
||||||
|
excludeRoundId: roundId,
|
||||||
|
search: search.trim() || undefined,
|
||||||
|
perPage: 10,
|
||||||
|
},
|
||||||
|
{ enabled: open && !!programId },
|
||||||
|
)
|
||||||
|
|
||||||
|
const assignMutation = trpc.projectPool.assignToRound.useMutation({
|
||||||
|
onSuccess: (data) => {
|
||||||
|
toast.success(`Added to round`)
|
||||||
|
onAssigned()
|
||||||
|
// Remove from addingIds
|
||||||
|
setAddingIds(new Set())
|
||||||
|
},
|
||||||
|
onError: (err) => toast.error(err.message),
|
||||||
|
})
|
||||||
|
|
||||||
|
const handleQuickAssign = (projectId: string) => {
|
||||||
|
setAddingIds((prev) => new Set(prev).add(projectId))
|
||||||
|
assignMutation.mutate({ projectIds: [projectId], roundId })
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="max-w-lg">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Quick Add Projects</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Search and assign projects to this round without leaving the page.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<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={search}
|
||||||
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
|
className="pl-8"
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="max-h-[320px] overflow-y-auto space-y-1">
|
||||||
|
{isLoading && (
|
||||||
|
<div className="flex items-center justify-center py-8">
|
||||||
|
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!isLoading && poolResults?.projects.length === 0 && (
|
||||||
|
<p className="text-sm text-muted-foreground text-center py-8">
|
||||||
|
{search.trim() ? `No projects found matching "${search}"` : 'No unassigned projects available'}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{poolResults?.projects.map((project: any) => (
|
||||||
|
<div
|
||||||
|
key={project.id}
|
||||||
|
className="flex items-center justify-between gap-3 p-2.5 rounded-md hover:bg-muted/50 border border-transparent hover:border-border"
|
||||||
|
>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="text-sm font-medium truncate">{project.title}</p>
|
||||||
|
<p className="text-xs text-muted-foreground truncate">
|
||||||
|
{project.teamName}
|
||||||
|
{project.competitionCategory && (
|
||||||
|
<> · {project.competitionCategory}</>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
className="shrink-0"
|
||||||
|
disabled={assignMutation.isPending && addingIds.has(project.id)}
|
||||||
|
onClick={() => handleQuickAssign(project.id)}
|
||||||
|
>
|
||||||
|
{assignMutation.isPending && addingIds.has(project.id) ? (
|
||||||
|
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Plus className="h-3.5 w-3.5 mr-1" />
|
||||||
|
Add
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{poolResults && poolResults.total > 10 && (
|
||||||
|
<p className="text-xs text-muted-foreground text-center">
|
||||||
|
Showing 10 of {poolResults.total} — refine your search for more specific results
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</DialogContent>
|
||||||
|
</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: 'HARD_DEADLINE', // Not available in query, use default
|
|
||||||
graceHours: 0, // Not available in query, use default
|
|
||||||
lockOnClose: true, // Not available in query, use default
|
|
||||||
sortOrder: 1, // Not available in query, use default
|
|
||||||
})
|
|
||||||
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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -46,12 +46,9 @@ export function DeliberationConfig({ config, onChange, juryGroups }: Deliberatio
|
|||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
) : (
|
) : (
|
||||||
<Input
|
<p className="text-sm text-muted-foreground italic">
|
||||||
id="juryGroupId"
|
No jury groups available. Create one in the Juries section first.
|
||||||
placeholder="Jury group ID"
|
</p>
|
||||||
value={(config.juryGroupId as string) ?? ''}
|
|
||||||
onChange={(e) => update('juryGroupId', e.target.value)}
|
|
||||||
/>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|||||||
@@ -143,6 +143,18 @@ export function EvaluationConfig({ config, onChange }: EvaluationConfigProps) {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="requireDocumentUpload">Require Document Upload</Label>
|
||||||
|
<p className="text-xs text-muted-foreground">Applicants must upload documents for this evaluation round (disable if documents were uploaded in a previous round)</p>
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
id="requireDocumentUpload"
|
||||||
|
checked={(config.requireDocumentUpload as boolean) ?? false}
|
||||||
|
onCheckedChange={(v) => update('requireDocumentUpload', v)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<Label htmlFor="peerReviewEnabled">Peer Review</Label>
|
<Label htmlFor="peerReviewEnabled">Peer Review</Label>
|
||||||
|
|||||||
@@ -250,6 +250,7 @@ export function UserMobileActions({
|
|||||||
try {
|
try {
|
||||||
await sendInvitation.mutateAsync({ userId })
|
await sendInvitation.mutateAsync({ userId })
|
||||||
toast.success(`Invitation sent to ${userEmail}`)
|
toast.success(`Invitation sent to ${userEmail}`)
|
||||||
|
utils.user.list.invalidate()
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
toast.error(error instanceof Error ? error.message : 'Failed to send invitation')
|
toast.error(error instanceof Error ? error.message : 'Failed to send invitation')
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
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'
|
'use client'
|
||||||
|
|
||||||
import {
|
import { BarChart } from '@tremor/react'
|
||||||
BarChart,
|
|
||||||
Bar,
|
|
||||||
XAxis,
|
|
||||||
YAxis,
|
|
||||||
CartesianGrid,
|
|
||||||
Tooltip,
|
|
||||||
ResponsiveContainer,
|
|
||||||
Cell,
|
|
||||||
} from 'recharts'
|
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||||
|
|
||||||
interface CriteriaScoreData {
|
interface CriteriaScoreData {
|
||||||
@@ -23,31 +14,24 @@ interface CriteriaScoresProps {
|
|||||||
data: CriteriaScoreData[]
|
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) {
|
export function CriteriaScoresChart({ data }: CriteriaScoresProps) {
|
||||||
const formattedData = data.map((d) => ({
|
if (!data?.length) return null
|
||||||
...d,
|
|
||||||
displayName:
|
|
||||||
d.name.length > 20 ? d.name.substring(0, 20) + '...' : d.name,
|
|
||||||
}))
|
|
||||||
|
|
||||||
const overallAverage =
|
const overallAverage =
|
||||||
data.length > 0
|
data.length > 0
|
||||||
? data.reduce((sum, d) => sum + d.averageScore, 0) / data.length
|
? data.reduce((sum, d) => sum + d.averageScore, 0) / data.length
|
||||||
: 0
|
: 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 (
|
return (
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<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>Score by Evaluation Criteria</span>
|
||||||
<span className="text-sm font-normal text-muted-foreground">
|
<span className="text-sm font-normal text-muted-foreground">
|
||||||
Overall Avg: {overallAverage.toFixed(2)}
|
Overall Avg: {overallAverage.toFixed(2)}
|
||||||
@@ -55,51 +39,17 @@ export function CriteriaScoresChart({ data }: CriteriaScoresProps) {
|
|||||||
</CardTitle>
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="h-[300px]">
|
|
||||||
<ResponsiveContainer width="100%" height="100%">
|
|
||||||
<BarChart
|
<BarChart
|
||||||
data={formattedData}
|
data={chartData}
|
||||||
margin={{ top: 20, right: 30, bottom: 60, left: 20 }}
|
index="criterion"
|
||||||
>
|
categories={['Avg Score']}
|
||||||
<CartesianGrid strokeDasharray="3 3" className="stroke-muted" />
|
colors={['indigo']}
|
||||||
<XAxis
|
maxValue={10}
|
||||||
dataKey="displayName"
|
layout="vertical"
|
||||||
tick={{ fontSize: 11 }}
|
yAxisWidth={160}
|
||||||
angle={-45}
|
showLegend={false}
|
||||||
textAnchor="end"
|
className="h-[300px]"
|
||||||
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>
|
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,15 +1,6 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import {
|
import { BarChart } from '@tremor/react'
|
||||||
BarChart,
|
|
||||||
Bar,
|
|
||||||
XAxis,
|
|
||||||
YAxis,
|
|
||||||
CartesianGrid,
|
|
||||||
Tooltip,
|
|
||||||
ResponsiveContainer,
|
|
||||||
Legend,
|
|
||||||
} from 'recharts'
|
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||||
|
|
||||||
interface StageComparison {
|
interface StageComparison {
|
||||||
@@ -26,128 +17,114 @@ interface CrossStageComparisonProps {
|
|||||||
data: StageComparison[]
|
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) {
|
const baseData = data.map((round) => ({
|
||||||
// Prepare comparison data
|
name: round.roundName,
|
||||||
const comparisonData = data.map((stage, i) => ({
|
Projects: round.projectCount,
|
||||||
name: stage.roundName.length > 20 ? stage.roundName.slice(0, 20) + '...' : stage.roundName,
|
Evaluations: round.evaluationCount,
|
||||||
projects: stage.projectCount,
|
'Completion Rate': round.completionRate,
|
||||||
evaluations: stage.evaluationCount,
|
'Avg Score': round.averageScore
|
||||||
completionRate: stage.completionRate,
|
? parseFloat(round.averageScore.toFixed(2))
|
||||||
avgScore: stage.averageScore ? parseFloat(stage.averageScore.toFixed(2)) : 0,
|
: 0,
|
||||||
color: STAGE_COLORS[i % STAGE_COLORS.length],
|
|
||||||
}))
|
}))
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
|
||||||
{/* Metrics Comparison */}
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Stage Metrics Comparison</CardTitle>
|
<CardTitle>Round Metrics Comparison</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="h-[350px]">
|
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||||
<ResponsiveContainer width="100%" height="100%">
|
|
||||||
<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>
|
|
||||||
|
|
||||||
{/* Completion & Score Comparison */}
|
|
||||||
<div className="grid gap-6 lg:grid-cols-2">
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader className="pb-2">
|
||||||
<CardTitle>Completion Rate by Stage</CardTitle>
|
<CardTitle className="text-sm font-medium">Projects</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent className="pt-0">
|
||||||
<div className="h-[300px]">
|
|
||||||
<ResponsiveContainer width="100%" height="100%">
|
|
||||||
<BarChart
|
<BarChart
|
||||||
data={comparisonData}
|
data={baseData}
|
||||||
margin={{ top: 20, right: 20, bottom: 60, left: 20 }}
|
index="name"
|
||||||
>
|
categories={['Projects']}
|
||||||
<CartesianGrid strokeDasharray="3 3" className="stroke-muted" />
|
colors={['blue']}
|
||||||
<XAxis
|
showLegend={false}
|
||||||
dataKey="name"
|
yAxisWidth={40}
|
||||||
angle={-25}
|
className="h-[200px]"
|
||||||
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>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader className="pb-2">
|
||||||
<CardTitle>Average Score by Stage</CardTitle>
|
<CardTitle className="text-sm font-medium">
|
||||||
|
Evaluations
|
||||||
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent className="pt-0">
|
||||||
<div className="h-[300px]">
|
|
||||||
<ResponsiveContainer width="100%" height="100%">
|
|
||||||
<BarChart
|
<BarChart
|
||||||
data={comparisonData}
|
data={baseData}
|
||||||
margin={{ top: 20, right: 20, bottom: 60, left: 20 }}
|
index="name"
|
||||||
>
|
categories={['Evaluations']}
|
||||||
<CartesianGrid strokeDasharray="3 3" className="stroke-muted" />
|
colors={['violet']}
|
||||||
<XAxis
|
showLegend={false}
|
||||||
dataKey="name"
|
yAxisWidth={40}
|
||||||
angle={-25}
|
className="h-[200px]"
|
||||||
textAnchor="end"
|
|
||||||
height={60}
|
|
||||||
tick={{ fontSize: 12 }}
|
|
||||||
/>
|
/>
|
||||||
<YAxis domain={[0, 10]} />
|
</CardContent>
|
||||||
<Tooltip
|
</Card>
|
||||||
contentStyle={{
|
|
||||||
backgroundColor: 'hsl(var(--card))',
|
<Card>
|
||||||
border: '1px solid hsl(var(--border))',
|
<CardHeader className="pb-2">
|
||||||
borderRadius: '6px',
|
<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]"
|
||||||
/>
|
/>
|
||||||
<Bar dataKey="avgScore" name="Avg Score" fill="#de0f1e" radius={[4, 4, 0, 0]} />
|
|
||||||
</BarChart>
|
|
||||||
</ResponsiveContainer>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</CardContent>
|
||||||
|
</Card>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,18 +1,6 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import {
|
import { BarChart } from '@tremor/react'
|
||||||
PieChart,
|
|
||||||
Pie,
|
|
||||||
Cell,
|
|
||||||
Tooltip,
|
|
||||||
ResponsiveContainer,
|
|
||||||
Legend,
|
|
||||||
BarChart,
|
|
||||||
Bar,
|
|
||||||
XAxis,
|
|
||||||
YAxis,
|
|
||||||
CartesianGrid,
|
|
||||||
} from 'recharts'
|
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||||
import { Badge } from '@/components/ui/badge'
|
import { Badge } from '@/components/ui/badge'
|
||||||
|
|
||||||
@@ -28,12 +16,6 @@ interface DiversityMetricsProps {
|
|||||||
data: DiversityData
|
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 */
|
/** Convert ISO 3166-1 alpha-2 code to full country name using Intl API */
|
||||||
function getCountryName(code: string): string {
|
function getCountryName(code: string): string {
|
||||||
if (code === 'Others') return 'Others'
|
if (code === 'Others') return 'Others'
|
||||||
@@ -54,35 +36,8 @@ function formatLabel(value: string): string {
|
|||||||
.replace(/\b\w/g, (c) => c.toUpperCase())
|
.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) {
|
export function DiversityMetricsChart({ data }: DiversityMetricsProps) {
|
||||||
if (data.total === 0) {
|
if (!data || data.total === 0) {
|
||||||
return (
|
return (
|
||||||
<Card>
|
<Card>
|
||||||
<CardContent className="flex items-center justify-center py-12">
|
<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)
|
// Top countries — horizontal bar chart for readability
|
||||||
const topCountries = data.byCountry.slice(0, 10)
|
const countryBarData = (data.byCountry || []).slice(0, 15).map((c) => ({
|
||||||
const otherCountries = data.byCountry.slice(10)
|
country: getCountryName(c.country),
|
||||||
const countryPieData = otherCountries.length > 0
|
Projects: c.count,
|
||||||
? [...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),
|
|
||||||
}))
|
}))
|
||||||
|
|
||||||
const formattedOceanIssues = data.byOceanIssue.slice(0, 15).map((o) => ({
|
const categoryData = (data.byCategory || []).slice(0, 10).map((c) => ({
|
||||||
...o,
|
category: formatLabel(c.category),
|
||||||
|
Projects: c.count,
|
||||||
|
}))
|
||||||
|
|
||||||
|
const oceanIssueData = (data.byOceanIssue || []).slice(0, 15).map((o) => ({
|
||||||
issue: formatLabel(o.issue),
|
issue: formatLabel(o.issue),
|
||||||
|
Projects: o.count,
|
||||||
}))
|
}))
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* Summary */}
|
{/* Summary stats row */}
|
||||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
|
||||||
<Card>
|
<Card>
|
||||||
<CardContent className="pt-6">
|
<CardContent className="p-4">
|
||||||
<div className="text-2xl font-bold">{data.total}</div>
|
<p className="text-2xl font-bold tabular-nums">{data.total}</p>
|
||||||
<p className="text-sm text-muted-foreground">Total Projects</p>
|
<p className="text-xs text-muted-foreground">Total Projects</p>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
<Card>
|
<Card>
|
||||||
<CardContent className="pt-6">
|
<CardContent className="p-4">
|
||||||
<div className="text-2xl font-bold">{data.byCountry.length}</div>
|
<p className="text-2xl font-bold tabular-nums">{(data.byCountry || []).length}</p>
|
||||||
<p className="text-sm text-muted-foreground">Countries Represented</p>
|
<p className="text-xs text-muted-foreground">Countries</p>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
<Card>
|
<Card>
|
||||||
<CardContent className="pt-6">
|
<CardContent className="p-4">
|
||||||
<div className="text-2xl font-bold">{data.byCategory.length}</div>
|
<p className="text-2xl font-bold tabular-nums">{(data.byCategory || []).length}</p>
|
||||||
<p className="text-sm text-muted-foreground">Categories</p>
|
<p className="text-xs text-muted-foreground">Categories</p>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
<Card>
|
<Card>
|
||||||
<CardContent className="pt-6">
|
<CardContent className="p-4">
|
||||||
<div className="text-2xl font-bold">{data.byTag.length}</div>
|
<p className="text-2xl font-bold tabular-nums">{(data.byOceanIssue || []).length}</p>
|
||||||
<p className="text-sm text-muted-foreground">Unique Tags</p>
|
<p className="text-xs text-muted-foreground">Ocean Issues</p>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid gap-6 lg:grid-cols-2">
|
<div className="grid gap-6 lg:grid-cols-2">
|
||||||
{/* Country Distribution */}
|
{/* Country Distribution — horizontal bars */}
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader className="pb-2">
|
||||||
<CardTitle>Geographic Distribution</CardTitle>
|
<CardTitle className="text-base">Geographic Distribution</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="h-[400px]">
|
{countryBarData.length > 0 ? (
|
||||||
<ResponsiveContainer width="100%" height="100%">
|
<BarChart
|
||||||
<PieChart>
|
data={countryBarData}
|
||||||
<Pie
|
index="country"
|
||||||
data={countryPieData}
|
categories={['Projects']}
|
||||||
cx="50%"
|
colors={['cyan']}
|
||||||
cy="50%"
|
showLegend={false}
|
||||||
innerRadius={60}
|
layout="horizontal"
|
||||||
outerRadius={120}
|
yAxisWidth={120}
|
||||||
paddingAngle={2}
|
className="h-[360px]"
|
||||||
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>
|
<p className="text-muted-foreground text-center py-8">No geographic data</p>
|
||||||
</div>
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* Category Distribution */}
|
{/* Competition Categories — horizontal bars */}
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader className="pb-2">
|
||||||
<CardTitle>Competition Categories</CardTitle>
|
<CardTitle className="text-base">Competition Categories</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
{formattedCategories.length > 0 ? (
|
{categoryData.length > 0 ? (
|
||||||
<div className="h-[400px]">
|
categoryData.length <= 4 ? (
|
||||||
<ResponsiveContainer width="100%" height="100%">
|
/* Clean stacked bars for few categories */
|
||||||
<BarChart
|
<div className="space-y-4 pt-2">
|
||||||
data={formattedCategories}
|
{categoryData.map((c) => {
|
||||||
layout="vertical"
|
const maxCount = Math.max(...categoryData.map((d) => d.Projects))
|
||||||
margin={{ top: 5, right: 30, bottom: 5, left: 120 }}
|
const pct = maxCount > 0 ? (c.Projects / maxCount) * 100 : 0
|
||||||
>
|
return (
|
||||||
<CartesianGrid strokeDasharray="3 3" className="stroke-muted" />
|
<div key={c.category} className="space-y-1.5">
|
||||||
<XAxis type="number" tick={{ fontSize: 13 }} />
|
<div className="flex items-center justify-between text-sm">
|
||||||
<YAxis
|
<span className="font-medium">{c.category}</span>
|
||||||
type="category"
|
<span className="tabular-nums text-muted-foreground">{c.Projects}</span>
|
||||||
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>
|
</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>
|
<p className="text-muted-foreground text-center py-8">No category data</p>
|
||||||
)}
|
)}
|
||||||
@@ -218,56 +165,43 @@ export function DiversityMetricsChart({ data }: DiversityMetricsProps) {
|
|||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Ocean Issues */}
|
{/* Ocean Issues — horizontal bars for readability */}
|
||||||
{formattedOceanIssues.length > 0 && (
|
{oceanIssueData.length > 0 && (
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader className="pb-2">
|
||||||
<CardTitle>Ocean Issues Addressed</CardTitle>
|
<CardTitle className="text-base">Ocean Issues Addressed</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="h-[400px]">
|
|
||||||
<ResponsiveContainer width="100%" height="100%">
|
|
||||||
<BarChart
|
<BarChart
|
||||||
data={formattedOceanIssues}
|
data={oceanIssueData}
|
||||||
margin={{ top: 20, right: 30, bottom: 80, left: 20 }}
|
index="issue"
|
||||||
>
|
categories={['Projects']}
|
||||||
<CartesianGrid strokeDasharray="3 3" className="stroke-muted" />
|
colors={['blue']}
|
||||||
<XAxis
|
showLegend={false}
|
||||||
dataKey="issue"
|
layout="horizontal"
|
||||||
angle={-35}
|
yAxisWidth={200}
|
||||||
textAnchor="end"
|
className="h-[400px]"
|
||||||
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>
|
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Tags Cloud */}
|
{/* Tags — clean pill cloud */}
|
||||||
{data.byTag.length > 0 && (
|
{(data.byTag || []).length > 0 && (
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader className="pb-2">
|
||||||
<CardTitle>Project Tags</CardTitle>
|
<CardTitle className="text-base">Project Tags</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
{data.byTag.slice(0, 30).map((tag) => (
|
{(data.byTag || []).slice(0, 30).map((tag) => (
|
||||||
<Badge
|
<Badge
|
||||||
key={tag.tag}
|
key={tag.tag}
|
||||||
variant="secondary"
|
variant="outline"
|
||||||
className="text-sm"
|
className="px-3 py-1 text-sm font-normal"
|
||||||
style={{
|
|
||||||
fontSize: `${Math.max(0.75, Math.min(1.4, 0.75 + tag.percentage / 20))}rem`,
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
{tag.tag} ({tag.count})
|
{tag.tag}
|
||||||
|
<span className="ml-1.5 text-muted-foreground tabular-nums">({tag.count})</span>
|
||||||
</Badge>
|
</Badge>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,18 +1,6 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import {
|
import { AreaChart } from '@tremor/react'
|
||||||
LineChart,
|
|
||||||
Line,
|
|
||||||
XAxis,
|
|
||||||
YAxis,
|
|
||||||
CartesianGrid,
|
|
||||||
Tooltip,
|
|
||||||
Legend,
|
|
||||||
ResponsiveContainer,
|
|
||||||
Area,
|
|
||||||
ComposedChart,
|
|
||||||
Bar,
|
|
||||||
} from 'recharts'
|
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||||
|
|
||||||
interface TimelineDataPoint {
|
interface TimelineDataPoint {
|
||||||
@@ -26,18 +14,20 @@ interface EvaluationTimelineProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function EvaluationTimelineChart({ data }: EvaluationTimelineProps) {
|
export function EvaluationTimelineChart({ data }: EvaluationTimelineProps) {
|
||||||
// Format date for display
|
if (!data?.length) return null
|
||||||
const formattedData = data.map((d) => ({
|
|
||||||
...d,
|
|
||||||
dateFormatted: new Date(d.date).toLocaleDateString('en-US', {
|
|
||||||
month: 'short',
|
|
||||||
day: 'numeric',
|
|
||||||
}),
|
|
||||||
}))
|
|
||||||
|
|
||||||
const totalEvaluations =
|
const totalEvaluations =
|
||||||
data.length > 0 ? data[data.length - 1].cumulative : 0
|
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 (
|
return (
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
@@ -49,53 +39,16 @@ export function EvaluationTimelineChart({ data }: EvaluationTimelineProps) {
|
|||||||
</CardTitle>
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="h-[300px]">
|
<AreaChart
|
||||||
<ResponsiveContainer width="100%" height="100%">
|
data={chartData}
|
||||||
<ComposedChart
|
index="date"
|
||||||
data={formattedData}
|
categories={['Cumulative', 'Daily']}
|
||||||
margin={{ top: 20, right: 30, bottom: 20, left: 20 }}
|
colors={['indigo', 'amber']}
|
||||||
>
|
curveType="monotone"
|
||||||
<CartesianGrid strokeDasharray="3 3" className="stroke-muted" />
|
showGradient={true}
|
||||||
<XAxis
|
yAxisWidth={50}
|
||||||
dataKey="dateFormatted"
|
className="h-[300px]"
|
||||||
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>
|
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
)
|
)
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user