fix: submission round completion %, document details, project teams UX
All checks were successful
Build and Push Docker Image / build (push) Successful in 7m32s

- 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 <noreply@anthropic.com>
This commit is contained in:
2026-03-06 15:50:51 +01:00
parent ec30dc83d6
commit 0390d05727
3 changed files with 99 additions and 48 deletions

View File

@@ -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<string, string> = {
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
<Users className="h-4 w-4 text-blue-500" />
<p className="text-2xl font-semibold tabular-nums">{stats.teamsSubmitted}</p>
</div>
<p className="text-xs text-muted-foreground mt-0.5">Teams Submitted</p>
<p className="text-xs text-muted-foreground mt-0.5">Teams with Uploads</p>
</Card>
</div>
) : null}
@@ -100,25 +85,47 @@ export function SubmissionPanel({ roundId, programId }: { roundId: string; progr
<CardContent className="p-0">
<div className="divide-y">
{files.map((f: any) => (
<div key={f.id} className="flex items-center gap-3 px-4 py-2.5">
<span className="text-lg shrink-0">
{fileIcon(f.fileType)}
</span>
<Link
key={f.id}
href={`/observer/projects/${f.project?.id}` as Route}
className="flex items-center gap-3 px-4 py-2.5 hover:bg-muted/50 transition-colors"
>
{/* Project avatar */}
<ProjectLogoWithUrl
project={{ id: f.project?.id ?? '', title: f.project?.title ?? '', logoKey: f.project?.logoKey }}
size="sm"
fallback="initials"
/>
<div className="min-w-0 flex-1">
<p className="text-sm font-medium truncate">{f.fileName}</p>
<p className="text-xs text-muted-foreground truncate">
<Link
href={`/observer/projects/${f.project?.id}` as Route}
className="hover:underline"
>
{f.project?.title ?? 'Unknown project'}
</Link>
</p>
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
<span className="truncate">{f.project?.title ?? 'Unknown project'}</span>
{/* Page count */}
{f.pageCount != null && (
<>
<span>·</span>
<span className="shrink-0">{f.pageCount} pg{f.pageCount !== 1 ? 's' : ''}</span>
</>
)}
{/* Language badge */}
{f.detectedLang && f.detectedLang !== 'und' && (
<>
<span>·</span>
<span className={`shrink-0 font-mono uppercase ${f.detectedLang !== 'eng' ? 'text-amber-600 font-semibold' : ''}`}>
{f.detectedLang}
</span>
{f.detectedLang !== 'eng' && (
<AlertTriangle className="h-3 w-3 text-amber-500 shrink-0" />
)}
</>
)}
</div>
</div>
<span className="text-[11px] tabular-nums text-muted-foreground shrink-0">
{f.createdAt ? relativeTime(f.createdAt) : ''}
</span>
</div>
</Link>
))}
</div>
</CardContent>
@@ -131,10 +138,18 @@ export function SubmissionPanel({ roundId, programId }: { roundId: string; progr
<AnimatedCard index={2}>
<Card>
<CardHeader className="pb-2">
<div className="flex items-center justify-between">
<CardTitle className="flex items-center gap-2 text-sm">
<Users className="h-4 w-4 text-emerald-500" />
Project Teams
Projects
</CardTitle>
<Link
href={'/observer/projects' as Route}
className="text-xs text-primary hover:underline"
>
See all
</Link>
</div>
</CardHeader>
<CardContent className="p-0">
<div className="divide-y">
@@ -142,17 +157,21 @@ export function SubmissionPanel({ roundId, programId }: { roundId: string; progr
<Link
key={p.id}
href={`/observer/projects/${p.id}` as Route}
className="flex items-center justify-between gap-2 px-4 py-2.5 hover:bg-muted/50 transition-colors"
className="flex items-center gap-3 px-4 py-2.5 hover:bg-muted/50 transition-colors"
>
<ProjectLogoWithUrl
project={{ id: p.id, title: p.title, logoKey: (p as any).logoKey }}
size="sm"
fallback="initials"
/>
<div className="min-w-0 flex-1">
<p className="text-sm font-medium truncate">{p.title}</p>
<p className="text-xs text-muted-foreground truncate">
{p.teamName ?? 'No team'} · {p.country ? <CountryDisplay country={p.country} /> : ''}
{p.country && (
<p className="text-xs text-muted-foreground">
<CountryDisplay country={p.country} />
</p>
)}
</div>
<Badge variant="outline" className="text-xs shrink-0">
{p.country ? <CountryDisplay country={p.country} /> : '—'}
</Badge>
</Link>
))}
</div>

View File

@@ -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':

View File

@@ -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<string, { teamsWithFiles: number; totalFiles: number }>()
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 },
},
},
})