merge: PR10 — applicant nationality stats card
This commit is contained in:
@@ -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 {}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
}
|
||||||
|
}),
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user