1381 lines
55 KiB
JavaScript
1381 lines
55 KiB
JavaScript
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>
|
||
);
|
||
}
|