Compare commits
11 Commits
6e697cb5d8
...
with-test
| Author | SHA1 | Date | |
|---|---|---|---|
| 3e70de3a5a | |||
| f42b452899 | |||
| 161cd1684a | |||
| 2e4b95f29c | |||
| ee3bfec8b0 | |||
| 8e607478d5 | |||
| 6d4ee93ab3 | |||
| 350e9b96e8 | |||
| 533d8cb8e5 | |||
| 4f73ba5a0e | |||
| 26e8830df2 |
40
package-lock.json
generated
40
package-lock.json
generated
@@ -8,6 +8,7 @@
|
|||||||
"name": "mopc-platform",
|
"name": "mopc-platform",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"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",
|
||||||
@@ -119,6 +120,26 @@
|
|||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@anthropic-ai/sdk": {
|
||||||
|
"version": "0.78.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.78.0.tgz",
|
||||||
|
"integrity": "sha512-PzQhR715td/m1UaaN5hHXjYB8Gl2lF9UVhrrGrZeysiF6Rb74Wc9GCB8hzLdzmQtBd1qe89F9OptgB9Za1Ib5w==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"json-schema-to-ts": "^3.1.1"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"anthropic-ai-sdk": "bin/cli"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"zod": "^3.25.0 || ^4.0.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"zod": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@auth/core": {
|
"node_modules/@auth/core": {
|
||||||
"version": "0.41.1",
|
"version": "0.41.1",
|
||||||
"resolved": "https://registry.npmjs.org/@auth/core/-/core-0.41.1.tgz",
|
"resolved": "https://registry.npmjs.org/@auth/core/-/core-0.41.1.tgz",
|
||||||
@@ -9277,6 +9298,19 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/json-schema-to-ts": {
|
||||||
|
"version": "3.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/json-schema-to-ts/-/json-schema-to-ts-3.1.1.tgz",
|
||||||
|
"integrity": "sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@babel/runtime": "^7.18.3",
|
||||||
|
"ts-algebra": "^2.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/json-schema-traverse": {
|
"node_modules/json-schema-traverse": {
|
||||||
"version": "0.4.1",
|
"version": "0.4.1",
|
||||||
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz",
|
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz",
|
||||||
@@ -13609,6 +13643,12 @@
|
|||||||
"url": "https://github.com/sponsors/wooorm"
|
"url": "https://github.com/sponsors/wooorm"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/ts-algebra": {
|
||||||
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/ts-algebra/-/ts-algebra-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/ts-api-utils": {
|
"node_modules/ts-api-utils": {
|
||||||
"version": "2.4.0",
|
"version": "2.4.0",
|
||||||
"resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.4.0.tgz",
|
"resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.4.0.tgz",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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?
|
||||||
@@ -495,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
|
||||||
|
|
||||||
@@ -619,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
|
||||||
|
|
||||||
@@ -907,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
|
||||||
@@ -2090,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
|
||||||
|
|
||||||
@@ -2104,6 +2116,7 @@ model Competition {
|
|||||||
|
|
||||||
@@index([programId])
|
@@index([programId])
|
||||||
@@index([status])
|
@@index([status])
|
||||||
|
@@index([isTest])
|
||||||
}
|
}
|
||||||
|
|
||||||
model Round {
|
model Round {
|
||||||
|
|||||||
@@ -69,7 +69,18 @@ export default function AssignmentsDashboardPage() {
|
|||||||
if (!competition) {
|
if (!competition) {
|
||||||
return (
|
return (
|
||||||
<div className="container mx-auto space-y-6 p-4 sm:p-6">
|
<div className="container mx-auto space-y-6 p-4 sm:p-6">
|
||||||
<p>Competition not found</p>
|
<Card>
|
||||||
|
<CardContent className="flex flex-col items-center justify-center py-12">
|
||||||
|
<p className="font-medium">Competition not found</p>
|
||||||
|
<p className="text-sm text-muted-foreground mt-1">
|
||||||
|
The requested competition does not exist or you don't have access.
|
||||||
|
</p>
|
||||||
|
<Button variant="outline" className="mt-4" onClick={() => router.back()}>
|
||||||
|
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||||
|
Go Back
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -43,13 +43,13 @@ 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 }
|
||||||
);
|
);
|
||||||
@@ -121,6 +121,24 @@ export default function DeliberationListPage({
|
|||||||
return <Badge variant={variants[status] || 'outline'}>{labels[status] || status}</Badge>;
|
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">
|
||||||
|
|||||||
@@ -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'
|
||||||
|
|
||||||
@@ -435,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>
|
||||||
|
|||||||
@@ -1,10 +0,0 @@
|
|||||||
import { redirect } from 'next/navigation'
|
|
||||||
|
|
||||||
export default async function MentorDetailPage({
|
|
||||||
params,
|
|
||||||
}: {
|
|
||||||
params: Promise<{ id: string }>
|
|
||||||
}) {
|
|
||||||
const { id } = await params
|
|
||||||
redirect(`/admin/members/${id}`)
|
|
||||||
}
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
import { redirect } from 'next/navigation'
|
|
||||||
|
|
||||||
export default function MentorsPage() {
|
|
||||||
redirect('/admin/members')
|
|
||||||
}
|
|
||||||
@@ -30,7 +30,7 @@ export default async function AdminDashboardPage({ searchParams }: PageProps) {
|
|||||||
|
|
||||||
if (!editionId) {
|
if (!editionId) {
|
||||||
const defaultEdition = await prisma.program.findFirst({
|
const defaultEdition = await prisma.program.findFirst({
|
||||||
where: { status: 'ACTIVE' },
|
where: { status: 'ACTIVE', isTest: false },
|
||||||
orderBy: { year: 'desc' },
|
orderBy: { year: 'desc' },
|
||||||
select: { id: true },
|
select: { id: true },
|
||||||
})
|
})
|
||||||
@@ -38,6 +38,7 @@ export default async function AdminDashboardPage({ searchParams }: PageProps) {
|
|||||||
|
|
||||||
if (!editionId) {
|
if (!editionId) {
|
||||||
const anyEdition = await prisma.program.findFirst({
|
const anyEdition = await prisma.program.findFirst({
|
||||||
|
where: { isTest: false },
|
||||||
orderBy: { year: 'desc' },
|
orderBy: { year: 'desc' },
|
||||||
select: { id: true },
|
select: { id: true },
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ import {
|
|||||||
TableHeader,
|
TableHeader,
|
||||||
TableRow,
|
TableRow,
|
||||||
} from '@/components/ui/table'
|
} from '@/components/ui/table'
|
||||||
import { ArrowLeft, Pencil, Plus } from 'lucide-react'
|
import { ArrowLeft, GraduationCap, Pencil, Plus } from 'lucide-react'
|
||||||
import { formatDateOnly } from '@/lib/utils'
|
import { formatDateOnly } from '@/lib/utils'
|
||||||
|
|
||||||
interface ProgramDetailPageProps {
|
interface ProgramDetailPageProps {
|
||||||
@@ -65,6 +65,13 @@ export default async function ProgramDetailPage({ params }: ProgramDetailPagePro
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button variant="outline" asChild>
|
||||||
|
<Link href={`/admin/programs/${id}/mentorship` as Route}>
|
||||||
|
<GraduationCap className="mr-2 h-4 w-4" />
|
||||||
|
Mentorship
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
<Button variant="outline" asChild>
|
<Button variant="outline" asChild>
|
||||||
<Link href={`/admin/programs/${id}/edit`}>
|
<Link href={`/admin/programs/${id}/edit`}>
|
||||||
<Pencil className="mr-2 h-4 w-4" />
|
<Pencil className="mr-2 h-4 w-4" />
|
||||||
@@ -72,6 +79,7 @@ export default async function ProgramDetailPage({ params }: ProgramDetailPagePro
|
|||||||
</Link>
|
</Link>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{program.description && (
|
{program.description && (
|
||||||
<Card>
|
<Card>
|
||||||
|
|||||||
@@ -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: {
|
||||||
|
|||||||
@@ -205,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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -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 */
|
||||||
@@ -313,3 +351,27 @@ div[class*="recharts-tooltip"] {
|
|||||||
background-color: hsl(var(--card)) !important;
|
background-color: hsl(var(--card)) !important;
|
||||||
border-color: hsl(var(--border)) !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={{
|
||||||
|
|||||||
@@ -1,654 +0,0 @@
|
|||||||
'use client'
|
|
||||||
|
|
||||||
import { useState } from 'react'
|
|
||||||
import { trpc } from '@/lib/trpc/client'
|
|
||||||
import { toast } from 'sonner'
|
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
|
||||||
import { Button } from '@/components/ui/button'
|
|
||||||
import { Input } from '@/components/ui/input'
|
|
||||||
import { Label } from '@/components/ui/label'
|
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
|
||||||
import { Badge } from '@/components/ui/badge'
|
|
||||||
import { Switch } from '@/components/ui/switch'
|
|
||||||
import {
|
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogDescription,
|
|
||||||
DialogFooter,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
DialogTrigger,
|
|
||||||
} from '@/components/ui/dialog'
|
|
||||||
import { Plus, Lock, Unlock, LockKeyhole, Loader2, Pencil, Trash2 } from 'lucide-react'
|
|
||||||
import { cn } from '@/lib/utils'
|
|
||||||
import { format } from 'date-fns'
|
|
||||||
|
|
||||||
type SubmissionWindowManagerProps = {
|
|
||||||
competitionId: string
|
|
||||||
roundId: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export function SubmissionWindowManager({ competitionId, roundId }: SubmissionWindowManagerProps) {
|
|
||||||
const [isCreateOpen, setIsCreateOpen] = useState(false)
|
|
||||||
const [editingWindow, setEditingWindow] = useState<string | null>(null)
|
|
||||||
const [deletingWindow, setDeletingWindow] = useState<string | null>(null)
|
|
||||||
|
|
||||||
// Create form state
|
|
||||||
const [createForm, setCreateForm] = useState({
|
|
||||||
name: '',
|
|
||||||
slug: '',
|
|
||||||
roundNumber: 1,
|
|
||||||
windowOpenAt: '',
|
|
||||||
windowCloseAt: '',
|
|
||||||
deadlinePolicy: 'HARD_DEADLINE' as 'HARD_DEADLINE' | 'FLAG' | 'GRACE',
|
|
||||||
graceHours: 0,
|
|
||||||
lockOnClose: true,
|
|
||||||
})
|
|
||||||
|
|
||||||
// Edit form state
|
|
||||||
const [editForm, setEditForm] = useState({
|
|
||||||
name: '',
|
|
||||||
slug: '',
|
|
||||||
roundNumber: 1,
|
|
||||||
windowOpenAt: '',
|
|
||||||
windowCloseAt: '',
|
|
||||||
deadlinePolicy: 'HARD_DEADLINE' as 'HARD_DEADLINE' | 'FLAG' | 'GRACE',
|
|
||||||
graceHours: 0,
|
|
||||||
lockOnClose: true,
|
|
||||||
sortOrder: 1,
|
|
||||||
})
|
|
||||||
|
|
||||||
const utils = trpc.useUtils()
|
|
||||||
|
|
||||||
const { data: competition, isLoading } = trpc.competition.getById.useQuery({
|
|
||||||
id: competitionId,
|
|
||||||
})
|
|
||||||
|
|
||||||
const createWindowMutation = trpc.round.createSubmissionWindow.useMutation({
|
|
||||||
onSuccess: () => {
|
|
||||||
utils.competition.getById.invalidate({ id: competitionId })
|
|
||||||
toast.success('Submission window created')
|
|
||||||
setIsCreateOpen(false)
|
|
||||||
// Reset form
|
|
||||||
setCreateForm({
|
|
||||||
name: '',
|
|
||||||
slug: '',
|
|
||||||
roundNumber: 1,
|
|
||||||
windowOpenAt: '',
|
|
||||||
windowCloseAt: '',
|
|
||||||
deadlinePolicy: 'HARD_DEADLINE',
|
|
||||||
graceHours: 0,
|
|
||||||
lockOnClose: true,
|
|
||||||
})
|
|
||||||
},
|
|
||||||
onError: (err) => toast.error(err.message),
|
|
||||||
})
|
|
||||||
|
|
||||||
const updateWindowMutation = trpc.round.updateSubmissionWindow.useMutation({
|
|
||||||
onSuccess: () => {
|
|
||||||
utils.competition.getById.invalidate({ id: competitionId })
|
|
||||||
toast.success('Submission window updated')
|
|
||||||
setEditingWindow(null)
|
|
||||||
},
|
|
||||||
onError: (err) => toast.error(err.message),
|
|
||||||
})
|
|
||||||
|
|
||||||
const deleteWindowMutation = trpc.round.deleteSubmissionWindow.useMutation({
|
|
||||||
onSuccess: () => {
|
|
||||||
utils.competition.getById.invalidate({ id: competitionId })
|
|
||||||
toast.success('Submission window deleted')
|
|
||||||
setDeletingWindow(null)
|
|
||||||
},
|
|
||||||
onError: (err) => toast.error(err.message),
|
|
||||||
})
|
|
||||||
|
|
||||||
const openWindowMutation = trpc.round.openSubmissionWindow.useMutation({
|
|
||||||
onSuccess: () => {
|
|
||||||
utils.competition.getById.invalidate({ id: competitionId })
|
|
||||||
toast.success('Window opened')
|
|
||||||
},
|
|
||||||
onError: (err) => toast.error(err.message),
|
|
||||||
})
|
|
||||||
|
|
||||||
const closeWindowMutation = trpc.round.closeSubmissionWindow.useMutation({
|
|
||||||
onSuccess: () => {
|
|
||||||
utils.competition.getById.invalidate({ id: competitionId })
|
|
||||||
toast.success('Window closed')
|
|
||||||
},
|
|
||||||
onError: (err) => toast.error(err.message),
|
|
||||||
})
|
|
||||||
|
|
||||||
const lockWindowMutation = trpc.round.lockSubmissionWindow.useMutation({
|
|
||||||
onSuccess: () => {
|
|
||||||
utils.competition.getById.invalidate({ id: competitionId })
|
|
||||||
toast.success('Window locked')
|
|
||||||
},
|
|
||||||
onError: (err) => toast.error(err.message),
|
|
||||||
})
|
|
||||||
|
|
||||||
const handleCreateNameChange = (value: string) => {
|
|
||||||
const autoSlug = value.toLowerCase().replace(/\s+/g, '-').replace(/[^a-z0-9-]/g, '')
|
|
||||||
setCreateForm({ ...createForm, name: value, slug: autoSlug })
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleEditNameChange = (value: string) => {
|
|
||||||
const autoSlug = value.toLowerCase().replace(/\s+/g, '-').replace(/[^a-z0-9-]/g, '')
|
|
||||||
setEditForm({ ...editForm, name: value, slug: autoSlug })
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleCreate = () => {
|
|
||||||
if (!createForm.name || !createForm.slug) {
|
|
||||||
toast.error('Name and slug are required')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
createWindowMutation.mutate({
|
|
||||||
competitionId,
|
|
||||||
name: createForm.name,
|
|
||||||
slug: createForm.slug,
|
|
||||||
roundNumber: createForm.roundNumber,
|
|
||||||
windowOpenAt: createForm.windowOpenAt ? new Date(createForm.windowOpenAt) : undefined,
|
|
||||||
windowCloseAt: createForm.windowCloseAt ? new Date(createForm.windowCloseAt) : undefined,
|
|
||||||
deadlinePolicy: createForm.deadlinePolicy,
|
|
||||||
graceHours: createForm.deadlinePolicy === 'GRACE' ? createForm.graceHours : undefined,
|
|
||||||
lockOnClose: createForm.lockOnClose,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleEdit = () => {
|
|
||||||
if (!editingWindow) return
|
|
||||||
if (!editForm.name || !editForm.slug) {
|
|
||||||
toast.error('Name and slug are required')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
updateWindowMutation.mutate({
|
|
||||||
id: editingWindow,
|
|
||||||
name: editForm.name,
|
|
||||||
slug: editForm.slug,
|
|
||||||
roundNumber: editForm.roundNumber,
|
|
||||||
windowOpenAt: editForm.windowOpenAt ? new Date(editForm.windowOpenAt) : null,
|
|
||||||
windowCloseAt: editForm.windowCloseAt ? new Date(editForm.windowCloseAt) : null,
|
|
||||||
deadlinePolicy: editForm.deadlinePolicy,
|
|
||||||
graceHours: editForm.deadlinePolicy === 'GRACE' ? editForm.graceHours : null,
|
|
||||||
lockOnClose: editForm.lockOnClose,
|
|
||||||
sortOrder: editForm.sortOrder,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleDelete = () => {
|
|
||||||
if (!deletingWindow) return
|
|
||||||
deleteWindowMutation.mutate({ id: deletingWindow })
|
|
||||||
}
|
|
||||||
|
|
||||||
const openEditDialog = (window: any) => {
|
|
||||||
setEditForm({
|
|
||||||
name: window.name,
|
|
||||||
slug: window.slug,
|
|
||||||
roundNumber: window.roundNumber,
|
|
||||||
windowOpenAt: window.windowOpenAt ? new Date(window.windowOpenAt).toISOString().slice(0, 16) : '',
|
|
||||||
windowCloseAt: window.windowCloseAt ? new Date(window.windowCloseAt).toISOString().slice(0, 16) : '',
|
|
||||||
deadlinePolicy: window.deadlinePolicy ?? 'HARD_DEADLINE',
|
|
||||||
graceHours: window.graceHours ?? 0,
|
|
||||||
lockOnClose: window.lockOnClose ?? true,
|
|
||||||
sortOrder: window.sortOrder ?? 1,
|
|
||||||
})
|
|
||||||
setEditingWindow(window.id)
|
|
||||||
}
|
|
||||||
|
|
||||||
const formatDate = (date: Date | null | undefined) => {
|
|
||||||
if (!date) return 'Not set'
|
|
||||||
return format(new Date(date), 'MMM d, yyyy h:mm a')
|
|
||||||
}
|
|
||||||
|
|
||||||
const windows = competition?.submissionWindows ?? []
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-4">
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
|
||||||
<div>
|
|
||||||
<CardTitle className="text-base">Submission Windows</CardTitle>
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
File upload windows for this round
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<Dialog open={isCreateOpen} onOpenChange={setIsCreateOpen}>
|
|
||||||
<DialogTrigger asChild>
|
|
||||||
<Button size="sm" variant="outline" className="w-full sm:w-auto">
|
|
||||||
<Plus className="h-4 w-4 mr-1" />
|
|
||||||
Create Window
|
|
||||||
</Button>
|
|
||||||
</DialogTrigger>
|
|
||||||
<DialogContent className="max-h-[90vh] overflow-y-auto">
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>Create Submission Window</DialogTitle>
|
|
||||||
</DialogHeader>
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="create-name">Window Name</Label>
|
|
||||||
<Input
|
|
||||||
id="create-name"
|
|
||||||
placeholder="e.g., Round 1 Submissions"
|
|
||||||
value={createForm.name}
|
|
||||||
onChange={(e) => handleCreateNameChange(e.target.value)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="create-slug">Slug</Label>
|
|
||||||
<Input
|
|
||||||
id="create-slug"
|
|
||||||
placeholder="e.g., round-1-submissions"
|
|
||||||
value={createForm.slug}
|
|
||||||
onChange={(e) => setCreateForm({ ...createForm, slug: e.target.value })}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="create-roundNumber">Round Number</Label>
|
|
||||||
<Input
|
|
||||||
id="create-roundNumber"
|
|
||||||
type="number"
|
|
||||||
min={1}
|
|
||||||
value={createForm.roundNumber}
|
|
||||||
onChange={(e) => setCreateForm({ ...createForm, roundNumber: parseInt(e.target.value, 10) || 1 })}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="create-windowOpenAt">Window Open At</Label>
|
|
||||||
<Input
|
|
||||||
id="create-windowOpenAt"
|
|
||||||
type="datetime-local"
|
|
||||||
value={createForm.windowOpenAt}
|
|
||||||
onChange={(e) => setCreateForm({ ...createForm, windowOpenAt: e.target.value })}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="create-windowCloseAt">Window Close At</Label>
|
|
||||||
<Input
|
|
||||||
id="create-windowCloseAt"
|
|
||||||
type="datetime-local"
|
|
||||||
value={createForm.windowCloseAt}
|
|
||||||
onChange={(e) => setCreateForm({ ...createForm, windowCloseAt: e.target.value })}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="create-deadlinePolicy">Deadline Policy</Label>
|
|
||||||
<Select
|
|
||||||
value={createForm.deadlinePolicy}
|
|
||||||
onValueChange={(value: 'HARD_DEADLINE' | 'FLAG' | 'GRACE') =>
|
|
||||||
setCreateForm({ ...createForm, deadlinePolicy: value })
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<SelectTrigger id="create-deadlinePolicy">
|
|
||||||
<SelectValue />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="HARD_DEADLINE">Hard Deadline</SelectItem>
|
|
||||||
<SelectItem value="FLAG">Flag Late Submissions</SelectItem>
|
|
||||||
<SelectItem value="GRACE">Grace Period</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{createForm.deadlinePolicy === 'GRACE' && (
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="create-graceHours">Grace Hours</Label>
|
|
||||||
<Input
|
|
||||||
id="create-graceHours"
|
|
||||||
type="number"
|
|
||||||
min={0}
|
|
||||||
value={createForm.graceHours}
|
|
||||||
onChange={(e) => setCreateForm({ ...createForm, graceHours: parseInt(e.target.value, 10) || 0 })}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Switch
|
|
||||||
id="create-lockOnClose"
|
|
||||||
checked={createForm.lockOnClose}
|
|
||||||
onCheckedChange={(checked) => setCreateForm({ ...createForm, lockOnClose: checked })}
|
|
||||||
/>
|
|
||||||
<Label htmlFor="create-lockOnClose" className="cursor-pointer">
|
|
||||||
Lock window on close
|
|
||||||
</Label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex gap-2 pt-4">
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
className="flex-1"
|
|
||||||
onClick={() => setIsCreateOpen(false)}
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
className="flex-1"
|
|
||||||
onClick={handleCreate}
|
|
||||||
disabled={createWindowMutation.isPending}
|
|
||||||
>
|
|
||||||
{createWindowMutation.isPending && (
|
|
||||||
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
|
||||||
)}
|
|
||||||
Create
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
</div>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
{isLoading ? (
|
|
||||||
<div className="py-8 text-center text-sm text-muted-foreground">
|
|
||||||
Loading windows...
|
|
||||||
</div>
|
|
||||||
) : windows.length === 0 ? (
|
|
||||||
<div className="py-8 text-center text-sm text-muted-foreground">
|
|
||||||
No submission windows yet. Create one to enable file uploads.
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="space-y-2">
|
|
||||||
{windows.map((window) => {
|
|
||||||
const isPending = !window.windowOpenAt
|
|
||||||
const isOpen = window.windowOpenAt && !window.windowCloseAt
|
|
||||||
const isClosed = window.windowCloseAt && !window.isLocked
|
|
||||||
const isLocked = window.isLocked
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={window.id}
|
|
||||||
className="flex flex-col gap-3 border rounded-lg p-3"
|
|
||||||
>
|
|
||||||
<div className="flex flex-col sm:flex-row sm:items-start sm:justify-between gap-3">
|
|
||||||
<div className="min-w-0 flex-1">
|
|
||||||
<div className="flex items-center gap-2 flex-wrap">
|
|
||||||
<p className="text-sm font-medium truncate">{window.name}</p>
|
|
||||||
{isPending && (
|
|
||||||
<Badge variant="secondary" className="text-[10px] bg-gray-100 text-gray-700">
|
|
||||||
Pending
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
{isOpen && (
|
|
||||||
<Badge variant="secondary" className="text-[10px] bg-emerald-100 text-emerald-700">
|
|
||||||
Open
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
{isClosed && (
|
|
||||||
<Badge variant="secondary" className="text-[10px] bg-blue-100 text-blue-700">
|
|
||||||
Closed
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
{isLocked && (
|
|
||||||
<Badge variant="secondary" className="text-[10px] bg-red-100 text-red-700">
|
|
||||||
<LockKeyhole className="h-2.5 w-2.5 mr-1" />
|
|
||||||
Locked
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
|
|
||||||
</div>
|
|
||||||
<p className="text-xs text-muted-foreground font-mono mt-0.5">{window.slug}</p>
|
|
||||||
<div className="flex flex-wrap gap-2 mt-1 text-xs text-muted-foreground">
|
|
||||||
<span>Round {window.roundNumber}</span>
|
|
||||||
<span>•</span>
|
|
||||||
<span>{window._count.fileRequirements} requirements</span>
|
|
||||||
<span>•</span>
|
|
||||||
<span>{window._count.projectFiles} files</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-wrap gap-2 mt-1 text-xs text-muted-foreground">
|
|
||||||
<span>Open: {formatDate(window.windowOpenAt)}</span>
|
|
||||||
<span>•</span>
|
|
||||||
<span>Close: {formatDate(window.windowCloseAt)}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center gap-2 shrink-0 flex-wrap">
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
variant="ghost"
|
|
||||||
onClick={() => openEditDialog(window)}
|
|
||||||
className="h-8 px-2"
|
|
||||||
>
|
|
||||||
<Pencil className="h-3 w-3" />
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
variant="ghost"
|
|
||||||
onClick={() => setDeletingWindow(window.id)}
|
|
||||||
className="h-8 px-2 text-destructive hover:text-destructive"
|
|
||||||
>
|
|
||||||
<Trash2 className="h-3 w-3" />
|
|
||||||
</Button>
|
|
||||||
{isPending && (
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
variant="outline"
|
|
||||||
onClick={() => openWindowMutation.mutate({ windowId: window.id })}
|
|
||||||
disabled={openWindowMutation.isPending}
|
|
||||||
>
|
|
||||||
{openWindowMutation.isPending ? (
|
|
||||||
<Loader2 className="h-3 w-3 mr-1 animate-spin" />
|
|
||||||
) : (
|
|
||||||
<Unlock className="h-3 w-3 mr-1" />
|
|
||||||
)}
|
|
||||||
Open
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
{isOpen && (
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
variant="outline"
|
|
||||||
onClick={() => closeWindowMutation.mutate({ windowId: window.id })}
|
|
||||||
disabled={closeWindowMutation.isPending}
|
|
||||||
>
|
|
||||||
{closeWindowMutation.isPending ? (
|
|
||||||
<Loader2 className="h-3 w-3 mr-1 animate-spin" />
|
|
||||||
) : (
|
|
||||||
<Lock className="h-3 w-3 mr-1" />
|
|
||||||
)}
|
|
||||||
Close
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
{isClosed && (
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
variant="outline"
|
|
||||||
onClick={() => lockWindowMutation.mutate({ windowId: window.id })}
|
|
||||||
disabled={lockWindowMutation.isPending}
|
|
||||||
>
|
|
||||||
{lockWindowMutation.isPending ? (
|
|
||||||
<Loader2 className="h-3 w-3 mr-1 animate-spin" />
|
|
||||||
) : (
|
|
||||||
<LockKeyhole className="h-3 w-3 mr-1" />
|
|
||||||
)}
|
|
||||||
Lock
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* Edit Dialog */}
|
|
||||||
<Dialog open={!!editingWindow} onOpenChange={(open) => !open && setEditingWindow(null)}>
|
|
||||||
<DialogContent className="max-h-[90vh] overflow-y-auto">
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>Edit Submission Window</DialogTitle>
|
|
||||||
</DialogHeader>
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="edit-name">Window Name</Label>
|
|
||||||
<Input
|
|
||||||
id="edit-name"
|
|
||||||
placeholder="e.g., Round 1 Submissions"
|
|
||||||
value={editForm.name}
|
|
||||||
onChange={(e) => handleEditNameChange(e.target.value)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="edit-slug">Slug</Label>
|
|
||||||
<Input
|
|
||||||
id="edit-slug"
|
|
||||||
placeholder="e.g., round-1-submissions"
|
|
||||||
value={editForm.slug}
|
|
||||||
onChange={(e) => setEditForm({ ...editForm, slug: e.target.value })}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="edit-roundNumber">Round Number</Label>
|
|
||||||
<Input
|
|
||||||
id="edit-roundNumber"
|
|
||||||
type="number"
|
|
||||||
min={1}
|
|
||||||
value={editForm.roundNumber}
|
|
||||||
onChange={(e) => setEditForm({ ...editForm, roundNumber: parseInt(e.target.value, 10) || 1 })}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="edit-windowOpenAt">Window Open At</Label>
|
|
||||||
<Input
|
|
||||||
id="edit-windowOpenAt"
|
|
||||||
type="datetime-local"
|
|
||||||
value={editForm.windowOpenAt}
|
|
||||||
onChange={(e) => setEditForm({ ...editForm, windowOpenAt: e.target.value })}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="edit-windowCloseAt">Window Close At</Label>
|
|
||||||
<Input
|
|
||||||
id="edit-windowCloseAt"
|
|
||||||
type="datetime-local"
|
|
||||||
value={editForm.windowCloseAt}
|
|
||||||
onChange={(e) => setEditForm({ ...editForm, windowCloseAt: e.target.value })}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="edit-deadlinePolicy">Deadline Policy</Label>
|
|
||||||
<Select
|
|
||||||
value={editForm.deadlinePolicy}
|
|
||||||
onValueChange={(value: 'HARD_DEADLINE' | 'FLAG' | 'GRACE') =>
|
|
||||||
setEditForm({ ...editForm, deadlinePolicy: value })
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<SelectTrigger id="edit-deadlinePolicy">
|
|
||||||
<SelectValue />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="HARD_DEADLINE">Hard Deadline</SelectItem>
|
|
||||||
<SelectItem value="FLAG">Flag Late Submissions</SelectItem>
|
|
||||||
<SelectItem value="GRACE">Grace Period</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{editForm.deadlinePolicy === 'GRACE' && (
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="edit-graceHours">Grace Hours</Label>
|
|
||||||
<Input
|
|
||||||
id="edit-graceHours"
|
|
||||||
type="number"
|
|
||||||
min={0}
|
|
||||||
value={editForm.graceHours}
|
|
||||||
onChange={(e) => setEditForm({ ...editForm, graceHours: parseInt(e.target.value, 10) || 0 })}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Switch
|
|
||||||
id="edit-lockOnClose"
|
|
||||||
checked={editForm.lockOnClose}
|
|
||||||
onCheckedChange={(checked) => setEditForm({ ...editForm, lockOnClose: checked })}
|
|
||||||
/>
|
|
||||||
<Label htmlFor="edit-lockOnClose" className="cursor-pointer">
|
|
||||||
Lock window on close
|
|
||||||
</Label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="edit-sortOrder">Sort Order</Label>
|
|
||||||
<Input
|
|
||||||
id="edit-sortOrder"
|
|
||||||
type="number"
|
|
||||||
min={1}
|
|
||||||
value={editForm.sortOrder}
|
|
||||||
onChange={(e) => setEditForm({ ...editForm, sortOrder: parseInt(e.target.value, 10) || 1 })}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex gap-2 pt-4">
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
className="flex-1"
|
|
||||||
onClick={() => setEditingWindow(null)}
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
className="flex-1"
|
|
||||||
onClick={handleEdit}
|
|
||||||
disabled={updateWindowMutation.isPending}
|
|
||||||
>
|
|
||||||
{updateWindowMutation.isPending && (
|
|
||||||
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
|
||||||
)}
|
|
||||||
Save Changes
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
|
|
||||||
{/* Delete Confirmation Dialog */}
|
|
||||||
<Dialog open={!!deletingWindow} onOpenChange={(open) => !open && setDeletingWindow(null)}>
|
|
||||||
<DialogContent>
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>Delete Submission Window</DialogTitle>
|
|
||||||
<DialogDescription>
|
|
||||||
Are you sure you want to delete this submission window? This action cannot be undone.
|
|
||||||
{(windows.find(w => w.id === deletingWindow)?._count?.projectFiles ?? 0) > 0 && (
|
|
||||||
<span className="block mt-2 text-destructive font-medium">
|
|
||||||
Warning: This window has uploaded files and cannot be deleted until they are removed.
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
<DialogFooter className="flex gap-2">
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
onClick={() => setDeletingWindow(null)}
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="destructive"
|
|
||||||
onClick={handleDelete}
|
|
||||||
disabled={deleteWindowMutation.isPending}
|
|
||||||
>
|
|
||||||
{deleteWindowMutation.isPending && (
|
|
||||||
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
|
||||||
)}
|
|
||||||
Delete
|
|
||||||
</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -20,31 +20,42 @@ export const BRAND_COLORS = [
|
|||||||
|
|
||||||
// Tremor named colors for chart components
|
// Tremor named colors for chart components
|
||||||
// These are the official Tremor palette names that render correctly
|
// These are the official Tremor palette names that render correctly
|
||||||
export const TREMOR_BRAND = 'cyan' as const
|
export const TREMOR_BRAND = 'blue' as const
|
||||||
export const TREMOR_ACCENT = 'teal' as const
|
export const TREMOR_ACCENT = 'indigo' as const
|
||||||
export const TREMOR_CHART_COLORS = [
|
export const TREMOR_CHART_COLORS = [
|
||||||
'cyan',
|
|
||||||
'teal',
|
|
||||||
'blue',
|
'blue',
|
||||||
'emerald',
|
'emerald',
|
||||||
'amber',
|
'amber',
|
||||||
'violet',
|
'violet',
|
||||||
'rose',
|
'rose',
|
||||||
'indigo',
|
'indigo',
|
||||||
'lime',
|
'sky',
|
||||||
'fuchsia',
|
'fuchsia',
|
||||||
|
'lime',
|
||||||
|
'orange',
|
||||||
] as const
|
] as const
|
||||||
|
|
||||||
// Donut / status chart colors (mapped to Tremor names)
|
// 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> = {
|
export const TREMOR_STATUS_COLORS: Record<string, string> = {
|
||||||
SUBMITTED: 'slate',
|
// Global project statuses
|
||||||
ELIGIBLE: 'cyan',
|
SUBMITTED: 'sky',
|
||||||
|
ELIGIBLE: 'blue',
|
||||||
ASSIGNED: 'violet',
|
ASSIGNED: 'violet',
|
||||||
SEMIFINALIST: 'amber',
|
SEMIFINALIST: 'amber',
|
||||||
FINALIST: 'emerald',
|
FINALIST: 'emerald',
|
||||||
REJECTED: 'rose',
|
REJECTED: 'rose',
|
||||||
DRAFT: 'gray',
|
DRAFT: 'gray',
|
||||||
WITHDRAWN: 'neutral',
|
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
|
// Project status colors — mapped to actual ProjectStatus enum values
|
||||||
@@ -57,18 +68,31 @@ export const STATUS_COLORS: Record<string, string> = {
|
|||||||
REJECTED: '#de0f1e', // Red
|
REJECTED: '#de0f1e', // Red
|
||||||
DRAFT: '#9ca3af', // Gray
|
DRAFT: '#9ca3af', // Gray
|
||||||
WITHDRAWN: '#6b7280', // Dark 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
|
// Human-readable status labels
|
||||||
export const STATUS_LABELS: Record<string, string> = {
|
export const STATUS_LABELS: Record<string, string> = {
|
||||||
SUBMITTED: 'Submitted',
|
SUBMITTED: 'Submitted',
|
||||||
ELIGIBLE: 'Eligible',
|
ELIGIBLE: 'In-Competition',
|
||||||
ASSIGNED: 'Special Award',
|
ASSIGNED: 'Special Award',
|
||||||
SEMIFINALIST: 'Semi-finalist',
|
SEMIFINALIST: 'Semi-finalist',
|
||||||
FINALIST: 'Finalist',
|
FINALIST: 'Finalist',
|
||||||
REJECTED: 'Rejected',
|
REJECTED: 'Rejected',
|
||||||
DRAFT: 'Draft',
|
DRAFT: 'Draft',
|
||||||
WITHDRAWN: 'Withdrawn',
|
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',
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ export function CriteriaScoresChart({ data }: CriteriaScoresProps) {
|
|||||||
data={chartData}
|
data={chartData}
|
||||||
index="criterion"
|
index="criterion"
|
||||||
categories={['Avg Score']}
|
categories={['Avg Score']}
|
||||||
colors={['teal']}
|
colors={['indigo']}
|
||||||
maxValue={10}
|
maxValue={10}
|
||||||
layout="vertical"
|
layout="vertical"
|
||||||
yAxisWidth={160}
|
yAxisWidth={160}
|
||||||
|
|||||||
@@ -56,7 +56,7 @@ export function CrossStageComparisonChart({
|
|||||||
data={baseData}
|
data={baseData}
|
||||||
index="name"
|
index="name"
|
||||||
categories={['Projects']}
|
categories={['Projects']}
|
||||||
colors={['cyan']}
|
colors={['blue']}
|
||||||
showLegend={false}
|
showLegend={false}
|
||||||
yAxisWidth={40}
|
yAxisWidth={40}
|
||||||
className="h-[200px]"
|
className="h-[200px]"
|
||||||
@@ -75,7 +75,7 @@ export function CrossStageComparisonChart({
|
|||||||
data={baseData}
|
data={baseData}
|
||||||
index="name"
|
index="name"
|
||||||
categories={['Evaluations']}
|
categories={['Evaluations']}
|
||||||
colors={['teal']}
|
colors={['violet']}
|
||||||
showLegend={false}
|
showLegend={false}
|
||||||
yAxisWidth={40}
|
yAxisWidth={40}
|
||||||
className="h-[200px]"
|
className="h-[200px]"
|
||||||
|
|||||||
@@ -1,9 +1,8 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { DonutChart, BarChart } from '@tremor/react'
|
import { BarChart } from '@tremor/react'
|
||||||
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 { TREMOR_CHART_COLORS } from './chart-theme'
|
|
||||||
|
|
||||||
interface DiversityData {
|
interface DiversityData {
|
||||||
total: number
|
total: number
|
||||||
@@ -48,76 +47,69 @@ export function DiversityMetricsChart({ data }: DiversityMetricsProps) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Top countries for donut 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 countryData = 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
|
|
||||||
|
|
||||||
const donutData = countryData.map((c) => ({
|
|
||||||
name: getCountryName(c.country),
|
|
||||||
value: c.count,
|
|
||||||
}))
|
}))
|
||||||
|
|
||||||
const categoryData = (data.byCategory || []).slice(0, 10).map((c) => ({
|
const categoryData = (data.byCategory || []).slice(0, 10).map((c) => ({
|
||||||
category: formatLabel(c.category),
|
category: formatLabel(c.category),
|
||||||
Count: c.count,
|
Projects: c.count,
|
||||||
}))
|
}))
|
||||||
|
|
||||||
const oceanIssueData = (data.byOceanIssue || []).slice(0, 15).map((o) => ({
|
const oceanIssueData = (data.byOceanIssue || []).slice(0, 15).map((o) => ({
|
||||||
issue: formatLabel(o.issue),
|
issue: formatLabel(o.issue),
|
||||||
Count: o.count,
|
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>
|
||||||
{donutData.length > 0 ? (
|
{countryBarData.length > 0 ? (
|
||||||
<DonutChart
|
<BarChart
|
||||||
data={donutData}
|
data={countryBarData}
|
||||||
category="value"
|
index="country"
|
||||||
index="name"
|
categories={['Projects']}
|
||||||
colors={[...TREMOR_CHART_COLORS]}
|
colors={['cyan']}
|
||||||
className="h-[400px]"
|
showLegend={false}
|
||||||
|
layout="horizontal"
|
||||||
|
yAxisWidth={120}
|
||||||
|
className="h-[360px]"
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<p className="text-muted-foreground text-center py-8">No geographic data</p>
|
<p className="text-muted-foreground text-center py-8">No geographic data</p>
|
||||||
@@ -125,23 +117,47 @@ export function DiversityMetricsChart({ data }: DiversityMetricsProps) {
|
|||||||
</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>
|
||||||
{categoryData.length > 0 ? (
|
{categoryData.length > 0 ? (
|
||||||
|
categoryData.length <= 4 ? (
|
||||||
|
/* Clean stacked bars for few categories */
|
||||||
|
<div className="space-y-4 pt-2">
|
||||||
|
{categoryData.map((c) => {
|
||||||
|
const maxCount = Math.max(...categoryData.map((d) => d.Projects))
|
||||||
|
const pct = maxCount > 0 ? (c.Projects / maxCount) * 100 : 0
|
||||||
|
return (
|
||||||
|
<div key={c.category} className="space-y-1.5">
|
||||||
|
<div className="flex items-center justify-between text-sm">
|
||||||
|
<span className="font-medium">{c.category}</span>
|
||||||
|
<span className="tabular-nums text-muted-foreground">{c.Projects}</span>
|
||||||
|
</div>
|
||||||
|
<div className="h-3 w-full rounded-full bg-muted overflow-hidden">
|
||||||
|
<div
|
||||||
|
className="h-full rounded-full bg-[#053d57] transition-all duration-500"
|
||||||
|
style={{ width: `${pct}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
<BarChart
|
<BarChart
|
||||||
data={categoryData}
|
data={categoryData}
|
||||||
index="category"
|
index="category"
|
||||||
categories={['Count']}
|
categories={['Projects']}
|
||||||
colors={['cyan']}
|
colors={['indigo']}
|
||||||
layout="horizontal"
|
layout="horizontal"
|
||||||
yAxisWidth={120}
|
yAxisWidth={140}
|
||||||
showLegend={false}
|
showLegend={false}
|
||||||
className="h-[400px]"
|
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>
|
||||||
)}
|
)}
|
||||||
@@ -149,45 +165,43 @@ export function DiversityMetricsChart({ data }: DiversityMetricsProps) {
|
|||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Ocean Issues */}
|
{/* Ocean Issues — horizontal bars for readability */}
|
||||||
{oceanIssueData.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>
|
||||||
<BarChart
|
<BarChart
|
||||||
data={oceanIssueData}
|
data={oceanIssueData}
|
||||||
index="issue"
|
index="issue"
|
||||||
categories={['Count']}
|
categories={['Projects']}
|
||||||
colors={['teal']}
|
colors={['blue']}
|
||||||
showLegend={false}
|
showLegend={false}
|
||||||
yAxisWidth={40}
|
layout="horizontal"
|
||||||
|
yAxisWidth={200}
|
||||||
className="h-[400px]"
|
className="h-[400px]"
|
||||||
rotateLabelX={{ angle: -35, xAxisHeight: 80 }}
|
|
||||||
/>
|
/>
|
||||||
</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>
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ export function EvaluationTimelineChart({ data }: EvaluationTimelineProps) {
|
|||||||
data={chartData}
|
data={chartData}
|
||||||
index="date"
|
index="date"
|
||||||
categories={['Cumulative', 'Daily']}
|
categories={['Cumulative', 'Daily']}
|
||||||
colors={['cyan', 'teal']}
|
colors={['indigo', 'amber']}
|
||||||
curveType="monotone"
|
curveType="monotone"
|
||||||
showGradient={true}
|
showGradient={true}
|
||||||
yAxisWidth={50}
|
yAxisWidth={50}
|
||||||
|
|||||||
@@ -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,6 +1,5 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { ScatterChart } from '@tremor/react'
|
|
||||||
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 {
|
||||||
@@ -12,6 +11,7 @@ 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
|
||||||
@@ -30,6 +30,24 @@ 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) {
|
||||||
if (!data?.jurors?.length) {
|
if (!data?.jurors?.length) {
|
||||||
return (
|
return (
|
||||||
@@ -42,27 +60,19 @@ export function JurorConsistencyChart({ data }: JurorConsistencyProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
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)
|
||||||
const scatterData = data.jurors.map((j) => ({
|
|
||||||
'Average Score': parseFloat(j.averageScore.toFixed(2)),
|
|
||||||
'Std Deviation': parseFloat(j.stddev.toFixed(2)),
|
|
||||||
category: j.isOutlier ? 'Outlier' : 'Normal',
|
|
||||||
name: j.name,
|
|
||||||
evaluations: j.evaluationCount,
|
|
||||||
size: Math.max(8, Math.min(20, j.evaluationCount * 2)),
|
|
||||||
}))
|
|
||||||
|
|
||||||
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>
|
||||||
)}
|
)}
|
||||||
@@ -70,18 +80,31 @@ export function JurorConsistencyChart({ data }: JurorConsistencyProps) {
|
|||||||
</CardTitle>
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<ScatterChart
|
<div className="space-y-2">
|
||||||
data={scatterData}
|
{sorted.map((juror) => (
|
||||||
x="Average Score"
|
<div
|
||||||
y="Std Deviation"
|
key={juror.userId}
|
||||||
category="category"
|
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'}`}
|
||||||
size="size"
|
>
|
||||||
colors={['cyan', 'rose']}
|
<div className="w-36 shrink-0 truncate">
|
||||||
className="h-[400px]"
|
<span className="text-sm font-medium">{juror.name}</span>
|
||||||
/>
|
</div>
|
||||||
<p className="text-xs text-muted-foreground mt-2 text-center">
|
<div className="flex-1">
|
||||||
Dot size represents number of evaluations. Red dots indicate outlier
|
<ScoreDot score={juror.averageScore} />
|
||||||
jurors (2+ points from mean).
|
</div>
|
||||||
|
<div className="hidden sm:flex items-center gap-3 text-xs text-muted-foreground shrink-0">
|
||||||
|
<span className="tabular-nums">σ {juror.stddev.toFixed(1)}</span>
|
||||||
|
<span className="tabular-nums">{juror.evaluationCount} eval{juror.evaluationCount !== 1 ? 's' : ''}</span>
|
||||||
|
</div>
|
||||||
|
{juror.isOutlier && (
|
||||||
|
<AlertTriangle className="h-3.5 w-3.5 text-destructive shrink-0" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{/* Overall average line */}
|
||||||
|
<p className="text-xs text-muted-foreground mt-4 text-center">
|
||||||
|
Bars show average score per juror. σ = standard deviation. Outliers deviate 2+ points from the overall mean.
|
||||||
</p>
|
</p>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -89,9 +112,11 @@ 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>
|
||||||
|
{/* Desktop table */}
|
||||||
|
<div className="hidden md:block">
|
||||||
<Table>
|
<Table>
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableRow>
|
<TableRow>
|
||||||
@@ -99,21 +124,17 @@ export function JurorConsistencyChart({ data }: JurorConsistencyProps) {
|
|||||||
<TableHead className="text-right">Evaluations</TableHead>
|
<TableHead className="text-right">Evaluations</TableHead>
|
||||||
<TableHead className="text-right">Avg Score</TableHead>
|
<TableHead className="text-right">Avg Score</TableHead>
|
||||||
<TableHead className="text-right">Std Dev</TableHead>
|
<TableHead className="text-right">Std Dev</TableHead>
|
||||||
<TableHead className="text-right">
|
<TableHead className="text-right">Deviation</TableHead>
|
||||||
Deviation from Mean
|
|
||||||
</TableHead>
|
|
||||||
<TableHead className="text-center">Status</TableHead>
|
<TableHead className="text-center">Status</TableHead>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{data.jurors.map((juror) => (
|
{sorted.map((juror) => (
|
||||||
<TableRow
|
<TableRow
|
||||||
key={juror.userId}
|
key={juror.userId}
|
||||||
className={juror.isOutlier ? 'bg-destructive/5' : ''}
|
className={juror.isOutlier ? 'bg-destructive/5' : ''}
|
||||||
>
|
>
|
||||||
<TableCell>
|
<TableCell className="font-medium">{juror.name}</TableCell>
|
||||||
<p className="font-medium">{juror.name}</p>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="text-right tabular-nums">
|
<TableCell className="text-right tabular-nums">
|
||||||
{juror.evaluationCount}
|
{juror.evaluationCount}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
@@ -124,7 +145,7 @@ export function JurorConsistencyChart({ data }: JurorConsistencyProps) {
|
|||||||
{juror.stddev.toFixed(2)}
|
{juror.stddev.toFixed(2)}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="text-right tabular-nums">
|
<TableCell className="text-right tabular-nums">
|
||||||
{juror.deviationFromOverall.toFixed(2)}
|
{juror.deviationFromOverall >= 0 ? '+' : ''}{juror.deviationFromOverall.toFixed(2)}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="text-center">
|
<TableCell className="text-center">
|
||||||
{juror.isOutlier ? (
|
{juror.isOutlier ? (
|
||||||
@@ -140,6 +161,43 @@ export function JurorConsistencyChart({ data }: JurorConsistencyProps) {
|
|||||||
))}
|
))}
|
||||||
</TableBody>
|
</TableBody>
|
||||||
</Table>
|
</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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -48,7 +48,7 @@ export function JurorWorkloadChart({ data }: JurorWorkloadProps) {
|
|||||||
data={chartData}
|
data={chartData}
|
||||||
index="juror"
|
index="juror"
|
||||||
categories={['Completed', 'Remaining']}
|
categories={['Completed', 'Remaining']}
|
||||||
colors={['cyan', 'gray']}
|
colors={['blue', 'slate']}
|
||||||
layout="horizontal"
|
layout="horizontal"
|
||||||
stack={true}
|
stack={true}
|
||||||
yAxisWidth={160}
|
yAxisWidth={160}
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ export function ProjectRankingsChart({
|
|||||||
data={chartData}
|
data={chartData}
|
||||||
index="project"
|
index="project"
|
||||||
categories={['Score']}
|
categories={['Score']}
|
||||||
colors={['teal']}
|
colors={['blue']}
|
||||||
layout="horizontal"
|
layout="horizontal"
|
||||||
yAxisWidth={200}
|
yAxisWidth={200}
|
||||||
maxValue={10}
|
maxValue={10}
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ export function ScoreDistributionChart({
|
|||||||
data={chartData}
|
data={chartData}
|
||||||
index="score"
|
index="score"
|
||||||
categories={['Count']}
|
categories={['Count']}
|
||||||
colors={['cyan']}
|
colors={['blue']}
|
||||||
yAxisWidth={40}
|
yAxisWidth={40}
|
||||||
showLegend={false}
|
showLegend={false}
|
||||||
className="h-[300px]"
|
className="h-[300px]"
|
||||||
|
|||||||
@@ -226,7 +226,8 @@ export function AdminSidebar({ user }: AdminSidebarProps) {
|
|||||||
{navigation.map((item) => {
|
{navigation.map((item) => {
|
||||||
const isActive =
|
const isActive =
|
||||||
pathname === item.href ||
|
pathname === item.href ||
|
||||||
(item.href !== '/admin' && pathname.startsWith(item.href))
|
(item.href !== '/admin' && pathname.startsWith(item.href)) ||
|
||||||
|
(item.href === '/admin/rounds' && pathname.startsWith('/admin/competitions'))
|
||||||
return (
|
return (
|
||||||
<div key={item.name}>
|
<div key={item.name}>
|
||||||
<Link
|
<Link
|
||||||
@@ -258,12 +259,24 @@ export function AdminSidebar({ user }: AdminSidebarProps) {
|
|||||||
Administration
|
Administration
|
||||||
</p>
|
</p>
|
||||||
{dynamicAdminNav.map((item) => {
|
{dynamicAdminNav.map((item) => {
|
||||||
|
const isDisabled = item.name === 'Apply Page' && !currentEdition?.id
|
||||||
let isActive = pathname.startsWith(item.href)
|
let isActive = pathname.startsWith(item.href)
|
||||||
if (item.activeMatch) {
|
if (item.activeMatch) {
|
||||||
isActive = pathname.includes(item.activeMatch)
|
isActive = pathname.includes(item.activeMatch)
|
||||||
} else if (item.activeExclude && pathname.includes(item.activeExclude)) {
|
} else if (item.activeExclude && pathname.includes(item.activeExclude)) {
|
||||||
isActive = false
|
isActive = false
|
||||||
}
|
}
|
||||||
|
if (isDisabled) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
key={item.name}
|
||||||
|
className="group flex items-center gap-3 rounded-lg px-3 py-2.5 text-sm font-medium opacity-50 pointer-events-none text-muted-foreground"
|
||||||
|
>
|
||||||
|
<item.icon className="h-4 w-4 text-muted-foreground" />
|
||||||
|
{item.name}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
return (
|
return (
|
||||||
<Link
|
<Link
|
||||||
key={item.name}
|
key={item.name}
|
||||||
|
|||||||
@@ -30,7 +30,6 @@ import {
|
|||||||
ClipboardList,
|
ClipboardList,
|
||||||
BarChart3,
|
BarChart3,
|
||||||
TrendingUp,
|
TrendingUp,
|
||||||
CheckCircle2,
|
|
||||||
Users,
|
Users,
|
||||||
Globe,
|
Globe,
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
@@ -201,79 +200,31 @@ export function ObserverDashboardContent({ userName }: { userName?: string }) {
|
|||||||
<p className="text-muted-foreground">Welcome, {userName || 'Observer'}</p>
|
<p className="text-muted-foreground">Welcome, {userName || 'Observer'}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Six Stat Tiles */}
|
{/* Stats Strip */}
|
||||||
{statsLoading ? (
|
{statsLoading ? (
|
||||||
<div className="grid grid-cols-2 gap-4 md:grid-cols-3 lg:grid-cols-6">
|
<Card className="p-4">
|
||||||
{[...Array(6)].map((_, i) => (
|
<Skeleton className="h-10 w-full" />
|
||||||
<Card key={i} className="p-4">
|
|
||||||
<Skeleton className="h-8 w-8 rounded-lg mb-3" />
|
|
||||||
<Skeleton className="h-7 w-16 mb-1" />
|
|
||||||
<Skeleton className="h-3 w-20" />
|
|
||||||
</Card>
|
</Card>
|
||||||
|
) : stats ? (
|
||||||
|
<Card className="p-0 overflow-hidden">
|
||||||
|
<div className="grid grid-cols-3 md:grid-cols-6 divide-x divide-border">
|
||||||
|
{[
|
||||||
|
{ value: stats.projectCount, label: 'Projects' },
|
||||||
|
{ value: stats.activeRoundName ?? `${stats.activeRoundCount} Active`, label: 'Active Round', isText: !!stats.activeRoundName },
|
||||||
|
{ value: avgScore, label: 'Avg Score' },
|
||||||
|
{ value: `${stats.completionRate}%`, label: 'Completion' },
|
||||||
|
{ value: stats.jurorCount, label: 'Jurors' },
|
||||||
|
{ value: countryCount, label: 'Countries' },
|
||||||
|
].map((stat) => (
|
||||||
|
<div key={stat.label} className="px-4 py-3.5 text-center">
|
||||||
|
<p className={`font-semibold leading-tight ${
|
||||||
|
'isText' in stat && stat.isText ? 'text-sm truncate' : 'text-xl tabular-nums'
|
||||||
|
}`}>{stat.value}</p>
|
||||||
|
<p className="text-[11px] text-muted-foreground mt-0.5">{stat.label}</p>
|
||||||
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
) : stats ? (
|
|
||||||
<div className="grid grid-cols-2 gap-4 md:grid-cols-3 lg:grid-cols-6">
|
|
||||||
<AnimatedCard index={0}>
|
|
||||||
<Card className="group cursor-default p-4 transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md">
|
|
||||||
<div className="mb-3 inline-flex rounded-lg bg-emerald-100 p-2">
|
|
||||||
<ClipboardList className="h-5 w-5 text-emerald-600" />
|
|
||||||
</div>
|
|
||||||
<p className="text-2xl font-bold tabular-nums">{stats.projectCount}</p>
|
|
||||||
<p className="text-xs text-muted-foreground">Total Projects</p>
|
|
||||||
</Card>
|
</Card>
|
||||||
</AnimatedCard>
|
|
||||||
|
|
||||||
<AnimatedCard index={1}>
|
|
||||||
<Card className="group cursor-default p-4 transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md">
|
|
||||||
<div className="mb-3 inline-flex rounded-lg bg-blue-100 p-2">
|
|
||||||
<BarChart3 className="h-5 w-5 text-blue-600" />
|
|
||||||
</div>
|
|
||||||
<p className="text-2xl font-bold tabular-nums">{stats.activeRoundCount}</p>
|
|
||||||
<p className="text-xs text-muted-foreground">Active Rounds</p>
|
|
||||||
</Card>
|
|
||||||
</AnimatedCard>
|
|
||||||
|
|
||||||
<AnimatedCard index={2}>
|
|
||||||
<Card className="group cursor-default p-4 transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md">
|
|
||||||
<div className="mb-3 inline-flex rounded-lg bg-amber-100 p-2">
|
|
||||||
<TrendingUp className="h-5 w-5 text-amber-600" />
|
|
||||||
</div>
|
|
||||||
<p className="text-2xl font-bold tabular-nums">{avgScore}</p>
|
|
||||||
<p className="text-xs text-muted-foreground">Avg Score</p>
|
|
||||||
</Card>
|
|
||||||
</AnimatedCard>
|
|
||||||
|
|
||||||
<AnimatedCard index={3}>
|
|
||||||
<Card className="group cursor-default p-4 transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md">
|
|
||||||
<div className="mb-3 inline-flex rounded-lg bg-teal-100 p-2">
|
|
||||||
<CheckCircle2 className="h-5 w-5 text-teal-600" />
|
|
||||||
</div>
|
|
||||||
<p className="text-2xl font-bold tabular-nums">{stats.completionRate}%</p>
|
|
||||||
<p className="text-xs text-muted-foreground">Completion</p>
|
|
||||||
</Card>
|
|
||||||
</AnimatedCard>
|
|
||||||
|
|
||||||
<AnimatedCard index={4}>
|
|
||||||
<Card className="group cursor-default p-4 transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md">
|
|
||||||
<div className="mb-3 inline-flex rounded-lg bg-violet-100 p-2">
|
|
||||||
<Users className="h-5 w-5 text-violet-600" />
|
|
||||||
</div>
|
|
||||||
<p className="text-2xl font-bold tabular-nums">{stats.jurorCount}</p>
|
|
||||||
<p className="text-xs text-muted-foreground">Active Jurors</p>
|
|
||||||
</Card>
|
|
||||||
</AnimatedCard>
|
|
||||||
|
|
||||||
<AnimatedCard index={5}>
|
|
||||||
<Card className="group cursor-default p-4 transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md">
|
|
||||||
<div className="mb-3 inline-flex rounded-lg bg-rose-100 p-2">
|
|
||||||
<Globe className="h-5 w-5 text-rose-600" />
|
|
||||||
</div>
|
|
||||||
<p className="text-2xl font-bold tabular-nums">{countryCount}</p>
|
|
||||||
<p className="text-xs text-muted-foreground">Countries</p>
|
|
||||||
</Card>
|
|
||||||
</AnimatedCard>
|
|
||||||
</div>
|
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{/* Pipeline */}
|
{/* Pipeline */}
|
||||||
|
|||||||
303
src/components/observer/reports/deliberation-report-tabs.tsx
Normal file
303
src/components/observer/reports/deliberation-report-tabs.tsx
Normal file
@@ -0,0 +1,303 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { trpc } from '@/lib/trpc/client'
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card'
|
||||||
|
import { Badge } from '@/components/ui/badge'
|
||||||
|
import { Skeleton } from '@/components/ui/skeleton'
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from '@/components/ui/table'
|
||||||
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||||
|
import { Users, Trophy } from 'lucide-react'
|
||||||
|
import { RoundTypeStatsCards } from '@/components/observer/round-type-stats'
|
||||||
|
|
||||||
|
interface DeliberationReportTabsProps {
|
||||||
|
roundId: string
|
||||||
|
programId: string
|
||||||
|
}
|
||||||
|
|
||||||
|
function sessionStatusBadge(status: string) {
|
||||||
|
switch (status) {
|
||||||
|
case 'DELIB_LOCKED':
|
||||||
|
return <Badge variant="default">Locked</Badge>
|
||||||
|
case 'VOTING':
|
||||||
|
return <Badge variant="secondary">Voting</Badge>
|
||||||
|
case 'TALLYING':
|
||||||
|
return <Badge className="bg-amber-100 text-amber-800 border-amber-200">Tallying</Badge>
|
||||||
|
case 'RUNOFF':
|
||||||
|
return <Badge className="bg-rose-100 text-rose-800 border-rose-200">Runoff</Badge>
|
||||||
|
case 'DELIB_OPEN':
|
||||||
|
return <Badge variant="outline">Open</Badge>
|
||||||
|
default:
|
||||||
|
return <Badge variant="outline">{status}</Badge>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function sessionModeBadge(mode: string) {
|
||||||
|
return <Badge variant="outline">{mode.charAt(0) + mode.slice(1).toLowerCase()}</Badge>
|
||||||
|
}
|
||||||
|
|
||||||
|
function SessionsTab({ roundId }: { roundId: string }) {
|
||||||
|
const { data: sessions, isLoading } =
|
||||||
|
trpc.analytics.getDeliberationSessions.useQuery({ roundId })
|
||||||
|
|
||||||
|
if (isLoading) return <Skeleton className="h-[350px]" />
|
||||||
|
|
||||||
|
if (!sessions?.length) {
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
|
||||||
|
<Users className="h-10 w-10 text-muted-foreground/40 mb-3" />
|
||||||
|
<p className="text-sm font-medium text-muted-foreground">No deliberation sessions yet</p>
|
||||||
|
<p className="text-xs text-muted-foreground mt-1">
|
||||||
|
Sessions will appear here once created by administrators
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* Desktop table */}
|
||||||
|
<div className="hidden md:block rounded-md border">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>Category</TableHead>
|
||||||
|
<TableHead>Mode</TableHead>
|
||||||
|
<TableHead>Status</TableHead>
|
||||||
|
<TableHead className="text-right tabular-nums">Participants</TableHead>
|
||||||
|
<TableHead className="text-right tabular-nums">Votes Cast</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{sessions.map((session) => (
|
||||||
|
<TableRow key={session.id}>
|
||||||
|
<TableCell className="font-medium">
|
||||||
|
{session.category ?? <span className="text-muted-foreground italic">General</span>}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>{sessionModeBadge(session.mode)}</TableCell>
|
||||||
|
<TableCell>{sessionStatusBadge(session.status)}</TableCell>
|
||||||
|
<TableCell className="text-right tabular-nums">{session._count.participants}</TableCell>
|
||||||
|
<TableCell className="text-right tabular-nums">{session._count.votes}</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Mobile card stack */}
|
||||||
|
<div className="space-y-3 md:hidden">
|
||||||
|
{sessions.map((session) => (
|
||||||
|
<Card key={session.id}>
|
||||||
|
<CardContent className="pt-4 space-y-3">
|
||||||
|
<div className="flex items-start justify-between gap-2">
|
||||||
|
<p className="font-medium text-sm">
|
||||||
|
{session.category ?? <span className="italic text-muted-foreground">General</span>}
|
||||||
|
</p>
|
||||||
|
{sessionStatusBadge(session.status)}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{sessionModeBadge(session.mode)}
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-2 text-xs">
|
||||||
|
<div>
|
||||||
|
<p className="text-muted-foreground">Participants</p>
|
||||||
|
<p className="font-medium tabular-nums">{session._count.participants}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-muted-foreground">Votes Cast</p>
|
||||||
|
<p className="font-medium tabular-nums">{session._count.votes}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ResultsTab({ roundId }: { roundId: string }) {
|
||||||
|
const [selectedSessionId, setSelectedSessionId] = useState<string | null>(null)
|
||||||
|
|
||||||
|
const { data: sessions, isLoading: sessionsLoading } =
|
||||||
|
trpc.analytics.getDeliberationSessions.useQuery({ roundId })
|
||||||
|
|
||||||
|
const activeSessions = sessions?.filter((s) => s._count.votes > 0) ?? []
|
||||||
|
const activeSessionId = selectedSessionId ?? activeSessions[0]?.id ?? null
|
||||||
|
|
||||||
|
const { data: aggregate, isLoading: aggregateLoading } =
|
||||||
|
trpc.analytics.getDeliberationAggregate.useQuery(
|
||||||
|
{ sessionId: activeSessionId! },
|
||||||
|
{ enabled: !!activeSessionId }
|
||||||
|
)
|
||||||
|
|
||||||
|
if (sessionsLoading) return <Skeleton className="h-[400px]" />
|
||||||
|
|
||||||
|
if (!activeSessions.length) {
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardContent className="flex items-center justify-center py-12">
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
No votes have been cast yet. Results will appear once deliberation is underway.
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentSessionId = selectedSessionId ?? activeSessions[0]?.id
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Session selector if multiple */}
|
||||||
|
{activeSessions.length > 1 && (
|
||||||
|
<div className="flex items-center gap-3 flex-wrap">
|
||||||
|
{activeSessions.map((s) => (
|
||||||
|
<button
|
||||||
|
key={s.id}
|
||||||
|
onClick={() => setSelectedSessionId(s.id)}
|
||||||
|
className={
|
||||||
|
currentSessionId === s.id
|
||||||
|
? 'rounded-full px-3 py-1 text-sm font-medium bg-primary text-primary-foreground'
|
||||||
|
: 'rounded-full px-3 py-1 text-sm font-medium bg-muted text-muted-foreground hover:bg-muted/80 transition-colors'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{s.category ?? 'General'}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{aggregateLoading ? (
|
||||||
|
<Skeleton className="h-[300px]" />
|
||||||
|
) : aggregate ? (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2 text-base">
|
||||||
|
<Trophy className="h-4 w-4 text-amber-500" />
|
||||||
|
Ranking Results
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
{aggregate.rankings.length} project{aggregate.rankings.length !== 1 ? 's' : ''} ranked
|
||||||
|
{aggregate.hasTies && (
|
||||||
|
<span className="ml-1 text-amber-600 font-medium">· Ties detected</span>
|
||||||
|
)}
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{/* Desktop table */}
|
||||||
|
<div className="hidden md:block rounded-md border">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead className="w-12 text-center">Rank</TableHead>
|
||||||
|
<TableHead>Project</TableHead>
|
||||||
|
<TableHead>Team</TableHead>
|
||||||
|
<TableHead className="text-right tabular-nums">Score</TableHead>
|
||||||
|
<TableHead className="text-right tabular-nums">Votes</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{aggregate.rankings.map((r, idx) => {
|
||||||
|
const isTied = aggregate.tiedProjectIds.includes(r.projectId)
|
||||||
|
return (
|
||||||
|
<TableRow key={r.projectId} className={isTied ? 'bg-amber-50/50' : undefined}>
|
||||||
|
<TableCell className="text-center font-bold tabular-nums text-lg">
|
||||||
|
{idx + 1}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="font-medium">{r.projectTitle}</span>
|
||||||
|
{isTied && (
|
||||||
|
<Badge className="bg-amber-100 text-amber-800 border-amber-200 text-[10px]">
|
||||||
|
Tie
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-muted-foreground text-sm">{r.teamName}</TableCell>
|
||||||
|
<TableCell className="text-right tabular-nums">
|
||||||
|
{typeof r.score === 'number' ? r.score : '—'}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right tabular-nums">{r.voteCount}</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Mobile list */}
|
||||||
|
<div className="space-y-2 md:hidden">
|
||||||
|
{aggregate.rankings.map((r, idx) => {
|
||||||
|
const isTied = aggregate.tiedProjectIds.includes(r.projectId)
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={r.projectId}
|
||||||
|
className={`flex items-center gap-3 rounded-md p-3 border${isTied ? ' bg-amber-50/50' : ''}`}
|
||||||
|
>
|
||||||
|
<span className="text-2xl font-bold tabular-nums w-8 text-center shrink-0">
|
||||||
|
{idx + 1}
|
||||||
|
</span>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
|
<p className="font-medium text-sm truncate">{r.projectTitle}</p>
|
||||||
|
{isTied && (
|
||||||
|
<Badge className="bg-amber-100 text-amber-800 border-amber-200 text-[10px]">
|
||||||
|
Tie
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground">{r.teamName}</p>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground tabular-nums shrink-0">
|
||||||
|
{r.voteCount} votes
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DeliberationReportTabs({ roundId }: DeliberationReportTabsProps) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<RoundTypeStatsCards roundId={roundId} />
|
||||||
|
|
||||||
|
<Tabs defaultValue="sessions" className="space-y-6">
|
||||||
|
<TabsList>
|
||||||
|
<TabsTrigger value="sessions" className="gap-2">
|
||||||
|
<Users className="h-4 w-4" />
|
||||||
|
Sessions
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="results" className="gap-2">
|
||||||
|
<Trophy className="h-4 w-4" />
|
||||||
|
Results
|
||||||
|
</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
|
||||||
|
<TabsContent value="sessions">
|
||||||
|
<SessionsTab roundId={roundId} />
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="results">
|
||||||
|
<ResultsTab roundId={roundId} />
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
790
src/components/observer/reports/evaluation-report-tabs.tsx
Normal file
790
src/components/observer/reports/evaluation-report-tabs.tsx
Normal file
@@ -0,0 +1,790 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState, useCallback } from 'react'
|
||||||
|
import { trpc } from '@/lib/trpc/client'
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||||
|
import { Badge } from '@/components/ui/badge'
|
||||||
|
import { Progress } from '@/components/ui/progress'
|
||||||
|
import { Skeleton } from '@/components/ui/skeleton'
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from '@/components/ui/table'
|
||||||
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import {
|
||||||
|
FileSpreadsheet,
|
||||||
|
BarChart3,
|
||||||
|
Users,
|
||||||
|
TrendingUp,
|
||||||
|
Download,
|
||||||
|
Clock,
|
||||||
|
ClipboardCheck,
|
||||||
|
} from 'lucide-react'
|
||||||
|
import { formatDateOnly } from '@/lib/utils'
|
||||||
|
import {
|
||||||
|
ScoreDistributionChart,
|
||||||
|
EvaluationTimelineChart,
|
||||||
|
StatusBreakdownChart,
|
||||||
|
CriteriaScoresChart,
|
||||||
|
JurorConsistencyChart,
|
||||||
|
JurorScoreHeatmap,
|
||||||
|
} from '@/components/charts'
|
||||||
|
import { BarChart } from '@tremor/react'
|
||||||
|
import { CsvExportDialog } from '@/components/shared/csv-export-dialog'
|
||||||
|
import { ExportPdfButton } from '@/components/shared/export-pdf-button'
|
||||||
|
import { AnimatedCard } from '@/components/shared/animated-container'
|
||||||
|
import { RoundTypeStatsCards } from '@/components/observer/round-type-stats'
|
||||||
|
import { ExpandableJurorTable } from './expandable-juror-table'
|
||||||
|
|
||||||
|
const ROUND_TYPE_LABELS: Record<string, string> = {
|
||||||
|
INTAKE: 'Intake',
|
||||||
|
FILTERING: 'Filtering',
|
||||||
|
EVALUATION: 'Evaluation',
|
||||||
|
SUBMISSION: 'Submission',
|
||||||
|
MENTORING: 'Mentoring',
|
||||||
|
LIVE_FINAL: 'Live Final',
|
||||||
|
DELIBERATION: 'Deliberation',
|
||||||
|
}
|
||||||
|
|
||||||
|
type Stage = {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
status: string
|
||||||
|
roundType: string
|
||||||
|
windowCloseAt: Date | null
|
||||||
|
_count: { projects: number; assignments: number; evaluations: number }
|
||||||
|
programId: string
|
||||||
|
programName: string
|
||||||
|
}
|
||||||
|
|
||||||
|
function roundStatusLabel(status: string): string {
|
||||||
|
if (status === 'ROUND_ACTIVE') return 'Active'
|
||||||
|
if (status === 'ROUND_CLOSED') return 'Closed'
|
||||||
|
if (status === 'ROUND_DRAFT') return 'Draft'
|
||||||
|
if (status === 'ROUND_ARCHIVED') return 'Archived'
|
||||||
|
return status
|
||||||
|
}
|
||||||
|
|
||||||
|
function roundStatusVariant(status: string): 'default' | 'secondary' | 'outline' {
|
||||||
|
if (status === 'ROUND_ACTIVE') return 'default'
|
||||||
|
if (status === 'ROUND_CLOSED') return 'secondary'
|
||||||
|
return 'outline'
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseSelection(value: string | null): { roundId?: string; programId?: string } {
|
||||||
|
if (!value) return {}
|
||||||
|
if (value.startsWith('all:')) return { programId: value.slice(4) }
|
||||||
|
return { roundId: value }
|
||||||
|
}
|
||||||
|
|
||||||
|
interface EvaluationReportTabsProps {
|
||||||
|
roundId: string
|
||||||
|
programId: string
|
||||||
|
stages: Stage[]
|
||||||
|
selectedValue: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Progress sub-tab ----
|
||||||
|
|
||||||
|
function ProgressSubTab({
|
||||||
|
selectedValue,
|
||||||
|
stages,
|
||||||
|
stagesLoading,
|
||||||
|
selectedRound,
|
||||||
|
}: {
|
||||||
|
selectedValue: string | null
|
||||||
|
stages: Stage[]
|
||||||
|
stagesLoading: boolean
|
||||||
|
selectedRound: Stage | undefined
|
||||||
|
}) {
|
||||||
|
const queryInput = parseSelection(selectedValue)
|
||||||
|
const hasSelection = !!queryInput.roundId || !!queryInput.programId
|
||||||
|
|
||||||
|
const { data: overviewStats, isLoading: statsLoading } =
|
||||||
|
trpc.analytics.getOverviewStats.useQuery(queryInput, { enabled: hasSelection })
|
||||||
|
|
||||||
|
const { data: timeline, isLoading: timelineLoading } =
|
||||||
|
trpc.analytics.getEvaluationTimeline.useQuery(queryInput, { enabled: hasSelection })
|
||||||
|
|
||||||
|
const [csvOpen, setCsvOpen] = useState(false)
|
||||||
|
const [csvData, setCsvData] = useState<{ data: Record<string, unknown>[]; columns: string[] } | undefined>()
|
||||||
|
const [csvLoading, setCsvLoading] = useState(false)
|
||||||
|
|
||||||
|
const handleRequestCsvData = useCallback(async () => {
|
||||||
|
setCsvLoading(true)
|
||||||
|
const columns = ['roundName', 'roundType', 'status', 'projects', 'assignments', 'completionRate']
|
||||||
|
const data = stages.map((s) => {
|
||||||
|
const assigned = s._count.assignments
|
||||||
|
const projects = s._count.projects
|
||||||
|
const rate = assigned > 0 && projects > 0 ? Math.round((assigned / projects) * 100) : 0
|
||||||
|
return {
|
||||||
|
roundName: s.name,
|
||||||
|
roundType: ROUND_TYPE_LABELS[s.roundType] || s.roundType,
|
||||||
|
status: roundStatusLabel(s.status),
|
||||||
|
projects,
|
||||||
|
assignments: assigned,
|
||||||
|
completionRate: rate,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
const result = { data, columns }
|
||||||
|
setCsvData(result)
|
||||||
|
setCsvLoading(false)
|
||||||
|
return result
|
||||||
|
}, [stages])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex items-center justify-between flex-wrap gap-3">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-base font-semibold">Progress Overview</h2>
|
||||||
|
<p className="text-sm text-muted-foreground">Evaluation progress across rounds</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{selectedValue && !selectedValue.startsWith('all:') && (
|
||||||
|
<ExportPdfButton
|
||||||
|
roundId={selectedValue}
|
||||||
|
roundName={selectedRound?.name}
|
||||||
|
programName={selectedRound?.programName}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setCsvOpen(true)}
|
||||||
|
disabled={stagesLoading || stages.length === 0}
|
||||||
|
>
|
||||||
|
<Download className="mr-2 h-4 w-4" />
|
||||||
|
Export CSV
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<CsvExportDialog
|
||||||
|
open={csvOpen}
|
||||||
|
onOpenChange={setCsvOpen}
|
||||||
|
exportData={csvData}
|
||||||
|
isLoading={csvLoading}
|
||||||
|
filename="round-progress"
|
||||||
|
onRequestData={handleRequestCsvData}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Stats tiles */}
|
||||||
|
{hasSelection && (
|
||||||
|
<>
|
||||||
|
{statsLoading ? (
|
||||||
|
<div className="grid gap-4 sm:grid-cols-3">
|
||||||
|
{[...Array(3)].map((_, i) => (
|
||||||
|
<Card key={i}>
|
||||||
|
<CardHeader className="space-y-0 pb-2">
|
||||||
|
<Skeleton className="h-4 w-20" />
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<Skeleton className="h-8 w-16" />
|
||||||
|
<Skeleton className="mt-2 h-3 w-24" />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : overviewStats ? (
|
||||||
|
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||||
|
<AnimatedCard index={0}>
|
||||||
|
<Card className="border-l-4 border-l-blue-500 transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md">
|
||||||
|
<CardContent className="p-5">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-muted-foreground">Projects</p>
|
||||||
|
<p className="text-2xl font-bold mt-1">{overviewStats.projectCount}</p>
|
||||||
|
<p className="text-xs text-muted-foreground mt-1">In round</p>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-xl bg-blue-50 p-3">
|
||||||
|
<FileSpreadsheet className="h-5 w-5 text-blue-600" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</AnimatedCard>
|
||||||
|
|
||||||
|
<AnimatedCard index={1}>
|
||||||
|
<Card className="border-l-4 border-l-teal-500 transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md">
|
||||||
|
<CardContent className="p-5">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-muted-foreground">Assignments</p>
|
||||||
|
<p className="text-2xl font-bold mt-1">{overviewStats.assignmentCount}</p>
|
||||||
|
<p className="text-xs text-muted-foreground mt-1">
|
||||||
|
{overviewStats.projectCount > 0
|
||||||
|
? `${(overviewStats.assignmentCount / overviewStats.projectCount).toFixed(1)} reviews/project`
|
||||||
|
: 'No projects'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-xl bg-teal-50 p-3">
|
||||||
|
<ClipboardCheck className="h-5 w-5 text-teal-600" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</AnimatedCard>
|
||||||
|
|
||||||
|
<AnimatedCard index={2}>
|
||||||
|
<Card className="border-l-4 border-l-emerald-500 transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md">
|
||||||
|
<CardContent className="p-5">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-muted-foreground">Evaluations</p>
|
||||||
|
<p className="text-2xl font-bold mt-1">{overviewStats.evaluationCount}</p>
|
||||||
|
<p className="text-xs text-muted-foreground mt-1">
|
||||||
|
{overviewStats.assignmentCount > 0
|
||||||
|
? `${overviewStats.evaluationCount}/${overviewStats.assignmentCount} submitted`
|
||||||
|
: 'Submitted'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-xl bg-emerald-50 p-3">
|
||||||
|
<TrendingUp className="h-5 w-5 text-emerald-600" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</AnimatedCard>
|
||||||
|
|
||||||
|
<AnimatedCard index={3}>
|
||||||
|
<Card className="border-l-4 border-l-violet-500 transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md">
|
||||||
|
<CardContent className="p-5">
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-muted-foreground">Completion</p>
|
||||||
|
<p className="text-2xl font-bold mt-1">{overviewStats.completionRate}%</p>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-xl bg-violet-50 p-3">
|
||||||
|
<BarChart3 className="h-5 w-5 text-violet-600" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Progress value={overviewStats.completionRate} className="mt-3 h-2" gradient />
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</AnimatedCard>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Completion Timeline */}
|
||||||
|
{hasSelection && (
|
||||||
|
<>
|
||||||
|
{timelineLoading ? (
|
||||||
|
<Skeleton className="h-[320px]" />
|
||||||
|
) : timeline?.length ? (
|
||||||
|
<EvaluationTimelineChart data={timeline} />
|
||||||
|
) : (
|
||||||
|
<Card>
|
||||||
|
<CardContent className="flex items-center justify-center py-12">
|
||||||
|
<p className="text-muted-foreground text-sm">No evaluation timeline data available yet</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Round Breakdown Table - Desktop */}
|
||||||
|
{stagesLoading ? (
|
||||||
|
<Skeleton className="h-[300px]" />
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Card className="hidden md:block">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Round Breakdown</CardTitle>
|
||||||
|
<CardDescription>Progress overview for each round</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>Round</TableHead>
|
||||||
|
<TableHead>Type</TableHead>
|
||||||
|
<TableHead>Status</TableHead>
|
||||||
|
<TableHead className="text-right">Projects</TableHead>
|
||||||
|
<TableHead className="text-right">Assignments</TableHead>
|
||||||
|
<TableHead className="min-w-[140px]">Completion</TableHead>
|
||||||
|
<TableHead className="text-right">Avg Days</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{stages.map((stage) => {
|
||||||
|
const projects = stage._count.projects
|
||||||
|
const assignments = stage._count.assignments
|
||||||
|
const evaluations = stage._count.evaluations
|
||||||
|
const isClosed = stage.status === 'ROUND_CLOSED' || stage.status === 'ROUND_ARCHIVED'
|
||||||
|
const rate = isClosed
|
||||||
|
? 100
|
||||||
|
: assignments > 0
|
||||||
|
? Math.min(100, Math.round((evaluations / assignments) * 100))
|
||||||
|
: 0
|
||||||
|
return (
|
||||||
|
<TableRow key={stage.id}>
|
||||||
|
<TableCell>
|
||||||
|
<div>
|
||||||
|
<p className="font-medium">{stage.name}</p>
|
||||||
|
{stage.windowCloseAt && (
|
||||||
|
<p className="text-xs text-muted-foreground flex items-center gap-1 mt-0.5">
|
||||||
|
<Clock className="h-3 w-3" />
|
||||||
|
{formatDateOnly(stage.windowCloseAt)}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Badge variant="outline">
|
||||||
|
{ROUND_TYPE_LABELS[stage.roundType] || stage.roundType}
|
||||||
|
</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Badge variant={roundStatusVariant(stage.status)}>
|
||||||
|
{roundStatusLabel(stage.status)}
|
||||||
|
</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right">{projects}</TableCell>
|
||||||
|
<TableCell className="text-right">{assignments}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Progress value={rate} className="h-2 w-20" />
|
||||||
|
<span className="text-sm tabular-nums">{rate}%</span>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right text-muted-foreground">-</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Round Breakdown Cards - Mobile */}
|
||||||
|
<div className="space-y-3 md:hidden">
|
||||||
|
<h2 className="text-base font-semibold">Round Breakdown</h2>
|
||||||
|
{stages.map((stage) => {
|
||||||
|
const projects = stage._count.projects
|
||||||
|
const assignments = stage._count.assignments
|
||||||
|
const evaluations = stage._count.evaluations
|
||||||
|
const isClosed = stage.status === 'ROUND_CLOSED' || stage.status === 'ROUND_ARCHIVED'
|
||||||
|
const rate = isClosed
|
||||||
|
? 100
|
||||||
|
: assignments > 0
|
||||||
|
? Math.min(100, Math.round((evaluations / assignments) * 100))
|
||||||
|
: 0
|
||||||
|
return (
|
||||||
|
<Card key={stage.id}>
|
||||||
|
<CardContent className="pt-4 space-y-3">
|
||||||
|
<div className="flex items-start justify-between gap-2">
|
||||||
|
<p className="font-medium leading-tight">{stage.name}</p>
|
||||||
|
<Badge variant={roundStatusVariant(stage.status)} className="shrink-0">
|
||||||
|
{roundStatusLabel(stage.status)}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Badge variant="outline">
|
||||||
|
{ROUND_TYPE_LABELS[stage.roundType] || stage.roundType}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-2 text-sm">
|
||||||
|
<div>
|
||||||
|
<p className="text-muted-foreground text-xs">Projects</p>
|
||||||
|
<p className="font-medium">{projects}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-muted-foreground text-xs">Assignments</p>
|
||||||
|
<p className="font-medium">{assignments}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center justify-between text-sm mb-1">
|
||||||
|
<span className="text-muted-foreground text-xs">Completion</span>
|
||||||
|
<span className="font-medium">{rate}%</span>
|
||||||
|
</div>
|
||||||
|
<Progress value={rate} className="h-2" />
|
||||||
|
</div>
|
||||||
|
{stage.windowCloseAt && (
|
||||||
|
<p className="text-xs text-muted-foreground flex items-center gap-1">
|
||||||
|
<Clock className="h-3 w-3" />
|
||||||
|
Closes: {formatDateOnly(stage.windowCloseAt)}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Jurors sub-tab ----
|
||||||
|
|
||||||
|
function JurorsSubTab({ roundId, selectedValue }: { roundId: string; selectedValue: string | null }) {
|
||||||
|
const queryInput = parseSelection(selectedValue)
|
||||||
|
const hasSelection = !!queryInput.roundId || !!queryInput.programId
|
||||||
|
|
||||||
|
const { data: workload, isLoading: workloadLoading } =
|
||||||
|
trpc.analytics.getJurorWorkload.useQuery(queryInput, { enabled: hasSelection })
|
||||||
|
|
||||||
|
const { data: consistency, isLoading: consistencyLoading } =
|
||||||
|
trpc.analytics.getJurorConsistency.useQuery(queryInput, { enabled: hasSelection })
|
||||||
|
|
||||||
|
const { data: heatmapData, isLoading: heatmapLoading } =
|
||||||
|
trpc.analytics.getJurorScoreMatrix.useQuery({ roundId }, { enabled: !!roundId })
|
||||||
|
|
||||||
|
const [csvOpen, setCsvOpen] = useState(false)
|
||||||
|
const [csvData, setCsvData] = useState<{ data: Record<string, unknown>[]; columns: string[] } | undefined>()
|
||||||
|
const [csvLoading, setCsvLoading] = useState(false)
|
||||||
|
|
||||||
|
type WorkloadItem = { id: string; name: string; assigned: number; completed: number; completionRate: number; projects: { id: string; title: string; evalStatus: string }[] }
|
||||||
|
type ConsistencyJuror = { userId: string; name: string; evaluationCount: number; averageScore: number; stddev: number; isOutlier: boolean }
|
||||||
|
|
||||||
|
const handleRequestCsvData = useCallback(async () => {
|
||||||
|
setCsvLoading(true)
|
||||||
|
const columns = ['name', 'assigned', 'completed', 'completionRate', 'avgScore', 'stddev', 'isOutlier']
|
||||||
|
|
||||||
|
const workloadMap = new Map<string, WorkloadItem>()
|
||||||
|
if (workload) {
|
||||||
|
for (const w of (workload as unknown as WorkloadItem[])) {
|
||||||
|
workloadMap.set(w.id, w)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const jurors = (consistency as { overallAverage: number; jurors: ConsistencyJuror[] } | undefined)?.jurors ?? []
|
||||||
|
const data = jurors.map((j) => {
|
||||||
|
const w = workloadMap.get(j.userId)
|
||||||
|
return {
|
||||||
|
name: j.name,
|
||||||
|
assigned: w?.assigned ?? '-',
|
||||||
|
completed: w?.completed ?? '-',
|
||||||
|
completionRate: w ? `${w.completionRate}%` : '-',
|
||||||
|
avgScore: j.averageScore,
|
||||||
|
stddev: j.stddev,
|
||||||
|
isOutlier: j.isOutlier ? 'Yes' : 'No',
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const result = { data, columns }
|
||||||
|
setCsvData(result)
|
||||||
|
setCsvLoading(false)
|
||||||
|
return result
|
||||||
|
}, [workload, consistency])
|
||||||
|
|
||||||
|
const isLoading = workloadLoading || consistencyLoading
|
||||||
|
|
||||||
|
type JurorRow = {
|
||||||
|
userId: string
|
||||||
|
name: string
|
||||||
|
assigned: number
|
||||||
|
completed: number
|
||||||
|
completionRate: number
|
||||||
|
averageScore: number
|
||||||
|
stddev: number
|
||||||
|
isOutlier: boolean
|
||||||
|
projects: { id: string; title: string; evalStatus: string }[]
|
||||||
|
}
|
||||||
|
|
||||||
|
const jurors: JurorRow[] = (() => {
|
||||||
|
if (!consistency) return []
|
||||||
|
const workloadMap = new Map<string, WorkloadItem>()
|
||||||
|
if (workload) {
|
||||||
|
for (const w of (workload as unknown as WorkloadItem[])) {
|
||||||
|
workloadMap.set(w.id, w)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const jurorList = (consistency as { overallAverage: number; jurors: ConsistencyJuror[] }).jurors ?? []
|
||||||
|
return jurorList
|
||||||
|
.map((j) => {
|
||||||
|
const w = workloadMap.get(j.userId)
|
||||||
|
return {
|
||||||
|
userId: j.userId,
|
||||||
|
name: j.name,
|
||||||
|
assigned: w?.assigned ?? 0,
|
||||||
|
completed: w?.completed ?? 0,
|
||||||
|
completionRate: w?.completionRate ?? 0,
|
||||||
|
averageScore: j.averageScore,
|
||||||
|
stddev: j.stddev,
|
||||||
|
isOutlier: j.isOutlier,
|
||||||
|
projects: w?.projects ?? [],
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.sort((a, b) => b.assigned - a.assigned)
|
||||||
|
})()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex items-center justify-between flex-wrap gap-3">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-base font-semibold">Juror Performance</h2>
|
||||||
|
<p className="text-sm text-muted-foreground">Workload and scoring consistency per juror</p>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setCsvOpen(true)}
|
||||||
|
disabled={!hasSelection || isLoading}
|
||||||
|
>
|
||||||
|
<Download className="mr-2 h-4 w-4" />
|
||||||
|
Export CSV
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<CsvExportDialog
|
||||||
|
open={csvOpen}
|
||||||
|
onOpenChange={setCsvOpen}
|
||||||
|
exportData={csvData}
|
||||||
|
isLoading={csvLoading}
|
||||||
|
filename="juror-performance"
|
||||||
|
onRequestData={handleRequestCsvData}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Expandable Juror Table */}
|
||||||
|
{isLoading ? (
|
||||||
|
<Skeleton className="h-[400px]" />
|
||||||
|
) : jurors.length > 0 ? (
|
||||||
|
<ExpandableJurorTable jurors={jurors} />
|
||||||
|
) : hasSelection ? (
|
||||||
|
<Card>
|
||||||
|
<CardContent className="flex items-center justify-center py-12">
|
||||||
|
<p className="text-sm text-muted-foreground">No juror data available for this selection</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{/* Juror Score Heatmap */}
|
||||||
|
{heatmapLoading ? (
|
||||||
|
<Skeleton className="h-[400px]" />
|
||||||
|
) : heatmapData ? (
|
||||||
|
<JurorScoreHeatmap
|
||||||
|
jurors={heatmapData.jurors}
|
||||||
|
projects={heatmapData.projects}
|
||||||
|
cells={heatmapData.cells}
|
||||||
|
truncated={heatmapData.truncated}
|
||||||
|
totalProjects={heatmapData.totalProjects}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{/* Juror Consistency Chart */}
|
||||||
|
{consistencyLoading ? (
|
||||||
|
<Skeleton className="h-[400px]" />
|
||||||
|
) : consistency ? (
|
||||||
|
<JurorConsistencyChart
|
||||||
|
data={consistency as { overallAverage: number; jurors: Array<{ userId: string; name: string; evaluationCount: number; averageScore: number; stddev: number; deviationFromOverall: number; isOutlier: boolean }> }}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Scores sub-tab ----
|
||||||
|
|
||||||
|
function ScoresSubTab({ selectedValue, programId }: { selectedValue: string | null; programId: string }) {
|
||||||
|
const queryInput = parseSelection(selectedValue)
|
||||||
|
const hasSelection = !!queryInput.roundId || !!queryInput.programId
|
||||||
|
|
||||||
|
const { data: scoreDistribution, isLoading: scoreLoading } =
|
||||||
|
trpc.analytics.getScoreDistribution.useQuery(queryInput, { enabled: hasSelection })
|
||||||
|
|
||||||
|
const { data: statusBreakdown, isLoading: statusLoading } =
|
||||||
|
trpc.analytics.getStatusBreakdown.useQuery(queryInput, { enabled: hasSelection })
|
||||||
|
|
||||||
|
const { data: criteriaScores, isLoading: criteriaLoading } =
|
||||||
|
trpc.analytics.getCriteriaScores.useQuery(queryInput, { enabled: hasSelection })
|
||||||
|
|
||||||
|
const geoProgramId = queryInput.programId || programId
|
||||||
|
const { data: geoData, isLoading: geoLoading } =
|
||||||
|
trpc.analytics.getGeographicDistribution.useQuery(
|
||||||
|
{ programId: geoProgramId, roundId: queryInput.roundId },
|
||||||
|
{ enabled: !!geoProgramId }
|
||||||
|
)
|
||||||
|
|
||||||
|
const [csvOpen, setCsvOpen] = useState(false)
|
||||||
|
const [csvData, setCsvData] = useState<{ data: Record<string, unknown>[]; columns: string[] } | undefined>()
|
||||||
|
const [csvLoading, setCsvLoading] = useState(false)
|
||||||
|
|
||||||
|
type CriterionItem = { criterionName: string; averageScore: number; count: number }
|
||||||
|
|
||||||
|
const handleRequestCsvData = useCallback(async () => {
|
||||||
|
setCsvLoading(true)
|
||||||
|
const columns = ['criterionName', 'averageScore', 'count']
|
||||||
|
const data = ((criteriaScores as CriterionItem[] | undefined) ?? []).map((c) => ({
|
||||||
|
criterionName: c.criterionName,
|
||||||
|
averageScore: c.averageScore,
|
||||||
|
count: c.count,
|
||||||
|
}))
|
||||||
|
const result = { data, columns }
|
||||||
|
setCsvData(result)
|
||||||
|
setCsvLoading(false)
|
||||||
|
return result
|
||||||
|
}, [criteriaScores])
|
||||||
|
|
||||||
|
const countryChartData = (() => {
|
||||||
|
if (!geoData?.length) return []
|
||||||
|
const sorted = [...geoData].sort((a, b) => b.count - a.count)
|
||||||
|
return sorted.slice(0, 15).map((d) => {
|
||||||
|
let name = d.countryCode
|
||||||
|
try {
|
||||||
|
const displayNames = new Intl.DisplayNames(['en'], { type: 'region' })
|
||||||
|
name = displayNames.of(d.countryCode.toUpperCase()) || d.countryCode
|
||||||
|
} catch { /* keep code */ }
|
||||||
|
return { country: name, Projects: d.count }
|
||||||
|
})
|
||||||
|
})()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex items-center justify-between flex-wrap gap-3">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-base font-semibold">Scores & Analytics</h2>
|
||||||
|
<p className="text-sm text-muted-foreground">Score distributions, criteria breakdown and geographic data</p>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setCsvOpen(true)}
|
||||||
|
disabled={!hasSelection || criteriaLoading}
|
||||||
|
>
|
||||||
|
<Download className="mr-2 h-4 w-4" />
|
||||||
|
Export CSV
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<CsvExportDialog
|
||||||
|
open={csvOpen}
|
||||||
|
onOpenChange={setCsvOpen}
|
||||||
|
exportData={csvData}
|
||||||
|
isLoading={csvLoading}
|
||||||
|
filename="scores-criteria"
|
||||||
|
onRequestData={handleRequestCsvData}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Score Distribution & Status Breakdown */}
|
||||||
|
<div className="grid gap-6 lg:grid-cols-2">
|
||||||
|
{scoreLoading ? (
|
||||||
|
<Skeleton className="h-[350px]" />
|
||||||
|
) : scoreDistribution ? (
|
||||||
|
<ScoreDistributionChart
|
||||||
|
data={scoreDistribution.distribution ?? []}
|
||||||
|
averageScore={scoreDistribution.averageScore ?? 0}
|
||||||
|
totalScores={scoreDistribution.totalScores ?? 0}
|
||||||
|
/>
|
||||||
|
) : hasSelection ? (
|
||||||
|
<Card>
|
||||||
|
<CardContent className="flex items-center justify-center py-12">
|
||||||
|
<p className="text-sm text-muted-foreground">No score data available yet</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{statusLoading ? (
|
||||||
|
<Skeleton className="h-[350px]" />
|
||||||
|
) : statusBreakdown ? (
|
||||||
|
<StatusBreakdownChart data={statusBreakdown} />
|
||||||
|
) : hasSelection ? (
|
||||||
|
<Card>
|
||||||
|
<CardContent className="flex items-center justify-center py-12">
|
||||||
|
<p className="text-sm text-muted-foreground">No status data available yet</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Criteria Breakdown */}
|
||||||
|
{criteriaLoading ? (
|
||||||
|
<Skeleton className="h-[350px]" />
|
||||||
|
) : criteriaScores?.length ? (
|
||||||
|
<CriteriaScoresChart data={criteriaScores} />
|
||||||
|
) : hasSelection ? (
|
||||||
|
<Card>
|
||||||
|
<CardContent className="flex items-center justify-center py-12">
|
||||||
|
<p className="text-sm text-muted-foreground">No criteria score data available yet</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{/* Country Distribution */}
|
||||||
|
{geoLoading ? (
|
||||||
|
<Skeleton className="h-[400px]" />
|
||||||
|
) : countryChartData.length > 0 ? (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center justify-between">
|
||||||
|
<span>Top Countries</span>
|
||||||
|
<span className="text-sm font-normal text-muted-foreground">
|
||||||
|
{geoData?.length ?? 0} countries represented
|
||||||
|
</span>
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<BarChart
|
||||||
|
data={countryChartData}
|
||||||
|
index="country"
|
||||||
|
categories={['Projects']}
|
||||||
|
colors={['blue']}
|
||||||
|
layout="vertical"
|
||||||
|
yAxisWidth={140}
|
||||||
|
showLegend={false}
|
||||||
|
className="h-[400px]"
|
||||||
|
/>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Main component ----
|
||||||
|
|
||||||
|
export function EvaluationReportTabs({ roundId, programId, stages, selectedValue }: EvaluationReportTabsProps) {
|
||||||
|
const selectedRound = stages.find((s) => s.id === selectedValue)
|
||||||
|
const stagesLoading = false // stages passed from parent already loaded
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<RoundTypeStatsCards roundId={roundId} />
|
||||||
|
|
||||||
|
<Tabs defaultValue="progress" className="space-y-6">
|
||||||
|
<TabsList>
|
||||||
|
<TabsTrigger value="progress" className="gap-2">
|
||||||
|
<TrendingUp className="h-4 w-4" />
|
||||||
|
Progress
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="jurors" className="gap-2">
|
||||||
|
<Users className="h-4 w-4" />
|
||||||
|
Jurors
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="scores" className="gap-2">
|
||||||
|
<BarChart3 className="h-4 w-4" />
|
||||||
|
Scores
|
||||||
|
</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
|
||||||
|
<TabsContent value="progress">
|
||||||
|
<ProgressSubTab
|
||||||
|
selectedValue={selectedValue}
|
||||||
|
stages={stages}
|
||||||
|
stagesLoading={stagesLoading}
|
||||||
|
selectedRound={selectedRound}
|
||||||
|
/>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="jurors">
|
||||||
|
<JurorsSubTab roundId={roundId} selectedValue={selectedValue} />
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="scores">
|
||||||
|
<ScoresSubTab selectedValue={selectedValue} programId={programId} />
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
269
src/components/observer/reports/expandable-juror-table.tsx
Normal file
269
src/components/observer/reports/expandable-juror-table.tsx
Normal file
@@ -0,0 +1,269 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { Card, CardContent } from '@/components/ui/card'
|
||||||
|
import { Badge } from '@/components/ui/badge'
|
||||||
|
import { Progress } from '@/components/ui/progress'
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from '@/components/ui/table'
|
||||||
|
import { ChevronDown, ChevronUp } from 'lucide-react'
|
||||||
|
import { scoreGradient } from '@/components/charts/chart-theme'
|
||||||
|
import { ProjectPreviewDialog } from './project-preview-dialog'
|
||||||
|
|
||||||
|
interface JurorRow {
|
||||||
|
userId: string
|
||||||
|
name: string
|
||||||
|
assigned: number
|
||||||
|
completed: number
|
||||||
|
completionRate: number
|
||||||
|
averageScore: number
|
||||||
|
stddev: number
|
||||||
|
isOutlier: boolean
|
||||||
|
projects: { id: string; title: string; evalStatus: string; score?: number | null }[]
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ExpandableJurorTableProps {
|
||||||
|
jurors: JurorRow[]
|
||||||
|
}
|
||||||
|
|
||||||
|
function evalStatusBadge(status: string) {
|
||||||
|
switch (status) {
|
||||||
|
case 'REVIEWED':
|
||||||
|
return <Badge variant="default">Reviewed</Badge>
|
||||||
|
case 'UNDER_REVIEW':
|
||||||
|
return <Badge variant="secondary">Under Review</Badge>
|
||||||
|
default:
|
||||||
|
return <Badge variant="outline">Not Reviewed</Badge>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function ScorePill({ score }: { score: number }) {
|
||||||
|
const bg = scoreGradient(score)
|
||||||
|
const text = score >= 6 ? '#ffffff' : '#1a1a1a'
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className="inline-flex items-center justify-center rounded-md px-2 py-0.5 text-xs font-semibold tabular-nums min-w-[36px]"
|
||||||
|
style={{ backgroundColor: bg, color: text }}
|
||||||
|
>
|
||||||
|
{score.toFixed(1)}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ExpandableJurorTable({ jurors }: ExpandableJurorTableProps) {
|
||||||
|
const [expanded, setExpanded] = useState<string | null>(null)
|
||||||
|
const [previewProjectId, setPreviewProjectId] = useState<string | null>(null)
|
||||||
|
|
||||||
|
function toggle(userId: string) {
|
||||||
|
setExpanded((prev) => (prev === userId ? null : userId))
|
||||||
|
}
|
||||||
|
|
||||||
|
function openPreview(projectId: string, e: React.MouseEvent) {
|
||||||
|
e.stopPropagation()
|
||||||
|
setPreviewProjectId(projectId)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (jurors.length === 0) {
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardContent className="flex items-center justify-center py-12">
|
||||||
|
<p className="text-sm text-muted-foreground">No juror data available</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* Desktop table */}
|
||||||
|
<div className="hidden md:block rounded-md border">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>Juror</TableHead>
|
||||||
|
<TableHead className="text-right tabular-nums">Assigned</TableHead>
|
||||||
|
<TableHead className="text-right tabular-nums">Completed</TableHead>
|
||||||
|
<TableHead>Rate</TableHead>
|
||||||
|
<TableHead className="text-right tabular-nums">Avg Score</TableHead>
|
||||||
|
<TableHead className="text-right tabular-nums">Std Dev</TableHead>
|
||||||
|
<TableHead>Status</TableHead>
|
||||||
|
<TableHead className="w-8" />
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{jurors.map((j) => (
|
||||||
|
<>
|
||||||
|
<TableRow
|
||||||
|
key={j.userId}
|
||||||
|
className="cursor-pointer hover:bg-muted/50"
|
||||||
|
onClick={() => toggle(j.userId)}
|
||||||
|
>
|
||||||
|
<TableCell className="font-medium">{j.name}</TableCell>
|
||||||
|
<TableCell className="text-right tabular-nums">{j.assigned}</TableCell>
|
||||||
|
<TableCell className="text-right tabular-nums">{j.completed}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Progress value={j.completionRate} className="w-20 h-2" />
|
||||||
|
<span className="text-xs tabular-nums text-muted-foreground">
|
||||||
|
{j.completionRate.toFixed(0)}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right tabular-nums">
|
||||||
|
{j.averageScore > 0 ? j.averageScore.toFixed(2) : '—'}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right tabular-nums">
|
||||||
|
{j.stddev > 0 ? j.stddev.toFixed(2) : '—'}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{j.isOutlier ? (
|
||||||
|
<Badge variant="destructive">Outlier</Badge>
|
||||||
|
) : (
|
||||||
|
<Badge variant="outline">Normal</Badge>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{expanded === j.userId ? (
|
||||||
|
<ChevronUp className="h-4 w-4 text-muted-foreground" />
|
||||||
|
) : (
|
||||||
|
<ChevronDown className="h-4 w-4 text-muted-foreground" />
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
{expanded === j.userId && (
|
||||||
|
<TableRow key={`${j.userId}-expanded`}>
|
||||||
|
<TableCell colSpan={8} className="bg-muted/30 p-0">
|
||||||
|
<div className="px-6 py-3">
|
||||||
|
{j.projects.length === 0 ? (
|
||||||
|
<p className="text-sm text-muted-foreground">No projects</p>
|
||||||
|
) : (
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="text-left text-xs text-muted-foreground border-b">
|
||||||
|
<th className="pb-2 font-medium">Project</th>
|
||||||
|
<th className="pb-2 font-medium text-center">Score</th>
|
||||||
|
<th className="pb-2 font-medium text-right">Status</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{j.projects.map((p) => (
|
||||||
|
<tr key={p.id} className="border-b last:border-0">
|
||||||
|
<td className="py-2 pr-4">
|
||||||
|
<button
|
||||||
|
className="text-primary hover:underline text-left"
|
||||||
|
onClick={(e) => openPreview(p.id, e)}
|
||||||
|
>
|
||||||
|
{p.title}
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
<td className="py-2 text-center">
|
||||||
|
{p.score != null ? (
|
||||||
|
<ScorePill score={p.score} />
|
||||||
|
) : (
|
||||||
|
<span className="text-xs text-muted-foreground">—</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="py-2 text-right">{evalStatusBadge(p.evalStatus)}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Mobile card stack */}
|
||||||
|
<div className="space-y-3 md:hidden">
|
||||||
|
{jurors.map((j) => (
|
||||||
|
<Card key={j.userId}>
|
||||||
|
<CardContent className="p-4">
|
||||||
|
<button
|
||||||
|
className="w-full text-left"
|
||||||
|
onClick={() => toggle(j.userId)}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="font-medium">{j.name}</p>
|
||||||
|
<p className="text-xs text-muted-foreground mt-0.5">
|
||||||
|
{j.completed}/{j.assigned} completed
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{j.isOutlier && <Badge variant="destructive">Outlier</Badge>}
|
||||||
|
{expanded === j.userId ? (
|
||||||
|
<ChevronUp className="h-4 w-4 text-muted-foreground" />
|
||||||
|
) : (
|
||||||
|
<ChevronDown className="h-4 w-4 text-muted-foreground" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 flex items-center gap-2">
|
||||||
|
<Progress value={j.completionRate} className="flex-1 h-1.5" />
|
||||||
|
<span className="text-xs tabular-nums text-muted-foreground">
|
||||||
|
{j.completionRate.toFixed(0)}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 grid grid-cols-2 gap-2 text-xs">
|
||||||
|
<div>
|
||||||
|
<span className="text-muted-foreground">Avg Score: </span>
|
||||||
|
<span className="tabular-nums font-medium">
|
||||||
|
{j.averageScore > 0 ? j.averageScore.toFixed(2) : '—'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="text-muted-foreground">Std Dev: </span>
|
||||||
|
<span className="tabular-nums font-medium">
|
||||||
|
{j.stddev > 0 ? j.stddev.toFixed(2) : '—'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{expanded === j.userId && j.projects.length > 0 && (
|
||||||
|
<div className="mt-3 pt-3 border-t space-y-2">
|
||||||
|
{j.projects.map((p) => (
|
||||||
|
<div key={p.id} className="flex items-center justify-between gap-2">
|
||||||
|
<button
|
||||||
|
className="text-sm text-primary hover:underline truncate text-left"
|
||||||
|
onClick={(e) => openPreview(p.id, e)}
|
||||||
|
>
|
||||||
|
{p.title}
|
||||||
|
</button>
|
||||||
|
<div className="flex items-center gap-2 shrink-0">
|
||||||
|
{p.score != null && <ScorePill score={p.score} />}
|
||||||
|
{evalStatusBadge(p.evalStatus)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{expanded === j.userId && j.projects.length === 0 && (
|
||||||
|
<p className="mt-3 pt-3 border-t text-sm text-muted-foreground">No projects</p>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Project Preview Dialog */}
|
||||||
|
<ProjectPreviewDialog
|
||||||
|
projectId={previewProjectId}
|
||||||
|
open={!!previewProjectId}
|
||||||
|
onOpenChange={(open) => { if (!open) setPreviewProjectId(null) }}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
329
src/components/observer/reports/filtering-report-tabs.tsx
Normal file
329
src/components/observer/reports/filtering-report-tabs.tsx
Normal file
@@ -0,0 +1,329 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { trpc } from '@/lib/trpc/client'
|
||||||
|
import { Card, CardContent } from '@/components/ui/card'
|
||||||
|
import { Skeleton } from '@/components/ui/skeleton'
|
||||||
|
import { Badge } from '@/components/ui/badge'
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from '@/components/ui/table'
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from '@/components/ui/select'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { ChevronLeft, ChevronRight, ChevronDown, ChevronUp } from 'lucide-react'
|
||||||
|
import { RoundTypeStatsCards } from '@/components/observer/round-type-stats'
|
||||||
|
import { FilteringScreeningBar } from './filtering-screening-bar'
|
||||||
|
import { ProjectPreviewDialog } from './project-preview-dialog'
|
||||||
|
|
||||||
|
interface FilteringReportTabsProps {
|
||||||
|
roundId: string
|
||||||
|
programId: string
|
||||||
|
}
|
||||||
|
|
||||||
|
type OutcomeFilter = 'ALL' | 'PASSED' | 'FILTERED_OUT' | 'FLAGGED'
|
||||||
|
|
||||||
|
function outcomeBadge(outcome: string) {
|
||||||
|
switch (outcome) {
|
||||||
|
case 'PASSED':
|
||||||
|
return <Badge className="bg-emerald-100 text-emerald-800 border-emerald-200">Passed</Badge>
|
||||||
|
case 'FILTERED_OUT':
|
||||||
|
return <Badge className="bg-rose-100 text-rose-800 border-rose-200">Filtered Out</Badge>
|
||||||
|
case 'FLAGGED':
|
||||||
|
return <Badge className="bg-amber-100 text-amber-800 border-amber-200">Flagged</Badge>
|
||||||
|
default:
|
||||||
|
return <Badge variant="outline">{outcome}</Badge>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Extract reasoning text from aiScreeningJson */
|
||||||
|
function extractReasoning(aiScreeningJson: unknown): string | null {
|
||||||
|
if (!aiScreeningJson || typeof aiScreeningJson !== 'object' || Array.isArray(aiScreeningJson)) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
const obj = aiScreeningJson as Record<string, unknown>
|
||||||
|
// Direct reasoning field
|
||||||
|
if (typeof obj.reasoning === 'string') return obj.reasoning
|
||||||
|
// Nested under rule ID: { [ruleId]: { reasoning, confidence, ... } }
|
||||||
|
for (const key of Object.keys(obj)) {
|
||||||
|
const inner = obj[key]
|
||||||
|
if (inner && typeof inner === 'object' && !Array.isArray(inner)) {
|
||||||
|
const innerObj = inner as Record<string, unknown>
|
||||||
|
if (typeof innerObj.reasoning === 'string') return innerObj.reasoning
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FilteringReportTabs({ roundId }: FilteringReportTabsProps) {
|
||||||
|
const [outcomeFilter, setOutcomeFilter] = useState<OutcomeFilter>('ALL')
|
||||||
|
const [page, setPage] = useState(1)
|
||||||
|
const [expandedId, setExpandedId] = useState<string | null>(null)
|
||||||
|
const [previewProjectId, setPreviewProjectId] = useState<string | null>(null)
|
||||||
|
const perPage = 20
|
||||||
|
|
||||||
|
const { data, isLoading } = trpc.analytics.getFilteringResults.useQuery({
|
||||||
|
roundId,
|
||||||
|
outcome: outcomeFilter === 'ALL' ? undefined : outcomeFilter,
|
||||||
|
page,
|
||||||
|
perPage,
|
||||||
|
})
|
||||||
|
|
||||||
|
function handleOutcomeChange(value: string) {
|
||||||
|
setOutcomeFilter(value as OutcomeFilter)
|
||||||
|
setPage(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleExpand(id: string) {
|
||||||
|
setExpandedId((prev) => (prev === id ? null : id))
|
||||||
|
}
|
||||||
|
|
||||||
|
function openPreview(projectId: string, e: React.MouseEvent) {
|
||||||
|
e.stopPropagation()
|
||||||
|
setPreviewProjectId(projectId)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<RoundTypeStatsCards roundId={roundId} />
|
||||||
|
<FilteringScreeningBar roundId={roundId} />
|
||||||
|
|
||||||
|
{/* Filter + count */}
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Select value={outcomeFilter} onValueChange={handleOutcomeChange}>
|
||||||
|
<SelectTrigger className="w-[180px]">
|
||||||
|
<SelectValue placeholder="All Outcomes" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="ALL">All Outcomes</SelectItem>
|
||||||
|
<SelectItem value="PASSED">Passed</SelectItem>
|
||||||
|
<SelectItem value="FILTERED_OUT">Filtered Out</SelectItem>
|
||||||
|
<SelectItem value="FLAGGED">Flagged</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
{data && (
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{data.total} project{data.total !== 1 ? 's' : ''}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isLoading ? (
|
||||||
|
<Skeleton className="h-[400px]" />
|
||||||
|
) : data?.results.length ? (
|
||||||
|
<>
|
||||||
|
{/* Desktop table */}
|
||||||
|
<div className="hidden md:block rounded-md border">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead className="w-8" />
|
||||||
|
<TableHead>Project</TableHead>
|
||||||
|
<TableHead>Team</TableHead>
|
||||||
|
<TableHead>Category</TableHead>
|
||||||
|
<TableHead>Country</TableHead>
|
||||||
|
<TableHead>Outcome</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{data.results.map((r) => {
|
||||||
|
const effectiveOutcome = r.finalOutcome ?? r.outcome
|
||||||
|
const reasoning = extractReasoning(r.aiScreeningJson)
|
||||||
|
const isExpanded = expandedId === r.id
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<TableRow
|
||||||
|
key={r.id}
|
||||||
|
className="cursor-pointer hover:bg-muted/50"
|
||||||
|
onClick={() => toggleExpand(r.id)}
|
||||||
|
>
|
||||||
|
<TableCell className="w-8 pr-0">
|
||||||
|
{isExpanded ? (
|
||||||
|
<ChevronUp className="h-4 w-4 text-muted-foreground" />
|
||||||
|
) : (
|
||||||
|
<ChevronDown className="h-4 w-4 text-muted-foreground" />
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<button
|
||||||
|
className="font-medium text-primary hover:underline text-left"
|
||||||
|
onClick={(e) => openPreview(r.project.id, e)}
|
||||||
|
>
|
||||||
|
{r.project.title}
|
||||||
|
</button>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-muted-foreground">{r.project.teamName}</TableCell>
|
||||||
|
<TableCell className="text-muted-foreground">
|
||||||
|
{r.project.competitionCategory ?? '—'}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-muted-foreground">
|
||||||
|
{r.project.country ?? '—'}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>{outcomeBadge(effectiveOutcome)}</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
{isExpanded && (
|
||||||
|
<TableRow key={`${r.id}-detail`}>
|
||||||
|
<TableCell colSpan={6} className="bg-muted/30 p-0">
|
||||||
|
<div className="px-6 py-4 space-y-2">
|
||||||
|
{reasoning ? (
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-medium text-muted-foreground mb-1">AI Reasoning</p>
|
||||||
|
<p className="text-sm whitespace-pre-wrap leading-relaxed">{reasoning}</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-muted-foreground italic">No AI reasoning available</p>
|
||||||
|
)}
|
||||||
|
{r.overrideReason && (
|
||||||
|
<div className="mt-2">
|
||||||
|
<p className="text-xs font-medium text-amber-700 mb-1">Override Reason</p>
|
||||||
|
<p className="text-sm rounded-md bg-amber-50 border border-amber-200 p-2">
|
||||||
|
{r.overrideReason}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{r.project.awardEligibilities.length > 0 && (
|
||||||
|
<div className="mt-2">
|
||||||
|
<p className="text-xs font-medium text-muted-foreground mb-1">Award Routing</p>
|
||||||
|
<div className="flex gap-1.5">
|
||||||
|
{r.project.awardEligibilities.map((ae, i) => (
|
||||||
|
<Badge key={i} variant="secondary">{ae.award.name}</Badge>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Mobile card stack */}
|
||||||
|
<div className="space-y-3 md:hidden">
|
||||||
|
{data.results.map((r) => {
|
||||||
|
const effectiveOutcome = r.finalOutcome ?? r.outcome
|
||||||
|
const reasoning = extractReasoning(r.aiScreeningJson)
|
||||||
|
const isExpanded = expandedId === r.id
|
||||||
|
return (
|
||||||
|
<Card key={r.id}>
|
||||||
|
<CardContent className="p-4">
|
||||||
|
<button
|
||||||
|
className="w-full text-left"
|
||||||
|
onClick={() => toggleExpand(r.id)}
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between gap-2">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<button
|
||||||
|
className="font-medium text-sm text-primary hover:underline text-left truncate block max-w-full"
|
||||||
|
onClick={(e) => openPreview(r.project.id, e)}
|
||||||
|
>
|
||||||
|
{r.project.title}
|
||||||
|
</button>
|
||||||
|
<p className="text-xs text-muted-foreground mt-0.5">{r.project.teamName}</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 shrink-0">
|
||||||
|
{outcomeBadge(effectiveOutcome)}
|
||||||
|
{isExpanded ? (
|
||||||
|
<ChevronUp className="h-4 w-4 text-muted-foreground" />
|
||||||
|
) : (
|
||||||
|
<ChevronDown className="h-4 w-4 text-muted-foreground" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-3 text-xs text-muted-foreground mt-1">
|
||||||
|
{r.project.competitionCategory && <span>{r.project.competitionCategory}</span>}
|
||||||
|
{r.project.country && <span>{r.project.country}</span>}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{isExpanded && (
|
||||||
|
<div className="mt-3 pt-3 border-t space-y-2">
|
||||||
|
{reasoning ? (
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-medium text-muted-foreground mb-1">AI Reasoning</p>
|
||||||
|
<p className="text-sm whitespace-pre-wrap leading-relaxed">{reasoning}</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-muted-foreground italic">No AI reasoning available</p>
|
||||||
|
)}
|
||||||
|
{r.overrideReason && (
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-medium text-amber-700 mb-1">Override Reason</p>
|
||||||
|
<p className="text-sm rounded-md bg-amber-50 border border-amber-200 p-2">
|
||||||
|
{r.overrideReason}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Pagination */}
|
||||||
|
{data.totalPages > 1 && (
|
||||||
|
<div className="flex items-center justify-center gap-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
||||||
|
disabled={page === 1}
|
||||||
|
>
|
||||||
|
<ChevronLeft className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
{Array.from({ length: Math.min(data.totalPages, 7) }, (_, i) => {
|
||||||
|
const pageNum = i + 1
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
key={pageNum}
|
||||||
|
variant={page === pageNum ? 'default' : 'outline'}
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setPage(pageNum)}
|
||||||
|
>
|
||||||
|
{pageNum}
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setPage((p) => Math.min(data.totalPages, p + 1))}
|
||||||
|
disabled={page === data.totalPages}
|
||||||
|
>
|
||||||
|
<ChevronRight className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<Card>
|
||||||
|
<CardContent className="flex items-center justify-center py-12">
|
||||||
|
<p className="text-sm text-muted-foreground">No filtering results found</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<ProjectPreviewDialog
|
||||||
|
projectId={previewProjectId}
|
||||||
|
open={!!previewProjectId}
|
||||||
|
onOpenChange={(open) => { if (!open) setPreviewProjectId(null) }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
115
src/components/observer/reports/filtering-screening-bar.tsx
Normal file
115
src/components/observer/reports/filtering-screening-bar.tsx
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { trpc } from '@/lib/trpc/client'
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||||
|
import { Skeleton } from '@/components/ui/skeleton'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
const SEGMENTS = [
|
||||||
|
{ key: 'passed' as const, label: 'Passed', color: '#2d8659', bg: '#2d865915' },
|
||||||
|
{ key: 'filteredOut' as const, label: 'Filtered Out', color: '#de0f1e', bg: '#de0f1e15' },
|
||||||
|
{ key: 'flagged' as const, label: 'Flagged', color: '#d97706', bg: '#d9770615' },
|
||||||
|
]
|
||||||
|
|
||||||
|
interface FilteringScreeningBarProps {
|
||||||
|
roundId: string
|
||||||
|
className?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FilteringScreeningBar({ roundId, className }: FilteringScreeningBarProps) {
|
||||||
|
const { data, isLoading } = trpc.analytics.getFilteringResultStats.useQuery(
|
||||||
|
{ roundId },
|
||||||
|
{ enabled: !!roundId }
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className={cn(className)}>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-base">Screening Results</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
{isLoading ? (
|
||||||
|
<>
|
||||||
|
<Skeleton className="h-5 w-full rounded-full" />
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<Skeleton className="h-8 w-28 rounded-lg" />
|
||||||
|
<Skeleton className="h-8 w-32 rounded-lg" />
|
||||||
|
<Skeleton className="h-8 w-24 rounded-lg" />
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : !data || data.total === 0 ? (
|
||||||
|
<p className="text-sm text-muted-foreground">No screening data available.</p>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{/* Segmented bar */}
|
||||||
|
<div className="flex h-5 w-full overflow-hidden rounded-full bg-muted">
|
||||||
|
{SEGMENTS.map(({ key, color }) => {
|
||||||
|
const pct = (data[key] / data.total) * 100
|
||||||
|
if (pct === 0) return null
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={key}
|
||||||
|
title={`${data[key]} (${Math.round(pct)}%)`}
|
||||||
|
style={{ width: `${pct}%`, backgroundColor: color, minWidth: '4px' }}
|
||||||
|
className="transition-all duration-500 first:rounded-l-full last:rounded-r-full"
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Stat pills */}
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{SEGMENTS.map(({ key, label, color, bg }) => {
|
||||||
|
const count = data[key]
|
||||||
|
const pct = Math.round((count / data.total) * 100)
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={key}
|
||||||
|
className="flex items-center gap-1.5 rounded-lg px-3 py-1.5 text-sm font-medium"
|
||||||
|
style={{ backgroundColor: bg, color }}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className="inline-block h-2 w-2 shrink-0 rounded-full"
|
||||||
|
style={{ backgroundColor: color }}
|
||||||
|
/>
|
||||||
|
<span>{label}</span>
|
||||||
|
<span className="tabular-nums font-bold">{count}</span>
|
||||||
|
<span className="font-normal opacity-70">({pct}%)</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
|
||||||
|
{/* Total */}
|
||||||
|
<div className="flex items-center gap-1.5 rounded-lg bg-muted px-3 py-1.5 text-sm font-medium text-muted-foreground">
|
||||||
|
<span>Total</span>
|
||||||
|
<span className="tabular-nums font-bold text-foreground">{data.total}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Overridden — only if any */}
|
||||||
|
{data.overridden > 0 && (
|
||||||
|
<div
|
||||||
|
className="flex items-center gap-1.5 rounded-lg px-3 py-1.5 text-sm font-medium"
|
||||||
|
style={{ backgroundColor: '#7c3aed15', color: '#7c3aed' }}
|
||||||
|
>
|
||||||
|
<span>Overridden</span>
|
||||||
|
<span className="tabular-nums font-bold">{data.overridden}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Routed to awards — only if any */}
|
||||||
|
{data.routedToAwards > 0 && (
|
||||||
|
<div
|
||||||
|
className="flex items-center gap-1.5 rounded-lg px-3 py-1.5 text-sm font-medium"
|
||||||
|
style={{ backgroundColor: '#053d5715', color: '#053d57' }}
|
||||||
|
>
|
||||||
|
<span>Routed to Awards</span>
|
||||||
|
<span className="tabular-nums font-bold">{data.routedToAwards}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
69
src/components/observer/reports/global-analytics-tab.tsx
Normal file
69
src/components/observer/reports/global-analytics-tab.tsx
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { trpc } from '@/lib/trpc/client'
|
||||||
|
import { Skeleton } from '@/components/ui/skeleton'
|
||||||
|
import {
|
||||||
|
GeographicDistribution,
|
||||||
|
StatusBreakdownChart,
|
||||||
|
DiversityMetricsChart,
|
||||||
|
CrossStageComparisonChart,
|
||||||
|
} from '@/components/charts'
|
||||||
|
|
||||||
|
interface GlobalAnalyticsTabProps {
|
||||||
|
programId: string
|
||||||
|
roundIds?: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export function GlobalAnalyticsTab({ programId, roundIds }: GlobalAnalyticsTabProps) {
|
||||||
|
const { data: geoData, isLoading: geoLoading } =
|
||||||
|
trpc.analytics.getGeographicDistribution.useQuery({ programId })
|
||||||
|
|
||||||
|
const { data: diversity, isLoading: diversityLoading } =
|
||||||
|
trpc.analytics.getDiversityMetrics.useQuery({ programId })
|
||||||
|
|
||||||
|
const { data: statusBreakdown, isLoading: statusLoading } =
|
||||||
|
trpc.analytics.getStatusBreakdown.useQuery({ programId })
|
||||||
|
|
||||||
|
const { data: crossRound, isLoading: crossLoading } =
|
||||||
|
trpc.analytics.getCrossRoundComparison.useQuery(
|
||||||
|
{ roundIds: roundIds ?? [] },
|
||||||
|
{ enabled: !!roundIds && roundIds.length >= 2 }
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Diversity Metrics — includes summary cards, category breakdown, ocean issues, tags */}
|
||||||
|
{diversityLoading ? (
|
||||||
|
<Skeleton className="h-[400px]" />
|
||||||
|
) : diversity ? (
|
||||||
|
<DiversityMetricsChart data={diversity} />
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{/* Geographic Distribution — full-width map with top countries */}
|
||||||
|
{geoLoading ? (
|
||||||
|
<Skeleton className="h-[500px]" />
|
||||||
|
) : geoData?.length ? (
|
||||||
|
<GeographicDistribution data={geoData} />
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{/* Project Status + Cross-Round Comparison */}
|
||||||
|
<div className="grid gap-6 lg:grid-cols-2">
|
||||||
|
{statusLoading ? (
|
||||||
|
<Skeleton className="h-[350px]" />
|
||||||
|
) : statusBreakdown ? (
|
||||||
|
<StatusBreakdownChart data={statusBreakdown} />
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{roundIds && roundIds.length >= 2 && (
|
||||||
|
<>
|
||||||
|
{crossLoading ? (
|
||||||
|
<Skeleton className="h-[350px]" />
|
||||||
|
) : crossRound ? (
|
||||||
|
<CrossStageComparisonChart data={crossRound} />
|
||||||
|
) : null}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
37
src/components/observer/reports/intake-report-tabs.tsx
Normal file
37
src/components/observer/reports/intake-report-tabs.tsx
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { trpc } from '@/lib/trpc/client'
|
||||||
|
import { Skeleton } from '@/components/ui/skeleton'
|
||||||
|
import { StatusBreakdownChart, DiversityMetricsChart } from '@/components/charts'
|
||||||
|
import { RoundTypeStatsCards } from '@/components/observer/round-type-stats'
|
||||||
|
|
||||||
|
interface IntakeReportTabsProps {
|
||||||
|
roundId: string
|
||||||
|
programId: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function IntakeReportTabs({ roundId, programId }: IntakeReportTabsProps) {
|
||||||
|
const { data: statusBreakdown, isLoading: statusLoading } =
|
||||||
|
trpc.analytics.getStatusBreakdown.useQuery({ roundId })
|
||||||
|
|
||||||
|
const { data: diversity, isLoading: diversityLoading } =
|
||||||
|
trpc.analytics.getDiversityMetrics.useQuery({ roundId })
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<RoundTypeStatsCards roundId={roundId} />
|
||||||
|
|
||||||
|
{statusLoading ? (
|
||||||
|
<Skeleton className="h-[350px]" />
|
||||||
|
) : statusBreakdown ? (
|
||||||
|
<StatusBreakdownChart data={statusBreakdown} />
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{diversityLoading ? (
|
||||||
|
<Skeleton className="h-[400px]" />
|
||||||
|
) : diversity ? (
|
||||||
|
<DiversityMetricsChart data={diversity} />
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
29
src/components/observer/reports/live-final-report-tabs.tsx
Normal file
29
src/components/observer/reports/live-final-report-tabs.tsx
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { trpc } from '@/lib/trpc/client'
|
||||||
|
import { Skeleton } from '@/components/ui/skeleton'
|
||||||
|
import { StatusBreakdownChart } from '@/components/charts'
|
||||||
|
import { RoundTypeStatsCards } from '@/components/observer/round-type-stats'
|
||||||
|
|
||||||
|
interface LiveFinalReportTabsProps {
|
||||||
|
roundId: string
|
||||||
|
programId: string
|
||||||
|
}
|
||||||
|
|
||||||
|
function StatusBreakdownSection({ roundId }: { roundId: string }) {
|
||||||
|
const { data: statusBreakdown, isLoading } =
|
||||||
|
trpc.analytics.getStatusBreakdown.useQuery({ roundId })
|
||||||
|
|
||||||
|
if (isLoading) return <Skeleton className="h-[350px]" />
|
||||||
|
if (!statusBreakdown) return null
|
||||||
|
return <StatusBreakdownChart data={statusBreakdown} />
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LiveFinalReportTabs({ roundId }: LiveFinalReportTabsProps) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<RoundTypeStatsCards roundId={roundId} />
|
||||||
|
<StatusBreakdownSection roundId={roundId} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
29
src/components/observer/reports/mentoring-report-tabs.tsx
Normal file
29
src/components/observer/reports/mentoring-report-tabs.tsx
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { trpc } from '@/lib/trpc/client'
|
||||||
|
import { Skeleton } from '@/components/ui/skeleton'
|
||||||
|
import { StatusBreakdownChart } from '@/components/charts'
|
||||||
|
import { RoundTypeStatsCards } from '@/components/observer/round-type-stats'
|
||||||
|
|
||||||
|
interface MentoringReportTabsProps {
|
||||||
|
roundId: string
|
||||||
|
programId: string
|
||||||
|
}
|
||||||
|
|
||||||
|
function StatusBreakdownSection({ roundId }: { roundId: string }) {
|
||||||
|
const { data: statusBreakdown, isLoading } =
|
||||||
|
trpc.analytics.getStatusBreakdown.useQuery({ roundId })
|
||||||
|
|
||||||
|
if (isLoading) return <Skeleton className="h-[350px]" />
|
||||||
|
if (!statusBreakdown) return null
|
||||||
|
return <StatusBreakdownChart data={statusBreakdown} />
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MentoringReportTabs({ roundId }: MentoringReportTabsProps) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<RoundTypeStatsCards roundId={roundId} />
|
||||||
|
<StatusBreakdownSection roundId={roundId} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
183
src/components/observer/reports/project-preview-dialog.tsx
Normal file
183
src/components/observer/reports/project-preview-dialog.tsx
Normal file
@@ -0,0 +1,183 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { trpc } from '@/lib/trpc/client'
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from '@/components/ui/dialog'
|
||||||
|
import { Badge } from '@/components/ui/badge'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Skeleton } from '@/components/ui/skeleton'
|
||||||
|
import { Separator } from '@/components/ui/separator'
|
||||||
|
import { StatusBadge } from '@/components/shared/status-badge'
|
||||||
|
import { ExternalLink, MapPin, Waves, Users } from 'lucide-react'
|
||||||
|
import Link from 'next/link'
|
||||||
|
import type { Route } from 'next'
|
||||||
|
import { scoreGradient } from '@/components/charts/chart-theme'
|
||||||
|
|
||||||
|
interface ProjectPreviewDialogProps {
|
||||||
|
projectId: string | null
|
||||||
|
open: boolean
|
||||||
|
onOpenChange: (open: boolean) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
function ScorePill({ score }: { score: number }) {
|
||||||
|
const bg = scoreGradient(score)
|
||||||
|
const text = score >= 6 ? '#ffffff' : '#1a1a1a'
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className="inline-flex items-center justify-center rounded-md px-2.5 py-1 text-sm font-bold tabular-nums"
|
||||||
|
style={{ backgroundColor: bg, color: text }}
|
||||||
|
>
|
||||||
|
{score.toFixed(1)}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ProjectPreviewDialog({ projectId, open, onOpenChange }: ProjectPreviewDialogProps) {
|
||||||
|
const { data, isLoading } = trpc.analytics.getProjectDetail.useQuery(
|
||||||
|
{ id: projectId! },
|
||||||
|
{ enabled: !!projectId && open },
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="max-w-2xl max-h-[85vh] overflow-y-auto">
|
||||||
|
{isLoading || !data ? (
|
||||||
|
<>
|
||||||
|
<DialogHeader>
|
||||||
|
<Skeleton className="h-6 w-48" />
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<Skeleton className="h-4 w-32" />
|
||||||
|
<Skeleton className="h-20 w-full" />
|
||||||
|
<Skeleton className="h-32 w-full" />
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="text-lg leading-tight pr-8">
|
||||||
|
{data.project.title}
|
||||||
|
</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Project info row */}
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<StatusBadge status={data.project.status} />
|
||||||
|
{data.project.teamName && (
|
||||||
|
<Badge variant="outline" className="gap-1">
|
||||||
|
<Users className="h-3 w-3" />
|
||||||
|
{data.project.teamName}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
{data.project.country && (
|
||||||
|
<Badge variant="outline" className="gap-1">
|
||||||
|
<MapPin className="h-3 w-3" />
|
||||||
|
{data.project.country}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
{data.project.competitionCategory && (
|
||||||
|
<Badge variant="secondary">{data.project.competitionCategory}</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Description */}
|
||||||
|
{data.project.description && (
|
||||||
|
<p className="text-sm text-muted-foreground line-clamp-4">
|
||||||
|
{data.project.description}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Ocean Issue */}
|
||||||
|
{data.project.oceanIssue && (
|
||||||
|
<Badge variant="outline" className="gap-1 text-xs">
|
||||||
|
<Waves className="h-3 w-3" />
|
||||||
|
{data.project.oceanIssue.replace(/_/g, ' ').toLowerCase().replace(/\b\w/g, (c: string) => c.toUpperCase())}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
|
||||||
|
{/* Evaluation summary */}
|
||||||
|
{data.stats && (
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-semibold mb-2">Evaluation Summary</h3>
|
||||||
|
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
|
||||||
|
<div className="rounded-md border p-3 text-center">
|
||||||
|
<p className="text-lg font-bold tabular-nums">
|
||||||
|
{data.stats.averageGlobalScore != null ? (
|
||||||
|
<ScorePill score={data.stats.averageGlobalScore} />
|
||||||
|
) : '—'}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-muted-foreground mt-1">Avg Score</p>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-md border p-3 text-center">
|
||||||
|
<p className="text-lg font-bold tabular-nums">{data.stats.totalEvaluations ?? 0}</p>
|
||||||
|
<p className="text-xs text-muted-foreground mt-1">Evaluations</p>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-md border p-3 text-center">
|
||||||
|
<p className="text-lg font-bold tabular-nums">{data.assignments?.length ?? 0}</p>
|
||||||
|
<p className="text-xs text-muted-foreground mt-1">Assignments</p>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-md border p-3 text-center">
|
||||||
|
<p className="text-lg font-bold tabular-nums">
|
||||||
|
{data.stats.yesPercentage != null ? `${Math.round(data.stats.yesPercentage)}%` : '—'}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-muted-foreground mt-1">Recommend</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Individual evaluations */}
|
||||||
|
{data.assignments?.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-semibold mb-2">Juror Evaluations</h3>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
{data.assignments.map((a: { id: string; user: { name: string | null }; evaluation: { status: string; globalScore: unknown } | null }) => {
|
||||||
|
const ev = a.evaluation
|
||||||
|
const score = ev?.status === 'SUBMITTED' && ev.globalScore != null
|
||||||
|
? Number(ev.globalScore)
|
||||||
|
: null
|
||||||
|
return (
|
||||||
|
<div key={a.id} className="flex items-center justify-between rounded-md border px-3 py-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-sm font-medium">{a.user.name ?? 'Unknown'}</span>
|
||||||
|
{ev?.status === 'SUBMITTED' ? (
|
||||||
|
<Badge variant="default" className="text-[10px]">Reviewed</Badge>
|
||||||
|
) : ev?.status === 'DRAFT' ? (
|
||||||
|
<Badge variant="secondary" className="text-[10px]">Draft</Badge>
|
||||||
|
) : (
|
||||||
|
<Badge variant="outline" className="text-[10px]">Pending</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{score !== null && <ScorePill score={score} />}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
|
||||||
|
{/* View full project button */}
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<Button asChild>
|
||||||
|
<Link href={`/observer/projects/${projectId}` as Route}>
|
||||||
|
<ExternalLink className="mr-2 h-4 w-4" />
|
||||||
|
View Full Project
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
29
src/components/observer/reports/submission-report-tabs.tsx
Normal file
29
src/components/observer/reports/submission-report-tabs.tsx
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { trpc } from '@/lib/trpc/client'
|
||||||
|
import { Skeleton } from '@/components/ui/skeleton'
|
||||||
|
import { StatusBreakdownChart } from '@/components/charts'
|
||||||
|
import { RoundTypeStatsCards } from '@/components/observer/round-type-stats'
|
||||||
|
|
||||||
|
interface SubmissionReportTabsProps {
|
||||||
|
roundId: string
|
||||||
|
programId: string
|
||||||
|
}
|
||||||
|
|
||||||
|
function StatusBreakdownSection({ roundId }: { roundId: string }) {
|
||||||
|
const { data: statusBreakdown, isLoading } =
|
||||||
|
trpc.analytics.getStatusBreakdown.useQuery({ roundId })
|
||||||
|
|
||||||
|
if (isLoading) return <Skeleton className="h-[350px]" />
|
||||||
|
if (!statusBreakdown) return null
|
||||||
|
return <StatusBreakdownChart data={statusBreakdown} />
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SubmissionReportTabs({ roundId }: SubmissionReportTabsProps) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<RoundTypeStatsCards roundId={roundId} />
|
||||||
|
<StatusBreakdownSection roundId={roundId} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
|
import { useEffect, useRef } from 'react'
|
||||||
import { useForm } from 'react-hook-form'
|
import { useForm } from 'react-hook-form'
|
||||||
import { zodResolver } from '@hookform/resolvers/zod'
|
import { zodResolver } from '@hookform/resolvers/zod'
|
||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
@@ -36,6 +37,7 @@ const formSchema = z.object({
|
|||||||
ai_model: z.string(),
|
ai_model: z.string(),
|
||||||
ai_send_descriptions: z.boolean(),
|
ai_send_descriptions: z.boolean(),
|
||||||
openai_api_key: z.string().optional(),
|
openai_api_key: z.string().optional(),
|
||||||
|
anthropic_api_key: z.string().optional(),
|
||||||
openai_base_url: z.string().optional(),
|
openai_base_url: z.string().optional(),
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -48,6 +50,7 @@ interface AISettingsFormProps {
|
|||||||
ai_model?: string
|
ai_model?: string
|
||||||
ai_send_descriptions?: string
|
ai_send_descriptions?: string
|
||||||
openai_api_key?: string
|
openai_api_key?: string
|
||||||
|
anthropic_api_key?: string
|
||||||
openai_base_url?: string
|
openai_base_url?: string
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -63,12 +66,29 @@ export function AISettingsForm({ settings }: AISettingsFormProps) {
|
|||||||
ai_model: settings.ai_model || 'gpt-4o',
|
ai_model: settings.ai_model || 'gpt-4o',
|
||||||
ai_send_descriptions: settings.ai_send_descriptions === 'true',
|
ai_send_descriptions: settings.ai_send_descriptions === 'true',
|
||||||
openai_api_key: '',
|
openai_api_key: '',
|
||||||
|
anthropic_api_key: '',
|
||||||
openai_base_url: settings.openai_base_url || '',
|
openai_base_url: settings.openai_base_url || '',
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
const watchProvider = form.watch('ai_provider')
|
const watchProvider = form.watch('ai_provider')
|
||||||
const isLiteLLM = watchProvider === 'litellm'
|
const isLiteLLM = watchProvider === 'litellm'
|
||||||
|
const isAnthropic = watchProvider === 'anthropic'
|
||||||
|
const prevProviderRef = useRef(settings.ai_provider || 'openai')
|
||||||
|
|
||||||
|
// Auto-reset model when provider changes
|
||||||
|
useEffect(() => {
|
||||||
|
if (watchProvider !== prevProviderRef.current) {
|
||||||
|
prevProviderRef.current = watchProvider
|
||||||
|
if (watchProvider === 'anthropic') {
|
||||||
|
form.setValue('ai_model', 'claude-sonnet-4-5-20250514')
|
||||||
|
} else if (watchProvider === 'openai') {
|
||||||
|
form.setValue('ai_model', 'gpt-4o')
|
||||||
|
} else if (watchProvider === 'litellm') {
|
||||||
|
form.setValue('ai_model', '')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [watchProvider, form])
|
||||||
|
|
||||||
// Fetch available models from OpenAI API (skip for LiteLLM — no models.list support)
|
// Fetch available models from OpenAI API (skip for LiteLLM — no models.list support)
|
||||||
const {
|
const {
|
||||||
@@ -119,6 +139,9 @@ export function AISettingsForm({ settings }: AISettingsFormProps) {
|
|||||||
if (data.openai_api_key && data.openai_api_key.trim()) {
|
if (data.openai_api_key && data.openai_api_key.trim()) {
|
||||||
settingsToUpdate.push({ key: 'openai_api_key', value: data.openai_api_key })
|
settingsToUpdate.push({ key: 'openai_api_key', value: data.openai_api_key })
|
||||||
}
|
}
|
||||||
|
if (data.anthropic_api_key && data.anthropic_api_key.trim()) {
|
||||||
|
settingsToUpdate.push({ key: 'anthropic_api_key', value: data.anthropic_api_key })
|
||||||
|
}
|
||||||
|
|
||||||
// Save base URL (empty string clears it)
|
// Save base URL (empty string clears it)
|
||||||
settingsToUpdate.push({ key: 'openai_base_url', value: data.openai_base_url?.trim() || '' })
|
settingsToUpdate.push({ key: 'openai_base_url', value: data.openai_base_url?.trim() || '' })
|
||||||
@@ -139,6 +162,9 @@ export function AISettingsForm({ settings }: AISettingsFormProps) {
|
|||||||
)
|
)
|
||||||
|
|
||||||
const categoryLabels: Record<string, string> = {
|
const categoryLabels: Record<string, string> = {
|
||||||
|
'claude-4.5': 'Claude 4.5 Series (Latest)',
|
||||||
|
'claude-4': 'Claude 4 Series',
|
||||||
|
'claude-3.5': 'Claude 3.5 Series',
|
||||||
'gpt-5+': 'GPT-5+ Series (Latest)',
|
'gpt-5+': 'GPT-5+ Series (Latest)',
|
||||||
'gpt-4o': 'GPT-4o Series',
|
'gpt-4o': 'GPT-4o Series',
|
||||||
'gpt-4': 'GPT-4 Series',
|
'gpt-4': 'GPT-4 Series',
|
||||||
@@ -147,7 +173,7 @@ export function AISettingsForm({ settings }: AISettingsFormProps) {
|
|||||||
other: 'Other Models',
|
other: 'Other Models',
|
||||||
}
|
}
|
||||||
|
|
||||||
const categoryOrder = ['gpt-5+', 'gpt-4o', 'gpt-4', 'gpt-3.5', 'reasoning', 'other']
|
const categoryOrder = ['claude-4.5', 'claude-4', 'claude-3.5', 'gpt-5+', 'gpt-4o', 'gpt-4', 'gpt-3.5', 'reasoning', 'other']
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Form {...form}>
|
<Form {...form}>
|
||||||
@@ -187,12 +213,15 @@ export function AISettingsForm({ settings }: AISettingsFormProps) {
|
|||||||
</FormControl>
|
</FormControl>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="openai">OpenAI (API Key)</SelectItem>
|
<SelectItem value="openai">OpenAI (API Key)</SelectItem>
|
||||||
|
<SelectItem value="anthropic">Anthropic (Claude API)</SelectItem>
|
||||||
<SelectItem value="litellm">LiteLLM Proxy (ChatGPT Subscription)</SelectItem>
|
<SelectItem value="litellm">LiteLLM Proxy (ChatGPT Subscription)</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
<FormDescription>
|
<FormDescription>
|
||||||
{field.value === 'litellm'
|
{field.value === 'litellm'
|
||||||
? 'Route AI calls through a LiteLLM proxy connected to your ChatGPT Plus/Pro subscription'
|
? 'Route AI calls through a LiteLLM proxy connected to your ChatGPT Plus/Pro subscription'
|
||||||
|
: field.value === 'anthropic'
|
||||||
|
? 'Direct Anthropic API access using Claude models'
|
||||||
: 'Direct OpenAI API access using your API key'}
|
: 'Direct OpenAI API access using your API key'}
|
||||||
</FormDescription>
|
</FormDescription>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
@@ -211,12 +240,45 @@ export function AISettingsForm({ settings }: AISettingsFormProps) {
|
|||||||
</Alert>
|
</Alert>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{isAnthropic && (
|
||||||
|
<Alert>
|
||||||
|
<Info className="h-4 w-4" />
|
||||||
|
<AlertDescription>
|
||||||
|
<strong>Anthropic Claude Mode</strong> — AI calls use the Anthropic Messages API.
|
||||||
|
Claude Opus models include extended thinking for deeper analysis.
|
||||||
|
JSON responses are validated with automatic retry.
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isAnthropic ? (
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="anthropic_api_key"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Anthropic API Key</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
type="password"
|
||||||
|
placeholder={settings.anthropic_api_key ? '••••••••' : 'Enter Anthropic API key'}
|
||||||
|
{...field}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormDescription>
|
||||||
|
Your Anthropic API key. Leave blank to keep the existing key.
|
||||||
|
</FormDescription>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="openai_api_key"
|
name="openai_api_key"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>{isLiteLLM ? 'API Key (Optional)' : 'API Key'}</FormLabel>
|
<FormLabel>{isLiteLLM ? 'API Key (Optional)' : 'OpenAI API Key'}</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input
|
<Input
|
||||||
type="password"
|
type="password"
|
||||||
@@ -235,13 +297,14 @@ export function AISettingsForm({ settings }: AISettingsFormProps) {
|
|||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="openai_base_url"
|
name="openai_base_url"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>{isLiteLLM ? 'LiteLLM Proxy URL' : 'API Base URL (Optional)'}</FormLabel>
|
<FormLabel>{isLiteLLM ? 'LiteLLM Proxy URL' : isAnthropic ? 'Anthropic Base URL (Optional)' : 'API Base URL (Optional)'}</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input
|
<Input
|
||||||
placeholder={isLiteLLM ? 'http://localhost:4000' : 'https://api.openai.com/v1'}
|
placeholder={isLiteLLM ? 'http://localhost:4000' : 'https://api.openai.com/v1'}
|
||||||
@@ -255,6 +318,10 @@ export function AISettingsForm({ settings }: AISettingsFormProps) {
|
|||||||
<code className="text-xs bg-muted px-1 rounded">http://localhost:4000</code>{' '}
|
<code className="text-xs bg-muted px-1 rounded">http://localhost:4000</code>{' '}
|
||||||
or your server address.
|
or your server address.
|
||||||
</>
|
</>
|
||||||
|
) : isAnthropic ? (
|
||||||
|
<>
|
||||||
|
Custom base URL for Anthropic API proxy or gateway. Leave blank for default Anthropic API.
|
||||||
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
Custom base URL for OpenAI-compatible providers. Leave blank for OpenAI.
|
Custom base URL for OpenAI-compatible providers. Leave blank for OpenAI.
|
||||||
@@ -288,7 +355,42 @@ export function AISettingsForm({ settings }: AISettingsFormProps) {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{isLiteLLM || modelsData?.manualEntry ? (
|
{isAnthropic ? (
|
||||||
|
// Anthropic: fetch models from server (hardcoded list)
|
||||||
|
modelsLoading ? (
|
||||||
|
<Skeleton className="h-10 w-full" />
|
||||||
|
) : modelsData?.success && modelsData.models && modelsData.models.length > 0 ? (
|
||||||
|
<Select onValueChange={field.onChange} value={field.value}>
|
||||||
|
<FormControl>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Select Claude model" />
|
||||||
|
</SelectTrigger>
|
||||||
|
</FormControl>
|
||||||
|
<SelectContent>
|
||||||
|
{categoryOrder
|
||||||
|
.filter((cat) => groupedModels?.[cat]?.length)
|
||||||
|
.map((category) => (
|
||||||
|
<SelectGroup key={category}>
|
||||||
|
<SelectLabel className="text-xs font-semibold text-muted-foreground">
|
||||||
|
{categoryLabels[category] || category}
|
||||||
|
</SelectLabel>
|
||||||
|
{groupedModels?.[category]?.map((model) => (
|
||||||
|
<SelectItem key={model.id} value={model.id}>
|
||||||
|
{model.name}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectGroup>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
) : (
|
||||||
|
<Input
|
||||||
|
value={field.value}
|
||||||
|
onChange={(e) => field.onChange(e.target.value)}
|
||||||
|
placeholder="claude-sonnet-4-5-20250514"
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
) : isLiteLLM || modelsData?.manualEntry ? (
|
||||||
<Input
|
<Input
|
||||||
value={field.value}
|
value={field.value}
|
||||||
onChange={(e) => field.onChange(e.target.value)}
|
onChange={(e) => field.onChange(e.target.value)}
|
||||||
@@ -341,7 +443,16 @@ export function AISettingsForm({ settings }: AISettingsFormProps) {
|
|||||||
</Select>
|
</Select>
|
||||||
)}
|
)}
|
||||||
<FormDescription>
|
<FormDescription>
|
||||||
{isLiteLLM ? (
|
{isAnthropic ? (
|
||||||
|
form.watch('ai_model')?.includes('opus') ? (
|
||||||
|
<span className="flex items-center gap-1 text-amber-600">
|
||||||
|
<SlidersHorizontal className="h-3 w-3" />
|
||||||
|
Opus model — includes extended thinking for deeper analysis
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
'Anthropic Claude model to use for AI features'
|
||||||
|
)
|
||||||
|
) : isLiteLLM ? (
|
||||||
<>
|
<>
|
||||||
Enter the model ID with the{' '}
|
Enter the model ID with the{' '}
|
||||||
<code className="text-xs bg-muted px-1 rounded">chatgpt/</code> prefix.
|
<code className="text-xs bg-muted px-1 rounded">chatgpt/</code> prefix.
|
||||||
|
|||||||
@@ -23,14 +23,15 @@ import {
|
|||||||
Newspaper,
|
Newspaper,
|
||||||
BarChart3,
|
BarChart3,
|
||||||
ShieldAlert,
|
ShieldAlert,
|
||||||
Globe,
|
|
||||||
Webhook,
|
Webhook,
|
||||||
MessageCircle,
|
MessageCircle,
|
||||||
|
FlaskConical,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
import { AnimatedCard } from '@/components/shared/animated-container'
|
import { AnimatedCard } from '@/components/shared/animated-container'
|
||||||
import { AISettingsForm } from './ai-settings-form'
|
import { AISettingsForm } from './ai-settings-form'
|
||||||
import { AIUsageCard } from './ai-usage-card'
|
import { AIUsageCard } from './ai-usage-card'
|
||||||
|
import { TestEnvironmentPanel } from './test-environment-panel'
|
||||||
import { BrandingSettingsForm } from './branding-settings-form'
|
import { BrandingSettingsForm } from './branding-settings-form'
|
||||||
import { EmailSettingsForm } from './email-settings-form'
|
import { EmailSettingsForm } from './email-settings-form'
|
||||||
import { StorageSettingsForm } from './storage-settings-form'
|
import { StorageSettingsForm } from './storage-settings-form'
|
||||||
@@ -158,11 +159,6 @@ export function SettingsContent({ initialSettings, isSuperAdmin = true }: Settin
|
|||||||
'whatsapp_provider',
|
'whatsapp_provider',
|
||||||
])
|
])
|
||||||
|
|
||||||
const localizationSettings = getSettingsByKeys([
|
|
||||||
'localization_enabled_locales',
|
|
||||||
'localization_default_locale',
|
|
||||||
])
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Tabs defaultValue="defaults" className="space-y-6">
|
<Tabs defaultValue="defaults" className="space-y-6">
|
||||||
@@ -176,10 +172,6 @@ export function SettingsContent({ initialSettings, isSuperAdmin = true }: Settin
|
|||||||
<Palette className="h-4 w-4" />
|
<Palette className="h-4 w-4" />
|
||||||
Branding
|
Branding
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
<TabsTrigger value="localization" className="gap-2 shrink-0">
|
|
||||||
<Globe className="h-4 w-4" />
|
|
||||||
Locale
|
|
||||||
</TabsTrigger>
|
|
||||||
{isSuperAdmin && (
|
{isSuperAdmin && (
|
||||||
<TabsTrigger value="email" className="gap-2 shrink-0">
|
<TabsTrigger value="email" className="gap-2 shrink-0">
|
||||||
<Mail className="h-4 w-4" />
|
<Mail className="h-4 w-4" />
|
||||||
@@ -236,6 +228,12 @@ export function SettingsContent({ initialSettings, isSuperAdmin = true }: Settin
|
|||||||
Webhooks
|
Webhooks
|
||||||
</Link>
|
</Link>
|
||||||
)}
|
)}
|
||||||
|
{isSuperAdmin && (
|
||||||
|
<TabsTrigger value="testenv" className="gap-2 shrink-0">
|
||||||
|
<FlaskConical className="h-4 w-4" />
|
||||||
|
Test Env
|
||||||
|
</TabsTrigger>
|
||||||
|
)}
|
||||||
</TabsList>
|
</TabsList>
|
||||||
|
|
||||||
<div className="lg:flex lg:gap-8">
|
<div className="lg:flex lg:gap-8">
|
||||||
@@ -253,10 +251,6 @@ export function SettingsContent({ initialSettings, isSuperAdmin = true }: Settin
|
|||||||
<Palette className="h-4 w-4" />
|
<Palette className="h-4 w-4" />
|
||||||
Branding
|
Branding
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
<TabsTrigger value="localization" className="justify-start gap-2 w-full px-3 py-2 h-auto data-[state=active]:bg-muted">
|
|
||||||
<Globe className="h-4 w-4" />
|
|
||||||
Locale
|
|
||||||
</TabsTrigger>
|
|
||||||
</TabsList>
|
</TabsList>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
@@ -333,6 +327,12 @@ export function SettingsContent({ initialSettings, isSuperAdmin = true }: Settin
|
|||||||
Webhooks
|
Webhooks
|
||||||
<ExternalLink className="ml-auto h-3 w-3 opacity-50" />
|
<ExternalLink className="ml-auto h-3 w-3 opacity-50" />
|
||||||
</Link>
|
</Link>
|
||||||
|
<TabsList className="flex flex-col items-stretch h-auto w-full bg-transparent p-0 gap-0.5 mt-1">
|
||||||
|
<TabsTrigger value="testenv" className="justify-start gap-2 w-full px-3 py-2 h-auto data-[state=active]:bg-muted">
|
||||||
|
<FlaskConical className="h-4 w-4" />
|
||||||
|
Test Env
|
||||||
|
</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</nav>
|
</nav>
|
||||||
@@ -510,22 +510,6 @@ export function SettingsContent({ initialSettings, isSuperAdmin = true }: Settin
|
|||||||
</AnimatedCard>
|
</AnimatedCard>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
<TabsContent value="localization" className="space-y-6">
|
|
||||||
<AnimatedCard>
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>Localization</CardTitle>
|
|
||||||
<CardDescription>
|
|
||||||
Configure language and locale settings
|
|
||||||
</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<LocalizationSettingsSection settings={localizationSettings} />
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</AnimatedCard>
|
|
||||||
</TabsContent>
|
|
||||||
|
|
||||||
{isSuperAdmin && (
|
{isSuperAdmin && (
|
||||||
<TabsContent value="whatsapp" className="space-y-6">
|
<TabsContent value="whatsapp" className="space-y-6">
|
||||||
<AnimatedCard>
|
<AnimatedCard>
|
||||||
@@ -543,6 +527,28 @@ export function SettingsContent({ initialSettings, isSuperAdmin = true }: Settin
|
|||||||
</AnimatedCard>
|
</AnimatedCard>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{isSuperAdmin && (
|
||||||
|
<TabsContent value="testenv" className="space-y-6">
|
||||||
|
<AnimatedCard>
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<FlaskConical className="h-5 w-5" />
|
||||||
|
Test Environment
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Create a sandboxed test competition with dummy data for testing all roles and workflows.
|
||||||
|
Fully isolated from production data.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<TestEnvironmentPanel />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</AnimatedCard>
|
||||||
|
</TabsContent>
|
||||||
|
)}
|
||||||
</div>{/* end content area */}
|
</div>{/* end content area */}
|
||||||
</div>{/* end lg:flex */}
|
</div>{/* end lg:flex */}
|
||||||
</Tabs>
|
</Tabs>
|
||||||
@@ -858,66 +864,3 @@ function WhatsAppSettingsSection({ settings }: { settings: Record<string, string
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function LocalizationSettingsSection({ settings }: { settings: Record<string, string> }) {
|
|
||||||
const mutation = useSettingsMutation()
|
|
||||||
const enabledLocales = (settings.localization_enabled_locales || 'en').split(',')
|
|
||||||
|
|
||||||
const toggleLocale = (locale: string) => {
|
|
||||||
const current = new Set(enabledLocales)
|
|
||||||
if (current.has(locale)) {
|
|
||||||
if (current.size <= 1) {
|
|
||||||
toast.error('At least one locale must be enabled')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
current.delete(locale)
|
|
||||||
} else {
|
|
||||||
current.add(locale)
|
|
||||||
}
|
|
||||||
mutation.mutate({
|
|
||||||
key: 'localization_enabled_locales',
|
|
||||||
value: Array.from(current).join(','),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div className="space-y-3">
|
|
||||||
<Label className="text-sm font-medium">Enabled Languages</Label>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<div className="flex items-center justify-between rounded-lg border p-3">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<span className="font-medium text-sm">EN</span>
|
|
||||||
<span className="text-sm text-muted-foreground">English</span>
|
|
||||||
</div>
|
|
||||||
<Checkbox
|
|
||||||
checked={enabledLocales.includes('en')}
|
|
||||||
onCheckedChange={() => toggleLocale('en')}
|
|
||||||
disabled={mutation.isPending}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center justify-between rounded-lg border p-3">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<span className="font-medium text-sm">FR</span>
|
|
||||||
<span className="text-sm text-muted-foreground">Français</span>
|
|
||||||
</div>
|
|
||||||
<Checkbox
|
|
||||||
checked={enabledLocales.includes('fr')}
|
|
||||||
onCheckedChange={() => toggleLocale('fr')}
|
|
||||||
disabled={mutation.isPending}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<SettingSelect
|
|
||||||
label="Default Locale"
|
|
||||||
description="The default language for new users"
|
|
||||||
settingKey="localization_default_locale"
|
|
||||||
value={settings.localization_default_locale || 'en'}
|
|
||||||
options={[
|
|
||||||
{ value: 'en', label: 'English' },
|
|
||||||
{ value: 'fr', label: 'Fran\u00e7ais' },
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|||||||
297
src/components/settings/test-environment-panel.tsx
Normal file
297
src/components/settings/test-environment-panel.tsx
Normal file
@@ -0,0 +1,297 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { useSession } from 'next-auth/react'
|
||||||
|
import { useRouter } from 'next/navigation'
|
||||||
|
import { trpc } from '@/lib/trpc/client'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Badge } from '@/components/ui/badge'
|
||||||
|
import { Input } from '@/components/ui/input'
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from '@/components/ui/card'
|
||||||
|
import {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogTitle,
|
||||||
|
AlertDialogTrigger,
|
||||||
|
} from '@/components/ui/alert-dialog'
|
||||||
|
import {
|
||||||
|
FlaskConical,
|
||||||
|
Plus,
|
||||||
|
Trash2,
|
||||||
|
ExternalLink,
|
||||||
|
Loader2,
|
||||||
|
Users,
|
||||||
|
UserCog,
|
||||||
|
CheckCircle2,
|
||||||
|
AlertTriangle,
|
||||||
|
} from 'lucide-react'
|
||||||
|
import type { UserRole } from '@prisma/client'
|
||||||
|
|
||||||
|
const ROLE_LABELS: Record<string, string> = {
|
||||||
|
JURY_MEMBER: 'Jury Member',
|
||||||
|
APPLICANT: 'Applicant',
|
||||||
|
MENTOR: 'Mentor',
|
||||||
|
OBSERVER: 'Observer',
|
||||||
|
AWARD_MASTER: 'Award Master',
|
||||||
|
PROGRAM_ADMIN: 'Program Admin',
|
||||||
|
}
|
||||||
|
|
||||||
|
const ROLE_COLORS: Record<string, string> = {
|
||||||
|
JURY_MEMBER: 'bg-blue-100 text-blue-800',
|
||||||
|
APPLICANT: 'bg-green-100 text-green-800',
|
||||||
|
MENTOR: 'bg-purple-100 text-purple-800',
|
||||||
|
OBSERVER: 'bg-orange-100 text-orange-800',
|
||||||
|
AWARD_MASTER: 'bg-yellow-100 text-yellow-800',
|
||||||
|
PROGRAM_ADMIN: 'bg-red-100 text-red-800',
|
||||||
|
}
|
||||||
|
|
||||||
|
const ROLE_LANDING: Record<string, string> = {
|
||||||
|
JURY_MEMBER: '/jury',
|
||||||
|
APPLICANT: '/applicant',
|
||||||
|
MENTOR: '/mentor',
|
||||||
|
OBSERVER: '/observer',
|
||||||
|
AWARD_MASTER: '/admin',
|
||||||
|
PROGRAM_ADMIN: '/admin',
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TestEnvironmentPanel() {
|
||||||
|
const { update } = useSession()
|
||||||
|
const router = useRouter()
|
||||||
|
const utils = trpc.useUtils()
|
||||||
|
|
||||||
|
const { data: status, isLoading } = trpc.testEnvironment.status.useQuery()
|
||||||
|
const createMutation = trpc.testEnvironment.create.useMutation({
|
||||||
|
onSuccess: () => utils.testEnvironment.status.invalidate(),
|
||||||
|
})
|
||||||
|
const tearDownMutation = trpc.testEnvironment.tearDown.useMutation({
|
||||||
|
onSuccess: () => utils.testEnvironment.status.invalidate(),
|
||||||
|
})
|
||||||
|
|
||||||
|
const [confirmText, setConfirmText] = useState('')
|
||||||
|
const [tearDownOpen, setTearDownOpen] = useState(false)
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center py-12">
|
||||||
|
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// No test environment — show creation card
|
||||||
|
if (!status?.active) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="rounded-lg border-2 border-dashed p-8 text-center">
|
||||||
|
<FlaskConical className="mx-auto h-12 w-12 text-muted-foreground/50" />
|
||||||
|
<h3 className="mt-4 text-lg font-semibold">No Test Environment</h3>
|
||||||
|
<p className="mt-2 text-sm text-muted-foreground max-w-md mx-auto">
|
||||||
|
Create a sandboxed test competition with dummy users, projects, jury assignments,
|
||||||
|
and partial evaluations. All test data is fully isolated from production.
|
||||||
|
</p>
|
||||||
|
<Button
|
||||||
|
className="mt-6"
|
||||||
|
onClick={() => createMutation.mutate()}
|
||||||
|
disabled={createMutation.isPending}
|
||||||
|
>
|
||||||
|
{createMutation.isPending ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
|
Creating test environment...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Plus className="mr-2 h-4 w-4" />
|
||||||
|
Create Test Competition
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
{createMutation.isError && (
|
||||||
|
<p className="mt-3 text-sm text-destructive">
|
||||||
|
{createMutation.error.message}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test environment is active
|
||||||
|
const { competition, rounds, users, emailRedirect } = status
|
||||||
|
|
||||||
|
// Group users by role for impersonation cards
|
||||||
|
const roleGroups = users.reduce(
|
||||||
|
(acc, u) => {
|
||||||
|
const role = u.role as string
|
||||||
|
if (!acc[role]) acc[role] = []
|
||||||
|
acc[role].push(u)
|
||||||
|
return acc
|
||||||
|
},
|
||||||
|
{} as Record<string, typeof users>
|
||||||
|
)
|
||||||
|
|
||||||
|
async function handleImpersonate(userId: string, role: UserRole) {
|
||||||
|
await update({ impersonateUserId: userId })
|
||||||
|
router.push((ROLE_LANDING[role] || '/admin') as any)
|
||||||
|
router.refresh()
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleTearDown() {
|
||||||
|
if (confirmText !== 'DELETE TEST') return
|
||||||
|
tearDownMutation.mutate(undefined, {
|
||||||
|
onSuccess: () => {
|
||||||
|
setTearDownOpen(false)
|
||||||
|
setConfirmText('')
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Status header */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Badge variant="outline" className="bg-green-50 text-green-700 border-green-200">
|
||||||
|
<CheckCircle2 className="mr-1 h-3 w-3" />
|
||||||
|
Test Active
|
||||||
|
</Badge>
|
||||||
|
<span className="text-sm text-muted-foreground">
|
||||||
|
{competition.name}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<Button variant="outline" size="sm" asChild>
|
||||||
|
<a href={`/admin/competitions/${competition.id}`} target="_blank" rel="noopener">
|
||||||
|
View Competition
|
||||||
|
<ExternalLink className="ml-1.5 h-3 w-3" />
|
||||||
|
</a>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Quick stats */}
|
||||||
|
<div className="grid grid-cols-3 gap-4 text-center">
|
||||||
|
<div className="rounded-lg border p-3">
|
||||||
|
<p className="text-2xl font-bold">{rounds.length}</p>
|
||||||
|
<p className="text-xs text-muted-foreground">Rounds</p>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-lg border p-3">
|
||||||
|
<p className="text-2xl font-bold">{users.length}</p>
|
||||||
|
<p className="text-xs text-muted-foreground">Test Users</p>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-lg border p-3">
|
||||||
|
<p className="text-2xl font-bold truncate text-sm font-mono">
|
||||||
|
{emailRedirect || '—'}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-muted-foreground">Email Redirect</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Impersonation section */}
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-2 mb-3">
|
||||||
|
<UserCog className="h-4 w-4 text-muted-foreground" />
|
||||||
|
<h4 className="text-sm font-semibold">Impersonate Test User</h4>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||||
|
{Object.entries(roleGroups).map(([role, roleUsers]) => (
|
||||||
|
<Card key={role} className="overflow-hidden">
|
||||||
|
<CardHeader className="py-2 px-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Badge variant="secondary" className={ROLE_COLORS[role] || ''}>
|
||||||
|
{ROLE_LABELS[role] || role}
|
||||||
|
</Badge>
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
{roleUsers.length} user{roleUsers.length !== 1 ? 's' : ''}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="py-2 px-3 space-y-1.5">
|
||||||
|
{roleUsers.slice(0, 3).map((u) => (
|
||||||
|
<button
|
||||||
|
key={u.id}
|
||||||
|
onClick={() => handleImpersonate(u.id, u.role as UserRole)}
|
||||||
|
className="flex items-center justify-between w-full rounded-md px-2 py-1.5 text-sm hover:bg-muted transition-colors text-left"
|
||||||
|
>
|
||||||
|
<span className="truncate">{u.name || u.email}</span>
|
||||||
|
<span className="text-xs text-muted-foreground shrink-0 ml-2">
|
||||||
|
Impersonate
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
{roleUsers.length > 3 && (
|
||||||
|
<p className="text-xs text-muted-foreground px-2">
|
||||||
|
+{roleUsers.length - 3} more (switch via banner)
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tear down */}
|
||||||
|
<div className="border-t pt-4">
|
||||||
|
<AlertDialog open={tearDownOpen} onOpenChange={setTearDownOpen}>
|
||||||
|
<AlertDialogTrigger asChild>
|
||||||
|
<Button variant="destructive" size="sm">
|
||||||
|
<Trash2 className="mr-2 h-4 w-4" />
|
||||||
|
Tear Down Test Environment
|
||||||
|
</Button>
|
||||||
|
</AlertDialogTrigger>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle className="flex items-center gap-2">
|
||||||
|
<AlertTriangle className="h-5 w-5 text-destructive" />
|
||||||
|
Destroy Test Environment
|
||||||
|
</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
This will permanently delete ALL test data: users, projects, competitions,
|
||||||
|
assignments, evaluations, and files. This action cannot be undone.
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<div className="space-y-2 py-2">
|
||||||
|
<p className="text-sm font-medium">
|
||||||
|
Type <code className="rounded bg-muted px-1.5 py-0.5 font-mono text-sm">DELETE TEST</code> to confirm:
|
||||||
|
</p>
|
||||||
|
<Input
|
||||||
|
value={confirmText}
|
||||||
|
onChange={(e) => setConfirmText(e.target.value)}
|
||||||
|
placeholder="DELETE TEST"
|
||||||
|
className="font-mono"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel onClick={() => setConfirmText('')}>
|
||||||
|
Cancel
|
||||||
|
</AlertDialogCancel>
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
onClick={handleTearDown}
|
||||||
|
disabled={confirmText !== 'DELETE TEST' || tearDownMutation.isPending}
|
||||||
|
>
|
||||||
|
{tearDownMutation.isPending ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
|
Tearing down...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
'Destroy Test Environment'
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
149
src/components/shared/impersonation-banner.tsx
Normal file
149
src/components/shared/impersonation-banner.tsx
Normal file
@@ -0,0 +1,149 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useSession } from 'next-auth/react'
|
||||||
|
import { useRouter } from 'next/navigation'
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { trpc } from '@/lib/trpc/client'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuLabel,
|
||||||
|
DropdownMenuSeparator,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from '@/components/ui/dropdown-menu'
|
||||||
|
import { ChevronDown, LogOut, UserCog } from 'lucide-react'
|
||||||
|
import type { UserRole } from '@prisma/client'
|
||||||
|
|
||||||
|
const ROLE_LABELS: Record<string, string> = {
|
||||||
|
JURY_MEMBER: 'Jury Member',
|
||||||
|
APPLICANT: 'Applicant',
|
||||||
|
MENTOR: 'Mentor',
|
||||||
|
OBSERVER: 'Observer',
|
||||||
|
AWARD_MASTER: 'Award Master',
|
||||||
|
PROGRAM_ADMIN: 'Program Admin',
|
||||||
|
SUPER_ADMIN: 'Super Admin',
|
||||||
|
}
|
||||||
|
|
||||||
|
const ROLE_LANDING: Record<string, string> = {
|
||||||
|
JURY_MEMBER: '/jury',
|
||||||
|
APPLICANT: '/applicant',
|
||||||
|
MENTOR: '/mentor',
|
||||||
|
OBSERVER: '/observer',
|
||||||
|
AWARD_MASTER: '/admin',
|
||||||
|
PROGRAM_ADMIN: '/admin',
|
||||||
|
SUPER_ADMIN: '/admin',
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ImpersonationBanner() {
|
||||||
|
const { data: session, update } = useSession()
|
||||||
|
const router = useRouter()
|
||||||
|
const [switching, setSwitching] = useState(false)
|
||||||
|
|
||||||
|
// Only fetch test users when impersonating (realRole check happens server-side)
|
||||||
|
const { data: testEnv } = trpc.testEnvironment.status.useQuery(undefined, {
|
||||||
|
enabled: !!session?.user?.isImpersonating,
|
||||||
|
staleTime: 60_000,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!session?.user?.isImpersonating) return null
|
||||||
|
|
||||||
|
const currentRole = session.user.role
|
||||||
|
const currentName = session.user.impersonatedName || session.user.name || 'Unknown'
|
||||||
|
|
||||||
|
// Group available test users by role (exclude currently impersonated user)
|
||||||
|
const availableUsers = testEnv?.active
|
||||||
|
? testEnv.users.filter((u) => u.id !== session.user.id)
|
||||||
|
: []
|
||||||
|
|
||||||
|
const roleGroups = availableUsers.reduce(
|
||||||
|
(acc, u) => {
|
||||||
|
const role = u.role as string
|
||||||
|
if (!acc[role]) acc[role] = []
|
||||||
|
acc[role].push(u)
|
||||||
|
return acc
|
||||||
|
},
|
||||||
|
{} as Record<string, typeof availableUsers>
|
||||||
|
)
|
||||||
|
|
||||||
|
async function handleSwitch(userId: string, role: UserRole) {
|
||||||
|
setSwitching(true)
|
||||||
|
await update({ impersonateUserId: userId })
|
||||||
|
router.push((ROLE_LANDING[role] || '/admin') as any)
|
||||||
|
router.refresh()
|
||||||
|
setSwitching(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleStopImpersonation() {
|
||||||
|
setSwitching(true)
|
||||||
|
await update({ stopImpersonation: true })
|
||||||
|
router.push('/admin/settings' as any)
|
||||||
|
router.refresh()
|
||||||
|
setSwitching(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed top-0 left-0 right-0 z-50 bg-amber-500 text-amber-950 shadow-md">
|
||||||
|
<div className="mx-auto flex items-center justify-between px-4 py-1.5 text-sm font-medium">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<UserCog className="h-4 w-4" />
|
||||||
|
<span>
|
||||||
|
Viewing as <strong>{currentName}</strong>{' '}
|
||||||
|
<span className="rounded bg-amber-600/30 px-1.5 py-0.5 text-xs font-semibold">
|
||||||
|
{ROLE_LABELS[currentRole] || currentRole}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{/* Quick-switch dropdown */}
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-7 gap-1 bg-amber-600/20 text-amber-950 hover:bg-amber-600/40"
|
||||||
|
disabled={switching}
|
||||||
|
>
|
||||||
|
Switch Role
|
||||||
|
<ChevronDown className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end" className="w-56">
|
||||||
|
{Object.entries(roleGroups).map(([role, users]) => (
|
||||||
|
<div key={role}>
|
||||||
|
<DropdownMenuLabel className="text-xs text-muted-foreground">
|
||||||
|
{ROLE_LABELS[role] || role}
|
||||||
|
</DropdownMenuLabel>
|
||||||
|
{users.map((u) => (
|
||||||
|
<DropdownMenuItem
|
||||||
|
key={u.id}
|
||||||
|
onClick={() => handleSwitch(u.id, u.role as UserRole)}
|
||||||
|
disabled={switching}
|
||||||
|
>
|
||||||
|
<span className="truncate">{u.name || u.email}</span>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
))}
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
|
||||||
|
{/* Return to admin */}
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-7 gap-1 bg-amber-600/20 text-amber-950 hover:bg-amber-600/40"
|
||||||
|
onClick={handleStopImpersonation}
|
||||||
|
disabled={switching}
|
||||||
|
>
|
||||||
|
<LogOut className="h-3 w-3" />
|
||||||
|
Return to Admin
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -10,6 +10,11 @@ declare module 'next-auth' {
|
|||||||
name?: string | null
|
name?: string | null
|
||||||
role: UserRole
|
role: UserRole
|
||||||
mustSetPassword?: boolean
|
mustSetPassword?: boolean
|
||||||
|
// Impersonation fields
|
||||||
|
isImpersonating?: boolean
|
||||||
|
realUserId?: string
|
||||||
|
realRole?: UserRole
|
||||||
|
impersonatedName?: string | null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -24,6 +29,12 @@ declare module '@auth/core/jwt' {
|
|||||||
id: string
|
id: string
|
||||||
role: UserRole
|
role: UserRole
|
||||||
mustSetPassword?: boolean
|
mustSetPassword?: boolean
|
||||||
|
// Impersonation fields
|
||||||
|
impersonatedUserId?: string
|
||||||
|
impersonatedRole?: UserRole
|
||||||
|
impersonatedName?: string | null
|
||||||
|
realUserId?: string
|
||||||
|
realRole?: UserRole
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -190,7 +190,7 @@ export const { handlers, auth, signIn, signOut } = NextAuth({
|
|||||||
],
|
],
|
||||||
callbacks: {
|
callbacks: {
|
||||||
...authConfig.callbacks,
|
...authConfig.callbacks,
|
||||||
async jwt({ token, user, trigger }) {
|
async jwt({ token, user, trigger, session: sessionUpdate }) {
|
||||||
// Initial sign in
|
// Initial sign in
|
||||||
if (user) {
|
if (user) {
|
||||||
token.id = user.id as string
|
token.id = user.id as string
|
||||||
@@ -198,8 +198,40 @@ export const { handlers, auth, signIn, signOut } = NextAuth({
|
|||||||
token.mustSetPassword = user.mustSetPassword
|
token.mustSetPassword = user.mustSetPassword
|
||||||
}
|
}
|
||||||
|
|
||||||
// On session update, refresh from database
|
// On session update
|
||||||
if (trigger === 'update') {
|
if (trigger === 'update') {
|
||||||
|
// Handle impersonation request
|
||||||
|
if (sessionUpdate?.impersonateUserId) {
|
||||||
|
const testUser = await prisma.user.findUnique({
|
||||||
|
where: { id: sessionUpdate.impersonateUserId },
|
||||||
|
select: { id: true, name: true, email: true, role: true, isTest: true },
|
||||||
|
})
|
||||||
|
// Only allow impersonating test users with @test.local emails
|
||||||
|
if (testUser?.isTest && testUser.email.endsWith('@test.local')) {
|
||||||
|
// Preserve original identity (only set once in case of quick-switch)
|
||||||
|
if (!token.realUserId) {
|
||||||
|
token.realUserId = token.id as string
|
||||||
|
token.realRole = token.role as UserRole
|
||||||
|
}
|
||||||
|
token.id = testUser.id
|
||||||
|
token.role = testUser.role
|
||||||
|
token.impersonatedUserId = testUser.id
|
||||||
|
token.impersonatedRole = testUser.role
|
||||||
|
token.impersonatedName = testUser.name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Handle stop impersonation
|
||||||
|
else if (sessionUpdate?.stopImpersonation && token.realUserId) {
|
||||||
|
token.id = token.realUserId
|
||||||
|
token.role = token.realRole!
|
||||||
|
delete token.impersonatedUserId
|
||||||
|
delete token.impersonatedRole
|
||||||
|
delete token.impersonatedName
|
||||||
|
delete token.realUserId
|
||||||
|
delete token.realRole
|
||||||
|
}
|
||||||
|
// Normal session refresh (only when not impersonating)
|
||||||
|
else if (!token.impersonatedUserId) {
|
||||||
const dbUser = await prisma.user.findUnique({
|
const dbUser = await prisma.user.findUnique({
|
||||||
where: { id: token.id as string },
|
where: { id: token.id as string },
|
||||||
select: { role: true, mustSetPassword: true },
|
select: { role: true, mustSetPassword: true },
|
||||||
@@ -209,6 +241,7 @@ export const { handlers, auth, signIn, signOut } = NextAuth({
|
|||||||
token.mustSetPassword = dbUser.mustSetPassword
|
token.mustSetPassword = dbUser.mustSetPassword
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return token
|
return token
|
||||||
},
|
},
|
||||||
@@ -217,6 +250,15 @@ export const { handlers, auth, signIn, signOut } = NextAuth({
|
|||||||
session.user.id = token.id as string
|
session.user.id = token.id as string
|
||||||
session.user.role = token.role as UserRole
|
session.user.role = token.role as UserRole
|
||||||
session.user.mustSetPassword = token.mustSetPassword as boolean | undefined
|
session.user.mustSetPassword = token.mustSetPassword as boolean | undefined
|
||||||
|
// Impersonation state
|
||||||
|
session.user.isImpersonating = !!token.impersonatedUserId
|
||||||
|
if (token.realUserId) {
|
||||||
|
session.user.realUserId = token.realUserId as string
|
||||||
|
session.user.realRole = token.realRole as UserRole
|
||||||
|
}
|
||||||
|
if (token.impersonatedName !== undefined) {
|
||||||
|
session.user.impersonatedName = token.impersonatedName as string | null
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return session
|
return session
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -7,6 +7,32 @@ let cachedTransporter: Transporter | null = null
|
|||||||
let cachedConfigHash = ''
|
let cachedConfigHash = ''
|
||||||
let cachedFrom = ''
|
let cachedFrom = ''
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve test email recipients: @test.local emails are redirected
|
||||||
|
* to the admin's email (from test_email_redirect setting) and
|
||||||
|
* the subject is prefixed with [TEST]. Real emails are never affected.
|
||||||
|
*/
|
||||||
|
async function resolveTestEmailRecipient(
|
||||||
|
to: string,
|
||||||
|
subject: string
|
||||||
|
): Promise<{ to: string; subject: string }> {
|
||||||
|
if (!to.endsWith('@test.local')) {
|
||||||
|
return { to, subject }
|
||||||
|
}
|
||||||
|
const redirect = await prisma.systemSettings.findUnique({
|
||||||
|
where: { key: 'test_email_redirect' },
|
||||||
|
select: { value: true },
|
||||||
|
})
|
||||||
|
if (redirect?.value) {
|
||||||
|
return {
|
||||||
|
to: redirect.value,
|
||||||
|
subject: `[TEST] ${subject}`,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// No redirect configured — suppress the email entirely
|
||||||
|
return { to: '', subject }
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get SMTP transporter using database settings with env var fallback.
|
* Get SMTP transporter using database settings with env var fallback.
|
||||||
* Caches the transporter and rebuilds it when settings change.
|
* Caches the transporter and rebuilds it when settings change.
|
||||||
@@ -47,12 +73,31 @@ async function getTransporter(): Promise<{ transporter: Transporter; from: strin
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Create new transporter
|
// Create new transporter
|
||||||
cachedTransporter = nodemailer.createTransport({
|
const rawTransporter = nodemailer.createTransport({
|
||||||
host,
|
host,
|
||||||
port: parseInt(port),
|
port: parseInt(port),
|
||||||
secure: port === '465',
|
secure: port === '465',
|
||||||
auth: { user, pass },
|
auth: { user, pass },
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Wrap sendMail to auto-redirect @test.local emails
|
||||||
|
const originalSendMail = rawTransporter.sendMail.bind(rawTransporter)
|
||||||
|
rawTransporter.sendMail = async function (mailOptions: any) {
|
||||||
|
if (mailOptions.to && typeof mailOptions.to === 'string') {
|
||||||
|
const resolved = await resolveTestEmailRecipient(
|
||||||
|
mailOptions.to,
|
||||||
|
mailOptions.subject || ''
|
||||||
|
)
|
||||||
|
if (!resolved.to) {
|
||||||
|
// Suppress email entirely (no redirect configured for test)
|
||||||
|
return { messageId: 'suppressed-test-email' }
|
||||||
|
}
|
||||||
|
mailOptions = { ...mailOptions, to: resolved.to, subject: resolved.subject }
|
||||||
|
}
|
||||||
|
return originalSendMail(mailOptions)
|
||||||
|
} as any
|
||||||
|
|
||||||
|
cachedTransporter = rawTransporter
|
||||||
cachedConfigHash = configHash
|
cachedConfigHash = configHash
|
||||||
cachedFrom = from
|
cachedFrom = from
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,36 @@
|
|||||||
import OpenAI from 'openai'
|
import OpenAI from 'openai'
|
||||||
import type { ChatCompletionCreateParamsNonStreaming } from 'openai/resources/chat/completions'
|
import type { ChatCompletionCreateParamsNonStreaming } from 'openai/resources/chat/completions'
|
||||||
|
import Anthropic from '@anthropic-ai/sdk'
|
||||||
import { prisma } from './prisma'
|
import { prisma } from './prisma'
|
||||||
|
|
||||||
|
// Hardcoded Claude model list (Anthropic API doesn't expose a models.list endpoint for all users)
|
||||||
|
export const ANTHROPIC_CLAUDE_MODELS = [
|
||||||
|
'claude-opus-4-5-20250514',
|
||||||
|
'claude-sonnet-4-5-20250514',
|
||||||
|
'claude-haiku-3-5-20241022',
|
||||||
|
'claude-opus-4-20250514',
|
||||||
|
'claude-sonnet-4-20250514',
|
||||||
|
] as const
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AI client type returned by getOpenAI().
|
||||||
|
* Both the OpenAI SDK and the Anthropic adapter satisfy this interface.
|
||||||
|
* All AI services only use .chat.completions.create(), so this is safe.
|
||||||
|
*/
|
||||||
|
export type AIClient = OpenAI | AnthropicClientAdapter
|
||||||
|
|
||||||
|
type AnthropicClientAdapter = {
|
||||||
|
__isAnthropicAdapter: true
|
||||||
|
chat: {
|
||||||
|
completions: {
|
||||||
|
create(params: ChatCompletionCreateParamsNonStreaming): Promise<OpenAI.Chat.Completions.ChatCompletion>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// OpenAI client singleton with lazy initialization
|
// OpenAI client singleton with lazy initialization
|
||||||
const globalForOpenAI = globalThis as unknown as {
|
const globalForOpenAI = globalThis as unknown as {
|
||||||
openai: OpenAI | undefined
|
openai: AIClient | undefined
|
||||||
openaiInitialized: boolean
|
openaiInitialized: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -12,15 +38,17 @@ const globalForOpenAI = globalThis as unknown as {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the configured AI provider from SystemSettings.
|
* Get the configured AI provider from SystemSettings.
|
||||||
* Returns 'openai' (default) or 'litellm' (ChatGPT subscription proxy).
|
* Returns 'openai' (default), 'litellm' (ChatGPT subscription proxy), or 'anthropic' (Claude API).
|
||||||
*/
|
*/
|
||||||
export async function getConfiguredProvider(): Promise<'openai' | 'litellm'> {
|
export async function getConfiguredProvider(): Promise<'openai' | 'litellm' | 'anthropic'> {
|
||||||
try {
|
try {
|
||||||
const setting = await prisma.systemSettings.findUnique({
|
const setting = await prisma.systemSettings.findUnique({
|
||||||
where: { key: 'ai_provider' },
|
where: { key: 'ai_provider' },
|
||||||
})
|
})
|
||||||
const value = setting?.value || 'openai'
|
const value = setting?.value || 'openai'
|
||||||
return value === 'litellm' ? 'litellm' : 'openai'
|
if (value === 'litellm') return 'litellm'
|
||||||
|
if (value === 'anthropic') return 'anthropic'
|
||||||
|
return 'openai'
|
||||||
} catch {
|
} catch {
|
||||||
return 'openai'
|
return 'openai'
|
||||||
}
|
}
|
||||||
@@ -219,6 +247,20 @@ async function getOpenAIApiKey(): Promise<string | null> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get Anthropic API key from SystemSettings
|
||||||
|
*/
|
||||||
|
async function getAnthropicApiKey(): Promise<string | null> {
|
||||||
|
try {
|
||||||
|
const setting = await prisma.systemSettings.findUnique({
|
||||||
|
where: { key: 'anthropic_api_key' },
|
||||||
|
})
|
||||||
|
return setting?.value || process.env.ANTHROPIC_API_KEY || null
|
||||||
|
} catch {
|
||||||
|
return process.env.ANTHROPIC_API_KEY || null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get custom base URL for OpenAI-compatible providers.
|
* Get custom base URL for OpenAI-compatible providers.
|
||||||
* Supports OpenRouter, Together AI, Groq, local models, etc.
|
* Supports OpenRouter, Together AI, Groq, local models, etc.
|
||||||
@@ -265,15 +307,165 @@ async function createOpenAIClient(): Promise<OpenAI | null> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the OpenAI client singleton
|
* Check if a model is a Claude Opus model (supports extended thinking).
|
||||||
* Returns null if API key is not configured
|
|
||||||
*/
|
*/
|
||||||
export async function getOpenAI(): Promise<OpenAI | null> {
|
function isClaudeOpusModel(model: string): boolean {
|
||||||
|
return model.toLowerCase().includes('opus')
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create an Anthropic adapter that wraps the Anthropic SDK behind the
|
||||||
|
* same `.chat.completions.create()` surface as OpenAI. This allows all
|
||||||
|
* AI service files to work with zero changes.
|
||||||
|
*/
|
||||||
|
async function createAnthropicAdapter(): Promise<AnthropicClientAdapter | null> {
|
||||||
|
const apiKey = await getAnthropicApiKey()
|
||||||
|
if (!apiKey) {
|
||||||
|
console.warn('Anthropic API key not configured')
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const baseURL = await getBaseURL()
|
||||||
|
const anthropic = new Anthropic({
|
||||||
|
apiKey,
|
||||||
|
...(baseURL ? { baseURL } : {}),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (baseURL) {
|
||||||
|
console.log(`[Anthropic] Using custom base URL: ${baseURL}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
__isAnthropicAdapter: true,
|
||||||
|
chat: {
|
||||||
|
completions: {
|
||||||
|
async create(params: ChatCompletionCreateParamsNonStreaming): Promise<OpenAI.Chat.Completions.ChatCompletion> {
|
||||||
|
// Extract system messages → Anthropic's system parameter
|
||||||
|
const systemMessages: string[] = []
|
||||||
|
const userAssistantMessages: Anthropic.MessageParam[] = []
|
||||||
|
|
||||||
|
for (const msg of params.messages) {
|
||||||
|
const content = typeof msg.content === 'string' ? msg.content : ''
|
||||||
|
if (msg.role === 'system' || msg.role === 'developer') {
|
||||||
|
systemMessages.push(content)
|
||||||
|
} else {
|
||||||
|
userAssistantMessages.push({
|
||||||
|
role: msg.role === 'assistant' ? 'assistant' : 'user',
|
||||||
|
content,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure messages start with a user message (Anthropic requirement)
|
||||||
|
if (userAssistantMessages.length === 0 || userAssistantMessages[0].role !== 'user') {
|
||||||
|
userAssistantMessages.unshift({ role: 'user', content: 'Hello' })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine max_tokens (required by Anthropic, default 16384)
|
||||||
|
const maxTokens = params.max_tokens ?? params.max_completion_tokens ?? 16384
|
||||||
|
|
||||||
|
// Build Anthropic request
|
||||||
|
const anthropicParams: Anthropic.MessageCreateParamsNonStreaming = {
|
||||||
|
model: params.model,
|
||||||
|
max_tokens: maxTokens,
|
||||||
|
messages: userAssistantMessages,
|
||||||
|
...(systemMessages.length > 0 ? { system: systemMessages.join('\n\n') } : {}),
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add temperature if present (Anthropic supports 0-1)
|
||||||
|
if (params.temperature !== undefined && params.temperature !== null) {
|
||||||
|
anthropicParams.temperature = params.temperature
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extended thinking for Opus models
|
||||||
|
if (isClaudeOpusModel(params.model)) {
|
||||||
|
anthropicParams.thinking = { type: 'enabled', budget_tokens: Math.min(8192, maxTokens - 1) }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Call Anthropic API
|
||||||
|
let response = await anthropic.messages.create(anthropicParams)
|
||||||
|
|
||||||
|
// Extract text from response (skip thinking blocks)
|
||||||
|
let responseText = response.content
|
||||||
|
.filter((block): block is Anthropic.TextBlock => block.type === 'text')
|
||||||
|
.map((block) => block.text)
|
||||||
|
.join('')
|
||||||
|
|
||||||
|
// JSON retry: if response_format was set but response isn't valid JSON
|
||||||
|
const wantsJson = params.response_format && 'type' in params.response_format && params.response_format.type === 'json_object'
|
||||||
|
if (wantsJson && responseText) {
|
||||||
|
try {
|
||||||
|
JSON.parse(responseText)
|
||||||
|
} catch {
|
||||||
|
// Retry once with explicit JSON instruction
|
||||||
|
const retryMessages = [...userAssistantMessages]
|
||||||
|
const lastIdx = retryMessages.length - 1
|
||||||
|
if (lastIdx >= 0 && retryMessages[lastIdx].role === 'user') {
|
||||||
|
retryMessages[lastIdx] = {
|
||||||
|
...retryMessages[lastIdx],
|
||||||
|
content: retryMessages[lastIdx].content + '\n\nIMPORTANT: You MUST respond with valid JSON only. No markdown, no extra text, just a JSON object or array.',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const retryParams: Anthropic.MessageCreateParamsNonStreaming = {
|
||||||
|
...anthropicParams,
|
||||||
|
messages: retryMessages,
|
||||||
|
}
|
||||||
|
|
||||||
|
response = await anthropic.messages.create(retryParams)
|
||||||
|
responseText = response.content
|
||||||
|
.filter((block): block is Anthropic.TextBlock => block.type === 'text')
|
||||||
|
.map((block) => block.text)
|
||||||
|
.join('')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Normalize response to OpenAI shape
|
||||||
|
return {
|
||||||
|
id: response.id,
|
||||||
|
object: 'chat.completion' as const,
|
||||||
|
created: Math.floor(Date.now() / 1000),
|
||||||
|
model: response.model,
|
||||||
|
choices: [
|
||||||
|
{
|
||||||
|
index: 0,
|
||||||
|
message: {
|
||||||
|
role: 'assistant' as const,
|
||||||
|
content: responseText || null,
|
||||||
|
refusal: null,
|
||||||
|
},
|
||||||
|
finish_reason: response.stop_reason === 'end_turn' || response.stop_reason === 'stop_sequence' ? 'stop' : response.stop_reason === 'max_tokens' ? 'length' : 'stop',
|
||||||
|
logprobs: null,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
usage: {
|
||||||
|
prompt_tokens: response.usage.input_tokens,
|
||||||
|
completion_tokens: response.usage.output_tokens,
|
||||||
|
total_tokens: response.usage.input_tokens + response.usage.output_tokens,
|
||||||
|
prompt_tokens_details: undefined as any,
|
||||||
|
completion_tokens_details: undefined as any,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the AI client singleton.
|
||||||
|
* Returns an OpenAI client or an Anthropic adapter (both expose .chat.completions.create()).
|
||||||
|
* Returns null if the API key is not configured.
|
||||||
|
*/
|
||||||
|
export async function getOpenAI(): Promise<AIClient | null> {
|
||||||
if (globalForOpenAI.openaiInitialized) {
|
if (globalForOpenAI.openaiInitialized) {
|
||||||
return globalForOpenAI.openai || null
|
return globalForOpenAI.openai || null
|
||||||
}
|
}
|
||||||
|
|
||||||
const client = await createOpenAIClient()
|
const provider = await getConfiguredProvider()
|
||||||
|
const client = provider === 'anthropic'
|
||||||
|
? await createAnthropicAdapter()
|
||||||
|
: await createOpenAIClient()
|
||||||
|
|
||||||
if (process.env.NODE_ENV !== 'production') {
|
if (process.env.NODE_ENV !== 'production') {
|
||||||
globalForOpenAI.openai = client || undefined
|
globalForOpenAI.openai = client || undefined
|
||||||
@@ -298,10 +490,13 @@ export function resetOpenAIClient(): void {
|
|||||||
export async function isOpenAIConfigured(): Promise<boolean> {
|
export async function isOpenAIConfigured(): Promise<boolean> {
|
||||||
const provider = await getConfiguredProvider()
|
const provider = await getConfiguredProvider()
|
||||||
if (provider === 'litellm') {
|
if (provider === 'litellm') {
|
||||||
// LiteLLM just needs a base URL configured
|
|
||||||
const baseURL = await getBaseURL()
|
const baseURL = await getBaseURL()
|
||||||
return !!baseURL
|
return !!baseURL
|
||||||
}
|
}
|
||||||
|
if (provider === 'anthropic') {
|
||||||
|
const apiKey = await getAnthropicApiKey()
|
||||||
|
return !!apiKey
|
||||||
|
}
|
||||||
const apiKey = await getOpenAIApiKey()
|
const apiKey = await getOpenAIApiKey()
|
||||||
return !!apiKey
|
return !!apiKey
|
||||||
}
|
}
|
||||||
@@ -327,6 +522,18 @@ export async function listAvailableModels(): Promise<{
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Anthropic: return hardcoded Claude model list
|
||||||
|
if (provider === 'anthropic') {
|
||||||
|
const apiKey = await getAnthropicApiKey()
|
||||||
|
if (!apiKey) {
|
||||||
|
return { success: false, error: 'Anthropic API key not configured' }
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
models: [...ANTHROPIC_CLAUDE_MODELS],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const client = await getOpenAI()
|
const client = await getOpenAI()
|
||||||
|
|
||||||
if (!client) {
|
if (!client) {
|
||||||
@@ -336,7 +543,7 @@ export async function listAvailableModels(): Promise<{
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const response = await client.models.list()
|
const response = await (client as OpenAI).models.list()
|
||||||
const chatModels = response.data
|
const chatModels = response.data
|
||||||
.filter((m) => m.id.includes('gpt') || m.id.includes('o1') || m.id.includes('o3') || m.id.includes('o4'))
|
.filter((m) => m.id.includes('gpt') || m.id.includes('o1') || m.id.includes('o3') || m.id.includes('o4'))
|
||||||
.map((m) => m.id)
|
.map((m) => m.id)
|
||||||
@@ -367,14 +574,16 @@ export async function validateModel(modelId: string): Promise<{
|
|||||||
if (!client) {
|
if (!client) {
|
||||||
return {
|
return {
|
||||||
valid: false,
|
valid: false,
|
||||||
error: 'OpenAI API key not configured',
|
error: 'AI API key not configured',
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Try a minimal completion with the model using correct parameters
|
const provider = await getConfiguredProvider()
|
||||||
|
|
||||||
|
// For Anthropic, use minimal max_tokens
|
||||||
const params = buildCompletionParams(modelId, {
|
const params = buildCompletionParams(modelId, {
|
||||||
messages: [{ role: 'user', content: 'test' }],
|
messages: [{ role: 'user', content: 'test' }],
|
||||||
maxTokens: 1,
|
maxTokens: provider === 'anthropic' ? 16 : 1,
|
||||||
})
|
})
|
||||||
|
|
||||||
await client.chat.completions.create(params)
|
await client.chat.completions.create(params)
|
||||||
@@ -407,11 +616,13 @@ export async function testOpenAIConnection(): Promise<{
|
|||||||
}> {
|
}> {
|
||||||
try {
|
try {
|
||||||
const client = await getOpenAI()
|
const client = await getOpenAI()
|
||||||
|
const provider = await getConfiguredProvider()
|
||||||
|
|
||||||
if (!client) {
|
if (!client) {
|
||||||
|
const label = provider === 'anthropic' ? 'Anthropic' : 'OpenAI'
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
error: 'OpenAI API key not configured',
|
error: `${label} API key not configured`,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -421,7 +632,7 @@ export async function testOpenAIConnection(): Promise<{
|
|||||||
// Test with the configured model using correct parameters
|
// Test with the configured model using correct parameters
|
||||||
const params = buildCompletionParams(configuredModel, {
|
const params = buildCompletionParams(configuredModel, {
|
||||||
messages: [{ role: 'user', content: 'Hello' }],
|
messages: [{ role: 'user', content: 'Hello' }],
|
||||||
maxTokens: 5,
|
maxTokens: provider === 'anthropic' ? 16 : 5,
|
||||||
})
|
})
|
||||||
|
|
||||||
const response = await client.chat.completions.create(params)
|
const response = await client.chat.completions.create(params)
|
||||||
@@ -436,7 +647,7 @@ export async function testOpenAIConnection(): Promise<{
|
|||||||
const configuredModel = await getConfiguredModel()
|
const configuredModel = await getConfiguredModel()
|
||||||
|
|
||||||
// Check for model-specific errors
|
// Check for model-specific errors
|
||||||
if (message.includes('does not exist') || message.includes('model_not_found')) {
|
if (message.includes('does not exist') || message.includes('model_not_found') || message.includes('not_found_error')) {
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
error: `Model "${configuredModel}" is not available. Check Settings → AI to select a valid model.`,
|
error: `Model "${configuredModel}" is not available. Check Settings → AI to select a valid model.`,
|
||||||
|
|||||||
@@ -51,6 +51,7 @@ import { roundEngineRouter } from './roundEngine'
|
|||||||
import { roundAssignmentRouter } from './roundAssignment'
|
import { roundAssignmentRouter } from './roundAssignment'
|
||||||
import { deliberationRouter } from './deliberation'
|
import { deliberationRouter } from './deliberation'
|
||||||
import { resultLockRouter } from './resultLock'
|
import { resultLockRouter } from './resultLock'
|
||||||
|
import { testEnvironmentRouter } from './testEnvironment'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Root tRPC router that combines all domain routers
|
* Root tRPC router that combines all domain routers
|
||||||
@@ -108,6 +109,8 @@ export const appRouter = router({
|
|||||||
roundAssignment: roundAssignmentRouter,
|
roundAssignment: roundAssignmentRouter,
|
||||||
deliberation: deliberationRouter,
|
deliberation: deliberationRouter,
|
||||||
resultLock: resultLockRouter,
|
resultLock: resultLockRouter,
|
||||||
|
// Test environment
|
||||||
|
testEnvironment: testEnvironmentRouter,
|
||||||
})
|
})
|
||||||
|
|
||||||
export type AppRouter = typeof appRouter
|
export type AppRouter = typeof appRouter
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { z } from 'zod'
|
|||||||
import { router, observerProcedure } from '../trpc'
|
import { router, observerProcedure } from '../trpc'
|
||||||
import { normalizeCountryToCode } from '@/lib/countries'
|
import { normalizeCountryToCode } from '@/lib/countries'
|
||||||
import { getUserAvatarUrl } from '../utils/avatar-url'
|
import { getUserAvatarUrl } from '../utils/avatar-url'
|
||||||
|
import { aggregateVotes } from '../services/deliberation'
|
||||||
|
|
||||||
const editionOrRoundInput = z.object({
|
const editionOrRoundInput = z.object({
|
||||||
roundId: z.string().optional(),
|
roundId: z.string().optional(),
|
||||||
@@ -11,8 +12,8 @@ const editionOrRoundInput = z.object({
|
|||||||
})
|
})
|
||||||
|
|
||||||
function projectWhere(input: { roundId?: string; programId?: string }) {
|
function projectWhere(input: { roundId?: string; programId?: string }) {
|
||||||
if (input.roundId) return { projectRoundStates: { some: { roundId: input.roundId } } }
|
if (input.roundId) return { isTest: false, projectRoundStates: { some: { roundId: input.roundId } } }
|
||||||
return { programId: input.programId! }
|
return { isTest: false, programId: input.programId! }
|
||||||
}
|
}
|
||||||
|
|
||||||
function assignmentWhere(input: { roundId?: string; programId?: string }) {
|
function assignmentWhere(input: { roundId?: string; programId?: string }) {
|
||||||
@@ -125,7 +126,7 @@ export const analyticsRouter = router({
|
|||||||
user: { select: { name: true } },
|
user: { select: { name: true } },
|
||||||
project: { select: { id: true, title: true } },
|
project: { select: { id: true, title: true } },
|
||||||
evaluation: {
|
evaluation: {
|
||||||
select: { id: true, status: true },
|
select: { id: true, status: true, globalScore: true },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
@@ -133,7 +134,7 @@ export const analyticsRouter = router({
|
|||||||
// Group by user
|
// Group by user
|
||||||
const byUser: Record<
|
const byUser: Record<
|
||||||
string,
|
string,
|
||||||
{ name: string; assigned: number; completed: number; projects: { id: string; title: string; evalStatus: string }[] }
|
{ name: string; assigned: number; completed: number; projects: { id: string; title: string; evalStatus: string; score: number | null }[] }
|
||||||
> = {}
|
> = {}
|
||||||
|
|
||||||
assignments.forEach((assignment) => {
|
assignments.forEach((assignment) => {
|
||||||
@@ -155,6 +156,9 @@ export const analyticsRouter = router({
|
|||||||
id: assignment.project.id,
|
id: assignment.project.id,
|
||||||
title: assignment.project.title,
|
title: assignment.project.title,
|
||||||
evalStatus: evalStatus === 'SUBMITTED' ? 'REVIEWED' : evalStatus === 'DRAFT' ? 'UNDER_REVIEW' : 'NOT_REVIEWED',
|
evalStatus: evalStatus === 'SUBMITTED' ? 'REVIEWED' : evalStatus === 'DRAFT' ? 'UNDER_REVIEW' : 'NOT_REVIEWED',
|
||||||
|
score: evalStatus === 'SUBMITTED' && assignment.evaluation?.globalScore != null
|
||||||
|
? Number(assignment.evaluation.globalScore)
|
||||||
|
: null,
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -249,12 +253,76 @@ export const analyticsRouter = router({
|
|||||||
getStatusBreakdown: observerProcedure
|
getStatusBreakdown: observerProcedure
|
||||||
.input(editionOrRoundInput)
|
.input(editionOrRoundInput)
|
||||||
.query(async ({ ctx, input }) => {
|
.query(async ({ ctx, input }) => {
|
||||||
|
if (input.roundId) {
|
||||||
|
// Check if this is an evaluation round — show eval-level status breakdown
|
||||||
|
const round = await ctx.prisma.round.findUnique({
|
||||||
|
where: { id: input.roundId },
|
||||||
|
select: { roundType: true },
|
||||||
|
})
|
||||||
|
|
||||||
|
if (round?.roundType === 'EVALUATION') {
|
||||||
|
// For evaluation rounds, break down by evaluation status per project
|
||||||
|
const projects = await ctx.prisma.projectRoundState.findMany({
|
||||||
|
where: { roundId: input.roundId, project: { isTest: false } },
|
||||||
|
select: {
|
||||||
|
projectId: true,
|
||||||
|
project: {
|
||||||
|
select: {
|
||||||
|
assignments: {
|
||||||
|
where: { roundId: input.roundId },
|
||||||
|
select: {
|
||||||
|
evaluation: { select: { status: true } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
let fullyReviewed = 0
|
||||||
|
let partiallyReviewed = 0
|
||||||
|
let notReviewed = 0
|
||||||
|
|
||||||
|
for (const p of projects) {
|
||||||
|
const assignments = p.project.assignments
|
||||||
|
if (assignments.length === 0) {
|
||||||
|
notReviewed++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
const submitted = assignments.filter((a) => a.evaluation?.status === 'SUBMITTED').length
|
||||||
|
if (submitted === 0) {
|
||||||
|
notReviewed++
|
||||||
|
} else if (submitted === assignments.length) {
|
||||||
|
fullyReviewed++
|
||||||
|
} else {
|
||||||
|
partiallyReviewed++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = []
|
||||||
|
if (fullyReviewed > 0) result.push({ status: 'FULLY_REVIEWED', count: fullyReviewed })
|
||||||
|
if (partiallyReviewed > 0) result.push({ status: 'PARTIALLY_REVIEWED', count: partiallyReviewed })
|
||||||
|
if (notReviewed > 0) result.push({ status: 'NOT_REVIEWED', count: notReviewed })
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// Non-evaluation rounds: use ProjectRoundState
|
||||||
|
const states = await ctx.prisma.projectRoundState.groupBy({
|
||||||
|
by: ['state'],
|
||||||
|
where: { roundId: input.roundId, project: { isTest: false } },
|
||||||
|
_count: true,
|
||||||
|
})
|
||||||
|
return states.map((s) => ({
|
||||||
|
status: s.state,
|
||||||
|
count: s._count,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
// Edition-level: use global project status
|
||||||
const projects = await ctx.prisma.project.groupBy({
|
const projects = await ctx.prisma.project.groupBy({
|
||||||
by: ['status'],
|
by: ['status'],
|
||||||
where: projectWhere(input),
|
where: projectWhere(input),
|
||||||
_count: true,
|
_count: true,
|
||||||
})
|
})
|
||||||
|
|
||||||
return projects.map((p) => ({
|
return projects.map((p) => ({
|
||||||
status: p.status,
|
status: p.status,
|
||||||
count: p._count,
|
count: p._count,
|
||||||
@@ -327,12 +395,14 @@ export const analyticsRouter = router({
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Build label → Set<id> map so program-level queries match all IDs for the same criterion label
|
// Build label → Set<id> map so program-level queries match all IDs for the same criterion label
|
||||||
|
// Skip boolean and section_header criteria — they don't have numeric scores
|
||||||
const labelToIds = new Map<string, Set<string>>()
|
const labelToIds = new Map<string, Set<string>>()
|
||||||
const labelToFirst = new Map<string, { id: string; label: string }>()
|
const labelToFirst = new Map<string, { id: string; label: string }>()
|
||||||
evaluationForms.forEach((form) => {
|
evaluationForms.forEach((form) => {
|
||||||
const criteria = form.criteriaJson as Array<{ id: string; label: string }> | null
|
const criteria = form.criteriaJson as Array<{ id: string; label: string; type?: string }> | null
|
||||||
if (criteria) {
|
if (criteria) {
|
||||||
criteria.forEach((c) => {
|
criteria.forEach((c) => {
|
||||||
|
if (c.type === 'boolean' || c.type === 'section_header') return
|
||||||
if (!labelToIds.has(c.label)) {
|
if (!labelToIds.has(c.label)) {
|
||||||
labelToIds.set(c.label, new Set())
|
labelToIds.set(c.label, new Set())
|
||||||
labelToFirst.set(c.label, c)
|
labelToFirst.set(c.label, c)
|
||||||
@@ -399,8 +469,8 @@ export const analyticsRouter = router({
|
|||||||
)
|
)
|
||||||
.query(async ({ ctx, input }) => {
|
.query(async ({ ctx, input }) => {
|
||||||
const where = input.roundId
|
const where = input.roundId
|
||||||
? { assignments: { some: { roundId: input.roundId } } }
|
? { isTest: false, assignments: { some: { roundId: input.roundId } } }
|
||||||
: { programId: input.programId }
|
: { isTest: false, programId: input.programId }
|
||||||
|
|
||||||
const distribution = await ctx.prisma.project.groupBy({
|
const distribution = await ctx.prisma.project.groupBy({
|
||||||
by: ['country'],
|
by: ['country'],
|
||||||
@@ -467,7 +537,7 @@ export const analyticsRouter = router({
|
|||||||
|
|
||||||
// Count distinct projects per round via assignments
|
// Count distinct projects per round via assignments
|
||||||
const projectAssignments = await ctx.prisma.assignment.findMany({
|
const projectAssignments = await ctx.prisma.assignment.findMany({
|
||||||
where: { roundId: { in: roundIds } },
|
where: { roundId: { in: roundIds }, project: { isTest: false } },
|
||||||
select: { roundId: true, projectId: true },
|
select: { roundId: true, projectId: true },
|
||||||
distinct: ['roundId', 'projectId'],
|
distinct: ['roundId', 'projectId'],
|
||||||
})
|
})
|
||||||
@@ -644,29 +714,30 @@ export const analyticsRouter = router({
|
|||||||
const roundId = input?.roundId
|
const roundId = input?.roundId
|
||||||
|
|
||||||
const projectFilter = roundId
|
const projectFilter = roundId
|
||||||
? { projectRoundStates: { some: { roundId } } }
|
? { isTest: false, projectRoundStates: { some: { roundId } } }
|
||||||
: {}
|
: { isTest: false }
|
||||||
const assignmentFilter = roundId ? { roundId } : {}
|
const assignmentFilter = roundId
|
||||||
|
? { roundId }
|
||||||
|
: { round: { competition: { isTest: false } } }
|
||||||
const evalFilter = roundId
|
const evalFilter = roundId
|
||||||
? { assignment: { roundId }, status: 'SUBMITTED' as const }
|
? { assignment: { roundId }, status: 'SUBMITTED' as const }
|
||||||
: { status: 'SUBMITTED' as const }
|
: { assignment: { round: { competition: { isTest: false } } }, status: 'SUBMITTED' as const }
|
||||||
|
|
||||||
const [
|
const [
|
||||||
programCount,
|
programCount,
|
||||||
activeRoundCount,
|
activeRounds,
|
||||||
projectCount,
|
projectCount,
|
||||||
jurorCount,
|
jurorCount,
|
||||||
submittedEvaluations,
|
submittedEvaluations,
|
||||||
totalAssignments,
|
totalAssignments,
|
||||||
evaluationScores,
|
evaluationScores,
|
||||||
] = await Promise.all([
|
] = await Promise.all([
|
||||||
ctx.prisma.program.count(),
|
ctx.prisma.program.count({ where: { isTest: false } }),
|
||||||
roundId
|
ctx.prisma.round.findMany({
|
||||||
? ctx.prisma.round.findUnique({ where: { id: roundId }, select: { competitionId: true } })
|
where: { status: 'ROUND_ACTIVE', competition: { isTest: false } },
|
||||||
.then((r) => r?.competitionId
|
select: { id: true, name: true },
|
||||||
? ctx.prisma.round.count({ where: { competitionId: r.competitionId, status: 'ROUND_ACTIVE' } })
|
take: 5,
|
||||||
: ctx.prisma.round.count({ where: { status: 'ROUND_ACTIVE' } }))
|
}),
|
||||||
: ctx.prisma.round.count({ where: { status: 'ROUND_ACTIVE' } }),
|
|
||||||
ctx.prisma.project.count({ where: projectFilter }),
|
ctx.prisma.project.count({ where: projectFilter }),
|
||||||
roundId
|
roundId
|
||||||
? ctx.prisma.assignment.findMany({
|
? ctx.prisma.assignment.findMany({
|
||||||
@@ -674,7 +745,7 @@ export const analyticsRouter = router({
|
|||||||
select: { userId: true },
|
select: { userId: true },
|
||||||
distinct: ['userId'],
|
distinct: ['userId'],
|
||||||
}).then((rows) => rows.length)
|
}).then((rows) => rows.length)
|
||||||
: ctx.prisma.user.count({ where: { role: 'JURY_MEMBER', status: 'ACTIVE' } }),
|
: ctx.prisma.user.count({ where: { isTest: false, role: 'JURY_MEMBER', status: 'ACTIVE' } }),
|
||||||
ctx.prisma.evaluation.count({ where: evalFilter }),
|
ctx.prisma.evaluation.count({ where: evalFilter }),
|
||||||
ctx.prisma.assignment.count({ where: assignmentFilter }),
|
ctx.prisma.assignment.count({ where: assignmentFilter }),
|
||||||
ctx.prisma.evaluation.findMany({
|
ctx.prisma.evaluation.findMany({
|
||||||
@@ -701,7 +772,8 @@ export const analyticsRouter = router({
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
programCount,
|
programCount,
|
||||||
activeRoundCount,
|
activeRoundCount: activeRounds.length,
|
||||||
|
activeRoundName: activeRounds.length === 1 ? activeRounds[0].name : null,
|
||||||
projectCount,
|
projectCount,
|
||||||
jurorCount,
|
jurorCount,
|
||||||
submittedEvaluations,
|
submittedEvaluations,
|
||||||
@@ -918,7 +990,7 @@ export const analyticsRouter = router({
|
|||||||
})
|
})
|
||||||
)
|
)
|
||||||
.query(async ({ ctx, input }) => {
|
.query(async ({ ctx, input }) => {
|
||||||
const where: Record<string, unknown> = {}
|
const where: Record<string, unknown> = { isTest: false }
|
||||||
|
|
||||||
if (input.roundId) {
|
if (input.roundId) {
|
||||||
where.projectRoundStates = { some: { roundId: input.roundId } }
|
where.projectRoundStates = { some: { roundId: input.roundId } }
|
||||||
@@ -1081,15 +1153,15 @@ export const analyticsRouter = router({
|
|||||||
switch (roundType) {
|
switch (roundType) {
|
||||||
case 'INTAKE': {
|
case 'INTAKE': {
|
||||||
const [total, byState, byCategory] = await Promise.all([
|
const [total, byState, byCategory] = await Promise.all([
|
||||||
ctx.prisma.projectRoundState.count({ where: { roundId: input.roundId } }),
|
ctx.prisma.projectRoundState.count({ where: { roundId: input.roundId, project: { isTest: false } } }),
|
||||||
ctx.prisma.projectRoundState.groupBy({
|
ctx.prisma.projectRoundState.groupBy({
|
||||||
by: ['state'],
|
by: ['state'],
|
||||||
where: { roundId: input.roundId },
|
where: { roundId: input.roundId, project: { isTest: false } },
|
||||||
_count: true,
|
_count: true,
|
||||||
}),
|
}),
|
||||||
ctx.prisma.project.groupBy({
|
ctx.prisma.project.groupBy({
|
||||||
by: ['competitionCategory'],
|
by: ['competitionCategory'],
|
||||||
where: { projectRoundStates: { some: { roundId: input.roundId } } },
|
where: { isTest: false, projectRoundStates: { some: { roundId: input.roundId } } },
|
||||||
_count: true,
|
_count: true,
|
||||||
}),
|
}),
|
||||||
])
|
])
|
||||||
@@ -1325,7 +1397,7 @@ export const analyticsRouter = router({
|
|||||||
// Get competition rounds for file grouping
|
// Get competition rounds for file grouping
|
||||||
let competitionRounds: { id: string; name: string; roundType: string }[] = []
|
let competitionRounds: { id: string; name: string; roundType: string }[] = []
|
||||||
const competition = await ctx.prisma.competition.findFirst({
|
const competition = await ctx.prisma.competition.findFirst({
|
||||||
where: { programId: projectRaw.programId },
|
where: { programId: projectRaw.programId, isTest: false },
|
||||||
include: { rounds: { select: { id: true, name: true, roundType: true }, orderBy: { sortOrder: 'asc' } } },
|
include: { rounds: { select: { id: true, name: true, roundType: true }, orderBy: { sortOrder: 'asc' } } },
|
||||||
})
|
})
|
||||||
if (competition) {
|
if (competition) {
|
||||||
@@ -1408,9 +1480,23 @@ export const analyticsRouter = router({
|
|||||||
.query(async ({ ctx, input }) => {
|
.query(async ({ ctx, input }) => {
|
||||||
const limit = input?.limit ?? 10
|
const limit = input?.limit ?? 10
|
||||||
|
|
||||||
|
// Exclude actions performed by test users
|
||||||
|
const testUserIds = await ctx.prisma.user.findMany({
|
||||||
|
where: { isTest: true },
|
||||||
|
select: { id: true },
|
||||||
|
}).then((users) => users.map((u) => u.id))
|
||||||
|
|
||||||
const entries = await ctx.prisma.decisionAuditLog.findMany({
|
const entries = await ctx.prisma.decisionAuditLog.findMany({
|
||||||
orderBy: { createdAt: 'desc' },
|
orderBy: { createdAt: 'desc' },
|
||||||
take: limit,
|
take: limit,
|
||||||
|
...(testUserIds.length > 0 && {
|
||||||
|
where: {
|
||||||
|
OR: [
|
||||||
|
{ actorId: null },
|
||||||
|
{ actorId: { notIn: testUserIds } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}),
|
||||||
select: {
|
select: {
|
||||||
id: true,
|
id: true,
|
||||||
eventType: true,
|
eventType: true,
|
||||||
@@ -1426,7 +1512,7 @@ export const analyticsRouter = router({
|
|||||||
const actorIds = [...new Set(entries.map((e) => e.actorId).filter(Boolean))] as string[]
|
const actorIds = [...new Set(entries.map((e) => e.actorId).filter(Boolean))] as string[]
|
||||||
const actors = actorIds.length > 0
|
const actors = actorIds.length > 0
|
||||||
? await ctx.prisma.user.findMany({
|
? await ctx.prisma.user.findMany({
|
||||||
where: { id: { in: actorIds } },
|
where: { id: { in: actorIds }, isTest: false },
|
||||||
select: { id: true, name: true },
|
select: { id: true, name: true },
|
||||||
})
|
})
|
||||||
: []
|
: []
|
||||||
@@ -1442,4 +1528,236 @@ export const analyticsRouter = router({
|
|||||||
createdAt: entry.createdAt,
|
createdAt: entry.createdAt,
|
||||||
}))
|
}))
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Round-Type-Specific Observer Reports
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get filtering result stats for a round (observer proxy of filtering.getResultStats)
|
||||||
|
*/
|
||||||
|
getFilteringResultStats: observerProcedure
|
||||||
|
.input(z.object({ roundId: z.string() }))
|
||||||
|
.query(async ({ ctx, input }) => {
|
||||||
|
const [passed, filteredOut, flagged, overridden] = await Promise.all([
|
||||||
|
ctx.prisma.filteringResult.count({
|
||||||
|
where: {
|
||||||
|
roundId: input.roundId,
|
||||||
|
OR: [
|
||||||
|
{ finalOutcome: 'PASSED' },
|
||||||
|
{ finalOutcome: null, outcome: 'PASSED' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
ctx.prisma.filteringResult.count({
|
||||||
|
where: {
|
||||||
|
roundId: input.roundId,
|
||||||
|
OR: [
|
||||||
|
{ finalOutcome: 'FILTERED_OUT' },
|
||||||
|
{ finalOutcome: null, outcome: 'FILTERED_OUT' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
ctx.prisma.filteringResult.count({
|
||||||
|
where: {
|
||||||
|
roundId: input.roundId,
|
||||||
|
OR: [
|
||||||
|
{ finalOutcome: 'FLAGGED' },
|
||||||
|
{ finalOutcome: null, outcome: 'FLAGGED' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
ctx.prisma.filteringResult.count({
|
||||||
|
where: { roundId: input.roundId, overriddenBy: { not: null } },
|
||||||
|
}),
|
||||||
|
])
|
||||||
|
|
||||||
|
const round = await ctx.prisma.round.findUnique({
|
||||||
|
where: { id: input.roundId },
|
||||||
|
select: { competitionId: true },
|
||||||
|
})
|
||||||
|
|
||||||
|
let routedToAwards = 0
|
||||||
|
if (round?.competitionId) {
|
||||||
|
routedToAwards = await ctx.prisma.awardEligibility.count({
|
||||||
|
where: {
|
||||||
|
award: {
|
||||||
|
competitionId: round.competitionId,
|
||||||
|
eligibilityMode: 'SEPARATE_POOL',
|
||||||
|
},
|
||||||
|
shortlisted: true,
|
||||||
|
confirmedAt: { not: null },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return { passed, filteredOut, flagged, overridden, routedToAwards, total: passed + filteredOut + flagged }
|
||||||
|
}),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get filtering results list for a round (observer proxy of filtering.getResults)
|
||||||
|
*/
|
||||||
|
getFilteringResults: observerProcedure
|
||||||
|
.input(z.object({
|
||||||
|
roundId: z.string(),
|
||||||
|
outcome: z.enum(['PASSED', 'FILTERED_OUT', 'FLAGGED']).optional(),
|
||||||
|
page: z.number().int().min(1).default(1),
|
||||||
|
perPage: z.number().int().min(1).max(100).default(20),
|
||||||
|
}))
|
||||||
|
.query(async ({ ctx, input }) => {
|
||||||
|
const { roundId, outcome, page, perPage } = input
|
||||||
|
const skip = (page - 1) * perPage
|
||||||
|
|
||||||
|
const where: Record<string, unknown> = { roundId }
|
||||||
|
if (outcome) {
|
||||||
|
where.OR = [
|
||||||
|
{ finalOutcome: outcome },
|
||||||
|
{ finalOutcome: null, outcome },
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
const [results, total] = await Promise.all([
|
||||||
|
ctx.prisma.filteringResult.findMany({
|
||||||
|
where,
|
||||||
|
skip,
|
||||||
|
take: perPage,
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
outcome: true,
|
||||||
|
finalOutcome: true,
|
||||||
|
aiScreeningJson: true,
|
||||||
|
overrideReason: true,
|
||||||
|
project: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
title: true,
|
||||||
|
teamName: true,
|
||||||
|
competitionCategory: true,
|
||||||
|
country: true,
|
||||||
|
awardEligibilities: {
|
||||||
|
where: {
|
||||||
|
shortlisted: true,
|
||||||
|
confirmedAt: { not: null },
|
||||||
|
award: { eligibilityMode: 'SEPARATE_POOL' },
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
award: { select: { name: true } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
ctx.prisma.filteringResult.count({ where }),
|
||||||
|
])
|
||||||
|
|
||||||
|
return {
|
||||||
|
results,
|
||||||
|
total,
|
||||||
|
page,
|
||||||
|
perPage,
|
||||||
|
totalPages: Math.ceil(total / perPage),
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get deliberation sessions for a round
|
||||||
|
*/
|
||||||
|
getDeliberationSessions: observerProcedure
|
||||||
|
.input(z.object({ roundId: z.string() }))
|
||||||
|
.query(async ({ ctx, input }) => {
|
||||||
|
const sessions = await ctx.prisma.deliberationSession.findMany({
|
||||||
|
where: { roundId: input.roundId },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
category: true,
|
||||||
|
status: true,
|
||||||
|
mode: true,
|
||||||
|
_count: { select: { votes: true, participants: true } },
|
||||||
|
},
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
})
|
||||||
|
return sessions
|
||||||
|
}),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get aggregated vote results for a deliberation session
|
||||||
|
*/
|
||||||
|
getDeliberationAggregate: observerProcedure
|
||||||
|
.input(z.object({ sessionId: z.string() }))
|
||||||
|
.query(async ({ ctx, input }) => {
|
||||||
|
const agg = await aggregateVotes(input.sessionId, ctx.prisma)
|
||||||
|
|
||||||
|
const projectIds = agg.rankings.map((r) => r.projectId)
|
||||||
|
const projects = await ctx.prisma.project.findMany({
|
||||||
|
where: { id: { in: projectIds } },
|
||||||
|
select: { id: true, title: true, teamName: true },
|
||||||
|
})
|
||||||
|
const projectMap = new Map(projects.map((p) => [p.id, p]))
|
||||||
|
|
||||||
|
return {
|
||||||
|
rankings: agg.rankings.map((r) => ({
|
||||||
|
...r,
|
||||||
|
projectTitle: projectMap.get(r.projectId)?.title ?? 'Unknown',
|
||||||
|
teamName: projectMap.get(r.projectId)?.teamName ?? '',
|
||||||
|
})),
|
||||||
|
hasTies: agg.hasTies,
|
||||||
|
tiedProjectIds: agg.tiedProjectIds,
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get juror score matrix for a round (capped at 30 most-assigned projects)
|
||||||
|
*/
|
||||||
|
getJurorScoreMatrix: observerProcedure
|
||||||
|
.input(z.object({ roundId: z.string() }))
|
||||||
|
.query(async ({ ctx, input }) => {
|
||||||
|
const assignments = await ctx.prisma.assignment.findMany({
|
||||||
|
where: { roundId: input.roundId },
|
||||||
|
include: {
|
||||||
|
user: { select: { id: true, name: true } },
|
||||||
|
project: { select: { id: true, title: true } },
|
||||||
|
evaluation: {
|
||||||
|
select: { globalScore: true, status: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const jurorMap = new Map<string, string>()
|
||||||
|
const projectMap = new Map<string, string>()
|
||||||
|
const cells: { jurorId: string; projectId: string; score: number | null }[] = []
|
||||||
|
|
||||||
|
for (const a of assignments) {
|
||||||
|
jurorMap.set(a.user.id, a.user.name ?? 'Unknown')
|
||||||
|
projectMap.set(a.project.id, a.project.title)
|
||||||
|
|
||||||
|
if (a.evaluation?.status === 'SUBMITTED') {
|
||||||
|
cells.push({
|
||||||
|
jurorId: a.user.id,
|
||||||
|
projectId: a.project.id,
|
||||||
|
score: a.evaluation.globalScore,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const projectAssignCounts = new Map<string, number>()
|
||||||
|
for (const a of assignments) {
|
||||||
|
projectAssignCounts.set(a.project.id, (projectAssignCounts.get(a.project.id) ?? 0) + 1)
|
||||||
|
}
|
||||||
|
const topProjectIds = [...projectAssignCounts.entries()]
|
||||||
|
.sort(([, a], [, b]) => b - a)
|
||||||
|
.slice(0, 30)
|
||||||
|
.map(([id]) => id)
|
||||||
|
|
||||||
|
const topProjectSet = new Set(topProjectIds)
|
||||||
|
|
||||||
|
return {
|
||||||
|
jurors: [...jurorMap.entries()].map(([id, name]) => ({ id, name })),
|
||||||
|
projects: topProjectIds.map((id) => ({ id, title: projectMap.get(id) ?? 'Unknown' })),
|
||||||
|
cells: cells.filter((c) => topProjectSet.has(c.projectId)),
|
||||||
|
truncated: projectAssignCounts.size > 30,
|
||||||
|
totalProjects: projectAssignCounts.size,
|
||||||
|
}
|
||||||
|
}),
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -105,7 +105,7 @@ export const applicationRouter = router({
|
|||||||
if (input.mode === 'edition') {
|
if (input.mode === 'edition') {
|
||||||
// Edition-wide application mode
|
// Edition-wide application mode
|
||||||
const program = await ctx.prisma.program.findFirst({
|
const program = await ctx.prisma.program.findFirst({
|
||||||
where: { slug: input.slug },
|
where: { slug: input.slug, isTest: false },
|
||||||
})
|
})
|
||||||
|
|
||||||
if (!program) {
|
if (!program) {
|
||||||
@@ -687,6 +687,7 @@ export const applicationRouter = router({
|
|||||||
const projects = await ctx.prisma.project.findMany({
|
const projects = await ctx.prisma.project.findMany({
|
||||||
where: {
|
where: {
|
||||||
isDraft: true,
|
isDraft: true,
|
||||||
|
isTest: false,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -837,6 +838,7 @@ export const applicationRouter = router({
|
|||||||
const projects = await ctx.prisma.project.findMany({
|
const projects = await ctx.prisma.project.findMany({
|
||||||
where: {
|
where: {
|
||||||
isDraft: true,
|
isDraft: true,
|
||||||
|
isTest: false,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -560,6 +560,7 @@ async function runAIAssignmentJob(jobId: string, roundId: string, userId: string
|
|||||||
where: {
|
where: {
|
||||||
role: 'JURY_MEMBER',
|
role: 'JURY_MEMBER',
|
||||||
status: 'ACTIVE',
|
status: 'ACTIVE',
|
||||||
|
isTest: false,
|
||||||
...(scopedJurorIds ? { id: { in: scopedJurorIds } } : {}),
|
...(scopedJurorIds ? { id: { in: scopedJurorIds } } : {}),
|
||||||
},
|
},
|
||||||
select: {
|
select: {
|
||||||
@@ -1255,6 +1256,7 @@ export const assignmentRouter = router({
|
|||||||
where: {
|
where: {
|
||||||
role: 'JURY_MEMBER',
|
role: 'JURY_MEMBER',
|
||||||
status: 'ACTIVE',
|
status: 'ACTIVE',
|
||||||
|
isTest: false,
|
||||||
...(scopedJurorIds ? { id: { in: scopedJurorIds } } : {}),
|
...(scopedJurorIds ? { id: { in: scopedJurorIds } } : {}),
|
||||||
},
|
},
|
||||||
select: {
|
select: {
|
||||||
|
|||||||
@@ -142,7 +142,7 @@ export const competitionRouter = router({
|
|||||||
.input(z.object({ programId: z.string() }))
|
.input(z.object({ programId: z.string() }))
|
||||||
.query(async ({ ctx, input }) => {
|
.query(async ({ ctx, input }) => {
|
||||||
return ctx.prisma.competition.findMany({
|
return ctx.prisma.competition.findMany({
|
||||||
where: { programId: input.programId },
|
where: { programId: input.programId, isTest: false },
|
||||||
orderBy: { createdAt: 'desc' },
|
orderBy: { createdAt: 'desc' },
|
||||||
include: {
|
include: {
|
||||||
_count: {
|
_count: {
|
||||||
@@ -254,7 +254,7 @@ export const competitionRouter = router({
|
|||||||
const competitionIds = [...new Set(memberships.map((m) => m.juryGroup.competitionId))]
|
const competitionIds = [...new Set(memberships.map((m) => m.juryGroup.competitionId))]
|
||||||
if (competitionIds.length === 0) return []
|
if (competitionIds.length === 0) return []
|
||||||
return ctx.prisma.competition.findMany({
|
return ctx.prisma.competition.findMany({
|
||||||
where: { id: { in: competitionIds }, status: { not: 'ARCHIVED' } },
|
where: { id: { in: competitionIds }, status: { not: 'ARCHIVED' }, isTest: false },
|
||||||
include: {
|
include: {
|
||||||
rounds: {
|
rounds: {
|
||||||
orderBy: { sortOrder: 'asc' },
|
orderBy: { sortOrder: 'asc' },
|
||||||
|
|||||||
@@ -172,18 +172,19 @@ export const dashboardRouter = router({
|
|||||||
|
|
||||||
// 7. Project count
|
// 7. Project count
|
||||||
ctx.prisma.project.count({
|
ctx.prisma.project.count({
|
||||||
where: { programId: editionId },
|
where: { programId: editionId, isTest: false },
|
||||||
}),
|
}),
|
||||||
|
|
||||||
// 8. New projects this week
|
// 8. New projects this week
|
||||||
ctx.prisma.project.count({
|
ctx.prisma.project.count({
|
||||||
where: { programId: editionId, createdAt: { gte: sevenDaysAgo } },
|
where: { programId: editionId, isTest: false, createdAt: { gte: sevenDaysAgo } },
|
||||||
}),
|
}),
|
||||||
|
|
||||||
// 9. Total jurors
|
// 9. Total jurors
|
||||||
ctx.prisma.user.count({
|
ctx.prisma.user.count({
|
||||||
where: {
|
where: {
|
||||||
role: 'JURY_MEMBER',
|
role: 'JURY_MEMBER',
|
||||||
|
isTest: false,
|
||||||
status: { in: ['ACTIVE', 'INVITED', 'NONE'] },
|
status: { in: ['ACTIVE', 'INVITED', 'NONE'] },
|
||||||
assignments: { some: { round: { competition: { programId: editionId } } } },
|
assignments: { some: { round: { competition: { programId: editionId } } } },
|
||||||
},
|
},
|
||||||
@@ -193,6 +194,7 @@ export const dashboardRouter = router({
|
|||||||
ctx.prisma.user.count({
|
ctx.prisma.user.count({
|
||||||
where: {
|
where: {
|
||||||
role: 'JURY_MEMBER',
|
role: 'JURY_MEMBER',
|
||||||
|
isTest: false,
|
||||||
status: 'ACTIVE',
|
status: 'ACTIVE',
|
||||||
assignments: { some: { round: { competition: { programId: editionId } } } },
|
assignments: { some: { round: { competition: { programId: editionId } } } },
|
||||||
},
|
},
|
||||||
@@ -212,7 +214,7 @@ export const dashboardRouter = router({
|
|||||||
|
|
||||||
// 13. Latest projects
|
// 13. Latest projects
|
||||||
ctx.prisma.project.findMany({
|
ctx.prisma.project.findMany({
|
||||||
where: { programId: editionId },
|
where: { programId: editionId, isTest: false },
|
||||||
orderBy: { createdAt: 'desc' },
|
orderBy: { createdAt: 'desc' },
|
||||||
take: 8,
|
take: 8,
|
||||||
select: {
|
select: {
|
||||||
@@ -232,20 +234,20 @@ export const dashboardRouter = router({
|
|||||||
// 14. Category breakdown
|
// 14. Category breakdown
|
||||||
ctx.prisma.project.groupBy({
|
ctx.prisma.project.groupBy({
|
||||||
by: ['competitionCategory'],
|
by: ['competitionCategory'],
|
||||||
where: { programId: editionId },
|
where: { programId: editionId, isTest: false },
|
||||||
_count: true,
|
_count: true,
|
||||||
}),
|
}),
|
||||||
|
|
||||||
// 15. Ocean issue breakdown
|
// 15. Ocean issue breakdown
|
||||||
ctx.prisma.project.groupBy({
|
ctx.prisma.project.groupBy({
|
||||||
by: ['oceanIssue'],
|
by: ['oceanIssue'],
|
||||||
where: { programId: editionId },
|
where: { programId: editionId, isTest: false },
|
||||||
_count: true,
|
_count: true,
|
||||||
}),
|
}),
|
||||||
|
|
||||||
// 16. Recent activity
|
// 16. Recent activity (exclude test user actions)
|
||||||
ctx.prisma.auditLog.findMany({
|
ctx.prisma.auditLog.findMany({
|
||||||
where: { timestamp: { gte: sevenDaysAgo } },
|
where: { timestamp: { gte: sevenDaysAgo }, user: { isTest: false } },
|
||||||
orderBy: { timestamp: 'desc' },
|
orderBy: { timestamp: 'desc' },
|
||||||
take: 8,
|
take: 8,
|
||||||
select: {
|
select: {
|
||||||
|
|||||||
@@ -105,6 +105,7 @@ export const exportRouter = router({
|
|||||||
.query(async ({ ctx, input }) => {
|
.query(async ({ ctx, input }) => {
|
||||||
const projects = await ctx.prisma.project.findMany({
|
const projects = await ctx.prisma.project.findMany({
|
||||||
where: {
|
where: {
|
||||||
|
isTest: false,
|
||||||
assignments: { some: { roundId: input.roundId } },
|
assignments: { some: { roundId: input.roundId } },
|
||||||
},
|
},
|
||||||
include: {
|
include: {
|
||||||
@@ -355,7 +356,7 @@ export const exportRouter = router({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const logs = await ctx.prisma.auditLog.findMany({
|
const logs = await ctx.prisma.auditLog.findMany({
|
||||||
where,
|
where: { ...where, user: { isTest: false } },
|
||||||
orderBy: { timestamp: 'desc' },
|
orderBy: { timestamp: 'desc' },
|
||||||
include: {
|
include: {
|
||||||
user: { select: { name: true, email: true } },
|
user: { select: { name: true, email: true } },
|
||||||
@@ -431,7 +432,7 @@ export const exportRouter = router({
|
|||||||
if (includeSection('summary')) {
|
if (includeSection('summary')) {
|
||||||
const [projectCount, assignmentCount, evaluationCount, jurorCount] = await Promise.all([
|
const [projectCount, assignmentCount, evaluationCount, jurorCount] = await Promise.all([
|
||||||
ctx.prisma.project.count({
|
ctx.prisma.project.count({
|
||||||
where: { assignments: { some: { roundId: input.roundId } } },
|
where: { isTest: false, assignments: { some: { roundId: input.roundId } } },
|
||||||
}),
|
}),
|
||||||
ctx.prisma.assignment.count({ where: { roundId: input.roundId } }),
|
ctx.prisma.assignment.count({ where: { roundId: input.roundId } }),
|
||||||
ctx.prisma.evaluation.count({
|
ctx.prisma.evaluation.count({
|
||||||
@@ -486,7 +487,7 @@ export const exportRouter = router({
|
|||||||
// Rankings
|
// Rankings
|
||||||
if (includeSection('rankings')) {
|
if (includeSection('rankings')) {
|
||||||
const projects = await ctx.prisma.project.findMany({
|
const projects = await ctx.prisma.project.findMany({
|
||||||
where: { assignments: { some: { roundId: input.roundId } } },
|
where: { isTest: false, assignments: { some: { roundId: input.roundId } } },
|
||||||
select: {
|
select: {
|
||||||
id: true,
|
id: true,
|
||||||
title: true,
|
title: true,
|
||||||
|
|||||||
@@ -994,6 +994,7 @@ export const fileRouter = router({
|
|||||||
// Build project filter
|
// Build project filter
|
||||||
const projectWhere: Record<string, unknown> = {
|
const projectWhere: Record<string, unknown> = {
|
||||||
programId: window.competition.programId,
|
programId: window.competition.programId,
|
||||||
|
isTest: false,
|
||||||
}
|
}
|
||||||
if (input.search) {
|
if (input.search) {
|
||||||
projectWhere.OR = [
|
projectWhere.OR = [
|
||||||
@@ -1303,6 +1304,7 @@ export const fileRouter = router({
|
|||||||
// Build project filter
|
// Build project filter
|
||||||
const projectWhere: Record<string, unknown> = {
|
const projectWhere: Record<string, unknown> = {
|
||||||
programId: round.competition.programId,
|
programId: round.competition.programId,
|
||||||
|
isTest: false,
|
||||||
}
|
}
|
||||||
if (input.search) {
|
if (input.search) {
|
||||||
projectWhere.OR = [
|
projectWhere.OR = [
|
||||||
|
|||||||
@@ -115,6 +115,7 @@ export async function runFilteringJob(jobId: string, roundId: string, userId: st
|
|||||||
roundId,
|
roundId,
|
||||||
exitedAt: null,
|
exitedAt: null,
|
||||||
state: { in: ['PENDING', 'IN_PROGRESS'] },
|
state: { in: ['PENDING', 'IN_PROGRESS'] },
|
||||||
|
project: { isTest: false },
|
||||||
},
|
},
|
||||||
include: {
|
include: {
|
||||||
project: {
|
project: {
|
||||||
|
|||||||
@@ -420,6 +420,7 @@ export const mentorRouter = router({
|
|||||||
const projects = await ctx.prisma.project.findMany({
|
const projects = await ctx.prisma.project.findMany({
|
||||||
where: {
|
where: {
|
||||||
programId: input.programId,
|
programId: input.programId,
|
||||||
|
isTest: false,
|
||||||
mentorAssignment: null,
|
mentorAssignment: null,
|
||||||
wantsMentorship: true,
|
wantsMentorship: true,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -402,7 +402,7 @@ async function resolveRecipients(
|
|||||||
const role = filter?.role as string
|
const role = filter?.role as string
|
||||||
if (!role) return []
|
if (!role) return []
|
||||||
const users = await prisma.user.findMany({
|
const users = await prisma.user.findMany({
|
||||||
where: { role: role as any, status: 'ACTIVE' },
|
where: { role: role as any, status: 'ACTIVE', isTest: false },
|
||||||
select: { id: true },
|
select: { id: true },
|
||||||
})
|
})
|
||||||
return users.map((u) => u.id)
|
return users.map((u) => u.id)
|
||||||
@@ -412,7 +412,7 @@ async function resolveRecipients(
|
|||||||
const targetRoundId = roundId || (filter?.roundId as string)
|
const targetRoundId = roundId || (filter?.roundId as string)
|
||||||
if (!targetRoundId) return []
|
if (!targetRoundId) return []
|
||||||
const assignments = await prisma.assignment.findMany({
|
const assignments = await prisma.assignment.findMany({
|
||||||
where: { roundId: targetRoundId },
|
where: { roundId: targetRoundId, user: { isTest: false } },
|
||||||
select: { userId: true },
|
select: { userId: true },
|
||||||
distinct: ['userId'],
|
distinct: ['userId'],
|
||||||
})
|
})
|
||||||
@@ -423,7 +423,7 @@ async function resolveRecipients(
|
|||||||
const programId = filter?.programId as string
|
const programId = filter?.programId as string
|
||||||
if (!programId) return []
|
if (!programId) return []
|
||||||
const projects = await prisma.project.findMany({
|
const projects = await prisma.project.findMany({
|
||||||
where: { programId },
|
where: { programId, isTest: false },
|
||||||
select: { submittedByUserId: true },
|
select: { submittedByUserId: true },
|
||||||
})
|
})
|
||||||
const ids = new Set(projects.map((p) => p.submittedByUserId).filter(Boolean) as string[])
|
const ids = new Set(projects.map((p) => p.submittedByUserId).filter(Boolean) as string[])
|
||||||
@@ -432,7 +432,7 @@ async function resolveRecipients(
|
|||||||
|
|
||||||
case 'ALL': {
|
case 'ALL': {
|
||||||
const users = await prisma.user.findMany({
|
const users = await prisma.user.findMany({
|
||||||
where: { status: 'ACTIVE' },
|
where: { status: 'ACTIVE', isTest: false },
|
||||||
select: { id: true },
|
select: { id: true },
|
||||||
})
|
})
|
||||||
return users.map((u) => u.id)
|
return users.map((u) => u.id)
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ export const programRouter = router({
|
|||||||
const includeStages = input?.includeStages || false
|
const includeStages = input?.includeStages || false
|
||||||
|
|
||||||
const programs = await ctx.prisma.program.findMany({
|
const programs = await ctx.prisma.program.findMany({
|
||||||
where: input?.status ? { status: input.status } : undefined,
|
where: input?.status ? { isTest: false, status: input.status } : { isTest: false },
|
||||||
orderBy: { year: 'desc' },
|
orderBy: { year: 'desc' },
|
||||||
include: includeStages
|
include: includeStages
|
||||||
? {
|
? {
|
||||||
@@ -34,6 +34,10 @@ export const programRouter = router({
|
|||||||
_count: {
|
_count: {
|
||||||
select: { assignments: true, projectRoundStates: true },
|
select: { assignments: true, projectRoundStates: true },
|
||||||
},
|
},
|
||||||
|
assignments: {
|
||||||
|
where: { evaluation: { status: 'SUBMITTED' } },
|
||||||
|
select: { id: true },
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -52,9 +56,11 @@ export const programRouter = router({
|
|||||||
// Provide `stages` as alias for backward compatibility
|
// Provide `stages` as alias for backward compatibility
|
||||||
stages: allRounds.map((round: any) => ({
|
stages: allRounds.map((round: any) => ({
|
||||||
...round,
|
...round,
|
||||||
|
assignments: undefined, // don't leak raw assignments array
|
||||||
_count: {
|
_count: {
|
||||||
projects: round._count?.projectRoundStates || 0,
|
projects: round._count?.projectRoundStates || 0,
|
||||||
assignments: round._count?.assignments || 0,
|
assignments: round._count?.assignments || 0,
|
||||||
|
evaluations: round.assignments?.length || 0,
|
||||||
},
|
},
|
||||||
})),
|
})),
|
||||||
// Main rounds array
|
// Main rounds array
|
||||||
@@ -68,6 +74,7 @@ export const programRouter = router({
|
|||||||
_count: {
|
_count: {
|
||||||
projects: round._count?.projectRoundStates || 0,
|
projects: round._count?.projectRoundStates || 0,
|
||||||
assignments: round._count?.assignments || 0,
|
assignments: round._count?.assignments || 0,
|
||||||
|
evaluations: round.assignments?.length || 0,
|
||||||
},
|
},
|
||||||
})),
|
})),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -103,6 +103,7 @@ export const projectPoolRouter = router({
|
|||||||
|
|
||||||
// Build where clause
|
// Build where clause
|
||||||
const where: Record<string, unknown> = {
|
const where: Record<string, unknown> = {
|
||||||
|
isTest: false,
|
||||||
programId,
|
programId,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -317,6 +318,7 @@ export const projectPoolRouter = router({
|
|||||||
|
|
||||||
// Find projects to assign
|
// Find projects to assign
|
||||||
const where: Record<string, unknown> = {
|
const where: Record<string, unknown> = {
|
||||||
|
isTest: false,
|
||||||
programId,
|
programId,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -84,7 +84,9 @@ export const projectRouter = router({
|
|||||||
const skip = (page - 1) * perPage
|
const skip = (page - 1) * perPage
|
||||||
|
|
||||||
// Build where clause
|
// Build where clause
|
||||||
const where: Record<string, unknown> = {}
|
const where: Record<string, unknown> = {
|
||||||
|
isTest: false,
|
||||||
|
}
|
||||||
|
|
||||||
// Filter by program
|
// Filter by program
|
||||||
if (programId) where.programId = programId
|
if (programId) where.programId = programId
|
||||||
@@ -219,7 +221,9 @@ export const projectRouter = router({
|
|||||||
wantsMentorship, hasFiles, hasAssignments,
|
wantsMentorship, hasFiles, hasAssignments,
|
||||||
} = input
|
} = input
|
||||||
|
|
||||||
const where: Record<string, unknown> = {}
|
const where: Record<string, unknown> = {
|
||||||
|
isTest: false,
|
||||||
|
}
|
||||||
|
|
||||||
if (programId) where.programId = programId
|
if (programId) where.programId = programId
|
||||||
if (roundId) {
|
if (roundId) {
|
||||||
@@ -357,19 +361,19 @@ export const projectRouter = router({
|
|||||||
.query(async ({ ctx }) => {
|
.query(async ({ ctx }) => {
|
||||||
const [countries, categories, issues] = await Promise.all([
|
const [countries, categories, issues] = await Promise.all([
|
||||||
ctx.prisma.project.findMany({
|
ctx.prisma.project.findMany({
|
||||||
where: { country: { not: null } },
|
where: { isTest: false, country: { not: null } },
|
||||||
select: { country: true },
|
select: { country: true },
|
||||||
distinct: ['country'],
|
distinct: ['country'],
|
||||||
orderBy: { country: 'asc' },
|
orderBy: { country: 'asc' },
|
||||||
}),
|
}),
|
||||||
ctx.prisma.project.groupBy({
|
ctx.prisma.project.groupBy({
|
||||||
by: ['competitionCategory'],
|
by: ['competitionCategory'],
|
||||||
where: { competitionCategory: { not: null } },
|
where: { isTest: false, competitionCategory: { not: null } },
|
||||||
_count: true,
|
_count: true,
|
||||||
}),
|
}),
|
||||||
ctx.prisma.project.groupBy({
|
ctx.prisma.project.groupBy({
|
||||||
by: ['oceanIssue'],
|
by: ['oceanIssue'],
|
||||||
where: { oceanIssue: { not: null } },
|
where: { isTest: false, oceanIssue: { not: null } },
|
||||||
_count: true,
|
_count: true,
|
||||||
}),
|
}),
|
||||||
])
|
])
|
||||||
@@ -838,7 +842,7 @@ export const projectRouter = router({
|
|||||||
)
|
)
|
||||||
.mutation(async ({ ctx, input }) => {
|
.mutation(async ({ ctx, input }) => {
|
||||||
const projects = await ctx.prisma.project.findMany({
|
const projects = await ctx.prisma.project.findMany({
|
||||||
where: { id: { in: input.ids } },
|
where: { id: { in: input.ids }, isTest: false },
|
||||||
select: { id: true, title: true, status: true },
|
select: { id: true, title: true, status: true },
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -948,11 +952,13 @@ export const projectRouter = router({
|
|||||||
programId: z.string().optional(),
|
programId: z.string().optional(),
|
||||||
}))
|
}))
|
||||||
.query(async ({ ctx, input }) => {
|
.query(async ({ ctx, input }) => {
|
||||||
const where: Record<string, unknown> = {}
|
const where: Record<string, unknown> = {
|
||||||
|
isTest: false,
|
||||||
|
}
|
||||||
if (input.programId) where.programId = input.programId
|
if (input.programId) where.programId = input.programId
|
||||||
|
|
||||||
const projects = await ctx.prisma.project.findMany({
|
const projects = await ctx.prisma.project.findMany({
|
||||||
where: Object.keys(where).length > 0 ? where : undefined,
|
where,
|
||||||
select: { tags: true },
|
select: { tags: true },
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -984,6 +990,7 @@ export const projectRouter = router({
|
|||||||
const projects = await ctx.prisma.project.findMany({
|
const projects = await ctx.prisma.project.findMany({
|
||||||
where: {
|
where: {
|
||||||
id: { in: input.ids },
|
id: { in: input.ids },
|
||||||
|
isTest: false,
|
||||||
},
|
},
|
||||||
select: { id: true, title: true },
|
select: { id: true, title: true },
|
||||||
})
|
})
|
||||||
@@ -1102,6 +1109,7 @@ export const projectRouter = router({
|
|||||||
|
|
||||||
const where: Record<string, unknown> = {
|
const where: Record<string, unknown> = {
|
||||||
programId,
|
programId,
|
||||||
|
isTest: false,
|
||||||
projectRoundStates: { none: {} }, // Projects not assigned to any round
|
projectRoundStates: { none: {} }, // Projects not assigned to any round
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -17,6 +17,11 @@ function categorizeModel(modelId: string): string {
|
|||||||
if (id.startsWith('gpt-4')) return 'gpt-4'
|
if (id.startsWith('gpt-4')) return 'gpt-4'
|
||||||
if (id.startsWith('gpt-3.5')) return 'gpt-3.5'
|
if (id.startsWith('gpt-3.5')) return 'gpt-3.5'
|
||||||
if (id.startsWith('o1') || id.startsWith('o3') || id.startsWith('o4')) return 'reasoning'
|
if (id.startsWith('o1') || id.startsWith('o3') || id.startsWith('o4')) return 'reasoning'
|
||||||
|
// Anthropic Claude models
|
||||||
|
if (id.startsWith('claude-opus-4-5') || id.startsWith('claude-sonnet-4-5')) return 'claude-4.5'
|
||||||
|
if (id.startsWith('claude-opus-4') || id.startsWith('claude-sonnet-4')) return 'claude-4'
|
||||||
|
if (id.startsWith('claude-haiku') || id.startsWith('claude-3')) return 'claude-3.5'
|
||||||
|
if (id.startsWith('claude')) return 'claude-4'
|
||||||
return 'other'
|
return 'other'
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -26,16 +31,10 @@ export const settingsRouter = router({
|
|||||||
* These are non-sensitive settings that can be exposed to any user
|
* These are non-sensitive settings that can be exposed to any user
|
||||||
*/
|
*/
|
||||||
getFeatureFlags: protectedProcedure.query(async ({ ctx }) => {
|
getFeatureFlags: protectedProcedure.query(async ({ ctx }) => {
|
||||||
const [whatsappEnabled, defaultLocale, availableLocales, juryCompareEnabled] = await Promise.all([
|
const [whatsappEnabled, juryCompareEnabled] = await Promise.all([
|
||||||
ctx.prisma.systemSettings.findUnique({
|
ctx.prisma.systemSettings.findUnique({
|
||||||
where: { key: 'whatsapp_enabled' },
|
where: { key: 'whatsapp_enabled' },
|
||||||
}),
|
}),
|
||||||
ctx.prisma.systemSettings.findUnique({
|
|
||||||
where: { key: 'i18n_default_locale' },
|
|
||||||
}),
|
|
||||||
ctx.prisma.systemSettings.findUnique({
|
|
||||||
where: { key: 'i18n_available_locales' },
|
|
||||||
}),
|
|
||||||
ctx.prisma.systemSettings.findUnique({
|
ctx.prisma.systemSettings.findUnique({
|
||||||
where: { key: 'jury_compare_enabled' },
|
where: { key: 'jury_compare_enabled' },
|
||||||
}),
|
}),
|
||||||
@@ -43,8 +42,6 @@ export const settingsRouter = router({
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
whatsappEnabled: whatsappEnabled?.value === 'true',
|
whatsappEnabled: whatsappEnabled?.value === 'true',
|
||||||
defaultLocale: defaultLocale?.value || 'en',
|
|
||||||
availableLocales: availableLocales?.value ? JSON.parse(availableLocales.value) : ['en', 'fr'],
|
|
||||||
juryCompareEnabled: juryCompareEnabled?.value === 'true',
|
juryCompareEnabled: juryCompareEnabled?.value === 'true',
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
@@ -171,14 +168,13 @@ export const settingsRouter = router({
|
|||||||
)
|
)
|
||||||
.mutation(async ({ ctx, input }) => {
|
.mutation(async ({ ctx, input }) => {
|
||||||
// Infer category from key prefix if not provided
|
// Infer category from key prefix if not provided
|
||||||
const inferCategory = (key: string): 'AI' | 'BRANDING' | 'EMAIL' | 'STORAGE' | 'SECURITY' | 'DEFAULTS' | 'WHATSAPP' | 'LOCALIZATION' => {
|
const inferCategory = (key: string): 'AI' | 'BRANDING' | 'EMAIL' | 'STORAGE' | 'SECURITY' | 'DEFAULTS' | 'WHATSAPP' => {
|
||||||
if (key.startsWith('openai') || key.startsWith('ai_')) return 'AI'
|
if (key.startsWith('openai') || key.startsWith('ai_') || key.startsWith('anthropic')) return 'AI'
|
||||||
if (key.startsWith('smtp_') || key.startsWith('email_')) return 'EMAIL'
|
if (key.startsWith('smtp_') || key.startsWith('email_')) return 'EMAIL'
|
||||||
if (key.startsWith('storage_') || key.startsWith('local_storage') || key.startsWith('max_file') || key.startsWith('avatar_') || key.startsWith('allowed_file')) return 'STORAGE'
|
if (key.startsWith('storage_') || key.startsWith('local_storage') || key.startsWith('max_file') || key.startsWith('avatar_') || key.startsWith('allowed_file')) return 'STORAGE'
|
||||||
if (key.startsWith('brand_') || key.startsWith('logo_') || key.startsWith('primary_') || key.startsWith('theme_')) return 'BRANDING'
|
if (key.startsWith('brand_') || key.startsWith('logo_') || key.startsWith('primary_') || key.startsWith('theme_')) return 'BRANDING'
|
||||||
if (key.startsWith('whatsapp_')) return 'WHATSAPP'
|
if (key.startsWith('whatsapp_')) return 'WHATSAPP'
|
||||||
if (key.startsWith('security_') || key.startsWith('session_')) return 'SECURITY'
|
if (key.startsWith('security_') || key.startsWith('session_')) return 'SECURITY'
|
||||||
if (key.startsWith('i18n_') || key.startsWith('locale_')) return 'LOCALIZATION'
|
|
||||||
return 'DEFAULTS'
|
return 'DEFAULTS'
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -206,7 +202,7 @@ export const settingsRouter = router({
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Reset OpenAI client if API key, base URL, model, or provider changed
|
// Reset OpenAI client if API key, base URL, model, or provider changed
|
||||||
if (input.settings.some((s) => s.key === 'openai_api_key' || s.key === 'openai_base_url' || s.key === 'ai_model' || s.key === 'ai_provider')) {
|
if (input.settings.some((s) => s.key === 'openai_api_key' || s.key === 'anthropic_api_key' || s.key === 'openai_base_url' || s.key === 'ai_model' || s.key === 'ai_provider')) {
|
||||||
const { resetOpenAIClient } = await import('@/lib/openai')
|
const { resetOpenAIClient } = await import('@/lib/openai')
|
||||||
resetOpenAIClient()
|
resetOpenAIClient()
|
||||||
}
|
}
|
||||||
@@ -276,9 +272,9 @@ export const settingsRouter = router({
|
|||||||
category: categorizeModel(model),
|
category: categorizeModel(model),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
// Sort: GPT-5+ first, then GPT-4o, then other GPT-4, then GPT-3.5, then reasoning models
|
// Sort by category priority
|
||||||
const sorted = categorizedModels.sort((a, b) => {
|
const sorted = categorizedModels.sort((a, b) => {
|
||||||
const order = ['gpt-5+', 'gpt-4o', 'gpt-4', 'gpt-3.5', 'reasoning', 'other']
|
const order = ['claude-4.5', 'claude-4', 'claude-3.5', 'gpt-5+', 'gpt-4o', 'gpt-4', 'gpt-3.5', 'reasoning', 'other']
|
||||||
const aOrder = order.findIndex(cat => a.category === cat)
|
const aOrder = order.findIndex(cat => a.category === cat)
|
||||||
const bOrder = order.findIndex(cat => b.category === cat)
|
const bOrder = order.findIndex(cat => b.category === cat)
|
||||||
if (aOrder !== bOrder) return aOrder - bOrder
|
if (aOrder !== bOrder) return aOrder - bOrder
|
||||||
@@ -740,62 +736,4 @@ export const settingsRouter = router({
|
|||||||
return results
|
return results
|
||||||
}),
|
}),
|
||||||
|
|
||||||
/**
|
|
||||||
* Get localization settings
|
|
||||||
*/
|
|
||||||
getLocalizationSettings: adminProcedure.query(async ({ ctx }) => {
|
|
||||||
const settings = await ctx.prisma.systemSettings.findMany({
|
|
||||||
where: { category: 'LOCALIZATION' },
|
|
||||||
orderBy: { key: 'asc' },
|
|
||||||
})
|
|
||||||
|
|
||||||
return settings
|
|
||||||
}),
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Update localization settings
|
|
||||||
*/
|
|
||||||
updateLocalizationSettings: superAdminProcedure
|
|
||||||
.input(
|
|
||||||
z.object({
|
|
||||||
settings: z.array(
|
|
||||||
z.object({
|
|
||||||
key: z.string(),
|
|
||||||
value: z.string(),
|
|
||||||
})
|
|
||||||
),
|
|
||||||
})
|
|
||||||
)
|
|
||||||
.mutation(async ({ ctx, input }) => {
|
|
||||||
const results = await Promise.all(
|
|
||||||
input.settings.map((s) =>
|
|
||||||
ctx.prisma.systemSettings.upsert({
|
|
||||||
where: { key: s.key },
|
|
||||||
update: { value: s.value, updatedBy: ctx.user.id },
|
|
||||||
create: {
|
|
||||||
key: s.key,
|
|
||||||
value: s.value,
|
|
||||||
category: 'LOCALIZATION',
|
|
||||||
updatedBy: ctx.user.id,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
try {
|
|
||||||
await logAudit({
|
|
||||||
prisma: ctx.prisma,
|
|
||||||
userId: ctx.user.id,
|
|
||||||
action: 'UPDATE_LOCALIZATION_SETTINGS',
|
|
||||||
entityType: 'SystemSettings',
|
|
||||||
detailsJson: { keys: input.settings.map((s) => s.key) },
|
|
||||||
ipAddress: ctx.ip,
|
|
||||||
userAgent: ctx.userAgent,
|
|
||||||
})
|
|
||||||
} catch {
|
|
||||||
// Never throw on audit failure
|
|
||||||
}
|
|
||||||
|
|
||||||
return results
|
|
||||||
}),
|
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -93,7 +93,7 @@ export const specialAwardRouter = router({
|
|||||||
let { competition } = award
|
let { competition } = award
|
||||||
if (!competition && award.programId) {
|
if (!competition && award.programId) {
|
||||||
const comp = await ctx.prisma.competition.findFirst({
|
const comp = await ctx.prisma.competition.findFirst({
|
||||||
where: { programId: award.programId },
|
where: { programId: award.programId, isTest: false },
|
||||||
orderBy: { createdAt: 'desc' },
|
orderBy: { createdAt: 'desc' },
|
||||||
select: { id: true, name: true, rounds: { select: { id: true, name: true, roundType: true, sortOrder: true }, orderBy: { sortOrder: 'asc' as const } } },
|
select: { id: true, name: true, rounds: { select: { id: true, name: true, roundType: true, sortOrder: true }, orderBy: { sortOrder: 'asc' as const } } },
|
||||||
})
|
})
|
||||||
@@ -141,7 +141,7 @@ export const specialAwardRouter = router({
|
|||||||
let competitionId = input.competitionId
|
let competitionId = input.competitionId
|
||||||
if (!competitionId) {
|
if (!competitionId) {
|
||||||
const comp = await ctx.prisma.competition.findFirst({
|
const comp = await ctx.prisma.competition.findFirst({
|
||||||
where: { programId: input.programId },
|
where: { programId: input.programId, isTest: false },
|
||||||
orderBy: { createdAt: 'desc' },
|
orderBy: { createdAt: 'desc' },
|
||||||
select: { id: true },
|
select: { id: true },
|
||||||
})
|
})
|
||||||
@@ -217,7 +217,7 @@ export const specialAwardRouter = router({
|
|||||||
})
|
})
|
||||||
if (existing && !existing.competitionId) {
|
if (existing && !existing.competitionId) {
|
||||||
const comp = await ctx.prisma.competition.findFirst({
|
const comp = await ctx.prisma.competition.findFirst({
|
||||||
where: { programId: existing.programId },
|
where: { programId: existing.programId, isTest: false },
|
||||||
orderBy: { createdAt: 'desc' },
|
orderBy: { createdAt: 'desc' },
|
||||||
select: { id: true },
|
select: { id: true },
|
||||||
})
|
})
|
||||||
@@ -404,7 +404,7 @@ export const specialAwardRouter = router({
|
|||||||
const { awardId, eligibleOnly, page, perPage } = input
|
const { awardId, eligibleOnly, page, perPage } = input
|
||||||
const skip = (page - 1) * perPage
|
const skip = (page - 1) * perPage
|
||||||
|
|
||||||
const where: Record<string, unknown> = { awardId }
|
const where: Record<string, unknown> = { awardId, project: { isTest: false } }
|
||||||
if (eligibleOnly) where.eligible = true
|
if (eligibleOnly) where.eligible = true
|
||||||
|
|
||||||
const [eligibilities, total] = await Promise.all([
|
const [eligibilities, total] = await Promise.all([
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ async function runTaggingJob(jobId: string, userId: string) {
|
|||||||
if (!job.programId) {
|
if (!job.programId) {
|
||||||
throw new Error('Job must have a programId')
|
throw new Error('Job must have a programId')
|
||||||
}
|
}
|
||||||
const whereClause = { programId: job.programId }
|
const whereClause = { programId: job.programId, isTest: false }
|
||||||
|
|
||||||
const allProjects = await prisma.project.findMany({
|
const allProjects = await prisma.project.findMany({
|
||||||
where: whereClause,
|
where: whereClause,
|
||||||
@@ -196,11 +196,13 @@ export const tagRouter = router({
|
|||||||
const userCount = await ctx.prisma.user.count({
|
const userCount = await ctx.prisma.user.count({
|
||||||
where: {
|
where: {
|
||||||
expertiseTags: { has: tag.name },
|
expertiseTags: { has: tag.name },
|
||||||
|
isTest: false,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
const projectCount = await ctx.prisma.project.count({
|
const projectCount = await ctx.prisma.project.count({
|
||||||
where: {
|
where: {
|
||||||
tags: { has: tag.name },
|
tags: { has: tag.name },
|
||||||
|
isTest: false,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
return {
|
return {
|
||||||
@@ -228,10 +230,10 @@ export const tagRouter = router({
|
|||||||
// Get usage counts
|
// Get usage counts
|
||||||
const [userCount, projectCount] = await Promise.all([
|
const [userCount, projectCount] = await Promise.all([
|
||||||
ctx.prisma.user.count({
|
ctx.prisma.user.count({
|
||||||
where: { expertiseTags: { has: tag.name } },
|
where: { expertiseTags: { has: tag.name }, isTest: false },
|
||||||
}),
|
}),
|
||||||
ctx.prisma.project.count({
|
ctx.prisma.project.count({
|
||||||
where: { tags: { has: tag.name } },
|
where: { tags: { has: tag.name }, isTest: false },
|
||||||
}),
|
}),
|
||||||
])
|
])
|
||||||
|
|
||||||
@@ -354,7 +356,7 @@ export const tagRouter = router({
|
|||||||
|
|
||||||
// Update users
|
// Update users
|
||||||
const usersWithTag = await ctx.prisma.user.findMany({
|
const usersWithTag = await ctx.prisma.user.findMany({
|
||||||
where: { expertiseTags: { has: oldTag.name } },
|
where: { expertiseTags: { has: oldTag.name }, isTest: false },
|
||||||
select: { id: true, expertiseTags: true },
|
select: { id: true, expertiseTags: true },
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -371,7 +373,7 @@ export const tagRouter = router({
|
|||||||
|
|
||||||
// Update projects
|
// Update projects
|
||||||
const projectsWithTag = await ctx.prisma.project.findMany({
|
const projectsWithTag = await ctx.prisma.project.findMany({
|
||||||
where: { tags: { has: oldTag.name } },
|
where: { tags: { has: oldTag.name }, isTest: false },
|
||||||
select: { id: true, tags: true },
|
select: { id: true, tags: true },
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -412,9 +414,9 @@ export const tagRouter = router({
|
|||||||
where: { id: input.id },
|
where: { id: input.id },
|
||||||
})
|
})
|
||||||
|
|
||||||
// Remove tag from all users
|
// Remove tag from all users (excluding test users)
|
||||||
const usersWithTag = await ctx.prisma.user.findMany({
|
const usersWithTag = await ctx.prisma.user.findMany({
|
||||||
where: { expertiseTags: { has: tag.name } },
|
where: { expertiseTags: { has: tag.name }, isTest: false },
|
||||||
select: { id: true, expertiseTags: true },
|
select: { id: true, expertiseTags: true },
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -427,9 +429,9 @@ export const tagRouter = router({
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Remove tag from all projects
|
// Remove tag from all projects (excluding test projects)
|
||||||
const projectsWithTag = await ctx.prisma.project.findMany({
|
const projectsWithTag = await ctx.prisma.project.findMany({
|
||||||
where: { tags: { has: tag.name } },
|
where: { tags: { has: tag.name }, isTest: false },
|
||||||
select: { id: true, tags: true },
|
select: { id: true, tags: true },
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
92
src/server/routers/testEnvironment.ts
Normal file
92
src/server/routers/testEnvironment.ts
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
import { router, superAdminProcedure, protectedProcedure } from '../trpc'
|
||||||
|
import { TRPCError } from '@trpc/server'
|
||||||
|
import { createTestEnvironment, tearDownTestEnvironment } from '../services/test-environment'
|
||||||
|
|
||||||
|
export const testEnvironmentRouter = router({
|
||||||
|
/**
|
||||||
|
* Get the current test environment status.
|
||||||
|
* Uses a custom auth check: allows access if realRole OR role is SUPER_ADMIN.
|
||||||
|
* This enables the impersonation banner to fetch test users while impersonating.
|
||||||
|
*/
|
||||||
|
status: protectedProcedure.query(async ({ ctx }) => {
|
||||||
|
// Allow access if the user's actual role (or impersonated-from role) is SUPER_ADMIN
|
||||||
|
const effectiveRole = (ctx.session?.user as any)?.realRole || ctx.user.role
|
||||||
|
if (effectiveRole !== 'SUPER_ADMIN') {
|
||||||
|
throw new TRPCError({ code: 'FORBIDDEN', message: 'Super admin access required' })
|
||||||
|
}
|
||||||
|
|
||||||
|
const competition = await ctx.prisma.competition.findFirst({
|
||||||
|
where: { isTest: true },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
status: true,
|
||||||
|
createdAt: true,
|
||||||
|
program: {
|
||||||
|
select: { id: true, name: true },
|
||||||
|
},
|
||||||
|
rounds: {
|
||||||
|
select: { id: true, name: true, roundType: true, status: true, sortOrder: true },
|
||||||
|
orderBy: { sortOrder: 'asc' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!competition) {
|
||||||
|
return { active: false as const }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get test users grouped by role
|
||||||
|
const testUsers = await ctx.prisma.user.findMany({
|
||||||
|
where: { isTest: true },
|
||||||
|
select: { id: true, name: true, email: true, role: true },
|
||||||
|
orderBy: [{ role: 'asc' }, { name: 'asc' }],
|
||||||
|
})
|
||||||
|
|
||||||
|
// Get email redirect setting
|
||||||
|
const emailRedirect = await ctx.prisma.systemSettings.findUnique({
|
||||||
|
where: { key: 'test_email_redirect' },
|
||||||
|
select: { value: true },
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
active: true as const,
|
||||||
|
competition: {
|
||||||
|
id: competition.id,
|
||||||
|
name: competition.name,
|
||||||
|
status: competition.status,
|
||||||
|
createdAt: competition.createdAt,
|
||||||
|
programId: competition.program.id,
|
||||||
|
programName: competition.program.name,
|
||||||
|
},
|
||||||
|
rounds: competition.rounds,
|
||||||
|
users: testUsers,
|
||||||
|
emailRedirect: emailRedirect?.value || null,
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a test environment. Idempotent — tears down existing first.
|
||||||
|
*/
|
||||||
|
create: superAdminProcedure.mutation(async ({ ctx }) => {
|
||||||
|
const result = await createTestEnvironment(ctx.prisma, ctx.user.email || '')
|
||||||
|
return result
|
||||||
|
}),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tear down the test environment.
|
||||||
|
*/
|
||||||
|
tearDown: superAdminProcedure.mutation(async ({ ctx }) => {
|
||||||
|
const program = await ctx.prisma.program.findFirst({
|
||||||
|
where: { isTest: true },
|
||||||
|
select: { id: true },
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!program) {
|
||||||
|
throw new TRPCError({ code: 'NOT_FOUND', message: 'No test environment found' })
|
||||||
|
}
|
||||||
|
|
||||||
|
await tearDownTestEnvironment(ctx.prisma, program.id)
|
||||||
|
return { success: true }
|
||||||
|
}),
|
||||||
|
})
|
||||||
@@ -249,7 +249,9 @@ export const userRouter = router({
|
|||||||
const { role, roles, status, search, page, perPage } = input
|
const { role, roles, status, search, page, perPage } = input
|
||||||
const skip = (page - 1) * perPage
|
const skip = (page - 1) * perPage
|
||||||
|
|
||||||
const where: Record<string, unknown> = {}
|
const where: Record<string, unknown> = {
|
||||||
|
isTest: false,
|
||||||
|
}
|
||||||
|
|
||||||
if (roles && roles.length > 0) {
|
if (roles && roles.length > 0) {
|
||||||
where.role = { in: roles }
|
where.role = { in: roles }
|
||||||
@@ -316,6 +318,7 @@ export const userRouter = router({
|
|||||||
)
|
)
|
||||||
.query(async ({ ctx, input }) => {
|
.query(async ({ ctx, input }) => {
|
||||||
const where: Record<string, unknown> = {
|
const where: Record<string, unknown> = {
|
||||||
|
isTest: false,
|
||||||
status: { in: ['NONE', 'INVITED'] },
|
status: { in: ['NONE', 'INVITED'] },
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -929,6 +932,7 @@ export const userRouter = router({
|
|||||||
)
|
)
|
||||||
.query(async ({ ctx, input }) => {
|
.query(async ({ ctx, input }) => {
|
||||||
const where: Record<string, unknown> = {
|
const where: Record<string, unknown> = {
|
||||||
|
isTest: false,
|
||||||
role: 'JURY_MEMBER',
|
role: 'JURY_MEMBER',
|
||||||
status: 'ACTIVE',
|
status: 'ACTIVE',
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -290,6 +290,7 @@ export async function generateSummary({
|
|||||||
select: {
|
select: {
|
||||||
id: true,
|
id: true,
|
||||||
title: true,
|
title: true,
|
||||||
|
isTest: true,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -297,6 +298,10 @@ export async function generateSummary({
|
|||||||
throw new TRPCError({ code: 'NOT_FOUND', message: 'Project not found' })
|
throw new TRPCError({ code: 'NOT_FOUND', message: 'Project not found' })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (project.isTest) {
|
||||||
|
throw new TRPCError({ code: 'BAD_REQUEST', message: 'Cannot generate AI summaries for test projects' })
|
||||||
|
}
|
||||||
|
|
||||||
// Fetch submitted evaluations for this project in this round
|
// Fetch submitted evaluations for this project in this round
|
||||||
const evaluations = await prisma.evaluation.findMany({
|
const evaluations = await prisma.evaluation.findMany({
|
||||||
where: {
|
where: {
|
||||||
|
|||||||
@@ -103,6 +103,7 @@ async function generateCategoryShortlist(
|
|||||||
const projects = await prisma.project.findMany({
|
const projects = await prisma.project.findMany({
|
||||||
where: {
|
where: {
|
||||||
competitionCategory: category,
|
competitionCategory: category,
|
||||||
|
isTest: false,
|
||||||
assignments: { some: { roundId } },
|
assignments: { some: { roundId } },
|
||||||
},
|
},
|
||||||
include: {
|
include: {
|
||||||
|
|||||||
@@ -473,6 +473,10 @@ export async function tagProject(
|
|||||||
throw new Error(`Project not found: ${projectId}`)
|
throw new Error(`Project not found: ${projectId}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if ((project as any).isTest) {
|
||||||
|
throw new Error(`Cannot run AI tagging on test project: ${projectId}`)
|
||||||
|
}
|
||||||
|
|
||||||
// Get available tags
|
// Get available tags
|
||||||
const availableTags = await getAvailableTags()
|
const availableTags = await getAvailableTags()
|
||||||
if (availableTags.length === 0) {
|
if (availableTags.length === 0) {
|
||||||
@@ -574,7 +578,7 @@ export async function tagProjectsBatch(
|
|||||||
|
|
||||||
// Fetch full project data for all projects at once (single DB query)
|
// Fetch full project data for all projects at once (single DB query)
|
||||||
const fullProjects = await prisma.project.findMany({
|
const fullProjects = await prisma.project.findMany({
|
||||||
where: { id: { in: projects.map((p) => p.id) } },
|
where: { id: { in: projects.map((p) => p.id) }, isTest: false },
|
||||||
include: {
|
include: {
|
||||||
projectTags: true,
|
projectTags: true,
|
||||||
files: { select: { fileType: true } },
|
files: { select: { fileType: true } },
|
||||||
@@ -712,6 +716,10 @@ export async function getTagSuggestions(
|
|||||||
throw new Error(`Project not found: ${projectId}`)
|
throw new Error(`Project not found: ${projectId}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if ((project as any).isTest) {
|
||||||
|
throw new Error(`Cannot run AI tagging on test project: ${projectId}`)
|
||||||
|
}
|
||||||
|
|
||||||
// Get available tags
|
// Get available tags
|
||||||
const availableTags = await getAvailableTags()
|
const availableTags = await getAvailableTags()
|
||||||
if (availableTags.length === 0) {
|
if (availableTags.length === 0) {
|
||||||
|
|||||||
@@ -88,6 +88,7 @@ export async function processEligibilityJob(
|
|||||||
where: {
|
where: {
|
||||||
id: { in: passedIds },
|
id: { in: passedIds },
|
||||||
programId: award.programId,
|
programId: award.programId,
|
||||||
|
isTest: false,
|
||||||
},
|
},
|
||||||
select: projectSelect,
|
select: projectSelect,
|
||||||
})
|
})
|
||||||
@@ -99,6 +100,7 @@ export async function processEligibilityJob(
|
|||||||
projects = await prisma.project.findMany({
|
projects = await prisma.project.findMany({
|
||||||
where: {
|
where: {
|
||||||
programId: award.programId,
|
programId: award.programId,
|
||||||
|
isTest: false,
|
||||||
status: { in: [...statusFilter] },
|
status: { in: [...statusFilter] },
|
||||||
},
|
},
|
||||||
select: projectSelect,
|
select: projectSelect,
|
||||||
|
|||||||
@@ -320,7 +320,7 @@ export async function analyzeAllUnanalyzed(): Promise<{
|
|||||||
total: number
|
total: number
|
||||||
}> {
|
}> {
|
||||||
const files = await prisma.projectFile.findMany({
|
const files = await prisma.projectFile.findMany({
|
||||||
where: { analyzedAt: null },
|
where: { analyzedAt: null, project: { isTest: false } },
|
||||||
select: {
|
select: {
|
||||||
id: true,
|
id: true,
|
||||||
objectKey: true,
|
objectKey: true,
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ export async function processDigests(
|
|||||||
// Find users who opted in for this digest frequency
|
// Find users who opted in for this digest frequency
|
||||||
const users = await prisma.user.findMany({
|
const users = await prisma.user.findMany({
|
||||||
where: {
|
where: {
|
||||||
|
isTest: false,
|
||||||
digestFrequency: type,
|
digestFrequency: type,
|
||||||
status: 'ACTIVE',
|
status: 'ACTIVE',
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -56,7 +56,7 @@ export async function sendManualReminders(roundId: string): Promise<ReminderResu
|
|||||||
if (usersToNotify.length === 0) return { sent, errors }
|
if (usersToNotify.length === 0) return { sent, errors }
|
||||||
|
|
||||||
const users = await prisma.user.findMany({
|
const users = await prisma.user.findMany({
|
||||||
where: { id: { in: usersToNotify } },
|
where: { id: { in: usersToNotify }, isTest: false },
|
||||||
select: { id: true, name: true, email: true },
|
select: { id: true, name: true, email: true },
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -133,6 +133,7 @@ export async function processEvaluationReminders(roundId?: string): Promise<Remi
|
|||||||
status: 'ROUND_ACTIVE' as const,
|
status: 'ROUND_ACTIVE' as const,
|
||||||
windowCloseAt: { gt: now },
|
windowCloseAt: { gt: now },
|
||||||
windowOpenAt: { lte: now },
|
windowOpenAt: { lte: now },
|
||||||
|
competition: { isTest: false },
|
||||||
...(roundId && { id: roundId }),
|
...(roundId && { id: roundId }),
|
||||||
},
|
},
|
||||||
select: {
|
select: {
|
||||||
@@ -213,7 +214,7 @@ async function sendRemindersForRound(
|
|||||||
|
|
||||||
// Get user details and their pending counts
|
// Get user details and their pending counts
|
||||||
const users = await prisma.user.findMany({
|
const users = await prisma.user.findMany({
|
||||||
where: { id: { in: usersToNotify } },
|
where: { id: { in: usersToNotify }, isTest: false },
|
||||||
select: { id: true, name: true, email: true },
|
select: { id: true, name: true, email: true },
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -297,6 +297,7 @@ export async function notifyAdmins(params: {
|
|||||||
where: {
|
where: {
|
||||||
role: { in: ['SUPER_ADMIN', 'PROGRAM_ADMIN'] },
|
role: { in: ['SUPER_ADMIN', 'PROGRAM_ADMIN'] },
|
||||||
status: 'ACTIVE',
|
status: 'ACTIVE',
|
||||||
|
isTest: false,
|
||||||
},
|
},
|
||||||
select: { id: true },
|
select: { id: true },
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -232,6 +232,7 @@ export async function getAIMentorSuggestionsBatch(
|
|||||||
{ role: 'JURY_MEMBER' },
|
{ role: 'JURY_MEMBER' },
|
||||||
],
|
],
|
||||||
status: 'ACTIVE',
|
status: 'ACTIVE',
|
||||||
|
isTest: false,
|
||||||
},
|
},
|
||||||
select: {
|
select: {
|
||||||
id: true,
|
id: true,
|
||||||
@@ -458,6 +459,7 @@ export async function getRoundRobinMentor(
|
|||||||
{ role: 'JURY_MEMBER' },
|
{ role: 'JURY_MEMBER' },
|
||||||
],
|
],
|
||||||
status: 'ACTIVE',
|
status: 'ACTIVE',
|
||||||
|
isTest: false,
|
||||||
id: { notIn: excludeMentorIds },
|
id: { notIn: excludeMentorIds },
|
||||||
},
|
},
|
||||||
select: {
|
select: {
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ export async function sendNotification(
|
|||||||
): Promise<NotificationResult> {
|
): Promise<NotificationResult> {
|
||||||
// Get user with notification preferences
|
// Get user with notification preferences
|
||||||
const user = await prisma.user.findUnique({
|
const user = await prisma.user.findUnique({
|
||||||
where: { id: userId },
|
where: { id: userId, isTest: false },
|
||||||
select: {
|
select: {
|
||||||
id: true,
|
id: true,
|
||||||
email: true,
|
email: true,
|
||||||
|
|||||||
@@ -94,7 +94,7 @@ export async function previewRoundAssignment(
|
|||||||
|
|
||||||
// Load jury group members
|
// Load jury group members
|
||||||
const members = await db.juryGroupMember.findMany({
|
const members = await db.juryGroupMember.findMany({
|
||||||
where: { juryGroupId: ctx.juryGroup.id },
|
where: { juryGroupId: ctx.juryGroup.id, user: { isTest: false } },
|
||||||
include: {
|
include: {
|
||||||
user: { select: { id: true, name: true, email: true, role: true, bio: true, expertiseTags: true, country: true, availabilityJson: true } },
|
user: { select: { id: true, name: true, email: true, role: true, bio: true, expertiseTags: true, country: true, availabilityJson: true } },
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ export async function processScheduledRounds(): Promise<{
|
|||||||
|
|
||||||
// Find a SUPER_ADMIN to use as the actor for audit logging
|
// Find a SUPER_ADMIN to use as the actor for audit logging
|
||||||
const systemActor = await prisma.user.findFirst({
|
const systemActor = await prisma.user.findFirst({
|
||||||
where: { role: 'SUPER_ADMIN' },
|
where: { role: 'SUPER_ADMIN', isTest: false },
|
||||||
select: { id: true },
|
select: { id: true },
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -29,7 +29,7 @@ export async function processScheduledRounds(): Promise<{
|
|||||||
where: {
|
where: {
|
||||||
status: 'ROUND_DRAFT',
|
status: 'ROUND_DRAFT',
|
||||||
windowOpenAt: { lte: now },
|
windowOpenAt: { lte: now },
|
||||||
competition: { status: { not: 'ARCHIVED' } },
|
competition: { status: { not: 'ARCHIVED' }, isTest: false },
|
||||||
},
|
},
|
||||||
select: { id: true, name: true },
|
select: { id: true, name: true },
|
||||||
})
|
})
|
||||||
@@ -49,6 +49,7 @@ export async function processScheduledRounds(): Promise<{
|
|||||||
where: {
|
where: {
|
||||||
status: 'ROUND_ACTIVE',
|
status: 'ROUND_ACTIVE',
|
||||||
windowCloseAt: { lte: now },
|
windowCloseAt: { lte: now },
|
||||||
|
competition: { isTest: false },
|
||||||
},
|
},
|
||||||
select: { id: true, name: true },
|
select: { id: true, name: true },
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -362,6 +362,7 @@ export async function getSmartSuggestions(options: {
|
|||||||
where: {
|
where: {
|
||||||
role,
|
role,
|
||||||
status: 'ACTIVE',
|
status: 'ACTIVE',
|
||||||
|
isTest: false,
|
||||||
},
|
},
|
||||||
select: {
|
select: {
|
||||||
id: true,
|
id: true,
|
||||||
@@ -683,6 +684,7 @@ export async function getMentorSuggestionsForProject(
|
|||||||
where: {
|
where: {
|
||||||
role: 'MENTOR',
|
role: 'MENTOR',
|
||||||
status: 'ACTIVE',
|
status: 'ACTIVE',
|
||||||
|
isTest: false,
|
||||||
},
|
},
|
||||||
select: {
|
select: {
|
||||||
id: true,
|
id: true,
|
||||||
|
|||||||
676
src/server/services/test-environment.ts
Normal file
676
src/server/services/test-environment.ts
Normal file
@@ -0,0 +1,676 @@
|
|||||||
|
import type { PrismaClient } from '@prisma/client'
|
||||||
|
|
||||||
|
// ─── Test Data Constants ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const TEST_JURY = [
|
||||||
|
{ name: 'Sophie Laurent', email: 'sophie.laurent@test.local', country: 'France', expertiseTags: ['Marine Biology', 'Conservation'] },
|
||||||
|
{ name: 'Marco Bianchi', email: 'marco.bianchi@test.local', country: 'Italy', expertiseTags: ['Sustainable Fishing', 'Policy'] },
|
||||||
|
{ name: 'Elena Petrova', email: 'elena.petrova@test.local', country: 'Germany', expertiseTags: ['Ocean Technology', 'Engineering'] },
|
||||||
|
{ name: 'James Chen', email: 'james.chen@test.local', country: 'Singapore', expertiseTags: ['Blue Economy', 'Innovation'] },
|
||||||
|
{ name: 'Aisha Diallo', email: 'aisha.diallo@test.local', country: 'Senegal', expertiseTags: ['Community Development', 'Education'] },
|
||||||
|
{ name: 'Carlos Rivera', email: 'carlos.rivera@test.local', country: 'Spain', expertiseTags: ['Climate Science', 'Renewable Energy'] },
|
||||||
|
]
|
||||||
|
|
||||||
|
const TEST_APPLICANTS_OWNERS = [
|
||||||
|
{ name: 'Léa Moreau', email: 'lea.moreau@test.local', country: 'France' },
|
||||||
|
{ name: 'Henrik Johansson', email: 'henrik.johansson@test.local', country: 'Sweden' },
|
||||||
|
{ name: 'Fatima Al-Rashid', email: 'fatima.alrashid@test.local', country: 'UAE' },
|
||||||
|
{ name: 'Yuki Tanaka', email: 'yuki.tanaka@test.local', country: 'Japan' },
|
||||||
|
{ name: 'Priya Sharma', email: 'priya.sharma@test.local', country: 'India' },
|
||||||
|
{ name: 'Lucas Oliveira', email: 'lucas.oliveira@test.local', country: 'Brazil' },
|
||||||
|
{ name: 'Nadia Kowalski', email: 'nadia.kowalski@test.local', country: 'Poland' },
|
||||||
|
{ name: 'Samuel Okonkwo', email: 'samuel.okonkwo@test.local', country: 'Nigeria' },
|
||||||
|
{ name: 'Ingrid Hansen', email: 'ingrid.hansen@test.local', country: 'Norway' },
|
||||||
|
{ name: 'Diego Fernández', email: 'diego.fernandez@test.local', country: 'Mexico' },
|
||||||
|
{ name: 'Amira Benali', email: 'amira.benali@test.local', country: 'Morocco' },
|
||||||
|
{ name: 'Thomas Müller', email: 'thomas.muller@test.local', country: 'Germany' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const TEST_APPLICANTS_TEAMMATES = [
|
||||||
|
{ name: 'Marie Dubois', email: 'marie.dubois@test.local', country: 'France' },
|
||||||
|
{ name: 'Kenji Watanabe', email: 'kenji.watanabe@test.local', country: 'Japan' },
|
||||||
|
{ name: 'Ana Costa', email: 'ana.costa@test.local', country: 'Brazil' },
|
||||||
|
{ name: 'Erik Lindqvist', email: 'erik.lindqvist@test.local', country: 'Sweden' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const TEST_OTHER_ROLES = [
|
||||||
|
{ name: 'Dr. Catherine Blanc', email: 'catherine.blanc@test.local', role: 'MENTOR' as const, country: 'Monaco' },
|
||||||
|
{ name: 'Oliver Schmidt', email: 'oliver.schmidt@test.local', role: 'OBSERVER' as const, country: 'Switzerland' },
|
||||||
|
{ name: 'Isabella Romano', email: 'isabella.romano@test.local', role: 'AWARD_MASTER' as const, country: 'Italy' },
|
||||||
|
{ name: 'Philippe Durand', email: 'philippe.durand@test.local', role: 'PROGRAM_ADMIN' as const, country: 'Monaco' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const TEST_PROJECTS = [
|
||||||
|
{
|
||||||
|
title: 'OceanGuard: AI-Powered Marine Debris Detection',
|
||||||
|
teamName: 'OceanGuard Technologies',
|
||||||
|
description: 'Using satellite imagery and deep learning to identify and track marine debris across the Mediterranean. Our proprietary algorithm achieves 94% accuracy in detecting microplastic concentration zones, enabling targeted cleanup operations.',
|
||||||
|
country: 'France',
|
||||||
|
category: 'STARTUP' as const,
|
||||||
|
oceanIssue: 'POLLUTION_REDUCTION' as const,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Blue Carbon Ventures: Seagrass Restoration at Scale',
|
||||||
|
teamName: 'Blue Carbon Ventures',
|
||||||
|
description: 'Developing cost-effective seagrass meadow restoration techniques for Mediterranean coastal zones. Our approach combines drone-based seed dispersal with AI monitoring to restore 500+ hectares of seagrass by 2030.',
|
||||||
|
country: 'Italy',
|
||||||
|
category: 'STARTUP' as const,
|
||||||
|
oceanIssue: 'BLUE_CARBON' as const,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Reef Resilience: Coral Thermal Adaptation Program',
|
||||||
|
teamName: 'Reef Resilience Lab',
|
||||||
|
description: 'Selective breeding of thermally tolerant coral genotypes combined with biofilm-enhanced substrate technology to accelerate reef recovery in warming waters. Currently operating pilot programs in 3 Mediterranean sites.',
|
||||||
|
country: 'Monaco',
|
||||||
|
category: 'STARTUP' as const,
|
||||||
|
oceanIssue: 'HABITAT_RESTORATION' as const,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'WaveHarvest: Ocean Energy for Coastal Communities',
|
||||||
|
teamName: 'WaveHarvest Energy',
|
||||||
|
description: 'Modular wave energy converters designed for small island nations and remote coastal communities. Our patented oscillating water column system delivers reliable 50kW power at 1/3 the cost of competing technologies.',
|
||||||
|
country: 'Norway',
|
||||||
|
category: 'STARTUP' as const,
|
||||||
|
oceanIssue: 'CLIMATE_MITIGATION' as const,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'SailCargo: Zero-Emission Maritime Logistics',
|
||||||
|
teamName: 'SailCargo Collective',
|
||||||
|
description: 'Reviving wind-powered shipping for short-sea routes in the Mediterranean using modernized sailing vessel designs with solar-electric auxiliary propulsion. Connecting ports along the French and Italian Riviera.',
|
||||||
|
country: 'Spain',
|
||||||
|
category: 'STARTUP' as const,
|
||||||
|
oceanIssue: 'SUSTAINABLE_SHIPPING' as const,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Neptune Analytics: Smart Fisheries Management',
|
||||||
|
teamName: 'Neptune Analytics',
|
||||||
|
description: 'IoT sensor network and machine learning platform for real-time fisheries monitoring. Tracks fish stock health, migration patterns, and fishing vessel compliance to support science-based quota management.',
|
||||||
|
country: 'Portugal',
|
||||||
|
category: 'STARTUP' as const,
|
||||||
|
oceanIssue: 'SUSTAINABLE_FISHING' as const,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'AquaLens: Underwater Environmental Monitoring',
|
||||||
|
teamName: 'AquaLens Research',
|
||||||
|
description: 'A network of autonomous underwater drones equipped with hyperspectral cameras for continuous marine ecosystem monitoring. Real-time data on water quality, biodiversity indices, and pollutant levels.',
|
||||||
|
country: 'Germany',
|
||||||
|
category: 'BUSINESS_CONCEPT' as const,
|
||||||
|
oceanIssue: 'TECHNOLOGY_INNOVATION' as const,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'TidalConnect: Ocean Literacy Education Platform',
|
||||||
|
teamName: 'TidalConnect Foundation',
|
||||||
|
description: 'Interactive mobile platform bringing ocean science education to underserved coastal communities in West Africa. Features gamified learning modules, local language support, and citizen science data collection.',
|
||||||
|
country: 'Senegal',
|
||||||
|
category: 'BUSINESS_CONCEPT' as const,
|
||||||
|
oceanIssue: 'COMMUNITY_CAPACITY' as const,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'BioFouling Solutions: Eco-Friendly Marine Coatings',
|
||||||
|
teamName: 'BioFouling Solutions',
|
||||||
|
description: 'Biomimetic antifouling coatings inspired by shark skin microstructure. Our non-toxic coating reduces fuel consumption by 12% while eliminating the release of harmful biocides into marine environments.',
|
||||||
|
country: 'Sweden',
|
||||||
|
category: 'BUSINESS_CONCEPT' as const,
|
||||||
|
oceanIssue: 'POLLUTION_REDUCTION' as const,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Kelp Climate: Industrial-Scale Kelp Farming',
|
||||||
|
teamName: 'Kelp Climate Co.',
|
||||||
|
description: 'Offshore macroalgae cultivation combining carbon sequestration with sustainable biomaterials production. Our proprietary deep-water cultivation system supports 10x faster growth rates than nearshore farms.',
|
||||||
|
country: 'Japan',
|
||||||
|
category: 'BUSINESS_CONCEPT' as const,
|
||||||
|
oceanIssue: 'BLUE_CARBON' as const,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'MarineTrack: Vessel Emission Compliance System',
|
||||||
|
teamName: 'MarineTrack Systems',
|
||||||
|
description: 'Real-time satellite and AIS-based monitoring platform for enforcing IMO 2030 emission standards. Automatically detects scrubber discharge violations and sulfur emission exceedances across Mediterranean shipping lanes.',
|
||||||
|
country: 'Greece',
|
||||||
|
category: 'STARTUP' as const,
|
||||||
|
oceanIssue: 'SUSTAINABLE_SHIPPING' as const,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Mangrove Guardians: Community-Led Restoration',
|
||||||
|
teamName: 'Mangrove Guardians Network',
|
||||||
|
description: 'Empowering coastal fishing communities in Southeast Asia to restore and manage mangrove ecosystems through microfinance-linked conservation incentives and satellite-verified carbon credits.',
|
||||||
|
country: 'India',
|
||||||
|
category: 'BUSINESS_CONCEPT' as const,
|
||||||
|
oceanIssue: 'HABITAT_RESTORATION' as const,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
const ROUND_DEFINITIONS = [
|
||||||
|
{ name: 'Intake', slug: 'intake', roundType: 'INTAKE' as const, sortOrder: 0 },
|
||||||
|
{ name: 'Eligibility Screening', slug: 'filtering', roundType: 'FILTERING' as const, sortOrder: 1 },
|
||||||
|
{ name: 'Expert Evaluation', slug: 'evaluation', roundType: 'EVALUATION' as const, sortOrder: 2 },
|
||||||
|
{ name: 'Document Submission', slug: 'submission', roundType: 'SUBMISSION' as const, sortOrder: 3 },
|
||||||
|
{ name: 'Mentoring Phase', slug: 'mentoring', roundType: 'MENTORING' as const, sortOrder: 4 },
|
||||||
|
{ name: 'Live Finals', slug: 'live-final', roundType: 'LIVE_FINAL' as const, sortOrder: 5 },
|
||||||
|
{ name: 'Final Deliberation', slug: 'deliberation', roundType: 'DELIBERATION' as const, sortOrder: 6 },
|
||||||
|
]
|
||||||
|
|
||||||
|
// ─── Public API ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface TestEnvironmentResult {
|
||||||
|
programId: string
|
||||||
|
competitionId: string
|
||||||
|
users: Array<{ id: string; name: string; email: string; role: string }>
|
||||||
|
projectCount: number
|
||||||
|
roundCount: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a complete test environment with realistic data.
|
||||||
|
* Idempotent — tears down existing test env first.
|
||||||
|
*/
|
||||||
|
export async function createTestEnvironment(
|
||||||
|
prisma: PrismaClient,
|
||||||
|
adminEmail: string
|
||||||
|
): Promise<TestEnvironmentResult> {
|
||||||
|
// Tear down existing if any
|
||||||
|
const existing = await prisma.competition.findFirst({ where: { isTest: true } })
|
||||||
|
if (existing) {
|
||||||
|
const existingProgram = await prisma.program.findFirst({ where: { isTest: true } })
|
||||||
|
if (existingProgram) {
|
||||||
|
await tearDownTestEnvironment(prisma, existingProgram.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. Email redirect setting
|
||||||
|
await prisma.systemSettings.upsert({
|
||||||
|
where: { key: 'test_email_redirect' },
|
||||||
|
update: { value: adminEmail },
|
||||||
|
create: { key: 'test_email_redirect', value: adminEmail, category: 'DEFAULTS' },
|
||||||
|
})
|
||||||
|
|
||||||
|
// 2. Program
|
||||||
|
const program = await prisma.program.create({
|
||||||
|
data: {
|
||||||
|
name: '[TEST] Test Environment 2026',
|
||||||
|
year: 2026,
|
||||||
|
status: 'ACTIVE',
|
||||||
|
description: 'Test environment for role impersonation and feature testing',
|
||||||
|
isTest: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// 3. Create test users
|
||||||
|
const createdUsers: Array<{ id: string; name: string; email: string; role: string }> = []
|
||||||
|
|
||||||
|
// Jury members
|
||||||
|
const juryUsers = await Promise.all(
|
||||||
|
TEST_JURY.map((j) =>
|
||||||
|
prisma.user.create({
|
||||||
|
data: {
|
||||||
|
name: j.name,
|
||||||
|
email: j.email,
|
||||||
|
role: 'JURY_MEMBER',
|
||||||
|
status: 'ACTIVE',
|
||||||
|
country: j.country,
|
||||||
|
expertiseTags: j.expertiseTags,
|
||||||
|
isTest: true,
|
||||||
|
mustSetPassword: false,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
)
|
||||||
|
)
|
||||||
|
juryUsers.forEach((u) => createdUsers.push({ id: u.id, name: u.name!, email: u.email, role: u.role }))
|
||||||
|
|
||||||
|
// Applicant owners
|
||||||
|
const ownerUsers = await Promise.all(
|
||||||
|
TEST_APPLICANTS_OWNERS.map((a) =>
|
||||||
|
prisma.user.create({
|
||||||
|
data: {
|
||||||
|
name: a.name,
|
||||||
|
email: a.email,
|
||||||
|
role: 'APPLICANT',
|
||||||
|
status: 'ACTIVE',
|
||||||
|
country: a.country,
|
||||||
|
isTest: true,
|
||||||
|
mustSetPassword: false,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
)
|
||||||
|
)
|
||||||
|
ownerUsers.forEach((u) => createdUsers.push({ id: u.id, name: u.name!, email: u.email, role: u.role }))
|
||||||
|
|
||||||
|
// Applicant teammates
|
||||||
|
const teammateUsers = await Promise.all(
|
||||||
|
TEST_APPLICANTS_TEAMMATES.map((a) =>
|
||||||
|
prisma.user.create({
|
||||||
|
data: {
|
||||||
|
name: a.name,
|
||||||
|
email: a.email,
|
||||||
|
role: 'APPLICANT',
|
||||||
|
status: 'ACTIVE',
|
||||||
|
country: a.country,
|
||||||
|
isTest: true,
|
||||||
|
mustSetPassword: false,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
)
|
||||||
|
)
|
||||||
|
teammateUsers.forEach((u) => createdUsers.push({ id: u.id, name: u.name!, email: u.email, role: u.role }))
|
||||||
|
|
||||||
|
// Other roles
|
||||||
|
const otherUsers = await Promise.all(
|
||||||
|
TEST_OTHER_ROLES.map((r) =>
|
||||||
|
prisma.user.create({
|
||||||
|
data: {
|
||||||
|
name: r.name,
|
||||||
|
email: r.email,
|
||||||
|
role: r.role,
|
||||||
|
status: 'ACTIVE',
|
||||||
|
country: r.country,
|
||||||
|
isTest: true,
|
||||||
|
mustSetPassword: false,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
)
|
||||||
|
)
|
||||||
|
otherUsers.forEach((u) => createdUsers.push({ id: u.id, name: u.name!, email: u.email, role: u.role }))
|
||||||
|
|
||||||
|
// 4. Create projects (one per owner)
|
||||||
|
const projects = await Promise.all(
|
||||||
|
TEST_PROJECTS.map((p, i) =>
|
||||||
|
prisma.project.create({
|
||||||
|
data: {
|
||||||
|
programId: program.id,
|
||||||
|
title: p.title,
|
||||||
|
teamName: p.teamName,
|
||||||
|
description: p.description,
|
||||||
|
country: p.country,
|
||||||
|
competitionCategory: p.category,
|
||||||
|
oceanIssue: p.oceanIssue,
|
||||||
|
status: 'SUBMITTED',
|
||||||
|
submissionSource: 'MANUAL',
|
||||||
|
submittedAt: new Date(),
|
||||||
|
submittedByUserId: ownerUsers[i].id,
|
||||||
|
isTest: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
// 5. Add teammates to projects 0, 1, and 3
|
||||||
|
const teammateAssignments = [
|
||||||
|
{ projectIdx: 0, teammateIdx: 0 },
|
||||||
|
{ projectIdx: 0, teammateIdx: 1 },
|
||||||
|
{ projectIdx: 1, teammateIdx: 2 },
|
||||||
|
{ projectIdx: 3, teammateIdx: 3 },
|
||||||
|
]
|
||||||
|
await Promise.all(
|
||||||
|
teammateAssignments.map((ta) =>
|
||||||
|
prisma.teamMember.create({
|
||||||
|
data: {
|
||||||
|
projectId: projects[ta.projectIdx].id,
|
||||||
|
userId: teammateUsers[ta.teammateIdx].id,
|
||||||
|
role: 'MEMBER',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
// Also add owners as team members (OWNER role)
|
||||||
|
await Promise.all(
|
||||||
|
projects.map((proj, i) =>
|
||||||
|
prisma.teamMember.create({
|
||||||
|
data: {
|
||||||
|
projectId: proj.id,
|
||||||
|
userId: ownerUsers[i].id,
|
||||||
|
role: 'LEAD',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
// 6. Competition
|
||||||
|
const competition = await prisma.competition.create({
|
||||||
|
data: {
|
||||||
|
programId: program.id,
|
||||||
|
name: 'Test Competition 2026',
|
||||||
|
slug: `test-env-${Date.now()}`,
|
||||||
|
status: 'ACTIVE',
|
||||||
|
isTest: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// 7. Jury Group
|
||||||
|
const observerUser = otherUsers.find((u) => u.role === 'OBSERVER')
|
||||||
|
const juryGroup = await prisma.juryGroup.create({
|
||||||
|
data: {
|
||||||
|
competitionId: competition.id,
|
||||||
|
name: 'Test Jury Panel',
|
||||||
|
slug: 'test-jury-panel',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// Add jury members to group
|
||||||
|
await Promise.all(
|
||||||
|
juryUsers.map((ju) =>
|
||||||
|
prisma.juryGroupMember.create({
|
||||||
|
data: {
|
||||||
|
juryGroupId: juryGroup.id,
|
||||||
|
userId: ju.id,
|
||||||
|
role: 'MEMBER',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
// Add observer to jury group
|
||||||
|
if (observerUser) {
|
||||||
|
await prisma.juryGroupMember.create({
|
||||||
|
data: {
|
||||||
|
juryGroupId: juryGroup.id,
|
||||||
|
userId: observerUser.id,
|
||||||
|
role: 'OBSERVER',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 8. Rounds (INTAKE=ACTIVE, rest=DRAFT)
|
||||||
|
const rounds = await Promise.all(
|
||||||
|
ROUND_DEFINITIONS.map((rd) =>
|
||||||
|
prisma.round.create({
|
||||||
|
data: {
|
||||||
|
competitionId: competition.id,
|
||||||
|
name: rd.name,
|
||||||
|
slug: rd.slug,
|
||||||
|
roundType: rd.roundType,
|
||||||
|
status: rd.sortOrder === 0 ? 'ROUND_ACTIVE' : 'ROUND_DRAFT',
|
||||||
|
sortOrder: rd.sortOrder,
|
||||||
|
juryGroupId: rd.roundType === 'EVALUATION' ? juryGroup.id : undefined,
|
||||||
|
windowOpenAt: rd.sortOrder === 0 ? new Date(Date.now() - 7 * 24 * 60 * 60 * 1000) : undefined,
|
||||||
|
windowCloseAt: rd.sortOrder === 0 ? new Date(Date.now() + 30 * 24 * 60 * 60 * 1000) : undefined,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
const intakeRound = rounds[0]
|
||||||
|
const evaluationRound = rounds[2]
|
||||||
|
|
||||||
|
// 9. Evaluation form on the evaluation round
|
||||||
|
const evalForm = await prisma.evaluationForm.create({
|
||||||
|
data: {
|
||||||
|
roundId: evaluationRound.id,
|
||||||
|
version: 1,
|
||||||
|
isActive: true,
|
||||||
|
criteriaJson: [
|
||||||
|
{ id: 'innovation', label: 'Innovation & Originality', description: 'Novelty of approach and potential impact', scale: '1-10', weight: 30, required: true },
|
||||||
|
{ id: 'feasibility', label: 'Technical Feasibility', description: 'Viability of implementation and scalability', scale: '1-10', weight: 40, required: true },
|
||||||
|
{ id: 'ocean_impact', label: 'Ocean Impact Potential', description: 'Direct benefit to marine ecosystems', scale: '1-10', weight: 30, required: true },
|
||||||
|
],
|
||||||
|
scalesJson: { '1-10': { min: 1, max: 10, labels: { 1: 'Poor', 5: 'Average', 10: 'Excellent' } } },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// 10. ProjectRoundState for all projects in INTAKE round (PENDING)
|
||||||
|
await Promise.all(
|
||||||
|
projects.map((proj) =>
|
||||||
|
prisma.projectRoundState.create({
|
||||||
|
data: {
|
||||||
|
projectId: proj.id,
|
||||||
|
roundId: intakeRound.id,
|
||||||
|
state: 'PENDING',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
// 11. Assignments — 5 projects per jury member in evaluation round (round-robin)
|
||||||
|
const assignmentsPerJury = 5
|
||||||
|
const assignmentData: Array<{ userId: string; projectId: string; roundId: string; juryGroupId: string }> = []
|
||||||
|
|
||||||
|
for (let j = 0; j < juryUsers.length; j++) {
|
||||||
|
for (let a = 0; a < assignmentsPerJury; a++) {
|
||||||
|
const projectIdx = (j * 2 + a) % projects.length
|
||||||
|
const key = `${juryUsers[j].id}-${projects[projectIdx].id}`
|
||||||
|
// Avoid duplicate assignments
|
||||||
|
if (!assignmentData.some((ad) => ad.userId === juryUsers[j].id && ad.projectId === projects[projectIdx].id)) {
|
||||||
|
assignmentData.push({
|
||||||
|
userId: juryUsers[j].id,
|
||||||
|
projectId: projects[projectIdx].id,
|
||||||
|
roundId: evaluationRound.id,
|
||||||
|
juryGroupId: juryGroup.id,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const assignments = await Promise.all(
|
||||||
|
assignmentData.map((ad) =>
|
||||||
|
prisma.assignment.create({
|
||||||
|
data: {
|
||||||
|
userId: ad.userId,
|
||||||
|
projectId: ad.projectId,
|
||||||
|
roundId: ad.roundId,
|
||||||
|
juryGroupId: ad.juryGroupId,
|
||||||
|
method: 'MANUAL',
|
||||||
|
isCompleted: false,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
// 12. Partial evaluations — first 2 jury members score their first 3-4 assignments
|
||||||
|
const evalJurors = juryUsers.slice(0, 2)
|
||||||
|
for (const juror of evalJurors) {
|
||||||
|
const jurorAssignments = assignments.filter((a) => a.userId === juror.id).slice(0, 3 + Math.round(Math.random()))
|
||||||
|
|
||||||
|
for (const assignment of jurorAssignments) {
|
||||||
|
const innovationScore = 5 + Math.floor(Math.random() * 5) // 5-9
|
||||||
|
const feasibilityScore = 4 + Math.floor(Math.random() * 5) // 4-8
|
||||||
|
const impactScore = 5 + Math.floor(Math.random() * 4) // 5-8
|
||||||
|
const globalScore = Math.round((innovationScore * 0.3 + feasibilityScore * 0.4 + impactScore * 0.3))
|
||||||
|
|
||||||
|
await prisma.evaluation.create({
|
||||||
|
data: {
|
||||||
|
assignmentId: assignment.id,
|
||||||
|
formId: evalForm.id,
|
||||||
|
status: 'SUBMITTED',
|
||||||
|
criterionScoresJson: {
|
||||||
|
innovation: innovationScore,
|
||||||
|
feasibility: feasibilityScore,
|
||||||
|
ocean_impact: impactScore,
|
||||||
|
},
|
||||||
|
globalScore,
|
||||||
|
binaryDecision: globalScore >= 6,
|
||||||
|
feedbackText: `Strong project with notable ${innovationScore >= 7 ? 'innovation' : 'potential'}. ${feasibilityScore >= 7 ? 'Highly feasible approach.' : 'Implementation timeline needs refinement.'}`,
|
||||||
|
submittedAt: new Date(Date.now() - Math.floor(Math.random() * 3 * 24 * 60 * 60 * 1000)),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// Mark assignment as completed
|
||||||
|
await prisma.assignment.update({
|
||||||
|
where: { id: assignment.id },
|
||||||
|
data: { isCompleted: true },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 13. Advancement rules linking consecutive rounds
|
||||||
|
for (let i = 0; i < rounds.length - 1; i++) {
|
||||||
|
await prisma.advancementRule.create({
|
||||||
|
data: {
|
||||||
|
roundId: rounds[i].id,
|
||||||
|
ruleType: 'ADMIN_SELECTION',
|
||||||
|
configJson: {},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
programId: program.id,
|
||||||
|
competitionId: competition.id,
|
||||||
|
users: createdUsers,
|
||||||
|
projectCount: projects.length,
|
||||||
|
roundCount: rounds.length,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tear down the test environment — delete all test data.
|
||||||
|
* Follows reverse-dependency order to avoid FK constraint errors.
|
||||||
|
*/
|
||||||
|
export async function tearDownTestEnvironment(
|
||||||
|
prisma: PrismaClient,
|
||||||
|
programId: string
|
||||||
|
): Promise<void> {
|
||||||
|
// Verify this is actually a test program
|
||||||
|
const program = await prisma.program.findUnique({
|
||||||
|
where: { id: programId },
|
||||||
|
select: { isTest: true },
|
||||||
|
})
|
||||||
|
if (!program?.isTest) {
|
||||||
|
throw new Error('Cannot tear down a non-test program')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get all test competition IDs
|
||||||
|
const competitions = await prisma.competition.findMany({
|
||||||
|
where: { programId, isTest: true },
|
||||||
|
select: { id: true },
|
||||||
|
})
|
||||||
|
const competitionIds = competitions.map((c) => c.id)
|
||||||
|
|
||||||
|
// Get all round IDs
|
||||||
|
const rounds = await prisma.round.findMany({
|
||||||
|
where: { competitionId: { in: competitionIds } },
|
||||||
|
select: { id: true },
|
||||||
|
})
|
||||||
|
const roundIds = rounds.map((r) => r.id)
|
||||||
|
|
||||||
|
// Get all test project IDs
|
||||||
|
const projects = await prisma.project.findMany({
|
||||||
|
where: { programId, isTest: true },
|
||||||
|
select: { id: true },
|
||||||
|
})
|
||||||
|
const projectIds = projects.map((p) => p.id)
|
||||||
|
|
||||||
|
// Get all test user IDs
|
||||||
|
const users = await prisma.user.findMany({
|
||||||
|
where: { isTest: true },
|
||||||
|
select: { id: true },
|
||||||
|
})
|
||||||
|
const userIds = users.map((u) => u.id)
|
||||||
|
|
||||||
|
// Delete in reverse-dependency order
|
||||||
|
|
||||||
|
// Deliberation data
|
||||||
|
if (roundIds.length > 0) {
|
||||||
|
await prisma.deliberationVote.deleteMany({ where: { session: { roundId: { in: roundIds } } } })
|
||||||
|
await prisma.deliberationResult.deleteMany({ where: { session: { roundId: { in: roundIds } } } })
|
||||||
|
await prisma.deliberationParticipant.deleteMany({ where: { session: { roundId: { in: roundIds } } } })
|
||||||
|
await prisma.deliberationSession.deleteMany({ where: { roundId: { in: roundIds } } })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Decision/Audit
|
||||||
|
if (competitionIds.length > 0) {
|
||||||
|
await prisma.resultLock.deleteMany({ where: { competitionId: { in: competitionIds } } })
|
||||||
|
}
|
||||||
|
if (roundIds.length > 0) {
|
||||||
|
await prisma.decisionAuditLog.deleteMany({
|
||||||
|
where: { entityType: 'Round', entityId: { in: roundIds } },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Evaluations → Assignments
|
||||||
|
if (roundIds.length > 0) {
|
||||||
|
await prisma.evaluation.deleteMany({ where: { assignment: { roundId: { in: roundIds } } } })
|
||||||
|
await prisma.conflictOfInterest.deleteMany({ where: { assignment: { roundId: { in: roundIds } } } })
|
||||||
|
await prisma.assignmentException.deleteMany({ where: { assignment: { roundId: { in: roundIds } } } })
|
||||||
|
await prisma.assignment.deleteMany({ where: { roundId: { in: roundIds } } })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Project round states
|
||||||
|
if (roundIds.length > 0) {
|
||||||
|
await prisma.projectRoundState.deleteMany({ where: { roundId: { in: roundIds } } })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filtering
|
||||||
|
if (roundIds.length > 0) {
|
||||||
|
await prisma.filteringResult.deleteMany({ where: { roundId: { in: roundIds } } })
|
||||||
|
await prisma.filteringRule.deleteMany({ where: { roundId: { in: roundIds } } })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Evaluation forms & advancement rules
|
||||||
|
if (roundIds.length > 0) {
|
||||||
|
await prisma.evaluationForm.deleteMany({ where: { roundId: { in: roundIds } } })
|
||||||
|
await prisma.advancementRule.deleteMany({ where: { roundId: { in: roundIds } } })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Live voting
|
||||||
|
if (roundIds.length > 0) {
|
||||||
|
await prisma.liveVote.deleteMany({ where: { session: { roundId: { in: roundIds } } } })
|
||||||
|
await prisma.liveVotingSession.deleteMany({ where: { roundId: { in: roundIds } } })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Assignment intents and policies
|
||||||
|
if (roundIds.length > 0) {
|
||||||
|
await prisma.assignmentIntent.deleteMany({ where: { roundId: { in: roundIds } } })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reminder logs
|
||||||
|
if (roundIds.length > 0) {
|
||||||
|
await prisma.reminderLog.deleteMany({ where: { roundId: { in: roundIds } } })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Jury groups
|
||||||
|
if (competitionIds.length > 0) {
|
||||||
|
await prisma.juryGroupMember.deleteMany({ where: { juryGroup: { competitionId: { in: competitionIds } } } })
|
||||||
|
await prisma.juryGroup.deleteMany({ where: { competitionId: { in: competitionIds } } })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Submission windows
|
||||||
|
if (competitionIds.length > 0) {
|
||||||
|
await prisma.roundSubmissionVisibility.deleteMany({ where: { submissionWindow: { competitionId: { in: competitionIds } } } })
|
||||||
|
await prisma.submissionWindow.deleteMany({ where: { competitionId: { in: competitionIds } } })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rounds
|
||||||
|
if (competitionIds.length > 0) {
|
||||||
|
await prisma.round.deleteMany({ where: { competitionId: { in: competitionIds } } })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Project-related data
|
||||||
|
if (projectIds.length > 0) {
|
||||||
|
await prisma.evaluationSummary.deleteMany({ where: { projectId: { in: projectIds } } })
|
||||||
|
await prisma.evaluationDiscussion.deleteMany({ where: { projectId: { in: projectIds } } })
|
||||||
|
await prisma.awardEligibility.deleteMany({ where: { projectId: { in: projectIds } } })
|
||||||
|
await prisma.awardVote.deleteMany({ where: { projectId: { in: projectIds } } })
|
||||||
|
await prisma.projectTag.deleteMany({ where: { projectId: { in: projectIds } } })
|
||||||
|
await prisma.projectStatusHistory.deleteMany({ where: { projectId: { in: projectIds } } })
|
||||||
|
await prisma.mentorMessage.deleteMany({ where: { projectId: { in: projectIds } } })
|
||||||
|
await prisma.mentorAssignment.deleteMany({ where: { projectId: { in: projectIds } } })
|
||||||
|
await prisma.cohortProject.deleteMany({ where: { projectId: { in: projectIds } } })
|
||||||
|
await prisma.teamMember.deleteMany({ where: { projectId: { in: projectIds } } })
|
||||||
|
await prisma.projectFile.deleteMany({ where: { projectId: { in: projectIds } } })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Special awards
|
||||||
|
if (competitionIds.length > 0) {
|
||||||
|
await prisma.specialAward.deleteMany({ where: { competitionId: { in: competitionIds } } })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Competitions
|
||||||
|
await prisma.competition.deleteMany({ where: { programId, isTest: true } })
|
||||||
|
|
||||||
|
// Projects
|
||||||
|
await prisma.project.deleteMany({ where: { programId, isTest: true } })
|
||||||
|
|
||||||
|
// Audit logs from test users
|
||||||
|
if (userIds.length > 0) {
|
||||||
|
await prisma.auditLog.deleteMany({ where: { userId: { in: userIds } } })
|
||||||
|
await prisma.notificationLog.deleteMany({ where: { userId: { in: userIds } } })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Users
|
||||||
|
await prisma.user.deleteMany({ where: { isTest: true } })
|
||||||
|
|
||||||
|
// Program
|
||||||
|
await prisma.program.deleteMany({ where: { id: programId, isTest: true } })
|
||||||
|
|
||||||
|
// Clean up email redirect setting
|
||||||
|
await prisma.systemSettings.deleteMany({ where: { key: 'test_email_redirect' } })
|
||||||
|
}
|
||||||
@@ -29,6 +29,7 @@ export interface LogAIUsageInput {
|
|||||||
entityType?: string
|
entityType?: string
|
||||||
entityId?: string
|
entityId?: string
|
||||||
model: string
|
model: string
|
||||||
|
provider?: string
|
||||||
promptTokens: number
|
promptTokens: number
|
||||||
completionTokens: number
|
completionTokens: number
|
||||||
totalTokens: number
|
totalTokens: number
|
||||||
@@ -98,6 +99,13 @@ const MODEL_PRICING: Record<string, ModelPricing> = {
|
|||||||
|
|
||||||
// o4 reasoning models (future-proofing)
|
// o4 reasoning models (future-proofing)
|
||||||
'o4-mini': { input: 1.1, output: 4.4 },
|
'o4-mini': { input: 1.1, output: 4.4 },
|
||||||
|
|
||||||
|
// Anthropic Claude models
|
||||||
|
'claude-opus-4-5-20250514': { input: 15.0, output: 75.0 },
|
||||||
|
'claude-sonnet-4-5-20250514': { input: 3.0, output: 15.0 },
|
||||||
|
'claude-haiku-3-5-20241022': { input: 0.8, output: 4.0 },
|
||||||
|
'claude-opus-4-20250514': { input: 15.0, output: 75.0 },
|
||||||
|
'claude-sonnet-4-20250514': { input: 3.0, output: 15.0 },
|
||||||
}
|
}
|
||||||
|
|
||||||
// Default pricing for unknown models (conservative estimate)
|
// Default pricing for unknown models (conservative estimate)
|
||||||
@@ -150,6 +158,16 @@ function getModelPricing(model: string): ModelPricing {
|
|||||||
if (modelLower.startsWith('o4')) {
|
if (modelLower.startsWith('o4')) {
|
||||||
return MODEL_PRICING['o4-mini'] || DEFAULT_PRICING
|
return MODEL_PRICING['o4-mini'] || DEFAULT_PRICING
|
||||||
}
|
}
|
||||||
|
// Anthropic Claude prefix fallbacks
|
||||||
|
if (modelLower.startsWith('claude-opus')) {
|
||||||
|
return { input: 15.0, output: 75.0 }
|
||||||
|
}
|
||||||
|
if (modelLower.startsWith('claude-sonnet')) {
|
||||||
|
return { input: 3.0, output: 15.0 }
|
||||||
|
}
|
||||||
|
if (modelLower.startsWith('claude-haiku')) {
|
||||||
|
return { input: 0.8, output: 4.0 }
|
||||||
|
}
|
||||||
|
|
||||||
return DEFAULT_PRICING
|
return DEFAULT_PRICING
|
||||||
}
|
}
|
||||||
@@ -200,6 +218,7 @@ export async function logAIUsage(input: LogAIUsageInput): Promise<void> {
|
|||||||
entityType: input.entityType,
|
entityType: input.entityType,
|
||||||
entityId: input.entityId,
|
entityId: input.entityId,
|
||||||
model: input.model,
|
model: input.model,
|
||||||
|
provider: input.provider,
|
||||||
promptTokens: input.promptTokens,
|
promptTokens: input.promptTokens,
|
||||||
completionTokens: input.completionTokens,
|
completionTokens: input.completionTokens,
|
||||||
totalTokens: input.totalTokens,
|
totalTokens: input.totalTokens,
|
||||||
|
|||||||
Reference in New Issue
Block a user