Admin UI audit round 2: fix 28 display bugs across 23 files
All checks were successful
Build and Push Docker Image / build (push) Successful in 8m51s
All checks were successful
Build and Push Docker Image / build (push) Successful in 8m51s
HIGH fixes (broken features / wrong data): - H1: Fix roundAssignments → projectRoundStates in project router (7 occurrences) - H2: Fix deliberation results panel blank table (wrong field names) - H3: Fix deliberation participant names blank (wrong data path) - H4: Fix awards "Evaluated" stat duplicating "Eligible" count - H5: Fix cross-round comparison enabled at 1 round (backend requires 2) - H6: Fix setState during render anti-pattern (6 occurrences) - H7: Fix round detail jury member count always showing 0 - H8: Remove 4 invalid status values from observer dashboard filter - H9: Fix filtering progress bar always showing 100% MEDIUM fixes (misleading display): - M1: Filter special-award rounds from competition timeline - M2: Exclude special-award rounds from distinct project count - M3: Fix MENTORING pipeline node hardcoded "0 mentored" - M4: Fix DELIB_LOCKED badge using red for success state - M5: Add status label maps to deliberation session detail - M6: Humanize deliberation category + tie-break method displays - M8: Rename setStageId → setRoundId, "Select Stage" → "Select Round" - M9: Add missing INVITED/ACTIVE/SUSPENDED to members status labels - M10: Add ROUND_DRAFT/ACTIVE/CLOSED/ARCHIVED to StatusBadge - M11: Fix unsent messages showing "Scheduled" instead of "Draft" - M12: Rename misleading totalEvaluations → totalAssignments - M13: Rename "Stage" column to "Program" in projects page LOW fixes (cosmetic / edge-case): - L1: Use unfiltered rounds array for active round detection - L2: Use all rounds length for new round sort order - L3: Filter special-award rounds from header count - L4: Fix single-underscore replace in award status badges - L5: Fix score bucket boundary gaps (4.99 dropped between buckets) - L6: Title-case LIVE_FINAL pipeline metric status - L7: Fix roundType.replace only replacing first underscore - L8: Remove duplicate severity sort in smart-actions component Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -438,7 +438,7 @@ export default function AwardDetailPage({
|
|||||||
</h1>
|
</h1>
|
||||||
<div className="flex items-center gap-2 mt-1">
|
<div className="flex items-center gap-2 mt-1">
|
||||||
<Badge variant={STATUS_COLORS[award.status] || 'secondary'}>
|
<Badge variant={STATUS_COLORS[award.status] || 'secondary'}>
|
||||||
{award.status.replace('_', ' ')}
|
{award.status.replace(/_/g, ' ')}
|
||||||
</Badge>
|
</Badge>
|
||||||
<span className="text-muted-foreground">
|
<span className="text-muted-foreground">
|
||||||
{award.program.year} Edition
|
{award.program.year} Edition
|
||||||
@@ -594,7 +594,7 @@ export default function AwardDetailPage({
|
|||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Evaluated</p>
|
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Evaluated</p>
|
||||||
<p className="text-2xl font-bold tabular-nums">{award._count.eligibilities}</p>
|
<p className="text-2xl font-bold tabular-nums">{(award as any).totalAssessed ?? award._count.eligibilities}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-blue-100 dark:bg-blue-950/40">
|
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-blue-100 dark:bg-blue-950/40">
|
||||||
<ListChecks className="h-5 w-5 text-blue-600 dark:text-blue-400" />
|
<ListChecks className="h-5 w-5 text-blue-600 dark:text-blue-400" />
|
||||||
@@ -657,7 +657,7 @@ export default function AwardDetailPage({
|
|||||||
<TabsContent value="eligibility" className="space-y-4">
|
<TabsContent value="eligibility" className="space-y-4">
|
||||||
<div className="flex flex-col gap-3 sm:flex-row sm:justify-between sm:items-center">
|
<div className="flex flex-col gap-3 sm:flex-row sm:justify-between sm:items-center">
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
{award.eligibleCount} of {award._count.eligibilities} projects
|
{award.eligibleCount} of {(award as any).totalAssessed ?? award._count.eligibilities} projects
|
||||||
eligible
|
eligible
|
||||||
</p>
|
</p>
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
|
|||||||
@@ -171,7 +171,7 @@ export default function AwardsListPage() {
|
|||||||
{award.name}
|
{award.name}
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<Badge variant={STATUS_COLORS[award.status] || 'secondary'}>
|
<Badge variant={STATUS_COLORS[award.status] || 'secondary'}>
|
||||||
{award.status.replace('_', ' ')}
|
{award.status.replace(/_/g, ' ')}
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
{award.description && (
|
{award.description && (
|
||||||
|
|||||||
@@ -12,6 +12,30 @@ import { toast } from 'sonner';
|
|||||||
import { ResultsPanel } from '@/components/admin/deliberation/results-panel';
|
import { ResultsPanel } from '@/components/admin/deliberation/results-panel';
|
||||||
import type { Route } from 'next';
|
import type { Route } from 'next';
|
||||||
|
|
||||||
|
const STATUS_LABELS: Record<string, string> = {
|
||||||
|
DELIB_OPEN: 'Open',
|
||||||
|
VOTING: 'Voting',
|
||||||
|
TALLYING: 'Tallying',
|
||||||
|
RUNOFF: 'Runoff',
|
||||||
|
DELIB_LOCKED: 'Locked',
|
||||||
|
};
|
||||||
|
const STATUS_VARIANTS: Record<string, 'default' | 'secondary' | 'destructive' | 'outline'> = {
|
||||||
|
DELIB_OPEN: 'outline',
|
||||||
|
VOTING: 'default',
|
||||||
|
TALLYING: 'secondary',
|
||||||
|
RUNOFF: 'secondary',
|
||||||
|
DELIB_LOCKED: 'secondary',
|
||||||
|
};
|
||||||
|
const CATEGORY_LABELS: Record<string, string> = {
|
||||||
|
STARTUP: 'Startup',
|
||||||
|
BUSINESS_CONCEPT: 'Business Concept',
|
||||||
|
};
|
||||||
|
const TIE_BREAK_LABELS: Record<string, string> = {
|
||||||
|
TIE_RUNOFF: 'Runoff Vote',
|
||||||
|
TIE_ADMIN_DECIDES: 'Admin Decides',
|
||||||
|
SCORE_FALLBACK: 'Score Fallback',
|
||||||
|
};
|
||||||
|
|
||||||
export default function DeliberationSessionPage({
|
export default function DeliberationSessionPage({
|
||||||
params: paramsPromise
|
params: paramsPromise
|
||||||
}: {
|
}: {
|
||||||
@@ -97,10 +121,10 @@ export default function DeliberationSessionPage({
|
|||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<h1 className="text-3xl font-bold">Deliberation Session</h1>
|
<h1 className="text-3xl font-bold">Deliberation Session</h1>
|
||||||
<Badge>{session.status}</Badge>
|
<Badge variant={STATUS_VARIANTS[session.status] ?? 'outline'}>{STATUS_LABELS[session.status] ?? session.status}</Badge>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-muted-foreground">
|
<p className="text-muted-foreground">
|
||||||
{session.round?.name} - {session.category}
|
{session.round?.name} - {CATEGORY_LABELS[session.category] ?? session.category}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -128,7 +152,7 @@ export default function DeliberationSessionPage({
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm font-medium text-muted-foreground">Tie Break Method</p>
|
<p className="text-sm font-medium text-muted-foreground">Tie Break Method</p>
|
||||||
<p className="mt-1">{session.tieBreakMethod}</p>
|
<p className="mt-1">{TIE_BREAK_LABELS[session.tieBreakMethod] ?? session.tieBreakMethod}</p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm font-medium text-muted-foreground">
|
<p className="text-sm font-medium text-muted-foreground">
|
||||||
@@ -156,8 +180,8 @@ export default function DeliberationSessionPage({
|
|||||||
className="flex items-center justify-between rounded-lg border p-3"
|
className="flex items-center justify-between rounded-lg border p-3"
|
||||||
>
|
>
|
||||||
<div>
|
<div>
|
||||||
<p className="font-medium">{participant.user?.name}</p>
|
<p className="font-medium">{participant.user?.user?.name ?? 'Unknown'}</p>
|
||||||
<p className="text-sm text-muted-foreground">{participant.user?.email}</p>
|
<p className="text-sm text-muted-foreground">{participant.user?.user?.email}</p>
|
||||||
</div>
|
</div>
|
||||||
<Badge variant={voterUserIds.has(participant.user?.user?.id) ? 'default' : 'outline'}>
|
<Badge variant={voterUserIds.has(participant.user?.user?.id) ? 'default' : 'outline'}>
|
||||||
{voterUserIds.has(participant.user?.user?.id) ? 'Voted' : 'Pending'}
|
{voterUserIds.has(participant.user?.user?.id) ? 'Voted' : 'Pending'}
|
||||||
@@ -211,7 +235,7 @@ export default function DeliberationSessionPage({
|
|||||||
key={participant.id}
|
key={participant.id}
|
||||||
className="flex items-center justify-between rounded-lg border p-3"
|
className="flex items-center justify-between rounded-lg border p-3"
|
||||||
>
|
>
|
||||||
<span>{participant.user?.name}</span>
|
<span>{participant.user?.user?.name ?? 'Unknown'}</span>
|
||||||
<Badge variant={voterUserIds.has(participant.user?.user?.id) ? 'default' : 'secondary'}>
|
<Badge variant={voterUserIds.has(participant.user?.user?.id) ? 'default' : 'secondary'}>
|
||||||
{voterUserIds.has(participant.user?.user?.id) ? 'Submitted' : 'Not Voted'}
|
{voterUserIds.has(participant.user?.user?.id) ? 'Submitted' : 'Not Voted'}
|
||||||
</Badge>
|
</Badge>
|
||||||
|
|||||||
@@ -109,7 +109,7 @@ export default function DeliberationListPage({
|
|||||||
VOTING: 'default',
|
VOTING: 'default',
|
||||||
TALLYING: 'secondary',
|
TALLYING: 'secondary',
|
||||||
RUNOFF: 'secondary',
|
RUNOFF: 'secondary',
|
||||||
DELIB_LOCKED: 'destructive',
|
DELIB_LOCKED: 'secondary',
|
||||||
};
|
};
|
||||||
const labels: Record<string, string> = {
|
const labels: Record<string, string> = {
|
||||||
DELIB_OPEN: 'Open',
|
DELIB_OPEN: 'Open',
|
||||||
@@ -173,7 +173,7 @@ export default function DeliberationListPage({
|
|||||||
<div className="flex items-start justify-between">
|
<div className="flex items-start justify-between">
|
||||||
<div>
|
<div>
|
||||||
<CardTitle>
|
<CardTitle>
|
||||||
{session.round?.name} - {session.category}
|
{session.round?.name} - {session.category === 'BUSINESS_CONCEPT' ? 'Business Concept' : session.category === 'STARTUP' ? 'Startup' : session.category}
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<CardDescription className="mt-1">
|
<CardDescription className="mt-1">
|
||||||
{session.mode === 'SINGLE_WINNER_VOTE' ? 'Single Winner Vote' : 'Full Ranking'}
|
{session.mode === 'SINGLE_WINNER_VOTE' ? 'Single Winner Vote' : 'Full Ranking'}
|
||||||
@@ -186,7 +186,7 @@ export default function DeliberationListPage({
|
|||||||
<div className="flex flex-wrap gap-2 text-sm text-muted-foreground">
|
<div className="flex flex-wrap gap-2 text-sm text-muted-foreground">
|
||||||
<span>{session.participants?.length || 0} participants</span>
|
<span>{session.participants?.length || 0} participants</span>
|
||||||
<span>•</span>
|
<span>•</span>
|
||||||
<span>Tie break: {session.tieBreakMethod}</span>
|
<span>Tie break: {session.tieBreakMethod === 'TIE_RUNOFF' ? 'Runoff Vote' : session.tieBreakMethod === 'TIE_ADMIN_DECIDES' ? 'Admin Decides' : session.tieBreakMethod === 'SCORE_FALLBACK' ? 'Score Fallback' : session.tieBreakMethod}</span>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@@ -332,7 +332,7 @@ export default function CompetitionDetailPage() {
|
|||||||
<TabsContent value="overview" className="space-y-6">
|
<TabsContent value="overview" className="space-y-6">
|
||||||
<CompetitionTimeline
|
<CompetitionTimeline
|
||||||
competitionId={competitionId}
|
competitionId={competitionId}
|
||||||
rounds={competition.rounds}
|
rounds={competition.rounds.filter((r: any) => !r.specialAwardId)}
|
||||||
/>
|
/>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
@@ -386,7 +386,7 @@ export default function CompetitionDetailPage() {
|
|||||||
roundTypeColors[round.roundType] ?? 'bg-gray-100 text-gray-700'
|
roundTypeColors[round.roundType] ?? 'bg-gray-100 text-gray-700'
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{round.roundType.replace('_', ' ')}
|
{round.roundType.replace(/_/g, ' ')}
|
||||||
</Badge>
|
</Badge>
|
||||||
<Badge
|
<Badge
|
||||||
variant="outline"
|
variant="outline"
|
||||||
|
|||||||
@@ -79,7 +79,7 @@ const ROLES = ['JURY_MEMBER', 'MENTOR', 'OBSERVER', 'APPLICANT', 'PROGRAM_ADMIN'
|
|||||||
export default function MessagesPage() {
|
export default function MessagesPage() {
|
||||||
const [recipientType, setRecipientType] = useState<RecipientType>('ALL')
|
const [recipientType, setRecipientType] = useState<RecipientType>('ALL')
|
||||||
const [selectedRole, setSelectedRole] = useState('')
|
const [selectedRole, setSelectedRole] = useState('')
|
||||||
const [roundId, setStageId] = useState('')
|
const [roundId, setRoundId] = useState('')
|
||||||
const [selectedProgramId, setSelectedProgramId] = useState('')
|
const [selectedProgramId, setSelectedProgramId] = useState('')
|
||||||
const [selectedUserId, setSelectedUserId] = useState('')
|
const [selectedUserId, setSelectedUserId] = useState('')
|
||||||
const [subject, setSubject] = useState('')
|
const [subject, setSubject] = useState('')
|
||||||
@@ -125,7 +125,7 @@ export default function MessagesPage() {
|
|||||||
setBody('')
|
setBody('')
|
||||||
setSelectedTemplateId('')
|
setSelectedTemplateId('')
|
||||||
setSelectedRole('')
|
setSelectedRole('')
|
||||||
setStageId('')
|
setRoundId('')
|
||||||
setSelectedProgramId('')
|
setSelectedProgramId('')
|
||||||
setSelectedUserId('')
|
setSelectedUserId('')
|
||||||
setIsScheduled(false)
|
setIsScheduled(false)
|
||||||
@@ -219,7 +219,7 @@ export default function MessagesPage() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (recipientType === 'ROUND_JURY' && !roundId) {
|
if (recipientType === 'ROUND_JURY' && !roundId) {
|
||||||
toast.error('Please select a stage')
|
toast.error('Please select a round')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (recipientType === 'PROGRAM_TEAM' && !selectedProgramId) {
|
if (recipientType === 'PROGRAM_TEAM' && !selectedProgramId) {
|
||||||
@@ -296,7 +296,7 @@ export default function MessagesPage() {
|
|||||||
onValueChange={(v) => {
|
onValueChange={(v) => {
|
||||||
setRecipientType(v as RecipientType)
|
setRecipientType(v as RecipientType)
|
||||||
setSelectedRole('')
|
setSelectedRole('')
|
||||||
setStageId('')
|
setRoundId('')
|
||||||
setSelectedProgramId('')
|
setSelectedProgramId('')
|
||||||
setSelectedUserId('')
|
setSelectedUserId('')
|
||||||
}}
|
}}
|
||||||
@@ -335,10 +335,10 @@ export default function MessagesPage() {
|
|||||||
|
|
||||||
{recipientType === 'ROUND_JURY' && (
|
{recipientType === 'ROUND_JURY' && (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label>Select Stage</Label>
|
<Label>Select Round</Label>
|
||||||
<Select value={roundId} onValueChange={setStageId}>
|
<Select value={roundId} onValueChange={setRoundId}>
|
||||||
<SelectTrigger>
|
<SelectTrigger>
|
||||||
<SelectValue placeholder="Choose a stage..." />
|
<SelectValue placeholder="Choose a round..." />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{rounds?.map((round) => (
|
{rounds?.map((round) => (
|
||||||
@@ -616,11 +616,15 @@ export default function MessagesPage() {
|
|||||||
<CheckCircle2 className="mr-1 h-3 w-3" />
|
<CheckCircle2 className="mr-1 h-3 w-3" />
|
||||||
Sent
|
Sent
|
||||||
</Badge>
|
</Badge>
|
||||||
) : (
|
) : msg.scheduledAt ? (
|
||||||
<Badge variant="default" className="text-xs">
|
<Badge variant="default" className="text-xs">
|
||||||
<Clock className="mr-1 h-3 w-3" />
|
<Clock className="mr-1 h-3 w-3" />
|
||||||
Scheduled
|
Scheduled
|
||||||
</Badge>
|
</Badge>
|
||||||
|
) : (
|
||||||
|
<Badge variant="outline" className="text-xs">
|
||||||
|
Draft
|
||||||
|
</Badge>
|
||||||
)}
|
)}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="text-right text-sm text-muted-foreground">
|
<TableCell className="text-right text-sm text-muted-foreground">
|
||||||
|
|||||||
@@ -864,7 +864,7 @@ export default function ProjectsPage() {
|
|||||||
</TableHead>
|
</TableHead>
|
||||||
<TableHead className="min-w-[280px]">Project</TableHead>
|
<TableHead className="min-w-[280px]">Project</TableHead>
|
||||||
<TableHead>Category</TableHead>
|
<TableHead>Category</TableHead>
|
||||||
<TableHead>Stage</TableHead>
|
<TableHead>Program</TableHead>
|
||||||
<TableHead>Tags</TableHead>
|
<TableHead>Tags</TableHead>
|
||||||
<TableHead>Assignments</TableHead>
|
<TableHead>Assignments</TableHead>
|
||||||
<TableHead>Status</TableHead>
|
<TableHead>Status</TableHead>
|
||||||
@@ -1065,7 +1065,7 @@ export default function ProjectsPage() {
|
|||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-3">
|
<CardContent className="space-y-3">
|
||||||
<div className="flex items-center justify-between text-sm">
|
<div className="flex items-center justify-between text-sm">
|
||||||
<span className="text-muted-foreground">Stage</span>
|
<span className="text-muted-foreground">Program</span>
|
||||||
<span>{project.program?.name ?? 'Unassigned'}</span>
|
<span>{project.program?.name ?? 'Unassigned'}</span>
|
||||||
</div>
|
</div>
|
||||||
{project.competitionCategory && (
|
{project.competitionCategory && (
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useState } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
import { trpc } from '@/lib/trpc/client'
|
import { trpc } from '@/lib/trpc/client'
|
||||||
import {
|
import {
|
||||||
@@ -71,9 +71,11 @@ function ReportsOverview() {
|
|||||||
// Project reporting scope (default: latest program, all rounds)
|
// Project reporting scope (default: latest program, all rounds)
|
||||||
const [selectedValue, setSelectedValue] = useState<string | null>(null)
|
const [selectedValue, setSelectedValue] = useState<string | null>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
if (programs?.length && !selectedValue) {
|
if (programs?.length && !selectedValue) {
|
||||||
setSelectedValue(`all:${programs[0].id}`)
|
setSelectedValue(`all:${programs[0].id}`)
|
||||||
}
|
}
|
||||||
|
}, [programs, selectedValue])
|
||||||
|
|
||||||
const scopeInput = parseSelection(selectedValue)
|
const scopeInput = parseSelection(selectedValue)
|
||||||
const hasScope = !!scopeInput.roundId || !!scopeInput.programId
|
const hasScope = !!scopeInput.roundId || !!scopeInput.programId
|
||||||
@@ -109,7 +111,7 @@ function ReportsOverview() {
|
|||||||
const activeRounds = dashStats?.activeRoundCount ?? rounds.filter((r: { status: string }) => r.status === 'ROUND_ACTIVE').length
|
const activeRounds = dashStats?.activeRoundCount ?? rounds.filter((r: { status: string }) => r.status === 'ROUND_ACTIVE').length
|
||||||
const jurorCount = dashStats?.jurorCount ?? 0
|
const jurorCount = dashStats?.jurorCount ?? 0
|
||||||
const submittedEvaluations = dashStats?.submittedEvaluations ?? 0
|
const submittedEvaluations = dashStats?.submittedEvaluations ?? 0
|
||||||
const totalEvaluations = dashStats?.totalEvaluations ?? 0
|
const totalAssignments = dashStats?.totalAssignments ?? 0
|
||||||
const completionRate = dashStats?.completionRate ?? 0
|
const completionRate = dashStats?.completionRate ?? 0
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -177,7 +179,7 @@ function ReportsOverview() {
|
|||||||
<p className="text-sm font-medium text-muted-foreground">Evaluations</p>
|
<p className="text-sm font-medium text-muted-foreground">Evaluations</p>
|
||||||
<p className="text-2xl font-bold mt-1">{submittedEvaluations}</p>
|
<p className="text-2xl font-bold mt-1">{submittedEvaluations}</p>
|
||||||
<p className="text-xs text-muted-foreground mt-1">
|
<p className="text-xs text-muted-foreground mt-1">
|
||||||
{totalEvaluations > 0
|
{totalAssignments > 0
|
||||||
? `${completionRate}% completion rate`
|
? `${completionRate}% completion rate`
|
||||||
: 'No assignments yet'}
|
: 'No assignments yet'}
|
||||||
</p>
|
</p>
|
||||||
@@ -417,9 +419,11 @@ function StageAnalytics() {
|
|||||||
) || []
|
) || []
|
||||||
|
|
||||||
// Set default selected stage
|
// Set default selected stage
|
||||||
|
useEffect(() => {
|
||||||
if (rounds.length && !selectedValue) {
|
if (rounds.length && !selectedValue) {
|
||||||
setSelectedValue(rounds[0].id)
|
setSelectedValue(rounds[0].id)
|
||||||
}
|
}
|
||||||
|
}, [rounds.length, selectedValue])
|
||||||
|
|
||||||
const queryInput = parseSelection(selectedValue)
|
const queryInput = parseSelection(selectedValue)
|
||||||
const hasSelection = !!queryInput.roundId || !!queryInput.programId
|
const hasSelection = !!queryInput.roundId || !!queryInput.programId
|
||||||
@@ -701,9 +705,11 @@ function JurorConsistencyTab() {
|
|||||||
((p.stages ?? []) as Array<{ id: string; name: string }>).map((s: { id: string; name: string }) => ({ id: s.id, name: s.name, programId: p.id, programName: `${p.year} Edition` }))
|
((p.stages ?? []) as Array<{ id: string; name: string }>).map((s: { id: string; name: string }) => ({ id: s.id, name: s.name, programId: p.id, programName: `${p.year} Edition` }))
|
||||||
) || []
|
) || []
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
if (stages.length && !selectedValue) {
|
if (stages.length && !selectedValue) {
|
||||||
setSelectedValue(stages[0].id)
|
setSelectedValue(stages[0].id)
|
||||||
}
|
}
|
||||||
|
}, [stages.length, selectedValue])
|
||||||
|
|
||||||
const queryInput = parseSelection(selectedValue)
|
const queryInput = parseSelection(selectedValue)
|
||||||
const hasSelection = !!queryInput.roundId || !!queryInput.programId
|
const hasSelection = !!queryInput.roundId || !!queryInput.programId
|
||||||
@@ -773,9 +779,11 @@ function DiversityTab() {
|
|||||||
((p.stages ?? []) as Array<{ id: string; name: string }>).map((s: { id: string; name: string }) => ({ id: s.id, name: s.name, programId: p.id, programName: `${p.year} Edition` }))
|
((p.stages ?? []) as Array<{ id: string; name: string }>).map((s: { id: string; name: string }) => ({ id: s.id, name: s.name, programId: p.id, programName: `${p.year} Edition` }))
|
||||||
) || []
|
) || []
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
if (stages.length && !selectedValue) {
|
if (stages.length && !selectedValue) {
|
||||||
setSelectedValue(stages[0].id)
|
setSelectedValue(stages[0].id)
|
||||||
}
|
}
|
||||||
|
}, [stages.length, selectedValue])
|
||||||
|
|
||||||
const queryInput = parseSelection(selectedValue)
|
const queryInput = parseSelection(selectedValue)
|
||||||
const hasSelection = !!queryInput.roundId || !!queryInput.programId
|
const hasSelection = !!queryInput.roundId || !!queryInput.programId
|
||||||
@@ -846,7 +854,7 @@ function RoundPipelineTab() {
|
|||||||
const { data: comparison, isLoading: comparisonLoading } =
|
const { data: comparison, isLoading: comparisonLoading } =
|
||||||
trpc.analytics.getCrossRoundComparison.useQuery(
|
trpc.analytics.getCrossRoundComparison.useQuery(
|
||||||
{ roundIds },
|
{ roundIds },
|
||||||
{ enabled: roundIds.length >= 1 }
|
{ enabled: roundIds.length >= 2 }
|
||||||
)
|
)
|
||||||
|
|
||||||
if (isLoading || comparisonLoading) {
|
if (isLoading || comparisonLoading) {
|
||||||
@@ -929,9 +937,11 @@ export default function ReportsPage() {
|
|||||||
((p.stages ?? []) as Array<{ id: string; name: string }>).map((s: { id: string; name: string }) => ({ id: s.id, name: s.name, programName: `${p.year} Edition` }))
|
((p.stages ?? []) as Array<{ id: string; name: string }>).map((s: { id: string; name: string }) => ({ id: s.id, name: s.name, programName: `${p.year} Edition` }))
|
||||||
) || []
|
) || []
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
if (pdfStages.length && !pdfStageId) {
|
if (pdfStages.length && !pdfStageId) {
|
||||||
setPdfStageId(pdfStages[0].id)
|
setPdfStageId(pdfStages[0].id)
|
||||||
}
|
}
|
||||||
|
}, [pdfStages.length, pdfStageId])
|
||||||
|
|
||||||
const selectedPdfStage = pdfStages.find((r) => r.id === pdfStageId)
|
const selectedPdfStage = pdfStages.find((r) => r.id === pdfStageId)
|
||||||
|
|
||||||
|
|||||||
@@ -450,7 +450,7 @@ export default function RoundDetailPage() {
|
|||||||
[projectStates])
|
[projectStates])
|
||||||
const passedCount = stateCounts['PASSED'] ?? 0
|
const passedCount = stateCounts['PASSED'] ?? 0
|
||||||
const juryGroup = round?.juryGroup
|
const juryGroup = round?.juryGroup
|
||||||
const juryMemberCount = juryGroup?.members?.length ?? 0
|
const juryMemberCount = juryGroupDetail?.members?.length ?? 0
|
||||||
|
|
||||||
const isFiltering = round?.roundType === 'FILTERING'
|
const isFiltering = round?.roundType === 'FILTERING'
|
||||||
const isEvaluation = round?.roundType === 'EVALUATION'
|
const isEvaluation = round?.roundType === 'EVALUATION'
|
||||||
|
|||||||
@@ -95,6 +95,7 @@ type RoundWithStats = {
|
|||||||
sortOrder: number
|
sortOrder: number
|
||||||
windowOpenAt: string | null
|
windowOpenAt: string | null
|
||||||
windowCloseAt: string | null
|
windowCloseAt: string | null
|
||||||
|
specialAwardId: string | null
|
||||||
juryGroup: { id: string; name: string } | null
|
juryGroup: { id: string; name: string } | null
|
||||||
_count: { projectRoundStates: number; assignments: number }
|
_count: { projectRoundStates: number; assignments: number }
|
||||||
}
|
}
|
||||||
@@ -193,7 +194,7 @@ export default function RoundsPage() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
const slug = roundForm.name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '')
|
const slug = roundForm.name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '')
|
||||||
const nextOrder = rounds.length
|
const nextOrder = (compDetail?.rounds ?? []).length
|
||||||
createRoundMutation.mutate({
|
createRoundMutation.mutate({
|
||||||
competitionId: comp.id,
|
competitionId: comp.id,
|
||||||
name: roundForm.name.trim(),
|
name: roundForm.name.trim(),
|
||||||
@@ -286,7 +287,7 @@ export default function RoundsPage() {
|
|||||||
const totalProjects = (compDetail as any)?.distinctProjectCount ?? 0
|
const totalProjects = (compDetail as any)?.distinctProjectCount ?? 0
|
||||||
const allRounds = (compDetail?.rounds ?? []) as RoundWithStats[]
|
const allRounds = (compDetail?.rounds ?? []) as RoundWithStats[]
|
||||||
const totalAssignments = allRounds.reduce((s, r) => s + r._count.assignments, 0)
|
const totalAssignments = allRounds.reduce((s, r) => s + r._count.assignments, 0)
|
||||||
const activeRound = rounds.find((r) => r.status === 'ROUND_ACTIVE')
|
const activeRound = allRounds.find((r) => r.status === 'ROUND_ACTIVE')
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TooltipProvider delayDuration={200}>
|
<TooltipProvider delayDuration={200}>
|
||||||
@@ -327,7 +328,7 @@ export default function RoundsPage() {
|
|||||||
</Tooltip>
|
</Tooltip>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-4 mt-1 text-sm text-muted-foreground">
|
<div className="flex items-center gap-4 mt-1 text-sm text-muted-foreground">
|
||||||
<span>{allRounds.length} rounds</span>
|
<span>{allRounds.filter((r) => !r.specialAwardId).length} rounds</span>
|
||||||
<span className="text-muted-foreground/30">|</span>
|
<span className="text-muted-foreground/30">|</span>
|
||||||
<span>{totalProjects} projects</span>
|
<span>{totalProjects} projects</span>
|
||||||
<span className="text-muted-foreground/30">|</span>
|
<span className="text-muted-foreground/30">|</span>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useState } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
import { trpc } from '@/lib/trpc/client'
|
import { trpc } from '@/lib/trpc/client'
|
||||||
import {
|
import {
|
||||||
Card,
|
Card,
|
||||||
@@ -591,9 +591,11 @@ export default function ObserverReportsPage() {
|
|||||||
) || []
|
) || []
|
||||||
|
|
||||||
// Set default selected stage
|
// Set default selected stage
|
||||||
|
useEffect(() => {
|
||||||
if (stages.length && !selectedValue) {
|
if (stages.length && !selectedValue) {
|
||||||
setSelectedValue(stages[0].id)
|
setSelectedValue(stages[0].id)
|
||||||
}
|
}
|
||||||
|
}, [stages.length, selectedValue])
|
||||||
|
|
||||||
const hasSelection = !!selectedValue
|
const hasSelection = !!selectedValue
|
||||||
const selectedRound = stages.find((s) => s.id === selectedValue)
|
const selectedRound = stages.find((s) => s.id === selectedValue)
|
||||||
|
|||||||
@@ -24,7 +24,10 @@ export function ResultsPanel({ sessionId }: ResultsPanelProps) {
|
|||||||
);
|
);
|
||||||
const { data: aggregatedResults } = trpc.deliberation.aggregate.useQuery(
|
const { data: aggregatedResults } = trpc.deliberation.aggregate.useQuery(
|
||||||
{ sessionId },
|
{ sessionId },
|
||||||
{ refetchInterval: 10_000 }
|
{
|
||||||
|
refetchInterval: 10_000,
|
||||||
|
enabled: session?.status === 'TALLYING' || session?.status === 'RUNOFF' || session?.status === 'DELIB_LOCKED',
|
||||||
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
const initRunoffMutation = trpc.deliberation.initRunoff.useMutation({
|
const initRunoffMutation = trpc.deliberation.initRunoff.useMutation({
|
||||||
@@ -52,34 +55,32 @@ export function ResultsPanel({ sessionId }: ResultsPanelProps) {
|
|||||||
return (
|
return (
|
||||||
<Card>
|
<Card>
|
||||||
<CardContent className="p-12 text-center">
|
<CardContent className="p-12 text-center">
|
||||||
<p className="text-muted-foreground">No voting results yet</p>
|
<p className="text-muted-foreground">
|
||||||
|
{session?.status === 'DELIB_OPEN' || session?.status === 'VOTING'
|
||||||
|
? 'Voting has not been tallied yet'
|
||||||
|
: 'No voting results yet'}
|
||||||
|
</p>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Detect ties: check if two or more top-ranked candidates share the same totalScore
|
// Detect ties using the backend-computed flag, with client-side fallback
|
||||||
const hasTie = (() => {
|
const hasTie = aggregatedResults.hasTies ?? (() => {
|
||||||
const rankings = aggregatedResults.rankings as Array<{ totalScore?: number; projectId: string }> | undefined;
|
const rankings = aggregatedResults.rankings as Array<{ score?: number; projectId: string }> | undefined;
|
||||||
if (!rankings || rankings.length < 2) return false;
|
if (!rankings || rankings.length < 2) return false;
|
||||||
// Group projects by totalScore
|
|
||||||
const scoreGroups = new Map<number, string[]>();
|
const scoreGroups = new Map<number, string[]>();
|
||||||
for (const r of rankings) {
|
for (const r of rankings) {
|
||||||
const score = r.totalScore ?? 0;
|
const score = r.score ?? 0;
|
||||||
const group = scoreGroups.get(score) || [];
|
const group = scoreGroups.get(score) || [];
|
||||||
group.push(r.projectId);
|
group.push(r.projectId);
|
||||||
scoreGroups.set(score, group);
|
scoreGroups.set(score, group);
|
||||||
}
|
}
|
||||||
// A tie exists if the highest score is shared by 2+ projects
|
|
||||||
const topScore = Math.max(...scoreGroups.keys());
|
const topScore = Math.max(...scoreGroups.keys());
|
||||||
const topGroup = scoreGroups.get(topScore);
|
const topGroup = scoreGroups.get(topScore);
|
||||||
return (topGroup?.length ?? 0) >= 2;
|
return (topGroup?.length ?? 0) >= 2;
|
||||||
})();
|
})();
|
||||||
const tiedProjectIds = hasTie
|
const tiedProjectIds = aggregatedResults.tiedProjectIds ?? [];
|
||||||
? (aggregatedResults.rankings as Array<{ totalScore?: number; projectId: string }>)
|
|
||||||
.filter((r) => r.totalScore === (aggregatedResults.rankings as Array<{ totalScore?: number }>)[0]?.totalScore)
|
|
||||||
.map((r) => r.projectId)
|
|
||||||
: [];
|
|
||||||
const canFinalize = session?.status === 'TALLYING' && !hasTie;
|
const canFinalize = session?.status === 'TALLYING' && !hasTie;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -101,17 +102,17 @@ export function ResultsPanel({ sessionId }: ResultsPanelProps) {
|
|||||||
>
|
>
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-primary/10 font-bold">
|
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-primary/10 font-bold">
|
||||||
#{index + 1}
|
#{result.rank ?? index + 1}
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p className="font-medium">{result.projectTitle}</p>
|
<p className="font-medium">{result.projectTitle ?? result.projectId}</p>
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
{result.votes} votes • {result.averageRank?.toFixed(2)} avg rank
|
{result.voteCount} votes
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Badge variant="outline" className="text-lg">
|
<Badge variant="outline" className="text-lg">
|
||||||
{result.totalScore?.toFixed(1) || 0}
|
{result.score?.toFixed?.(1) ?? 0}
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -53,6 +53,9 @@ const statusColors: Record<string, 'default' | 'success' | 'secondary' | 'destru
|
|||||||
|
|
||||||
const statusLabels: Record<string, string> = {
|
const statusLabels: Record<string, string> = {
|
||||||
NONE: 'Not Invited',
|
NONE: 'Not Invited',
|
||||||
|
INVITED: 'Invited',
|
||||||
|
ACTIVE: 'Active',
|
||||||
|
SUSPENDED: 'Suspended',
|
||||||
}
|
}
|
||||||
|
|
||||||
const roleColors: Record<string, 'default' | 'outline' | 'secondary'> = {
|
const roleColors: Record<string, 'default' | 'outline' | 'secondary'> = {
|
||||||
|
|||||||
@@ -169,7 +169,7 @@ function RoundTypeContent({ round }: { round: PipelineRound }) {
|
|||||||
|
|
||||||
case 'FILTERING': {
|
case 'FILTERING': {
|
||||||
const processed = round.filteringPassed + round.filteringRejected + round.filteringFlagged
|
const processed = round.filteringPassed + round.filteringRejected + round.filteringFlagged
|
||||||
const total = round.filteringTotal
|
const total = round.projectStates.total || round.filteringTotal
|
||||||
const pct = total > 0 ? Math.round((processed / total) * 100) : 0
|
const pct = total > 0 ? Math.round((processed / total) * 100) : 0
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -133,9 +133,11 @@ function getMetric(round: PipelineRound): string {
|
|||||||
case 'SUBMISSION':
|
case 'SUBMISSION':
|
||||||
return `${projectStates.COMPLETED} submitted`
|
return `${projectStates.COMPLETED} submitted`
|
||||||
case 'MENTORING':
|
case 'MENTORING':
|
||||||
return '0 mentored'
|
return `${projectStates.COMPLETED ?? 0} mentored`
|
||||||
case 'LIVE_FINAL':
|
case 'LIVE_FINAL': {
|
||||||
return liveSessionStatus || `${projectStates.total} finalists`
|
const status = liveSessionStatus
|
||||||
|
return status ? status.charAt(0) + status.slice(1).toLowerCase() : `${projectStates.total} finalists`
|
||||||
|
}
|
||||||
case 'DELIBERATION':
|
case 'DELIBERATION':
|
||||||
return deliberationCount > 0
|
return deliberationCount > 0
|
||||||
? `${deliberationCount} sessions`
|
? `${deliberationCount} sessions`
|
||||||
|
|||||||
@@ -29,12 +29,6 @@ type SmartActionsProps = {
|
|||||||
actions: DashboardAction[]
|
actions: DashboardAction[]
|
||||||
}
|
}
|
||||||
|
|
||||||
const severityOrder: Record<DashboardAction['severity'], number> = {
|
|
||||||
critical: 0,
|
|
||||||
warning: 1,
|
|
||||||
info: 2,
|
|
||||||
}
|
|
||||||
|
|
||||||
const severityConfig = {
|
const severityConfig = {
|
||||||
critical: {
|
critical: {
|
||||||
icon: AlertTriangle,
|
icon: AlertTriangle,
|
||||||
@@ -57,10 +51,6 @@ const severityConfig = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function SmartActions({ actions }: SmartActionsProps) {
|
export function SmartActions({ actions }: SmartActionsProps) {
|
||||||
const sorted = [...actions].sort(
|
|
||||||
(a, b) => severityOrder[a.severity] - severityOrder[b.severity]
|
|
||||||
)
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader className="flex flex-row items-center gap-3 space-y-0 pb-4">
|
<CardHeader className="flex flex-row items-center gap-3 space-y-0 pb-4">
|
||||||
@@ -73,7 +63,7 @@ export function SmartActions({ actions }: SmartActionsProps) {
|
|||||||
)}
|
)}
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
{sorted.length === 0 ? (
|
{actions.length === 0 ? (
|
||||||
<div className="flex flex-col items-center justify-center py-8 text-center">
|
<div className="flex flex-col items-center justify-center py-8 text-center">
|
||||||
<div className="flex h-12 w-12 items-center justify-center rounded-full bg-emerald-100 dark:bg-emerald-900/40">
|
<div className="flex h-12 w-12 items-center justify-center rounded-full bg-emerald-100 dark:bg-emerald-900/40">
|
||||||
<CheckCircle2 className="h-6 w-6 text-emerald-600 dark:text-emerald-400" />
|
<CheckCircle2 className="h-6 w-6 text-emerald-600 dark:text-emerald-400" />
|
||||||
@@ -84,7 +74,7 @@ export function SmartActions({ actions }: SmartActionsProps) {
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{sorted.map((action) => {
|
{actions.map((action) => {
|
||||||
const config = severityConfig[action.severity]
|
const config = severityConfig[action.severity]
|
||||||
const Icon = config.icon
|
const Icon = config.icon
|
||||||
|
|
||||||
|
|||||||
@@ -290,13 +290,9 @@ export function ObserverDashboardContent({ userName }: { userName?: string }) {
|
|||||||
<SelectItem value="SUBMITTED">Submitted</SelectItem>
|
<SelectItem value="SUBMITTED">Submitted</SelectItem>
|
||||||
<SelectItem value="ELIGIBLE">Eligible</SelectItem>
|
<SelectItem value="ELIGIBLE">Eligible</SelectItem>
|
||||||
<SelectItem value="ASSIGNED">Assigned</SelectItem>
|
<SelectItem value="ASSIGNED">Assigned</SelectItem>
|
||||||
<SelectItem value="UNDER_REVIEW">Under Review</SelectItem>
|
|
||||||
<SelectItem value="SHORTLISTED">Shortlisted</SelectItem>
|
|
||||||
<SelectItem value="SEMIFINALIST">Semi-finalist</SelectItem>
|
<SelectItem value="SEMIFINALIST">Semi-finalist</SelectItem>
|
||||||
<SelectItem value="FINALIST">Finalist</SelectItem>
|
<SelectItem value="FINALIST">Finalist</SelectItem>
|
||||||
<SelectItem value="WINNER">Winner</SelectItem>
|
|
||||||
<SelectItem value="REJECTED">Rejected</SelectItem>
|
<SelectItem value="REJECTED">Rejected</SelectItem>
|
||||||
<SelectItem value="WITHDRAWN">Withdrawn</SelectItem>
|
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
<Select value={String(perPage)} onValueChange={(v) => { setPerPage(Number(v)); setPage(1) }}>
|
<Select value={String(perPage)} onValueChange={(v) => { setPerPage(Number(v)); setPage(1) }}>
|
||||||
|
|||||||
@@ -7,6 +7,10 @@ const STATUS_STYLES: Record<string, { variant: BadgeProps['variant']; className?
|
|||||||
ACTIVE: { variant: 'default', className: 'bg-blue-500/10 text-blue-700 border-blue-200 dark:text-blue-400' },
|
ACTIVE: { variant: 'default', className: 'bg-blue-500/10 text-blue-700 border-blue-200 dark:text-blue-400' },
|
||||||
EVALUATION: { variant: 'default', className: 'bg-violet-500/10 text-violet-700 border-violet-200 dark:text-violet-400' },
|
EVALUATION: { variant: 'default', className: 'bg-violet-500/10 text-violet-700 border-violet-200 dark:text-violet-400' },
|
||||||
CLOSED: { variant: 'secondary', className: 'bg-slate-500/10 text-slate-600 border-slate-200' },
|
CLOSED: { variant: 'secondary', className: 'bg-slate-500/10 text-slate-600 border-slate-200' },
|
||||||
|
ROUND_DRAFT: { variant: 'secondary' },
|
||||||
|
ROUND_ACTIVE: { variant: 'default', className: 'bg-blue-500/10 text-blue-700 border-blue-200 dark:text-blue-400' },
|
||||||
|
ROUND_CLOSED: { variant: 'secondary', className: 'bg-slate-500/10 text-slate-600 border-slate-200' },
|
||||||
|
ROUND_ARCHIVED: { variant: 'secondary', className: 'bg-slate-400/10 text-slate-400 border-slate-200' },
|
||||||
|
|
||||||
// Project statuses
|
// Project statuses
|
||||||
SUBMITTED: { variant: 'secondary', className: 'bg-indigo-500/10 text-indigo-700 border-indigo-200 dark:text-indigo-400' },
|
SUBMITTED: { variant: 'secondary', className: 'bg-indigo-500/10 text-indigo-700 border-indigo-200 dark:text-indigo-400' },
|
||||||
|
|||||||
@@ -754,14 +754,14 @@ export const analyticsRouter = router({
|
|||||||
|
|
||||||
const scores = evaluationScores.map((e) => e.globalScore!).filter((s) => s != null)
|
const scores = evaluationScores.map((e) => e.globalScore!).filter((s) => s != null)
|
||||||
const scoreDistribution = [
|
const scoreDistribution = [
|
||||||
{ label: '9-10', min: 9, max: 10 },
|
{ label: '9-10', min: 9, max: Infinity },
|
||||||
{ label: '7-8', min: 7, max: 8.99 },
|
{ label: '7-8', min: 7, max: 9 },
|
||||||
{ label: '5-6', min: 5, max: 6.99 },
|
{ label: '5-6', min: 5, max: 7 },
|
||||||
{ label: '3-4', min: 3, max: 4.99 },
|
{ label: '3-4', min: 3, max: 5 },
|
||||||
{ label: '1-2', min: 1, max: 2.99 },
|
{ label: '1-2', min: 1, max: 3 },
|
||||||
].map((b) => ({
|
].map((b) => ({
|
||||||
label: b.label,
|
label: b.label,
|
||||||
count: scores.filter((s) => s >= b.min && s <= b.max).length,
|
count: scores.filter((s) => s >= b.min && s < b.max).length,
|
||||||
}))
|
}))
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -770,7 +770,7 @@ export const analyticsRouter = router({
|
|||||||
projectCount,
|
projectCount,
|
||||||
jurorCount,
|
jurorCount,
|
||||||
submittedEvaluations,
|
submittedEvaluations,
|
||||||
totalEvaluations: totalAssignments,
|
totalAssignments,
|
||||||
completionRate,
|
completionRate,
|
||||||
scoreDistribution,
|
scoreDistribution,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -123,7 +123,7 @@ export const competitionRouter = router({
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Count distinct projects across all rounds (not sum of per-round states)
|
// Count distinct projects across all rounds (not sum of per-round states)
|
||||||
const roundIds = competition.rounds.map((r) => r.id)
|
const roundIds = competition.rounds.filter((r) => !r.specialAwardId).map((r) => r.id)
|
||||||
const distinctProjectCount = roundIds.length > 0
|
const distinctProjectCount = roundIds.length > 0
|
||||||
? await ctx.prisma.projectRoundState.findMany({
|
? await ctx.prisma.projectRoundState.findMany({
|
||||||
where: { roundId: { in: roundIds } },
|
where: { roundId: { in: roundIds } },
|
||||||
|
|||||||
@@ -146,7 +146,24 @@ export const deliberationRouter = router({
|
|||||||
aggregate: adminProcedure
|
aggregate: adminProcedure
|
||||||
.input(z.object({ sessionId: z.string() }))
|
.input(z.object({ sessionId: z.string() }))
|
||||||
.query(async ({ ctx, input }) => {
|
.query(async ({ ctx, input }) => {
|
||||||
return aggregateVotes(input.sessionId, ctx.prisma)
|
const result = await aggregateVotes(input.sessionId, ctx.prisma)
|
||||||
|
// Enrich rankings with project titles
|
||||||
|
const projectIds = result.rankings.map((r) => r.projectId)
|
||||||
|
const projects = projectIds.length > 0
|
||||||
|
? 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 {
|
||||||
|
...result,
|
||||||
|
rankings: result.rankings.map((r) => ({
|
||||||
|
...r,
|
||||||
|
projectTitle: projectMap.get(r.projectId)?.title ?? 'Unknown Project',
|
||||||
|
teamName: projectMap.get(r.projectId)?.teamName ?? '',
|
||||||
|
})),
|
||||||
|
}
|
||||||
}),
|
}),
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -89,19 +89,19 @@ export const projectRouter = router({
|
|||||||
// Filter by program
|
// Filter by program
|
||||||
if (programId) where.programId = programId
|
if (programId) where.programId = programId
|
||||||
|
|
||||||
// Filter by round (via RoundAssignment join)
|
// Filter by round (via ProjectRoundState)
|
||||||
if (roundId) {
|
if (roundId) {
|
||||||
where.roundAssignments = { some: { roundId } }
|
where.projectRoundStates = { some: { roundId } }
|
||||||
}
|
}
|
||||||
|
|
||||||
// Exclude projects already in a specific round
|
// Exclude projects already in a specific round
|
||||||
if (excludeInRoundId) {
|
if (excludeInRoundId) {
|
||||||
where.roundAssignments = { none: { roundId: excludeInRoundId } }
|
where.projectRoundStates = { none: { roundId: excludeInRoundId } }
|
||||||
}
|
}
|
||||||
|
|
||||||
// Filter by unassigned (not in any round)
|
// Filter by unassigned (not in any round)
|
||||||
if (unassignedOnly) {
|
if (unassignedOnly) {
|
||||||
where.roundAssignments = { none: {} }
|
where.projectRoundStates = { none: {} }
|
||||||
}
|
}
|
||||||
|
|
||||||
// Status filter
|
// Status filter
|
||||||
@@ -223,13 +223,13 @@ export const projectRouter = router({
|
|||||||
|
|
||||||
if (programId) where.programId = programId
|
if (programId) where.programId = programId
|
||||||
if (roundId) {
|
if (roundId) {
|
||||||
where.roundAssignments = { some: { roundId } }
|
where.projectRoundStates = { some: { roundId } }
|
||||||
}
|
}
|
||||||
if (excludeInRoundId) {
|
if (excludeInRoundId) {
|
||||||
where.roundAssignments = { none: { roundId: excludeInRoundId } }
|
where.projectRoundStates = { none: { roundId: excludeInRoundId } }
|
||||||
}
|
}
|
||||||
if (unassignedOnly) {
|
if (unassignedOnly) {
|
||||||
where.roundAssignments = { none: {} }
|
where.projectRoundStates = { none: {} }
|
||||||
}
|
}
|
||||||
if (statuses?.length) where.status = { in: statuses }
|
if (statuses?.length) where.status = { in: statuses }
|
||||||
if (tags && tags.length > 0) where.tags = { hasSome: tags }
|
if (tags && tags.length > 0) where.tags = { hasSome: tags }
|
||||||
@@ -1102,7 +1102,7 @@ export const projectRouter = router({
|
|||||||
|
|
||||||
const where: Record<string, unknown> = {
|
const where: Record<string, unknown> = {
|
||||||
programId,
|
programId,
|
||||||
roundAssignments: { none: {} }, // Projects not assigned to any round
|
projectRoundStates: { none: {} }, // Projects not assigned to any round
|
||||||
}
|
}
|
||||||
|
|
||||||
if (search) {
|
if (search) {
|
||||||
|
|||||||
@@ -102,12 +102,17 @@ export const specialAwardRouter = router({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Count eligible projects
|
// Count eligible projects and total assessed
|
||||||
const eligibleCount = await ctx.prisma.awardEligibility.count({
|
const [eligibleCount, totalAssessed] = await Promise.all([
|
||||||
|
ctx.prisma.awardEligibility.count({
|
||||||
where: { awardId: input.id, eligible: true },
|
where: { awardId: input.id, eligible: true },
|
||||||
})
|
}),
|
||||||
|
ctx.prisma.awardEligibility.count({
|
||||||
|
where: { awardId: input.id },
|
||||||
|
}),
|
||||||
|
])
|
||||||
|
|
||||||
return { ...award, competition, eligibleCount }
|
return { ...award, competition, eligibleCount, totalAssessed }
|
||||||
}),
|
}),
|
||||||
|
|
||||||
// ─── Admin Mutations ────────────────────────────────────────────────────
|
// ─── Admin Mutations ────────────────────────────────────────────────────
|
||||||
|
|||||||
Reference in New Issue
Block a user