From 8d4b62a602c672d8768729c4630a18b46ced974d Mon Sep 17 00:00:00 2001 From: Matt Date: Fri, 22 May 2026 18:38:52 +0200 Subject: [PATCH] feat(reports): applicant nationality breakdown card with scope filter (PR10) - stats.getApplicantNationalities procedure aggregates User.nationality across team members of projects in the selected scope (round/program /global) - New Applicant Nationalities card on /admin/reports, top-10 with Show all expansion, country names from the existing ISO map - Handles the ~30% null case explicitly ("Not declared: N") Co-Authored-By: Claude Opus 4.7 (1M context) --- src/app/(admin)/admin/reports/page.tsx | 153 +++++++++++++++++++++++++ src/server/routers/analytics.ts | 63 ++++++++++ 2 files changed, 216 insertions(+) diff --git a/src/app/(admin)/admin/reports/page.tsx b/src/app/(admin)/admin/reports/page.tsx index 38df4f2..a5f4ecf 100644 --- a/src/app/(admin)/admin/reports/page.tsx +++ b/src/app/(admin)/admin/reports/page.tsx @@ -52,6 +52,7 @@ import { } from 'lucide-react' import { toast } from 'sonner' import { formatDateOnly } from '@/lib/utils' +import { getCountryName, getCountryFlag } from '@/lib/countries' import { ScoreDistributionChart, EvaluationTimelineChart, @@ -91,6 +92,17 @@ function ReportsOverview({ scope }: { scope: string | null }) { { enabled: hasScope } ) + // Applicant nationality breakdown — always runs (scope optional; + // empty scope = global view across all programs). + const { data: nationalityStats, isLoading: nationalityLoading } = + trpc.analytics.getApplicantNationalities.useQuery(scopeInput) + + const nationalityScopeLabel = scopeInput.roundId + ? 'in this round' + : scopeInput.programId + ? `in ${programs?.find((p) => p.id === scopeInput.programId)?.year ?? ''} Edition` + : 'across all programs' + if (isLoading || statsLoading) { return (
@@ -198,6 +210,13 @@ function ReportsOverview({ scope }: { scope: string | null }) {
+ {/* Applicant Nationalities */} + + {/* Score Distribution (if any evaluations exist) */} {dashStats?.scoreDistribution && dashStats.scoreDistribution.some(b => b.count > 0) && ( @@ -500,6 +519,140 @@ function ReportsOverview({ scope }: { scope: string | null }) { ) } +type NationalityStats = { + total: number + declared: number + notDeclared: number + byCountry: Array<{ country: string; count: number }> +} + +function ApplicantNationalitiesCard({ + data, + loading, + scopeLabel, +}: { + data: NationalityStats | undefined + loading: boolean + scopeLabel: string +}) { + const [showAll, setShowAll] = useState(false) + + return ( + + + +
+ +
+ Applicant Nationalities +
+ + Self-declared nationality of team members on projects {scopeLabel}. + +
+ + {loading ? ( +
+ + +
+ ) : !data || data.total === 0 ? ( +
+ +

+ No applicants in this scope. +

+
+ ) : data.declared === 0 ? ( + <> + +
+ +

+ No nationality data yet. +

+
+ + ) : ( + <> + + +
+ + + + Country + Applicants + + + + {(showAll ? data.byCountry : data.byCountry.slice(0, 10)).map((row) => { + const name = getCountryName(row.country) + const flag = getCountryFlag(row.country) + return ( + + + + {flag && {flag}} + {name} + {name !== row.country && ( + + {row.country} + + )} + + + + + {row.count} + + + + ) + })} + +
+
+ + {data.byCountry.length > 10 && ( +
+ +
+ )} + + )} +
+
+ ) +} + +function NationalitySummary({ declared, notDeclared }: { declared: number; notDeclared: number }) { + return ( +
+
+

Declared

+

{declared}

+
+
+

Not declared

+

+ {notDeclared} +

+
+
+ ) +} + // Parse selection value: "all:programId" for edition-wide, or roundId function parseSelection(value: string | null): { roundId?: string; programId?: string } { if (!value) return {} diff --git a/src/server/routers/analytics.ts b/src/server/routers/analytics.ts index 7d52a17..206eb9d 100644 --- a/src/server/routers/analytics.ts +++ b/src/server/routers/analytics.ts @@ -2403,4 +2403,67 @@ export const analyticsRouter = router({ prisma: ctx.prisma, }) }), + + /** + * Nationality breakdown for the applicants (team members) of projects in + * the selected scope. Counts UNIQUE users so a single applicant on + * multiple teams isn't double-counted. + * + * Scope: + * - roundId set → projects with a ProjectRoundState in that round + * - programId set → projects in that program + * - neither → all team members across all projects (global) + */ + getApplicantNationalities: adminProcedure + .input( + z + .object({ + roundId: z.string().optional(), + programId: z.string().optional(), + }) + .optional() + ) + .query(async ({ ctx, input }) => { + const roundId = input?.roundId + const programId = input?.programId + + const projectFilter = roundId + ? { projectRoundStates: { some: { roundId } } } + : programId + ? { programId } + : {} + + // Pull all distinct team-member userIds + their nationality in one query. + // `distinct: ['userId']` collapses a user appearing on multiple teams in + // the same scope to a single row. + const teamMembers = await ctx.prisma.teamMember.findMany({ + where: { project: projectFilter }, + select: { userId: true, user: { select: { nationality: true } } }, + distinct: ['userId'], + }) + + const total = teamMembers.length + const declaredEntries = teamMembers.filter( + (tm) => tm.user?.nationality && tm.user.nationality.trim().length > 0 + ) + const declared = declaredEntries.length + const notDeclared = total - declared + + const counts = new Map() + for (const tm of declaredEntries) { + const code = (tm.user!.nationality as string).trim() + counts.set(code, (counts.get(code) ?? 0) + 1) + } + + const byCountry = Array.from(counts.entries()) + .map(([country, count]) => ({ country, count })) + .sort((a, b) => b.count - a.count || a.country.localeCompare(b.country)) + + return { + total, + declared, + notDeclared, + byCountry, + } + }), })