merge: PR10 — applicant nationality stats card

This commit is contained in:
Matt
2026-05-22 18:42:51 +02:00
2 changed files with 216 additions and 0 deletions

View File

@@ -52,6 +52,7 @@ import {
} from 'lucide-react' } from 'lucide-react'
import { toast } from 'sonner' import { toast } from 'sonner'
import { formatDateOnly } from '@/lib/utils' import { formatDateOnly } from '@/lib/utils'
import { getCountryName, getCountryFlag } from '@/lib/countries'
import { import {
ScoreDistributionChart, ScoreDistributionChart,
EvaluationTimelineChart, EvaluationTimelineChart,
@@ -91,6 +92,17 @@ function ReportsOverview({ scope }: { scope: string | null }) {
{ enabled: hasScope } { 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) { if (isLoading || statsLoading) {
return ( return (
<div className="space-y-6"> <div className="space-y-6">
@@ -198,6 +210,13 @@ function ReportsOverview({ scope }: { scope: string | null }) {
</AnimatedCard> </AnimatedCard>
</div> </div>
{/* Applicant Nationalities */}
<ApplicantNationalitiesCard
data={nationalityStats}
loading={nationalityLoading}
scopeLabel={nationalityScopeLabel}
/>
{/* Score Distribution (if any evaluations exist) */} {/* Score Distribution (if any evaluations exist) */}
{dashStats?.scoreDistribution && dashStats.scoreDistribution.some(b => b.count > 0) && ( {dashStats?.scoreDistribution && dashStats.scoreDistribution.some(b => b.count > 0) && (
<Card> <Card>
@@ -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 (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<div className="rounded-lg bg-violet-500/10 p-1.5">
<Globe className="h-4 w-4 text-violet-600" />
</div>
Applicant Nationalities
</CardTitle>
<CardDescription>
Self-declared nationality of team members on projects {scopeLabel}.
</CardDescription>
</CardHeader>
<CardContent>
{loading ? (
<div className="space-y-3">
<Skeleton className="h-12 w-full" />
<Skeleton className="h-64 w-full" />
</div>
) : !data || data.total === 0 ? (
<div className="flex flex-col items-center justify-center py-8 text-center">
<Globe className="h-10 w-10 text-muted-foreground/50" />
<p className="mt-2 text-sm text-muted-foreground">
No applicants in this scope.
</p>
</div>
) : data.declared === 0 ? (
<>
<NationalitySummary declared={data.declared} notDeclared={data.notDeclared} />
<div className="mt-4 flex flex-col items-center justify-center rounded-lg border border-dashed py-8 text-center">
<Globe className="h-8 w-8 text-muted-foreground/50" />
<p className="mt-2 text-sm text-muted-foreground">
No nationality data yet.
</p>
</div>
</>
) : (
<>
<NationalitySummary declared={data.declared} notDeclared={data.notDeclared} />
<div className="mt-4 rounded-lg border">
<Table>
<TableHeader>
<TableRow>
<TableHead>Country</TableHead>
<TableHead className="text-right w-32">Applicants</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{(showAll ? data.byCountry : data.byCountry.slice(0, 10)).map((row) => {
const name = getCountryName(row.country)
const flag = getCountryFlag(row.country)
return (
<TableRow key={row.country}>
<TableCell className="font-medium">
<span className="inline-flex items-center gap-2">
{flag && <span aria-hidden>{flag}</span>}
<span>{name}</span>
{name !== row.country && (
<span className="text-xs text-muted-foreground tabular-nums">
{row.country}
</span>
)}
</span>
</TableCell>
<TableCell className="text-right">
<Badge variant="secondary" className="tabular-nums">
{row.count}
</Badge>
</TableCell>
</TableRow>
)
})}
</TableBody>
</Table>
</div>
{data.byCountry.length > 10 && (
<div className="mt-3 flex justify-end">
<Button
variant="ghost"
size="sm"
onClick={() => setShowAll((v) => !v)}
className="gap-1 text-muted-foreground"
>
{showAll
? 'Show top 10'
: `Show all (${data.byCountry.length} countries)`}
<ArrowRight className="h-3.5 w-3.5" />
</Button>
</div>
)}
</>
)}
</CardContent>
</Card>
)
}
function NationalitySummary({ declared, notDeclared }: { declared: number; notDeclared: number }) {
return (
<div className="grid grid-cols-2 gap-3">
<div className="rounded-lg border p-3 text-center">
<p className="text-xs text-muted-foreground">Declared</p>
<p className="text-2xl font-bold tabular-nums">{declared}</p>
</div>
<div className="rounded-lg border p-3 text-center">
<p className="text-xs text-muted-foreground">Not declared</p>
<p className="text-2xl font-bold tabular-nums text-muted-foreground">
{notDeclared}
</p>
</div>
</div>
)
}
// Parse selection value: "all:programId" for edition-wide, or roundId // Parse selection value: "all:programId" for edition-wide, or roundId
function parseSelection(value: string | null): { roundId?: string; programId?: string } { function parseSelection(value: string | null): { roundId?: string; programId?: string } {
if (!value) return {} if (!value) return {}

View File

@@ -2403,4 +2403,67 @@ export const analyticsRouter = router({
prisma: ctx.prisma, 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<string, number>()
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,
}
}),
}) })