Compare commits
95 Commits
771f35c695
...
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 |
@@ -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
|
||||||
|
|||||||
873
package-lock.json
generated
873
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",
|
||||||
@@ -83,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"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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?
|
||||||
@@ -378,8 +380,9 @@ model User {
|
|||||||
filteringOverrides FilteringResult[] @relation("FilteringOverriddenBy")
|
filteringOverrides FilteringResult[] @relation("FilteringOverriddenBy")
|
||||||
|
|
||||||
// Award overrides
|
// Award overrides
|
||||||
awardEligibilityOverrides AwardEligibility[] @relation("AwardEligibilityOverriddenBy")
|
awardEligibilityOverrides AwardEligibility[] @relation("AwardEligibilityOverriddenBy")
|
||||||
awardWinnerOverrides SpecialAward[] @relation("AwardOverriddenBy")
|
awardEligibilityConfirms AwardEligibility[] @relation("AwardEligibilityConfirmer")
|
||||||
|
awardWinnerOverrides SpecialAward[] @relation("AwardOverriddenBy")
|
||||||
|
|
||||||
// In-app notifications
|
// In-app notifications
|
||||||
notifications InAppNotification[] @relation("UserNotifications")
|
notifications InAppNotification[] @relation("UserNotifications")
|
||||||
@@ -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
|
||||||
|
|
||||||
@@ -689,6 +698,12 @@ model ProjectFile {
|
|||||||
size Int // bytes
|
size Int // bytes
|
||||||
pageCount Int? // Number of pages (PDFs, presentations, etc.)
|
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
|
||||||
objectKey String
|
objectKey String
|
||||||
@@ -900,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
|
||||||
@@ -1501,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
|
||||||
@@ -1524,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])
|
||||||
@@ -1539,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
|
||||||
|
|
||||||
@@ -1551,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])
|
||||||
@@ -2074,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
|
||||||
|
|
||||||
@@ -2088,6 +2116,7 @@ model Competition {
|
|||||||
|
|
||||||
@@index([programId])
|
@@index([programId])
|
||||||
@@index([status])
|
@@index([status])
|
||||||
|
@@index([isTest])
|
||||||
}
|
}
|
||||||
|
|
||||||
model Round {
|
model Round {
|
||||||
@@ -2112,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[]
|
||||||
@@ -2151,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>
|
||||||
<pre className="text-xs bg-muted rounded p-2 overflow-x-auto">
|
{log.action === 'JUROR_DROPOUT_RESHUFFLE' ? (
|
||||||
{JSON.stringify(log.detailsJson, null, 2)}
|
<ReshuffleDetailView details={log.detailsJson as Record<string, unknown>} />
|
||||||
</pre>
|
) : log.action === 'COI_REASSIGNMENT' ? (
|
||||||
|
<COIReassignmentDetailView details={log.detailsJson as Record<string, unknown>} />
|
||||||
|
) : (
|
||||||
|
<pre className="text-xs bg-muted rounded p-2 overflow-x-auto">
|
||||||
|
{JSON.stringify(log.detailsJson, null, 2)}
|
||||||
|
</pre>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{!!(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>
|
||||||
<pre className="text-xs bg-muted rounded p-2 overflow-x-auto">
|
{log.action === 'JUROR_DROPOUT_RESHUFFLE' ? (
|
||||||
{JSON.stringify(log.detailsJson, null, 2)}
|
<ReshuffleDetailView details={log.detailsJson as Record<string, unknown>} />
|
||||||
</pre>
|
) : log.action === 'COI_REASSIGNMENT' ? (
|
||||||
|
<COIReassignmentDetailView details={log.detailsJson as Record<string, unknown>} />
|
||||||
|
) : (
|
||||||
|
<pre className="text-xs bg-muted rounded p-2 overflow-x-auto">
|
||||||
|
{JSON.stringify(log.detailsJson, null, 2)}
|
||||||
|
</pre>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</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,13 +262,11 @@ 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)
|
<SelectItem key={round.id} value={round.id}>
|
||||||
.map((round) => (
|
{round.name} ({round.roundType})
|
||||||
<SelectItem key={round.id} value={round.id}>
|
</SelectItem>
|
||||||
{round.name} ({round.roundType})
|
))}
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
@@ -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 }
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -50,8 +68,19 @@ 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,13 +106,39 @@ 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 (
|
||||||
<div className="space-y-6 p-4 sm:p-6">
|
<div className="space-y-6 p-4 sm:p-6">
|
||||||
@@ -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 (
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ import {
|
|||||||
Loader2,
|
Loader2,
|
||||||
Plus,
|
Plus,
|
||||||
CalendarDays,
|
CalendarDays,
|
||||||
|
Radio,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { CompetitionTimeline } from '@/components/admin/competition/competition-timeline'
|
import { CompetitionTimeline } from '@/components/admin/competition/competition-timeline'
|
||||||
|
|
||||||
@@ -104,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: () => {
|
||||||
@@ -284,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>
|
||||||
@@ -303,7 +305,7 @@ export default function CompetitionDetailPage() {
|
|||||||
<span className="text-sm font-medium">Projects</span>
|
<span className="text-sm font-medium">Projects</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-2xl font-bold mt-1">
|
<p className="text-2xl font-bold mt-1">
|
||||||
{competition.rounds.reduce((sum: number, r: any) => sum + (r._count?.projectRoundStates ?? 0), 0)}
|
{(competition as any).distinctProjectCount ?? 0}
|
||||||
</p>
|
</p>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -331,21 +333,21 @@ 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.
|
||||||
@@ -353,7 +355,7 @@ export default function CompetitionDetailPage() {
|
|||||||
</Card>
|
</Card>
|
||||||
) : (
|
) : (
|
||||||
<div className="grid gap-3 md:grid-cols-2 lg:grid-cols-3">
|
<div className="grid gap-3 md:grid-cols-2 lg:grid-cols-3">
|
||||||
{competition.rounds.map((round: any, index: number) => {
|
{competition.rounds.filter((r: any) => !r.specialAwardId).map((round: any, index: number) => {
|
||||||
const projectCount = round._count?.projectRoundStates ?? 0
|
const projectCount = round._count?.projectRoundStates ?? 0
|
||||||
const assignmentCount = round._count?.assignments ?? 0
|
const assignmentCount = round._count?.assignments ?? 0
|
||||||
const statusLabel = round.status.replace('ROUND_', '')
|
const statusLabel = round.status.replace('ROUND_', '')
|
||||||
@@ -385,7 +387,7 @@ export default function CompetitionDetailPage() {
|
|||||||
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"
|
||||||
@@ -434,6 +436,19 @@ export default function CompetitionDetailPage() {
|
|||||||
<span className="truncate">{round.juryGroup.name}</span>
|
<span className="truncate">{round.juryGroup.name}</span>
|
||||||
</div>
|
</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>
|
||||||
|
|||||||
@@ -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,99 +556,20 @@ 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>
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Suggested cap for new members. Per-member overrides and juror self-service preferences take priority.
|
||||||
<div className="space-y-2">
|
</p>
|
||||||
<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">
|
|
||||||
Number of assignments allowed above the cap when in soft mode
|
|
||||||
</p>
|
|
||||||
</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>
|
</div>
|
||||||
|
|
||||||
<Button type="submit" disabled={isPending} className="w-full sm:w-auto">
|
<Button type="submit" disabled={isPending} className="w-full sm:w-auto">
|
||||||
|
|||||||
@@ -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,57 +565,74 @@ 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>
|
||||||
)}
|
|
||||||
<span className={isRead ? 'text-muted-foreground' : 'font-medium'}>
|
|
||||||
{String(msg?.subject || 'No subject')}
|
|
||||||
</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">
|
||||||
<Badge variant="outline" className="text-xs">
|
<div className="flex gap-1">
|
||||||
{channel === 'EMAIL' ? (
|
{channels.includes('EMAIL') && (
|
||||||
<><Mail className="mr-1 h-3 w-3" />Email</>
|
<Badge variant="outline" className="text-xs">
|
||||||
) : (
|
<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,12 +65,20 @@ export default async function ProgramDetailPage({ params }: ProgramDetailPagePro
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Button variant="outline" asChild>
|
<div className="flex items-center gap-2">
|
||||||
<Link href={`/admin/programs/${id}/edit`}>
|
<Button variant="outline" asChild>
|
||||||
<Pencil className="mr-2 h-4 w-4" />
|
<Link href={`/admin/programs/${id}/mentorship` as Route}>
|
||||||
Edit
|
<GraduationCap className="mr-2 h-4 w-4" />
|
||||||
</Link>
|
Mentorship
|
||||||
</Button>
|
</Link>
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" asChild>
|
||||||
|
<Link href={`/admin/programs/${id}/edit`}>
|
||||||
|
<Pencil className="mr-2 h-4 w-4" />
|
||||||
|
Edit
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{program.description && (
|
{program.description && (
|
||||||
@@ -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
|
||||||
@@ -114,6 +124,10 @@ function ProjectDetailContent({ projectId }: { projectId: string }) {
|
|||||||
|
|
||||||
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 />
|
||||||
}
|
}
|
||||||
@@ -529,116 +543,25 @@ function ProjectDetailContent({ projectId }: { projectId: string }) {
|
|||||||
<AnimatedCard index={4}>
|
<AnimatedCard index={4}>
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="flex items-center gap-2.5 text-lg">
|
<div className="flex items-center justify-between">
|
||||||
<div className="rounded-lg bg-rose-500/10 p-1.5">
|
<div>
|
||||||
<FileText className="h-4 w-4 text-rose-500" />
|
<CardTitle className="flex items-center gap-2.5 text-lg">
|
||||||
|
<div className="rounded-lg bg-rose-500/10 p-1.5">
|
||||||
|
<FileText className="h-4 w-4 text-rose-500" />
|
||||||
|
</div>
|
||||||
|
Files
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Project documents and materials organized by competition round
|
||||||
|
</CardDescription>
|
||||||
</div>
|
</div>
|
||||||
Files
|
<AnalyzeDocumentsButton projectId={projectId} onComplete={() => utils.file.listByProject.invalidate({ projectId })} />
|
||||||
</CardTitle>
|
</div>
|
||||||
<CardDescription>
|
|
||||||
Project documents and materials organized by competition round
|
|
||||||
</CardDescription>
|
|
||||||
</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 }))}
|
||||||
@@ -652,21 +575,30 @@ function ProjectDetailContent({ projectId }: { projectId: string }) {
|
|||||||
{files && files.length > 0 && (
|
{files && files.length > 0 && (
|
||||||
<>
|
<>
|
||||||
<Separator />
|
<Separator />
|
||||||
<div>
|
<FileViewer
|
||||||
<p className="text-sm font-semibold mb-3">All Uploaded Files</p>
|
projectId={projectId}
|
||||||
<FileViewer
|
files={files.map((f) => ({
|
||||||
projectId={projectId}
|
id: f.id,
|
||||||
files={files.map((f) => ({
|
fileName: f.fileName,
|
||||||
id: f.id,
|
fileType: f.fileType,
|
||||||
fileName: f.fileName,
|
mimeType: f.mimeType,
|
||||||
fileType: f.fileType,
|
size: f.size,
|
||||||
mimeType: f.mimeType,
|
bucket: f.bucket,
|
||||||
size: f.size,
|
objectKey: f.objectKey,
|
||||||
bucket: f.bucket,
|
pageCount: f.pageCount,
|
||||||
objectKey: f.objectKey,
|
textPreview: f.textPreview,
|
||||||
}))}
|
detectedLang: f.detectedLang,
|
||||||
/>
|
langConfidence: f.langConfidence,
|
||||||
</div>
|
analyzedAt: f.analyzedAt ? String(f.analyzedAt) : null,
|
||||||
|
requirementId: f.requirementId,
|
||||||
|
requirement: f.requirement ? {
|
||||||
|
id: f.requirement.id,
|
||||||
|
name: f.requirement.name,
|
||||||
|
description: f.requirement.description,
|
||||||
|
isRequired: f.requirement.isRequired,
|
||||||
|
} : null,
|
||||||
|
}))}
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
@@ -708,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
|
||||||
@@ -786,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>
|
||||||
@@ -795,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
|
||||||
@@ -847,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)
|
||||||
|
|
||||||
|
|||||||
@@ -711,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)
|
||||||
@@ -870,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>
|
||||||
@@ -913,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>
|
||||||
@@ -1071,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 && (
|
||||||
@@ -1182,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>
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ import { Badge } from '@/components/ui/badge'
|
|||||||
import { Input } from '@/components/ui/input'
|
import { Input } from '@/components/ui/input'
|
||||||
import { Card, CardContent } 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, X, Layers, Info } from 'lucide-react'
|
import { ArrowLeft, ChevronLeft, ChevronRight, Loader2, X, Layers, Info } from 'lucide-react'
|
||||||
|
|
||||||
@@ -387,7 +388,12 @@ export default function ProjectPoolPage() {
|
|||||||
)}
|
)}
|
||||||
</td>
|
</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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
if (programs?.length && !selectedValue) {
|
useEffect(() => {
|
||||||
setSelectedValue(`all:${programs[0].id}`)
|
if (programs?.length && !selectedValue) {
|
||||||
}
|
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
|
||||||
if (rounds.length && !selectedValue) {
|
useEffect(() => {
|
||||||
setSelectedValue(rounds[0].id)
|
if (rounds.length && !selectedValue) {
|
||||||
}
|
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` }))
|
||||||
) || []
|
) || []
|
||||||
|
|
||||||
if (stages.length && !selectedValue) {
|
useEffect(() => {
|
||||||
setSelectedValue(stages[0].id)
|
if (stages.length && !selectedValue) {
|
||||||
}
|
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` }))
|
||||||
) || []
|
) || []
|
||||||
|
|
||||||
if (stages.length && !selectedValue) {
|
useEffect(() => {
|
||||||
setSelectedValue(stages[0].id)
|
if (stages.length && !selectedValue) {
|
||||||
}
|
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` }))
|
||||||
) || []
|
) || []
|
||||||
|
|
||||||
if (pdfStages.length && !pdfStageId) {
|
useEffect(() => {
|
||||||
setPdfStageId(pdfStages[0].id)
|
if (pdfStages.length && !pdfStageId) {
|
||||||
}
|
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
@@ -95,6 +95,7 @@ type RoundWithStats = {
|
|||||||
sortOrder: number
|
sortOrder: number
|
||||||
windowOpenAt: string | null
|
windowOpenAt: string | null
|
||||||
windowCloseAt: string | null
|
windowCloseAt: string | null
|
||||||
|
specialAwardId: string | null
|
||||||
juryGroup: { id: string; name: string } | null
|
juryGroup: { id: string; name: string } | null
|
||||||
_count: { projectRoundStates: number; assignments: number }
|
_count: { projectRoundStates: number; assignments: number }
|
||||||
}
|
}
|
||||||
@@ -122,18 +123,19 @@ export default function RoundsPage() {
|
|||||||
const [competitionEdits, setCompetitionEdits] = useState<Record<string, unknown>>({})
|
const [competitionEdits, setCompetitionEdits] = useState<Record<string, unknown>>({})
|
||||||
const [editingCompId, setEditingCompId] = useState<string | null>(null)
|
const [editingCompId, setEditingCompId] = useState<string | null>(null)
|
||||||
const [filterType, setFilterType] = useState<string>('all')
|
const [filterType, setFilterType] = useState<string>('all')
|
||||||
|
const [selectedCompId, setSelectedCompId] = useState<string | null>(null)
|
||||||
|
|
||||||
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 }
|
||||||
)
|
)
|
||||||
|
|
||||||
// Use the first (and usually only) competition
|
// Auto-select first competition, or use the user's selection
|
||||||
const comp = competitions?.[0]
|
const comp = competitions?.find((c: any) => c.id === selectedCompId) ?? competitions?.[0]
|
||||||
|
|
||||||
const { data: compDetail, isLoading: isLoadingDetail } = trpc.competition.getById.useQuery(
|
const { data: compDetail, isLoading: isLoadingDetail } = trpc.competition.getById.useQuery(
|
||||||
{ id: comp?.id! },
|
{ id: comp?.id! },
|
||||||
{ enabled: !!comp?.id }
|
{ enabled: !!comp?.id, refetchInterval: 30_000 }
|
||||||
)
|
)
|
||||||
|
|
||||||
const { data: awards } = trpc.specialAward.list.useQuery(
|
const { data: awards } = trpc.specialAward.list.useQuery(
|
||||||
@@ -192,7 +194,7 @@ export default function RoundsPage() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
const slug = roundForm.name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '')
|
const slug = roundForm.name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '')
|
||||||
const nextOrder = rounds.length
|
const nextOrder = (compDetail?.rounds ?? []).length
|
||||||
createRoundMutation.mutate({
|
createRoundMutation.mutate({
|
||||||
competitionId: comp.id,
|
competitionId: comp.id,
|
||||||
name: roundForm.name.trim(),
|
name: roundForm.name.trim(),
|
||||||
@@ -203,14 +205,14 @@ export default function RoundsPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const startEditSettings = () => {
|
const startEditSettings = () => {
|
||||||
if (!comp) return
|
if (!comp || !compDetail) return
|
||||||
setEditingCompId(comp.id)
|
setEditingCompId(comp.id)
|
||||||
setCompetitionEdits({
|
setCompetitionEdits({
|
||||||
name: comp.name,
|
name: compDetail.name,
|
||||||
categoryMode: (comp as any).categoryMode,
|
categoryMode: compDetail.categoryMode,
|
||||||
startupFinalistCount: (comp as any).startupFinalistCount,
|
startupFinalistCount: compDetail.startupFinalistCount,
|
||||||
conceptFinalistCount: (comp as any).conceptFinalistCount,
|
conceptFinalistCount: compDetail.conceptFinalistCount,
|
||||||
notifyOnDeadlineApproach: (comp as any).notifyOnDeadlineApproach,
|
notifyOnDeadlineApproach: compDetail.notifyOnDeadlineApproach,
|
||||||
})
|
})
|
||||||
setSettingsOpen(true)
|
setSettingsOpen(true)
|
||||||
}
|
}
|
||||||
@@ -282,13 +284,30 @@ export default function RoundsPage() {
|
|||||||
// ─── Main Render ─────────────────────────────────────────────────────────
|
// ─── Main Render ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
const activeFilter = filterType !== 'all'
|
const activeFilter = filterType !== 'all'
|
||||||
const totalProjects = rounds.reduce((s, r) => s + r._count.projectRoundStates, 0)
|
const totalProjects = (compDetail as any)?.distinctProjectCount ?? 0
|
||||||
const totalAssignments = rounds.reduce((s, r) => s + r._count.assignments, 0)
|
const allRounds = (compDetail?.rounds ?? []) as RoundWithStats[]
|
||||||
const activeRound = rounds.find((r) => r.status === 'ROUND_ACTIVE')
|
const totalAssignments = allRounds.reduce((s, r) => s + r._count.assignments, 0)
|
||||||
|
const activeRound = allRounds.find((r) => r.status === 'ROUND_ACTIVE')
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TooltipProvider delayDuration={200}>
|
<TooltipProvider delayDuration={200}>
|
||||||
<div className="space-y-5">
|
<div className="space-y-5">
|
||||||
|
{/* Competition selector (when multiple exist) */}
|
||||||
|
{competitions && competitions.length > 1 && (
|
||||||
|
<Select value={comp.id} onValueChange={setSelectedCompId}>
|
||||||
|
<SelectTrigger className="w-[280px] mb-4">
|
||||||
|
<SelectValue placeholder="Select competition" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{competitions.map((c: any) => (
|
||||||
|
<SelectItem key={c.id} value={c.id}>
|
||||||
|
{c.name}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* ── Header Bar ──────────────────────────────────────────────── */}
|
{/* ── Header Bar ──────────────────────────────────────────────── */}
|
||||||
<div className="flex items-start justify-between gap-4">
|
<div className="flex items-start justify-between gap-4">
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
@@ -309,7 +328,7 @@ export default function RoundsPage() {
|
|||||||
</Tooltip>
|
</Tooltip>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-4 mt-1 text-sm text-muted-foreground">
|
<div className="flex items-center gap-4 mt-1 text-sm text-muted-foreground">
|
||||||
<span>{rounds.length} rounds</span>
|
<span>{allRounds.filter((r) => !r.specialAwardId).length} rounds</span>
|
||||||
<span className="text-muted-foreground/30">|</span>
|
<span className="text-muted-foreground/30">|</span>
|
||||||
<span>{totalProjects} projects</span>
|
<span>{totalProjects} projects</span>
|
||||||
<span className="text-muted-foreground/30">|</span>
|
<span className="text-muted-foreground/30">|</span>
|
||||||
@@ -476,7 +495,7 @@ export default function RoundsPage() {
|
|||||||
{projectCount}
|
{projectCount}
|
||||||
</span>
|
</span>
|
||||||
{assignmentCount > 0 && (
|
{assignmentCount > 0 && (
|
||||||
<span className="tabular-nums">{assignmentCount} eval</span>
|
<span className="tabular-nums">{assignmentCount} asgn</span>
|
||||||
)}
|
)}
|
||||||
{(round.windowOpenAt || round.windowCloseAt) && (
|
{(round.windowOpenAt || round.windowCloseAt) && (
|
||||||
<span className="flex items-center gap-1 tabular-nums">
|
<span className="flex items-center gap-1 tabular-nums">
|
||||||
|
|||||||
@@ -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,16 +19,25 @@ 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) {
|
||||||
const role = session.user.role
|
// Verify user still exists in DB (handles deleted accounts with stale sessions)
|
||||||
if (role === 'SUPER_ADMIN' || role === 'PROGRAM_ADMIN') {
|
const dbUser = await prisma.user.findUnique({
|
||||||
redirect('/admin')
|
where: { id: session.user.id },
|
||||||
} else if (role === 'JURY_MEMBER') {
|
select: { id: true },
|
||||||
redirect('/jury')
|
})
|
||||||
} else if (role === 'OBSERVER') {
|
|
||||||
redirect('/observer')
|
if (dbUser) {
|
||||||
} else if (role === 'MENTOR') {
|
const role = session.user.role
|
||||||
redirect('/mentor')
|
if (role === 'SUPER_ADMIN' || role === 'PROGRAM_ADMIN') {
|
||||||
|
redirect('/admin')
|
||||||
|
} else if (role === 'JURY_MEMBER') {
|
||||||
|
redirect('/jury')
|
||||||
|
} else if (role === 'OBSERVER') {
|
||||||
|
redirect('/observer')
|
||||||
|
} else if (role === 'MENTOR') {
|
||||||
|
redirect('/mentor')
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
// If user doesn't exist in DB, fall through and show auth page
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -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,60 +529,59 @@ 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}
|
</Label>
|
||||||
</Label>
|
<Slider
|
||||||
<Slider
|
value={[capValue]}
|
||||||
value={[capValue]}
|
onValueChange={([v]) =>
|
||||||
onValueChange={([v]) =>
|
setJuryPrefs((prev) => {
|
||||||
setJuryPrefs((prev) => {
|
const next = new Map(prev)
|
||||||
const next = new Map(prev)
|
next.set(m.juryGroupMemberId, { ...pref, cap: v })
|
||||||
next.set(m.juryGroupMemberId, { ...pref, cap: v })
|
return next
|
||||||
return next
|
})
|
||||||
})
|
}
|
||||||
}
|
min={1}
|
||||||
min={1}
|
max={50}
|
||||||
max={m.currentCap}
|
step={1}
|
||||||
step={1}
|
/>
|
||||||
/>
|
<p className="text-xs text-muted-foreground">
|
||||||
<p className="text-xs text-muted-foreground">
|
Admin suggestion: {m.currentCap}. Adjust to match your availability.
|
||||||
Admin default: {m.currentCap}. You may reduce this 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">
|
Category preference: {Math.round(ratioValue * 100)}% Startups / {Math.round((1 - ratioValue) * 100)}% Business Concepts
|
||||||
Startup vs Business Concept ratio: {Math.round(ratioValue * 100)}% / {Math.round((1 - ratioValue) * 100)}%
|
</Label>
|
||||||
</Label>
|
<Slider
|
||||||
<Slider
|
value={[ratioValue * 100]}
|
||||||
value={[ratioValue * 100]}
|
onValueChange={([v]) =>
|
||||||
onValueChange={([v]) =>
|
setJuryPrefs((prev) => {
|
||||||
setJuryPrefs((prev) => {
|
const next = new Map(prev)
|
||||||
const next = new Map(prev)
|
next.set(m.juryGroupMemberId, { ...pref, ratio: v / 100 })
|
||||||
next.set(m.juryGroupMemberId, { ...pref, ratio: v / 100 })
|
return next
|
||||||
return next
|
})
|
||||||
})
|
}
|
||||||
}
|
min={0}
|
||||||
min={0}
|
max={100}
|
||||||
max={100}
|
step={10}
|
||||||
step={5}
|
/>
|
||||||
/>
|
<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>
|
</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>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -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
|
||||||
return
|
)
|
||||||
}
|
for (const c of requiredCriteria) {
|
||||||
const requiredCriteria = evalConfig?.requireAllCriteriaScored !== false
|
const val = criteriaValues[c.id]
|
||||||
if (requiredCriteria) {
|
if (c.type === 'numeric' && (val === undefined || val === null)) {
|
||||||
const allScored = criteria.every((c) => criteriaScores[c.id] !== undefined)
|
toast.error(`Please score "${c.label}"`)
|
||||||
if (!allScored) {
|
return
|
||||||
toast.error('Please score all criteria')
|
}
|
||||||
|
if (c.type === 'boolean' && val === undefined) {
|
||||||
|
toast.error(`Please answer "${c.label}"`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
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,10 +524,27 @@ 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">
|
||||||
<AlertCircle className="h-5 w-5 text-amber-600 shrink-0 mt-0.5" />
|
<AlertCircle className="h-5 w-5 text-amber-600 shrink-0 mt-0.5" />
|
||||||
@@ -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>
|
||||||
<CardTitle>Evaluation Form</CardTitle>
|
<div className="flex items-start justify-between">
|
||||||
<CardDescription>
|
<div>
|
||||||
Provide your assessment using the {scoringMode} scoring method
|
<CardTitle>Evaluation Form</CardTitle>
|
||||||
</CardDescription>
|
<CardDescription>
|
||||||
|
{scoringMode === 'criteria'
|
||||||
|
? 'Complete all required fields below'
|
||||||
|
: `Provide your assessment using the ${scoringMode} scoring method`}
|
||||||
|
</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>
|
||||||
)}
|
)}
|
||||||
</Label>
|
</div>
|
||||||
{criterion.description && (
|
)
|
||||||
<p className="text-xs text-muted-foreground">{criterion.description}</p>
|
}
|
||||||
)}
|
|
||||||
<Input
|
if (criterion.type === 'boolean') {
|
||||||
id={criterion.id}
|
const currentValue = criteriaValues[criterion.id]
|
||||||
type="number"
|
return (
|
||||||
min={criterion.minScore ?? 0}
|
<div key={criterion.id} className="space-y-3 p-4 border rounded-lg">
|
||||||
max={criterion.maxScore ?? 10}
|
<div className="space-y-1">
|
||||||
value={criteriaScores[criterion.id] ?? ''}
|
<Label className="text-base font-medium">
|
||||||
onChange={(e) =>
|
{criterion.label}
|
||||||
setCriteriaScores({
|
{criterion.required && <span className="text-destructive ml-1">*</span>}
|
||||||
...criteriaScores,
|
</Label>
|
||||||
[criterion.id]: parseInt(e.target.value, 10) || 0,
|
{criterion.description && (
|
||||||
})
|
<p className="text-sm text-muted-foreground">{criterion.description}</p>
|
||||||
}
|
)}
|
||||||
placeholder={`Score (${criterion.minScore ?? 0}-${criterion.maxScore ?? 10})`}
|
</div>
|
||||||
/>
|
<div className="flex gap-3">
|
||||||
</div>
|
<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">
|
||||||
Overall Score <span className="text-destructive">*</span>
|
<Label>
|
||||||
</Label>
|
Overall Score <span className="text-destructive">*</span>
|
||||||
<Input
|
</Label>
|
||||||
id="globalScore"
|
<span className="rounded-md bg-muted px-2.5 py-1 text-sm font-bold tabular-nums">
|
||||||
type="number"
|
{globalScore || '\u2014'}/10
|
||||||
min="1"
|
</span>
|
||||||
max="10"
|
</div>
|
||||||
value={globalScore}
|
<div className="flex items-center gap-2">
|
||||||
onChange={(e) => setGlobalScore(e.target.value)}
|
<span className="text-xs text-muted-foreground">1</span>
|
||||||
placeholder="Enter score (1-10)"
|
<Slider
|
||||||
/>
|
min={1}
|
||||||
<p className="text-xs text-muted-foreground">
|
max={10}
|
||||||
Provide a score from 1 to 10 based on your overall assessment
|
step={1}
|
||||||
</p>
|
value={[globalScore ? parseInt(globalScore, 10) : 5]}
|
||||||
|
onValueChange={(v) => handleGlobalScoreChange(v[0].toString())}
|
||||||
|
className="flex-1"
|
||||||
|
/>
|
||||||
|
<span className="text-xs text-muted-foreground">10</span>
|
||||||
|
</div>
|
||||||
|
<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,35 +78,76 @@ 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>
|
||||||
<Button asChild className="bg-brand-blue hover:bg-brand-blue-light">
|
{isVotingOpen ? (
|
||||||
<Link href={`/jury/competitions/${roundId}/projects/${projectId}/evaluate` as Route}>
|
<Button asChild className="bg-brand-blue hover:bg-brand-blue-light">
|
||||||
<Target className="mr-2 h-4 w-4" />
|
<Link href={`/jury/competitions/${roundId}/projects/${projectId}/evaluate` as Route}>
|
||||||
Evaluate Project
|
<Target className="mr-2 h-4 w-4" />
|
||||||
</Link>
|
Evaluate Project
|
||||||
</Button>
|
</Link>
|
||||||
|
</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 && (
|
|
||||||
<Badge variant="outline">{project.competitionCategory}</Badge>
|
|
||||||
)}
|
|
||||||
{project.tags && project.tags.length > 0 && (
|
|
||||||
project.tags.slice(0, 3).map((tag: string) => (
|
|
||||||
<Badge key={tag} variant="secondary">
|
|
||||||
{tag}
|
|
||||||
</Badge>
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* 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>
|
||||||
|
)}
|
||||||
|
</Badge>
|
||||||
|
))
|
||||||
|
: project.tags.map((tag: string) => (
|
||||||
|
<Badge key={tag} variant="secondary" className="text-xs">
|
||||||
|
{tag}
|
||||||
|
</Badge>
|
||||||
|
))
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Description */}
|
{/* Description */}
|
||||||
{project.description && (
|
{project.description && (
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@@ -31,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
|
||||||
@@ -63,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>
|
||||||
@@ -79,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>
|
||||||
@@ -140,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 className="flex items-center gap-2 shrink-0">
|
||||||
|
{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>
|
||||||
|
) : (
|
||||||
|
<Badge variant="secondary" className="gap-1">
|
||||||
|
<Clock className="h-3 w-3" />
|
||||||
|
Pending
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
<ArrowRight className="h-4 w-4 text-muted-foreground group-hover:text-brand-blue transition-colors" />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<ArrowRight className="h-4 w-4 text-muted-foreground group-hover:text-brand-blue transition-colors shrink-0" />
|
|
||||||
</Link>
|
</Link>
|
||||||
))
|
)
|
||||||
) : (
|
})}
|
||||||
<p className="text-sm text-muted-foreground text-center py-2">
|
|
||||||
No active rounds
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
{activeRounds.length > 2 && (
|
|
||||||
<p className="text-xs text-muted-foreground text-center">
|
|
||||||
+{activeRounds.length - 2} more round{activeRounds.length - 2 !== 1 ? 's' : ''}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</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,18 +244,20 @@ 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>
|
||||||
<Link
|
{juryCompareEnabled && (
|
||||||
href="/jury/competitions"
|
<Link
|
||||||
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"
|
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"
|
||||||
<div className="rounded-lg bg-teal-50 p-2 transition-colors group-hover:bg-teal-100 dark:bg-teal-950/40">
|
>
|
||||||
<GitCompare className="h-4 w-4 text-brand-teal" />
|
<div className="rounded-lg bg-teal-50 p-2 transition-colors group-hover:bg-teal-100 dark:bg-teal-950/40">
|
||||||
</div>
|
<GitCompare className="h-4 w-4 text-brand-teal" />
|
||||||
<div className="text-left">
|
</div>
|
||||||
<p className="font-semibold text-sm group-hover:text-brand-teal transition-colors">Compare Projects</p>
|
<div className="text-left">
|
||||||
<p className="text-xs text-muted-foreground">Side-by-side view</p>
|
<p className="font-semibold text-sm group-hover:text-brand-teal transition-colors">Compare Projects</p>
|
||||||
</div>
|
<p className="text-xs text-muted-foreground">Side-by-side view</p>
|
||||||
</Link>
|
</div>
|
||||||
|
</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>
|
||||||
<div className={cn('rounded-xl p-3', stat.iconBg)}>
|
))}
|
||||||
<stat.icon className={cn('h-5 w-5', stat.iconColor)} />
|
</div>
|
||||||
</div>
|
|
||||||
<div>
|
{/* Desktop: editorial stat row */}
|
||||||
<p className="text-2xl font-bold tabular-nums tracking-tight">{stat.value}</p>
|
<div className="hidden md:block">
|
||||||
<p className="text-sm text-muted-foreground font-medium">{stat.label}</p>
|
<div className="grid grid-cols-4 gap-px rounded-lg bg-border/40 overflow-hidden">
|
||||||
</div>
|
{stats.map((s, i) => (
|
||||||
</CardContent>
|
<div
|
||||||
</Card>
|
key={i}
|
||||||
</AnimatedCard>
|
className="bg-background px-5 py-4 group hover:bg-muted/30 transition-colors"
|
||||||
))}
|
>
|
||||||
{/* Overall completion as 5th stat card */}
|
<span className="text-3xl font-bold tabular-nums tracking-tight">{s.value}</span>
|
||||||
<AnimatedCard index={5}>
|
<p className="text-xs font-medium text-muted-foreground mt-1">{s.label}</p>
|
||||||
<Card className="border-l-4 border-l-brand-teal transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md">
|
<p className={`text-xs mt-0.5 ${s.accent}`}>{s.detail}</p>
|
||||||
<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">
|
</div>
|
||||||
{completionRate.toFixed(0)}%
|
</div>
|
||||||
</p>
|
</AnimatedCard>
|
||||||
<div className="relative h-1.5 w-full overflow-hidden rounded-full bg-muted/60 mt-1">
|
|
||||||
<div
|
|
||||||
className="h-full rounded-full bg-gradient-to-r from-brand-teal to-brand-blue transition-all duration-500 ease-out"
|
|
||||||
style={{ width: `${completionRate}%` }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</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,18 +464,20 @@ 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>
|
||||||
<Link
|
{juryCompareEnabled && (
|
||||||
href="/jury/competitions"
|
<Link
|
||||||
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"
|
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"
|
||||||
<div className="rounded-xl bg-teal-50 p-3 transition-colors group-hover:bg-teal-100 dark:bg-teal-950/40 dark:group-hover:bg-teal-950/60">
|
>
|
||||||
<GitCompare className="h-5 w-5 text-brand-teal" />
|
<div className="rounded-xl bg-teal-50 p-3 transition-colors group-hover:bg-teal-100 dark:bg-teal-950/40 dark:group-hover:bg-teal-950/60">
|
||||||
</div>
|
<GitCompare className="h-5 w-5 text-brand-teal" />
|
||||||
<div className="text-left">
|
</div>
|
||||||
<p className="font-semibold text-sm group-hover:text-brand-teal transition-colors">Compare Projects</p>
|
<div className="text-left">
|
||||||
<p className="text-xs text-muted-foreground mt-0.5">Side-by-side comparison</p>
|
<p className="font-semibold text-sm group-hover:text-brand-teal transition-colors">Compare Projects</p>
|
||||||
</div>
|
<p className="text-xs text-muted-foreground mt-0.5">Side-by-side comparison</p>
|
||||||
</Link>
|
</div>
|
||||||
|
</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>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
{/* Progress bar skeleton */}
|
<div className="hidden md:block">
|
||||||
<Card className="overflow-hidden">
|
<div className="grid grid-cols-4 gap-px rounded-lg bg-border/40 overflow-hidden">
|
||||||
<div className="h-1 w-full bg-muted" />
|
{[...Array(4)].map((_, i) => (
|
||||||
<CardContent className="py-5 px-6">
|
<div key={i} className="bg-background px-5 py-4">
|
||||||
<div className="flex items-center justify-between mb-3">
|
<Skeleton className="h-9 w-12" />
|
||||||
<Skeleton className="h-4 w-36" />
|
<Skeleton className="h-4 w-20 mt-1" />
|
||||||
<Skeleton className="h-7 w-16" />
|
<Skeleton className="h-3 w-16 mt-1" />
|
||||||
</div>
|
</div>
|
||||||
<Skeleton className="h-3 w-full rounded-full" />
|
))}
|
||||||
</CardContent>
|
</div>
|
||||||
</Card>
|
</div>
|
||||||
{/* 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,13 +11,15 @@ export default async function ObserverLayout({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-background">
|
<div className="min-h-screen bg-background">
|
||||||
<ObserverNav
|
<EditionProvider>
|
||||||
user={{
|
<ObserverNav
|
||||||
name: session.user.name,
|
user={{
|
||||||
email: session.user.email,
|
name: session.user.name,
|
||||||
}}
|
email: session.user.email,
|
||||||
/>
|
}}
|
||||||
<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
|
||||||
|
status: string
|
||||||
|
roundType: string
|
||||||
|
windowCloseAt: Date | null
|
||||||
|
_count: { projects: number; assignments: number; evaluations: number }
|
||||||
|
programId: string
|
||||||
|
programName: string
|
||||||
|
}
|
||||||
|
|
||||||
const stages = programs?.flatMap(p =>
|
type TabDef = { value: string; label: string; icon: LucideIcon }
|
||||||
(p.stages as { id: string; name: string; status: string; windowCloseAt: Date | null; _count: { projects: number; assignments: number } }[]).map(s => ({
|
|
||||||
...s,
|
|
||||||
programName: `${p.year} Edition`,
|
|
||||||
}))
|
|
||||||
) || []
|
|
||||||
|
|
||||||
const queryInput = parseSelection(selectedValue)
|
function getRoundTabs(roundType: string): TabDef[] {
|
||||||
const hasSelection = !!queryInput.roundId || !!queryInput.programId
|
switch (roundType) {
|
||||||
|
case 'INTAKE':
|
||||||
const { data: overviewStats, isLoading: statsLoading } =
|
return [{ value: 'overview', label: 'Overview', icon: LayoutDashboard }]
|
||||||
trpc.analytics.getOverviewStats.useQuery(
|
case 'FILTERING':
|
||||||
queryInput,
|
return [
|
||||||
{ enabled: hasSelection }
|
{ value: 'screening', label: 'Screening', icon: Filter },
|
||||||
)
|
]
|
||||||
|
case 'EVALUATION':
|
||||||
if (isLoading) {
|
return [
|
||||||
return (
|
{ value: 'evaluation', label: 'Evaluation', icon: TrendingUp },
|
||||||
<div className="space-y-6">
|
]
|
||||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
case 'SUBMISSION':
|
||||||
{[...Array(4)].map((_, i) => (
|
return [{ value: 'overview', label: 'Overview', icon: Upload }]
|
||||||
<Card key={i}>
|
case 'MENTORING':
|
||||||
<CardHeader className="space-y-0 pb-2">
|
return [{ value: 'overview', label: 'Overview', icon: Users }]
|
||||||
<Skeleton className="h-4 w-20" />
|
case 'LIVE_FINAL':
|
||||||
</CardHeader>
|
return [{ value: 'session', label: 'Session', icon: Presentation }]
|
||||||
<CardContent>
|
case 'DELIBERATION':
|
||||||
<Skeleton className="h-8 w-16" />
|
return [
|
||||||
<Skeleton className="mt-2 h-3 w-24" />
|
{ value: 'deliberation', label: 'Deliberation', icon: Vote },
|
||||||
</CardContent>
|
]
|
||||||
</Card>
|
default:
|
||||||
))}
|
return []
|
||||||
</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 }) {
|
function RoundTypeContent({
|
||||||
const queryInput = parseSelection(selectedValue)
|
roundType,
|
||||||
const hasSelection = !!queryInput.roundId || !!queryInput.programId
|
roundId,
|
||||||
|
programId,
|
||||||
const { data: scoreDistribution, isLoading: scoreLoading } =
|
stages,
|
||||||
trpc.analytics.getScoreDistribution.useQuery(
|
selectedValue,
|
||||||
queryInput,
|
}: {
|
||||||
{ enabled: hasSelection }
|
roundType: string
|
||||||
)
|
roundId: string
|
||||||
|
programId: string
|
||||||
const { data: timeline, isLoading: timelineLoading } =
|
stages: Stage[]
|
||||||
trpc.analytics.getEvaluationTimeline.useQuery(
|
selectedValue: string | null
|
||||||
queryInput,
|
}) {
|
||||||
{ enabled: hasSelection }
|
switch (roundType) {
|
||||||
)
|
case 'INTAKE':
|
||||||
|
return <IntakeReportTabs roundId={roundId} programId={programId} />
|
||||||
const { data: statusBreakdown, isLoading: statusLoading } =
|
case 'FILTERING':
|
||||||
trpc.analytics.getStatusBreakdown.useQuery(
|
return <FilteringReportTabs roundId={roundId} programId={programId} />
|
||||||
queryInput,
|
case 'EVALUATION':
|
||||||
{ enabled: hasSelection }
|
return (
|
||||||
)
|
<EvaluationReportTabs
|
||||||
|
roundId={roundId}
|
||||||
const { data: jurorWorkload, isLoading: workloadLoading } =
|
programId={programId}
|
||||||
trpc.analytics.getJurorWorkload.useQuery(
|
stages={stages}
|
||||||
queryInput,
|
selectedValue={selectedValue}
|
||||||
{ enabled: hasSelection }
|
/>
|
||||||
)
|
)
|
||||||
|
case 'SUBMISSION':
|
||||||
const { data: projectRankings, isLoading: rankingsLoading } =
|
return <SubmissionReportTabs roundId={roundId} programId={programId} />
|
||||||
trpc.analytics.getProjectRankings.useQuery(
|
case 'MENTORING':
|
||||||
{ ...queryInput, limit: 15 },
|
return <MentoringReportTabs roundId={roundId} programId={programId} />
|
||||||
{ enabled: hasSelection }
|
case 'LIVE_FINAL':
|
||||||
)
|
return <LiveFinalReportTabs roundId={roundId} programId={programId} />
|
||||||
|
case 'DELIBERATION':
|
||||||
const { data: criteriaScores, isLoading: criteriaLoading } =
|
return <DeliberationReportTabs roundId={roundId} programId={programId} />
|
||||||
trpc.analytics.getCriteriaScores.useQuery(
|
default:
|
||||||
queryInput,
|
return null
|
||||||
{ 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 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: consistency, isLoading } =
|
const [activeTab, setActiveTab] = useState<string | null>(null)
|
||||||
trpc.analytics.getJurorConsistency.useQuery(
|
|
||||||
queryInput,
|
|
||||||
{ enabled: hasSelection }
|
|
||||||
)
|
|
||||||
|
|
||||||
if (isLoading) return <Skeleton className="h-[400px]" />
|
|
||||||
|
|
||||||
if (!consistency) return null
|
|
||||||
|
|
||||||
return (
|
|
||||||
<JurorConsistencyChart
|
|
||||||
data={consistency as {
|
|
||||||
overallAverage: number
|
|
||||||
jurors: Array<{
|
|
||||||
userId: string; name: string; email: string
|
|
||||||
evaluationCount: number; averageScore: number
|
|
||||||
stddev: number; deviationFromOverall: number; isOutlier: boolean
|
|
||||||
}>
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function DiversityTab({ selectedValue }: { selectedValue: string }) {
|
|
||||||
const queryInput = parseSelection(selectedValue)
|
|
||||||
const hasSelection = !!queryInput.roundId || !!queryInput.programId
|
|
||||||
|
|
||||||
const { data: diversity, isLoading } =
|
|
||||||
trpc.analytics.getDiversityMetrics.useQuery(
|
|
||||||
queryInput,
|
|
||||||
{ enabled: hasSelection }
|
|
||||||
)
|
|
||||||
|
|
||||||
if (isLoading) return <Skeleton className="h-[400px]" />
|
|
||||||
|
|
||||||
if (!diversity) return null
|
|
||||||
|
|
||||||
return (
|
|
||||||
<DiversityMetricsChart
|
|
||||||
data={diversity as {
|
|
||||||
total: number
|
|
||||||
byCountry: { country: string; count: number; percentage: number }[]
|
|
||||||
byCategory: { category: string; count: number; percentage: number }[]
|
|
||||||
byOceanIssue: { issue: string; count: number; percentage: number }[]
|
|
||||||
byTag: { tag: string; count: number; percentage: number }[]
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function ObserverReportsPage() {
|
|
||||||
const [selectedValue, setSelectedValue] = useState<string | null>(null)
|
|
||||||
|
|
||||||
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)
|
||||||
if (stages.length && !selectedValue) {
|
|
||||||
setSelectedValue(stages[0].id)
|
|
||||||
}
|
|
||||||
|
|
||||||
const hasSelection = !!selectedValue
|
useEffect(() => {
|
||||||
|
if (stages.length && !selectedValue) {
|
||||||
|
const active = stages.find((s) => s.status === 'ROUND_ACTIVE')
|
||||||
|
setSelectedValue(active ? active.id : stages[0].id)
|
||||||
|
}
|
||||||
|
}, [stages.length, 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}>
|
</TabsTrigger>
|
||||||
<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>
|
|
||||||
</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}
|
||||||
/>
|
/>
|
||||||
)}
|
</TabsContent>
|
||||||
</div>
|
|
||||||
|
|
||||||
<TabsContent value="overview">
|
{/* Round-type-specific or "All Rounds" progress tab */}
|
||||||
<OverviewTab selectedValue={selectedValue} />
|
{roundSpecificTabs.map((tab) => (
|
||||||
</TabsContent>
|
<TabsContent key={tab.value} value={tab.value}>
|
||||||
|
{isAllRounds ? (
|
||||||
<TabsContent value="analytics">
|
<EvaluationReportTabs
|
||||||
{hasSelection ? (
|
roundId=""
|
||||||
<AnalyticsTab selectedValue={selectedValue!} />
|
programId={programId}
|
||||||
) : (
|
stages={stages}
|
||||||
<Card>
|
selectedValue={selectedValue}
|
||||||
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
|
/>
|
||||||
<BarChart3 className="h-12 w-12 text-muted-foreground/50" />
|
) : selectedRound ? (
|
||||||
<p className="mt-2 font-medium">Select a round</p>
|
<RoundTypeContent
|
||||||
<p className="text-sm text-muted-foreground">
|
roundType={roundType}
|
||||||
Choose a round or edition from the dropdown above to view analytics
|
roundId={selectedRound.id}
|
||||||
</p>
|
programId={programId}
|
||||||
</CardContent>
|
stages={stages}
|
||||||
</Card>
|
selectedValue={selectedValue}
|
||||||
)}
|
/>
|
||||||
</TabsContent>
|
) : null}
|
||||||
|
</TabsContent>
|
||||||
<TabsContent value="cross-stage">
|
))}
|
||||||
<CrossStageTab />
|
</Tabs>
|
||||||
</TabsContent>
|
)}
|
||||||
|
|
||||||
<TabsContent value="consistency">
|
|
||||||
{hasSelection ? (
|
|
||||||
<JurorConsistencyTab selectedValue={selectedValue!} />
|
|
||||||
) : (
|
|
||||||
<Card>
|
|
||||||
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
|
|
||||||
<UserCheck className="h-12 w-12 text-muted-foreground/50" />
|
|
||||||
<p className="mt-2 font-medium">Select a round</p>
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
Choose a round or edition above to view juror consistency metrics
|
|
||||||
</p>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
)}
|
|
||||||
</TabsContent>
|
|
||||||
|
|
||||||
<TabsContent value="diversity">
|
|
||||||
{hasSelection ? (
|
|
||||||
<DiversityTab selectedValue={selectedValue!} />
|
|
||||||
) : (
|
|
||||||
<Card>
|
|
||||||
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
|
|
||||||
<Globe className="h-12 w-12 text-muted-foreground/50" />
|
|
||||||
<p className="mt-2 font-medium">Select a round</p>
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
Choose a round or edition above to view diversity metrics
|
|
||||||
</p>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
)}
|
|
||||||
</TabsContent>
|
|
||||||
</Tabs>
|
|
||||||
</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,11 +7,12 @@ 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, requiredReviews: 3 },
|
{ roundId, requiredReviews },
|
||||||
{ refetchInterval: 15_000 },
|
{ refetchInterval: 15_000 },
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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,
|
||||||
@@ -38,12 +40,18 @@ export function AddMemberDialog({ juryGroupId, open, onOpenChange }: AddMemberDi
|
|||||||
const [selectedUserId, setSelectedUserId] = useState<string>('')
|
const [selectedUserId, setSelectedUserId] = useState<string>('')
|
||||||
const [maxAssignments, setMaxAssignments] = useState<string>('')
|
const [maxAssignments, setMaxAssignments] = useState<string>('')
|
||||||
const [capMode, setCapMode] = 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
|
// Invite new user state
|
||||||
const [inviteName, setInviteName] = useState('')
|
const [inviteName, setInviteName] = useState('')
|
||||||
const [inviteEmail, setInviteEmail] = useState('')
|
const [inviteEmail, setInviteEmail] = useState('')
|
||||||
const [inviteMaxAssignments, setInviteMaxAssignments] = useState<string>('')
|
const [inviteMaxAssignments, setInviteMaxAssignments] = useState<string>('')
|
||||||
const [inviteCapMode, setInviteCapMode] = 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 [inviteExpertise, setInviteExpertise] = useState('')
|
||||||
|
|
||||||
const utils = trpc.useUtils()
|
const utils = trpc.useUtils()
|
||||||
@@ -73,9 +81,11 @@ export function AddMemberDialog({ juryGroupId, open, onOpenChange }: AddMemberDi
|
|||||||
addMember({
|
addMember({
|
||||||
juryGroupId,
|
juryGroupId,
|
||||||
userId: newUser.id,
|
userId: newUser.id,
|
||||||
role: 'MEMBER',
|
role: inviteRole as 'CHAIR' | 'MEMBER' | 'OBSERVER',
|
||||||
maxAssignmentsOverride: inviteMaxAssignments ? parseInt(inviteMaxAssignments, 10) : null,
|
maxAssignmentsOverride: inviteMaxAssignments ? parseInt(inviteMaxAssignments, 10) : null,
|
||||||
capModeOverride: inviteCapMode && inviteCapMode !== 'DEFAULT' ? (inviteCapMode as 'HARD' | 'SOFT' | 'NONE') : null,
|
capModeOverride: inviteCapMode && inviteCapMode !== 'DEFAULT' ? (inviteCapMode as 'HARD' | 'SOFT' | 'NONE') : null,
|
||||||
|
preferredStartupRatio: inviteStartupRatio,
|
||||||
|
availabilityNotes: inviteAvailabilityNotes.trim() || null,
|
||||||
})
|
})
|
||||||
// Send invitation email
|
// Send invitation email
|
||||||
sendInvitation({ userId: newUser.id, juryGroupId })
|
sendInvitation({ userId: newUser.id, juryGroupId })
|
||||||
@@ -101,10 +111,16 @@ export function AddMemberDialog({ juryGroupId, open, onOpenChange }: AddMemberDi
|
|||||||
setSelectedUserId('')
|
setSelectedUserId('')
|
||||||
setMaxAssignments('')
|
setMaxAssignments('')
|
||||||
setCapMode('')
|
setCapMode('')
|
||||||
|
setRole('MEMBER')
|
||||||
|
setStartupRatio(null)
|
||||||
|
setAvailabilityNotes('')
|
||||||
setInviteName('')
|
setInviteName('')
|
||||||
setInviteEmail('')
|
setInviteEmail('')
|
||||||
setInviteMaxAssignments('')
|
setInviteMaxAssignments('')
|
||||||
setInviteCapMode('')
|
setInviteCapMode('')
|
||||||
|
setInviteRole('MEMBER')
|
||||||
|
setInviteStartupRatio(null)
|
||||||
|
setInviteAvailabilityNotes('')
|
||||||
setInviteExpertise('')
|
setInviteExpertise('')
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -119,9 +135,11 @@ export function AddMemberDialog({ juryGroupId, open, onOpenChange }: AddMemberDi
|
|||||||
addMember({
|
addMember({
|
||||||
juryGroupId,
|
juryGroupId,
|
||||||
userId: selectedUserId,
|
userId: selectedUserId,
|
||||||
role: 'MEMBER',
|
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,
|
capModeOverride: capMode && capMode !== 'DEFAULT' ? (capMode as 'HARD' | 'SOFT' | 'NONE') : null,
|
||||||
|
preferredStartupRatio: startupRatio,
|
||||||
|
availabilityNotes: availabilityNotes.trim() || null,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -212,6 +230,19 @@ export function AddMemberDialog({ juryGroupId, open, onOpenChange }: AddMemberDi
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-3">
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="role">Role</Label>
|
||||||
|
<Select value={role} onValueChange={setRole}>
|
||||||
|
<SelectTrigger id="role">
|
||||||
|
<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">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="capMode">Cap Mode</Label>
|
<Label htmlFor="capMode">Cap Mode</Label>
|
||||||
<Select value={capMode || 'DEFAULT'} onValueChange={setCapMode}>
|
<Select value={capMode || 'DEFAULT'} onValueChange={setCapMode}>
|
||||||
@@ -240,6 +271,38 @@ 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
|
||||||
@@ -281,6 +344,19 @@ export function AddMemberDialog({ juryGroupId, open, onOpenChange }: AddMemberDi
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-3">
|
<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">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="inviteCapMode">Cap Mode</Label>
|
<Label htmlFor="inviteCapMode">Cap Mode</Label>
|
||||||
<Select value={inviteCapMode || 'DEFAULT'} onValueChange={setInviteCapMode}>
|
<Select value={inviteCapMode || 'DEFAULT'} onValueChange={setInviteCapMode}>
|
||||||
@@ -309,6 +385,38 @@ 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={[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">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="inviteExpertise">Expertise Tags (optional)</Label>
|
<Label htmlFor="inviteExpertise">Expertise Tags (optional)</Label>
|
||||||
<Input
|
<Input
|
||||||
|
|||||||
@@ -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,
|
||||||
@@ -45,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)
|
||||||
@@ -80,6 +154,7 @@ 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 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 className="hidden lg:table-cell">Cap Mode</TableHead>
|
||||||
@@ -89,7 +164,7 @@ export function JuryMembersTable({ juryGroupId, members }: JuryMembersTableProps
|
|||||||
<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>
|
||||||
@@ -99,11 +174,20 @@ 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 sm:table-cell">
|
<TableCell className="hidden sm:table-cell">
|
||||||
{member.maxAssignmentsOverride ?? '—'}
|
<InlineCapEdit
|
||||||
|
memberId={member.id}
|
||||||
|
currentValue={member.maxAssignmentsOverride}
|
||||||
|
juryGroupId={juryGroupId}
|
||||||
|
/>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="hidden lg:table-cell">
|
<TableCell className="hidden lg:table-cell">
|
||||||
{member.capModeOverride ? (
|
{member.capModeOverride ? (
|
||||||
|
|||||||
@@ -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">
|
||||||
</div>
|
{(cursor.activeProject.tags as string[]).map((tag: string) => (
|
||||||
|
<Badge key={tag} variant="secondary" className="text-xs">
|
||||||
|
{tag}
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
</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>
|
<Play className="mr-2 h-4 w-4" />
|
||||||
</CardContent>
|
{resumeMutation.isPending ? 'Resuming...' : 'Resume Session'}
|
||||||
</Card>
|
</Button>
|
||||||
|
) : (
|
||||||
{/* Scores Display */}
|
<Button
|
||||||
<Card>
|
className="w-full"
|
||||||
<CardHeader>
|
variant="outline"
|
||||||
<CardTitle>Live Scores</CardTitle>
|
onClick={() => pauseMutation.mutate({ roundId })}
|
||||||
</CardHeader>
|
disabled={pauseMutation.isPending || !cursor}
|
||||||
<CardContent>
|
>
|
||||||
{scores && scores.length > 0 ? (
|
<Pause className="mr-2 h-4 w-4" />
|
||||||
<div className="space-y-2">
|
{pauseMutation.isPending ? 'Pausing...' : 'Pause Session'}
|
||||||
{scores.map((score: any, index: number) => (
|
</Button>
|
||||||
<div
|
)}
|
||||||
key={score.projectId}
|
{cursor?.isPaused && (
|
||||||
className="flex items-center justify-between rounded-lg border p-3"
|
<Badge variant="destructive" className="w-full justify-center py-1">
|
||||||
>
|
Session Paused
|
||||||
<div>
|
</Badge>
|
||||||
<p className="font-medium">
|
)}
|
||||||
#{index + 1} {score.projectTitle}
|
{cursor?.openCohorts && cursor.openCohorts.length > 0 && (
|
||||||
</p>
|
<div className="rounded-lg border p-3">
|
||||||
<p className="text-sm text-muted-foreground">{score.votes} votes</p>
|
<p className="text-sm font-medium mb-2">Open Voting Windows</p>
|
||||||
</div>
|
{cursor.openCohorts.map((cohort: any) => (
|
||||||
<Badge variant="outline">{score.totalScore.toFixed(1)}</Badge>
|
<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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -35,7 +35,6 @@ import {
|
|||||||
Pencil,
|
Pencil,
|
||||||
Trash2,
|
Trash2,
|
||||||
FileText,
|
FileText,
|
||||||
GripVertical,
|
|
||||||
FileCheck,
|
FileCheck,
|
||||||
FileQuestion,
|
FileQuestion,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -9,6 +9,9 @@ import { Badge } from '@/components/ui/badge'
|
|||||||
import { Checkbox } from '@/components/ui/checkbox'
|
import { Checkbox } from '@/components/ui/checkbox'
|
||||||
import { Skeleton } from '@/components/ui/skeleton'
|
import { Skeleton } from '@/components/ui/skeleton'
|
||||||
import { Input } from '@/components/ui/input'
|
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 {
|
import {
|
||||||
Select,
|
Select,
|
||||||
SelectContent,
|
SelectContent,
|
||||||
@@ -85,6 +88,7 @@ export function ProjectStatesTable({ competitionId, roundId }: ProjectStatesTabl
|
|||||||
const [removeConfirmId, setRemoveConfirmId] = useState<string | null>(null)
|
const [removeConfirmId, setRemoveConfirmId] = useState<string | null>(null)
|
||||||
const [batchRemoveOpen, setBatchRemoveOpen] = useState(false)
|
const [batchRemoveOpen, setBatchRemoveOpen] = useState(false)
|
||||||
const [quickAddOpen, setQuickAddOpen] = useState(false)
|
const [quickAddOpen, setQuickAddOpen] = useState(false)
|
||||||
|
const [addProjectOpen, setAddProjectOpen] = useState(false)
|
||||||
|
|
||||||
const utils = trpc.useUtils()
|
const utils = trpc.useUtils()
|
||||||
|
|
||||||
@@ -274,16 +278,10 @@ export function ProjectStatesTable({ competitionId, roundId }: ProjectStatesTabl
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Button size="sm" variant="outline" onClick={() => { setQuickAddOpen(true) }}>
|
<Button size="sm" variant="outline" onClick={() => { setAddProjectOpen(true) }}>
|
||||||
<Plus className="h-4 w-4 mr-1.5" />
|
<Plus className="h-4 w-4 mr-1.5" />
|
||||||
Quick Add
|
Add Project
|
||||||
</Button>
|
</Button>
|
||||||
<Link href={poolLink}>
|
|
||||||
<Button size="sm" variant="outline">
|
|
||||||
<Plus className="h-4 w-4 mr-1.5" />
|
|
||||||
Add from Pool
|
|
||||||
</Button>
|
|
||||||
</Link>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -330,7 +328,7 @@ export function ProjectStatesTable({ competitionId, roundId }: ProjectStatesTabl
|
|||||||
{/* Table */}
|
{/* Table */}
|
||||||
<div className="border rounded-lg overflow-hidden">
|
<div className="border rounded-lg overflow-hidden">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="grid grid-cols-[40px_1fr_140px_120px_100px_48px] gap-2 px-4 py-2.5 bg-muted/40 text-xs font-medium text-muted-foreground border-b">
|
<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>
|
<div>
|
||||||
<Checkbox
|
<Checkbox
|
||||||
checked={filtered.length > 0 && filtered.every((ps: any) => selectedIds.has(ps.projectId))}
|
checked={filtered.length > 0 && filtered.every((ps: any) => selectedIds.has(ps.projectId))}
|
||||||
@@ -339,6 +337,7 @@ export function ProjectStatesTable({ competitionId, roundId }: ProjectStatesTabl
|
|||||||
</div>
|
</div>
|
||||||
<div>Project</div>
|
<div>Project</div>
|
||||||
<div>Category</div>
|
<div>Category</div>
|
||||||
|
<div>Country</div>
|
||||||
<div>State</div>
|
<div>State</div>
|
||||||
<div>Entered</div>
|
<div>Entered</div>
|
||||||
<div />
|
<div />
|
||||||
@@ -351,7 +350,7 @@ export function ProjectStatesTable({ competitionId, roundId }: ProjectStatesTabl
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={ps.id}
|
key={ps.id}
|
||||||
className="grid grid-cols-[40px_1fr_140px_120px_100px_48px] gap-2 px-4 py-3 items-center border-b last:border-b-0 hover:bg-muted/30 text-sm"
|
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>
|
<div>
|
||||||
<Checkbox
|
<Checkbox
|
||||||
@@ -373,6 +372,9 @@ export function ProjectStatesTable({ competitionId, roundId }: ProjectStatesTabl
|
|||||||
{ps.project?.competitionCategory || '—'}
|
{ps.project?.competitionCategory || '—'}
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="text-xs text-muted-foreground truncate">
|
||||||
|
{ps.project?.country || '—'}
|
||||||
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<Badge variant="outline" className={`text-xs ${cfg.color}`}>
|
<Badge variant="outline" className={`text-xs ${cfg.color}`}>
|
||||||
<StateIcon className="h-3 w-3 mr-1" />
|
<StateIcon className="h-3 w-3 mr-1" />
|
||||||
@@ -432,7 +434,7 @@ export function ProjectStatesTable({ competitionId, roundId }: ProjectStatesTabl
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Quick Add Dialog */}
|
{/* Quick Add Dialog (legacy, kept for empty state) */}
|
||||||
<QuickAddDialog
|
<QuickAddDialog
|
||||||
open={quickAddOpen}
|
open={quickAddOpen}
|
||||||
onOpenChange={setQuickAddOpen}
|
onOpenChange={setQuickAddOpen}
|
||||||
@@ -443,6 +445,17 @@ export function ProjectStatesTable({ competitionId, roundId }: ProjectStatesTabl
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* Add Project Dialog (Create New + From Pool) */}
|
||||||
|
<AddProjectDialog
|
||||||
|
open={addProjectOpen}
|
||||||
|
onOpenChange={setAddProjectOpen}
|
||||||
|
roundId={roundId}
|
||||||
|
competitionId={competitionId}
|
||||||
|
onAssigned={() => {
|
||||||
|
utils.roundEngine.getProjectStates.invalidate({ roundId })
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* Single Remove Confirmation */}
|
{/* Single Remove Confirmation */}
|
||||||
<AlertDialog open={!!removeConfirmId} onOpenChange={(open) => { if (!open) setRemoveConfirmId(null) }}>
|
<AlertDialog open={!!removeConfirmId} onOpenChange={(open) => { if (!open) setRemoveConfirmId(null) }}>
|
||||||
<AlertDialogContent>
|
<AlertDialogContent>
|
||||||
@@ -669,3 +682,287 @@ function QuickAddDialog({
|
|||||||
</Dialog>
|
</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>
|
||||||
|
|||||||
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]">
|
<BarChart
|
||||||
<ResponsiveContainer width="100%" height="100%">
|
data={chartData}
|
||||||
<BarChart
|
index="criterion"
|
||||||
data={formattedData}
|
categories={['Avg Score']}
|
||||||
margin={{ top: 20, right: 30, bottom: 60, left: 20 }}
|
colors={['indigo']}
|
||||||
>
|
maxValue={10}
|
||||||
<CartesianGrid strokeDasharray="3 3" className="stroke-muted" />
|
layout="vertical"
|
||||||
<XAxis
|
yAxisWidth={160}
|
||||||
dataKey="displayName"
|
showLegend={false}
|
||||||
tick={{ fontSize: 11 }}
|
className="h-[300px]"
|
||||||
angle={-45}
|
/>
|
||||||
textAnchor="end"
|
|
||||||
interval={0}
|
|
||||||
height={60}
|
|
||||||
/>
|
|
||||||
<YAxis domain={[0, 10]} />
|
|
||||||
<Tooltip
|
|
||||||
contentStyle={{
|
|
||||||
backgroundColor: 'hsl(var(--card))',
|
|
||||||
border: '1px solid hsl(var(--border))',
|
|
||||||
borderRadius: '6px',
|
|
||||||
}}
|
|
||||||
formatter={(value: number | undefined) => [
|
|
||||||
(value ?? 0).toFixed(2),
|
|
||||||
'Average Score',
|
|
||||||
]}
|
|
||||||
labelFormatter={(_, payload) => {
|
|
||||||
if (payload && payload[0]) {
|
|
||||||
const item = payload[0].payload as CriteriaScoreData
|
|
||||||
return `${item.name} (${item.count} ratings)`
|
|
||||||
}
|
|
||||||
return ''
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Bar dataKey="averageScore" radius={[4, 4, 0, 0]}>
|
|
||||||
{formattedData.map((entry, index) => (
|
|
||||||
<Cell
|
|
||||||
key={`cell-${index}`}
|
|
||||||
fill={getScoreColor(entry.averageScore)}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</Bar>
|
|
||||||
</BarChart>
|
|
||||||
</ResponsiveContainer>
|
|
||||||
</div>
|
|
||||||
</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">
|
<Card>
|
||||||
{/* Metrics Comparison */}
|
<CardHeader>
|
||||||
<Card>
|
<CardTitle>Round Metrics Comparison</CardTitle>
|
||||||
<CardHeader>
|
</CardHeader>
|
||||||
<CardTitle>Stage Metrics Comparison</CardTitle>
|
<CardContent>
|
||||||
</CardHeader>
|
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||||
<CardContent>
|
<Card>
|
||||||
<div className="h-[350px]">
|
<CardHeader className="pb-2">
|
||||||
<ResponsiveContainer width="100%" height="100%">
|
<CardTitle className="text-sm font-medium">Projects</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="pt-0">
|
||||||
<BarChart
|
<BarChart
|
||||||
data={comparisonData}
|
data={baseData}
|
||||||
margin={{ top: 20, right: 30, 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}
|
</CardContent>
|
||||||
tick={{ fontSize: 12 }}
|
</Card>
|
||||||
/>
|
|
||||||
<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 */}
|
<Card>
|
||||||
<div className="grid gap-6 lg:grid-cols-2">
|
<CardHeader className="pb-2">
|
||||||
<Card>
|
<CardTitle className="text-sm font-medium">
|
||||||
<CardHeader>
|
Evaluations
|
||||||
<CardTitle>Completion Rate by Stage</CardTitle>
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent className="pt-0">
|
||||||
<div className="h-[300px]">
|
<BarChart
|
||||||
<ResponsiveContainer width="100%" height="100%">
|
data={baseData}
|
||||||
<BarChart
|
index="name"
|
||||||
data={comparisonData}
|
categories={['Evaluations']}
|
||||||
margin={{ top: 20, right: 20, bottom: 60, left: 20 }}
|
colors={['violet']}
|
||||||
>
|
showLegend={false}
|
||||||
<CartesianGrid strokeDasharray="3 3" className="stroke-muted" />
|
yAxisWidth={40}
|
||||||
<XAxis
|
className="h-[200px]"
|
||||||
dataKey="name"
|
/>
|
||||||
angle={-25}
|
</CardContent>
|
||||||
textAnchor="end"
|
</Card>
|
||||||
height={60}
|
|
||||||
tick={{ fontSize: 12 }}
|
|
||||||
/>
|
|
||||||
<YAxis domain={[0, 100]} unit="%" />
|
|
||||||
<Tooltip
|
|
||||||
contentStyle={{
|
|
||||||
backgroundColor: 'hsl(var(--card))',
|
|
||||||
border: '1px solid hsl(var(--border))',
|
|
||||||
borderRadius: '6px',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Bar dataKey="completionRate" name="Completion %" fill="#6ad82f" radius={[4, 4, 0, 0]} />
|
|
||||||
</BarChart>
|
|
||||||
</ResponsiveContainer>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader className="pb-2">
|
||||||
<CardTitle>Average Score by Stage</CardTitle>
|
<CardTitle className="text-sm font-medium">
|
||||||
</CardHeader>
|
Completion Rate
|
||||||
<CardContent>
|
</CardTitle>
|
||||||
<div className="h-[300px]">
|
</CardHeader>
|
||||||
<ResponsiveContainer width="100%" height="100%">
|
<CardContent className="pt-0">
|
||||||
<BarChart
|
<BarChart
|
||||||
data={comparisonData}
|
data={baseData}
|
||||||
margin={{ top: 20, right: 20, bottom: 60, left: 20 }}
|
index="name"
|
||||||
>
|
categories={['Completion Rate']}
|
||||||
<CartesianGrid strokeDasharray="3 3" className="stroke-muted" />
|
colors={['emerald']}
|
||||||
<XAxis
|
showLegend={false}
|
||||||
dataKey="name"
|
maxValue={100}
|
||||||
angle={-25}
|
yAxisWidth={40}
|
||||||
textAnchor="end"
|
valueFormatter={(v) => `${v}%`}
|
||||||
height={60}
|
className="h-[200px]"
|
||||||
tick={{ fontSize: 12 }}
|
/>
|
||||||
/>
|
</CardContent>
|
||||||
<YAxis domain={[0, 10]} />
|
</Card>
|
||||||
<Tooltip
|
|
||||||
contentStyle={{
|
<Card>
|
||||||
backgroundColor: 'hsl(var(--card))',
|
<CardHeader className="pb-2">
|
||||||
border: '1px solid hsl(var(--border))',
|
<CardTitle className="text-sm font-medium">
|
||||||
borderRadius: '6px',
|
Average Score
|
||||||
}}
|
</CardTitle>
|
||||||
/>
|
</CardHeader>
|
||||||
<Bar dataKey="avgScore" name="Avg Score" fill="#de0f1e" radius={[4, 4, 0, 0]} />
|
<CardContent className="pt-0">
|
||||||
</BarChart>
|
<BarChart
|
||||||
</ResponsiveContainer>
|
data={baseData}
|
||||||
</div>
|
index="name"
|
||||||
</CardContent>
|
categories={['Avg Score']}
|
||||||
</Card>
|
colors={['amber']}
|
||||||
</div>
|
showLegend={false}
|
||||||
</div>
|
maxValue={10}
|
||||||
|
yAxisWidth={40}
|
||||||
|
className="h-[200px]"
|
||||||
|
/>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</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) => {
|
<p className="text-muted-foreground text-center py-8">No geographic data</p>
|
||||||
const p = props as { country: string; percentage: number }
|
)}
|
||||||
return `${getCountryName(p.country)} (${p.percentage.toFixed(0)}%)`
|
|
||||||
}) as unknown as boolean}
|
|
||||||
fontSize={13}
|
|
||||||
>
|
|
||||||
{countryPieData.map((_, index) => (
|
|
||||||
<Cell key={`cell-${index}`} fill={PIE_COLORS[index % PIE_COLORS.length]} />
|
|
||||||
))}
|
|
||||||
</Pie>
|
|
||||||
<Tooltip content={<CountryTooltip />} />
|
|
||||||
<Legend
|
|
||||||
formatter={(value: string) => getCountryName(value)}
|
|
||||||
wrapperStyle={{ fontSize: '13px' }}
|
|
||||||
/>
|
|
||||||
</PieChart>
|
|
||||||
</ResponsiveContainer>
|
|
||||||
</div>
|
|
||||||
</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"
|
</div>
|
||||||
width={110}
|
<div className="h-3 w-full rounded-full bg-muted overflow-hidden">
|
||||||
tick={{ fontSize: 13 }}
|
<div
|
||||||
/>
|
className="h-full rounded-full bg-[#053d57] transition-all duration-500"
|
||||||
<Tooltip content={<BarTooltip labelFormatter={(v) => v} />} />
|
style={{ width: `${pct}%` }}
|
||||||
<Bar dataKey="count" fill="#053d57" radius={[0, 4, 4, 0]} />
|
/>
|
||||||
</BarChart>
|
</div>
|
||||||
</ResponsiveContainer>
|
</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]">
|
<BarChart
|
||||||
<ResponsiveContainer width="100%" height="100%">
|
data={oceanIssueData}
|
||||||
<BarChart
|
index="issue"
|
||||||
data={formattedOceanIssues}
|
categories={['Projects']}
|
||||||
margin={{ top: 20, right: 30, bottom: 80, left: 20 }}
|
colors={['blue']}
|
||||||
>
|
showLegend={false}
|
||||||
<CartesianGrid strokeDasharray="3 3" className="stroke-muted" />
|
layout="horizontal"
|
||||||
<XAxis
|
yAxisWidth={200}
|
||||||
dataKey="issue"
|
className="h-[400px]"
|
||||||
angle={-35}
|
/>
|
||||||
textAnchor="end"
|
|
||||||
height={100}
|
|
||||||
tick={{ fontSize: 12 }}
|
|
||||||
interval={0}
|
|
||||||
/>
|
|
||||||
<YAxis tick={{ fontSize: 13 }} />
|
|
||||||
<Tooltip content={<BarTooltip labelFormatter={(v) => v} />} />
|
|
||||||
<Bar dataKey="count" fill="#557f8c" radius={[4, 4, 0, 0]} />
|
|
||||||
</BarChart>
|
|
||||||
</ResponsiveContainer>
|
|
||||||
</div>
|
|
||||||
</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>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -10,3 +10,4 @@ export { GeographicSummaryCard } from './geographic-summary-card'
|
|||||||
export { CrossStageComparisonChart } from './cross-round-comparison'
|
export { CrossStageComparisonChart } from './cross-round-comparison'
|
||||||
export { JurorConsistencyChart } from './juror-consistency'
|
export { JurorConsistencyChart } from './juror-consistency'
|
||||||
export { DiversityMetricsChart } from './diversity-metrics'
|
export { DiversityMetricsChart } from './diversity-metrics'
|
||||||
|
export { JurorScoreHeatmap } from './juror-score-heatmap'
|
||||||
|
|||||||
@@ -1,15 +1,5 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import {
|
|
||||||
ScatterChart,
|
|
||||||
Scatter,
|
|
||||||
XAxis,
|
|
||||||
YAxis,
|
|
||||||
CartesianGrid,
|
|
||||||
Tooltip,
|
|
||||||
ResponsiveContainer,
|
|
||||||
ReferenceLine,
|
|
||||||
} 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'
|
||||||
import {
|
import {
|
||||||
@@ -21,11 +11,11 @@ import {
|
|||||||
TableRow,
|
TableRow,
|
||||||
} from '@/components/ui/table'
|
} from '@/components/ui/table'
|
||||||
import { AlertTriangle } from 'lucide-react'
|
import { AlertTriangle } from 'lucide-react'
|
||||||
|
import { scoreGradient } from './chart-theme'
|
||||||
|
|
||||||
interface JurorMetric {
|
interface JurorMetric {
|
||||||
userId: string
|
userId: string
|
||||||
name: string
|
name: string
|
||||||
email: string
|
|
||||||
evaluationCount: number
|
evaluationCount: number
|
||||||
averageScore: number
|
averageScore: number
|
||||||
stddev: number
|
stddev: number
|
||||||
@@ -40,28 +30,49 @@ interface JurorConsistencyProps {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function ScoreDot({ score, maxScore = 10 }: { score: number; maxScore?: number }) {
|
||||||
|
const pct = ((score / maxScore) * 100).toFixed(1)
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2 w-full min-w-[120px]">
|
||||||
|
<div className="flex-1 h-2.5 rounded-full bg-muted overflow-hidden">
|
||||||
|
<div
|
||||||
|
className="h-full rounded-full transition-all duration-300"
|
||||||
|
style={{
|
||||||
|
width: `${pct}%`,
|
||||||
|
backgroundColor: scoreGradient(score),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<span className="text-xs tabular-nums font-medium w-8 text-right">{score.toFixed(1)}</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export function JurorConsistencyChart({ data }: JurorConsistencyProps) {
|
export function JurorConsistencyChart({ data }: JurorConsistencyProps) {
|
||||||
const scatterData = data.jurors.map((j) => ({
|
if (!data?.jurors?.length) {
|
||||||
name: j.name,
|
return (
|
||||||
avgScore: parseFloat(j.averageScore.toFixed(2)),
|
<Card>
|
||||||
stddev: parseFloat(j.stddev.toFixed(2)),
|
<CardContent className="flex items-center justify-center py-12">
|
||||||
evaluations: j.evaluationCount,
|
<p className="text-muted-foreground">No juror consistency data available</p>
|
||||||
isOutlier: j.isOutlier,
|
</CardContent>
|
||||||
}))
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
const outlierCount = data.jurors.filter((j) => j.isOutlier).length
|
const outlierCount = data.jurors.filter((j) => j.isOutlier).length
|
||||||
|
const sorted = [...data.jurors].sort((a, b) => b.averageScore - a.averageScore)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* Scatter: Average Score vs Standard Deviation */}
|
{/* Juror Scoring Patterns — bar-based visual instead of scatter */}
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="flex items-center justify-between">
|
<CardTitle className="flex items-center justify-between flex-wrap gap-2">
|
||||||
<span>Juror Scoring Patterns</span>
|
<span className="text-base">Juror Scoring Patterns</span>
|
||||||
<span className="text-sm font-normal text-muted-foreground">
|
<span className="text-sm font-normal text-muted-foreground flex items-center gap-2">
|
||||||
Overall Avg: {data.overallAverage.toFixed(2)}
|
Overall Avg: {data.overallAverage.toFixed(2)}
|
||||||
{outlierCount > 0 && (
|
{outlierCount > 0 && (
|
||||||
<Badge variant="destructive" className="ml-2">
|
<Badge variant="destructive">
|
||||||
{outlierCount} outlier{outlierCount > 1 ? 's' : ''}
|
{outlierCount} outlier{outlierCount > 1 ? 's' : ''}
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
@@ -69,51 +80,31 @@ export function JurorConsistencyChart({ data }: JurorConsistencyProps) {
|
|||||||
</CardTitle>
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="h-[400px]">
|
<div className="space-y-2">
|
||||||
<ResponsiveContainer width="100%" height="100%">
|
{sorted.map((juror) => (
|
||||||
<ScatterChart margin={{ top: 20, right: 20, bottom: 20, left: 20 }}>
|
<div
|
||||||
<CartesianGrid strokeDasharray="3 3" className="stroke-muted" />
|
key={juror.userId}
|
||||||
<XAxis
|
className={`flex items-center gap-3 rounded-md px-3 py-2 ${juror.isOutlier ? 'bg-destructive/5 border border-destructive/20' : 'hover:bg-muted/50'}`}
|
||||||
type="number"
|
>
|
||||||
dataKey="avgScore"
|
<div className="w-36 shrink-0 truncate">
|
||||||
name="Average Score"
|
<span className="text-sm font-medium">{juror.name}</span>
|
||||||
domain={[0, 10]}
|
</div>
|
||||||
label={{ value: 'Average Score', position: 'insideBottom', offset: -10 }}
|
<div className="flex-1">
|
||||||
/>
|
<ScoreDot score={juror.averageScore} />
|
||||||
<YAxis
|
</div>
|
||||||
type="number"
|
<div className="hidden sm:flex items-center gap-3 text-xs text-muted-foreground shrink-0">
|
||||||
dataKey="stddev"
|
<span className="tabular-nums">σ {juror.stddev.toFixed(1)}</span>
|
||||||
name="Std Deviation"
|
<span className="tabular-nums">{juror.evaluationCount} eval{juror.evaluationCount !== 1 ? 's' : ''}</span>
|
||||||
label={{ value: 'Std Deviation', angle: -90, position: 'insideLeft' }}
|
</div>
|
||||||
/>
|
{juror.isOutlier && (
|
||||||
<Tooltip
|
<AlertTriangle className="h-3.5 w-3.5 text-destructive shrink-0" />
|
||||||
contentStyle={{
|
)}
|
||||||
backgroundColor: 'hsl(var(--card))',
|
</div>
|
||||||
border: '1px solid hsl(var(--border))',
|
))}
|
||||||
borderRadius: '6px',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<ReferenceLine
|
|
||||||
x={data.overallAverage}
|
|
||||||
stroke="#de0f1e"
|
|
||||||
strokeDasharray="3 3"
|
|
||||||
label={{ value: 'Avg', fill: '#de0f1e', position: 'top' }}
|
|
||||||
/>
|
|
||||||
<Scatter data={scatterData} fill="#053d57">
|
|
||||||
{scatterData.map((entry, index) => (
|
|
||||||
<circle
|
|
||||||
key={index}
|
|
||||||
r={Math.max(4, entry.evaluations)}
|
|
||||||
fill={entry.isOutlier ? '#de0f1e' : '#053d57'}
|
|
||||||
fillOpacity={0.7}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</Scatter>
|
|
||||||
</ScatterChart>
|
|
||||||
</ResponsiveContainer>
|
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-muted-foreground mt-2 text-center">
|
{/* Overall average line */}
|
||||||
Dot size represents number of evaluations. Red dots indicate outlier jurors (2+ points from mean).
|
<p className="text-xs text-muted-foreground mt-4 text-center">
|
||||||
|
Bars show average score per juror. σ = standard deviation. Outliers deviate 2+ points from the overall mean.
|
||||||
</p>
|
</p>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -121,49 +112,92 @@ export function JurorConsistencyChart({ data }: JurorConsistencyProps) {
|
|||||||
{/* Juror details table */}
|
{/* Juror details table */}
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Juror Consistency Details</CardTitle>
|
<CardTitle className="text-base">Juror Consistency Details</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<Table>
|
{/* Desktop table */}
|
||||||
<TableHeader>
|
<div className="hidden md:block">
|
||||||
<TableRow>
|
<Table>
|
||||||
<TableHead>Juror</TableHead>
|
<TableHeader>
|
||||||
<TableHead className="text-right">Evaluations</TableHead>
|
<TableRow>
|
||||||
<TableHead className="text-right">Avg Score</TableHead>
|
<TableHead>Juror</TableHead>
|
||||||
<TableHead className="text-right">Std Dev</TableHead>
|
<TableHead className="text-right">Evaluations</TableHead>
|
||||||
<TableHead className="text-right">Deviation from Mean</TableHead>
|
<TableHead className="text-right">Avg Score</TableHead>
|
||||||
<TableHead className="text-center">Status</TableHead>
|
<TableHead className="text-right">Std Dev</TableHead>
|
||||||
</TableRow>
|
<TableHead className="text-right">Deviation</TableHead>
|
||||||
</TableHeader>
|
<TableHead className="text-center">Status</TableHead>
|
||||||
<TableBody>
|
|
||||||
{data.jurors.map((juror) => (
|
|
||||||
<TableRow key={juror.userId} className={juror.isOutlier ? 'bg-destructive/5' : ''}>
|
|
||||||
<TableCell>
|
|
||||||
<div>
|
|
||||||
<p className="font-medium">{juror.name}</p>
|
|
||||||
<p className="text-xs text-muted-foreground">{juror.email}</p>
|
|
||||||
</div>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="text-right tabular-nums">{juror.evaluationCount}</TableCell>
|
|
||||||
<TableCell className="text-right tabular-nums">{juror.averageScore.toFixed(2)}</TableCell>
|
|
||||||
<TableCell className="text-right tabular-nums">{juror.stddev.toFixed(2)}</TableCell>
|
|
||||||
<TableCell className="text-right tabular-nums">
|
|
||||||
{juror.deviationFromOverall.toFixed(2)}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="text-center">
|
|
||||||
{juror.isOutlier ? (
|
|
||||||
<Badge variant="destructive" className="gap-1">
|
|
||||||
<AlertTriangle className="h-3 w-3" />
|
|
||||||
Outlier
|
|
||||||
</Badge>
|
|
||||||
) : (
|
|
||||||
<Badge variant="secondary">Normal</Badge>
|
|
||||||
)}
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
</TableRow>
|
||||||
))}
|
</TableHeader>
|
||||||
</TableBody>
|
<TableBody>
|
||||||
</Table>
|
{sorted.map((juror) => (
|
||||||
|
<TableRow
|
||||||
|
key={juror.userId}
|
||||||
|
className={juror.isOutlier ? 'bg-destructive/5' : ''}
|
||||||
|
>
|
||||||
|
<TableCell className="font-medium">{juror.name}</TableCell>
|
||||||
|
<TableCell className="text-right tabular-nums">
|
||||||
|
{juror.evaluationCount}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right tabular-nums">
|
||||||
|
{juror.averageScore.toFixed(2)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right tabular-nums">
|
||||||
|
{juror.stddev.toFixed(2)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right tabular-nums">
|
||||||
|
{juror.deviationFromOverall >= 0 ? '+' : ''}{juror.deviationFromOverall.toFixed(2)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-center">
|
||||||
|
{juror.isOutlier ? (
|
||||||
|
<Badge variant="destructive" className="gap-1">
|
||||||
|
<AlertTriangle className="h-3 w-3" />
|
||||||
|
Outlier
|
||||||
|
</Badge>
|
||||||
|
) : (
|
||||||
|
<Badge variant="secondary">Normal</Badge>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Mobile card stack */}
|
||||||
|
<div className="space-y-2 md:hidden">
|
||||||
|
{sorted.map((juror) => (
|
||||||
|
<div
|
||||||
|
key={juror.userId}
|
||||||
|
className={`rounded-md border p-3 space-y-1 ${juror.isOutlier ? 'bg-destructive/5 border-destructive/20' : ''}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-sm font-medium">{juror.name}</span>
|
||||||
|
{juror.isOutlier ? (
|
||||||
|
<Badge variant="destructive" className="gap-1 text-[10px]">
|
||||||
|
<AlertTriangle className="h-3 w-3" />
|
||||||
|
Outlier
|
||||||
|
</Badge>
|
||||||
|
) : (
|
||||||
|
<Badge variant="secondary" className="text-[10px]">Normal</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-3 gap-2 text-xs">
|
||||||
|
<div>
|
||||||
|
<p className="text-muted-foreground">Avg Score</p>
|
||||||
|
<p className="font-medium tabular-nums">{juror.averageScore.toFixed(2)}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-muted-foreground">Std Dev</p>
|
||||||
|
<p className="font-medium tabular-nums">{juror.stddev.toFixed(2)}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-muted-foreground">Evals</p>
|
||||||
|
<p className="font-medium tabular-nums">{juror.evaluationCount}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
240
src/components/charts/juror-score-heatmap.tsx
Normal file
240
src/components/charts/juror-score-heatmap.tsx
Normal file
@@ -0,0 +1,240 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { Fragment, useState } from 'react'
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card'
|
||||||
|
import { Badge } from '@/components/ui/badge'
|
||||||
|
import { scoreGradient } from './chart-theme'
|
||||||
|
|
||||||
|
interface JurorScoreHeatmapProps {
|
||||||
|
jurors: { id: string; name: string }[]
|
||||||
|
projects: { id: string; title: string }[]
|
||||||
|
cells: { jurorId: string; projectId: string; score: number | null }[]
|
||||||
|
truncated?: boolean
|
||||||
|
totalProjects?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
function getScoreColor(score: number | null): string {
|
||||||
|
if (score === null) return 'transparent'
|
||||||
|
return scoreGradient(score)
|
||||||
|
}
|
||||||
|
|
||||||
|
function getTextColor(score: number | null): string {
|
||||||
|
if (score === null) return 'inherit'
|
||||||
|
return score >= 6 ? '#ffffff' : '#1a1a1a'
|
||||||
|
}
|
||||||
|
|
||||||
|
function ScoreBadge({ score }: { score: number }) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className="inline-flex items-center justify-center rounded-md px-2 py-0.5 text-xs font-semibold tabular-nums min-w-[36px]"
|
||||||
|
style={{
|
||||||
|
backgroundColor: getScoreColor(score),
|
||||||
|
color: getTextColor(score),
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{score.toFixed(1)}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function JurorSummaryRow({
|
||||||
|
juror,
|
||||||
|
scores,
|
||||||
|
averageScore,
|
||||||
|
projectCount,
|
||||||
|
isExpanded,
|
||||||
|
onToggle,
|
||||||
|
projects,
|
||||||
|
}: {
|
||||||
|
juror: { id: string; name: string }
|
||||||
|
scores: { projectId: string; score: number | null }[]
|
||||||
|
averageScore: number | null
|
||||||
|
projectCount: number
|
||||||
|
isExpanded: boolean
|
||||||
|
onToggle: () => void
|
||||||
|
projects: { id: string; title: string }[]
|
||||||
|
}) {
|
||||||
|
const scored = scores.filter((s) => s.score !== null)
|
||||||
|
const unscored = projectCount - scored.length
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<tr
|
||||||
|
className="border-b cursor-pointer transition-colors hover:bg-muted/50"
|
||||||
|
onClick={onToggle}
|
||||||
|
>
|
||||||
|
<td className="py-3 px-4 font-medium text-sm whitespace-nowrap">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className={`inline-flex h-5 w-5 items-center justify-center rounded text-[10px] font-bold transition-transform ${isExpanded ? 'rotate-90' : ''}`}>
|
||||||
|
›
|
||||||
|
</span>
|
||||||
|
{juror.name}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="py-3 px-4 text-center tabular-nums text-sm">
|
||||||
|
{scored.length}
|
||||||
|
<span className="text-muted-foreground">/{projectCount}</span>
|
||||||
|
</td>
|
||||||
|
<td className="py-3 px-4 text-center">
|
||||||
|
{averageScore !== null ? (
|
||||||
|
<ScoreBadge score={averageScore} />
|
||||||
|
) : (
|
||||||
|
<span className="text-xs text-muted-foreground">—</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="py-3 px-4">
|
||||||
|
{/* Mini score bar */}
|
||||||
|
<div className="flex items-center gap-0.5">
|
||||||
|
{scored
|
||||||
|
.sort((a, b) => (a.score ?? 0) - (b.score ?? 0))
|
||||||
|
.map((s, i) => (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
className="h-4 w-1.5 rounded-full"
|
||||||
|
style={{ backgroundColor: getScoreColor(s.score) }}
|
||||||
|
title={`${s.score?.toFixed(1)}`}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
{unscored > 0 &&
|
||||||
|
Array.from({ length: Math.min(unscored, 10) }).map((_, i) => (
|
||||||
|
<div
|
||||||
|
key={`empty-${i}`}
|
||||||
|
className="h-4 w-1.5 rounded-full bg-muted"
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{isExpanded && (
|
||||||
|
<tr className="border-b bg-muted/30">
|
||||||
|
<td colSpan={4} className="p-4">
|
||||||
|
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-2">
|
||||||
|
{projects.map((p) => {
|
||||||
|
const cell = scores.find((s) => s.projectId === p.id)
|
||||||
|
const score = cell?.score ?? null
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={p.id}
|
||||||
|
className="flex items-center gap-2 rounded-md border bg-background px-2.5 py-1.5"
|
||||||
|
>
|
||||||
|
{score !== null ? (
|
||||||
|
<ScoreBadge score={score} />
|
||||||
|
) : (
|
||||||
|
<span className="inline-flex items-center justify-center rounded-md bg-muted px-2 py-0.5 text-xs text-muted-foreground min-w-[36px]">
|
||||||
|
—
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span className="text-xs truncate" title={p.title}>
|
||||||
|
{p.title}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function JurorScoreHeatmap({
|
||||||
|
jurors,
|
||||||
|
projects,
|
||||||
|
cells,
|
||||||
|
truncated,
|
||||||
|
totalProjects,
|
||||||
|
}: JurorScoreHeatmapProps) {
|
||||||
|
const [expandedId, setExpandedId] = useState<string | null>(null)
|
||||||
|
|
||||||
|
const cellMap = new Map<string, number | null>()
|
||||||
|
for (const c of cells) {
|
||||||
|
cellMap.set(`${c.jurorId}:${c.projectId}`, c.score)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (jurors.length === 0 || projects.length === 0) {
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardContent className="flex items-center justify-center py-12">
|
||||||
|
<p className="text-sm text-muted-foreground">No score data available for heatmap</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compute per-juror data
|
||||||
|
const jurorData = jurors.map((j) => {
|
||||||
|
const scores = projects.map((p) => ({
|
||||||
|
projectId: p.id,
|
||||||
|
score: cellMap.get(`${j.id}:${p.id}`) ?? null,
|
||||||
|
}))
|
||||||
|
const scored = scores.filter((s) => s.score !== null)
|
||||||
|
const avg = scored.length > 0
|
||||||
|
? scored.reduce((sum, s) => sum + (s.score ?? 0), 0) / scored.length
|
||||||
|
: null
|
||||||
|
return { juror: j, scores, averageScore: avg ? parseFloat(avg.toFixed(1)) : null, scoredCount: scored.length }
|
||||||
|
})
|
||||||
|
|
||||||
|
// Sort: jurors with most evaluations first
|
||||||
|
jurorData.sort((a, b) => b.scoredCount - a.scoredCount)
|
||||||
|
|
||||||
|
// Color legend
|
||||||
|
const legendScores = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<div className="flex items-start justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<CardTitle className="text-base">Score Heatmap</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
{jurors.length} juror{jurors.length !== 1 ? 's' : ''} · {projects.length} project{projects.length !== 1 ? 's' : ''}
|
||||||
|
{truncated && totalProjects ? ` (top ${projects.length} of ${totalProjects})` : ''}
|
||||||
|
</CardDescription>
|
||||||
|
</div>
|
||||||
|
{/* Color legend */}
|
||||||
|
<div className="hidden sm:flex items-center gap-1 shrink-0">
|
||||||
|
<span className="text-[10px] text-muted-foreground mr-1">Low</span>
|
||||||
|
{legendScores.map((s) => (
|
||||||
|
<div
|
||||||
|
key={s}
|
||||||
|
className="h-4 w-4 rounded-sm"
|
||||||
|
style={{ backgroundColor: getScoreColor(s) }}
|
||||||
|
title={s.toString()}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
<span className="text-[10px] text-muted-foreground ml-1">High</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b text-xs text-muted-foreground">
|
||||||
|
<th className="text-left py-2 px-4 font-medium">Juror</th>
|
||||||
|
<th className="text-center py-2 px-4 font-medium whitespace-nowrap">Reviewed</th>
|
||||||
|
<th className="text-center py-2 px-4 font-medium">Avg</th>
|
||||||
|
<th className="text-left py-2 px-4 font-medium">Score Distribution</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{jurorData.map(({ juror, scores, averageScore }) => (
|
||||||
|
<JurorSummaryRow
|
||||||
|
key={juror.id}
|
||||||
|
juror={juror}
|
||||||
|
scores={scores}
|
||||||
|
averageScore={averageScore}
|
||||||
|
projectCount={projects.length}
|
||||||
|
isExpanded={expandedId === juror.id}
|
||||||
|
onToggle={() => setExpandedId(expandedId === juror.id ? null : juror.id)}
|
||||||
|
projects={projects}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,15 +1,6 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import {
|
import { BarChart } from '@tremor/react'
|
||||||
BarChart,
|
|
||||||
Bar,
|
|
||||||
XAxis,
|
|
||||||
YAxis,
|
|
||||||
CartesianGrid,
|
|
||||||
Tooltip,
|
|
||||||
Legend,
|
|
||||||
ResponsiveContainer,
|
|
||||||
} from 'recharts'
|
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||||
|
|
||||||
interface JurorWorkloadData {
|
interface JurorWorkloadData {
|
||||||
@@ -25,17 +16,23 @@ interface JurorWorkloadProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function JurorWorkloadChart({ data }: JurorWorkloadProps) {
|
export function JurorWorkloadChart({ data }: JurorWorkloadProps) {
|
||||||
// Truncate names for display
|
if (!data?.length) return null
|
||||||
const formattedData = data.map((d) => ({
|
|
||||||
...d,
|
|
||||||
displayName: d.name.length > 15 ? d.name.substring(0, 15) + '...' : d.name,
|
|
||||||
}))
|
|
||||||
|
|
||||||
const totalAssigned = data.reduce((sum, d) => sum + d.assigned, 0)
|
const totalAssigned = data.reduce((sum, d) => sum + d.assigned, 0)
|
||||||
const totalCompleted = data.reduce((sum, d) => sum + d.completed, 0)
|
const totalCompleted = data.reduce((sum, d) => sum + d.completed, 0)
|
||||||
const overallRate =
|
const overallRate =
|
||||||
totalAssigned > 0 ? Math.round((totalCompleted / totalAssigned) * 100) : 0
|
totalAssigned > 0 ? Math.round((totalCompleted / totalAssigned) * 100) : 0
|
||||||
|
|
||||||
|
const sortedData = [...data].sort(
|
||||||
|
(a, b) => b.completionRate - a.completionRate,
|
||||||
|
)
|
||||||
|
|
||||||
|
const chartData = sortedData.map((d) => ({
|
||||||
|
juror: d.name.length > 25 ? d.name.substring(0, 25) + '...' : d.name,
|
||||||
|
Completed: d.completed,
|
||||||
|
Remaining: d.assigned - d.completed,
|
||||||
|
}))
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
@@ -47,55 +44,17 @@ export function JurorWorkloadChart({ data }: JurorWorkloadProps) {
|
|||||||
</CardTitle>
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="h-[400px]">
|
<BarChart
|
||||||
<ResponsiveContainer width="100%" height="100%">
|
data={chartData}
|
||||||
<BarChart
|
index="juror"
|
||||||
data={formattedData}
|
categories={['Completed', 'Remaining']}
|
||||||
layout="vertical"
|
colors={['blue', 'slate']}
|
||||||
margin={{ top: 20, right: 30, bottom: 20, left: 100 }}
|
layout="horizontal"
|
||||||
>
|
stack={true}
|
||||||
<CartesianGrid strokeDasharray="3 3" className="stroke-muted" />
|
yAxisWidth={160}
|
||||||
<XAxis type="number" />
|
className={`h-[${Math.max(300, data.length * 35)}px]`}
|
||||||
<YAxis
|
style={{ height: `${Math.max(300, data.length * 35)}px` }}
|
||||||
dataKey="displayName"
|
/>
|
||||||
type="category"
|
|
||||||
width={90}
|
|
||||||
tick={{ fontSize: 12 }}
|
|
||||||
/>
|
|
||||||
<Tooltip
|
|
||||||
contentStyle={{
|
|
||||||
backgroundColor: 'hsl(var(--card))',
|
|
||||||
border: '1px solid hsl(var(--border))',
|
|
||||||
borderRadius: '6px',
|
|
||||||
}}
|
|
||||||
formatter={(value: number | undefined, name: string | undefined) => [
|
|
||||||
value ?? 0,
|
|
||||||
(name ?? '') === 'assigned' ? 'Assigned' : 'Completed',
|
|
||||||
]}
|
|
||||||
labelFormatter={(_, payload) => {
|
|
||||||
if (payload && payload[0]) {
|
|
||||||
const item = payload[0].payload as JurorWorkloadData
|
|
||||||
return `${item.name} (${item.completionRate}% complete)`
|
|
||||||
}
|
|
||||||
return ''
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Legend />
|
|
||||||
<Bar
|
|
||||||
dataKey="assigned"
|
|
||||||
name="Assigned"
|
|
||||||
fill="#8884d8"
|
|
||||||
radius={[0, 4, 4, 0]}
|
|
||||||
/>
|
|
||||||
<Bar
|
|
||||||
dataKey="completed"
|
|
||||||
name="Completed"
|
|
||||||
fill="#82ca9d"
|
|
||||||
radius={[0, 4, 4, 0]}
|
|
||||||
/>
|
|
||||||
</BarChart>
|
|
||||||
</ResponsiveContainer>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,16 +1,6 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import {
|
import { BarChart } from '@tremor/react'
|
||||||
BarChart,
|
|
||||||
Bar,
|
|
||||||
XAxis,
|
|
||||||
YAxis,
|
|
||||||
CartesianGrid,
|
|
||||||
Tooltip,
|
|
||||||
ResponsiveContainer,
|
|
||||||
Cell,
|
|
||||||
ReferenceLine,
|
|
||||||
} from 'recharts'
|
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||||
|
|
||||||
interface ProjectRankingData {
|
interface ProjectRankingData {
|
||||||
@@ -27,31 +17,24 @@ interface ProjectRankingsProps {
|
|||||||
limit?: number
|
limit?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
// Generate color based on score (red to green gradient)
|
|
||||||
const getScoreColor = (score: number): string => {
|
|
||||||
if (score >= 8) return '#0bd90f' // Excellent - green
|
|
||||||
if (score >= 6) return '#82ca9d' // Good - light green
|
|
||||||
if (score >= 4) return '#ffc658' // Average - yellow
|
|
||||||
if (score >= 2) return '#ff7300' // Poor - orange
|
|
||||||
return '#de0f1e' // Very poor - red
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ProjectRankingsChart({
|
export function ProjectRankingsChart({
|
||||||
data,
|
data,
|
||||||
limit = 20,
|
limit = 20,
|
||||||
}: ProjectRankingsProps) {
|
}: ProjectRankingsProps) {
|
||||||
const displayData = data.slice(0, limit).map((d, index) => ({
|
const scoredData = (data ?? []).filter(
|
||||||
...d,
|
(d): d is ProjectRankingData & { averageScore: number } =>
|
||||||
rank: index + 1,
|
d.averageScore !== null,
|
||||||
displayTitle:
|
)
|
||||||
d.title.length > 25 ? d.title.substring(0, 25) + '...' : d.title,
|
|
||||||
score: d.averageScore || 0,
|
|
||||||
}))
|
|
||||||
|
|
||||||
const averageScore =
|
if (!scoredData.length) return null
|
||||||
data.length > 0
|
|
||||||
? data.reduce((sum, d) => sum + (d.averageScore || 0), 0) / data.length
|
const displayData = scoredData.slice(0, limit)
|
||||||
: 0
|
|
||||||
|
const chartData = displayData.map((d) => ({
|
||||||
|
project:
|
||||||
|
d.title.length > 30 ? d.title.substring(0, 30) + '...' : d.title,
|
||||||
|
Score: parseFloat(d.averageScore.toFixed(2)),
|
||||||
|
}))
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card>
|
<Card>
|
||||||
@@ -59,62 +42,23 @@ export function ProjectRankingsChart({
|
|||||||
<CardTitle className="flex items-center justify-between">
|
<CardTitle className="flex items-center justify-between">
|
||||||
<span>Project Rankings</span>
|
<span>Project Rankings</span>
|
||||||
<span className="text-sm font-normal text-muted-foreground">
|
<span className="text-sm font-normal text-muted-foreground">
|
||||||
Top {displayData.length} of {data.length} projects
|
Top {displayData.length} of {scoredData.length} scored projects
|
||||||
</span>
|
</span>
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="h-[500px]">
|
<BarChart
|
||||||
<ResponsiveContainer width="100%" height="100%">
|
data={chartData}
|
||||||
<BarChart
|
index="project"
|
||||||
data={displayData}
|
categories={['Score']}
|
||||||
layout="vertical"
|
colors={['blue']}
|
||||||
margin={{ top: 20, right: 30, bottom: 20, left: 150 }}
|
layout="horizontal"
|
||||||
>
|
yAxisWidth={200}
|
||||||
<CartesianGrid strokeDasharray="3 3" className="stroke-muted" />
|
maxValue={10}
|
||||||
<XAxis type="number" domain={[0, 10]} />
|
showLegend={false}
|
||||||
<YAxis
|
className={`h-[${Math.max(400, displayData.length * 30)}px]`}
|
||||||
dataKey="displayTitle"
|
style={{ height: `${Math.max(400, displayData.length * 30)}px` }}
|
||||||
type="category"
|
/>
|
||||||
width={140}
|
|
||||||
tick={{ fontSize: 11 }}
|
|
||||||
/>
|
|
||||||
<Tooltip
|
|
||||||
contentStyle={{
|
|
||||||
backgroundColor: 'hsl(var(--card))',
|
|
||||||
border: '1px solid hsl(var(--border))',
|
|
||||||
borderRadius: '6px',
|
|
||||||
}}
|
|
||||||
formatter={(value: number | undefined) => [(value ?? 0).toFixed(2), 'Average Score']}
|
|
||||||
labelFormatter={(_, payload) => {
|
|
||||||
if (payload && payload[0]) {
|
|
||||||
const item = payload[0].payload as ProjectRankingData & {
|
|
||||||
rank: number
|
|
||||||
}
|
|
||||||
return `#${item.rank} - ${item.title}${item.teamName ? ` (${item.teamName})` : ''}`
|
|
||||||
}
|
|
||||||
return ''
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<ReferenceLine
|
|
||||||
x={averageScore}
|
|
||||||
stroke="#666"
|
|
||||||
strokeDasharray="5 5"
|
|
||||||
label={{
|
|
||||||
value: `Avg: ${averageScore.toFixed(1)}`,
|
|
||||||
position: 'top',
|
|
||||||
fill: '#666',
|
|
||||||
fontSize: 11,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Bar dataKey="score" radius={[0, 4, 4, 0]}>
|
|
||||||
{displayData.map((entry, index) => (
|
|
||||||
<Cell key={`cell-${index}`} fill={getScoreColor(entry.score)} />
|
|
||||||
))}
|
|
||||||
</Bar>
|
|
||||||
</BarChart>
|
|
||||||
</ResponsiveContainer>
|
|
||||||
</div>
|
|
||||||
</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,
|
|
||||||
Cell,
|
|
||||||
} from 'recharts'
|
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||||
|
|
||||||
interface ScoreDistributionProps {
|
interface ScoreDistributionProps {
|
||||||
@@ -18,24 +9,18 @@ interface ScoreDistributionProps {
|
|||||||
totalScores: number
|
totalScores: number
|
||||||
}
|
}
|
||||||
|
|
||||||
const COLORS = [
|
|
||||||
'#de0f1e', // 1 - red (poor)
|
|
||||||
'#e6382f',
|
|
||||||
'#ed6141',
|
|
||||||
'#f38a52',
|
|
||||||
'#f8b364', // 5 - yellow (average)
|
|
||||||
'#c9c052',
|
|
||||||
'#99cc41',
|
|
||||||
'#6ad82f',
|
|
||||||
'#3be31e',
|
|
||||||
'#0bd90f', // 10 - green (excellent)
|
|
||||||
]
|
|
||||||
|
|
||||||
export function ScoreDistributionChart({
|
export function ScoreDistributionChart({
|
||||||
data,
|
data,
|
||||||
averageScore,
|
averageScore,
|
||||||
totalScores,
|
totalScores,
|
||||||
}: ScoreDistributionProps) {
|
}: ScoreDistributionProps) {
|
||||||
|
if (!data?.length) return null
|
||||||
|
|
||||||
|
const chartData = data.map((d) => ({
|
||||||
|
score: String(d.score),
|
||||||
|
Count: d.count,
|
||||||
|
}))
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
@@ -47,45 +32,15 @@ export function ScoreDistributionChart({
|
|||||||
</CardTitle>
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="h-[300px]">
|
<BarChart
|
||||||
<ResponsiveContainer width="100%" height="100%">
|
data={chartData}
|
||||||
<BarChart
|
index="score"
|
||||||
data={data}
|
categories={['Count']}
|
||||||
margin={{ top: 20, right: 20, bottom: 20, left: 20 }}
|
colors={['blue']}
|
||||||
>
|
yAxisWidth={40}
|
||||||
<CartesianGrid strokeDasharray="3 3" className="stroke-muted" />
|
showLegend={false}
|
||||||
<XAxis
|
className="h-[300px]"
|
||||||
dataKey="score"
|
/>
|
||||||
label={{
|
|
||||||
value: 'Score',
|
|
||||||
position: 'insideBottom',
|
|
||||||
offset: -10,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<YAxis
|
|
||||||
label={{
|
|
||||||
value: 'Count',
|
|
||||||
angle: -90,
|
|
||||||
position: 'insideLeft',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Tooltip
|
|
||||||
contentStyle={{
|
|
||||||
backgroundColor: 'hsl(var(--card))',
|
|
||||||
border: '1px solid hsl(var(--border))',
|
|
||||||
borderRadius: '6px',
|
|
||||||
}}
|
|
||||||
formatter={(value: number | undefined) => [value ?? 0, 'Count']}
|
|
||||||
labelFormatter={(label) => `Score: ${label}`}
|
|
||||||
/>
|
|
||||||
<Bar dataKey="count" radius={[4, 4, 0, 0]}>
|
|
||||||
{data.map((_, index) => (
|
|
||||||
<Cell key={`cell-${index}`} fill={COLORS[index]} />
|
|
||||||
))}
|
|
||||||
</Bar>
|
|
||||||
</BarChart>
|
|
||||||
</ResponsiveContainer>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,13 +1,8 @@
|
|||||||
'use client'
|
'use client'
|
||||||
import {
|
|
||||||
PieChart,
|
import { DonutChart } from '@tremor/react'
|
||||||
Pie,
|
|
||||||
Cell,
|
|
||||||
Tooltip,
|
|
||||||
Legend,
|
|
||||||
ResponsiveContainer,
|
|
||||||
} from 'recharts'
|
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||||
|
import { formatStatus, getStatusColor } from './chart-theme'
|
||||||
|
|
||||||
interface StatusDataPoint {
|
interface StatusDataPoint {
|
||||||
status: string
|
status: string
|
||||||
@@ -18,68 +13,18 @@ interface StatusBreakdownProps {
|
|||||||
data: StatusDataPoint[]
|
data: StatusDataPoint[]
|
||||||
}
|
}
|
||||||
|
|
||||||
const STATUS_COLORS: Record<string, string> = {
|
|
||||||
PENDING: '#8884d8',
|
|
||||||
UNDER_REVIEW: '#82ca9d',
|
|
||||||
SHORTLISTED: '#ffc658',
|
|
||||||
SEMIFINALIST: '#ff7300',
|
|
||||||
FINALIST: '#00C49F',
|
|
||||||
WINNER: '#0088FE',
|
|
||||||
ELIMINATED: '#de0f1e',
|
|
||||||
WITHDRAWN: '#999999',
|
|
||||||
}
|
|
||||||
|
|
||||||
const renderCustomLabel = ({
|
|
||||||
cx,
|
|
||||||
cy,
|
|
||||||
midAngle,
|
|
||||||
innerRadius,
|
|
||||||
outerRadius,
|
|
||||||
percent,
|
|
||||||
}: {
|
|
||||||
cx?: number
|
|
||||||
cy?: number
|
|
||||||
midAngle?: number
|
|
||||||
innerRadius?: number
|
|
||||||
outerRadius?: number
|
|
||||||
percent?: number
|
|
||||||
}) => {
|
|
||||||
if (cx === undefined || cy === undefined || midAngle === undefined ||
|
|
||||||
innerRadius === undefined || outerRadius === undefined || percent === undefined) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
if (percent < 0.05) return null // Don't show labels for small slices
|
|
||||||
|
|
||||||
const RADIAN = Math.PI / 180
|
|
||||||
const radius = innerRadius + (outerRadius - innerRadius) * 0.5
|
|
||||||
const x = cx + radius * Math.cos(-midAngle * RADIAN)
|
|
||||||
const y = cy + radius * Math.sin(-midAngle * RADIAN)
|
|
||||||
|
|
||||||
return (
|
|
||||||
<text
|
|
||||||
x={x}
|
|
||||||
y={y}
|
|
||||||
fill="white"
|
|
||||||
textAnchor={x > cx ? 'start' : 'end'}
|
|
||||||
dominantBaseline="central"
|
|
||||||
fontSize={12}
|
|
||||||
fontWeight={600}
|
|
||||||
>
|
|
||||||
{`${(percent * 100).toFixed(0)}%`}
|
|
||||||
</text>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function StatusBreakdownChart({ data }: StatusBreakdownProps) {
|
export function StatusBreakdownChart({ data }: StatusBreakdownProps) {
|
||||||
|
if (!data?.length) return null
|
||||||
|
|
||||||
const total = data.reduce((sum, item) => sum + item.count, 0)
|
const total = data.reduce((sum, item) => sum + item.count, 0)
|
||||||
|
|
||||||
// Format status for display
|
const chartData = data.map((d) => ({
|
||||||
const formattedData = data.map((d) => ({
|
name: formatStatus(d.status),
|
||||||
...d,
|
value: d.count,
|
||||||
name: d.status.replace(/_/g, ' '),
|
|
||||||
color: STATUS_COLORS[d.status] || '#8884d8',
|
|
||||||
}))
|
}))
|
||||||
|
|
||||||
|
const colors = data.map((d) => getStatusColor(d.status))
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
@@ -91,40 +36,14 @@ export function StatusBreakdownChart({ data }: StatusBreakdownProps) {
|
|||||||
</CardTitle>
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="h-[300px]">
|
<DonutChart
|
||||||
<ResponsiveContainer width="100%" height="100%">
|
data={chartData}
|
||||||
<PieChart>
|
category="value"
|
||||||
<Pie
|
index="name"
|
||||||
data={formattedData}
|
colors={colors}
|
||||||
cx="50%"
|
showLabel={true}
|
||||||
cy="50%"
|
className="h-[300px]"
|
||||||
labelLine={false}
|
/>
|
||||||
label={renderCustomLabel}
|
|
||||||
outerRadius={100}
|
|
||||||
innerRadius={50}
|
|
||||||
fill="#8884d8"
|
|
||||||
dataKey="count"
|
|
||||||
nameKey="name"
|
|
||||||
>
|
|
||||||
{formattedData.map((entry, index) => (
|
|
||||||
<Cell key={`cell-${index}`} fill={entry.color} />
|
|
||||||
))}
|
|
||||||
</Pie>
|
|
||||||
<Tooltip
|
|
||||||
contentStyle={{
|
|
||||||
backgroundColor: 'hsl(var(--card))',
|
|
||||||
border: '1px solid hsl(var(--border))',
|
|
||||||
borderRadius: '6px',
|
|
||||||
}}
|
|
||||||
formatter={(value: number | undefined, name: string | undefined) => [
|
|
||||||
`${value ?? 0} (${(((value ?? 0) / total) * 100).toFixed(1)}%)`,
|
|
||||||
name ?? '',
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
<Legend />
|
|
||||||
</PieChart>
|
|
||||||
</ResponsiveContainer>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
)
|
)
|
||||||
|
|||||||
302
src/components/dashboard/active-round-panel.tsx
Normal file
302
src/components/dashboard/active-round-panel.tsx
Normal file
@@ -0,0 +1,302 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import Link from 'next/link'
|
||||||
|
import { motion } from 'motion/react'
|
||||||
|
import {
|
||||||
|
Inbox,
|
||||||
|
Filter,
|
||||||
|
ClipboardCheck,
|
||||||
|
Upload,
|
||||||
|
Users,
|
||||||
|
Radio,
|
||||||
|
Scale,
|
||||||
|
Clock,
|
||||||
|
ArrowRight,
|
||||||
|
} from 'lucide-react'
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||||
|
import { Badge } from '@/components/ui/badge'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Progress } from '@/components/ui/progress'
|
||||||
|
import {
|
||||||
|
Tooltip,
|
||||||
|
TooltipContent,
|
||||||
|
TooltipProvider,
|
||||||
|
TooltipTrigger,
|
||||||
|
} from '@/components/ui/tooltip'
|
||||||
|
import { StatusBadge } from '@/components/shared/status-badge'
|
||||||
|
import { cn, formatEnumLabel, daysUntil } from '@/lib/utils'
|
||||||
|
|
||||||
|
export type PipelineRound = {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
roundType: string
|
||||||
|
status: string
|
||||||
|
projectStates: {
|
||||||
|
PENDING: number
|
||||||
|
IN_PROGRESS: number
|
||||||
|
PASSED: number
|
||||||
|
REJECTED: number
|
||||||
|
COMPLETED: number
|
||||||
|
WITHDRAWN: number
|
||||||
|
total: number
|
||||||
|
}
|
||||||
|
assignmentCount: number
|
||||||
|
evalSubmitted: number
|
||||||
|
evalDraft: number
|
||||||
|
evalTotal: number
|
||||||
|
filteringPassed: number
|
||||||
|
filteringRejected: number
|
||||||
|
filteringFlagged: number
|
||||||
|
filteringTotal: number
|
||||||
|
liveSessionStatus: string | null
|
||||||
|
deliberationCount: number
|
||||||
|
windowOpenAt: Date | null
|
||||||
|
windowCloseAt: Date | null
|
||||||
|
sortOrder: number
|
||||||
|
slug: string
|
||||||
|
}
|
||||||
|
|
||||||
|
type ActiveRoundPanelProps = {
|
||||||
|
round: PipelineRound
|
||||||
|
}
|
||||||
|
|
||||||
|
const roundTypeIcons: Record<string, React.ElementType> = {
|
||||||
|
INTAKE: Inbox,
|
||||||
|
FILTERING: Filter,
|
||||||
|
EVALUATION: ClipboardCheck,
|
||||||
|
SUBMISSION: Upload,
|
||||||
|
MENTORING: Users,
|
||||||
|
LIVE_FINAL: Radio,
|
||||||
|
DELIBERATION: Scale,
|
||||||
|
}
|
||||||
|
|
||||||
|
const stateColors: Record<string, { bg: string; label: string }> = {
|
||||||
|
PENDING: { bg: 'bg-slate-300', label: 'Pending' },
|
||||||
|
IN_PROGRESS: { bg: 'bg-blue-400', label: 'In Progress' },
|
||||||
|
PASSED: { bg: 'bg-emerald-500', label: 'Passed' },
|
||||||
|
REJECTED: { bg: 'bg-red-400', label: 'Rejected' },
|
||||||
|
COMPLETED: { bg: 'bg-[#557f8c]', label: 'Completed' },
|
||||||
|
WITHDRAWN: { bg: 'bg-slate-400', label: 'Withdrawn' },
|
||||||
|
}
|
||||||
|
|
||||||
|
function DeadlineCountdown({ date }: { date: Date }) {
|
||||||
|
const days = daysUntil(date)
|
||||||
|
|
||||||
|
if (days < 0) {
|
||||||
|
return (
|
||||||
|
<Badge variant="secondary" className="gap-1">
|
||||||
|
<Clock className="h-3 w-3" />
|
||||||
|
Closed
|
||||||
|
</Badge>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Badge
|
||||||
|
variant={days <= 2 ? 'destructive' : days <= 7 ? 'warning' : 'info'}
|
||||||
|
className="gap-1"
|
||||||
|
>
|
||||||
|
<Clock className="h-3 w-3" />
|
||||||
|
{days === 0 ? 'Closes today' : `${days}d left`}
|
||||||
|
</Badge>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ProjectStateBar({
|
||||||
|
projectStates,
|
||||||
|
}: {
|
||||||
|
projectStates: PipelineRound['projectStates']
|
||||||
|
}) {
|
||||||
|
const total = projectStates.total
|
||||||
|
if (total === 0) return null
|
||||||
|
|
||||||
|
const segments = (
|
||||||
|
['PENDING', 'IN_PROGRESS', 'PASSED', 'REJECTED', 'COMPLETED', 'WITHDRAWN'] as const
|
||||||
|
).filter((key) => projectStates[key] > 0)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TooltipProvider>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex h-3 w-full overflow-hidden rounded-full bg-muted">
|
||||||
|
{segments.map((key) => {
|
||||||
|
const pct = (projectStates[key] / total) * 100
|
||||||
|
const color = stateColors[key]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Tooltip key={key}>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<motion.div
|
||||||
|
className={cn('h-full', color.bg)}
|
||||||
|
initial={{ width: 0 }}
|
||||||
|
animate={{ width: `${pct}%` }}
|
||||||
|
transition={{ duration: 0.6, ease: 'easeOut' }}
|
||||||
|
/>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>
|
||||||
|
{color.label}: {projectStates[key]} ({Math.round(pct)}%)
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-x-4 gap-y-1">
|
||||||
|
{segments.map((key) => {
|
||||||
|
const color = stateColors[key]
|
||||||
|
return (
|
||||||
|
<div key={key} className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||||
|
<span className={cn('inline-block h-2 w-2 rounded-full', color.bg)} />
|
||||||
|
{color.label}: {projectStates[key]}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</TooltipProvider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function RoundTypeContent({ round }: { round: PipelineRound }) {
|
||||||
|
const { projectStates } = round
|
||||||
|
|
||||||
|
switch (round.roundType) {
|
||||||
|
case 'INTAKE':
|
||||||
|
return (
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Projects are submitting documents. {projectStates.PASSED} auto-passed,{' '}
|
||||||
|
{projectStates.PENDING} pending.
|
||||||
|
</p>
|
||||||
|
)
|
||||||
|
|
||||||
|
case 'FILTERING': {
|
||||||
|
const processed = round.filteringPassed + round.filteringRejected + round.filteringFlagged
|
||||||
|
const total = round.projectStates.total || round.filteringTotal
|
||||||
|
const pct = total > 0 ? Math.round((processed / total) * 100) : 0
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center justify-between text-sm">
|
||||||
|
<span className="text-muted-foreground">Filtering progress</span>
|
||||||
|
<span className="font-medium">{pct}%</span>
|
||||||
|
</div>
|
||||||
|
<Progress value={pct} gradient />
|
||||||
|
<div className="flex gap-4 text-xs text-muted-foreground">
|
||||||
|
<span className="text-emerald-600">{round.filteringPassed} passed</span>
|
||||||
|
<span className="text-red-600">{round.filteringRejected} failed</span>
|
||||||
|
<span className="text-amber-600">{round.filteringFlagged} flagged</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'EVALUATION': {
|
||||||
|
const pct =
|
||||||
|
round.evalTotal > 0
|
||||||
|
? Math.round((round.evalSubmitted / round.evalTotal) * 100)
|
||||||
|
: 0
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center justify-between text-sm">
|
||||||
|
<span className="text-muted-foreground">Evaluation progress</span>
|
||||||
|
<span className="font-medium">
|
||||||
|
{round.evalSubmitted} / {round.evalTotal} ({pct}%)
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<Progress value={pct} gradient />
|
||||||
|
{round.evalDraft > 0 && (
|
||||||
|
<p className="text-xs text-amber-600">
|
||||||
|
{round.evalDraft} draft{round.evalDraft !== 1 ? 's' : ''} in progress
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'SUBMISSION':
|
||||||
|
return (
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{projectStates.COMPLETED} submissions completed, {projectStates.IN_PROGRESS} in
|
||||||
|
progress, {projectStates.PENDING} pending.
|
||||||
|
</p>
|
||||||
|
)
|
||||||
|
|
||||||
|
case 'MENTORING':
|
||||||
|
return (
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Mentoring phase active. {round.assignmentCount} mentor assignment
|
||||||
|
{round.assignmentCount !== 1 ? 's' : ''} configured.{' '}
|
||||||
|
{projectStates.COMPLETED} projects completed mentoring.
|
||||||
|
</p>
|
||||||
|
)
|
||||||
|
|
||||||
|
case 'LIVE_FINAL':
|
||||||
|
return (
|
||||||
|
<div className="space-y-1">
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Live finals round.{' '}
|
||||||
|
{round.liveSessionStatus
|
||||||
|
? `Session: ${formatEnumLabel(round.liveSessionStatus)}`
|
||||||
|
: 'No session started yet.'}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{projectStates.total} projects in round, {projectStates.COMPLETED} completed.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
|
case 'DELIBERATION':
|
||||||
|
return (
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Deliberation phase.{' '}
|
||||||
|
{round.deliberationCount > 0
|
||||||
|
? `${round.deliberationCount} deliberation session${round.deliberationCount !== 1 ? 's' : ''} recorded.`
|
||||||
|
: 'No deliberation sessions yet.'}
|
||||||
|
</p>
|
||||||
|
)
|
||||||
|
|
||||||
|
default:
|
||||||
|
return (
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{projectStates.total} projects in this round.
|
||||||
|
</p>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ActiveRoundPanel({ round }: ActiveRoundPanelProps) {
|
||||||
|
const Icon = roundTypeIcons[round.roundType] || ClipboardCheck
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex flex-row items-center gap-3 space-y-0 pb-4">
|
||||||
|
<div className="flex h-9 w-9 items-center justify-center rounded-lg bg-brand-blue/10">
|
||||||
|
<Icon className="h-5 w-5 text-brand-blue" />
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<CardTitle className="truncate">{round.name}</CardTitle>
|
||||||
|
<p className="mt-0.5 text-xs text-muted-foreground">
|
||||||
|
{formatEnumLabel(round.roundType)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<StatusBadge status={round.status} size="sm" />
|
||||||
|
{round.windowCloseAt && (
|
||||||
|
<DeadlineCountdown date={new Date(round.windowCloseAt)} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<ProjectStateBar projectStates={round.projectStates} />
|
||||||
|
<RoundTypeContent round={round} />
|
||||||
|
<div className="pt-1">
|
||||||
|
<Button asChild size="sm">
|
||||||
|
<Link href={`/admin/rounds/${round.id}`}>
|
||||||
|
Manage Round
|
||||||
|
<ArrowRight className="h-4 w-4" />
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
76
src/components/dashboard/activity-feed.tsx
Normal file
76
src/components/dashboard/activity-feed.tsx
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { motion } from 'motion/react'
|
||||||
|
import { Activity } from 'lucide-react'
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from '@/components/ui/card'
|
||||||
|
import { formatRelativeTime } from '@/lib/utils'
|
||||||
|
import { formatAction, getActionIcon } from '@/components/dashboard/utils'
|
||||||
|
|
||||||
|
type ActivityFeedProps = {
|
||||||
|
activity: Array<{
|
||||||
|
id: string
|
||||||
|
action: string
|
||||||
|
entityType: string | null
|
||||||
|
timestamp: Date
|
||||||
|
user: { name: string | null } | null
|
||||||
|
}>
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ActivityFeed({ activity }: ActivityFeedProps) {
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-3">
|
||||||
|
<div className="flex items-center gap-2.5">
|
||||||
|
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-brand-blue/10">
|
||||||
|
<Activity className="h-4 w-4 text-brand-blue" />
|
||||||
|
</div>
|
||||||
|
<CardTitle className="text-base">Activity</CardTitle>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{activity.length === 0 ? (
|
||||||
|
<div className="flex flex-col items-center justify-center py-6 text-center">
|
||||||
|
<Activity className="h-8 w-8 text-muted-foreground/30" />
|
||||||
|
<p className="mt-2 text-xs text-muted-foreground">
|
||||||
|
No recent activity
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="relative">
|
||||||
|
{/* Timeline line */}
|
||||||
|
<div className="absolute left-[13px] top-2 bottom-2 w-px bg-border" />
|
||||||
|
<div className="space-y-3">
|
||||||
|
{activity.map((log, idx) => (
|
||||||
|
<motion.div
|
||||||
|
key={log.id}
|
||||||
|
initial={{ opacity: 0, x: 6 }}
|
||||||
|
animate={{ opacity: 1, x: 0 }}
|
||||||
|
transition={{ duration: 0.2, delay: 0.2 + idx * 0.03 }}
|
||||||
|
className="relative flex items-start gap-3"
|
||||||
|
>
|
||||||
|
<div className="relative z-10 flex h-[26px] w-[26px] shrink-0 items-center justify-center rounded-full border-2 border-background bg-muted">
|
||||||
|
{getActionIcon(log.action)}
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0 pt-0.5">
|
||||||
|
<p className="text-xs leading-relaxed">
|
||||||
|
<span className="font-semibold">{log.user?.name || 'System'}</span>
|
||||||
|
{' '}{formatAction(log.action, log.entityType)}
|
||||||
|
</p>
|
||||||
|
<p className="text-[11px] text-muted-foreground">
|
||||||
|
{formatRelativeTime(log.timestamp)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
110
src/components/dashboard/category-breakdown.tsx
Normal file
110
src/components/dashboard/category-breakdown.tsx
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { motion } from 'motion/react'
|
||||||
|
import { Layers } from 'lucide-react'
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from '@/components/ui/card'
|
||||||
|
import { formatEnumLabel } from '@/lib/utils'
|
||||||
|
|
||||||
|
type CategoryBreakdownProps = {
|
||||||
|
categories: Array<{ competitionCategory: string | null; _count: number }>
|
||||||
|
issues: Array<{ oceanIssue: string | null; _count: number }>
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CategoryBreakdown({ categories: rawCategories, issues: rawIssues }: CategoryBreakdownProps) {
|
||||||
|
const categories = rawCategories
|
||||||
|
.filter((c) => c.competitionCategory !== null)
|
||||||
|
.map((c) => ({
|
||||||
|
label: formatEnumLabel(c.competitionCategory!),
|
||||||
|
count: c._count,
|
||||||
|
}))
|
||||||
|
.sort((a, b) => b.count - a.count)
|
||||||
|
|
||||||
|
const issues = rawIssues
|
||||||
|
.filter((i) => i.oceanIssue !== null)
|
||||||
|
.map((i) => ({
|
||||||
|
label: formatEnumLabel(i.oceanIssue!),
|
||||||
|
count: i._count,
|
||||||
|
}))
|
||||||
|
.sort((a, b) => b.count - a.count)
|
||||||
|
.slice(0, 5)
|
||||||
|
|
||||||
|
const maxCategoryCount = Math.max(...categories.map((c) => c.count), 1)
|
||||||
|
const maxIssueCount = Math.max(...issues.map((i) => i.count), 1)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="h-full">
|
||||||
|
<CardHeader className="pb-3">
|
||||||
|
<div className="flex items-center gap-2.5">
|
||||||
|
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-violet-500/10">
|
||||||
|
<Layers className="h-4 w-4 text-violet-500" />
|
||||||
|
</div>
|
||||||
|
<CardTitle className="text-base">Categories</CardTitle>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{categories.length === 0 && issues.length === 0 ? (
|
||||||
|
<div className="flex flex-col items-center justify-center py-8 text-center">
|
||||||
|
<Layers className="h-8 w-8 text-muted-foreground/30" />
|
||||||
|
<p className="mt-2 text-xs text-muted-foreground">
|
||||||
|
No category data
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-5">
|
||||||
|
{categories.length > 0 && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<p className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
|
||||||
|
Competition Type
|
||||||
|
</p>
|
||||||
|
{categories.map((cat) => (
|
||||||
|
<div key={cat.label} className="space-y-1">
|
||||||
|
<div className="flex items-center justify-between text-xs">
|
||||||
|
<span className="truncate mr-2">{cat.label}</span>
|
||||||
|
<span className="font-bold tabular-nums">{cat.count}</span>
|
||||||
|
</div>
|
||||||
|
<div className="h-1.5 rounded-full bg-muted overflow-hidden">
|
||||||
|
<motion.div
|
||||||
|
className="h-full rounded-full bg-gradient-to-r from-brand-blue to-brand-teal"
|
||||||
|
initial={{ width: 0 }}
|
||||||
|
animate={{ width: `${(cat.count / maxCategoryCount) * 100}%` }}
|
||||||
|
transition={{ duration: 0.6, delay: 0.3, ease: 'easeOut' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{issues.length > 0 && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<p className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
|
||||||
|
Top Issues
|
||||||
|
</p>
|
||||||
|
{issues.map((issue) => (
|
||||||
|
<div key={issue.label} className="space-y-1">
|
||||||
|
<div className="flex items-center justify-between text-xs">
|
||||||
|
<span className="truncate mr-2">{issue.label}</span>
|
||||||
|
<span className="font-bold tabular-nums">{issue.count}</span>
|
||||||
|
</div>
|
||||||
|
<div className="h-1.5 rounded-full bg-muted overflow-hidden">
|
||||||
|
<motion.div
|
||||||
|
className="h-full rounded-full bg-gradient-to-r from-brand-teal to-brand-teal-light"
|
||||||
|
initial={{ width: 0 }}
|
||||||
|
animate={{ width: `${(issue.count / maxIssueCount) * 100}%` }}
|
||||||
|
transition={{ duration: 0.6, delay: 0.4, ease: 'easeOut' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
110
src/components/dashboard/competition-pipeline.tsx
Normal file
110
src/components/dashboard/competition-pipeline.tsx
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import Link from 'next/link'
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
import { motion } from 'motion/react'
|
||||||
|
import { Workflow, ArrowRight } from 'lucide-react'
|
||||||
|
import {
|
||||||
|
PipelineRoundNode,
|
||||||
|
type PipelineRound,
|
||||||
|
} from '@/components/dashboard/pipeline-round-node'
|
||||||
|
|
||||||
|
function Connector({
|
||||||
|
prevStatus,
|
||||||
|
index,
|
||||||
|
}: {
|
||||||
|
prevStatus: string
|
||||||
|
index: number
|
||||||
|
}) {
|
||||||
|
const isCompleted =
|
||||||
|
prevStatus === 'ROUND_CLOSED' || prevStatus === 'ROUND_ARCHIVED'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
initial={{ scaleX: 0 }}
|
||||||
|
animate={{ scaleX: 1 }}
|
||||||
|
transition={{ duration: 0.25, delay: 0.15 + index * 0.06 }}
|
||||||
|
className="flex items-center self-center origin-left"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'h-0.5 w-6',
|
||||||
|
isCompleted ? 'bg-emerald-300' : 'bg-slate-200'
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</motion.div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CompetitionPipeline({
|
||||||
|
rounds,
|
||||||
|
}: {
|
||||||
|
rounds: PipelineRound[]
|
||||||
|
}) {
|
||||||
|
if (rounds.length === 0) {
|
||||||
|
return (
|
||||||
|
<Card className="border-dashed">
|
||||||
|
<CardHeader className="pb-3">
|
||||||
|
<div className="flex items-center gap-2.5">
|
||||||
|
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-slate-500/10">
|
||||||
|
<Workflow className="h-4 w-4 text-slate-500" />
|
||||||
|
</div>
|
||||||
|
<CardTitle className="text-base">Competition Pipeline</CardTitle>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="flex flex-col items-center justify-center py-10 text-center">
|
||||||
|
<div className="flex h-14 w-14 items-center justify-center rounded-2xl bg-muted">
|
||||||
|
<Workflow className="h-7 w-7 text-muted-foreground/40" />
|
||||||
|
</div>
|
||||||
|
<p className="mt-3 text-sm font-medium text-muted-foreground">
|
||||||
|
No rounds configured yet
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 text-xs text-muted-foreground">
|
||||||
|
Create rounds to visualize the competition pipeline.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-2.5">
|
||||||
|
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-brand-teal/10">
|
||||||
|
<Workflow className="h-4 w-4 text-brand-teal" />
|
||||||
|
</div>
|
||||||
|
<CardTitle className="text-base">Competition Pipeline</CardTitle>
|
||||||
|
</div>
|
||||||
|
<Link
|
||||||
|
href="/admin/rounds"
|
||||||
|
className="flex items-center gap-1 text-xs font-semibold uppercase tracking-wider text-brand-teal hover:text-brand-teal-light transition-colors"
|
||||||
|
>
|
||||||
|
All rounds <ArrowRight className="h-3 w-3" />
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="overflow-x-auto pb-2">
|
||||||
|
<div className="flex items-start gap-0 min-w-max">
|
||||||
|
{rounds.map((round, index) => (
|
||||||
|
<div key={round.id} className="flex items-start">
|
||||||
|
<PipelineRoundNode round={round} index={index} />
|
||||||
|
{index < rounds.length - 1 && (
|
||||||
|
<Connector
|
||||||
|
prevStatus={round.status}
|
||||||
|
index={index}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
78
src/components/dashboard/dashboard-skeleton.tsx
Normal file
78
src/components/dashboard/dashboard-skeleton.tsx
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { Card, CardContent, CardHeader } from '@/components/ui/card'
|
||||||
|
import { Skeleton } from '@/components/ui/skeleton'
|
||||||
|
|
||||||
|
export function DashboardSkeleton() {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* Header skeleton */}
|
||||||
|
<Skeleton className="h-32 w-full rounded-2xl" />
|
||||||
|
|
||||||
|
{/* Stats row skeleton */}
|
||||||
|
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-5">
|
||||||
|
{[...Array(5)].map((_, i) => (
|
||||||
|
<Card key={i} className="border-l-4 border-l-muted">
|
||||||
|
<CardContent className="p-4">
|
||||||
|
<Skeleton className="h-4 w-16" />
|
||||||
|
<Skeleton className="mt-2 h-8 w-12" />
|
||||||
|
<Skeleton className="mt-1 h-3 w-20" />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Two-column content skeleton */}
|
||||||
|
<div className="grid gap-6 lg:grid-cols-12">
|
||||||
|
<div className="space-y-6 lg:col-span-8">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<Skeleton className="h-5 w-32" />
|
||||||
|
<Skeleton className="h-3 w-48" />
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="space-y-3">
|
||||||
|
{[...Array(3)].map((_, i) => (
|
||||||
|
<Skeleton key={i} className="h-24 w-full rounded-xl" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<Skeleton className="h-5 w-40" />
|
||||||
|
<Skeleton className="h-3 w-32" />
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{[...Array(5)].map((_, i) => (
|
||||||
|
<Skeleton key={i} className="h-14 w-full" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-6 lg:col-span-4">
|
||||||
|
{[...Array(4)].map((_, i) => (
|
||||||
|
<Card key={i}>
|
||||||
|
<CardHeader><Skeleton className="h-5 w-32" /></CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="space-y-3">
|
||||||
|
{[...Array(3)].map((_, j) => (
|
||||||
|
<Skeleton key={j} className="h-10 w-full" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Bottom skeleton */}
|
||||||
|
<div className="grid gap-6 lg:grid-cols-12">
|
||||||
|
<Skeleton className="h-[400px] w-full rounded-lg lg:col-span-8" />
|
||||||
|
<Skeleton className="h-[400px] w-full rounded-lg lg:col-span-4" />
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
218
src/components/dashboard/pipeline-round-node.tsx
Normal file
218
src/components/dashboard/pipeline-round-node.tsx
Normal file
@@ -0,0 +1,218 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import Link from 'next/link'
|
||||||
|
import type { Route } from 'next'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
import { motion } from 'motion/react'
|
||||||
|
import {
|
||||||
|
Upload,
|
||||||
|
Filter,
|
||||||
|
ClipboardCheck,
|
||||||
|
FileUp,
|
||||||
|
GraduationCap,
|
||||||
|
Radio,
|
||||||
|
Scale,
|
||||||
|
} from 'lucide-react'
|
||||||
|
|
||||||
|
type PipelineRound = {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
slug: string
|
||||||
|
roundType:
|
||||||
|
| 'INTAKE'
|
||||||
|
| 'FILTERING'
|
||||||
|
| 'EVALUATION'
|
||||||
|
| 'SUBMISSION'
|
||||||
|
| 'MENTORING'
|
||||||
|
| 'LIVE_FINAL'
|
||||||
|
| 'DELIBERATION'
|
||||||
|
status:
|
||||||
|
| 'ROUND_DRAFT'
|
||||||
|
| 'ROUND_ACTIVE'
|
||||||
|
| 'ROUND_CLOSED'
|
||||||
|
| 'ROUND_ARCHIVED'
|
||||||
|
sortOrder: number
|
||||||
|
windowOpenAt: Date | null
|
||||||
|
windowCloseAt: Date | null
|
||||||
|
projectStates: {
|
||||||
|
PENDING: number
|
||||||
|
IN_PROGRESS: number
|
||||||
|
PASSED: number
|
||||||
|
REJECTED: number
|
||||||
|
COMPLETED: number
|
||||||
|
WITHDRAWN: number
|
||||||
|
total: number
|
||||||
|
}
|
||||||
|
assignmentCount: number
|
||||||
|
evalSubmitted: number
|
||||||
|
evalDraft: number
|
||||||
|
evalTotal: number
|
||||||
|
filteringPassed: number
|
||||||
|
filteringRejected: number
|
||||||
|
filteringFlagged: number
|
||||||
|
filteringTotal: number
|
||||||
|
liveSessionStatus: string | null
|
||||||
|
deliberationCount: number
|
||||||
|
}
|
||||||
|
|
||||||
|
const roundTypeConfig: Record<
|
||||||
|
string,
|
||||||
|
{ icon: typeof Upload; iconColor: string; iconBg: string }
|
||||||
|
> = {
|
||||||
|
INTAKE: { icon: Upload, iconColor: 'text-sky-600', iconBg: 'bg-sky-100' },
|
||||||
|
FILTERING: {
|
||||||
|
icon: Filter,
|
||||||
|
iconColor: 'text-amber-600',
|
||||||
|
iconBg: 'bg-amber-100',
|
||||||
|
},
|
||||||
|
EVALUATION: {
|
||||||
|
icon: ClipboardCheck,
|
||||||
|
iconColor: 'text-violet-600',
|
||||||
|
iconBg: 'bg-violet-100',
|
||||||
|
},
|
||||||
|
SUBMISSION: {
|
||||||
|
icon: FileUp,
|
||||||
|
iconColor: 'text-blue-600',
|
||||||
|
iconBg: 'bg-blue-100',
|
||||||
|
},
|
||||||
|
MENTORING: {
|
||||||
|
icon: GraduationCap,
|
||||||
|
iconColor: 'text-teal-600',
|
||||||
|
iconBg: 'bg-teal-100',
|
||||||
|
},
|
||||||
|
LIVE_FINAL: {
|
||||||
|
icon: Radio,
|
||||||
|
iconColor: 'text-red-600',
|
||||||
|
iconBg: 'bg-red-100',
|
||||||
|
},
|
||||||
|
DELIBERATION: {
|
||||||
|
icon: Scale,
|
||||||
|
iconColor: 'text-indigo-600',
|
||||||
|
iconBg: 'bg-indigo-100',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
const statusStyles: Record<
|
||||||
|
string,
|
||||||
|
{ container: string; label: string }
|
||||||
|
> = {
|
||||||
|
ROUND_DRAFT: {
|
||||||
|
container:
|
||||||
|
'bg-slate-50 border-slate-200 text-slate-400 border-dashed',
|
||||||
|
label: 'Draft',
|
||||||
|
},
|
||||||
|
ROUND_ACTIVE: {
|
||||||
|
container:
|
||||||
|
'bg-blue-50 border-blue-300 text-blue-700 ring-2 ring-blue-400/30 shadow-lg shadow-blue-500/10',
|
||||||
|
label: 'Active',
|
||||||
|
},
|
||||||
|
ROUND_CLOSED: {
|
||||||
|
container: 'bg-emerald-50 border-emerald-200 text-emerald-600',
|
||||||
|
label: 'Closed',
|
||||||
|
},
|
||||||
|
ROUND_ARCHIVED: {
|
||||||
|
container: 'bg-slate-50/50 border-slate-100 text-slate-300',
|
||||||
|
label: 'Archived',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
function getMetric(round: PipelineRound): string {
|
||||||
|
const { roundType, projectStates, filteringTotal, filteringPassed, evalTotal, evalSubmitted, assignmentCount, liveSessionStatus, deliberationCount } = round
|
||||||
|
|
||||||
|
switch (roundType) {
|
||||||
|
case 'INTAKE':
|
||||||
|
return `${projectStates.total} submitted`
|
||||||
|
case 'FILTERING':
|
||||||
|
return filteringTotal > 0
|
||||||
|
? `${filteringPassed}/${filteringTotal} passed`
|
||||||
|
: `${projectStates.total} to filter`
|
||||||
|
case 'EVALUATION':
|
||||||
|
return evalTotal > 0
|
||||||
|
? `${evalSubmitted}/${evalTotal} evaluated`
|
||||||
|
: `${assignmentCount} assignments`
|
||||||
|
case 'SUBMISSION':
|
||||||
|
return `${projectStates.COMPLETED} submitted`
|
||||||
|
case 'MENTORING':
|
||||||
|
return `${projectStates.COMPLETED ?? 0} mentored`
|
||||||
|
case 'LIVE_FINAL': {
|
||||||
|
const status = liveSessionStatus
|
||||||
|
return status ? status.charAt(0) + status.slice(1).toLowerCase() : `${projectStates.total} finalists`
|
||||||
|
}
|
||||||
|
case 'DELIBERATION':
|
||||||
|
return deliberationCount > 0
|
||||||
|
? `${deliberationCount} sessions`
|
||||||
|
: 'Not started'
|
||||||
|
default:
|
||||||
|
return `${projectStates.total} projects`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PipelineRoundNode({
|
||||||
|
round,
|
||||||
|
index,
|
||||||
|
}: {
|
||||||
|
round: PipelineRound
|
||||||
|
index: number
|
||||||
|
}) {
|
||||||
|
const typeConfig = roundTypeConfig[round.roundType] ?? roundTypeConfig.INTAKE
|
||||||
|
const Icon = typeConfig.icon
|
||||||
|
const status = statusStyles[round.status] ?? statusStyles.ROUND_DRAFT
|
||||||
|
const isActive = round.status === 'ROUND_ACTIVE'
|
||||||
|
const metric = getMetric(round)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 8 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ duration: 0.3, delay: 0.1 + index * 0.06 }}
|
||||||
|
>
|
||||||
|
<Link
|
||||||
|
href={`/admin/rounds/${round.id}` as Route}
|
||||||
|
className="group block"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'relative flex flex-col items-center rounded-xl border-2 p-3 transition-all hover:-translate-y-0.5 hover:shadow-md',
|
||||||
|
isActive ? 'w-44' : 'w-36',
|
||||||
|
status.container
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{/* Active ping indicator */}
|
||||||
|
{isActive && (
|
||||||
|
<span className="absolute -right-1 -top-1 flex h-3 w-3">
|
||||||
|
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-blue-400 opacity-75" />
|
||||||
|
<span className="relative inline-flex h-3 w-3 rounded-full bg-blue-500" />
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Icon */}
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'flex h-8 w-8 items-center justify-center rounded-lg',
|
||||||
|
typeConfig.iconBg
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Icon className={cn('h-4 w-4', typeConfig.iconColor)} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Name */}
|
||||||
|
<p className="mt-2 text-center text-xs font-semibold leading-tight line-clamp-2 group-hover:text-foreground transition-colors">
|
||||||
|
{round.name}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* Status label */}
|
||||||
|
<span className="mt-1.5 text-[10px] font-medium uppercase tracking-wider opacity-70">
|
||||||
|
{status.label}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
{/* Metric */}
|
||||||
|
<p className="mt-1 text-[11px] font-medium tabular-nums opacity-80">
|
||||||
|
{metric}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
</motion.div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export type { PipelineRound }
|
||||||
114
src/components/dashboard/project-list-compact.tsx
Normal file
114
src/components/dashboard/project-list-compact.tsx
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import Link from 'next/link'
|
||||||
|
import { motion } from 'motion/react'
|
||||||
|
import { ClipboardList, ArrowRight } from 'lucide-react'
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from '@/components/ui/card'
|
||||||
|
import { StatusBadge } from '@/components/shared/status-badge'
|
||||||
|
import { ProjectLogo } from '@/components/shared/project-logo'
|
||||||
|
import { getCountryName } from '@/lib/countries'
|
||||||
|
import { formatDateOnly, truncate } from '@/lib/utils'
|
||||||
|
|
||||||
|
type ProjectListCompactProps = {
|
||||||
|
projects: Array<{
|
||||||
|
id: string
|
||||||
|
title: string
|
||||||
|
teamName: string | null
|
||||||
|
country: string | null
|
||||||
|
competitionCategory: string | null
|
||||||
|
oceanIssue: string | null
|
||||||
|
logoKey: string | null
|
||||||
|
createdAt: Date
|
||||||
|
submittedAt: Date | null
|
||||||
|
status: string
|
||||||
|
}>
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ProjectListCompact({ projects }: ProjectListCompactProps) {
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-2.5">
|
||||||
|
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-brand-blue/10">
|
||||||
|
<ClipboardList className="h-4 w-4 text-brand-blue" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<CardTitle className="text-base">Recent Projects</CardTitle>
|
||||||
|
<CardDescription className="text-xs">Latest submissions</CardDescription>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Link
|
||||||
|
href="/admin/projects"
|
||||||
|
className="flex items-center gap-1 text-xs font-semibold uppercase tracking-wider text-brand-teal hover:text-brand-teal-light transition-colors"
|
||||||
|
>
|
||||||
|
All projects <ArrowRight className="h-3 w-3" />
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{projects.length === 0 ? (
|
||||||
|
<div className="flex flex-col items-center justify-center py-10 text-center">
|
||||||
|
<div className="flex h-14 w-14 items-center justify-center rounded-2xl bg-muted">
|
||||||
|
<ClipboardList className="h-7 w-7 text-muted-foreground/40" />
|
||||||
|
</div>
|
||||||
|
<p className="mt-3 text-sm font-medium text-muted-foreground">
|
||||||
|
No projects submitted yet
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="divide-y">
|
||||||
|
{projects.map((project, idx) => (
|
||||||
|
<motion.div
|
||||||
|
key={project.id}
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
transition={{ duration: 0.25, delay: 0.15 + idx * 0.04 }}
|
||||||
|
>
|
||||||
|
<Link
|
||||||
|
href={`/admin/projects/${project.id}`}
|
||||||
|
className="block"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3 py-3 px-1 transition-colors hover:bg-muted/40 rounded-lg group">
|
||||||
|
<ProjectLogo
|
||||||
|
project={project}
|
||||||
|
size="sm"
|
||||||
|
fallback="initials"
|
||||||
|
/>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<p className="text-sm font-medium truncate group-hover:text-brand-blue transition-colors">
|
||||||
|
{truncate(project.title, 50)}
|
||||||
|
</p>
|
||||||
|
<StatusBadge
|
||||||
|
status={project.status ?? 'SUBMITTED'}
|
||||||
|
size="sm"
|
||||||
|
className="shrink-0"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground mt-0.5">
|
||||||
|
{[
|
||||||
|
project.teamName,
|
||||||
|
project.country ? getCountryName(project.country) : null,
|
||||||
|
formatDateOnly(project.submittedAt || project.createdAt),
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(' \u00b7 ')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
</motion.div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
114
src/components/dashboard/recent-evaluations.tsx
Normal file
114
src/components/dashboard/recent-evaluations.tsx
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import Link from 'next/link'
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||||
|
import { Badge } from '@/components/ui/badge'
|
||||||
|
import { ClipboardCheck, ThumbsUp, ThumbsDown, ExternalLink } from 'lucide-react'
|
||||||
|
import { formatDistanceToNow } from 'date-fns'
|
||||||
|
|
||||||
|
type RecentEvaluation = {
|
||||||
|
id: string
|
||||||
|
globalScore: number | null
|
||||||
|
binaryDecision: boolean | null
|
||||||
|
submittedAt: Date | string | null
|
||||||
|
feedbackText: string | null
|
||||||
|
assignment: {
|
||||||
|
project: { id: string; title: string }
|
||||||
|
round: { id: string; name: string }
|
||||||
|
user: { id: string; name: string | null; email: string }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RecentEvaluations({ evaluations }: { evaluations: RecentEvaluation[] }) {
|
||||||
|
if (!evaluations || evaluations.length === 0) {
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-3">
|
||||||
|
<CardTitle className="flex items-center gap-2 text-base">
|
||||||
|
<ClipboardCheck className="h-4 w-4" />
|
||||||
|
Recent Evaluations
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<p className="text-sm text-muted-foreground text-center py-4">
|
||||||
|
No evaluations submitted yet
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-3">
|
||||||
|
<CardTitle className="flex items-center gap-2 text-base">
|
||||||
|
<ClipboardCheck className="h-4 w-4" />
|
||||||
|
Recent Evaluations
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>Latest jury reviews as they come in</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-3">
|
||||||
|
{evaluations.map((ev) => (
|
||||||
|
<Link
|
||||||
|
key={ev.id}
|
||||||
|
href={`/admin/projects/${ev.assignment.project.id}`}
|
||||||
|
className="block group"
|
||||||
|
>
|
||||||
|
<div className="flex items-start gap-3 p-2.5 rounded-lg border hover:bg-muted/50 transition-colors">
|
||||||
|
{/* Score indicator */}
|
||||||
|
<div className="flex flex-col items-center gap-0.5 shrink-0 pt-0.5">
|
||||||
|
{ev.globalScore !== null ? (
|
||||||
|
<span className="text-lg font-bold tabular-nums leading-none">
|
||||||
|
{ev.globalScore}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-lg font-bold text-muted-foreground leading-none">-</span>
|
||||||
|
)}
|
||||||
|
<span className="text-[10px] text-muted-foreground">/10</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Details */}
|
||||||
|
<div className="flex-1 min-w-0 space-y-1">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<p className="text-sm font-medium truncate flex-1">
|
||||||
|
{ev.assignment.project.title}
|
||||||
|
</p>
|
||||||
|
<ExternalLink className="h-3 w-3 text-muted-foreground opacity-0 group-hover:opacity-100 transition-opacity shrink-0" />
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||||
|
<span className="truncate">{ev.assignment.user.name || ev.assignment.user.email}</span>
|
||||||
|
<span className="shrink-0">
|
||||||
|
{ev.submittedAt
|
||||||
|
? formatDistanceToNow(new Date(ev.submittedAt), { addSuffix: true })
|
||||||
|
: ''}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Badge variant="outline" className="text-[10px] h-5">
|
||||||
|
{ev.assignment.round.name}
|
||||||
|
</Badge>
|
||||||
|
{ev.binaryDecision !== null && (
|
||||||
|
ev.binaryDecision ? (
|
||||||
|
<span className="flex items-center gap-0.5 text-xs text-emerald-600">
|
||||||
|
<ThumbsUp className="h-3 w-3" /> Yes
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="flex items-center gap-0.5 text-xs text-red-500">
|
||||||
|
<ThumbsDown className="h-3 w-3" /> No
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{ev.feedbackText && (
|
||||||
|
<p className="text-xs text-muted-foreground line-clamp-2 leading-relaxed">
|
||||||
|
{ev.feedbackText}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
94
src/components/dashboard/round-stats-evaluation.tsx
Normal file
94
src/components/dashboard/round-stats-evaluation.tsx
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { motion } from 'motion/react'
|
||||||
|
|
||||||
|
type PipelineRound = {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
roundType: string
|
||||||
|
status: string
|
||||||
|
assignmentCount: number
|
||||||
|
evalSubmitted: number
|
||||||
|
evalDraft: number
|
||||||
|
evalTotal: number
|
||||||
|
}
|
||||||
|
|
||||||
|
type RoundStatsEvaluationProps = {
|
||||||
|
round: PipelineRound
|
||||||
|
activeJurors: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RoundStatsEvaluation({ round, activeJurors }: RoundStatsEvaluationProps) {
|
||||||
|
const { assignmentCount, evalSubmitted, evalDraft, evalTotal } = round
|
||||||
|
const completionPct = evalTotal > 0 ? ((evalSubmitted / evalTotal) * 100).toFixed(0) : '0'
|
||||||
|
|
||||||
|
const stats = [
|
||||||
|
{
|
||||||
|
value: assignmentCount,
|
||||||
|
label: 'Assignments',
|
||||||
|
detail: 'Jury-project pairs',
|
||||||
|
accent: 'text-brand-blue',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: `${evalSubmitted}/${evalTotal}`,
|
||||||
|
label: 'Submitted',
|
||||||
|
detail: `${completionPct}% complete`,
|
||||||
|
accent: 'text-emerald-600',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: evalDraft,
|
||||||
|
label: 'In draft',
|
||||||
|
detail: evalDraft > 0 ? 'Not yet submitted' : 'No drafts',
|
||||||
|
accent: evalDraft > 0 ? 'text-amber-600' : 'text-emerald-600',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: activeJurors,
|
||||||
|
label: 'Active jurors',
|
||||||
|
detail: 'Evaluating',
|
||||||
|
accent: 'text-brand-teal',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* Round label */}
|
||||||
|
<p className="mb-2 text-[11px] font-semibold uppercase tracking-widest text-muted-foreground/70">
|
||||||
|
{round.name} — Evaluation
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* Mobile: horizontal data strip */}
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
transition={{ duration: 0.4 }}
|
||||||
|
className="flex items-baseline justify-between border-b border-t py-3 md:hidden"
|
||||||
|
>
|
||||||
|
{stats.map((s, i) => (
|
||||||
|
<div key={i} className={`flex-1 text-center ${i > 0 ? 'border-l border-border/50' : ''}`}>
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
{/* Desktop: editorial stat row */}
|
||||||
|
<div className="hidden md:block">
|
||||||
|
<div className="grid grid-cols-4 gap-px rounded-lg bg-border/40 overflow-hidden">
|
||||||
|
{stats.map((s, i) => (
|
||||||
|
<motion.div
|
||||||
|
key={i}
|
||||||
|
initial={{ opacity: 0, y: 8 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ duration: 0.3, delay: i * 0.06 }}
|
||||||
|
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>
|
||||||
|
</motion.div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
107
src/components/dashboard/round-stats-filtering.tsx
Normal file
107
src/components/dashboard/round-stats-filtering.tsx
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { motion } from 'motion/react'
|
||||||
|
|
||||||
|
type PipelineRound = {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
roundType: string
|
||||||
|
status: string
|
||||||
|
projectStates: {
|
||||||
|
PENDING: number
|
||||||
|
IN_PROGRESS: number
|
||||||
|
PASSED: number
|
||||||
|
REJECTED: number
|
||||||
|
COMPLETED: number
|
||||||
|
WITHDRAWN: number
|
||||||
|
total: number
|
||||||
|
}
|
||||||
|
filteringPassed: number
|
||||||
|
filteringRejected: number
|
||||||
|
filteringFlagged: number
|
||||||
|
filteringTotal: number
|
||||||
|
}
|
||||||
|
|
||||||
|
type RoundStatsFilteringProps = {
|
||||||
|
round: PipelineRound
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RoundStatsFiltering({ round }: RoundStatsFilteringProps) {
|
||||||
|
const { filteringPassed, filteringRejected, filteringFlagged, projectStates } = round
|
||||||
|
const passRate = projectStates.total > 0
|
||||||
|
? ((filteringPassed / projectStates.total) * 100).toFixed(0)
|
||||||
|
: '0'
|
||||||
|
const rejectRate = projectStates.total > 0
|
||||||
|
? ((filteringRejected / projectStates.total) * 100).toFixed(0)
|
||||||
|
: '0'
|
||||||
|
|
||||||
|
const stats = [
|
||||||
|
{
|
||||||
|
value: projectStates.total,
|
||||||
|
label: 'To filter',
|
||||||
|
detail: 'In pipeline',
|
||||||
|
accent: 'text-brand-blue',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: filteringPassed,
|
||||||
|
label: 'Passed',
|
||||||
|
detail: `${passRate}% pass rate`,
|
||||||
|
accent: 'text-emerald-600',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: filteringRejected,
|
||||||
|
label: 'Rejected',
|
||||||
|
detail: `${rejectRate}% rejected`,
|
||||||
|
accent: 'text-red-600',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: filteringFlagged,
|
||||||
|
label: 'Flagged',
|
||||||
|
detail: filteringFlagged > 0 ? 'Manual review' : 'None flagged',
|
||||||
|
accent: filteringFlagged > 0 ? 'text-amber-600' : 'text-emerald-600',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* Round label */}
|
||||||
|
<p className="mb-2 text-[11px] font-semibold uppercase tracking-widest text-muted-foreground/70">
|
||||||
|
{round.name} — Filtering
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* Mobile: horizontal data strip */}
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
transition={{ duration: 0.4 }}
|
||||||
|
className="flex items-baseline justify-between border-b border-t py-3 md:hidden"
|
||||||
|
>
|
||||||
|
{stats.map((s, i) => (
|
||||||
|
<div key={i} className={`flex-1 text-center ${i > 0 ? 'border-l border-border/50' : ''}`}>
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
{/* Desktop: editorial stat row */}
|
||||||
|
<div className="hidden md:block">
|
||||||
|
<div className="grid grid-cols-4 gap-px rounded-lg bg-border/40 overflow-hidden">
|
||||||
|
{stats.map((s, i) => (
|
||||||
|
<motion.div
|
||||||
|
key={i}
|
||||||
|
initial={{ opacity: 0, y: 8 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ duration: 0.3, delay: i * 0.06 }}
|
||||||
|
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>
|
||||||
|
</motion.div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
97
src/components/dashboard/round-stats-generic.tsx
Normal file
97
src/components/dashboard/round-stats-generic.tsx
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { motion } from 'motion/react'
|
||||||
|
|
||||||
|
type RoundStatsGenericProps = {
|
||||||
|
projectCount: number
|
||||||
|
newProjectsThisWeek: number
|
||||||
|
totalJurors: number
|
||||||
|
activeJurors: number
|
||||||
|
totalAssignments: number
|
||||||
|
evaluationStats: Array<{ status: string; _count: number }>
|
||||||
|
actionsCount: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RoundStatsGeneric({
|
||||||
|
projectCount,
|
||||||
|
newProjectsThisWeek,
|
||||||
|
totalJurors,
|
||||||
|
activeJurors,
|
||||||
|
totalAssignments,
|
||||||
|
evaluationStats,
|
||||||
|
actionsCount,
|
||||||
|
}: RoundStatsGenericProps) {
|
||||||
|
const submittedCount =
|
||||||
|
evaluationStats.find((e) => e.status === 'SUBMITTED')?._count ?? 0
|
||||||
|
const completionPct =
|
||||||
|
totalAssignments > 0 ? ((submittedCount / totalAssignments) * 100).toFixed(0) : '0'
|
||||||
|
|
||||||
|
const stats = [
|
||||||
|
{
|
||||||
|
value: projectCount,
|
||||||
|
label: 'Projects',
|
||||||
|
detail: newProjectsThisWeek > 0 ? `+${newProjectsThisWeek} this week` : null,
|
||||||
|
accent: 'text-brand-blue',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: totalJurors,
|
||||||
|
label: 'Jurors',
|
||||||
|
detail: `${activeJurors} active`,
|
||||||
|
accent: 'text-brand-teal',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: `${submittedCount}/${totalAssignments}`,
|
||||||
|
label: 'Evaluations',
|
||||||
|
detail: `${completionPct}% complete`,
|
||||||
|
accent: 'text-emerald-600',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: actionsCount,
|
||||||
|
label: actionsCount === 1 ? 'Action' : 'Actions',
|
||||||
|
detail: actionsCount > 0 ? 'Pending' : 'All clear',
|
||||||
|
accent: actionsCount > 0 ? 'text-amber-600' : 'text-emerald-600',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* Mobile: horizontal data strip */}
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
transition={{ duration: 0.4 }}
|
||||||
|
className="flex items-baseline justify-between border-b border-t py-3 md:hidden"
|
||||||
|
>
|
||||||
|
{stats.map((s, i) => (
|
||||||
|
<div key={i} className={`flex-1 text-center ${i > 0 ? 'border-l border-border/50' : ''}`}>
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
{/* Desktop: editorial stat row */}
|
||||||
|
<div className="hidden md:block">
|
||||||
|
<div className="grid grid-cols-4 gap-px rounded-lg bg-border/40 overflow-hidden">
|
||||||
|
{stats.map((s, i) => (
|
||||||
|
<motion.div
|
||||||
|
key={i}
|
||||||
|
initial={{ opacity: 0, y: 8 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ duration: 0.3, delay: i * 0.06 }}
|
||||||
|
className="bg-background px-5 py-4 group hover:bg-muted/30 transition-colors"
|
||||||
|
>
|
||||||
|
<div className="flex items-baseline gap-2">
|
||||||
|
<span className="text-3xl font-bold tabular-nums tracking-tight">{s.value}</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs font-medium text-muted-foreground mt-1">{s.label}</p>
|
||||||
|
{s.detail && (
|
||||||
|
<p className={`text-xs mt-0.5 ${s.accent}`}>{s.detail}</p>
|
||||||
|
)}
|
||||||
|
</motion.div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
101
src/components/dashboard/round-stats-intake.tsx
Normal file
101
src/components/dashboard/round-stats-intake.tsx
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { motion } from 'motion/react'
|
||||||
|
|
||||||
|
type PipelineRound = {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
roundType: string
|
||||||
|
status: string
|
||||||
|
projectStates: {
|
||||||
|
PENDING: number
|
||||||
|
IN_PROGRESS: number
|
||||||
|
PASSED: number
|
||||||
|
REJECTED: number
|
||||||
|
COMPLETED: number
|
||||||
|
WITHDRAWN: number
|
||||||
|
total: number
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type RoundStatsIntakeProps = {
|
||||||
|
round: PipelineRound
|
||||||
|
newProjectsThisWeek: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RoundStatsIntake({ round, newProjectsThisWeek }: RoundStatsIntakeProps) {
|
||||||
|
const { projectStates } = round
|
||||||
|
const completePct = projectStates.total > 0
|
||||||
|
? ((projectStates.PASSED / projectStates.total) * 100).toFixed(0)
|
||||||
|
: '0'
|
||||||
|
|
||||||
|
const stats = [
|
||||||
|
{
|
||||||
|
value: projectStates.total,
|
||||||
|
label: 'Submitted',
|
||||||
|
detail: 'Total projects',
|
||||||
|
accent: 'text-brand-blue',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: projectStates.PASSED,
|
||||||
|
label: 'Docs complete',
|
||||||
|
detail: `${completePct}% of total`,
|
||||||
|
accent: 'text-emerald-600',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: projectStates.PENDING,
|
||||||
|
label: 'Pending',
|
||||||
|
detail: projectStates.PENDING > 0 ? 'Awaiting review' : 'All reviewed',
|
||||||
|
accent: projectStates.PENDING > 0 ? 'text-amber-600' : 'text-emerald-600',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: newProjectsThisWeek,
|
||||||
|
label: 'This week',
|
||||||
|
detail: newProjectsThisWeek > 0 ? 'New submissions' : 'No new',
|
||||||
|
accent: 'text-brand-teal',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* Round label */}
|
||||||
|
<p className="mb-2 text-[11px] font-semibold uppercase tracking-widest text-muted-foreground/70">
|
||||||
|
{round.name} — Intake
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* Mobile: horizontal data strip */}
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
transition={{ duration: 0.4 }}
|
||||||
|
className="flex items-baseline justify-between border-b border-t py-3 md:hidden"
|
||||||
|
>
|
||||||
|
{stats.map((s, i) => (
|
||||||
|
<div key={i} className={`flex-1 text-center ${i > 0 ? 'border-l border-border/50' : ''}`}>
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
{/* Desktop: editorial stat row */}
|
||||||
|
<div className="hidden md:block">
|
||||||
|
<div className="grid grid-cols-4 gap-px rounded-lg bg-border/40 overflow-hidden">
|
||||||
|
{stats.map((s, i) => (
|
||||||
|
<motion.div
|
||||||
|
key={i}
|
||||||
|
initial={{ opacity: 0, y: 8 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ duration: 0.3, delay: i * 0.06 }}
|
||||||
|
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>
|
||||||
|
</motion.div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user