ui changes

This commit is contained in:
2026-08-10 19:36:45 +05:30
parent 409dffe938
commit 5cd49a1655
49 changed files with 7988 additions and 470 deletions

View File

@@ -0,0 +1,957 @@
import React, { useMemo, useState } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import {
ArrowLeft, Calendar, CalendarPlus, Check, ChevronRight, Mail, Phone, Quote,
Sparkles, Star, UserCheck,
} from 'lucide-react';
import { cn } from '@/lib/utils';
import {
Avatar, Badge, Button, EmptyState, ProgressBar, StatusBadge, toast,
} from '@/components/ds';
import { useApplications, useInterviews, useJobPostings } from '@/lib/krowHooks';
import {
ALL_DIMENSIONS, SCORE_GROUPS, activityTimeline, careerScore, careerTier,
communicationLabel, endorsements, marketValue, nextPromotion, recommendationVerdict,
reputationMetrics, strengthTags,
} from '@/lib/candidateIntelligence';
/**
* Candidate Intelligence Profile — a full page, not a drawer.
*
* The drawer this replaces had one structural problem that no amount of styling
* would fix: it was 480px wide and the candidate has around forty fields. Every
* consequence followed from that — a 40px portrait, eight sections stacked into a
* column, the AI recommendation buried below the fold, and fifteen screening
* dimensions rendered as a wall of pills because pills were the only thing that
* fit. It read as an ATS record because it was shaped like one.
*
* So the shape changed. A full page can put identity and decision side by side,
* give the reasoning room to be read as prose, and group the signals instead of
* listing them.
*
* The order answers questions in the order a hiring manager asks them:
*
* who is this → are they a fit → why does KROW think so → what backs it up
* → what is their reputation → strengths and gaps → every signal → what happened
*
* Identity first, decision second, evidence third, deep data last. Every field the
* drawer showed is still here; the ones that are not primary live under a tab
* rather than being dropped.
*/
const TABS = [
{ value: 'overview', label: 'Overview' },
{ value: 'match', label: 'Match Analysis' },
{ value: 'reputation', label: 'Reputation' },
{ value: 'experience', label: 'Experience' },
{ value: 'endorsements', label: 'Endorsements' },
{ value: 'activity', label: 'Activity' },
];
/* ── Small shared pieces ────────────────────────────────────────────────── */
/**
* A section heading.
*
* Small caps and letter-spaced: on an editorial page the headings need to organise
* without competing with the prose they introduce, and a heavier treatment would
* turn every section into another card.
*/
function SectionLabel({ children, meta, className }) {
return (
<div className={cn('mb-3 flex items-baseline justify-between gap-3', className)}>
<h2 className="text-[11px] font-semibold uppercase tracking-[0.08em] text-ink-4">{children}</h2>
{meta && <span className="text-caption text-ink-4">{meta}</span>}
</div>
);
}
/** One score line. Label, bar, figure — readable down a column. */
function Signal({ label, value }) {
const missing = value == null || value === 0;
return (
<div className="flex items-center gap-3">
<span className="w-32 shrink-0 truncate text-body-sm text-ink-2">{label}</span>
<span className="min-w-0 flex-1">
<ProgressBar value={missing ? 0 : value} tone="score" size="xs" />
</span>
<span
className={cn(
'w-8 shrink-0 text-right text-body-sm font-semibold tabular-nums',
missing ? 'text-ink-4' : 'text-ink-1'
)}
>
{missing ? '—' : value}
</span>
</div>
);
}
/** A reputation figure. Value large, label quiet, no card around it. */
function Metric({ label, value, suffix, tone }) {
const empty = value == null || value === '';
return (
<div className="min-w-0 px-4 py-3">
<p className="text-[10px] font-medium uppercase tracking-wide text-ink-4">{label}</p>
<p
className={cn(
'mt-1 font-heading text-title font-bold leading-none tabular-nums',
empty ? 'text-ink-4' : tone === 'brand' ? 'text-krow-blue' : 'text-ink-1'
)}
>
{empty ? '—' : value}
{!empty && suffix && <span className="ml-0.5 text-body-sm font-semibold text-ink-3">{suffix}</span>}
</p>
</div>
);
}
function Stars({ count = 5 }) {
return (
<span className="inline-flex items-center gap-0.5" aria-label={`${count} out of 5`}>
{Array.from({ length: 5 }).map((_, i) => (
<Star
key={i}
className={cn('h-3.5 w-3.5', i < count ? 'fill-krow-yellow text-krow-yellow' : 'text-border')}
aria-hidden="true"
/>
))}
</span>
);
}
/* ── Hero ───────────────────────────────────────────────────────────────── */
/**
* The identity header.
*
* The portrait is 112px — the drawer gave it 40. That is the single biggest reason
* the old view felt like a spreadsheet row: at 40px a face is a decoration, and at
* 112 it is a person. The score sits to the right at deliberately calmer weight,
* because the candidate should be the strongest thing on the page and the number
* should not out-shout them.
*/
function Hero({ candidate, tier, onShortlist, onInterview, onBack }) {
const c = candidate;
const scored = c.ai_score > 0;
return (
<header className="border-b border-border bg-surface">
<div className="px-5 py-5 lg:px-8 lg:py-7">
<button
type="button"
onClick={onBack}
className="mb-5 inline-flex items-center gap-1.5 rounded text-caption font-medium text-ink-3 transition-colors
hover:text-krow-blue focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-krow-blue/50"
>
<ArrowLeft className="h-3.5 w-3.5" aria-hidden="true" />
All candidates
</button>
<div className="flex flex-col gap-6 lg:flex-row lg:items-start">
{/* Identity */}
<div className="flex min-w-0 flex-1 flex-col gap-5 sm:flex-row sm:items-start">
<Avatar
name={c.applicant_name}
src={c.selfie_url}
className="h-24 w-24 shrink-0 text-title-lg sm:h-28 sm:w-28"
/>
<div className="min-w-0 flex-1">
<h1 className="font-heading text-display font-bold leading-tight text-ink-1">
{c.applicant_name}
</h1>
<p className="mt-1 text-body-lg text-ink-2">{c.job_title || 'No position on file'}</p>
<p className="mt-2 text-body-sm text-ink-3">
{c.years_experience ? `${c.years_experience} years experience` : 'Experience not recorded'}
{c.english_level && ` · ${c.english_level[0].toUpperCase()}${c.english_level.slice(1)} English`}
</p>
<div className="mt-3 flex flex-wrap items-center gap-1.5">
<StatusBadge status={c.status} size="sm" />
{c.ai_match_label && <Badge variant="soft" size="sm">{c.ai_match_label}</Badge>}
{scored && <Badge variant="neutral" size="sm">{tier.label}</Badge>}
{c.ai_recommendation && (
<Badge variant="neutral" size="sm">Rec: {c.ai_recommendation}</Badge>
)}
</div>
<div className="mt-4 flex flex-wrap items-center gap-x-5 gap-y-1.5 text-body-sm">
{c.email && (
<a
href={`mailto:${c.email}`}
className="inline-flex items-center gap-1.5 rounded text-ink-2 transition-colors hover:text-krow-blue
focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-krow-blue/50"
>
<Mail className="h-3.5 w-3.5 text-ink-4" aria-hidden="true" />
{c.email}
</a>
)}
{c.phone && (
<a
href={`tel:${c.phone}`}
className="inline-flex items-center gap-1.5 rounded text-ink-2 transition-colors hover:text-krow-blue
focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-krow-blue/50"
>
<Phone className="h-3.5 w-3.5 text-ink-4" aria-hidden="true" />
{c.phone}
</a>
)}
</div>
</div>
</div>
{/* Score and actions */}
<div className="flex shrink-0 flex-col items-start gap-4 lg:items-end">
{scored ? (
<div className="lg:text-right">
<p className="font-heading text-display font-bold leading-none tabular-nums text-krow-blue">
{c.ai_score}
<span className="text-title font-semibold text-ink-4">/100</span>
</p>
{/* Never the bare number: a score without its band is a figure
nobody can act on. */}
<p className="mt-1 text-body-sm font-medium text-ink-2">
{c.ai_match_label || 'Match score'}
</p>
</div>
) : (
<div className="rounded-lg border border-dashed border-border px-4 py-3 lg:text-right">
<p className="text-body-sm font-medium text-ink-2">Not screened</p>
<p className="text-caption text-ink-4">No KROW score yet</p>
</div>
)}
<div className="flex flex-wrap items-center gap-2">
<Button size="sm" shape="rounded" onClick={onShortlist}>
<UserCheck aria-hidden="true" /> Shortlist
</Button>
<Button variant="outline" size="sm" shape="rounded" onClick={onInterview}>
<CalendarPlus aria-hidden="true" /> Interview
</Button>
</div>
</div>
</div>
</div>
</header>
);
}
/* ── Recommendation ─────────────────────────────────────────────────────── */
/**
* The KROW recommendation, near the top where a decision gets made.
*
* In the drawer this sat below the fold under four other sections, which meant the
* one piece of actual decision support arrived after the reader had already formed
* a view from the raw scores. It leads now, and it shows its reasoning: a verdict,
* the sentences behind it, the evidence for, and the gap against — so it reads as
* something you can check rather than something you have to trust.
*/
function Recommendation({ candidate, verdict, onShortlist }) {
const c = candidate;
if (!c.ai_score) {
return (
<section>
<SectionLabel>KROW recommendation</SectionLabel>
<div className="rounded-xl border border-border bg-surface p-5">
<p className="text-body-sm leading-relaxed text-ink-2">
This candidate has not been screened, so there is no recommendation yet.
Screening scores them against the role&apos;s requirements and produces the
analysis that appears here.
</p>
</div>
</section>
);
}
return (
<section>
<SectionLabel>KROW recommendation</SectionLabel>
<div className="rounded-xl border border-border bg-surface">
<div className="border-b border-border px-5 py-4">
<div className="flex flex-wrap items-center gap-2.5">
<span className="grid h-7 w-7 shrink-0 place-items-center rounded-lg bg-krow-blue text-white">
<Sparkles className="h-3.5 w-3.5" aria-hidden="true" />
</span>
<p className="font-heading text-body-lg font-semibold text-ink-1">
{verdict.verdict}
<span className="text-ink-4"> · </span>
<span className="text-krow-blue">{verdict.action}</span>
</p>
</div>
{c.ai_summary && (
<p className="mt-3 max-w-3xl text-body leading-relaxed text-ink-2">{c.ai_summary}</p>
)}
</div>
<div className="grid gap-x-8 gap-y-5 px-5 py-4 sm:grid-cols-2">
{c.ai_strengths?.length > 0 && (
<div>
<p className="mb-2 text-[11px] font-semibold uppercase tracking-[0.08em] text-ink-4">
Why KROW recommends {c.applicant_name.split(' ').pop()}
</p>
<ul className="space-y-1.5">
{c.ai_strengths.map((s) => (
<li key={s} className="flex gap-2 text-body-sm leading-relaxed text-ink-2">
<Check className="mt-0.5 h-3.5 w-3.5 shrink-0 text-success" aria-hidden="true" />
{s}
</li>
))}
</ul>
</div>
)}
{c.ai_gaps?.length > 0 && (
<div>
<p className="mb-2 text-[11px] font-semibold uppercase tracking-[0.08em] text-ink-4">
{c.ai_gaps.length === 1 ? 'Gap' : 'Gaps'}
</p>
<ul className="space-y-1.5">
{c.ai_gaps.map((s) => (
<li key={s} className="flex gap-2 text-body-sm leading-relaxed text-ink-2">
{/* A neutral dot, not a red alert. A missing qualification is
information for the reader, not an error the page caught. */}
<span
className="mt-1.5 h-1.5 w-1.5 shrink-0 rounded-full bg-ink-4"
aria-hidden="true"
/>
{s}
</li>
))}
</ul>
<p className="mt-2 text-caption leading-relaxed text-ink-4">
Noted for context, not a disqualification.
</p>
</div>
)}
</div>
<div className="border-t border-border px-5 py-3">
<Button size="sm" shape="rounded" onClick={onShortlist}>
<UserCheck aria-hidden="true" /> Shortlist {c.applicant_name.split(' ')[0]}
</Button>
</div>
</div>
</section>
);
}
/* ── Tab panels ─────────────────────────────────────────────────────────── */
/** Skills and credentials, as light chips. */
function SkillsAndCerts({ candidate }) {
const c = candidate;
return (
<div className="grid gap-8 sm:grid-cols-2">
<section>
<SectionLabel meta={c.skills?.length ? `${c.skills.length}` : undefined}>Skills</SectionLabel>
{c.skills?.length ? (
<div className="flex flex-wrap gap-1.5">
{c.skills.map((s) => (
<span
key={s}
className="rounded-md border border-border bg-surface px-2.5 py-1 text-body-sm text-ink-2"
>
{s}
</span>
))}
</div>
) : (
<p className="text-body-sm text-ink-3">No skills recorded.</p>
)}
</section>
<section>
<SectionLabel meta={c.certifications?.length ? `${c.certifications.length}` : undefined}>
Certifications
</SectionLabel>
{c.certifications?.length ? (
<div className="flex flex-wrap gap-1.5">
{c.certifications.map((s) => (
<span
key={s}
className="inline-flex items-center gap-1 rounded-md border border-success/25 bg-success-muted px-2.5 py-1 text-body-sm font-medium text-success"
>
<Check className="h-3 w-3" aria-hidden="true" />
{s}
</span>
))}
</div>
) : (
<p className="text-body-sm text-ink-3">None on file.</p>
)}
</section>
</div>
);
}
/** The written record: profile summary and screening analysis, as prose. */
function EditorialSections({ candidate }) {
const c = candidate;
if (!c.professional_summary && !c.ai_summary) return null;
return (
<div className="grid gap-8 lg:grid-cols-2">
{c.professional_summary && (
<section>
<SectionLabel>About the candidate</SectionLabel>
<p className="max-w-prose text-body leading-relaxed text-ink-2">{c.professional_summary}</p>
</section>
)}
{c.ai_summary && (
<section>
<SectionLabel>Screening analysis</SectionLabel>
<p className="max-w-prose text-body leading-relaxed text-ink-2">{c.ai_summary}</p>
</section>
)}
</div>
);
}
/** Strengths against gaps. */
function StrengthsAndGaps({ candidate }) {
const c = candidate;
if (!c.ai_strengths?.length && !c.ai_gaps?.length) return null;
return (
<div className="grid gap-8 sm:grid-cols-2">
{c.ai_strengths?.length > 0 && (
<section>
<SectionLabel>Strengths</SectionLabel>
<ul className="space-y-2">
{c.ai_strengths.map((s) => (
<li key={s} className="flex gap-2 text-body-sm leading-relaxed text-ink-2">
<Check className="mt-0.5 h-3.5 w-3.5 shrink-0 text-success" aria-hidden="true" />
{s}
</li>
))}
</ul>
</section>
)}
{c.ai_gaps?.length > 0 && (
<section>
<SectionLabel>Gaps</SectionLabel>
<ul className="space-y-2">
{c.ai_gaps.map((s) => (
<li key={s} className="flex gap-2 text-body-sm leading-relaxed text-ink-2">
<span className="mt-1.5 h-1.5 w-1.5 shrink-0 rounded-full bg-ink-4" aria-hidden="true" />
{s}
</li>
))}
</ul>
</section>
)}
</div>
);
}
/** Grouped screening signals, with the full set behind a disclosure. */
function MatchBreakdown({ candidate }) {
const [showAll, setShowAll] = useState(false);
const b = candidate.score_breakdown || {};
if (!candidate.ai_score) {
return (
<EmptyState
title="Not screened yet"
description="Screening scores this candidate across fifteen dimensions. Nothing to break down until it runs."
/>
);
}
/* The first three groups are the decision; the fourth is context, so it is
behind "view all signals" rather than adding four more rows to scan. */
const groups = showAll ? SCORE_GROUPS : SCORE_GROUPS.slice(0, 3);
const hidden = ALL_DIMENSIONS.length - groups.reduce((n, g) => n + g.dimensions.length, 0);
return (
<section>
<SectionLabel meta={`${candidate.ai_score}/100 overall · ${ALL_DIMENSIONS.length} dimensions`}>
Match breakdown
</SectionLabel>
<div className="grid gap-x-10 gap-y-7 lg:grid-cols-2">
{groups.map((group) => (
<div key={group.id}>
<div className="mb-3">
<p className="text-body-sm font-semibold text-ink-1">{group.label}</p>
<p className="text-caption text-ink-4">{group.description}</p>
</div>
<div className="space-y-2.5">
{group.dimensions.map((d) => (
<Signal key={d.key} label={d.label} value={b[d.key]} />
))}
</div>
</div>
))}
</div>
{hidden > 0 && (
<button
type="button"
onClick={() => setShowAll(true)}
className="mt-6 inline-flex items-center gap-0.5 rounded text-caption font-semibold text-krow-blue
transition-colors hover:underline focus-visible:outline-none focus-visible:ring-2
focus-visible:ring-krow-blue/50"
>
View all {ALL_DIMENSIONS.length} signals
<ChevronRight className="h-3 w-3" aria-hidden="true" />
</button>
)}
</section>
);
}
/** Career score, market value, promotion track and the five headline metrics. */
function Reputation({ candidate }) {
const c = candidate;
const tier = careerTier(c);
const metrics = reputationMetrics(c);
const tags = strengthTags(c);
return (
<div className="space-y-8">
<section>
<SectionLabel>Workforce reputation</SectionLabel>
<div className="rounded-xl border border-border bg-surface">
<div className="grid gap-x-8 gap-y-5 border-b border-border px-5 py-4 sm:grid-cols-3">
<div>
<p className="text-[10px] font-medium uppercase tracking-wide text-ink-4">Career score</p>
<div className="mt-1 flex items-baseline gap-2">
<p className="font-heading text-title-lg font-bold leading-none tabular-nums text-krow-blue">
{c.ai_score > 0 ? careerScore(c) : '—'}
</p>
{c.ai_score > 0 && <Badge variant="soft" size="sm">{tier.label}</Badge>}
</div>
<p className="mt-1 text-[10px] text-ink-4">300–850 scale</p>
</div>
<div>
<p className="text-[10px] font-medium uppercase tracking-wide text-ink-4">
Potential market value
</p>
<p className="mt-1 font-heading text-title-lg font-bold leading-none text-ink-1">
{c.ai_score > 0 ? marketValue(c) : '—'}
</p>
<p className="mt-1 text-[10px] text-ink-4">Based on career score</p>
</div>
<div>
<p className="text-[10px] font-medium uppercase tracking-wide text-ink-4">Next promotion</p>
<p className="mt-1 font-heading text-title-lg font-bold leading-none text-ink-1">
{c.ai_score > 0 ? nextPromotion(c) : '—'}
</p>
<p className="mt-1 text-[10px] text-ink-4">Tier this candidate tracks toward</p>
</div>
</div>
<div className="grid grid-cols-2 divide-x divide-y divide-border sm:grid-cols-3 lg:grid-cols-5 lg:divide-y-0">
{metrics.map((m) => (
<Metric key={m.label} label={m.label} value={m.value} suffix={m.suffix} />
))}
</div>
</div>
</section>
{c.companies_worked?.length > 0 && (
<section>
<SectionLabel>Worked with</SectionLabel>
<div className="flex flex-wrap gap-2">
{c.companies_worked.map((company) => (
<span
key={company}
className="rounded-md border border-border bg-surface px-2.5 py-1 text-body-sm text-ink-2"
>
{company}
</span>
))}
</div>
</section>
)}
{tags.length > 0 && (
<section>
<SectionLabel meta={`${tags.length} verified`}>Strength tags</SectionLabel>
<div className="flex flex-wrap gap-1.5">
{tags.map((t, i) => (
<span
key={`${t}-${i}`}
className="inline-flex items-center gap-1 rounded-md border border-krow-blue/20 bg-krow-blue-tint px-2.5 py-1 text-body-sm font-medium text-krow-blue"
>
<Check className="h-3 w-3" aria-hidden="true" />
{t}
</span>
))}
</div>
</section>
)}
</div>
);
}
/** The endorsement feed. Compact rows, not a wall of repeated cards. */
function Endorsements({ candidate }) {
const voices = endorsements(candidate);
const tags = strengthTags(candidate);
if (!voices.length && !tags.length) {
return (
<EmptyState
title="No endorsements yet"
description="Endorsements come from supervisors this candidate has worked with on KROW shifts."
/>
);
}
return (
<div className="space-y-8">
<section>
<SectionLabel meta={`${voices.length + tags.length} total`}>
Endorsements
</SectionLabel>
{voices.length ? (
<ul className="divide-y divide-border overflow-hidden rounded-xl border border-border bg-surface">
{voices.map((v) => (
<li key={v.id} className="px-5 py-4">
<div className="flex items-start gap-3">
<Avatar name={v.name} size="sm" />
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-baseline justify-between gap-x-3 gap-y-1">
<p className="text-body-sm font-semibold text-ink-1">{v.name}</p>
<Stars count={v.rating} />
</div>
<p className="text-caption text-ink-3">
{v.role} · {v.events} events
{v.company && v.company !== 'Verified Reference' && ` · ${v.company}`}
</p>
<p className="mt-2 flex gap-1.5 text-body-sm leading-relaxed text-ink-2">
<Quote className="mt-0.5 h-3.5 w-3.5 shrink-0 text-ink-4" aria-hidden="true" />
{v.comment}
</p>
<div className="mt-3 flex flex-wrap items-center gap-2">
<Button
variant="outline"
size="xs"
shape="rounded"
onClick={() => toast.success(`Reference requested from ${v.name}`)}
>
Request Reference
</Button>
<Button
variant="outline"
size="xs"
shape="rounded"
onClick={() => toast.success(`Skill endorsement recorded for ${candidate.applicant_name}`)}
>
Endorse Skill
</Button>
</div>
</div>
</div>
</li>
))}
</ul>
) : (
<p className="text-body-sm text-ink-3">No written endorsements on record.</p>
)}
</section>
{tags.length > 0 && (
<section>
<SectionLabel meta={`${tags.length} verified`}>Endorsed strengths</SectionLabel>
<div className="flex flex-wrap gap-1.5">
{tags.map((t, i) => (
<span
key={`${t}-${i}`}
className="inline-flex items-center gap-1 rounded-md border border-krow-blue/20 bg-krow-blue-tint px-2.5 py-1 text-body-sm font-medium text-krow-blue"
>
<Check className="h-3 w-3" aria-hidden="true" />
{t}
</span>
))}
</div>
</section>
)}
</div>
);
}
/** Experience: history, availability, credentials and the applied position. */
function Experience({ candidate, job, interview }) {
const c = candidate;
return (
<div className="space-y-8">
<section>
<SectionLabel>Experience</SectionLabel>
<div className="grid gap-x-8 gap-y-5 sm:grid-cols-3">
<div>
<p className="text-[10px] font-medium uppercase tracking-wide text-ink-4">Years in role</p>
<p className="mt-1 font-heading text-title-lg font-bold leading-none text-ink-1">
{c.years_experience || '—'}
</p>
</div>
<div>
<p className="text-[10px] font-medium uppercase tracking-wide text-ink-4">English</p>
<p className="mt-1 text-body font-semibold capitalize text-ink-1">
{c.english_level || '—'}
</p>
{c.score_breakdown?.english > 0 && (
<p className="mt-0.5 text-caption text-ink-4">Scored {c.score_breakdown.english}</p>
)}
</div>
<div>
<p className="text-[10px] font-medium uppercase tracking-wide text-ink-4">Communication</p>
<p className="mt-1 text-body font-semibold text-ink-1">
{communicationLabel(c.score_breakdown?.communication_style) || '—'}
</p>
</div>
</div>
</section>
<section>
<SectionLabel>Availability</SectionLabel>
{c.availability?.length ? (
<div className="flex flex-wrap gap-1.5">
{c.availability.map((a) => (
<span
key={a}
className="inline-flex items-center gap-1 rounded-md border border-border bg-surface px-2.5 py-1 text-body-sm text-ink-2"
>
<Calendar className="h-3 w-3 text-ink-4" aria-hidden="true" />
{a}
</span>
))}
</div>
) : (
<p className="text-body-sm text-ink-3">
Not set — scheduling this candidate would be guesswork.
</p>
)}
</section>
<SkillsAndCerts candidate={c} />
<section>
<SectionLabel>Interview history</SectionLabel>
{interview ? (
<div className="rounded-xl border border-border bg-surface px-5 py-4">
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<p className="text-body font-semibold text-ink-1">
{interview.overall_interview_score > 0
? `Scored ${interview.overall_interview_score}/100`
: 'Started, not scored'}
</p>
<p className="text-caption text-ink-3">
Integrity {interview.integrity_score ?? 100}/100
</p>
</div>
<StatusBadge status={interview.verdict} size="sm" />
</div>
{interview.summary && (
<p className="mt-3 max-w-prose text-body-sm leading-relaxed text-ink-2">
{interview.summary}
</p>
)}
</div>
) : (
<p className="text-body-sm text-ink-3">No interview on record.</p>
)}
</section>
{job && (
<section>
<SectionLabel>Applied position</SectionLabel>
<div className="rounded-xl border border-border bg-surface px-5 py-4">
<p className="text-body font-semibold text-ink-1">{job.title}</p>
<p className="mt-0.5 text-caption text-ink-3">
{job.role_category || 'Uncategorized'}
{job.location ? ` · ${job.location}` : ''}
{job.pay_range_max ? ` · $${job.pay_range_min}–$${job.pay_range_max}/hr` : ''}
</p>
</div>
</section>
)}
</div>
);
}
/** The candidate's history as a timeline. */
function Activity({ candidate, interview }) {
const events = activityTimeline(candidate, interview);
return (
<section>
<SectionLabel meta={`${events.length} events`}>Activity</SectionLabel>
<ol className="relative space-y-0">
{events.map((e, i) => (
<li key={i} className="flex gap-3.5 pb-5 last:pb-0">
<div className="flex flex-col items-center">
<span
className={cn(
'mt-1.5 h-2 w-2 shrink-0 rounded-full ring-4 ring-surface-subtle',
i === events.length - 1 ? 'bg-krow-blue' : 'bg-ink-4'
)}
aria-hidden="true"
/>
{i < events.length - 1 && <span className="mt-1 w-px flex-1 bg-border" aria-hidden="true" />}
</div>
<div className="min-w-0 flex-1 pb-1">
<div className="flex flex-wrap items-baseline justify-between gap-x-3">
<p className="text-body-sm font-medium text-ink-1">{e.label}</p>
{e.date && (
<time className="text-caption text-ink-4">
{new Date(e.date).toLocaleDateString(undefined, {
month: 'short', day: 'numeric', year: 'numeric',
})}
</time>
)}
</div>
{e.detail && <p className="mt-0.5 text-caption text-ink-3">{e.detail}</p>}
</div>
</li>
))}
</ol>
</section>
);
}
/* ── Page ───────────────────────────────────────────────────────────────── */
export default function CandidateProfile() {
const { id } = useParams();
const navigate = useNavigate();
const [tab, setTab] = useState('overview');
const { data: applications = [], isLoading } = useApplications();
const { data: postings = [] } = useJobPostings();
const { data: interviews = [] } = useInterviews();
const candidate = useMemo(() => applications.find((a) => a.id === id), [applications, id]);
const job = useMemo(
() => postings.find((p) => p.id === candidate?.job_posting_id),
[postings, candidate]
);
const interview = useMemo(
() => interviews.find((i) => i.application_id === candidate?.id),
[interviews, candidate]
);
if (isLoading) {
return (
<div className="px-5 py-10 lg:px-8">
<div className="h-28 w-28 animate-pulse rounded-full bg-surface-sunken" />
<div className="mt-5 h-8 w-64 animate-pulse rounded bg-surface-sunken" />
</div>
);
}
if (!candidate) {
return (
<div className="px-5 py-16 lg:px-8">
<EmptyState
title="Candidate not found"
description="This candidate may have been removed, or the link may be out of date."
action={
<Button size="sm" onClick={() => navigate('/admin/candidates')}>
Back to candidates
</Button>
}
/>
</div>
);
}
const tier = careerTier(candidate);
const verdict = recommendationVerdict(candidate);
const shortlist = () => toast.success(`${candidate.applicant_name} shortlisted`);
const scheduleInterview = () => toast.info('Interview scheduling is disabled in the demo');
return (
<div className="-mx-5 -my-6 lg:-mx-6">
<Hero
candidate={candidate}
tier={tier}
onShortlist={shortlist}
onInterview={scheduleInterview}
onBack={() => navigate('/admin/candidates')}
/>
{/* Sticky tab bar. Sits below the app header, so both stay visible while a
long profile scrolls. Underline rather than pills: this is the page's
second level of navigation and should not compete with the first. */}
<div className="sticky top-14 z-20 border-b border-border bg-surface/95 backdrop-blur-xl">
<div
role="tablist"
aria-label="Candidate profile sections"
className="flex gap-1 overflow-x-auto px-5 lg:px-8"
>
{TABS.map((t) => {
const active = t.value === tab;
return (
<button
key={t.value}
type="button"
role="tab"
aria-selected={active}
onClick={() => setTab(t.value)}
className={cn(
'relative whitespace-nowrap px-3 py-3 text-body-sm font-medium transition-colors duration-base',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-krow-blue/50',
active ? 'text-krow-blue' : 'text-ink-3 hover:text-ink-1'
)}
>
{t.label}
{active && (
<span
className="absolute inset-x-3 bottom-0 h-0.5 rounded-full bg-krow-blue"
aria-hidden="true"
/>
)}
</button>
);
})}
</div>
</div>
<div className="px-5 py-7 lg:px-8">
<div className="max-w-5xl space-y-10">
{tab === 'overview' && (
<>
<Recommendation candidate={candidate} verdict={verdict} onShortlist={shortlist} />
<EditorialSections candidate={candidate} />
<SkillsAndCerts candidate={candidate} />
<StrengthsAndGaps candidate={candidate} />
<MatchBreakdown candidate={candidate} />
</>
)}
{tab === 'match' && (
<>
<Recommendation candidate={candidate} verdict={verdict} onShortlist={shortlist} />
<MatchBreakdown candidate={candidate} />
<StrengthsAndGaps candidate={candidate} />
</>
)}
{tab === 'reputation' && <Reputation candidate={candidate} />}
{tab === 'experience' && (
<Experience candidate={candidate} job={job} interview={interview} />
)}
{tab === 'endorsements' && <Endorsements candidate={candidate} />}
{tab === 'activity' && <Activity candidate={candidate} interview={interview} />}
</div>
</div>
</div>
);
}