Comprehensive admin UI stats audit: fix 16 display bugs

HIGH fixes:
- H1: Competition detail project count no longer double-counts across rounds
- H2: Rounds page header stats use unfiltered round set
- H3: Rounds page "eval" label corrected to "asgn" (assignment count)
- H4: Observer reports project count uses distinct analytics count
- H5: Awards eligibility count filters to only eligible=true (backend)
- H6: Round detail projectCount derived from projectStates for consistency
- H7: Deliberation hasVoted derived from votes array (was always undefined)

MEDIUM fixes:
- M1: Reports page round status badges use correct ROUND_ACTIVE/ROUND_CLOSED enums
- M2: Observer reports badges use ROUND_ prefix instead of stale STAGE_ prefix
- M3: Deliberation list status badges use correct VOTING/TALLYING/RUNOFF enums
- M4: Competition list/detail round count excludes special-award rounds (backend)
- M5: Messages page shows actual recipient count instead of hardcoded "1 user"

LOW fixes:
- L2: Observer analytics jurorCount scoped to round when roundId provided
- L3: Analytics round-scoped project count uses ProjectRoundState not assignments
- L4: JuryGroup delete audit log reports member count (not assignment count)
- L5: Project rankings include unevaluated projects at bottom instead of hiding

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Matt
2026-02-19 09:56:09 +01:00
parent d117090fca
commit ae1685179c
12 changed files with 68 additions and 37 deletions

View File

@@ -1,6 +1,6 @@
'use client'; 'use client';
import { use } from 'react'; import { use, useMemo } from 'react';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import { trpc } from '@/lib/trpc/client'; import { trpc } from '@/lib/trpc/client';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
@@ -46,6 +46,12 @@ export default function DeliberationSessionPage({
} }
}); });
// Derive which participants have voted from the votes array
const voterUserIds = useMemo(() => {
if (!session?.votes) return new Set<string>();
return new Set(session.votes.map((v: any) => v.juryMember?.user?.id).filter(Boolean));
}, [session?.votes]);
if (isLoading) { if (isLoading) {
return ( return (
<div className="space-y-6"> <div className="space-y-6">
@@ -153,8 +159,8 @@ export default function DeliberationSessionPage({
<p className="font-medium">{participant.user?.name}</p> <p className="font-medium">{participant.user?.name}</p>
<p className="text-sm text-muted-foreground">{participant.user?.email}</p> <p className="text-sm text-muted-foreground">{participant.user?.email}</p>
</div> </div>
<Badge variant={participant.hasVoted ? 'default' : 'outline'}> <Badge variant={voterUserIds.has(participant.user?.user?.id) ? 'default' : 'outline'}>
{participant.hasVoted ? 'Voted' : 'Pending'} {voterUserIds.has(participant.user?.user?.id) ? 'Voted' : 'Pending'}
</Badge> </Badge>
</div> </div>
))} ))}
@@ -206,8 +212,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"
> >
<span>{participant.user?.name}</span> <span>{participant.user?.name}</span>
<Badge variant={participant.hasVoted ? 'default' : 'secondary'}> <Badge variant={voterUserIds.has(participant.user?.user?.id) ? 'default' : 'secondary'}>
{participant.hasVoted ? 'Submitted' : 'Not Voted'} {voterUserIds.has(participant.user?.user?.id) ? 'Submitted' : 'Not Voted'}
</Badge> </Badge>
</div> </div>
))} ))}

View File

