Files
krow_talent_app/src/pages/admin/CandidateProfile.jsx
2026-08-17 17:33:56 +05:30

1381 lines
55 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import React, { useMemo, useState } from 'react';
import { useNavigate, useParams, useSearchParams } from 'react-router-dom';
import { motion, useReducedMotion } from 'framer-motion';
import {
ArrowLeft, Calendar, CalendarPlus, Check, ChevronDown, Loader2, Mail, MessageSquare, Minus,
Phone, Quote, Sparkles, Star, UserCheck, X,
} from 'lucide-react';
import { cn } from '@/lib/utils';
import {
Avatar, Badge, Button, ConfirmModal, DropdownMenu, DropdownMenuContent, DropdownMenuItem,
DropdownMenuTrigger, EmptyState, ProgressBar, StatusBadge, toast,
} from '@/components/ds';
import {
useApplications, useAssignments, useCourses, useHireCandidate, useInterviews, useJobPostings,
useScreenCandidate, useStaff, useUpdateApplication, useWorkerProfiles,
} from '@/lib/krowHooks';
import { poolFor } from '@/lib/workforce';
import { profileForEmail } from '@/lib/skillGraph';
import { Journey, JourneyStep, useCountUp } from '@/components/krow/Journey';
import { VerifiedSkills } from '@/components/krow/VerifiedSkills';
import { SkillSurface } from '@/components/skills/SkillSurface';
import AIInterviewModal from '@/components/krow/AIInterviewModal';
import ScheduleInterviewModal from '@/components/krow/ScheduleInterviewModal';
import MessageCandidateModal from '@/components/krow/MessageCandidateModal';
import { usePageSkills } from '@/lib/skills/usePageSkills';
import {
ALL_DIMENSIONS, SCORE_GROUPS, activityTimeline, careerScore, careerTier,
communicationLabel, endorsements, marketValue, nextPromotion, recommendationVerdict,
reputationMetrics, strengthTags,
} from '@/lib/candidateIntelligence';
/**
* The candidate profile — one person, one page.
*
* This was six tabs: Overview, Match Analysis, Reputation, Experience, Endorsements,
* Activity. Every one of them held a piece of the same decision, so reaching a
* verdict meant clicking through all six and holding the earlier ones in your head.
* Tabs are for alternatives; these were never alternatives.
*
* So it scrolls now, in the order the decision is actually made:
*
* who is this → can we trust them → are they a match → why → can they do the job
* → what has KROW verified → what do others say → what happened → what should I do
*
* The order is drawn, not just implied: `Journey` runs a thin rail down the left
* with a node per section, filled behind the reader and hollow ahead. Sections
* fade up once as they arrive and then hold still. All of it collapses to the
* final state under `prefers-reduced-motion`.
*
* Nothing was dropped in the flattening. Every field the tabs held is still here,
* and the sections that were duplicated across tabs — the AI summary printed twice,
* strengths listed in two places, the applied position in three — appear once.
*
* The rail on the right holds the decision itself: score, actions, and the facts
* you check against while reading. It is sticky, so a page this long never
* separates the evidence from the buttons.
*/
/**
* One measure, shared by the header and the body — and it is the Admin layout's
* measure, not a private one.
*
* Every other Admin page renders straight into `AdminLayout`'s `<main>`, which is
* `flex-1` inside a `px-4 lg:px-6` row: content starts 24px from the viewport
* edge and runs to whatever is left. `AdminPage` adds no width of its own.
*
* This page used to cap itself at a max-width and centre it. That is what made it
* read as the narrowest page in the product despite being given the most room:
* the assistant panel is deliberately excluded on this route (see
* `EXCLUDED_ROUTES` in ai-assistant/placement.js), so `<main>` here is ~400px
* wider than on Candidates or Positions — and the cap threw that extra space
* away as two symmetrical margins, which read as a page floating in a canvas
* rather than a page filling one.
*
* So: no max-width and no centring. The gutters match the layout exactly, and
* the page lines up with the Candidates list it was opened from. The negative
* margins on the wrapper exist only so the header band can bleed to the edges;
* this puts the content back on the layout's own grid.
*/
const SHELL = 'w-full px-4 lg:px-6';
/** Buttons that answer to the pointer. Suppressed for reduced motion by Tailwind. */
const PRESSABLE = 'transition-transform duration-150 hover:-translate-y-px active:scale-[0.98] motion-reduce:transform-none';
/* ── Small shared pieces ────────────────────────────────────────────────── */
/** A label inside a section — a rung below the section headings the rail draws. */
/** @param {any} props */
function SubLabel({ children, className = '' }) {
return (
<p className={cn('mb-2 text-[10px] font-semibold uppercase tracking-[0.08em] text-ink-4', className)}>
{children}
</p>
);
}
/** 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. */
/** @param {any} props */
function Metric({ label, value, suffix = '', tone = '' }) {
if (value == null || value === '') return null;
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',
tone === 'brand' ? 'text-krow-blue' : 'text-ink-1'
)}
>
{value}
{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>
);
}
/** One label/value row in the snapshot. */
function Fact({ label, children }) {
return (
<div className="flex items-baseline justify-between gap-3 px-4 py-2.5">
<span className="shrink-0 text-caption text-ink-4">{label}</span>
<span className="min-w-0 text-right text-body-sm font-medium text-ink-1">{children}</span>
</div>
);
}
/**
* A chip that arrives with its neighbours.
*
* Staggered by index, once. The delay is capped so a candidate with twenty
* skills does not make the reader watch a queue form.
*/
/** @param {any} props */
function Pill({ index = 0, className = '', children }) {
const reduced = useReducedMotion();
return (
<motion.span
className={className}
initial={reduced ? false : { opacity: 0, y: 4 }}
whileInView={reduced ? undefined : { opacity: 1, y: 0 }}
viewport={{ once: true, amount: 0.6 }}
transition={{ duration: 0.25, ease: 'easeOut', delay: Math.min(index * 0.03, 0.3) }}
>
{children}
</motion.span>
);
}
/* ── Header ─────────────────────────────────────────────────────────────── */
/** Identity, and the position this person is being read against. */
function Hero({ candidate, tier, viewingFor, onBack }) {
const c = candidate;
const scored = c.ai_score > 0;
return (
<header className="border-b border-border bg-surface">
<div className={cn(SHELL, 'pb-5 pt-4')}>
<button
type="button"
onClick={onBack}
className="mb-4 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 min-w-0 flex-col items-start gap-3 sm:flex-row sm:items-center sm:gap-4">
<Avatar name={c.applicant_name} src={c.selfie_url} size="xl" />
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-x-3 gap-y-1.5">
<h1 className="font-heading text-title-lg font-bold leading-tight text-ink-1">
{c.applicant_name}
</h1>
<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>}
</div>
<p className="mt-1 text-body text-ink-2">
{c.job_title || 'No position on file'}
<span className="text-ink-4"> · </span>
<span className="text-ink-3">
{c.years_experience ? `${c.years_experience} yrs experience` : 'Experience not recorded'}
{c.english_level &&
` · ${c.english_level[0].toUpperCase()}${c.english_level.slice(1)} English`}
</span>
</p>
<div className="mt-2 flex flex-wrap items-center gap-x-4 gap-y-1 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>
{/* Opened from a position — Owliver, or a position's matched list. The
person is the same person; what changes is what they are being read
against, and the page says so rather than letting the reader assume
the score below belongs to this role. */}
{viewingFor && (
<p className="inline-flex shrink-0 flex-wrap items-center gap-1.5 rounded-full bg-krow-blue-tint px-3 py-1.5 text-body-sm text-krow-blue sm:self-start">
<span className="text-[10px] font-semibold uppercase tracking-[0.08em] text-krow-blue/70">
Viewing for
</span>
<span className="font-semibold">{viewingFor.title}</span>
</p>
)}
</div>
</div>
</header>
);
}
/* ── Decision rail ──────────────────────────────────────────────────────── */
/**
* Score and actions.
*
* Sticky, because on a single page of this length the buttons must still be there
* when the reader reaches the bottom of the endorsements.
*/
function ScoreCard({
candidate, onShortlist, onInterview, onMessage, onHire, onDecline, onScreen, screening, busy,
}) {
const c = candidate;
const scored = c.ai_score > 0;
const settled = c.status === 'hired' || c.status === 'rejected';
const shownScore = useCountUp(c.ai_score || 0, { start: scored });
return (
<div className="overflow-hidden rounded-xl border border-border bg-surface shadow-sm">
{scored ? (
<div className="border-b border-border px-4 py-4">
<p className="text-[10px] font-semibold uppercase tracking-[0.08em] text-ink-4">KROW score</p>
<p className="mt-1 font-heading text-display font-bold leading-none tabular-nums text-krow-blue">
{shownScore}
<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.5 text-body-sm font-medium text-ink-2">
{c.ai_match_label || 'Match score'}
<span className="text-ink-4"> · </span>
<span className="text-ink-3">{careerTier(c).label}</span>
</p>
<div className="mt-2.5">
<ProgressBar value={shownScore} tone="score" size="xs" />
</div>
</div>
) : (
<div className="border-b border-border bg-krow-blue-tint px-4 py-4">
<div className="flex items-center gap-2">
<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 font-semibold text-ink-1">Not screened yet</p>
</div>
<p className="mt-2 text-body-sm leading-relaxed text-ink-2">
Screening scores {c.applicant_name.split(' ')[0]} against the role across{' '}
{ALL_DIMENSIONS.length} dimensions and writes the analysis on this page.
</p>
<Button
size="sm"
shape="rounded"
className={cn('mt-3 w-full', PRESSABLE)}
onClick={onScreen}
disabled={screening}
>
{screening
? <Loader2 className="animate-spin" aria-hidden="true" />
: <Sparkles aria-hidden="true" />}
{screening ? 'Screening…' : 'Run screening'}
</Button>
</div>
)}
<div className="space-y-2 p-4">
{!settled && (
<Button
size="sm"
shape="rounded"
className={cn('w-full', PRESSABLE)}
onClick={onShortlist}
disabled={busy || c.status === 'shortlisted'}
>
<UserCheck aria-hidden="true" />
{c.status === 'shortlisted' ? 'Shortlisted' : `Shortlist ${c.applicant_name.split(' ')[0]}`}
</Button>
)}
<Button
variant="outline"
size="sm"
shape="rounded"
className={cn('w-full', PRESSABLE)}
onClick={onInterview}
>
<CalendarPlus aria-hidden="true" />
{c.status === 'interview' ? 'AI interview' : 'Schedule interview'}
</Button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="sm" shape="rounded" className={cn('w-full', PRESSABLE)}>
Actions
<ChevronDown className="h-3.5 w-3.5" aria-hidden="true" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-52">
<DropdownMenuItem onClick={onMessage}>
<MessageSquare className="mr-2 h-4 w-4 text-krow-blue" aria-hidden="true" />
Message
</DropdownMenuItem>
<DropdownMenuItem onClick={onInterview}>
<Calendar className="mr-2 h-4 w-4 text-krow-blue" aria-hidden="true" />
Set up interview
</DropdownMenuItem>
<DropdownMenuItem onClick={onHire} disabled={c.status === 'hired'}>
<UserCheck className="mr-2 h-4 w-4 text-success" aria-hidden="true" />
Hire candidate
</DropdownMenuItem>
<DropdownMenuItem onClick={onDecline} disabled={c.status === 'rejected'}>
<X className="mr-2 h-4 w-4 text-destructive" aria-hidden="true" />
Decline candidate
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
);
}
/**
* The position, in the rail beside the person.
*
* Strictly the *role's* facts — what they applied to, where it is, what it pays,
* when it starts. It used to also carry experience, English, availability and a
* certification count, every one of which the Experience and Skills sections
* already state in full a few hundred pixels to the left. Two copies of a fact
* on one screen is one copy too many, and the copy that lost is the abbreviated
* one.
*
* The division the rail keeps: main column answers "who is this person", rail
* answers "what are they being considered for, and what can I do about it".
*/
function SnapshotCard({ candidate, job }) {
const c = candidate;
const applied = c.created_date ? new Date(c.created_date) : null;
return (
<div className="overflow-hidden rounded-xl border border-border bg-surface shadow-sm">
<div className="border-b border-border px-4 py-3">
<p className="text-[10px] font-semibold uppercase tracking-[0.08em] text-ink-4">Position</p>
</div>
<div className="divide-y divide-border">
<Fact label="Applied for">{c.job_title || job?.title || '—'}</Fact>
{job?.location && <Fact label="Location">{job.location}</Fact>}
{job?.pay_range_max > 0 && (
<Fact label="Pay range">
${job.pay_range_min}–${job.pay_range_max}/hr
</Fact>
)}
{job?.employment_type && (
<Fact label="Type">
<span className="capitalize">{String(job.employment_type).replace(/_/g, ' ')}</span>
</Fact>
)}
{applied && (
<Fact label="Applied">
{applied.toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' })}
</Fact>
)}
</div>
</div>
);
}
/* ── Sections ───────────────────────────────────────────────────────────── */
/* The career score's scale, from `toFICO` — stated once so the meter, the
denominator and the axis labels cannot disagree with the arithmetic. */
const CAREER_MIN = 300;
const CAREER_MAX = 850;
/**
* Workforce reputation — the record behind the person, where one exists.
*
* The page's anchor, and first. What a hiring manager wants before anything
* else is "is this person any good" — a question about the person, answered the
* same way for every position. Match analysis answers a different question
* ("are they right for *this* role") and follows, kept visually distinct so the
* two are never read as one number.
*
* Career score leads on its own tinted panel: the figure, the tier it earns, and
* a meter placing it on the 300–850 scale, because 834 means nothing to a reader
* who does not know the range. Market value and next promotion sit beside it as
* consequences of the score, not as peers of it.
*
* Every figure is computed from the candidate record by `candidateIntelligence`
* — the same module the talent-side profile reads. A field with nothing behind
* it is left out rather than printed as "N/A".
*/
function Reputation({ candidate }) {
const c = candidate;
const tier = careerTier(c);
const metrics = reputationMetrics(c).filter((m) => m.value != null && m.value !== '');
const tags = strengthTags(c);
const target = careerScore(c);
const score = useCountUp(target);
const position = Math.round(((target - CAREER_MIN) / (CAREER_MAX - CAREER_MIN)) * 100);
return (
<>
<div className="overflow-hidden rounded-xl border border-border bg-surface">
<div className="grid divide-y divide-border sm:grid-cols-[minmax(240px,1fr)_1fr_1fr] sm:divide-x sm:divide-y-0">
{/* The anchor. Tinted rather than larger: the analytical surface marks
it as the headline without a number so big it unbalances the row. */}
<div className="bg-krow-blue-tint/60 px-5 py-4">
<p className="text-[10px] font-semibold uppercase tracking-[0.08em] text-krow-blue/70">
Career score
</p>
<div className="mt-1.5 flex flex-wrap items-baseline gap-x-2.5 gap-y-1">
<p className="font-heading text-display font-bold leading-none tabular-nums text-krow-blue">
{score}
<span className="text-title font-semibold text-krow-blue/40">/{CAREER_MAX}</span>
</p>
<Badge variant="soft" size="sm">{tier.label}</Badge>
</div>
<div className="mt-3 h-1.5 overflow-hidden rounded-full bg-white/70">
<div
className="h-full rounded-full bg-krow-blue transition-[width] duration-700 ease-out motion-reduce:transition-none"
style={{ width: `${position}%` }}
/>
</div>
<div className="mt-1 flex justify-between text-[10px] tabular-nums text-krow-blue/60">
<span>{CAREER_MIN}</span>
<span>{CAREER_MAX}</span>
</div>
</div>
<div className="px-5 py-4">
<p className="text-[10px] font-medium uppercase tracking-wide text-ink-4">
Potential market value
</p>
<p className="mt-1.5 font-heading text-title-lg font-bold leading-none text-ink-1">
{marketValue(c)}
</p>
<p className="mt-1.5 text-[10px] text-ink-4">Derived from the career score</p>
</div>
<div className="px-5 py-4">
<p className="text-[10px] font-medium uppercase tracking-wide text-ink-4">Next promotion</p>
<p className="mt-1.5 font-heading text-title-lg font-bold leading-none text-ink-1">
{nextPromotion(c)}
</p>
<p className="mt-1.5 text-[10px] text-ink-4">Tier this candidate tracks toward</p>
</div>
</div>
{metrics.length > 0 && (
<div className="grid grid-cols-2 divide-x divide-y divide-border border-t border-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>
{c.companies_worked?.length > 0 && (
<div className="mt-4">
<SubLabel>Worked with</SubLabel>
<div className="flex flex-wrap gap-1.5">
{c.companies_worked.map((company, i) => (
<Pill
key={company}
index={i}
className="rounded-md border border-border bg-surface px-2.5 py-1 text-body-sm text-ink-2"
>
{company}
</Pill>
))}
</div>
</div>
)}
{tags.length > 0 && (
<div className="mt-4">
<SubLabel>Verified strengths · {tags.length}</SubLabel>
<div className="flex flex-wrap gap-1.5">
{tags.map((t, i) => (
<Pill
key={`${t}-${i}`}
index={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}
</Pill>
))}
</div>
</div>
)}
</>
);
}
/**
* The match against the position the profile was opened for.
*
* Only rendered when that is a different role from the one they applied to. The
* numbers come from `poolFor` — the same engine that ranked this person in
* Owliver — so the profile cannot contradict the recommendation that led here.
*/
function PositionMatch({ row, position }) {
const score = row?.score ?? row?.match?.score ?? 0;
const shown = useCountUp(score);
if (!row?.match) return null;
const { match } = row;
return (
<div className="mb-5 overflow-hidden rounded-xl border border-krow-blue/20 bg-krow-blue-tint/50">
<div className="px-4 py-3.5">
<div className="flex flex-wrap items-baseline justify-between gap-2">
<p className="text-[11px] font-semibold uppercase tracking-[0.08em] text-krow-blue">
Match for {position.title}
</p>
<p className="font-heading text-title font-bold leading-none tabular-nums text-krow-blue">
{shown}%
</p>
</div>
<p className="mt-1 text-caption text-ink-3">
{match.band}
{row.basis === 'verified'
? ' · scored on verified training'
: " · scored on this role's stated requirements"}
</p>
<div className="mt-2.5">
<ProgressBar value={shown} tone="brand" size="xs" />
</div>
<ul className="mt-3 space-y-1.5">
{match.lines.map((line) => {
const Icon = line.met ? Check : line.close ? Minus : X;
return (
<li key={line.name} className="flex items-center gap-2 text-body-sm">
<Icon
className={cn(
'h-3.5 w-3.5 shrink-0',
line.met ? 'text-success' : line.close ? 'text-warning' : 'text-destructive'
)}
aria-hidden="true"
/>
<span className="min-w-0 flex-1 truncate text-ink-2">
{line.name} — {line.heldLabel}
{!line.met && <span className="text-ink-4"> · needs {line.requiredLabel}</span>}
</span>
</li>
);
})}
</ul>
{row.availability?.known === false && (
<p className="mt-2.5 text-caption text-ink-4">Availability not on file for this person.</p>
)}
</div>
</div>
);
}
/** Screening signals, grouped, with the full set behind a disclosure. */
function MatchAnalysis({ candidate, positionRow, position }) {
const [showAll, setShowAll] = useState(false);
const b = candidate.score_breakdown || {};
const scored = candidate.ai_score > 0;
/* 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 (
<>
{position && <PositionMatch row={positionRow} position={position} />}
{scored ? (
<>
{/* Two columns from xl, three from 2xl. Without the third the reading
column's extra width all goes into the bars, and a 450px bar is
harder to compare against its neighbour than a 300px one — the
width is better spent on another group than on longer lines. */}
<div className="grid gap-x-10 gap-y-6 xl:grid-cols-2 2xl:grid-cols-3">
{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-5 inline-flex items-center gap-1 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
<ChevronDown className="h-3 w-3" aria-hidden="true" />
</button>
)}
</>
) : (
<div className="rounded-xl border border-dashed border-border px-4 py-3.5">
<p className="text-body-sm leading-relaxed text-ink-3">
Screening scores appear here once it runs — skills, experience, reliability
and role fit, each rated against this position.
</p>
</div>
)}
</>
);
}
/**
* What the candidate says about themselves.
*
* Their own words, as prose and nothing else — no card, no icon. It used to sit
* in the same section as the screening summary, which put a person's account of
* their career and a model's account of it side by side as if they were the same
* kind of claim.
*/
function Assessment({ candidate }) {
return (
<p className="max-w-prose text-body leading-relaxed text-ink-2">
{candidate.professional_summary}
</p>
);
}
/**
* What the screening concluded — summary, strengths, gaps.
*
* Held back until after the candidate's own record has been read. It is the
* page's interpretation, and it should not be the first thing that speaks.
*/
function AiInsight({ candidate }) {
const c = candidate;
return (
<>
{c.ai_summary && (
<div className="rounded-xl border border-border bg-surface px-4 py-3.5">
<div className="flex items-center gap-2">
<span className="grid h-6 w-6 shrink-0 place-items-center rounded-md bg-krow-blue text-white">
<Sparkles className="h-3 w-3" aria-hidden="true" />
</span>
<p className="text-[10px] font-semibold uppercase tracking-[0.08em] text-ink-4">
KROW screening summary
</p>
</div>
<p className="mt-2 max-w-prose text-body leading-relaxed text-ink-2">{c.ai_summary}</p>
</div>
)}
{(c.ai_strengths?.length > 0 || c.ai_gaps?.length > 0) && (
<div className={cn('grid gap-6 sm:grid-cols-2', c.ai_summary && 'mt-5')}>
{c.ai_strengths?.length > 0 && (
<div>
<SubLabel>Strengths</SubLabel>
<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>
<SubLabel>{c.ai_gaps.length === 1 ? 'Gap' : 'Gaps'}</SubLabel>
<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>
)}
</>
);
}
/**
* Work profile and interview history — what this person has actually done.
*
* A capability strip: four facts of equal weight. The figures were set two sizes
* larger than the words beside them, which made "4" the loudest thing in a
* section whose point is the combination.
*/
function Experience({ candidate, interview }) {
const c = candidate;
const cells = [
{ label: 'Years in role', value: c.years_experience ? `${c.years_experience} years` : '—' },
{
label: 'English',
value: c.english_level || '—',
capitalize: true,
note: c.score_breakdown?.english > 0 ? `Scored ${c.score_breakdown.english}` : null,
},
{ label: 'Communication', value: communicationLabel(c.score_breakdown?.communication_style) || '—' },
{
label: 'Availability',
value: c.availability?.length ? c.availability.join(', ') : 'Not set',
muted: !c.availability?.length,
icon: c.availability?.length ? Calendar : null,
},
];
return (
<>
<div className="grid overflow-hidden rounded-xl border border-border bg-surface sm:grid-cols-2 lg:grid-cols-4">
{cells.map((cell, i) => (
<div
key={cell.label}
className={cn(
'min-w-0 px-4 py-3',
i < cells.length - 1 && 'border-b border-border sm:border-b-0 sm:border-r',
i === 1 && 'sm:border-r-0 lg:border-r',
i < 2 && 'sm:border-b lg:border-b-0'
)}
>
<p
className={cn(
'flex items-start gap-1.5 text-body-lg font-semibold leading-snug',
cell.muted ? 'text-ink-4' : 'text-ink-1',
cell.capitalize && 'capitalize'
)}
>
{cell.icon && <cell.icon className="mt-1 h-3.5 w-3.5 shrink-0 text-ink-4" aria-hidden="true" />}
{cell.value}
</p>
<p className="mt-0.5 text-[10px] font-medium uppercase tracking-wide text-ink-4">
{cell.label}
{cell.note && <span className="normal-case tracking-normal text-ink-4"> · {cell.note}</span>}
</p>
</div>
))}
</div>
{interview && (
<div className="mt-4 rounded-xl border border-border bg-surface px-4 py-3.5">
<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
? `Interview scored ${interview.overall_interview_score}/100`
: 'Interview 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-2.5 max-w-prose text-body-sm leading-relaxed text-ink-2">
{interview.summary}
</p>
)}
</div>
)}
</>
);
}
/**
* Skills and credentials.
*
* What the candidate states. What KROW has verified is the next step of the
* journey, kept separate on purpose: one is a claim and the other is evidence,
* and a page that blends them makes the weaker one look stronger.
*/
function SkillsAndCerts({ candidate }) {
const c = candidate;
return (
<div className="grid overflow-hidden rounded-xl border border-border bg-surface sm:grid-cols-2 sm:divide-x divide-y divide-border sm:divide-y-0">
<div className="px-4 py-3.5">
<SubLabel>Skills {c.skills?.length ? `· ${c.skills.length}` : ''}</SubLabel>
{c.skills?.length ? (
<div className="flex flex-wrap gap-1.5">
{c.skills.map((s, i) => (
<Pill key={s} index={i} className="rounded-md bg-surface-sunken px-2.5 py-1 text-body-sm text-ink-2">
{s}
</Pill>
))}
</div>
) : (
<p className="text-body-sm text-ink-4">No skills recorded.</p>
)}
</div>
<div className="px-4 py-3.5">
<SubLabel>Certifications {c.certifications?.length ? `· ${c.certifications.length}` : ''}</SubLabel>
{c.certifications?.length ? (
<div className="flex flex-wrap gap-1.5">
{c.certifications.map((s, i) => (
<Pill
key={s}
index={i}
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}
</Pill>
))}
</div>
) : (
<p className="text-body-sm text-ink-4">None on file.</p>
)}
</div>
</div>
);
}
/** The endorsement feed. Three to start, the rest on request. */
function Endorsements({ candidate, voices }) {
const [expanded, setExpanded] = useState(false);
const shown = expanded ? voices : voices.slice(0, 3);
return (
<>
<ul className="divide-y divide-border overflow-hidden rounded-xl border border-border bg-surface">
{shown.map((v) => (
<li key={v.id} className="px-4 py-3.5">
<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-2.5 flex flex-wrap items-center gap-2">
<Button
variant="outline"
size="xs"
shape="rounded"
className={PRESSABLE}
onClick={() => toast.success(`Reference requested from ${v.name}`)}
>
Request Reference
</Button>
<Button
variant="outline"
size="xs"
shape="rounded"
className={PRESSABLE}
onClick={() => toast.success(`Skill endorsement recorded for ${candidate.applicant_name}`)}
>
Endorse Skill
</Button>
</div>
</div>
</div>
</li>
))}
</ul>
{!expanded && voices.length > shown.length && (
<button
type="button"
onClick={() => setExpanded(true)}
className="mt-3 inline-flex items-center gap-1 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"
>
Show all {voices.length} endorsements
<ChevronDown className="h-3 w-3" aria-hidden="true" />
</button>
)}
</>
);
}
/** The candidate's history as a timeline. */
function Activity({ events }) {
return (
<ol className="relative space-y-0">
{events.map((e, i) => (
<li key={i} className="flex gap-3.5 pb-4 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>
);
}
/** Where the page lands: the verdict, and where this candidate now stands. */
function Recommendation({ candidate, verdict }) {
const c = candidate;
return (
<div className="rounded-xl border border-border bg-surface px-4 py-3.5">
<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>
{/* A div, not a <p>: StatusBadge renders a block element, and a badge
inside a paragraph is invalid markup React warns about. */}
<div className="mt-2 flex flex-wrap items-center gap-2 text-body-sm text-ink-3">
<span>Current stage:</span>
<StatusBadge status={c.status} size="sm" />
{c.ai_recommendation && <span>· KROW recommends {c.ai_recommendation}</span>}
</div>
<p className="mt-2 text-caption leading-relaxed text-ink-4">
KROW recommends; the hiring decision is yours.
</p>
</div>
);
}
/* ── Page ───────────────────────────────────────────────────────────────── */
export default function CandidateProfile() {
const { id } = useParams();
const [params] = useSearchParams();
const navigate = useNavigate();
const { data: applications = [], isLoading } = useApplications();
const { data: postings = [] } = useJobPostings();
const { data: interviews = [] } = useInterviews();
/* The workforce and the training library, for the verified-skill panel and for
scoring against a position: a candidate's verified capability lives on the
worker profile behind the application, not on the application itself. */
const { data: workerProfiles = [] } = useWorkerProfiles();
const { data: courses = [] } = useCourses();
const { data: assignments = [] } = useAssignments();
const { data: staff = [] } = useStaff();
const updateApp = useUpdateApplication();
const hire = useHireCandidate();
const screen = useScreenCandidate();
/* What Forge has attached to the Candidates page. Nothing here names a skill —
a path authored tomorrow with `pages: candidates` appears without this page
changing. */
const pageSkills = usePageSkills('candidates');
const [scheduleOpen, setScheduleOpen] = useState(false);
const [aiInterviewOpen, setAiInterviewOpen] = useState(false);
const [messageOpen, setMessageOpen] = useState(false);
const [declineOpen, setDeclineOpen] = useState(false);
/* The candidate record is the identity — never a name, never a worker profile. */
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]
);
const workerProfile = useMemo(
() => (candidate ? profileForEmail(workerProfiles, candidate.email) : null),
[workerProfiles, candidate]
);
/* Position context. Owliver and a position's matched list both link here with
`?for=<positionId>`; the same person read against a different role gets that
role's match, not a rewritten identity. */
const forId = params.get('for');
const forPosition = useMemo(
() => (forId ? postings.find((p) => p.id === forId) : null),
[postings, forId]
);
const differentRole = Boolean(forPosition && forPosition.id !== candidate?.job_posting_id);
const positionRow = useMemo(() => {
if (!forPosition || !candidate) return null;
return poolFor(forPosition, { profiles: workerProfiles, applications, assignments, courses, staff })
.find((r) => r.candidateId === candidate.id) || null;
}, [forPosition, candidate, workerProfiles, applications, assignments, courses, staff]);
const voices = useMemo(() => (candidate ? endorsements(candidate) : []), [candidate]);
const events = useMemo(
() => (candidate ? activityTimeline(candidate, interview) : []),
[candidate, interview]
);
if (isLoading) {
return (
<div className={cn(SHELL, 'py-8')}>
<div className="flex items-center gap-4">
<div className="h-16 w-16 animate-pulse rounded-full bg-surface-sunken" />
<div className="space-y-2">
<div className="h-6 w-56 animate-pulse rounded bg-surface-sunken" />
<div className="h-4 w-72 animate-pulse rounded bg-surface-sunken" />
</div>
</div>
</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);
/* The list page's handlers, on the profile. Nothing here is a toast pretending
to be an action. */
const shortlist = async () => {
try {
await updateApp.mutateAsync({ id: candidate.id, data: { status: 'shortlisted' } });
toast.success(`${candidate.applicant_name} shortlisted`);
} catch {
toast.error('Failed to shortlist candidate');
}
};
const decline = async () => {
try {
await updateApp.mutateAsync({ id: candidate.id, data: { status: 'rejected' } });
toast.success(`${candidate.applicant_name} declined`);
setDeclineOpen(false);
} catch {
toast.error('Failed to decline candidate');
}
};
const hireCandidate = async () => {
try {
await hire.mutateAsync({ application: candidate, job });
toast.success(`${candidate.applicant_name} hired`);
} catch {
toast.error('Failed to hire candidate');
}
};
const runScreening = async () => {
try {
await screen.mutateAsync({ application: candidate, job: job || forPosition });
toast.success(`${candidate.applicant_name} screened`);
} catch {
toast.error('Screening failed');
}
};
/* Interviewing candidates get the AI interview; everyone else gets scheduling —
the same branch the Candidates list makes. */
const openInterview = () =>
candidate.status === 'interview' ? setAiInterviewOpen(true) : setScheduleOpen(true);
/**
* The journey, in order.
*
* The order is the order a hiring decision is actually made, and it separates
* two questions the page used to blur:
*
* 1. Is this person any good? → workforce reputation, career score first
* 2. Are they right for this role? → match analysis, this position only
* 3. What have they actually done? → assessment, experience, skills, training
* 4. What does KROW conclude? → screening summary, strengths, gaps, verdict
* 5. Who vouches, what happened? → endorsements, activity
*
* The screening's own reading of the candidate comes after the candidate's
* record rather than before it: it is an interpretation, and an interpretation
* that speaks first sets the frame for everything read after it.
*
* A section with nothing behind it is not a step — an empty node on the rail
* is the page reporting its own gaps as progress. Filtering here is also what
* keeps the node indices contiguous.
*/
const steps = [
{
label: 'Workforce reputation',
/* Every figure derives from `ai_score`, `score_breakdown` and
`client_rating`. An unscreened candidate has none of them, and a career
score invented for the sake of the layout would be the one number on
this page nobody could trace to the record. */
show: candidate.ai_score > 0,
render: () => <Reputation candidate={candidate} />,
},
{
label: 'Match analysis',
/* The overall score is the rail's anchor and stays visible while this
section is read, so the heading counts signals rather than repeating it. */
meta: `${ALL_DIMENSIONS.length} signals`,
show: true,
render: () => (
<MatchAnalysis
candidate={candidate}
positionRow={positionRow}
position={differentRole ? forPosition : null}
/>
),
},
{
label: 'Assessment',
show: Boolean(candidate.professional_summary),
render: () => <Assessment candidate={candidate} />,
},
{
label: 'Experience',
show: true,
render: () => <Experience candidate={candidate} interview={interview} />,
},
{
label: 'Skills & certifications',
show: true,
render: () => <SkillsAndCerts candidate={candidate} />,
},
{
/* Enrichment, and the strongest evidence on the page — shown when Forge has
skills attached to this page *and* the person has a worker profile behind
them. Most candidates do not, and a heading over "no verified training to
show" is a section reporting its own absence. */
label: 'Verified through KROW training',
show: pageSkills.skills.length > 0 && Boolean(workerProfile),
render: () => (
<VerifiedSkills
pageId="candidates"
profile={workerProfile}
courses={courses}
positions={postings}
customSources={pageSkills.customSources}
/>
),
},
{
label: 'KROW screening insight',
show: Boolean(
candidate.ai_summary || candidate.ai_strengths?.length || candidate.ai_gaps?.length
),
render: () => <AiInsight candidate={candidate} />,
},
{
label: 'Recommendation',
show: candidate.ai_score > 0,
render: () => <Recommendation candidate={candidate} verdict={verdict} />,
},
{
label: 'Endorsements',
meta: voices.length ? `${voices.length} total` : undefined,
show: voices.length > 0,
render: () => <Endorsements candidate={candidate} voices={voices} />,
},
{
label: 'Activity',
meta: events.length ? `${events.length} events` : undefined,
show: events.length > 0,
render: () => <Activity events={events} />,
},
].filter((s) => s.show);
return (
<div className="-mx-4 -my-6 lg:-mx-6">
<Hero
candidate={candidate}
tier={tier}
viewingFor={differentRole ? forPosition : null}
/* Back to Positions, not to the Candidates list. A candidate is read
against a role, and the decision this page leads to — shortlist,
interview, hire — is made on the position it belongs to. Returning to
an undifferentiated list of every applicant drops that thread. */
onBack={() => navigate('/admin/positions')}
/>
{/* One page, two columns. 75/25 rather than a fixed sidebar width: the rail
holds a score and six facts, and on a wide monitor it should grow with
the page instead of leaving a channel of empty blue beside it. The gap
is 24px — close enough that the panel reads as part of the profile
rather than a second page parked beside it. */}
<div className={cn(SHELL, 'py-6')}>
<div className="flex flex-col gap-6 lg:grid lg:grid-cols-[minmax(0,75fr)_minmax(280px,25fr)] lg:items-start lg:gap-6">
<Journey className="order-2 min-w-0 lg:order-1">
{steps.map((step, i) => (
<JourneyStep
key={step.label}
index={i}
label={step.label}
meta={step.meta}
last={i === steps.length - 1}
>
{step.render()}
</JourneyStep>
))}
{/* Per-candidate extension point. A skill declaring `candidates`
with `after-candidate-summary` renders against this record; with
none registered it renders nothing. */}
<SkillSurface
page="candidates"
placement="after-candidate-summary"
context={{ candidate }}
className="mt-6"
/>
</Journey>
{/* `contents` below lg: the two rail cards join the page's own flex
flow so they can be ordered independently. The score and its actions
stay at the top where a phone reader wants them; the position card
drops below the profile, so the career score is the first thing
under the fold rather than the third card down. From lg the aside is
a real box again and both sit in the sticky rail. */}
<aside className="contents lg:sticky lg:top-20 lg:order-2 lg:block lg:w-full lg:space-y-4">
<div className="order-1 lg:order-none">
<ScoreCard
candidate={candidate}
onShortlist={shortlist}
onInterview={openInterview}
onMessage={() => setMessageOpen(true)}
onHire={hireCandidate}
onDecline={() => setDeclineOpen(true)}
onScreen={runScreening}
screening={screen.isPending}
busy={updateApp.isPending || hire.isPending}
/>
</div>
<div className="order-3 lg:order-none">
<SnapshotCard candidate={candidate} job={job || forPosition} />
</div>
</aside>
</div>
</div>
{scheduleOpen && (
<ScheduleInterviewModal open onClose={() => setScheduleOpen(false)} application={candidate} />
)}
{aiInterviewOpen && (
<AIInterviewModal
open
onClose={() => setAiInterviewOpen(false)}
application={candidate}
job={job}
/>
)}
{messageOpen && (
<MessageCandidateModal open onClose={() => setMessageOpen(false)} application={candidate} />
)}
<ConfirmModal
open={declineOpen}
onOpenChange={setDeclineOpen}
title={`Decline ${candidate.applicant_name}?`}
description="They move to the declined stage. This can be changed later from the candidate list."
confirmLabel="Decline candidate"
onConfirm={decline}
busy={updateApp.isPending}
/>
</div>
);
}