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