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 `
`, 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 `
` 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 (

{children}

); } /** One score line. Label, bar, figure — readable down a column. */ function Signal({ label, value }) { const missing = value == null || value === 0; return (
{label} {missing ? '—' : value}
); } /** 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 (

{label}

{value} {suffix && {suffix}}

); } function Stars({ count = 5 }) { return ( {Array.from({ length: 5 }).map((_, i) => ( ); } /** One label/value row in the snapshot. */ function Fact({ label, children }) { return (
{label} {children}
); } /** * 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 ( {children} ); } /* ── 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 (

{c.applicant_name}

{c.ai_match_label && {c.ai_match_label}} {scored && {tier.label}}

{c.job_title || 'No position on file'} · {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`}

{c.email && ( )} {c.phone && ( )}
{/* 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 && (

Viewing for {viewingFor.title}

)}
); } /* ── 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 (
{scored ? (

KROW score

{shownScore} /100

{/* Never the bare number: a score without its band is a figure nobody can act on. */}

{c.ai_match_label || 'Match score'} · {careerTier(c).label}

) : (

Not screened yet

Screening scores {c.applicant_name.split(' ')[0]} against the role across{' '} {ALL_DIMENSIONS.length} dimensions and writes the analysis on this page.

)}
{!settled && ( )}
); } /** * 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 (

Position

{c.job_title || job?.title || '—'} {job?.location && {job.location}} {job?.pay_range_max > 0 && ( ${job.pay_range_min}–${job.pay_range_max}/hr )} {job?.employment_type && ( {String(job.employment_type).replace(/_/g, ' ')} )} {applied && ( {applied.toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' })} )}
); } /* ── 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 ( <>
{/* The anchor. Tinted rather than larger: the analytical surface marks it as the headline without a number so big it unbalances the row. */}

Career score

{score} /{CAREER_MAX}

{tier.label}
{CAREER_MIN} {CAREER_MAX}

Potential market value

{marketValue(c)}

Derived from the career score

Next promotion

{nextPromotion(c)}

Tier this candidate tracks toward

{metrics.length > 0 && (
{metrics.map((m) => ( ))}
)}
{c.companies_worked?.length > 0 && (
Worked with
{c.companies_worked.map((company, i) => ( {company} ))}
)} {tags.length > 0 && (
Verified strengths · {tags.length}
{tags.map((t, i) => ( ))}
)} ); } /** * 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 (

Match for {position.title}

{shown}%

{match.band} {row.basis === 'verified' ? ' · scored on verified training' : " · scored on this role's stated requirements"}

    {match.lines.map((line) => { const Icon = line.met ? Check : line.close ? Minus : X; return (
  • ); })}
{row.availability?.known === false && (

Availability not on file for this person.

)}
); } /** 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 && } {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. */}
{groups.map((group) => (

{group.label}

{group.description}

{group.dimensions.map((d) => ( ))}
))}
{hidden > 0 && ( )} ) : (

Screening scores appear here once it runs — skills, experience, reliability and role fit, each rated against this position.

)} ); } /** * 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 (

{candidate.professional_summary}

); } /** * 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 && (

KROW screening summary

{c.ai_summary}

)} {(c.ai_strengths?.length > 0 || c.ai_gaps?.length > 0) && (
{c.ai_strengths?.length > 0 && (
Strengths
    {c.ai_strengths.map((s) => (
  • ))}
)} {c.ai_gaps?.length > 0 && (
{c.ai_gaps.length === 1 ? 'Gap' : 'Gaps'}
    {c.ai_gaps.map((s) => (
  • {/* A neutral dot, not a red alert. A missing qualification is information for the reader, not an error the page caught. */}
  • ))}

Noted for context, not a disqualification.

)}
)} ); } /** * 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 ( <>
{cells.map((cell, i) => (

{cell.icon &&

{cell.label} {cell.note && · {cell.note}}

))}
{interview && (

{interview.overall_interview_score > 0 ? `Interview scored ${interview.overall_interview_score}/100` : 'Interview started, not scored'}

Integrity {interview.integrity_score ?? 100}/100

{interview.summary && (

{interview.summary}

)}
)} ); } /** * 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 (
Skills {c.skills?.length ? `· ${c.skills.length}` : ''} {c.skills?.length ? (
{c.skills.map((s, i) => ( {s} ))}
) : (

No skills recorded.

)}
Certifications {c.certifications?.length ? `· ${c.certifications.length}` : ''} {c.certifications?.length ? (
{c.certifications.map((s, i) => ( ))}
) : (

None on file.

)}
); } /** 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 ( <>
    {shown.map((v) => (
  • {v.name}

    {v.role} · {v.events} events {v.company && v.company !== 'Verified Reference' && ` · ${v.company}`}

  • ))}
{!expanded && voices.length > shown.length && ( )} ); } /** The candidate's history as a timeline. */ function Activity({ events }) { return (
    {events.map((e, i) => (
  1. {e.label}

    {e.date && ( )}
    {e.detail &&

    {e.detail}

    }
  2. ))}
); } /** Where the page lands: the verdict, and where this candidate now stands. */ function Recommendation({ candidate, verdict }) { const c = candidate; return (

{verdict.verdict} · {verdict.action}

{/* A div, not a

: StatusBadge renders a block element, and a badge inside a paragraph is invalid markup React warns about. */}

Current stage: {c.ai_recommendation && · KROW recommends {c.ai_recommendation}}

KROW recommends; the hiring decision is yours.

); } /* ── 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=`; 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 (
); } if (!candidate) { return (
navigate('/admin/candidates')}> Back to candidates } />
); } 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: () => , }, { 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: () => ( ), }, { label: 'Assessment', show: Boolean(candidate.professional_summary), render: () => , }, { label: 'Experience', show: true, render: () => , }, { label: 'Skills & certifications', show: true, render: () => , }, { /* 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: () => ( ), }, { label: 'KROW screening insight', show: Boolean( candidate.ai_summary || candidate.ai_strengths?.length || candidate.ai_gaps?.length ), render: () => , }, { label: 'Recommendation', show: candidate.ai_score > 0, render: () => , }, { label: 'Endorsements', meta: voices.length ? `${voices.length} total` : undefined, show: voices.length > 0, render: () => , }, { label: 'Activity', meta: events.length ? `${events.length} events` : undefined, show: events.length > 0, render: () => , }, ].filter((s) => s.show); return (
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. */}
{steps.map((step, i) => ( {step.render()} ))} {/* Per-candidate extension point. A skill declaring `candidates` with `after-candidate-summary` renders against this record; with none registered it renders nothing. */} {/* `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. */}
{scheduleOpen && ( setScheduleOpen(false)} application={candidate} /> )} {aiInterviewOpen && ( setAiInterviewOpen(false)} application={candidate} job={job} /> )} {messageOpen && ( setMessageOpen(false)} application={candidate} /> )}
); }