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,
+ }
+ }),
})