fix: save roundId on admin file upload and group assignments by round
All checks were successful
Build and Push Docker Image / build (push) Successful in 7m45s

The admin upload flow accepted roundId but never wrote it to the
ProjectFile record, causing all admin-uploaded files to appear under
"General". Fixed the create call, the listByProject filter, and the
listByProjectForStage grouping to also use the direct roundId field.

Jury assignments on the project detail page are now grouped by round
with per-round completion counts instead of a flat list.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Matt
2026-04-12 23:20:48 -04:00
parent 2344f2e4ff
commit c7488b3e07
3 changed files with 182 additions and 142 deletions

View File

@@ -845,9 +845,10 @@ function ProjectDetailContent({ projectId }: { projectId: string }) {
} : null,
}))
for (const f of files) {
const roundId = f.requirement?.roundId ?? null
const roundName = f.requirement?.round?.name ?? 'General'
const sortOrder = f.requirement?.round?.sortOrder ?? -1
const roundId = f.requirement?.roundId ?? f.roundId ?? null
const matchedRound = roundId ? competitionRounds.find((r: any) => r.id === roundId) : null
const roundName = f.requirement?.round?.name ?? matchedRound?.name ?? 'General'
const sortOrder = f.requirement?.round?.sortOrder ?? matchedRound?.sortOrder ?? -1
const key = roundId ?? '_general'
if (!groups.has(key)) {
groups.set(key, { roundId, roundName, sortOrder, files: [] })
@@ -864,141 +865,172 @@ function ProjectDetailContent({ projectId }: { projectId: string }) {
</Card>
</AnimatedCard>
{/* Assignments Section */}
{assignments && assignments.length > 0 && (
<AnimatedCard index={5}>
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<div>
<CardTitle className="flex items-center gap-2.5 text-lg">
<div className="rounded-lg bg-violet-500/10 p-1.5">
<Users className="h-4 w-4 text-violet-500" />
</div>
Jury Assignments
</CardTitle>
<CardDescription>
{assignments.filter((a) => a.evaluation?.status === 'SUBMITTED')
.length}{' '}
of {assignments.length} evaluations completed
</CardDescription>
{/* Assignments Section — grouped by round */}
{assignments && assignments.length > 0 && (() => {
// Group assignments by round
const roundGroups = new Map<string, {
roundId: string
roundName: string
assignments: typeof assignments
}>()
for (const a of assignments) {
const rId = a.round?.id ?? '_unknown'
const rName = a.round?.name ?? 'Unknown Round'
if (!roundGroups.has(rId)) {
roundGroups.set(rId, { roundId: rId, roundName: rName, assignments: [] })
}
roundGroups.get(rId)!.assignments.push(a)
}
const groups = Array.from(roundGroups.values())
return (
<AnimatedCard index={5}>
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<div>
<CardTitle className="flex items-center gap-2.5 text-lg">
<div className="rounded-lg bg-violet-500/10 p-1.5">
<Users className="h-4 w-4 text-violet-500" />
</div>
Jury Assignments
</CardTitle>
<CardDescription>
{assignments.filter((a) => a.evaluation?.status === 'SUBMITTED')
.length}{' '}
of {assignments.length} evaluations completed across {groups.length} round{groups.length !== 1 ? 's' : ''}
</CardDescription>
</div>
<Button variant="outline" size="sm" asChild>
<Link href={`/admin/members`}>
Manage
</Link>
</Button>
</div>
<Button variant="outline" size="sm" asChild>
<Link href={`/admin/members`}>
Manage
</Link>
</Button>
</div>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead>Juror</TableHead>
<TableHead>Expertise</TableHead>
<TableHead>Status</TableHead>
<TableHead>Score</TableHead>
<TableHead>Decision</TableHead>
<TableHead className="w-10"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{assignments.map((assignment) => (
<TableRow
key={assignment.id}
className={assignment.evaluation?.status === 'SUBMITTED' ? 'cursor-pointer hover:bg-muted/50' : ''}
onClick={() => {
if (assignment.evaluation?.status === 'SUBMITTED') {
setSelectedEvalAssignment(assignment)
}
}}
>
<TableCell>
<div className="flex items-center gap-2">
<UserAvatar
user={assignment.user}
avatarUrl={assignment.user.avatarUrl}
size="sm"
/>
<div>
<p className="font-medium text-sm">
{assignment.user.name || 'Unnamed'}
</p>
<p className="text-xs text-muted-foreground">
{assignment.user.email}
</p>
</div>
</div>
</TableCell>
<TableCell>
<div className="flex flex-wrap gap-1">
{assignment.user.expertiseTags?.slice(0, 2).map((tag) => (
<Badge key={tag} variant="outline" className="text-xs">
{tag}
</Badge>
</CardHeader>
<CardContent className="space-y-6">
{groups.map((group) => {
const submitted = group.assignments.filter((a) => a.evaluation?.status === 'SUBMITTED').length
return (
<div key={group.roundId}>
<div className="flex items-center justify-between mb-2">
<h4 className="text-sm font-semibold">{group.roundName}</h4>
<span className="text-xs text-muted-foreground">
{submitted} of {group.assignments.length} completed
</span>
</div>
<Table>
<TableHeader>
<TableRow>
<TableHead>Juror</TableHead>
<TableHead>Expertise</TableHead>
<TableHead>Status</TableHead>
<TableHead>Score</TableHead>
<TableHead>Decision</TableHead>
<TableHead className="w-10"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{group.assignments.map((assignment) => (
<TableRow
key={assignment.id}
className={assignment.evaluation?.status === 'SUBMITTED' ? 'cursor-pointer hover:bg-muted/50' : ''}
onClick={() => {
if (assignment.evaluation?.status === 'SUBMITTED') {
setSelectedEvalAssignment(assignment)
}
}}
>
<TableCell>
<div className="flex items-center gap-2">
<UserAvatar
user={assignment.user}
avatarUrl={assignment.user.avatarUrl}
size="sm"
/>
<div>
<p className="font-medium text-sm">
{assignment.user.name || 'Unnamed'}
</p>
<p className="text-xs text-muted-foreground">
{assignment.user.email}
</p>
</div>
</div>
</TableCell>
<TableCell>
<div className="flex flex-wrap gap-1">
{assignment.user.expertiseTags?.slice(0, 2).map((tag) => (
<Badge key={tag} variant="outline" className="text-xs">
{tag}
</Badge>
))}
{(assignment.user.expertiseTags?.length || 0) > 2 && (
<Badge variant="outline" className="text-xs">
+{(assignment.user.expertiseTags?.length || 0) - 2}
</Badge>
)}
</div>
</TableCell>
<TableCell>
<Badge
variant={
evalStatusColors[
assignment.evaluation?.status || 'NOT_STARTED'
] || 'secondary'
}
>
{(assignment.evaluation?.status || 'NOT_STARTED').replace(
'_',
' '
)}
</Badge>
</TableCell>
<TableCell>
{assignment.evaluation?.globalScore !== null &&
assignment.evaluation?.globalScore !== undefined ? (
<span className="font-medium">
{assignment.evaluation.globalScore}/10
</span>
) : (
<span className="text-muted-foreground">-</span>
)}
</TableCell>
<TableCell>
{assignment.evaluation?.binaryDecision !== null &&
assignment.evaluation?.binaryDecision !== undefined ? (
assignment.evaluation.binaryDecision ? (
<div className="flex items-center gap-1 text-green-600">
<ThumbsUp className="h-4 w-4" />
<span className="text-sm">Yes</span>
</div>
) : (
<div className="flex items-center gap-1 text-red-600">
<ThumbsDown className="h-4 w-4" />
<span className="text-sm">No</span>
</div>
)
) : (
<span className="text-muted-foreground">-</span>
)}
</TableCell>
<TableCell>
{assignment.evaluation?.status === 'SUBMITTED' && (
<Eye className="h-4 w-4 text-muted-foreground" />
)}
</TableCell>
</TableRow>
))}
{(assignment.user.expertiseTags?.length || 0) > 2 && (
<Badge variant="outline" className="text-xs">
+{(assignment.user.expertiseTags?.length || 0) - 2}
</Badge>
)}
</div>
</TableCell>
<TableCell>
<Badge
variant={
evalStatusColors[
assignment.evaluation?.status || 'NOT_STARTED'
] || 'secondary'
}
>
{(assignment.evaluation?.status || 'NOT_STARTED').replace(
'_',
' '
)}
</Badge>
</TableCell>
<TableCell>
{assignment.evaluation?.globalScore !== null &&
assignment.evaluation?.globalScore !== undefined ? (
<span className="font-medium">
{assignment.evaluation.globalScore}/10
</span>
) : (
<span className="text-muted-foreground">-</span>
)}
</TableCell>
<TableCell>
{assignment.evaluation?.binaryDecision !== null &&
assignment.evaluation?.binaryDecision !== undefined ? (
assignment.evaluation.binaryDecision ? (
<div className="flex items-center gap-1 text-green-600">
<ThumbsUp className="h-4 w-4" />
<span className="text-sm">Yes</span>
</div>
) : (
<div className="flex items-center gap-1 text-red-600">
<ThumbsDown className="h-4 w-4" />
<span className="text-sm">No</span>
</div>
)
) : (
<span className="text-muted-foreground">-</span>
)}
</TableCell>
<TableCell>
{assignment.evaluation?.status === 'SUBMITTED' && (
<Eye className="h-4 w-4 text-muted-foreground" />
)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
</Card>
</AnimatedCard>
)}
</TableBody>
</Table>
</div>
)
})}
</CardContent>
</Card>
</AnimatedCard>
)
})()}
{/* Evaluation Detail Sheet */}
<EvaluationEditSheet

View File

@@ -54,8 +54,8 @@ export function MultiWindowDocViewer({ roundId, projectId }: MultiWindowDocViewe
}> = {}
for (const file of files) {
const roundName = file.requirement?.round?.name ?? 'General'
const rId = file.requirement?.round?.id ?? null
const rId = file.requirement?.round?.id ?? (file as any).roundId ?? null
const roundName = file.requirement?.round?.name ?? (rId ? 'Round Files' : 'General')
const sortOrder = file.requirement?.round?.sortOrder ?? 999
if (!groupMap[roundName]) {
groupMap[roundName] = { roundId: rId, roundName, sortOrder, files: [] }

View File

@@ -206,6 +206,7 @@ export const fileRouter = router({
const file = await ctx.prisma.projectFile.create({
data: {
projectId: input.projectId,
roundId: input.roundId ?? null,
fileType: input.fileType,
fileName: input.fileName,
mimeType: input.mimeType,
@@ -341,7 +342,10 @@ export const fileRouter = router({
const where: Record<string, unknown> = { projectId: input.projectId }
if (input.roundId) {
where.requirement = { roundId: input.roundId }
where.OR = [
{ requirement: { roundId: input.roundId } },
{ roundId: input.roundId, requirementId: null },
]
}
return ctx.prisma.projectFile.findMany({
@@ -429,7 +433,8 @@ export const fileRouter = router({
projectId: input.projectId,
OR: [
{ requirement: { roundId: { in: eligibleRoundIds } } },
{ requirementId: null },
{ roundId: { in: eligibleRoundIds }, requirementId: null },
{ roundId: null, requirementId: null },
],
},
include: {
@@ -454,7 +459,8 @@ export const fileRouter = router({
files: typeof files
}> = []
const generalFiles = files.filter((f) => !f.requirementId)
// Files with no round association at all go to General
const generalFiles = files.filter((f) => !f.requirementId && !f.roundId)
if (generalFiles.length > 0) {
grouped.push({
roundId: null,
@@ -465,7 +471,9 @@ export const fileRouter = router({
}
for (const round of eligibleRounds) {
const roundFiles = files.filter((f) => f.requirement?.roundId === round.id)
const roundFiles = files.filter(
(f) => f.requirement?.roundId === round.id || (!f.requirementId && f.roundId === round.id)
)
if (roundFiles.length > 0) {
grouped.push({
roundId: round.id,