From 0390d057270a154cacf08f071cbb1a8f410af9a1 Mon Sep 17 00:00:00 2001 From: Matt Date: Fri, 6 Mar 2026 15:50:51 +0100 Subject: [PATCH] fix: submission round completion %, document details, project teams UX MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fix 0% completion on SUBMISSION pipeline cards โ€” now based on teams with uploads / total projects instead of evaluation completions - Add page count, detected language, and non-English warning indicator to Recent Documents list items - Add project avatar (logo) to document and project list rows - Make document rows clickable into project detail page - Remove team name from project list (none have names), show country only - Rename "Teams Submitted" to "Teams with Uploads" for clarity - Add "See all" link to Projects section โ†’ /observer/projects - Rename section from "Project Teams" to "Projects" Co-Authored-By: Claude Opus 4.6 --- .../observer/dashboard/submission-panel.tsx | 103 +++++++++++------- src/components/observer/round-type-stats.tsx | 2 +- src/server/routers/analytics.ts | 42 ++++++- 3 files changed, 99 insertions(+), 48 deletions(-) diff --git a/src/components/observer/dashboard/submission-panel.tsx b/src/components/observer/dashboard/submission-panel.tsx index 47a7cfb..e644e03 100644 --- a/src/components/observer/dashboard/submission-panel.tsx +++ b/src/components/observer/dashboard/submission-panel.tsx @@ -8,7 +8,8 @@ import { Skeleton } from '@/components/ui/skeleton' import { Badge } from '@/components/ui/badge' import { AnimatedCard } from '@/components/shared/animated-container' import { CountryDisplay } from '@/components/shared/country-display' -import { FileText, Upload, Users } from 'lucide-react' +import { ProjectLogoWithUrl } from '@/components/shared/project-logo-with-url' +import { AlertTriangle, FileText, Upload, Users } from 'lucide-react' function relativeTime(date: Date | string): string { const now = Date.now() @@ -20,22 +21,6 @@ function relativeTime(date: Date | string): string { return `${Math.floor(diff / 86400)}d ago` } -const FILE_TYPE_ICONS: Record = { - pdf: '๐Ÿ“„', - image: '๐Ÿ–ผ๏ธ', - video: '๐ŸŽฅ', - default: '๐Ÿ“Ž', -} - -function fileIcon(fileType: string | null | undefined): string { - if (!fileType) return FILE_TYPE_ICONS.default - const ft = fileType.toLowerCase() - if (ft.includes('pdf')) return FILE_TYPE_ICONS.pdf - if (ft.includes('image') || ft.includes('png') || ft.includes('jpg') || ft.includes('jpeg')) return FILE_TYPE_ICONS.image - if (ft.includes('video') || ft.includes('mp4')) return FILE_TYPE_ICONS.video - return FILE_TYPE_ICONS.default -} - export function SubmissionPanel({ roundId, programId }: { roundId: string; programId: string }) { const { data: roundStats, isLoading: statsLoading } = trpc.analytics.getRoundTypeStats.useQuery( { roundId }, @@ -82,7 +67,7 @@ export function SubmissionPanel({ roundId, programId }: { roundId: string; progr

{stats.teamsSubmitted}

-

Teams Submitted

+

Teams with Uploads

) : null} @@ -100,25 +85,47 @@ export function SubmissionPanel({ roundId, programId }: { roundId: string; progr
{files.map((f: any) => ( -
- - {fileIcon(f.fileType)} - + + {/* Project avatar */} + +

{f.fileName}

-

- - {f.project?.title ?? 'Unknown project'} - -

+
+ {f.project?.title ?? 'Unknown project'} + {/* Page count */} + {f.pageCount != null && ( + <> + ยท + {f.pageCount} pg{f.pageCount !== 1 ? 's' : ''} + + )} + {/* Language badge */} + {f.detectedLang && f.detectedLang !== 'und' && ( + <> + ยท + + {f.detectedLang} + + {f.detectedLang !== 'eng' && ( + + )} + + )} +
{f.createdAt ? relativeTime(f.createdAt) : ''} -
+ ))}
@@ -131,10 +138,18 @@ export function SubmissionPanel({ roundId, programId }: { roundId: string; progr - - - Project Teams - +
+ + + Projects + + + See all + +
@@ -142,17 +157,21 @@ export function SubmissionPanel({ roundId, programId }: { roundId: string; progr +

{p.title}

-

- {p.teamName ?? 'No team'} ยท {p.country ? : ''} -

+ {p.country && ( +

+ +

+ )}
- - {p.country ? : 'โ€”'} - ))}
diff --git a/src/components/observer/round-type-stats.tsx b/src/components/observer/round-type-stats.tsx index 225d159..39362cc 100644 --- a/src/components/observer/round-type-stats.tsx +++ b/src/components/observer/round-type-stats.tsx @@ -105,7 +105,7 @@ export function RoundTypeStatsCards({ roundId }: RoundTypeStatsCardsProps) { case 'SUBMISSION': return [ { label: 'Total Files', value: (stats.totalFiles as number) ?? 0, icon: Upload, color: '#053d57' }, - { label: 'Teams Submitted', value: (stats.teamsSubmitted as number) ?? 0, icon: FileText, color: '#557f8c' }, + { label: 'Teams with Uploads', value: (stats.teamsSubmitted as number) ?? 0, icon: FileText, color: '#557f8c' }, ] case 'MENTORING': diff --git a/src/server/routers/analytics.ts b/src/server/routers/analytics.ts index 29a9ada..5f9ad9b 100644 --- a/src/server/routers/analytics.ts +++ b/src/server/routers/analytics.ts @@ -887,6 +887,7 @@ export const analyticsRouter = router({ allAssignmentCounts, allCompletedEvals, allDistinctJurors, + allSubmissionFileCounts, ] = await Promise.all([ ctx.prisma.projectRoundState.groupBy({ by: ['roundId', 'state'], @@ -910,6 +911,15 @@ export const analyticsRouter = router({ by: ['roundId', 'userId'], where: { roundId: { in: roundIds } }, }), + // Count distinct projects with files per round (for SUBMISSION completion) + ctx.prisma.$queryRaw<{ roundId: string; teamsWithFiles: bigint; totalFiles: bigint }[]>` + SELECT "roundId", + COUNT(DISTINCT "projectId")::bigint as "teamsWithFiles", + COUNT(id)::bigint as "totalFiles" + FROM "ProjectFile" + WHERE "roundId" = ANY(${roundIds}) + GROUP BY "roundId" + `, ]) // Build lookup maps @@ -935,16 +945,34 @@ export const analyticsRouter = router({ jurorCountByRound.set(j.roundId, (jurorCountByRound.get(j.roundId) || 0) + 1) } + const submissionFilesByRound = new Map() + for (const sf of allSubmissionFileCounts) { + submissionFilesByRound.set(sf.roundId, { + teamsWithFiles: Number(sf.teamsWithFiles), + totalFiles: Number(sf.totalFiles), + }) + } + const roundOverviews = rounds.map((round) => { const stateBreakdown = statesByRound.get(round.id) || [] const totalProjects = stateBreakdown.reduce((sum, ps) => sum + ps.count, 0) const totalAssignments = assignmentCountByRound.get(round.id) || 0 const completedEvaluations = completedEvalsByRound.get(round.id) || 0 - const completionRate = (round.status === 'ROUND_CLOSED' || round.status === 'ROUND_ARCHIVED') - ? 100 - : totalAssignments > 0 - ? Math.min(100, Math.round((completedEvaluations / totalAssignments) * 100)) + const submissionFiles = submissionFilesByRound.get(round.id) + + let completionRate: number + if (round.status === 'ROUND_CLOSED' || round.status === 'ROUND_ARCHIVED') { + completionRate = 100 + } else if (round.roundType === 'SUBMISSION') { + // For submission rounds, completion = teams that uploaded at least one file / total projects + completionRate = totalProjects > 0 && submissionFiles + ? Math.min(100, Math.round((submissionFiles.teamsWithFiles / totalProjects) * 100)) : 0 + } else if (totalAssignments > 0) { + completionRate = Math.min(100, Math.round((completedEvaluations / totalAssignments) * 100)) + } else { + completionRate = 0 + } return { roundId: round.id, @@ -2143,8 +2171,12 @@ export const analyticsRouter = router({ fileName: true, fileType: true, createdAt: true, + pageCount: true, + detectedLang: true, + langConfidence: true, + analyzedAt: true, project: { - select: { id: true, title: true, teamName: true }, + select: { id: true, title: true, teamName: true, logoKey: true }, }, }, })