Observer dashboard extraction, PDF reports, jury UX overhaul, and miscellaneous improvements

- Extract observer dashboard to client component, add PDF export button
- Add PDF report generator with jsPDF for analytics reports
- Overhaul jury evaluation page with improved layout and UX
- Add new analytics endpoints for observer/admin reports
- Improve round creation/edit forms with better settings
- Fix filtering rules page, CSV export dialog, notification bell
- Update auth, prisma schema, and various type fixes

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-10 23:08:00 +01:00
parent 5c8d22ac11
commit d787a24921
31 changed files with 2565 additions and 930 deletions

View File

@@ -634,4 +634,157 @@ export const analyticsRouter = router({
return stats
}),
/**
* Get dashboard stats (optionally scoped to a round)
*/
getDashboardStats: observerProcedure
.input(z.object({ roundId: z.string().optional() }).optional())
.query(async ({ ctx, input }) => {
const roundId = input?.roundId
const roundWhere = roundId ? { roundId } : {}
const assignmentWhere = roundId ? { roundId } : {}
const evalWhere = roundId
? { assignment: { roundId }, status: 'SUBMITTED' as const }
: { status: 'SUBMITTED' as const }
const [
programCount,
activeRoundCount,
projectCount,
jurorCount,
submittedEvaluations,
totalAssignments,
evaluationScores,
] = await Promise.all([
ctx.prisma.program.count(),
ctx.prisma.round.count({ where: { status: 'ACTIVE' } }),
ctx.prisma.project.count({ where: roundWhere }),
ctx.prisma.user.count({ where: { role: 'JURY_MEMBER', status: 'ACTIVE' } }),
ctx.prisma.evaluation.count({ where: evalWhere }),
ctx.prisma.assignment.count({ where: assignmentWhere }),
ctx.prisma.evaluation.findMany({
where: { ...evalWhere, globalScore: { not: null } },
select: { globalScore: true },
}),
])
const completionRate = totalAssignments > 0
? Math.round((submittedEvaluations / totalAssignments) * 100)
: 0
const scores = evaluationScores.map((e) => e.globalScore!).filter((s) => s != null)
const scoreDistribution = [
{ label: '9-10', min: 9, max: 10 },
{ label: '7-8', min: 7, max: 8.99 },
{ label: '5-6', min: 5, max: 6.99 },
{ label: '3-4', min: 3, max: 4.99 },
{ label: '1-2', min: 1, max: 2.99 },
].map((b) => ({
label: b.label,
count: scores.filter((s) => s >= b.min && s <= b.max).length,
}))
return {
programCount,
activeRoundCount,
projectCount,
jurorCount,
submittedEvaluations,
totalEvaluations: totalAssignments,
completionRate,
scoreDistribution,
}
}),
/**
* Get all projects with pagination, filtering, and search (for observer dashboard)
*/
getAllProjects: observerProcedure
.input(
z.object({
roundId: z.string().optional(),
search: z.string().optional(),
status: z.string().optional(),
page: z.number().min(1).default(1),
perPage: z.number().min(1).max(100).default(20),
})
)
.query(async ({ ctx, input }) => {
const where: Record<string, unknown> = {}
if (input.roundId) {
where.roundId = input.roundId
}
if (input.status) {
where.status = input.status
}
if (input.search) {
where.OR = [
{ title: { contains: input.search, mode: 'insensitive' } },
{ teamName: { contains: input.search, mode: 'insensitive' } },
]
}
const [projects, total] = await Promise.all([
ctx.prisma.project.findMany({
where,
select: {
id: true,
title: true,
teamName: true,
status: true,
country: true,
round: { select: { id: true, name: true } },
assignments: {
select: {
evaluation: {
select: { globalScore: true, status: true },
},
},
},
},
orderBy: { title: 'asc' },
skip: (input.page - 1) * input.perPage,
take: input.perPage,
}),
ctx.prisma.project.count({ where }),
])
const mapped = projects.map((p) => {
const submitted = p.assignments
.map((a) => a.evaluation)
.filter((e) => e?.status === 'SUBMITTED')
const scores = submitted
.map((e) => e?.globalScore)
.filter((s): s is number => s !== null)
const averageScore =
scores.length > 0
? scores.reduce((a, b) => a + b, 0) / scores.length
: null
return {
id: p.id,
title: p.title,
teamName: p.teamName,
status: p.status,
country: p.country,
roundId: p.round?.id ?? '',
roundName: p.round?.name ?? '',
averageScore,
evaluationCount: submitted.length,
}
})
return {
projects: mapped,
total,
page: input.page,
perPage: input.perPage,
totalPages: Math.ceil(total / input.perPage),
}
}),
})