merge: PR10 — applicant nationality stats card
This commit is contained in:
@@ -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 (
|
||||
<div className="space-y-6">
|
||||
@@ -198,6 +210,13 @@ function ReportsOverview({ scope }: { scope: string | null }) {
|
||||
</AnimatedCard>
|
||||
</div>
|
||||
|
||||
{/* Applicant Nationalities */}
|
||||
<ApplicantNationalitiesCard
|
||||
data={nationalityStats}
|
||||
loading={nationalityLoading}
|
||||
scopeLabel={nationalityScopeLabel}
|
||||
/>
|
||||
|
||||
{/* Score Distribution (if any evaluations exist) */}
|
||||
{dashStats?.scoreDistribution && dashStats.scoreDistribution.some(b => b.count > 0) && (
|
||||
<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
|
||||
function parseSelection(value: string | null): { roundId?: string; programId?: string } {
|
||||
if (!value) return {}
|
||||
|
||||
@@ -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<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,
|
||||
}
|
||||
}),
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user