fix: submission round completion %, document details, project teams UX
All checks were successful
Build and Push Docker Image / build (push) Successful in 7m32s
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:
@@ -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>
|
||||
|
||||
@@ -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':
|
||||
|
||||
@@ -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 },
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user