@@ -106,11 +106,19 @@ export default function DeliberationListPage({
const getStatusBadge = (status: string) => { const getStatusBadge = (status: string) => {
const variants: Record<string, 'default' | 'secondary' | 'destructive' | 'outline'> = { const variants: Record<string, 'default' | 'secondary' | 'destructive' | 'outline'> = {
DELIB_OPEN: 'outline', DELIB_OPEN: 'outline',
DELIB_VOTING: 'default', VOTING: 'default',
DELIB_TALLYING: 'secondary', TALLYING: 'secondary',
DELIB_LOCKED: 'destructive' RUNOFF: 'secondary',
DELIB_LOCKED: 'destructive',
}; };
return <Badge variant={variants[status] || 'outline'}>{status}</Badge>; const labels: Record<string, string> = {
DELIB_OPEN: 'Open',
VOTING: 'Voting',
TALLYING: 'Tallying',
RUNOFF: 'Runoff',
DELIB_LOCKED: 'Locked',
};
return <Badge variant={variants[status] || 'outline'}>{labels[status] || status}</Badge>;
}; };
if (isLoading) { if (isLoading) {

View File

@@ -285,7 +285,7 @@ export default function CompetitionDetailPage() {
<Layers className="h-4 w-4 text-blue-500" /> <Layers className="h-4 w-4 text-blue-500" />
<span className="text-sm font-medium">Rounds</span> <span className="text-sm font-medium">Rounds</span>
</div> </div>
<p className="text-2xl font-bold mt-1">{competition.rounds.length}</p> <p className="text-2xl font-bold mt-1">{competition.rounds.filter((r: any) => !r.specialAwardId).length}</p>
</CardContent> </CardContent>
</Card> </Card>
<Card> <Card>
@@ -304,7 +304,7 @@ export default function CompetitionDetailPage() {
<span className="text-sm font-medium">Projects</span> <span className="text-sm font-medium">Projects</span>
</div> </div>
<p className="text-2xl font-bold mt-1"> <p className="text-2xl font-bold mt-1">
{competition.rounds.reduce((sum: number, r: any) => sum + (r._count?.projectRoundStates ?? 0), 0)} {(competition as any).distinctProjectCount ?? 0}
</p> </p>
</CardContent> </CardContent>
</Card> </Card>

View File

@@ -592,7 +592,7 @@ export default function MessagesPage() {
: msg.recipientType === 'ROUND_JURY' : msg.recipientType === 'ROUND_JURY'
? 'Round jury' ? 'Round jury'
: msg.recipientType === 'USER' : msg.recipientType === 'USER'
? '1 user' ? `${recipientCount || 1} user${recipientCount > 1 ? 's' : ''}`
: msg.recipientType} : msg.recipientType}
{recipientCount > 0 && ` (${recipientCount})`} {recipientCount > 0 && ` (${recipientCount})`}
</TableCell> </TableCell>

View File

@@ -354,14 +354,14 @@ function ReportsOverview() {
<TableCell> <TableCell>
<Badge <Badge
variant={ variant={
round.status === 'ACTIVE' round.status === 'ROUND_ACTIVE'
? 'default' ? 'default'
: round.status === 'CLOSED' : round.status === 'ROUND_CLOSED'
? 'secondary' ? 'secondary'
: 'outline' : 'outline'
} }
> >
{round.status} {round.status?.replace('ROUND_', '') || round.status}
</Badge> </Badge>
</TableCell> </TableCell>
<TableCell className="text-right"> <TableCell className="text-right">

View File

@@ -441,7 +441,7 @@ export default function RoundDetailPage() {
}, [configJson]) }, [configJson])
// ── Computed values ──────────────────────────────────────────────────── // ── Computed values ────────────────────────────────────────────────────
const projectCount = round?._count?.projectRoundStates ?? 0 const projectCount = projectStates?.length ?? round?._count?.projectRoundStates ?? 0
const stateCounts = useMemo(() => const stateCounts = useMemo(() =>
projectStates?.reduce((acc: Record<string, number>, ps: any) => { projectStates?.reduce((acc: Record<string, number>, ps: any) => {
acc[ps.state] = (acc[ps.state] || 0) + 1 acc[ps.state] = (acc[ps.state] || 0) + 1

View File

@@ -284,7 +284,8 @@ export default function RoundsPage() {
const activeFilter = filterType !== 'all' const activeFilter = filterType !== 'all'
const totalProjects = (compDetail as any)?.distinctProjectCount ?? 0 const totalProjects = (compDetail as any)?.distinctProjectCount ?? 0
const totalAssignments = rounds.reduce((s, r) => s + r._count.assignments, 0) const allRounds = (compDetail?.rounds ?? []) as RoundWithStats[]
const totalAssignments = allRounds.reduce((s, r) => s + r._count.assignments, 0)
const activeRound = rounds.find((r) => r.status === 'ROUND_ACTIVE') const activeRound = rounds.find((r) => r.status === 'ROUND_ACTIVE')
return ( return (
@@ -326,7 +327,7 @@ export default function RoundsPage() {
</Tooltip> </Tooltip>
</div> </div>
<div className="flex items-center gap-4 mt-1 text-sm text-muted-foreground"> <div className="flex items-center gap-4 mt-1 text-sm text-muted-foreground">
<span>{rounds.length} rounds</span> <span>{allRounds.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>
@@ -493,7 +494,7 @@ export default function RoundsPage() {
{projectCount} {projectCount}
</span> </span>
{assignmentCount > 0 && ( {assignmentCount > 0 && (
<span className="tabular-nums">{assignmentCount} eval</span> <span className="tabular-nums">{assignmentCount} asgn</span>
)} )}
{(round.windowOpenAt || round.windowCloseAt) && ( {(round.windowOpenAt || round.windowCloseAt) && (
<span className="flex items-center gap-1 tabular-nums"> <span className="flex items-center gap-1 tabular-nums">

View File

@@ -100,7 +100,8 @@ function OverviewTab({ selectedValue }: { selectedValue: string | null }) {
) )
} }
const totalProjects = stages.reduce((acc, s) => acc + (s._count?.projects || 0), 0) // Count distinct projects by collecting unique IDs, not summing per-round states
const totalProjects = overviewStats?.projectCount ?? stages.reduce((acc, s) => acc + (s._count?.projects || 0), 0)
const activeStages = stages.filter((s) => s.status === 'ROUND_ACTIVE').length const activeStages = stages.filter((s) => s.status === 'ROUND_ACTIVE').length
const totalPrograms = programs?.length || 0 const totalPrograms = programs?.length || 0
@@ -285,14 +286,14 @@ function OverviewTab({ selectedValue }: { selectedValue: string | null }) {
<TableCell> <TableCell>
<Badge <Badge
variant={ variant={
stage.status === 'STAGE_ACTIVE' stage.status === 'ROUND_ACTIVE'
? 'default' ? 'default'
: stage.status === 'STAGE_CLOSED' : stage.status === 'ROUND_CLOSED'
? 'secondary' ? 'secondary'
: 'outline' : 'outline'
} }
> >
{stage.status === 'STAGE_ACTIVE' ? 'Active' : stage.status === 'STAGE_CLOSED' ? 'Closed' : stage.status} {stage.status === 'ROUND_ACTIVE' ? 'Active' : stage.status === 'ROUND_CLOSED' ? 'Closed' : stage.status}
</Badge> </Badge>
</TableCell> </TableCell>
</TableRow> </TableRow>
@@ -312,14 +313,14 @@ function OverviewTab({ selectedValue }: { selectedValue: string | null }) {
<p className="font-medium">{stage.name}</p> <p className="font-medium">{stage.name}</p>
<Badge <Badge
variant={ variant={
stage.status === 'STAGE_ACTIVE' stage.status === 'ROUND_ACTIVE'
? 'default' ? 'default'
: stage.status === 'STAGE_CLOSED' : stage.status === 'ROUND_CLOSED'
? 'secondary' ? 'secondary'
: 'outline' : 'outline'
} }
> >
{stage.status === 'STAGE_ACTIVE' ? 'Active' : stage.status === 'STAGE_CLOSED' ? 'Closed' : stage.status} {stage.status === 'ROUND_ACTIVE' ? 'Active' : stage.status === 'ROUND_CLOSED' ? 'Closed' : stage.status}
</Badge> </Badge>
</div> </div>
<p className="text-sm text-muted-foreground">{stage.programName}</p> <p className="text-sm text-muted-foreground">{stage.programName}</p>

View File

@@ -10,7 +10,7 @@ const editionOrRoundInput = z.object({
}) })
function projectWhere(input: { roundId?: string; programId?: string }) { function projectWhere(input: { roundId?: string; programId?: string }) {
if (input.roundId) return { assignments: { some: { roundId: input.roundId } } } if (input.roundId) return { projectRoundStates: { some: { roundId: input.roundId } } }
return { programId: input.programId! } return { programId: input.programId! }
} }
@@ -223,8 +223,13 @@ export const analyticsRouter = router({
evaluationCount: allScores.length, evaluationCount: allScores.length,
} }
}) })
.filter((p) => p.averageScore !== null) .sort((a, b) => {
.sort((a, b) => (b.averageScore || 0) - (a.averageScore || 0)) // Evaluated projects first (sorted by score desc), unevaluated at bottom
if (a.averageScore !== null && b.averageScore !== null) return b.averageScore - a.averageScore
if (a.averageScore !== null) return -1
if (b.averageScore !== null) return 1
return 0
})
return input.limit ? rankings.slice(0, input.limit) : rankings return input.limit ? rankings.slice(0, input.limit) : rankings
}), }),
@@ -709,7 +714,7 @@ export const analyticsRouter = router({
const roundId = input?.roundId const roundId = input?.roundId
const projectFilter = roundId const projectFilter = roundId
? { assignments: { some: { roundId } } } ? { projectRoundStates: { some: { roundId } } }
: {} : {}
const assignmentFilter = roundId ? { roundId } : {} const assignmentFilter = roundId ? { roundId } : {}
const evalFilter = roundId const evalFilter = roundId
@@ -728,7 +733,13 @@ export const analyticsRouter = router({
ctx.prisma.program.count(), ctx.prisma.program.count(),
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 }),
ctx.prisma.user.count({ where: { role: 'JURY_MEMBER', status: 'ACTIVE' } }), roundId
? ctx.prisma.assignment.findMany({
where: { roundId },
select: { userId: true },
distinct: ['userId'],
}).then((rows) => rows.length)
: ctx.prisma.user.count({ where: { 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({

View File

@@ -146,7 +146,11 @@ export const competitionRouter = router({
orderBy: { createdAt: 'desc' }, orderBy: { createdAt: 'desc' },
include: { include: {
_count: { _count: {
select: { rounds: true, juryGroups: true, submissionWindows: true }, select: {
rounds: { where: { specialAwardId: null } },
juryGroups: true,
submissionWindows: true,
},
}, },
}, },
}) })
@@ -256,7 +260,7 @@ export const competitionRouter = router({
orderBy: { sortOrder: 'asc' }, orderBy: { sortOrder: 'asc' },
select: { id: true, name: true, roundType: true, status: true }, select: { id: true, name: true, roundType: true, status: true },
}, },
_count: { select: { rounds: true, juryGroups: true } }, _count: { select: { rounds: { where: { specialAwardId: null } }, juryGroups: true } },
}, },
orderBy: { createdAt: 'desc' }, orderBy: { createdAt: 'desc' },
}) })

View File

@@ -258,7 +258,7 @@ export const juryGroupRouter = router({
const group = await ctx.prisma.juryGroup.findUniqueOrThrow({ const group = await ctx.prisma.juryGroup.findUniqueOrThrow({
where: { id: input.id }, where: { id: input.id },
include: { include: {
_count: { select: { assignments: true, rounds: true } }, _count: { select: { members: true, assignments: true, rounds: true } },
}, },
}) })
@@ -284,7 +284,7 @@ export const juryGroupRouter = router({
detailsJson: { detailsJson: {
name: group.name, name: group.name,
competitionId: group.competitionId, competitionId: group.competitionId,
memberCount: group._count.assignments, memberCount: group._count.members,
}, },
ipAddress: ctx.ip, ipAddress: ctx.ip,
userAgent: ctx.userAgent, userAgent: ctx.userAgent,

View File

@@ -43,7 +43,7 @@ export const specialAwardRouter = router({
include: { include: {
_count: { _count: {
select: { select: {
eligibilities: true, eligibilities: { where: { eligible: true } },
jurors: true, jurors: true,
votes: true, votes: true,
}, },
@@ -66,7 +66,7 @@ export const specialAwardRouter = router({
include: { include: {
_count: { _count: {
select: { select: {
eligibilities: true, eligibilities: { where: { eligible: true } },
jurors: true, jurors: true,
votes: true, votes: true,
}, },