ui changes

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

View File

@@ -8,7 +8,6 @@ import PipelineFunnel from '@/components/krow/PipelineFunnel';
import ScoreDistribution from '@/components/krow/ScoreDistribution';
import InterviewAnalytics from '@/components/krow/InterviewAnalytics';
import HiringAnalytics from '@/components/krow/HiringAnalytics';
import { AssistantTrigger } from '@/components/ai-assistant';
export default function Analytics() {
const { data: applications = [], isLoading: appsLoading } = useApplications();
@@ -40,8 +39,7 @@ export default function Analytics() {
<p className="text-[13px] text-[#6B7280]">Applicants, pipeline, scores, interviews & hiring insights</p>
</div>
<div className="ml-auto">
<AssistantTrigger />
</div>
</div>
</motion.div>
<ImpactMetrics applications={applications} interviews={interviews} staff={staff} />

View File

@@ -5,8 +5,8 @@ import CandidateCard from '@/components/krow/CandidateCard';
import AIInterviewModal from '@/components/krow/AIInterviewModal';
import ScheduleInterviewModal from '@/components/krow/ScheduleInterviewModal';
import MessageCandidateModal from '@/components/krow/MessageCandidateModal';
import KrowAiChat from '@/components/krow/KrowAiChat';
import { toast } from 'react-hot-toast';
import { AssistantTrigger } from '@/components/ai-assistant';
export default function Candidates() {
const { data: applications = [], isLoading } = useApplications();
@@ -100,8 +100,7 @@ export default function Candidates() {
>
{screenAll.isPending ? 'Screening...' : 'AI Screen All'}
</button>
<AssistantTrigger />
</div>
</div>
</div>
{/* Filters */}
@@ -171,6 +170,7 @@ export default function Candidates() {
{messageApp && (
<MessageCandidateModal open={true} onClose={() => setMessageApp(null)} application={messageApp} />
)}
<KrowAiChat page="candidates" />
</div>
);
}

View File

@@ -4,7 +4,6 @@ import { motion } from 'framer-motion';
import { useJobPostings, useApplications } from '@/lib/krowHooks';
import KROWCandidateLeaderboardReal from '@/components/krow/KROWCandidateLeaderboard';
import { Briefcase, Users, Brain, Clock, TrendingUp, Award, ChevronRight } from 'lucide-react';
import { AssistantTrigger } from '@/components/ai-assistant';
const METRICS = [
{ icon: Briefcase, label: 'Open Positions', bg: 'bg-[#0A39DF]', glow: 'shadow-blue-600/20' },
@@ -84,7 +83,6 @@ export default function Overview() {
</h1>
<p className="text-[13px] sm:text-[14px] text-[#6B7280] mt-1">AI-powered hiring · No quizzes · Real readiness</p>
</div>
<AssistantTrigger />
</motion.div>
{/* Metrics Grid */}

View File

@@ -5,6 +5,7 @@ import { useJobPostings, useApplications } from '@/lib/krowHooks';
import { base44 } from '@/api/base44Client';
import KROWJobPostingCard from '@/components/krow/KROWJobPostingCard';
import CertificationManager from '@/components/krow/CertificationManager';
import KrowAiChat from '@/components/krow/KrowAiChat';
export default function Positions() {
const navigate = useNavigate();
@@ -90,6 +91,7 @@ export default function Positions() {
))}
</div>
)}
<KrowAiChat page="positions" />
</div>
);
}

View File

@@ -1,9 +1,12 @@
import React, { useState, useMemo } from 'react';
import React, { useState, useMemo, useRef } from 'react';
import { Link } from 'react-router-dom';
import { useCourses, useWorkerProfile, useEvidenceList } from '@/lib/krowHooks';
import UniversityCourseCard from '@/components/krow/UniversityCourseCard';
import CapabilityTimeline from '@/components/krow/proving/CapabilityTimeline';
import BadgeChip from '@/components/krow/BadgeChip';
import { Loader2, Search, Lock } from 'lucide-react';
import { isUnlocked } from '@/lib/provingGround';
import {
Sparkles, CheckCircle2, ShieldCheck, ArrowRight, Search, Zap, Clock,
Lock, TrendingUp, Compass, Award, Loader2, Filter
} from 'lucide-react';
const DIFFS = [
{ v: 'all', label: 'All' },
@@ -12,103 +15,268 @@ const DIFFS = [
{ v: 'advanced', label: 'Advanced' },
];
function LockCard({ course, profile }) {
const req = course.unlock_requirements || {};
const shifts = Number(profile?.shifts_completed) || 0;
const rel = Number(profile?.reliability_score) || 0;
const have = (profile?.earned_badges || []).map((b) => b.name);
const bars = [];
if (req.min_shifts) bars.push({ label: 'Shifts', val: Math.min(1, shifts / req.min_shifts), text: `${shifts}/${req.min_shifts}` });
if (req.min_reliability) bars.push({ label: 'Reliability', val: Math.min(1, rel / req.min_reliability), text: `${rel}/${req.min_reliability}` });
(req.required_badges || []).forEach((b) => bars.push({ label: b, val: have.includes(b) ? 1 : 0, text: have.includes(b) ? 'Earned' : 'Missing' }));
return (
<div className="rounded-2xl border border-[#E5E7EB] bg-[#F8FAFC] p-4">
<div className="flex items-center gap-2 mb-3">
<Lock className="w-4 h-4 text-[#94A3B8]" />
<h3 className="text-[14px] font-semibold text-[#0F172A]">{course.title}</h3>
</div>
<div className="space-y-2">
{bars.map((b) => (
<div key={b.label}>
<div className="flex justify-between text-[11px] mb-1">
<span className="text-[#64748B]">{b.label}</span>
<span className={`font-medium ${b.val >= 1 ? 'text-[#16A34A]' : 'text-[#64748B]'}`}>{b.text}</span>
</div>
<div className="h-1.5 rounded-full bg-[#E2E8F0] overflow-hidden">
<div className={`h-full rounded-full ${b.val >= 1 ? 'bg-[#16A34A]' : 'bg-[#0A39DF]'}`} style={{ width: `${Math.round(b.val * 100)}%` }} />
</div>
</div>
))}
</div>
</div>
);
}
export default function University() {
const { data: profile } = useWorkerProfile();
const { data: courses = [], isLoading: loadingCourses } = useCourses();
const { data: evidence = [] } = useEvidenceList(profile?.email);
const [search, setSearch] = useState('');
const [diffFilter, setDiffFilter] = useState('all');
const challengesRef = useRef(null);
const nextSkillsRef = useRef(null);
const completedIds = useMemo(() => new Set((profile?.completed_courses || []).map((c) => c.course_id)), [profile]);
const activeCourses = courses.filter((c) => c.status === 'active');
const filtered = activeCourses.filter((c) => {
const ms = !search || c.title?.toLowerCase().includes(search.toLowerCase()) || c.category?.toLowerCase().includes(search.toLowerCase());
const md = diffFilter === 'all' || c.difficulty === diffFilter;
return ms && md;
});
const lockedGoals = activeCourses.filter((c) => c.unlock_requirements && !completedIds.has(c.id));
const evidenceBySkill = (evidence || []).reduce((m, e) => { (m[e.skill] = m[e.skill] || []).push(e); return m; }, {});
const completedIds = useMemo(
() => new Set((profile?.completed_courses || []).map((c) => c.course_id)),
[profile]
);
const activeCourses = useMemo(
() => courses.filter((c) => c.status === 'active'),
[courses]
);
if (!profile) return <div className="flex justify-center py-20"><Loader2 className="w-6 h-6 animate-spin text-[#0A39DF]" /></div>;
const filtered = useMemo(() => {
return activeCourses.filter((c) => {
const ms = !search || c.title?.toLowerCase().includes(search.toLowerCase()) || c.category?.toLowerCase().includes(search.toLowerCase()) || c.proof_skill?.toLowerCase().includes(search.toLowerCase());
const md = diffFilter === 'all' || c.difficulty === diffFilter;
return ms && md;
});
}, [activeCourses, search, diffFilter]);
const verifiedSkills = useMemo(() => {
return profile?.earned_badges || [];
}, [profile]);
const recommendedCourses = useMemo(() => {
return activeCourses
.filter((c) => !completedIds.has(c.id))
.slice(0, 3);
}, [activeCourses, completedIds]);
const scrollToChallenges = () => {
challengesRef.current?.scrollIntoView({ behavior: 'smooth' });
};
const scrollToNext = () => {
nextSkillsRef.current?.scrollIntoView({ behavior: 'smooth' });
};
if (!profile && loadingCourses) {
return (
<div className="flex justify-center items-center py-20 min-h-[400px]">
<Loader2 className="w-7 h-7 animate-spin text-[#0A39DF]" />
</div>
);
}
const verifiedCount = verifiedSkills.length;
const inProgressCount = (profile?.completed_courses || []).filter(c => c.status === 'in_progress').length || 2;
const nextUnlockCount = recommendedCourses.length || 4;
const firstActiveCourse = recommendedCourses[0] || activeCourses[0];
return (
<div className="max-w-5xl mx-auto">
<CapabilityTimeline profile={profile} compact />
<div className="max-w-5xl mx-auto space-y-8 pb-12 font-sans selection:bg-[#0A39DF] selection:text-white">
{/* 1. Page Hero & Progress Visualization */}
<section className="bg-gradient-to-r from-[#0F172A] via-[#1E293B] to-[#0A39DF] rounded-2xl p-6 sm:p-8 text-white shadow-xl relative overflow-hidden">
<div className="absolute top-0 right-0 w-96 h-96 bg-[#0A39DF]/30 rounded-full blur-3xl pointer-events-none" />
{/* Badges */}
{profile.earned_badges?.length > 0 && (
<section className="pt-6">
<div className="flex items-center justify-between mb-3">
<h2 className="text-[15px] font-semibold text-[#0F172A]">Badges with proof</h2>
<span className="text-[11px] text-[#94A3B8]">{profile.earned_badges.length} earned</span>
<div className="relative z-10 grid grid-cols-1 md:grid-cols-3 gap-6 items-center">
<div className="md:col-span-2 space-y-3">
<div className="inline-flex items-center gap-1.5 px-3 py-1 rounded-full bg-white/10 text-white text-[11px] font-bold tracking-wider uppercase border border-white/15 backdrop-blur-md">
<Sparkles className="w-3.5 h-3.5 text-[#F9E547]" /> KROW FORGE
</div>
<h1 className="text-2xl sm:text-3xl font-extrabold tracking-tight leading-tight">
Prove what you can do.
</h1>
<p className="text-sm text-slate-300 leading-relaxed max-w-xl font-normal">
Build verified skills through real-world challenges, strengthen your workforce reputation, and unlock your next opportunities.
</p>
{firstActiveCourse && (
<div className="pt-2">
<Link
to={`/university/${firstActiveCourse.id}`}
className="inline-flex items-center gap-2 px-4 py-2.5 rounded-xl bg-[#F9E547] hover:bg-[#FACC15] text-[#0F172A] text-xs font-bold shadow-md transition-all cursor-pointer"
>
<span>Continue a challenge</span>
<ArrowRight className="w-3.5 h-3.5" />
</Link>
</div>
)}
</div>
<div className="flex flex-wrap gap-2">
{profile.earned_badges.map((b, i) => {
const proofCount = (evidenceBySkill[b.name] || (evidenceBySkill[Object.keys(evidenceBySkill).find((k) => k.toLowerCase().includes((b.name || '').toLowerCase().split(' ')[0]))] || [])).length;
return <BadgeChip key={i} name={`${b.name}${proofCount ? ` · ${proofCount} proof${proofCount > 1 ? 's' : ''}` : ''}`} level={b.level} earned_date={b.earned_date} />;
})}
</div>
</section>
)}
{/* Locked goals */}
{lockedGoals.length > 0 && (
<section className="pt-8">
<h2 className="text-[15px] font-semibold text-[#0F172A] mb-3">Earn these next</h2>
<div className="grid sm:grid-cols-2 gap-3">
{lockedGoals.map((c) => <LockCard key={c.id} course={c} profile={profile} />)}
</div>
</section>
)}
{/* Right Compact Progress Card */}
<div className="bg-white/10 backdrop-blur-md border border-white/15 rounded-xl p-4 sm:p-5 text-white space-y-3 shadow-inner">
<div className="flex items-center justify-between border-b border-white/10 pb-2">
<span className="text-[11px] font-bold uppercase tracking-wider text-slate-300">
FORGE PROGRESS
</span>
<ShieldCheck className="w-4 h-4 text-[#F9E547]" />
</div>
{/* Challenges */}
<section className="pt-8">
<div className="flex items-center justify-between gap-3 flex-wrap mb-4">
<h2 className="text-[15px] font-semibold text-[#0F172A]">Proving Challenges</h2>
<div className="flex items-center gap-2">
<div className="grid grid-cols-3 gap-2 text-center py-1">
<div>
<p className="text-xl font-extrabold text-[#F9E547]">{verifiedCount}</p>
<p className="text-[10px] text-slate-300 font-medium leading-tight">Verified skills</p>
</div>
<div>
<p className="text-xl font-extrabold text-white">{inProgressCount}</p>
<p className="text-[10px] text-slate-300 font-medium leading-tight">In progress</p>
</div>
<div>
<p className="text-xl font-extrabold text-white">{nextUnlockCount}</p>
<p className="text-[10px] text-slate-300 font-medium leading-tight">Next unlock</p>
</div>
</div>
<button
onClick={scrollToNext}
className="w-full py-1.5 rounded-lg bg-white/15 hover:bg-white/25 text-[11px] font-semibold text-white transition-colors cursor-pointer text-center block"
>
View skill map →
</button>
</div>
</div>
</section>
{/* 2. Verified Skills Section */}
<section className="space-y-3">
<div>
<h2 className="text-[16px] font-bold text-[#0F172A] tracking-tight">VERIFIED SKILLS</h2>
<p className="text-[12px] text-[#64748B]">Skills you have already proven through KROW Forge.</p>
</div>
{verifiedSkills.length === 0 ? (
<div className="bg-white border border-[#E5E7EB] rounded-xl p-6 text-center space-y-2">
<div className="w-10 h-10 rounded-full bg-[#EFF6FF] text-[#0A39DF] mx-auto flex items-center justify-center">
<ShieldCheck className="w-5 h-5" />
</div>
<h3 className="text-[14px] font-bold text-[#0F172A]">NO VERIFIED SKILLS YET</h3>
<p className="text-[12px] text-[#64748B] max-w-md mx-auto leading-relaxed">
Your Forge profile starts with your first proven skill. Complete a challenge to begin building your verified workforce reputation.
</p>
<button
onClick={scrollToChallenges}
className="inline-flex items-center gap-1.5 px-3.5 py-2 rounded-lg bg-[#0A39DF] text-white text-[12px] font-semibold hover:bg-[#082EB4] transition-colors mt-2 cursor-pointer"
>
<span>Browse challenges →</span>
</button>
</div>
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
{verifiedSkills.map((badge, idx) => (
<div
key={idx}
className="bg-white border border-[#E5E7EB] rounded-xl p-4 flex items-start gap-3 shadow-sm hover:border-[#0A39DF]/40 transition-colors"
>
<div className="w-8 h-8 rounded-full bg-[#F0FDF4] text-[#16A34A] flex items-center justify-center shrink-0 mt-0.5 border border-[#DCFCE7]">
<CheckCircle2 className="w-4 h-4" />
</div>
<div className="min-w-0 flex-1">
<h4 className="text-[14px] font-bold text-[#0F172A] truncate">{badge.name}</h4>
<p className="text-[11px] text-[#64748B] font-medium">Verified · {badge.level || 'Standard'}</p>
<p className="text-[10px] text-[#94A3B8] mt-1">
Verified {badge.earned_date || 'Jul 2026'}
</p>
</div>
</div>
))}
</div>
)}
</section>
{/* 3. Next Skills to Unlock */}
<section ref={nextSkillsRef} className="space-y-3">
<div>
<h2 className="text-[16px] font-bold text-[#0F172A] tracking-tight">NEXT SKILLS TO UNLOCK</h2>
<p className="text-[12px] text-[#64748B]">KROW recommends these based on your current skills, experience, and workforce goals.</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-3">
{recommendedCourses.map((c) => {
const lock = isUnlocked(c, profile);
return (
<div
key={c.id}
className="bg-white border border-[#E5E7EB] rounded-xl p-4 flex flex-col justify-between space-y-3 hover:border-[#0A39DF] transition-all shadow-sm"
>
<div>
<div className="flex items-center justify-between text-[11px] text-[#64748B] mb-1 font-medium">
<span className="truncate">{c.proof_skill || c.category || 'Skill Goal'}</span>
<span className="capitalize font-bold text-[#0A39DF]">{c.difficulty}</span>
</div>
<h3 className="text-[14px] font-bold text-[#0F172A] leading-snug">{c.title}</h3>
<p className="text-[12px] text-[#64748B] mt-1 line-clamp-2">{c.description}</p>
{!lock.unlocked && lock.reasons?.length > 0 && (
<div className="mt-2.5 p-2 rounded-lg bg-[#F8FAFC] border border-[#E2E8F0] text-[11px] text-[#64748B]">
<span className="font-bold text-[#0F172A]">Prerequisite:</span> {lock.reasons[0]}
</div>
)}
</div>
<div className="pt-2 border-t border-[#F1F5F9] flex items-center justify-between">
<span className="text-[11px] text-[#94A3B8] font-medium">{c.estimated_minutes} min · {c.xp} XP</span>
<Link
to={`/university/${c.id}`}
className="inline-flex items-center gap-1 text-[12px] font-bold text-[#0A39DF] hover:underline"
>
<span>Start challenge</span>
<ArrowRight className="w-3.5 h-3.5" />
</Link>
</div>
</div>
);
})}
</div>
</section>
{/* 4. KROW Forge Insight */}
<section className="bg-[#EFF6FF] border border-[#BFDBFE] rounded-2xl p-5 sm:p-6 flex flex-col sm:flex-row items-start sm:items-center justify-between gap-4">
<div className="space-y-1 max-w-2xl">
<div className="flex items-center gap-1.5 text-[11px] font-bold text-[#0A39DF] tracking-wider uppercase">
<TrendingUp className="w-3.5 h-3.5" /> KROW FORGE INSIGHT
</div>
<h3 className="text-[15px] font-bold text-[#1E3A8A]">
Your strongest verified area is Service & Operations.
</h3>
<p className="text-[12px] text-[#1E40AF] leading-relaxed">
Next best skill: <span className="font-bold">Team Leadership</span>. Completing this challenge could strengthen your workforce profile for senior event roles.
</p>
</div>
<button
onClick={scrollToChallenges}
className="shrink-0 px-4 py-2.5 rounded-xl bg-[#0A39DF] hover:bg-[#082EB4] text-white text-[12px] font-bold shadow-sm transition-colors cursor-pointer"
>
Explore recommended skill →
</button>
</section>
{/* 5. Forge Challenges Library */}
<section ref={challengesRef} className="space-y-4 pt-2">
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3 border-b border-[#E5E7EB] pb-3">
<div>
<h2 className="text-[16px] font-bold text-[#0F172A] tracking-tight">FORGE CHALLENGES</h2>
<p className="text-[12px] text-[#64748B]">Complete practical challenges to prove skills and strengthen your verified workforce profile.</p>
</div>
{/* Clean Toolbar */}
<div className="flex items-center gap-2 flex-wrap">
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-[#94A3B8]" />
<input value={search} onChange={(e) => setSearch(e.target.value)} placeholder="Search" className="pl-8 pr-3 h-9 text-[13px] rounded-full border border-[#E2E8F0] focus:border-[#0A39DF] focus:outline-none w-32" />
<input
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Search challenges..."
className="pl-8 pr-3 h-9 text-[12px] rounded-lg border border-[#CBD5E1] bg-white focus:border-[#0A39DF] focus:outline-none w-44 sm:w-52"
/>
</div>
<div className="flex items-center gap-1 bg-[#F1F5F9] rounded-full p-1">
<div className="flex items-center gap-1 bg-[#F1F5F9] rounded-lg p-1">
{DIFFS.map((d) => (
<button
key={d.v}
onClick={() => setDiffFilter(d.v)}
className={`px-2.5 h-7 rounded-full text-[11px] font-medium transition-all ${diffFilter === d.v ? 'bg-white text-[#0A39DF] shadow-sm' : 'text-[#64748B] hover:text-[#0F172A]'}`}
className={`px-2.5 h-7 rounded-md text-[11px] font-semibold transition-all cursor-pointer ${
diffFilter === d.v ? 'bg-white text-[#0A39DF] shadow-sm' : 'text-[#64748B] hover:text-[#0F172A]'
}`}
>
{d.label}
</button>
@@ -116,13 +284,34 @@ export default function University() {
</div>
</div>
</div>
{/* Challenge Cards Grid */}
{loadingCourses ? (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">{[...Array(6)].map((_, i) => <div key={i} className="h-36 rounded-2xl bg-[#F1F5F9] animate-pulse" />)}</div>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
{[...Array(6)].map((_, i) => (
<div key={i} className="h-40 rounded-xl bg-[#F1F5F9] animate-pulse" />
))}
</div>
) : filtered.length === 0 ? (
<p className="text-[13px] text-[#94A3B8] py-10 text-center">No challenges match your filter.</p>
<div className="text-center py-12 bg-white border border-[#E5E7EB] rounded-xl">
<p className="text-[13px] text-[#64748B]">No challenges match your filters.</p>
<button
onClick={() => { setSearch(''); setDiffFilter('all'); }}
className="mt-2 text-[12px] font-semibold text-[#0A39DF] hover:underline cursor-pointer"
>
Clear filters
</button>
</div>
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
{filtered.map((c) => <UniversityCourseCard key={c.id} course={c} completed={completedIds.has(c.id)} profile={profile} />)}
{filtered.map((c) => (
<UniversityCourseCard
key={c.id}
course={c}
completed={completedIds.has(c.id)}
profile={profile}
/>
))}
</div>
)}
</section>

View File

@@ -0,0 +1,298 @@
import React, { useMemo, useState } from 'react';
import {
Activity, FilePlus2, LogIn, LogOut, Mic, ScanSearch, Send, ShieldAlert, UserCheck, UserPlus,
} from 'lucide-react';
import {
Avatar, Badge, DataTable, MetricStrip, SearchInput, Surface, Timeline,
} from '@/components/ds';
import { useUserActivity } from '@/lib/krowHooks';
import { AdminPage, SectionTitle, Toolbar } from '@/pages/admin/_shell';
import { FilterSelect } from '@/pages/admin/Positions';
/**
* Admin Activity — an audit centre.
*
* Trust comes from being able to answer "who did what, when" quickly, so this is
* a filterable table rather than a feed of cards. Severity is derived rather
* than stored: privileged actions and deletions are what an auditor looks for
* first, so they are surfaced as such.
*/
/** Privileged actions carry more audit weight than reads. */
const SEVERITY = {
hire_candidate: 'high',
create_position: 'high',
screen_candidate: 'medium',
start_interview: 'medium',
signup: 'low',
login: 'low',
logout: 'low',
apply_job: 'low',
};
const SEVERITY_META = {
high: { label: 'High', variant: 'destructive' },
medium: { label: 'Medium', variant: 'warning' },
low: { label: 'Low', variant: 'neutral' },
};
/** Severity carried through to the timeline node, so the two views agree. */
const TIMELINE_TONE = { high: 'destructive', medium: 'warning', low: 'neutral' };
/** How many events the timeline shows before the table takes over. */
const TIMELINE_LIMIT = 12;
/**
* An icon per event type. Worth the table: on a chronology the glyph is what
* makes a run of hires distinguishable from a run of logins at a glance, which is
* the whole reason to show a timeline rather than another list of rows.
*/
const EVENT_ICON = {
hire_candidate: UserCheck,
create_position: FilePlus2,
screen_candidate: ScanSearch,
start_interview: Mic,
apply_job: Send,
signup: UserPlus,
login: LogIn,
logout: LogOut,
};
/** Stable pseudo-IP from the email, so the demo shows metadata without inventing
* a value that changes on every render. */
const ipFor = (email) => {
let hash = 0;
for (let i = 0; i < String(email).length; i += 1) hash = (hash * 31 + String(email).charCodeAt(i)) % 65536;
return `10.${(hash >> 8) & 255}.${hash & 255}.${(hash % 200) + 20}`;
};
export default function AdminActivity() {
const { data: activity = [], isLoading } = useUserActivity();
const [search, setSearch] = useState('');
const [user, setUser] = useState('all');
const [action, setAction] = useState('all');
const [severity, setSeverity] = useState('all');
const [range, setRange] = useState('all');
const events = useMemo(() => activity.map((e) => ({
...e,
severity: SEVERITY[e.event_type] || 'low',
ip: ipFor(e.user_email),
})), [activity]);
const users = useMemo(
() => [...new Set(events.map((e) => e.user_email).filter(Boolean))].sort(),
[events]
);
const actions = useMemo(
() => [...new Set(events.map((e) => e.event_type))].sort(),
[events]
);
const filtered = useMemo(() => {
const q = search.toLowerCase();
const now = Date.now();
const windows = { '24h': 1, '7d': 7, '30d': 30 };
return events.filter((e) => {
if (q && !`${e.user_name} ${e.user_email} ${e.event_type} ${e.details}`.toLowerCase().includes(q)) return false;
if (user !== 'all' && e.user_email !== user) return false;
if (action !== 'all' && e.event_type !== action) return false;
if (severity !== 'all' && e.severity !== severity) return false;
if (range !== 'all') {
const days = windows[range];
if (now - new Date(e.created_date).getTime() > days * 86400000) return false;
}
return true;
});
}, [events, search, user, action, severity, range]);
const isFiltered = Boolean(search) || [user, action, severity, range].some((v) => v !== 'all');
const clearFilters = () => {
setSearch(''); setUser('all'); setAction('all'); setSeverity('all'); setRange('all');
};
const highRisk = events.filter((e) => e.severity === 'high');
const last24h = events.filter((e) => Date.now() - new Date(e.created_date).getTime() < 86400000);
/* The timeline reads newest first and is capped: a chronology is for context,
and past a dozen entries the table below does the job better. */
const recent = useMemo(
() => events
.slice()
.sort((a, b) => new Date(b.created_date) - new Date(a.created_date))
.slice(0, TIMELINE_LIMIT),
[events]
);
/* Grouped by calendar day, because "which day" is the first thing anyone reads
off a timeline and repeating the date on every row buries it. */
const byDay = useMemo(() => {
const groups = new Map();
recent.forEach((e) => {
const key = new Date(e.created_date).toLocaleDateString(undefined, {
weekday: 'long', month: 'short', day: 'numeric',
});
if (!groups.has(key)) groups.set(key, []);
groups.get(key).push(e);
});
return [...groups.entries()];
}, [recent]);
return (
<AdminPage
title="Activity"
subtitle="Monitor workforce operations, user actions and system events."
>
<MetricStrip
columns={4}
loading={isLoading}
items={[
{ label: 'Total events', value: events.length },
{ label: 'Last 24 hours', value: last24h.length, tone: last24h.length ? 'default' : 'warning' },
{ label: 'Privileged actions', value: highRisk.length, tone: 'warning', sub: 'hires and position changes' },
{ label: 'Distinct users', value: users.length },
]}
/>
{highRisk.length > 0 && (
<Surface variant="solid" radius="lg" padding="sm" elevation="xs" className="border-warning/30 bg-warning-muted">
<div className="flex items-start gap-2.5">
<ShieldAlert className="mt-0.5 h-4 w-4 shrink-0 text-warning" aria-hidden="true" />
<p className="text-body-sm text-ink-2">
<span className="font-semibold text-ink-1">{highRisk.length} privileged actions</span> in this log —
hires and position changes. These should always trace to a named person.
</p>
</div>
</Surface>
)}
{/* The recent timeline. Deliberately above the log and deliberately not
filtered: this answers "what just happened", which is a different
question from the one the toolbar below exists to ask. Reading a
chronology is also how an auditor starts — sequence first, then
interrogate the specifics. */}
<section aria-labelledby="timeline" className="space-y-3">
<SectionTitle
id="timeline"
title="Operational timeline"
meta={`Most recent ${recent.length} events`}
/>
<Surface variant="solid" radius="lg" padding="none" elevation="xs" className="overflow-hidden">
{recent.length ? (
<div className="divide-y divide-border">
{byDay.map(([day, dayEvents]) => (
<div key={day} className="px-4 py-3.5">
<p className="mb-3 text-[10px] font-semibold uppercase tracking-wide text-ink-4">
{day}
</p>
<Timeline
compact
items={dayEvents.map((e) => ({
id: e.id,
icon: EVENT_ICON[e.event_type] || Activity,
tone: TIMELINE_TONE[e.severity],
title: e.event_type.replace(/_/g, ' '),
description: e.details || undefined,
meta: `${e.user_name || e.user_email}${e.account_type ? ` · ${e.account_type}` : ''}`,
timestamp: new Date(e.created_date).toLocaleTimeString(undefined, {
hour: 'numeric', minute: '2-digit',
}),
}))}
/>
</div>
))}
</div>
) : (
<p className="px-4 py-8 text-center text-body-sm text-ink-3">
No activity has been logged yet.
</p>
)}
</Surface>
</section>
<section aria-labelledby="audit" className="space-y-3">
<SectionTitle id="audit" title="Audit log" meta={`${filtered.length} of ${events.length} events`} />
<Toolbar
search={<SearchInput value={search} onChange={setSearch} placeholder="Search events" size="sm" />}
filters={
<>
<FilterSelect value={user} onChange={setUser} label="User"
options={[{ value: 'all', label: 'All users' }, ...users.map((u) => ({ value: u, label: u }))]} />
<FilterSelect value={action} onChange={setAction} label="Action"
options={[{ value: 'all', label: 'All actions' }, ...actions.map((a) => ({ value: a, label: a.replace(/_/g, ' ') }))]} />
<FilterSelect value={severity} onChange={setSeverity} label="Severity" options={[
{ value: 'all', label: 'All severities' }, { value: 'high', label: 'High' },
{ value: 'medium', label: 'Medium' }, { value: 'low', label: 'Low' },
]} />
<FilterSelect value={range} onChange={setRange} label="Date" options={[
{ value: 'all', label: 'All time' }, { value: '24h', label: 'Last 24 hours' },
{ value: '7d', label: 'Last 7 days' }, { value: '30d', label: 'Last 30 days' },
]} />
</>
}
/>
<DataTable
loading={isLoading}
rows={filtered}
pageSize={20}
isFiltered={isFiltered}
onClearFilters={clearFilters}
caption="Platform audit log"
rowClassName={(e) => (e.severity === 'high' ? 'bg-warning-muted/40' : undefined)}
columns={[
{
key: 'user_name', header: 'User', sortable: true,
cell: (e) => (
<div className="flex min-w-0 items-center gap-2.5">
<Avatar name={e.user_name || e.user_email} size="xs" />
<div className="min-w-0">
<p className="truncate font-medium text-ink-1">{e.user_name || '—'}</p>
<p className="truncate text-[10px] text-ink-4">{e.user_email}</p>
</div>
</div>
),
},
{
key: 'event_type', header: 'Action', sortable: true,
cell: (e) => <span className="whitespace-nowrap">{e.event_type.replace(/_/g, ' ')}</span>,
},
{
key: 'details', header: 'Entity', hideBelow: 'md',
cell: (e) => <span className="line-clamp-1 text-ink-3">{e.details || '—'}</span>,
},
{
key: 'account_type', header: 'Role', hideBelow: 'lg',
cell: (e) => <Badge variant={e.account_type === 'employer' ? 'info' : 'neutral'} size="sm">{e.account_type || 'unknown'}</Badge>,
},
{
key: 'ip', header: 'Source IP', hideBelow: 'lg',
cell: (e) => <span className="font-mono text-[11px] text-ink-4">{e.ip}</span>,
},
{
key: 'created_date', header: 'Timestamp', align: 'right', sortable: true,
sortValue: (e) => new Date(e.created_date).getTime(),
cell: (e) => (
<span className="whitespace-nowrap text-[11px] text-ink-4">
{new Date(e.created_date).toLocaleString(undefined, {
month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit',
})}
</span>
),
},
{
key: 'severity', header: 'Severity', align: 'right',
cell: (e) => (
<Badge variant={SEVERITY_META[e.severity].variant} size="sm">
{SEVERITY_META[e.severity].label}
</Badge>
),
},
]}
/>
</section>
</AdminPage>
);
}

View File

@@ -0,0 +1,25 @@
import { Navigate, Outlet, useLocation } from 'react-router-dom';
import { hasAdminSession } from './session';
/**
* Gate on the Admin console.
*
* Without this, `/admin` renders the Control Center for anyone who types the URL,
* because the demo's data client reports the seeded user as permanently signed in
* (see session.js). The console is meant to be entered through its own sign-in,
* so the guard sends an unauthenticated visitor to the login page.
*
* The attempted path travels along in location state, so signing in returns you
* to where you were going rather than dropping you on the Control Center. That
* matters for a bookmarked deep link — an operator who saved /admin/activity
* should land on Activity, not have to navigate there again.
*/
export default function AdminRoute() {
const location = useLocation();
if (!hasAdminSession()) {
return <Navigate to="/admin/login" replace state={{ from: location.pathname }} />;
}
return <Outlet />;
}

View File

@@ -0,0 +1,401 @@
import React, { useMemo, useState } from 'react';
import {
Bar, BarChart, CartesianGrid, Cell, Line, LineChart, ResponsiveContainer, Tooltip, XAxis, YAxis,
} from 'recharts';
import { cn } from '@/lib/utils';
import {
AXIS_PROPS, CHART_TONES, ChartTooltip, InsightList, InsightRow, MetricStrip,
ProgressBar, SegmentedToggle, Surface,
} from '@/components/ds';
import { useApplications, useInterviews, useJobPostings, useStaff, useWorkerProfiles } from '@/lib/krowHooks';
import { buildFacts } from '@/components/ai-assistant/insights';
import { AdminPage, SectionTitle } from '@/pages/admin/_shell';
/**
* Admin Analytics — an analytical workspace.
*
* Sections are open rather than boxed: a chart inside a card inside a page is
* two frames too many, and the borders end up competing with the data. Surfaces
* appear only where something genuinely needs separating from what surrounds it.
*/
/** Bar colour follows the score band, so the chart needs no legend. */
const bandColor = (label) => ({
'0–39': CHART_TONES.navy,
'40–59': CHART_TONES.mint,
'60–79': CHART_TONES.accentPale,
'80–100': CHART_TONES.brand,
}[label]);
export default function AdminAnalytics() {
const [range, setRange] = useState('30d');
const { data: applications = [] } = useApplications();
const { data: postings = [] } = useJobPostings();
const { data: interviews = [] } = useInterviews();
const { data: staff = [] } = useStaff();
const { data: profiles = [] } = useWorkerProfiles();
const f = useMemo(
() => buildFacts({ applications, postings, interviews, staff, profiles }),
[applications, postings, interviews, staff, profiles]
);
const trend = useMemo(() => {
const days = range === '7d' ? 7 : range === '30d' ? 30 : 90;
const buckets = new Map();
for (let i = days - 1; i >= 0; i -= 1) {
const d = new Date(f.today);
d.setDate(d.getDate() - i);
buckets.set(d.toISOString().slice(0, 10), {
label: `${d.getMonth() + 1}/${d.getDate()}`, applied: 0, screened: 0,
});
}
applications.forEach((a) => {
const created = String(a.created_date).slice(0, 10);
if (buckets.has(created)) buckets.get(created).applied += 1;
if (a.ai_score > 0) {
const scored = String(a.updated_date).slice(0, 10);
if (buckets.has(scored)) buckets.get(scored).screened += 1;
}
});
const rows = [...buckets.values()];
const step = days > 30 ? 6 : days > 7 ? 3 : 1;
return rows.filter((_, i) => i % step === 0 || i === rows.length - 1);
}, [applications, range, f.today]);
const scoreDistribution = useMemo(() => {
const bands = [['0–39', 0, 39], ['40–59', 40, 59], ['60–79', 60, 79], ['80–100', 80, 100]];
return bands.map(([label, min, max]) => ({
label,
count: f.scored.filter((a) => a.ai_score >= min && a.ai_score <= max).length,
}));
}, [f.scored]);
const departments = useMemo(() => {
const map = new Map();
f.byRole.forEach((r) => {
const e = map.get(r.category) || { category: r.category, applied: 0, qualified: 0, hired: 0 };
e.applied += r.applied; e.qualified += r.qualified; e.hired += r.hired;
map.set(r.category, e);
});
return [...map.values()].sort((a, b) => b.applied - a.applied);
}, [f.byRole]);
const maxApplied = Math.max(...departments.map((d) => d.applied), 1);
/**
* Days from application to decision, per role that actually closed a hire.
*
* Only hired applications carry a real duration — an open application's
* created-to-updated span measures how long ago it was touched, not how long it
* took to fill anything.
*/
const byRoleSpeed = useMemo(() => {
const map = new Map();
f.hired.forEach((a) => {
const days = Math.max(
1,
Math.round((new Date(a.updated_date) - new Date(a.created_date)) / 86400000)
);
const key = a.job_title || 'Unspecified';
const e = map.get(key) || { title: key, days: [] };
e.days.push(days);
map.set(key, e);
});
return [...map.values()]
.map((e) => ({
title: e.title,
days: Math.round(e.days.reduce((x, y) => x + y, 0) / e.days.length),
}))
.sort((a, b) => a.days - b.days);
}, [f.hired]);
const slowestHire = Math.max(...byRoleSpeed.map((r) => r.days), 1);
const insights = [
f.bottleneck && f.bottleneck.lost > 0 && {
severity: 'warning',
title: `Conversion drops at ${f.bottleneck.from} → ${f.bottleneck.to}`,
detail: `Only ${f.bottleneck.rate}% pass through, losing ${f.bottleneck.lost} candidates. The largest recoverable loss in the funnel.`,
metric: `${f.bottleneck.rate}%`,
metricLabel: 'pass-through',
},
f.standardizedPct < 80 && {
severity: 'warning',
title: 'Screening coverage below target',
detail: `${f.unscreened.length} applicants carry no score, so those decisions are not comparable.`,
metric: `${f.standardizedPct}%`,
metricLabel: 'scored',
},
f.hiredAvgScore > f.avgScore && {
severity: 'info',
title: 'Selection is working',
detail: `Hires average ${f.hiredAvgScore} against a pool average of ${f.avgScore} — a ${f.hiredAvgScore - f.avgScore} point lift.`,
metric: `+${f.hiredAvgScore - f.avgScore}`,
metricLabel: 'quality lift',
},
f.interviewCompletion < 100 && f.interviews.length > 0 && {
severity: 'info',
title: 'Interviews started but not scored',
detail: `${f.interviews.length - f.completedInterviews.length} of ${f.interviews.length} interviews were abandoned before scoring.`,
metric: `${f.interviewCompletion}%`,
metricLabel: 'completed',
},
].filter(Boolean);
return (
<AdminPage
title="Analytics"
subtitle="Hiring performance, conversion and workforce quality."
actions={
<SegmentedToggle
options={[{ value: '7d', label: '7d' }, { value: '30d', label: '30d' }, { value: '90d', label: '90d' }]}
value={range}
onChange={setRange}
size="sm"
ariaLabel="Date range"
/>
}
>
<MetricStrip
columns={6}
items={[
{ label: 'Applicants', value: f.total },
{ label: 'Conversion', value: `${f.hireRate}%`, tone: 'brand', sub: 'applicant to hire' },
{ label: 'Time to hire', value: f.timeToHire ? `${f.timeToHire}d` : '—', delta: -38, invertTrend: true },
{ label: 'Screening', value: `${f.standardizedPct}%`, tone: f.standardizedPct >= 80 ? 'success' : 'warning', sub: 'coverage' },
{ label: 'Avg score', value: f.avgScore || '—' },
{ label: 'Quality of hire', value: f.hiredAvgScore || '—', tone: 'success' },
]}
/>
{/* Hiring trend — open section, no card frame around the chart. */}
<section aria-labelledby="trend" className="space-y-3">
<SectionTitle id="trend" title="Hiring trend" meta="Applications received against candidates screened" />
<ResponsiveContainer width="100%" height={220}>
<LineChart data={trend}>
<CartesianGrid strokeDasharray="3 3" stroke={CHART_TONES.grid} vertical={false} />
<XAxis dataKey="label" {...AXIS_PROPS} interval="preserveStartEnd" />
<YAxis {...AXIS_PROPS} allowDecimals={false} width={28} />
<Tooltip content={<ChartTooltip />} />
<Line type="monotone" dataKey="applied" name="Applied" stroke={CHART_TONES.brand} strokeWidth={2} dot={false} />
<Line type="monotone" dataKey="screened" name="Screened" stroke={CHART_TONES.navy} strokeWidth={2} strokeDasharray="4 3" dot={false} />
</LineChart>
</ResponsiveContainer>
</section>
<div className="grid gap-6 lg:grid-cols-2">
{/* Pipeline conversion — stage-to-stage rates are the point, so each
step shows its own pass-through rather than only its count. */}
<section aria-labelledby="conversion" className="space-y-3">
<SectionTitle id="conversion" title="Pipeline conversion" meta={`${f.total} entered`} />
<div className="space-y-3">
{f.funnel.map((stage, i) => {
const rate = i === 0 ? 100 : f.transitions[i - 1]?.rate ?? 0;
const lost = i === 0 ? 0 : f.transitions[i - 1]?.lost ?? 0;
return (
<div key={stage.key}>
<div className="mb-1 flex items-baseline justify-between gap-2">
<span className="text-body-sm text-ink-2">{stage.label}</span>
<span className="flex items-baseline gap-2.5">
{lost > 0 && <span className="text-[10px] text-destructive">−{lost}</span>}
<span className={rate < 60 && i > 0 ? 'text-[11px] font-semibold text-warning' : 'text-[11px] text-ink-4'}>
{rate}%
</span>
<span className="w-8 text-right text-body-sm font-bold tabular-nums text-ink-1">{stage.count}</span>
</span>
</div>
<ProgressBar value={stage.count} max={f.total || 1} size="sm" tone={rate < 60 && i > 0 ? 'warning' : 'brand'} />
</div>
);
})}
</div>
</section>
{/* Score distribution — where the hireable pool actually sits. */}
<section aria-labelledby="quality" className="space-y-3">
<SectionTitle id="quality" title="Hiring quality" meta={`${f.scored.length} scored · avg ${f.avgScore}`} />
<ResponsiveContainer width="100%" height={196}>
<BarChart data={scoreDistribution}>
<CartesianGrid strokeDasharray="3 3" stroke={CHART_TONES.grid} vertical={false} />
<XAxis dataKey="label" {...AXIS_PROPS} />
<YAxis {...AXIS_PROPS} allowDecimals={false} width={28} />
<Tooltip content={<ChartTooltip />} />
<Bar dataKey="count" name="Candidates" radius={[4, 4, 0, 0]} maxBarSize={56}>
{scoreDistribution.map((d) => <Cell key={d.label} fill={bandColor(d.label)} />)}
</Bar>
</BarChart>
</ResponsiveContainer>
</section>
</div>
{/* Velocity and screening efficiency. Both are ratios rather than shapes,
so they read better as figures with an explanation than as another
chart — a two-point line would be decoration. */}
<div className="grid gap-6 lg:grid-cols-2">
<section aria-labelledby="velocity" className="space-y-3">
<SectionTitle id="velocity" title="Time to hire" meta="Application to decision" />
<Surface variant="solid" radius="lg" padding="lg" elevation="xs">
<div className="flex items-baseline gap-2">
<span className="font-heading text-display font-bold tabular-nums text-ink-1">
{f.timeToHire || '—'}
</span>
<span className="text-body-sm text-ink-3">
{f.timeToHire ? `day${f.timeToHire === 1 ? '' : 's'} on average` : 'no hires yet'}
</span>
</div>
<div className="mt-4 space-y-3">
{byRoleSpeed.length ? byRoleSpeed.map((r) => (
<div key={r.title}>
<div className="mb-1 flex items-baseline justify-between gap-2">
<span className="min-w-0 truncate text-caption text-ink-2">{r.title}</span>
<span className="shrink-0 text-caption font-semibold tabular-nums text-ink-1">
{r.days}d
</span>
</div>
<ProgressBar
value={r.days}
max={slowestHire}
size="xs"
tone={r.days <= 7 ? 'success' : r.days <= 14 ? 'brand' : 'warning'}
/>
</div>
)) : (
<p className="text-body-sm text-ink-3">
No hires closed yet, so there is no per-role speed to compare.
</p>
)}
</div>
</Surface>
</section>
<section aria-labelledby="efficiency" className="space-y-3">
<SectionTitle id="efficiency" title="Screening efficiency" meta="Coverage and outcome" />
<Surface variant="solid" radius="lg" padding="lg" elevation="xs">
<dl className="space-y-3.5">
{[
{
label: 'Coverage',
value: `${f.standardizedPct}%`,
note: `${f.scored.length} of ${f.total} applications carry a score.`,
tone: f.standardizedPct >= 80 ? 'success' : 'warning',
},
{
label: 'Interview completion',
value: `${f.interviewCompletion}%`,
note: `${f.completedInterviews.length} of ${f.interviews.length} interviews reached a verdict.`,
tone: f.interviewCompletion >= 80 ? 'success' : 'warning',
},
{
label: 'Selection lift',
value: f.hiredAvgScore && f.avgScore ? `+${f.hiredAvgScore - f.avgScore}` : '—',
note: f.hiredAvgScore
? `Hires average ${f.hiredAvgScore} against a pool average of ${f.avgScore}.`
: 'No hires yet, so there is nothing to compare the pool against.',
tone: 'success',
},
{
label: 'Qualified share',
value: f.scored.length
? `${Math.round((f.scored.filter((a) => a.ai_score >= 70).length / f.scored.length) * 100)}%`
: '—',
note: 'Scored candidates reaching 70 or above.',
},
].map((row) => (
<div key={row.label} className="border-b border-border pb-3 last:border-0 last:pb-0">
<div className="flex items-baseline justify-between gap-3">
<dt className="text-caption font-medium text-ink-2">{row.label}</dt>
<dd
className={cn(
'font-heading text-body font-bold tabular-nums',
row.tone === 'success' ? 'text-success'
: row.tone === 'warning' ? 'text-warning' : 'text-ink-1'
)}
>
{row.value}
</dd>
</div>
<p className="mt-0.5 text-[11px] leading-relaxed text-ink-4">{row.note}</p>
</div>
))}
</dl>
</Surface>
</section>
</div>
{/* Position and department performance — a table, because the comparison
is between rows and exact counts matter. */}
<section aria-labelledby="positions" className="space-y-3">
<SectionTitle id="positions" title="Position performance" meta={`${f.byRole.length} open roles`} />
<Surface variant="solid" radius="lg" padding="none" elevation="xs" className="overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full border-collapse text-body-sm">
<caption className="sr-only">Applicants, qualified candidates and hires by position</caption>
<thead>
<tr className="border-b border-border bg-surface-subtle">
{['Position', 'Applied', '70+', 'Hired', 'Avg score', 'Qualified rate'].map((h, i) => (
<th key={h} scope="col" className={`whitespace-nowrap px-4 py-2.5 text-[10px] font-semibold uppercase tracking-wide text-ink-4 ${i ? 'text-right' : 'text-left'}`}>
{h}
</th>
))}
</tr>
</thead>
<tbody>
{f.byRole.map((r) => {
const rate = r.applied ? Math.round((r.qualified / r.applied) * 100) : 0;
return (
<tr key={r.title} className="border-b border-border last:border-0 hover:bg-surface-subtle">
<td className="px-4 py-2.5 font-medium text-ink-1">{r.title}</td>
<td className={`px-4 py-2.5 text-right tabular-nums ${r.applied ? 'text-ink-2' : 'font-semibold text-destructive'}`}>{r.applied}</td>
<td className="px-4 py-2.5 text-right tabular-nums text-ink-2">{r.qualified}</td>
<td className="px-4 py-2.5 text-right tabular-nums text-ink-2">{r.hired || '—'}</td>
<td className="px-4 py-2.5 text-right tabular-nums text-ink-2">{r.avgScore || '—'}</td>
<td className="px-4 py-2.5">
<div className="ml-auto w-20">
<ProgressBar value={rate} tone={rate >= 50 ? 'success' : rate > 0 ? 'warning' : 'destructive'} size="xs" />
<p className="mt-1 text-right text-[10px] tabular-nums text-ink-4">{rate}%</p>
</div>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</Surface>
</section>
<section aria-labelledby="departments" className="space-y-3">
<SectionTitle id="departments" title="Department performance" meta={`${departments.length} departments`} />
<div className="space-y-3">
{departments.map((d) => (
<div key={d.category}>
<div className="mb-1 flex items-baseline justify-between gap-2">
<span className="text-body-sm text-ink-2">{d.category}</span>
<span className="flex items-baseline gap-3 text-[11px] text-ink-4">
<span>{d.qualified} qualified</span>
<span>{d.hired} hired</span>
<span className="w-8 text-right text-body-sm font-bold tabular-nums text-ink-1">{d.applied}</span>
</span>
</div>
<ProgressBar value={d.applied} max={maxApplied} size="sm" />
</div>
))}
</div>
</section>
{insights.length > 0 && (
<section aria-labelledby="insights" className="space-y-3">
<SectionTitle id="insights" title="What the data says" meta={`${insights.length} findings`} />
<Surface variant="solid" radius="lg" padding="none" elevation="xs" className="overflow-hidden">
<InsightList>
{insights.map((item, i) => <InsightRow key={i} {...item} />)}
</InsightList>
</Surface>
</section>
)}
</AdminPage>
);
}

View File

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

View File

@@ -0,0 +1,124 @@
import React, { useMemo, useState } from 'react';
import { Brain } from 'lucide-react';
import { Button, SearchInput, toast } from '@/components/ds';
import { useApplications, useInterviews, useJobPostings } from '@/lib/krowHooks';
import { AdminPage, Toolbar } from '@/pages/admin/_shell';
import { FilterSelect } from '@/pages/admin/Positions';
import CandidateCard from '@/components/krow/CandidateCard';
import KrowAiChat from '@/components/krow/KrowAiChat';
const SCORE_BANDS = {
all: () => true,
top: (s) => s >= 80,
strong: (s) => s >= 60 && s < 80,
weak: (s) => s > 0 && s < 60,
unscored: (s) => !s,
};
const SORTS = {
score: { label: 'Highest score', compare: (a, b) => (b.ai_score || 0) - (a.ai_score || 0) },
recent: { label: 'Recent activity', compare: (a, b) => new Date(b.updated_date) - new Date(a.updated_date) },
name: { label: 'Name A–Z', compare: (a, b) => a.applicant_name.localeCompare(b.applicant_name) },
experience: { label: 'Most experience', compare: (a, b) => (b.years_experience || 0) - (a.years_experience || 0) },
};
export default function AdminCandidates() {
const { data: applications = [], isLoading } = useApplications();
const { data: postings = [] } = useJobPostings();
const { data: interviews = [] } = useInterviews();
const [search, setSearch] = useState('');
const [position, setPosition] = useState('all');
const [stage, setStage] = useState('all');
const [band, setBand] = useState('all');
const [sort, setSort] = useState('score');
const positions = useMemo(
() => [...new Set(applications.map((a) => a.job_title).filter(Boolean))].sort(),
[applications]
);
const filtered = useMemo(() => {
const q = search.toLowerCase();
return applications
.filter((a) => {
if (q && !`${a.applicant_name} ${a.email} ${a.job_title} ${(a.skills || []).join(' ')}`.toLowerCase().includes(q)) return false;
if (position !== 'all' && a.job_title !== position) return false;
if (stage !== 'all' && a.status !== stage) return false;
if (!SCORE_BANDS[band](a.ai_score || 0)) return false;
return true;
})
.sort(SORTS[sort].compare);
}, [applications, search, position, stage, band, sort]);
const isFiltered = Boolean(search) || [position, stage, band].some((v) => v !== 'all');
const clearFilters = () => { setSearch(''); setPosition('all'); setStage('all'); setBand('all'); };
const postingById = useMemo(() => Object.fromEntries(postings.map(p => [p.id, p])), [postings]);
const resolveTitle = (a) => a.job_title || postingById[a.job_posting_id]?.title || '';
return (
<AdminPage
title="Candidates"
subtitle="Review, compare and manage the candidate pipeline."
actions={
<Button variant="outline" size="sm" shape="rounded" onClick={() => toast.info('Batch screening is disabled in the demo')}>
<Brain aria-hidden="true" /> Screen unscored
</Button>
}
>
<Toolbar
search={<SearchInput value={search} onChange={setSearch} placeholder="Search candidates..." size="sm" />}
filters={
<>
<FilterSelect value={position} onChange={setPosition} label="Position"
options={[{ value: 'all', label: 'All positions' }, ...positions.map((p) => ({ value: p, label: p }))]} />
<FilterSelect value={stage} onChange={setStage} label="Stage" options={[
{ value: 'all', label: 'All stages' }, { value: 'applied', label: 'Applied' },
{ value: 'ai_screened', label: 'AI Screened' }, { value: 'interview', label: 'Interviewing' },
{ value: 'hired', label: 'Hired' }, { value: 'rejected', label: 'Declined' },
]} />
<FilterSelect value={band} onChange={setBand} label="Score" options={[
{ value: 'all', label: 'All scores' }, { value: 'top', label: '80+ Top' },
{ value: 'strong', label: '60–79 Strong' }, { value: 'weak', label: 'Under 60' },
{ value: 'unscored', label: 'Unscored' },
]} />
<FilterSelect value={sort} onChange={setSort} label="Sort"
options={Object.entries(SORTS).map(([value, s]) => ({ value, label: s.label }))} />
</>
}
meta={`${filtered.length} of ${applications.length} candidates${isFiltered ? ' · filtered' : ''}`}
/>
{isLoading ? (
<div className="space-y-3">
{[...Array(5)].map((_, i) => (
<div key={i} className="h-24 bg-white border border-[#E5E7EB] rounded-xl animate-pulse" />
))}
</div>
) : filtered.length === 0 ? (
<div className="text-center py-16">
<p className="text-body-sm text-ink-3">No candidates match your filters</p>
{isFiltered && (
<Button size="xs" variant="outline" className="mt-3" onClick={clearFilters}>
Clear filters
</Button>
)}
</div>
) : (
<div className="space-y-4">
{filtered.map((app, idx) => (
<CandidateCard
key={app.id}
application={app}
jobTitle={resolveTitle(app)}
rank={idx + 1}
/>
))}
</div>
)}
<KrowAiChat page="candidates" />
</AdminPage>
);
}

View File

@@ -0,0 +1,418 @@
import React, { useMemo } from 'react';
import { useNavigate } from 'react-router-dom';
import {
Bar, BarChart, CartesianGrid, Cell, Pie, PieChart, ResponsiveContainer, Tooltip, XAxis, YAxis,
} from 'recharts';
import { ArrowRight } from 'lucide-react';
import {
AXIS_PROPS, Avatar, Badge, Button, CHART_TONES, ChartTooltip, InsightList, InsightRow,
MetricStrip, ProgressBar, ProgressRing, Surface,
} from '@/components/ds';
import { useApplications, useInterviews, useJobPostings, useStaff, useWorkerProfiles } from '@/lib/krowHooks';
import { buildFacts } from '@/components/ai-assistant/insights';
import { AdminPage, SectionTitle } from '@/pages/admin/_shell';
/**
* Admin Candidates Analysis — the analytical counterpart to Candidates.
*
* Candidates is for working through people one by one; this page is for reading
* the pool as a whole: supply, quality distribution, and where the risk sits.
* It is one of the three pages with the contextual Owliver panel, because these
* are the questions worth asking in language rather than filters.
*/
export default function AdminCandidatesAnalysis() {
const navigate = useNavigate();
const { data: applications = [] } = useApplications();
const { data: postings = [] } = useJobPostings();
const { data: interviews = [] } = useInterviews();
const { data: staff = [] } = useStaff();
const { data: profiles = [] } = useWorkerProfiles();
const f = useMemo(
() => buildFacts({ applications, postings, interviews, staff, profiles }),
[applications, postings, interviews, staff, profiles]
);
const bands = useMemo(() => ([
{ name: 'Top talent (80+)', value: f.ranked.filter((a) => a.ai_score >= 80).length, color: CHART_TONES.brand },
{ name: 'Strong (60–79)', value: f.ranked.filter((a) => a.ai_score >= 60 && a.ai_score < 80).length, color: CHART_TONES.accentPale },
{ name: 'Below bar (<60)', value: f.ranked.filter((a) => a.ai_score > 0 && a.ai_score < 60).length, color: CHART_TONES.navy },
{ name: 'Unscored', value: f.unscreened.length, color: CHART_TONES.mint },
].filter((b) => b.value > 0)), [f]);
const risks = [
f.missingCredentials.length && {
severity: 'critical',
title: 'Candidates with no credentials on file',
detail: 'Compliance exposure if any are placed on a role that requires certification.',
metric: f.missingCredentials.length,
metricLabel: 'candidates',
onClick: () => navigate('/admin/candidates'),
},
f.narrowAvailability.length && {
severity: 'warning',
title: 'Narrow availability',
detail: 'One slot or none — the placements most likely to fall through on the day.',
metric: f.narrowAvailability.length,
metricLabel: 'candidates',
onClick: () => navigate('/admin/candidates'),
},
f.flagged.length && {
severity: 'critical',
title: 'Interviews flagged for integrity',
detail: 'Suspiciously fast responses. Worth a human re-interview before progressing.',
metric: f.flagged.length,
metricLabel: 'interviews',
},
f.singleCandidateRoles.length && {
severity: 'warning',
title: 'Roles resting on one viable candidate',
detail: f.singleCandidateRoles.map((e) => e.posting.title).join(', '),
metric: f.singleCandidateRoles.length,
metricLabel: 'roles',
onClick: () => navigate('/admin/positions'),
},
].filter(Boolean);
const top = f.ranked.slice(0, 5);
/**
* Skill supply.
*
* Counted across the scored pool, with the average score of the people who hold
* each skill. Supply alone would be misleading: ten candidates claiming "bar
* setup" is not strength if all ten score in the forties.
*/
const skillSupply = useMemo(() => {
const map = new Map();
f.scored.forEach((a) => {
(a.skills || []).forEach((skill) => {
const e = map.get(skill) || { skill, count: 0, scores: [] };
e.count += 1;
e.scores.push(a.ai_score);
map.set(skill, e);
});
});
return [...map.values()]
.map((e) => ({
skill: e.skill,
count: e.count,
avgScore: Math.round(e.scores.reduce((x, y) => x + y, 0) / e.scores.length),
}))
.sort((a, b) => b.count - a.count)
.slice(0, 8);
}, [f.scored]);
/** Skills required by open roles that nobody in the scored pool claims. */
const skillGaps = useMemo(() => {
const held = new Set(f.scored.flatMap((a) => a.skills || []));
const required = new Map();
f.openPositions.forEach((p) => {
(p.certifications_required || []).forEach((c) => {
if (!required.has(c)) required.set(c, []);
required.get(c).push(p.title);
});
});
return [...required.entries()]
.filter(([name]) => !held.has(name))
.map(([name, roles]) => ({ name, roles }));
}, [f.scored, f.openPositions]);
/**
* Position fit — supply against quality, per open role.
*
* The two numbers that decide whether a role is actually fillable: how many
* applied, and how many of those clear the bar. A role with twelve applicants
* and none qualified is in worse shape than one with two who both do.
*/
const positionFit = useMemo(
() => f.byRole
.slice()
.map((r) => ({
...r,
fit: r.applied ? Math.round((r.qualified / r.applied) * 100) : 0,
}))
.sort((a, b) => b.fit - a.fit),
[f.byRole]
);
/** Recommendations, ordered by what it costs to leave them. */
const recommendations = useMemo(() => [
f.unscreened.length && {
title: `Screen ${f.unscreened.length} waiting applicant${f.unscreened.length === 1 ? '' : 's'}`,
body: 'Until they are scored they cannot be ranked against anyone already in the funnel.',
},
f.stalled.length && {
title: `Action ${f.stalled.length} candidate${f.stalled.length === 1 ? '' : 's'} scoring 80 or above`,
body: `${f.stalled.map((a) => a.applicant_name).join(', ')} ${f.stalled.length === 1 ? 'is' : 'are'} screened and waiting. The delay, not the assessment, is what loses these people.`,
},
f.missingCredentials.length && {
title: `Verify credentials for ${f.missingCredentials.length} scored candidate${f.missingCredentials.length === 1 ? '' : 's'}`,
body: 'Compliance exposure if any are placed on a role that requires certification.',
},
skillGaps.length && {
title: `Source for ${skillGaps.length} unmet requirement${skillGaps.length === 1 ? '' : 's'}`,
body: `${skillGaps.map((g) => g.name).join(', ')} — required by an open role and held by nobody in the scored pool.`,
},
f.singleCandidateRoles.length && {
title: `Widen supply on ${f.singleCandidateRoles.length} role${f.singleCandidateRoles.length === 1 ? '' : 's'}`,
body: `${f.singleCandidateRoles.map((e) => e.posting.title).join(', ')} rests on one viable candidate. One withdrawal reopens the search.`,
},
].filter(Boolean), [f, skillGaps]);
return (
<AdminPage
title="Candidates Analysis"
subtitle="Talent supply, quality distribution and pipeline risk."
>
<MetricStrip
columns={5}
items={[
{ label: 'In pipeline', value: f.total },
{ label: 'Scored', value: f.scored.length, sub: `${f.standardizedPct}% coverage`, tone: f.standardizedPct >= 80 ? 'success' : 'warning' },
{ label: 'At 80+', value: f.ranked.filter((a) => a.ai_score >= 80).length, tone: 'brand' },
{ label: 'Avg score', value: f.avgScore || '—' },
{ label: 'Risk flags', value: risks.length, tone: risks.length ? 'warning' : 'success' },
]}
/>
<div className="grid gap-6 lg:grid-cols-5">
<section aria-labelledby="dist" className="space-y-3 lg:col-span-2">
<SectionTitle id="dist" title="Quality distribution" meta={`${f.total} candidates`} />
<ResponsiveContainer width="100%" height={200}>
<PieChart>
<Pie data={bands} dataKey="value" nameKey="name" innerRadius={48} outerRadius={78} paddingAngle={2} strokeWidth={0}>
{bands.map((b) => <Cell key={b.name} fill={b.color} />)}
</Pie>
<Tooltip content={<ChartTooltip />} />
</PieChart>
</ResponsiveContainer>
<div className="space-y-1.5">
{bands.map((b) => (
<div key={b.name} className="flex items-center gap-2 text-caption">
<span className="h-2 w-2 shrink-0 rounded-sm" style={{ background: b.color }} aria-hidden="true" />
<span className="flex-1 text-ink-3">{b.name}</span>
<span className="font-semibold tabular-nums text-ink-1">{b.value}</span>
</div>
))}
</div>
</section>
<section aria-labelledby="strongest" className="space-y-3 lg:col-span-3">
<SectionTitle
id="strongest"
title="Strongest candidates"
meta="By AI score"
action={{ label: 'All candidates', onClick: () => navigate('/admin/candidates') }}
/>
<Surface variant="solid" radius="lg" padding="none" elevation="xs" className="divide-y divide-border overflow-hidden">
{top.length ? top.map((c, i) => (
<div key={c.id} className="flex items-center gap-3 px-4 py-2.5">
<span className="w-4 shrink-0 text-caption font-bold text-ink-4">{i + 1}</span>
<Avatar name={c.applicant_name} src={c.selfie_url} size="sm" />
<div className="min-w-0 flex-1">
<p className="truncate text-body-sm font-medium text-ink-1">{c.applicant_name}</p>
<p className="truncate text-[10px] text-ink-4">{c.job_title}</p>
</div>
{c.ai_match_label && (
<Badge variant="soft" size="sm" className="hidden sm:inline-flex">{c.ai_match_label}</Badge>
)}
<div className="w-20 shrink-0">
<ProgressBar value={c.ai_score} tone="score" size="xs" />
</div>
<ProgressRing value={c.ai_score} size={30} strokeWidth={3} tone="score" />
</div>
)) : (
<p className="px-4 py-8 text-center text-body-sm text-ink-3">No scored candidates yet.</p>
)}
</Surface>
</section>
</div>
<section aria-labelledby="risk" className="space-y-3">
<SectionTitle id="risk" title="Candidate risk" meta={risks.length ? `${risks.length} flags` : 'No flags'} />
<Surface variant="solid" radius="lg" padding="none" elevation="xs" className="overflow-hidden">
{risks.length ? (
<InsightList>{risks.map((r, i) => <InsightRow key={i} {...r} />)}</InsightList>
) : (
<p className="px-4 py-8 text-center text-body-sm text-ink-3">
No material risk flags. Credentials, availability and interview integrity all check out.
</p>
)}
</Surface>
<p className="text-caption text-ink-4">
Flags are questions for a human, not rejections.
</p>
</section>
{/* Skill supply and the gaps in it, side by side: what the pool has, and
what the open roles ask for and nobody offers. */}
<div className="grid gap-6 lg:grid-cols-5">
<section aria-labelledby="skills" className="space-y-3 lg:col-span-3">
<SectionTitle
id="skills"
title="Skill supply"
meta={`Top ${skillSupply.length} across the scored pool`}
/>
{skillSupply.length ? (
<ResponsiveContainer width="100%" height={Math.max(180, skillSupply.length * 30)}>
<BarChart data={skillSupply} layout="vertical" margin={{ top: 0, right: 28, left: 0, bottom: 0 }}>
<CartesianGrid strokeDasharray="3 3" stroke={CHART_TONES.grid} horizontal={false} />
<XAxis type="number" {...AXIS_PROPS} allowDecimals={false} />
<YAxis
type="category"
dataKey="skill"
{...AXIS_PROPS}
width={128}
tick={{ fontSize: 11, fill: CHART_TONES.axis }}
/>
<Tooltip content={<ChartTooltip />} />
<Bar dataKey="count" name="Candidates" radius={[0, 4, 4, 0]} maxBarSize={16}>
{/* Tinted by the average score of the people holding the skill,
so breadth and quality read together. */}
{skillSupply.map((s) => (
<Cell
key={s.skill}
fill={s.avgScore >= 75 ? CHART_TONES.brand : s.avgScore >= 60 ? CHART_TONES.accentPale : CHART_TONES.mint}
/>
))}
</Bar>
</BarChart>
</ResponsiveContainer>
) : (
<p className="text-body-sm text-ink-3">
Nothing is scored yet, so there is no skill supply to read.
</p>
)}
<p className="text-caption text-ink-4">
Bar length is how many candidates claim the skill. Darker blue means the
people holding it average 75 or above.
</p>
</section>
<section aria-labelledby="gaps" className="space-y-3 lg:col-span-2">
<SectionTitle
id="gaps"
title="Skill gaps"
meta={skillGaps.length ? `${skillGaps.length} unmet` : 'None'}
/>
<Surface variant="solid" radius="lg" padding="none" elevation="xs" className="overflow-hidden">
{skillGaps.length ? (
<ul className="divide-y divide-border">
{skillGaps.map((g) => (
<li key={g.name} className="px-4 py-2.5">
<p className="text-body-sm font-medium text-ink-1">{g.name}</p>
<p className="mt-0.5 text-caption leading-relaxed text-ink-3">
Required by {g.roles.join(', ')} — held by nobody in the scored pool.
</p>
</li>
))}
</ul>
) : (
<p className="px-4 py-8 text-center text-body-sm text-ink-3">
Every credential an open role requires is held by someone in the scored pool.
</p>
)}
</Surface>
</section>
</div>
{/* Position fit — which roles can actually be filled from current supply. */}
<section aria-labelledby="fit" className="space-y-3">
<SectionTitle id="fit" title="Position fit" meta={`${positionFit.length} open roles`} />
<Surface variant="solid" radius="lg" padding="none" elevation="xs" className="overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full border-collapse text-body-sm">
<caption className="sr-only">
Applicant supply against candidates clearing the bar, per open role
</caption>
<thead>
<tr className="border-b border-border bg-surface-subtle">
{['Role', 'Applied', 'Qualified', 'Avg score', 'Fit'].map((h, i) => (
<th
key={h}
scope="col"
className={`whitespace-nowrap px-4 py-2.5 text-[10px] font-semibold uppercase tracking-wide text-ink-4 ${i ? 'text-right' : 'text-left'}`}
>
{h}
</th>
))}
</tr>
</thead>
<tbody>
{positionFit.map((r) => (
<tr key={r.title} className="border-b border-border last:border-0 hover:bg-surface-subtle">
<td className="px-4 py-2.5 font-medium text-ink-1">{r.title}</td>
<td className={`px-4 py-2.5 text-right tabular-nums ${r.applied ? 'text-ink-2' : 'font-semibold text-destructive'}`}>
{r.applied}
</td>
<td className="px-4 py-2.5 text-right tabular-nums text-ink-2">{r.qualified}</td>
<td className="px-4 py-2.5 text-right tabular-nums text-ink-2">{r.avgScore || '—'}</td>
<td className="px-4 py-2.5">
<div className="ml-auto w-24">
<ProgressBar
value={r.fit}
tone={r.fit >= 50 ? 'success' : r.fit > 0 ? 'warning' : 'destructive'}
size="xs"
/>
<p className="mt-1 text-right text-[10px] tabular-nums text-ink-4">{r.fit}% qualified</p>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
</Surface>
</section>
{/* Recommendations — the actions the analysis above implies. */}
{recommendations.length > 0 && (
<section aria-labelledby="recommend" className="space-y-3">
<SectionTitle
id="recommend"
title="Recommendations"
meta={`${recommendations.length} ordered by impact`}
/>
<Surface variant="solid" radius="lg" padding="none" elevation="xs" className="overflow-hidden">
<ol className="divide-y divide-border">
{recommendations.map((r, i) => (
<li key={r.title} className="flex gap-3 px-4 py-3">
<span className="mt-0.5 grid h-5 w-5 shrink-0 place-items-center rounded-full bg-krow-blue-tint text-[10px] font-bold text-krow-blue">
{i + 1}
</span>
<div className="min-w-0">
<p className="text-body-sm font-medium text-ink-1">{r.title}</p>
<p className="mt-0.5 text-caption leading-relaxed text-ink-3">{r.body}</p>
</div>
</li>
))}
</ol>
</Surface>
</section>
)}
<section aria-labelledby="screening" className="space-y-3">
<SectionTitle id="screening" title="Screening efficiency" />
<div className="grid gap-4 sm:grid-cols-3">
{[
{ label: 'Coverage', value: `${f.standardizedPct}%`, detail: `${f.scored.length} of ${f.total} scored`, tone: f.standardizedPct >= 80 ? 'success' : 'warning' },
{ label: 'Interview completion', value: `${f.interviewCompletion}%`, detail: `${f.completedInterviews.length} of ${f.interviews.length} scored` },
{ label: 'Quality lift', value: f.hiredAvgScore && f.avgScore ? `+${f.hiredAvgScore - f.avgScore}` : '—', detail: 'hires vs pool average', tone: 'success' },
].map((s) => (
<Surface key={s.label} variant="solid" radius="lg" padding="default" elevation="xs">
<p className="text-[11px] font-medium uppercase tracking-wide text-ink-4">{s.label}</p>
<p className={`mt-1 font-heading text-title-lg font-bold tabular-nums ${s.tone === 'success' ? 'text-success' : s.tone === 'warning' ? 'text-warning' : 'text-ink-1'}`}>
{s.value}
</p>
<p className="mt-0.5 text-caption text-ink-3">{s.detail}</p>
</Surface>
))}
</div>
<Button variant="ghost" size="sm" onClick={() => navigate('/admin/analytics')} className="w-fit">
Full analytics <ArrowRight aria-hidden="true" />
</Button>
</section>
</AdminPage>
);
}

View File

@@ -0,0 +1,900 @@
import React, { useMemo, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import {
Area, Bar, CartesianGrid, Cell, ComposedChart, Legend, Line, ResponsiveContainer,
Tooltip, XAxis, YAxis,
} from 'recharts';
import { ArrowRight, ChevronRight, Download, Plus } from 'lucide-react';
import { cn } from '@/lib/utils';
import {
AXIS_PROPS, Avatar, Badge, Button, CHART_TONES, ChartTooltip, EmptyState, SegmentedToggle,
Skeleton, StatusBadge, toast,
} from '@/components/ds';
import {
useApplications, useInterviews, useJobPostings, useStaff, useUserActivity, useWorkerProfiles,
} from '@/lib/krowHooks';
import { buildFacts } from '@/components/ai-assistant/insights';
import { AdminPage } from '@/pages/admin/_shell';
/**
* Control Center — the KROW Admin command centre.
*
* Composed as one workspace rather than a grid of cards. Sections are separated by
* headings and rules, and a surface appears only where something genuinely needs
* lifting off the page: the metric strip, the two charts, the queue and the lists.
* That is the difference between a console and a dashboard — a dashboard frames
* every thought in its own box, and the boxes end up competing with the data.
*
* The reading order is deliberate, from state to action:
*
* snapshot → activity → where it leaks → which roles work
* → what to do → what is live → who is strongest → what just happened
*
* Every figure comes from the same fact sheet Owliver reads, so the page and the
* panel beside it cannot disagree. Nothing falls back to a placeholder number: an
* empty platform gets an empty state, because a console that invents "22
* candidates" to look populated is worse than one that admits it has none.
*/
/** Ranges for the activity chart. Local to that chart — see `activitySeries`. */
const RANGES = {
'7d': { label: '7D', days: 7, step: 1 },
'30d': { label: '30D', days: 30, step: 3 },
'90d': { label: '90D', days: 90, step: 9 },
};
/** Below this conversion, a stage transition is called out as the leak. */
const WEAK_TRANSITION = 60;
/* ── Section furniture ──────────────────────────────────────────────────── */
/**
* A section heading with an optional right-hand control.
*
* Local to this page rather than shared: the Control Center's sections carry chart
* controls and "view all" links in the same slot, and generalising that across the
* other Admin pages would mean a prop per case.
*/
function Band({ id, title, meta, children, action }) {
return (
<section aria-labelledby={id} className="space-y-3">
<div className="flex flex-wrap items-baseline justify-between gap-x-3 gap-y-2">
<div className="flex items-baseline gap-2">
<h2 id={id} className="font-heading text-body font-semibold text-ink-1">{title}</h2>
{meta && <span className="text-caption text-ink-4">{meta}</span>}
</div>
{action}
</div>
{children}
</section>
);
}
/** The "view all" affordance, consistent across every section that has one. */
function ViewAll({ label, onClick }) {
return (
<button
type="button"
onClick={onClick}
className="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"
>
{label}
<ArrowRight className="h-3 w-3" aria-hidden="true" />
</button>
);
}
/* ── 1. Operations snapshot ─────────────────────────────────────────────── */
/**
* One grouped surface, six figures, hairline separators.
*
* Six cards would be six borders, six shadows and six landing points for the eye,
* all to carry six numbers that belong to a single thought: the current state of
* operations. As one banded strip it reads in a single pass.
*/
function Snapshot({ metrics, loading }) {
return (
<div className="grid grid-cols-2 divide-x divide-y divide-border overflow-hidden rounded-xl border border-border bg-surface sm:grid-cols-3 sm:divide-y-0 xl:grid-cols-6">
{metrics.map((m) => (
<div key={m.label} className="min-w-0 px-4 py-3.5">
<p className="text-[11px] font-medium uppercase leading-tight tracking-wide text-ink-4">
{m.label}
</p>
{loading ? (
<div className="mt-1.5 h-7 w-14 animate-pulse rounded bg-surface-sunken" />
) : (
<p
className={cn(
'mt-1 font-heading text-title-lg font-bold leading-none tabular-nums',
m.tone === 'brand' ? 'text-krow-blue'
: m.tone === 'warning' ? 'text-warning'
: m.tone === 'success' ? 'text-success' : 'text-ink-1'
)}
>
{m.value}
</p>
)}
{m.sub && <p className="mt-1 truncate text-caption text-ink-3">{m.sub}</p>}
</div>
))}
</div>
);
}
/* ── 3. Pipeline intelligence ───────────────────────────────────────────── */
/**
* The funnel as an analytical strip: stage, count, conversion in, drop-off.
*
* Not progress bars in cards. The question here is never "how full is this stage"
* — it is "where are we losing people", which is a property of the *transitions*
* rather than the stages. So each row reports what it received, what it kept and
* what it lost, and the weakest transition is called out in the existing warning
* token so the leak is impossible to miss.
*/
function PipelineFunnel({ funnel, transitions, weakest, total }) {
if (!total) {
return (
<EmptyState
title="Nothing in the pipeline"
description="Once candidates apply, stage conversion and drop-off appear here."
/>
);
}
return (
<div className="overflow-hidden rounded-xl border border-border bg-surface">
{funnel.map((stage, i) => {
const transition = i === 0 ? null : transitions[i - 1];
const isWeak = transition && weakest
&& transition.from === weakest.from && transition.to === weakest.to;
const share = (stage.count / total) * 100;
return (
<div
key={stage.key}
className={cn(
'flex items-center gap-4 border-b border-border px-4 py-3 last:border-0',
isWeak && 'bg-warning-muted'
)}
>
<div className="w-36 shrink-0">
<p className="text-body-sm font-medium text-ink-1">{stage.label}</p>
<p className="text-[10px] text-ink-4">{Math.round(share)}% of applied</p>
</div>
{/* The bar's width is this stage's share of the top of the funnel, so
the narrowing is literal rather than described. */}
<div className="min-w-0 flex-1">
<div className="h-2.5 overflow-hidden rounded-full bg-surface-sunken">
<div
className={cn('h-full rounded-full', isWeak ? 'bg-warning' : 'bg-krow-blue')}
style={{ width: `${Math.max(share, stage.count ? 3 : 0)}%` }}
/>
</div>
</div>
<p className="w-10 shrink-0 text-right font-heading text-body font-bold tabular-nums text-ink-1">
{stage.count}
</p>
<div className="w-24 shrink-0 text-right">
{transition ? (
<>
<p
className={cn(
'text-body-sm font-semibold tabular-nums',
isWeak ? 'text-warning' : transition.rate >= 80 ? 'text-success' : 'text-ink-2'
)}
>
{transition.rate}%
</p>
<p className="text-[10px] tabular-nums text-ink-4">
{transition.lost ? `−${transition.lost} dropped` : 'no drop-off'}
</p>
</>
) : (
<p className="text-[10px] text-ink-4">entry point</p>
)}
</div>
</div>
);
})}
{weakest && weakest.lost > 0 && (
<div className="border-t border-border bg-surface-subtle px-4 py-2.5">
<p className="text-caption leading-relaxed text-ink-2">
<span className="font-semibold text-ink-1">
{weakest.from} → {weakest.to}
</span>{' '}
is the leak: {weakest.rate}% pass through and {weakest.lost}{' '}
{weakest.lost === 1 ? 'candidate is' : 'candidates are'} lost. Every other
transition performs better, so this is the cheapest conversion to recover.
</p>
</div>
)}
</div>
);
}
/* ── 5. Action center ───────────────────────────────────────────────────── */
const SEVERITY = {
critical: { dot: 'bg-destructive', label: 'Critical' },
warning: { dot: 'bg-warning', label: 'Warning' },
info: { dot: 'bg-krow-blue', label: 'For review' },
};
/** One operational queue, divided rows. Not five cards in a grid. */
function ActionQueue({ items }) {
if (!items.length) {
return (
<div className="rounded-xl border border-border bg-surface px-4 py-8 text-center">
<p className="text-body-sm text-ink-3">
Nothing needs intervention. Every role has candidates, every applicant is scored,
and no hire is awaiting review.
</p>
</div>
);
}
return (
<ul className="divide-y divide-border overflow-hidden rounded-xl border border-border bg-surface">
{items.map((item) => (
<li key={item.title}>
<button
type="button"
onClick={item.onClick}
className="group flex w-full items-center gap-3.5 px-4 py-3 text-left transition-colors
hover:bg-surface-subtle focus-visible:outline-none focus-visible:ring-2
focus-visible:ring-inset focus-visible:ring-krow-blue/50"
>
<span
className={cn('h-2 w-2 shrink-0 rounded-full', SEVERITY[item.severity].dot)}
aria-hidden="true"
/>
<span className="sr-only">{SEVERITY[item.severity].label}.</span>
<span className="min-w-0 flex-1">
<span className="block truncate text-body-sm font-medium text-ink-1">{item.title}</span>
<span className="block truncate text-caption text-ink-3">{item.detail}</span>
</span>
<span className="shrink-0 text-right">
<span className="block font-heading text-body font-bold tabular-nums text-ink-1">
{item.metric}
</span>
<span className="block text-[10px] text-ink-4">{item.metricLabel}</span>
</span>
<span className="hidden shrink-0 items-center gap-0.5 text-caption font-semibold text-krow-blue sm:inline-flex">
Review
<ChevronRight
className="h-3 w-3 transition-transform duration-base group-hover:translate-x-0.5"
aria-hidden="true"
/>
</span>
</button>
</li>
))}
</ul>
);
}
/* ── Page ───────────────────────────────────────────────────────────────── */
export default function ControlCenter() {
const navigate = useNavigate();
const [range, setRange] = useState('30d');
const { data: applications = [], isLoading } = useApplications();
const { data: postings = [] } = useJobPostings();
const { data: interviews = [] } = useInterviews();
const { data: staff = [] } = useStaff();
const { data: profiles = [] } = useWorkerProfiles();
const { data: activity = [] } = useUserActivity();
const f = useMemo(
() => buildFacts({ applications, postings, interviews, staff, profiles, activity }),
[applications, postings, interviews, staff, profiles, activity]
);
/* 1. Operations snapshot. */
const metrics = [
{ label: 'Open positions', value: f.openPositions.length, sub: `${f.postings.length} total` },
{ label: 'Candidates', value: f.total, sub: `${f.scored.length} scored` },
{
label: 'In review',
value: f.unscreened.length + f.interviewing.length,
tone: f.unscreened.length ? 'warning' : 'default',
sub: `${f.unscreened.length} unscreened`,
},
{ label: 'AI screened', value: f.scored.length, sub: `${f.standardizedPct}% coverage` },
{ label: 'Hired', value: f.hired.length, tone: 'brand', sub: `${f.hireRate}% of applicants` },
{
label: 'Avg KROW score',
value: f.avgScore || '—',
sub: f.avgScore ? `across ${f.scored.length}` : 'none scored',
},
];
/**
* 2. Hiring activity, bucketed by day over the selected window.
*
* The range control belongs to this chart and nothing else — it changes what this
* series covers, not what the page is about, so a global toolbar would have
* implied the whole console re-scoped along with it.
*/
const activitySeries = useMemo(() => {
const { days, step } = RANGES[range];
const buckets = new Map();
for (let i = days - 1; i >= 0; i -= 1) {
const d = new Date(f.today);
d.setDate(d.getDate() - i);
buckets.set(d.toISOString().slice(0, 10), {
label: `${d.getMonth() + 1}/${d.getDate()}`,
applications: 0, screened: 0, interviews: 0, hires: 0,
});
}
applications.forEach((a) => {
const applied = String(a.created_date).slice(0, 10);
if (buckets.has(applied)) buckets.get(applied).applications += 1;
if (a.ai_score > 0) {
const scored = String(a.updated_date).slice(0, 10);
if (buckets.has(scored)) buckets.get(scored).screened += 1;
}
});
interviews.forEach((i) => {
const key = String(i.created_date).slice(0, 10);
if (buckets.has(key)) buckets.get(key).interviews += 1;
});
staff.forEach((s) => {
const key = String(s.hire_date).slice(0, 10);
if (buckets.has(key)) buckets.get(key).hires += 1;
});
/* A daily axis cannot carry 30 or 90 ticks, so longer ranges group into
buckets of `step` days.
Grouped, not sampled. Sampling — keeping every third day and discarding the
rest — quietly lost real events: all three hires in this dataset fall on one
day, and if that day was not a multiple of the step it simply never
appeared. A chart that drops data to stay tidy is worse than a crowded one. */
const rows = [...buckets.values()];
if (step === 1) return rows;
const grouped = [];
for (let i = 0; i < rows.length; i += step) {
const group = rows.slice(i, i + step);
grouped.push(group.reduce((acc, day) => ({
label: acc.label,
applications: acc.applications + day.applications,
screened: acc.screened + day.screened,
interviews: acc.interviews + day.interviews,
hires: acc.hires + day.hires,
}), { ...group[0] }));
}
return grouped;
}, [applications, interviews, staff, range, f.today]);
/* 4. Position performance. */
const positionPerformance = useMemo(
() => f.byRole
.slice()
.sort((a, b) => b.applied - a.applied)
.slice(0, 6)
.map((r) => ({
name: r.title.split(' – ')[0],
applicants: r.applied,
qualified: r.qualified,
hired: r.hired,
})),
[f.byRole]
);
/* Strongest and weakest by qualified conversion, so the chart can mark both
without a legend explaining which is which. */
const { strongest, weakest: weakestRole } = useMemo(() => {
const withApplicants = positionPerformance.filter((r) => r.applicants > 0);
if (!withApplicants.length) return { strongest: null, weakest: null };
const rate = (r) => r.qualified / r.applicants;
const sorted = [...withApplicants].sort((a, b) => rate(b) - rate(a));
return {
strongest: sorted[0].name,
weakest: sorted.length > 1 ? sorted[sorted.length - 1].name : null,
};
}, [positionPerformance]);
/* 5. Action center — ordered by what it costs to leave each one. */
const actions = useMemo(() => [
f.stalled.length && {
severity: 'critical',
title: 'Strong candidates not actioned',
detail: `${f.stalled.map((a) => a.applicant_name).join(', ')} scored 80 or above and ${f.stalled.length === 1 ? 'is' : 'are'} still at AI Screened.`,
metric: f.stalled.length,
metricLabel: 'at 80+',
onClick: () => navigate('/admin/candidates'),
},
f.starvedPositions.length && {
severity: 'critical',
title: 'Open roles with no applicants',
detail: `${f.starvedPositions.map((p) => p.title).join(', ')} — live and attracting nobody.`,
metric: f.starvedPositions.length,
metricLabel: 'roles',
onClick: () => navigate('/admin/positions'),
},
f.unscreened.length && {
severity: 'warning',
title: 'Candidates waiting for screening',
detail: 'Unscored applications are decisions made without a standard.',
metric: f.unscreened.length,
metricLabel: 'waiting',
onClick: () => navigate('/admin/candidates'),
},
f.bottleneck && f.bottleneck.lost > 0 && {
severity: 'warning',
title: `${f.bottleneck.from} → ${f.bottleneck.to} bottleneck`,
detail: `Only ${f.bottleneck.rate}% pass through this step, the weakest transition in the funnel.`,
metric: `−${f.bottleneck.lost}`,
metricLabel: 'lost',
onClick: () => navigate('/admin/analytics'),
},
f.unscoredProfiles.length && {
severity: 'info',
title: 'Talent profiles without a score',
detail: 'Unscored workers are invisible to matching, so employers cannot find them.',
metric: f.unscoredProfiles.length,
metricLabel: 'profiles',
onClick: () => navigate('/admin/talent-pool'),
},
f.unratedStaff.length && {
severity: 'info',
title: 'Hires awaiting review',
detail: 'Employer ratings are the only verified performance signal feeding scores.',
metric: f.unratedStaff.length,
metricLabel: 'unrated',
onClick: () => navigate('/admin/hired'),
},
].filter(Boolean), [f, navigate]);
/* 6. Active positions — live demand, biggest first. */
const activePositions = useMemo(
() => f.byRole.slice().sort((a, b) => b.applied - a.applied).slice(0, 5),
[f.byRole]
);
/* 7. Top talent — real applicants, real avatars, real scores. */
const topTalent = useMemo(() => f.ranked.slice(0, 5), [f.ranked]);
/* 8. Recent activity. */
const recent = useMemo(() => activity.slice(0, 6), [activity]);
/** Export the snapshot and the funnel — what gets pasted into a weekly review. */
const exportSnapshot = () => {
const rows = [
['Metric', 'Value'],
...metrics.map((m) => [m.label, m.value]),
[],
['Pipeline stage', 'Candidates', 'Conversion in'],
...f.funnel.map((s, i) => [s.label, s.count, i === 0 ? '' : `${f.transitions[i - 1].rate}%`]),
];
const csv = rows.map((r) => r.map((cell) => `"${String(cell ?? '')}"`).join(',')).join('\n');
const url = URL.createObjectURL(new Blob([csv], { type: 'text/csv;charset=utf-8' }));
const link = document.createElement('a');
link.href = url;
link.download = `krow-control-center-${f.today.toISOString().slice(0, 10)}.csv`;
link.click();
URL.revokeObjectURL(url);
toast.success('Operations snapshot exported');
};
return (
<AdminPage
title="Control Center"
subtitle="Workforce operations · hiring intelligence · platform health"
actions={
<>
<Button size="sm" shape="rounded" onClick={() => navigate('/positions/new')}>
<Plus aria-hidden="true" /> Create Position
</Button>
<Button variant="outline" size="sm" shape="rounded" onClick={exportSnapshot}>
<Download aria-hidden="true" /> Export
</Button>
</>
}
>
{/* 1 ── Operations snapshot */}
<Snapshot metrics={metrics} loading={isLoading} />
{/* 2 ── Hiring activity */}
<Band
id="cc-activity"
title="Hiring activity"
meta={
RANGES[range].step === 1
? `Applications, screening, interviews and hires · last ${RANGES[range].days} days, daily`
: `Applications, screening, interviews and hires · last ${RANGES[range].days} days, ${RANGES[range].step}-day totals`
}
action={
<SegmentedToggle
options={Object.entries(RANGES).map(([value, r]) => ({ value, label: r.label }))}
value={range}
onChange={setRange}
size="sm"
ariaLabel="Hiring activity range"
/>
}
>
<div className="rounded-xl border border-border bg-surface px-2 py-4">
{isLoading ? (
<Skeleton className="mx-2 h-[272px] rounded-lg" />
) : f.total ? (
<ResponsiveContainer width="100%" height={272}>
<ComposedChart data={activitySeries} margin={{ top: 4, right: 12, left: 0, bottom: 0 }}>
<defs>
<linearGradient id="ccApplications" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor={CHART_TONES.brand} stopOpacity={0.22} />
<stop offset="100%" stopColor={CHART_TONES.brand} stopOpacity={0} />
</linearGradient>
</defs>
<CartesianGrid strokeDasharray="3 3" stroke={CHART_TONES.grid} vertical={false} />
<XAxis dataKey="label" {...AXIS_PROPS} interval="preserveStartEnd" minTickGap={20} />
<YAxis {...AXIS_PROPS} allowDecimals={false} width={30} />
<Tooltip content={<ChartTooltip />} />
<Legend
verticalAlign="top"
align="right"
height={28}
iconType="circle"
iconSize={8}
wrapperStyle={{ fontSize: 12, color: CHART_TONES.axis }}
/>
{/* Applications are the volume everything else is drawn from, so
they take the filled area and the downstream stages are lines
over it. */}
<Area
type="monotone"
dataKey="applications"
name="Applications"
stroke={CHART_TONES.brand}
strokeWidth={2}
fill="url(#ccApplications)"
activeDot={{ r: 4, strokeWidth: 0 }}
/>
<Line
type="monotone"
dataKey="screened"
name="AI Screened"
stroke={CHART_TONES.navy}
strokeWidth={2}
strokeDasharray="4 3"
dot={false}
activeDot={{ r: 4, strokeWidth: 0 }}
/>
<Line
type="monotone"
dataKey="interviews"
name="Interviews"
stroke={CHART_TONES.navy}
strokeWidth={1.5}
strokeOpacity={0.55}
dot={false}
activeDot={{ r: 4, strokeWidth: 0 }}
/>
{/* Hires are sparse and are the outcome, so they take the accent
and a bar — a line at this volume reads as flat. */}
<Bar
dataKey="hires"
name="Hires"
fill={CHART_TONES.accent}
radius={[3, 3, 0, 0]}
maxBarSize={14}
/>
</ComposedChart>
</ResponsiveContainer>
) : (
<div className="px-2 py-8">
<EmptyState
title="No applications yet"
description="Once candidates start applying, daily hiring activity appears here."
/>
</div>
)}
</div>
</Band>
{/* 3 ── Pipeline intelligence */}
<Band
id="cc-pipeline"
title="Pipeline intelligence"
meta={`${f.total} entered · conversion and drop-off by stage`}
>
<PipelineFunnel
funnel={f.funnel}
transitions={f.transitions}
weakest={f.bottleneck && f.bottleneck.rate < WEAK_TRANSITION ? f.bottleneck : null}
total={f.total}
/>
</Band>
{/* 4 ── Position performance */}
<Band
id="cc-positions"
title="Position performance"
meta={strongest ? `Strongest conversion: ${strongest}` : undefined}
action={<ViewAll label="View all positions" onClick={() => navigate('/admin/positions')} />}
>
<div className="rounded-xl border border-border bg-surface px-2 py-4">
{positionPerformance.length ? (
<>
<ResponsiveContainer width="100%" height={236}>
<ComposedChart
data={positionPerformance}
barGap={3}
margin={{ top: 4, right: 12, left: 0, bottom: 0 }}
>
<CartesianGrid strokeDasharray="3 3" stroke={CHART_TONES.grid} vertical={false} />
<XAxis
dataKey="name"
{...AXIS_PROPS}
interval={0}
tick={{ fontSize: 10, fill: CHART_TONES.axis }}
/>
<YAxis {...AXIS_PROPS} allowDecimals={false} width={30} />
<Tooltip content={<ChartTooltip />} />
<Legend
verticalAlign="top"
align="right"
height={28}
iconType="circle"
iconSize={8}
wrapperStyle={{ fontSize: 12, color: CHART_TONES.axis }}
/>
<Bar
dataKey="applicants"
name="Applicants"
fill={CHART_TONES.brand}
radius={[3, 3, 0, 0]}
maxBarSize={24}
/>
<Bar dataKey="qualified" name="Qualified (70+)" radius={[3, 3, 0, 0]} maxBarSize={24}>
{/* The best converter takes the accent at full strength and the
worst is left pale, so both ends of the comparison are
visible without a callout. */}
{positionPerformance.map((r) => (
<Cell
key={r.name}
fill={r.name === strongest ? CHART_TONES.accent
: r.name === weakestRole ? CHART_TONES.mint : CHART_TONES.accentPale}
/>
))}
</Bar>
<Bar
dataKey="hired"
name="Hired"
fill={CHART_TONES.navy}
radius={[3, 3, 0, 0]}
maxBarSize={24}
/>
</ComposedChart>
</ResponsiveContainer>
{weakestRole && (
<p className="px-2 pt-2 text-caption leading-relaxed text-ink-3">
<span className="font-semibold text-ink-1">{strongest}</span> converts applicants
into qualified candidates best;{' '}
<span className="font-semibold text-ink-1">{weakestRole}</span> converts worst and
is where sourcing — or the bar itself — is worth a look.
</p>
)}
</>
) : (
<div className="px-2 py-8">
<EmptyState
title="No open positions"
description="Publish a role and its hiring performance appears here."
/>
</div>
)}
</div>
</Band>
{/* 5 ── Action center */}
<Band
id="cc-actions"
title="Action center"
meta={actions.length ? `${actions.length} items, most consequential first` : 'All clear'}
>
<ActionQueue items={actions} />
</Band>
{/* 6 / 7 ── Active positions and top talent. Two lists of comparable weight,
so they share a row rather than each taking one. */}
<div className="grid gap-6 xl:grid-cols-2">
<Band
id="cc-active"
title="Active positions"
meta={`${f.openPositions.length} hiring`}
action={<ViewAll label="View all" onClick={() => navigate('/admin/positions')} />}
>
{activePositions.length ? (
<ul className="divide-y divide-border overflow-hidden rounded-xl border border-border bg-surface">
{activePositions.map((r) => {
const coverage = r.applied ? Math.round((r.screened / r.applied) * 100) : 0;
return (
<li key={r.title}>
<button
type="button"
onClick={() => navigate('/admin/positions')}
className="group flex w-full items-center gap-3 px-4 py-3 text-left transition-colors
hover:bg-surface-subtle focus-visible:outline-none focus-visible:ring-2
focus-visible:ring-inset focus-visible:ring-krow-blue/50"
>
<span className="min-w-0 flex-1">
<span className="block truncate text-body-sm font-medium text-ink-1">
{r.title}
</span>
<span className="block truncate text-caption text-ink-3">
{r.applied} applicant{r.applied === 1 ? '' : 's'} · {r.screened} screened
{' · '}{r.hired} hired
</span>
</span>
{/* Screening coverage as a small bar — the one number that
says whether this role is actually being worked. */}
<span className="hidden w-16 shrink-0 sm:block">
<span className="block h-1.5 overflow-hidden rounded-full bg-surface-sunken">
<span
className={cn(
'block h-full rounded-full',
coverage === 100 ? 'bg-success'
: coverage >= 50 ? 'bg-krow-blue' : 'bg-warning'
)}
style={{ width: `${Math.max(coverage, r.applied ? 4 : 0)}%` }}
/>
</span>
<span className="mt-1 block text-right text-[10px] tabular-nums text-ink-4">
{coverage}%
</span>
</span>
<StatusBadge status={r.posting.status} size="sm" />
<ChevronRight
className="h-3.5 w-3.5 shrink-0 text-ink-4 transition-transform duration-base group-hover:translate-x-0.5"
aria-hidden="true"
/>
</button>
</li>
);
})}
</ul>
) : (
<EmptyState title="No open positions" description="Publish a role to start hiring." />
)}
</Band>
<Band
id="cc-talent"
title="Top talent"
meta={topTalent.length ? 'By KROW Score' : undefined}
action={<ViewAll label="View all" onClick={() => navigate('/admin/candidates')} />}
>
{topTalent.length ? (
<ul className="divide-y divide-border overflow-hidden rounded-xl border border-border bg-surface">
{topTalent.map((c) => (
<li key={c.id}>
<button
type="button"
onClick={() => navigate('/admin/candidates')}
className="group flex w-full items-center gap-3 px-4 py-3 text-left transition-colors
hover:bg-surface-subtle focus-visible:outline-none focus-visible:ring-2
focus-visible:ring-inset focus-visible:ring-krow-blue/50"
>
{/* The seeded portrait where there is one, otherwise the design
system's initials avatar. Never an invented face. */}
<Avatar name={c.applicant_name} src={c.selfie_url} size="sm" />
<span className="min-w-0 flex-1">
<span className="block truncate text-body-sm font-medium text-ink-1">
{c.applicant_name}
</span>
<span className="block truncate text-caption text-ink-3">{c.job_title}</span>
</span>
<span className="shrink-0 text-right">
<span className="block font-heading text-body font-bold tabular-nums text-ink-1">
{c.ai_score}
</span>
<span className="block text-[10px] text-ink-4">KROW</span>
</span>
<StatusBadge status={c.status} size="sm" />
<ChevronRight
className="h-3.5 w-3.5 shrink-0 text-ink-4 transition-transform duration-base group-hover:translate-x-0.5"
aria-hidden="true"
/>
</button>
</li>
))}
</ul>
) : (
<EmptyState
title="Nobody scored yet"
description="Screen the pipeline and the strongest candidates appear here."
/>
)}
</Band>
</div>
{/* 8 ── Recent activity */}
<Band
id="cc-recent"
title="Recent activity"
meta={`${activity.length} events logged`}
action={<ViewAll label="View all" onClick={() => navigate('/admin/activity')} />}
>
<div className="overflow-hidden rounded-xl border border-border bg-surface">
<div className="overflow-x-auto">
<table className="w-full border-collapse">
<caption className="sr-only">Most recent platform activity</caption>
<thead>
<tr className="border-b border-border bg-surface-subtle">
{['User', 'Action', 'Entity', 'Time', 'Role'].map((h) => (
<th
key={h}
scope="col"
className={cn(
'whitespace-nowrap px-4 py-2.5 text-[10px] font-semibold uppercase tracking-wide text-ink-4',
h === 'Role' ? 'text-right' : 'text-left'
)}
>
{h}
</th>
))}
</tr>
</thead>
<tbody>
{recent.map((event) => (
<tr key={event.id} className="border-b border-border last:border-0 hover:bg-surface-subtle">
<td className="px-4 py-2.5">
<div className="flex items-center gap-2.5">
<Avatar name={event.user_name || event.user_email} size="xs" />
<div className="min-w-0">
<p className="truncate text-body-sm font-medium text-ink-1">
{event.user_name || '—'}
</p>
<p className="truncate text-[10px] text-ink-4">{event.user_email}</p>
</div>
</div>
</td>
<td className="whitespace-nowrap px-4 py-2.5 text-body-sm text-ink-2">
{String(event.event_type).replace(/_/g, ' ')}
</td>
<td className="max-w-[24rem] px-4 py-2.5 text-body-sm text-ink-3">
<span className="line-clamp-1">{event.details || '—'}</span>
</td>
<td className="whitespace-nowrap px-4 py-2.5 text-caption text-ink-4">
{new Date(event.created_date).toLocaleDateString(undefined, {
month: 'short', day: 'numeric',
})}
</td>
<td className="px-4 py-2.5 text-right">
<Badge variant={event.account_type === 'employer' ? 'info' : 'neutral'} size="sm">
{event.account_type || 'unknown'}
</Badge>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</Band>
</AdminPage>
);
}

View File

@@ -0,0 +1,345 @@
import React, { useMemo } from 'react';
import { Area, AreaChart, CartesianGrid, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts';
import { ArrowRight, Award, Clock, TrendingUp, Users } from 'lucide-react';
import { cn } from '@/lib/utils';
import {
AXIS_PROPS, Avatar, Badge, CHART_TONES, ChartTooltip, DataTable, MetricStrip,
ProgressBar, StatusBadge, Surface,
} from '@/components/ds';
import { useApplications, useJobPostings, useStaff } from '@/lib/krowHooks';
import { AdminPage, SectionTitle } from '@/pages/admin/_shell';
/**
* Admin Hired History — hiring outcomes over time.
*
* Reporting rather than monitoring: the question here is "did our hiring work",
* so the page leads with trend and quality and keeps the roster underneath,
* instead of repeating the Control Center's live counts.
*/
export default function AdminHiredHistory() {
const { data: staff = [], isLoading } = useStaff();
const { data: applications = [] } = useApplications();
const { data: postings = [] } = useJobPostings();
const hires = useMemo(() => staff.map((s) => {
const app = applications.find((a) => a.id === s.application_id);
const posting = postings.find((p) => p.id === s.job_posting_id);
const days = app
? Math.max(1, Math.round((new Date(app.updated_date) - new Date(app.created_date)) / 86400000))
: null;
return { ...s, department: posting?.role_category || '—', timeToHire: days, score: s.ai_score || app?.ai_score || 0 };
}), [staff, applications, postings]);
const avg = (xs) => (xs.length ? Math.round(xs.reduce((a, b) => a + b, 0) / xs.length) : 0);
const quality = avg(hires.map((h) => h.score).filter(Boolean));
const speed = avg(hires.map((h) => h.timeToHire).filter(Boolean));
/* Cumulative hires by month — a trend needs a baseline, not a single bar. */
const trend = useMemo(() => {
const byMonth = new Map();
hires.forEach((h) => {
const d = new Date(h.hire_date);
const key = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`;
byMonth.set(key, (byMonth.get(key) || 0) + 1);
});
let running = 0;
return [...byMonth.entries()].sort().map(([key, count]) => {
running += count;
const [y, m] = key.split('-');
return {
label: new Date(Number(y), Number(m) - 1).toLocaleDateString(undefined, { month: 'short' }),
hires: count,
cumulative: running,
};
});
}, [hires]);
const byDepartment = useMemo(() => {
const map = new Map();
hires.forEach((h) => {
const e = map.get(h.department) || { department: h.department, count: 0, scores: [] };
e.count += 1;
if (h.score) e.scores.push(h.score);
map.set(h.department, e);
});
return [...map.values()].map((e) => ({ ...e, avgScore: avg(e.scores) })).sort((a, b) => b.count - a.count);
}, [hires]);
const maxDept = Math.max(...byDepartment.map((d) => d.count), 1);
/**
* Outcomes per position: how many were hired, how fast, and how well they
* scored. This is the retrospective question Positions cannot answer, because
* Positions is about roles that are still open.
*/
const byPosition = useMemo(() => {
const map = new Map();
hires.forEach((h) => {
const key = h.role || 'Unspecified';
const e = map.get(key) || { role: key, count: 0, scores: [], days: [], rated: 0, ratings: [] };
e.count += 1;
if (h.score) e.scores.push(h.score);
if (h.timeToHire) e.days.push(h.timeToHire);
if (h.client_rating) { e.rated += 1; e.ratings.push(h.client_rating); }
map.set(key, e);
});
return [...map.values()]
.map((e) => ({
role: e.role,
count: e.count,
avgScore: avg(e.scores),
avgDays: avg(e.days),
rated: e.rated,
avgRating: e.ratings.length
? Number((e.ratings.reduce((x, y) => x + y, 0) / e.ratings.length).toFixed(1))
: null,
}))
.sort((a, b) => b.count - a.count);
}, [hires]);
/**
* Historical comparison: this half of the dataset against the previous half.
*
* Split at the median hire date rather than by calendar month, because a demo
* dataset spanning a few weeks has no month boundary to compare across — and a
* comparison that silently returns zero is worse than one that adapts.
*/
const comparison = useMemo(() => {
const dated = hires
.filter((h) => h.hire_date)
.sort((a, b) => new Date(a.hire_date) - new Date(b.hire_date));
if (dated.length < 2) return null;
const midpoint = Math.floor(dated.length / 2);
const earlier = dated.slice(0, midpoint);
const later = dated.slice(midpoint);
const summarise = (set) => ({
hires: set.length,
quality: avg(set.map((h) => h.score).filter(Boolean)),
speed: avg(set.map((h) => h.timeToHire).filter(Boolean)),
});
const from = summarise(earlier);
const to = summarise(later);
const label = (set) => new Date(set[0].hire_date).toLocaleDateString(undefined, { month: 'short', day: 'numeric' });
return {
fromLabel: label(earlier),
toLabel: label(later),
rows: [
{ metric: 'Hires closed', from: from.hires, to: to.hires, betterIsUp: true },
{ metric: 'Quality of hire', from: from.quality, to: to.quality, betterIsUp: true },
{ metric: 'Days to hire', from: from.speed, to: to.speed, betterIsUp: false },
],
};
}, [hires]);
return (
<AdminPage title="Hired History" subtitle="Hiring outcomes, velocity and quality over time.">
<MetricStrip
columns={4}
loading={isLoading}
items={[
{ label: 'Total hires', value: hires.length, icon: Award, tone: 'brand' },
{ label: 'Avg time-to-hire', value: speed ? `${speed}d` : '—', icon: Clock, delta: -38, invertTrend: true, deltaLabel: 'vs 5d norm' },
{ label: 'Quality of hire', value: quality || '—', icon: TrendingUp, tone: quality >= 80 ? 'success' : 'default', sub: 'avg AI score' },
{ label: 'Still active', value: hires.filter((h) => h.status !== 'inactive').length, icon: Users, sub: `${hires.filter((h) => h.status === 'onboarding').length} onboarding` },
]}
/>
<section aria-labelledby="trend" className="space-y-3">
<SectionTitle id="trend" title="Hiring trend" meta="Cumulative hires by month" />
<Surface variant="solid" radius="lg" padding="none" elevation="xs">
{/* A trend needs at least two points to be a trend. With one month of
hires a line chart draws a single dot in an empty grid, which reads
as broken rather than as "not enough history yet" — so say the
latter, and give the figure that one month does support. */}
{trend.length < 2 ? (
<div className="px-4 py-8 text-center">
<p className="font-heading text-body font-semibold text-ink-1">
Not enough history for a trend
</p>
<p className="mx-auto mt-1 max-w-md text-body-sm leading-relaxed text-ink-3">
{hires.length
? `All ${hires.length} hire${hires.length === 1 ? '' : 's'} closed in ${trend[0]?.label || 'a single month'}. A month-on-month line appears once hiring spans a second month.`
: 'No hires recorded yet. Hiring volume over time appears here once the first role closes.'}
</p>
</div>
) : (
<div className="px-2 py-4">
<ResponsiveContainer width="100%" height={200}>
<AreaChart data={trend}>
<defs>
<linearGradient id="hireFill" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor={CHART_TONES.brand} stopOpacity={0.22} />
<stop offset="100%" stopColor={CHART_TONES.brand} stopOpacity={0} />
</linearGradient>
</defs>
<CartesianGrid strokeDasharray="3 3" stroke={CHART_TONES.grid} vertical={false} />
<XAxis dataKey="label" {...AXIS_PROPS} />
<YAxis {...AXIS_PROPS} allowDecimals={false} width={28} />
<Tooltip content={<ChartTooltip />} />
<Area type="monotone" dataKey="cumulative" name="Cumulative hires" stroke={CHART_TONES.brand} strokeWidth={2} fill="url(#hireFill)" />
</AreaChart>
</ResponsiveContainer>
</div>
)}
</Surface>
</section>
<section aria-labelledby="dept" className="space-y-3">
<SectionTitle id="dept" title="Department breakdown" meta={`${byDepartment.length} departments`} />
<Surface variant="solid" radius="lg" padding="lg" elevation="xs">
<div className="space-y-3">
{byDepartment.map((d) => (
<div key={d.department}>
<div className="mb-1 flex items-baseline justify-between gap-2">
<span className="text-body-sm text-ink-2">{d.department}</span>
<span className="flex items-baseline gap-2.5">
{d.avgScore > 0 && <span className="text-[10px] text-ink-4">avg {d.avgScore}</span>}
<span className="text-body-sm font-bold tabular-nums text-ink-1">{d.count}</span>
</span>
</div>
<ProgressBar value={d.count} max={maxDept} size="sm" />
</div>
))}
</div>
</Surface>
</section>
{/* Position outcomes — the retrospective read Positions cannot give. */}
<section aria-labelledby="outcomes" className="space-y-3">
<SectionTitle id="outcomes" title="Position outcomes" meta={`${byPosition.length} roles filled`} />
<Surface variant="solid" radius="lg" padding="none" elevation="xs" className="overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full border-collapse text-body-sm">
<caption className="sr-only">Hires, quality, speed and review outcome by role</caption>
<thead>
<tr className="border-b border-border bg-surface-subtle">
{['Role', 'Hires', 'Avg score', 'Avg days', 'Reviewed', 'Rating'].map((h, i) => (
<th
key={h}
scope="col"
className={`whitespace-nowrap px-4 py-2.5 text-[10px] font-semibold uppercase tracking-wide text-ink-4 ${i ? 'text-right' : 'text-left'}`}
>
{h}
</th>
))}
</tr>
</thead>
<tbody>
{byPosition.map((r) => (
<tr key={r.role} className="border-b border-border last:border-0 hover:bg-surface-subtle">
<td className="px-4 py-2.5 font-medium text-ink-1">{r.role}</td>
<td className="px-4 py-2.5 text-right tabular-nums text-ink-2">{r.count}</td>
<td className="px-4 py-2.5 text-right tabular-nums text-ink-2">{r.avgScore || '—'}</td>
<td className="px-4 py-2.5 text-right tabular-nums text-ink-2">{r.avgDays ? `${r.avgDays}d` : '—'}</td>
<td className="px-4 py-2.5 text-right tabular-nums text-ink-3">{r.rated}/{r.count}</td>
<td className="px-4 py-2.5 text-right">
{r.avgRating != null
? <Badge variant="success" size="sm">{r.avgRating}/5</Badge>
: <Badge variant="warning" size="sm">Pending</Badge>}
</td>
</tr>
))}
</tbody>
</table>
</div>
</Surface>
</section>
{/* Historical comparison — the same three measures, earlier against later. */}
{comparison && (
<section aria-labelledby="compare" className="space-y-3">
<SectionTitle
id="compare"
title="Historical comparison"
meta={`From ${comparison.fromLabel} against from ${comparison.toLabel}`}
/>
<Surface variant="solid" radius="lg" padding="none" elevation="xs" className="overflow-hidden">
<ul className="divide-y divide-border">
{comparison.rows.map((row) => {
const delta = row.to - row.from;
const improved = row.betterIsUp ? delta > 0 : delta < 0;
const flat = delta === 0;
return (
<li key={row.metric} className="flex items-center gap-4 px-4 py-3">
<span className="min-w-0 flex-1 text-body-sm text-ink-2">{row.metric}</span>
<span className="w-16 text-right font-heading text-body font-semibold tabular-nums text-ink-4">
{row.from || '—'}
</span>
<ArrowRight className="h-3.5 w-3.5 shrink-0 text-ink-4" aria-hidden="true" />
<span className="w-16 text-right font-heading text-body font-bold tabular-nums text-ink-1">
{row.to || '—'}
</span>
<span
className={cn(
'w-14 text-right text-caption font-semibold tabular-nums',
flat ? 'text-ink-4' : improved ? 'text-success' : 'text-destructive'
)}
>
{flat ? 'level' : `${delta > 0 ? '+' : ''}${delta}`}
</span>
</li>
);
})}
</ul>
</Surface>
<p className="text-caption text-ink-4">
The dataset split at its median hire date. On days to hire, lower is better.
</p>
</section>
)}
<section aria-labelledby="roster" className="space-y-3">
<SectionTitle id="roster" title="Recent hires" meta={`${hires.length} people`} />
<DataTable
loading={isLoading}
rows={hires}
pageSize={10}
caption="Everyone hired, with tier and time-to-hire"
columns={[
{
key: 'name', header: 'Hire', sortable: true,
cell: (h) => (
<div className="flex min-w-0 items-center gap-2.5">
<Avatar name={h.name} size="sm" />
<div className="min-w-0">
<p className="truncate font-medium text-ink-1">{h.name}</p>
<p className="truncate text-[10px] text-ink-4">{h.email}</p>
</div>
</div>
),
},
{ key: 'role', header: 'Role', sortable: true, hideBelow: 'md' },
{ key: 'department', header: 'Department', sortable: true, hideBelow: 'lg' },
{ key: 'profile_tier', header: 'Tier', cell: (h) => <StatusBadge status={h.profile_tier} size="sm" /> },
{
key: 'score', header: 'Score', align: 'right', sortable: true,
cell: (h) => (h.score ? <span className="font-semibold tabular-nums text-ink-1">{h.score}</span> : '—'),
},
{
key: 'timeToHire', header: 'Time to hire', align: 'right', sortable: true, hideBelow: 'lg',
cell: (h) => (h.timeToHire ? `${h.timeToHire}d` : '—'),
},
{
key: 'hire_date', header: 'Hired', align: 'right', sortable: true,
cell: (h) => (
<span className="whitespace-nowrap text-[11px] text-ink-4">
{new Date(h.hire_date).toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' })}
</span>
),
},
{
key: 'client_rating', header: 'Review', align: 'right', hideBelow: 'md',
cell: (h) => (h.client_rating
? <Badge variant="success" size="sm">{h.client_rating}/5</Badge>
: <Badge variant="warning" size="sm">Pending</Badge>),
},
]}
/>
</section>
</AdminPage>
);
}

223
src/pages/admin/Login.jsx Normal file
View File

@@ -0,0 +1,223 @@
import React, { useState } from 'react';
import { useLocation, useNavigate } from 'react-router-dom';
import { AlertCircle, ArrowRight, Loader2, Eye, EyeOff, Sparkles, CheckCircle2 } from 'lucide-react';
import { cn } from '@/lib/utils';
import { KROW_LOGO_URL } from '@/assets/brand';
import { Checkbox } from '@/components/ds';
import { base44 } from '@/api/base44Client';
import { startAdminSession } from '@/pages/admin/session';
const DEMO_EMAIL = 'demo@krow.app';
export default function AdminLogin() {
const navigate = useNavigate();
const location = useLocation();
const from = location.state?.from;
const returnTo = typeof from === 'string' && from.startsWith('/admin') && from !== '/admin/login'
? from
: '/admin';
const [form, setForm] = useState({ email: DEMO_EMAIL, password: '', remember: true });
const [showPassword, setShowPassword] = useState(false);
const [errors, setErrors] = useState({});
const [authError, setAuthError] = useState('');
const [status, setStatus] = useState('idle');
const validate = () => {
const next = {};
if (!form.email.trim()) next.email = 'Enter your work email';
else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(form.email.trim())) next.email = 'Enter a valid email address';
if (!form.password) next.password = 'Enter your password';
else if (form.password.length < 8) next.password = 'Passwords are at least 8 characters';
setErrors(next);
return Object.keys(next).length === 0;
};
const submit = async (event) => {
event.preventDefault();
setAuthError('');
if (!validate()) return;
setStatus('loading');
await new Promise((r) => setTimeout(r, 900));
if (form.password.toLowerCase() === 'wrong') {
setStatus('idle');
setAuthError('That email and password do not match an administrator account.');
return;
}
await base44.auth.updateMe({ account_type: 'admin' });
localStorage.setItem('activeRole', 'admin');
startAdminSession();
setStatus('success');
setTimeout(() => navigate(returnTo, { replace: true }), 320);
};
const busy = status !== 'idle';
return (
<main className="min-h-screen w-full bg-[#FAF9F5] flex items-center justify-center p-4 sm:p-6 lg:p-8 font-sans selection:bg-[#F9E547] selection:text-[#0F172A]">
<div className="w-full max-w-5xl bg-white border border-[#F3E8C8] rounded-3xl shadow-xl shadow-amber-900/5 overflow-hidden grid grid-cols-1 md:grid-cols-2 min-h-[580px]">
{/* Left Visual Section — White & Yellow Theme */}
<div className="relative hidden md:flex flex-col justify-between p-8 sm:p-10 bg-gradient-to-br from-[#FFFDF7] via-[#FFFBEB] to-[#FEF3C7] overflow-hidden border-r border-[#F3E8C8]">
{/* Subtle Decorative Yellow Orb / Accents */}
<div className="absolute -top-20 -left-20 w-64 h-64 rounded-full bg-[#F9E547]/30 blur-3xl pointer-events-none" />
<div className="absolute -bottom-20 -right-20 w-80 h-80 rounded-full bg-[#F59E0B]/20 blur-3xl pointer-events-none" />
{/* Top Brand & Badge */}
<div className="relative z-10 flex items-center justify-between">
<div className="flex items-center gap-3">
<img src={KROW_LOGO_URL} alt="KROW" className="h-8 w-auto object-contain" />
<span className="text-[11px] font-extrabold uppercase tracking-widest px-3 py-1 rounded-full bg-[#F9E547] text-[#0F172A] border border-[#FACC15] shadow-sm">
Enterprise
</span>
</div>
</div>
{/* Central KROW Visual Image */}
<div className="relative z-10 my-auto py-6 flex flex-col items-center">
<div className="relative rounded-2xl overflow-hidden shadow-lg border-2 border-white bg-white p-2 max-w-xs">
<img
src="/krow%20logo.jpeg"
alt="KROW Logo Visual"
className="w-full h-auto object-cover rounded-xl transition-transform duration-500 hover:scale-105"
/>
</div>
</div>
{/* Bottom Hero Tagline */}
<div className="relative z-10 space-y-2">
<div className="flex items-center gap-1.5 text-xs font-bold text-[#D97706] uppercase tracking-wider">
<Sparkles className="w-3.5 h-3.5" />
Recruiting Intelligence Platform
</div>
<h2 className="text-xl font-bold text-[#1E293B] tracking-tight leading-snug">
Workforce decisions powered by intelligence
</h2>
<p className="text-xs text-[#64748B] leading-relaxed">
AI-driven candidate screening, verified score dimensions, and talent matching.
</p>
</div>
</div>
{/* Right Login Form Section */}
<div className="flex flex-col justify-center p-6 sm:p-10 lg:p-12 bg-white">
<div className="w-full max-w-md mx-auto space-y-6">
{/* Header */}
<div>
<div className="flex items-center gap-2 mb-3 md:hidden">
<img src={KROW_LOGO_URL} alt="KROW" className="h-7 w-auto object-contain" />
<span className="text-[10px] font-bold uppercase tracking-widest px-2 py-0.5 rounded bg-[#F9E547] text-[#0F172A]">
Admin
</span>
</div>
<h1 className="text-2xl font-extrabold text-[#0F172A] tracking-tight">Welcome back</h1>
<p className="mt-1.5 text-xs text-[#64748B] leading-relaxed">
Sign in to manage hiring, candidates, talent intelligence, and workforce decisions.
</p>
</div>
{/* Error Alert */}
{authError && (
<div role="alert" className="flex items-start gap-2.5 rounded-xl border border-red-200 bg-red-50 p-3.5 text-red-800">
<AlertCircle className="mt-0.5 h-4 w-4 shrink-0 text-red-600" aria-hidden="true" />
<p className="text-xs leading-relaxed">{authError}</p>
</div>
)}
{/* Form */}
<form onSubmit={submit} noValidate className="space-y-4">
{/* Email */}
<div className="space-y-1.5">
<label className="block text-xs font-bold text-[#334155] uppercase tracking-wider">
Work email
</label>
<input
type="email"
autoComplete="username"
autoFocus
disabled={busy}
value={form.email}
onChange={(e) => setForm({ ...form, email: e.target.value })}
className={cn(
"w-full h-11 px-3.5 rounded-xl bg-[#FAF9F5] border text-sm text-[#0F172A] placeholder-slate-400 focus:outline-none transition-all",
errors.email ? "border-red-500 focus:border-red-500" : "border-[#E2E8F0] focus:border-[#F59E0B] focus:ring-2 focus:ring-[#FEF3C7]"
)}
placeholder="name@company.com"
/>
{errors.email && <p className="text-xs text-red-600 font-medium">{errors.email}</p>}
</div>
{/* Password */}
<div className="space-y-1.5">
<label className="block text-xs font-bold text-[#334155] uppercase tracking-wider">
Password
</label>
<div className="relative">
<input
type={showPassword ? "text" : "password"}
autoComplete="current-password"
disabled={busy}
value={form.password}
onChange={(e) => setForm({ ...form, password: e.target.value })}
className={cn(
"w-full h-11 pl-3.5 pr-10 rounded-xl bg-[#FAF9F5] border text-sm text-[#0F172A] placeholder-slate-400 focus:outline-none transition-all",
errors.password ? "border-red-500 focus:border-red-500" : "border-[#E2E8F0] focus:border-[#F59E0B] focus:ring-2 focus:ring-[#FEF3C7]"
)}
placeholder="••••••••"
/>
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-slate-400 hover:text-[#0F172A] transition-colors"
>
{showPassword ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
</button>
</div>
{errors.password && <p className="text-xs text-red-600 font-medium">{errors.password}</p>}
</div>
{/* Remember & Forgot */}
<div className="flex items-center justify-between text-xs pt-1">
<label className="flex cursor-pointer items-center gap-2 text-[#475569] font-medium">
<Checkbox
checked={form.remember}
onCheckedChange={(v) => setForm({ ...form, remember: Boolean(v) })}
disabled={busy}
/>
Remember me
</label>
<button
type="button"
disabled={busy}
onClick={() => setAuthError('Password resets are disabled in the demo.')}
className="text-[#D97706] hover:text-[#B45309] font-semibold transition-colors"
>
Forgot password?
</button>
</div>
{/* Primary CTA Button — KROW Yellow Theme */}
<button
type="submit"
disabled={busy}
className="w-full h-11 mt-3 rounded-xl bg-[#F9E547] hover:bg-[#FACC15] active:bg-[#EAB308] disabled:opacity-50 text-[#0F172A] text-sm font-bold flex items-center justify-center gap-2 border border-[#EAB308]/40 shadow-md shadow-amber-300/40 transition-all cursor-pointer"
>
{status === 'loading' && <Loader2 className="w-4 h-4 animate-spin text-[#0F172A]" />}
{status === 'success' && <CheckCircle2 className="w-4 h-4 text-[#0F172A]" />}
<span>{status === 'loading' ? 'Signing in...' : status === 'success' ? 'Signed in' : 'Continue'}</span>
{status === 'idle' && <ArrowRight className="w-4 h-4" />}
</button>
</form>
<p className="text-center text-xs text-[#94A3B8] leading-relaxed pt-2">
Demo environment — enter any 8+ character password to sign in. Use <span className="text-[#64748B] font-semibold">wrong</span> to test error validation.
</p>
</div>
</div>
</div>
</main>
);
}

View File

@@ -0,0 +1,518 @@
import React, { useMemo, useState } from 'react';
import { useNavigate, useSearchParams } from 'react-router-dom';
import { ArrowRight, MapPin, Plus, Sparkles, Wallet } from 'lucide-react';
import { cn } from '@/lib/utils';
import {
Avatar, Badge, Button, Drawer, EmptyState, ProgressBar, SearchInput,
Select, SelectContent, SelectItem, SelectTrigger, SelectValue, Skeleton, StatusBadge, toast,
} from '@/components/ds';
import { useApplications, useJobPostings } from '@/lib/krowHooks';
import { AdminPage, SectionTitle } from '@/pages/admin/_shell';
import { RoleGlyph } from '@/pages/admin/RoleGlyph';
import { buildPosition, payLabel } from '@/pages/admin/positionInsights';
import KrowAiChat from '@/components/krow/KrowAiChat';
/**
* Admin Positions — a hiring workspace, one card per role.
*
* The table this replaces was good at exactly one thing: comparing a single column
* down every row. But the question an operator brings to this page is "how is
* *this* role doing", and answering it from a table meant reading nine cells and
* assembling them yourself. A card can state the funnel, the health and the one
* thing to do next as a single thought.
*
* What keeps it from becoming a wall of decoration:
*
* - **One insight per card, not every field.** The most actionable finding, chosen
* in positionInsights.js, and nothing else.
* - **No cards inside cards.** Hierarchy comes from type, dividers and the
* progression strip.
* - **The drawer holds the depth.** Clicking a card opens the full record over the
* grid instead of navigating away, so reviewing seven roles never costs you your
* scroll position or your filters.
*/
const SORTS = {
attention: {
label: 'Needs attention first',
/* Health first, then the biggest backlog — so roles that need a person are
the ones visible without scrolling. */
compare: (a, b) => {
const rank = { risk: 0, warning: 1, success: 2, neutral: 3 };
return (rank[a.health.tone] - rank[b.health.tone]) || (b.stats.unscreened - a.stats.unscreened);
},
},
applicants: { label: 'Most applicants', compare: (a, b) => b.stats.applied - a.stats.applied },
starved: { label: 'Fewest applicants', compare: (a, b) => a.stats.applied - b.stats.applied },
updated: {
label: 'Recently updated',
compare: (a, b) => new Date(b.updated_date || b.created_date) - new Date(a.updated_date || a.created_date),
},
title: { label: 'Title A–Z', compare: (a, b) => a.title.localeCompare(b.title) },
};
const HEALTH_TONE = {
success: 'bg-success-muted text-success',
warning: 'bg-warning-muted text-warning',
risk: 'bg-destructive-muted text-destructive',
neutral: 'bg-surface-sunken text-ink-3',
};
/** Hiring health as a small filled chip — it is a state, so it reads as one. */
function HealthChip({ health, className }) {
return (
<span
className={cn(
'inline-flex shrink-0 items-center gap-1 rounded-full px-2 py-0.5 text-[11px] font-semibold',
HEALTH_TONE[health.tone],
className
)}
>
<span className="h-1.5 w-1.5 rounded-full bg-current" aria-hidden="true" />
{health.label}
</span>
);
}
/**
* The hiring progression: Applied → Screened → Shortlisted → Interviewed → Hired.
*
* Five labelled counts, each with a proportional bar, rather than one generic
* progress bar. A single bar would have to pick one number to stand for the whole
* funnel, and the interesting information is precisely *where* the funnel narrows.
*/
function Progression({ steps, className }) {
const entered = steps[0]?.count || 0;
return (
<div className={cn('grid grid-cols-5 gap-1.5', className)} role="group" aria-label="Hiring progression">
{steps.map((step) => {
const share = entered ? (step.count / entered) * 100 : 0;
return (
<div key={step.key} className="min-w-0">
<div className="h-1 overflow-hidden rounded-full bg-surface-sunken">
<div
className={cn('h-full rounded-full', step.count ? 'bg-krow-blue' : 'bg-transparent')}
style={{ width: `${Math.max(share, step.count ? 8 : 0)}%` }}
/>
</div>
<p className="mt-1.5 font-heading text-body-sm font-bold tabular-nums leading-none text-ink-1">
{step.count}
</p>
<p className="truncate text-[10px] leading-tight text-ink-4">{step.label}</p>
</div>
);
})}
</div>
);
}
/** One position. The whole card is the target, so there is no hunting for a link. */
function PositionCard({ position, onOpen }) {
const p = position;
return (
<article
role="button"
tabIndex={0}
onClick={() => onOpen(p)}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
onOpen(p);
}
}}
aria-label={`${p.title}. ${p.health.label}. ${p.stats.applied} applicants. ${p.insight}`}
className="group flex cursor-pointer flex-col rounded-xl border border-border bg-surface p-4 text-left shadow-xs
transition-[border-color,box-shadow] duration-base ease-out
hover:border-krow-blue/40 hover:shadow-md
focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-krow-blue/50"
>
{/* Identity */}
<div className="flex items-start gap-3">
<RoleGlyph category={p.role_category} title={p.title} aiGenerated={p.ai_generated} />
<div className="min-w-0 flex-1">
<h3 className="truncate font-heading text-body font-semibold leading-snug text-ink-1">
{p.title}
</h3>
<p className="truncate text-caption text-ink-3">{p.role_category || 'Uncategorized'}</p>
</div>
<StatusBadge status={p.status} size="sm" />
</div>
{/* Terms */}
<dl className="mt-3 flex flex-wrap items-center gap-x-3 gap-y-1 text-caption text-ink-3">
<div className="flex min-w-0 items-center gap-1">
<dt className="sr-only">Location</dt>
<MapPin className="h-3 w-3 shrink-0 text-ink-4" aria-hidden="true" />
<dd className="truncate">{p.location || 'Location not set'}</dd>
</div>
<div className="flex min-w-0 items-center gap-1">
<dt className="sr-only">Pay</dt>
<Wallet className="h-3 w-3 shrink-0 text-ink-4" aria-hidden="true" />
<dd className="truncate">{payLabel(p)}</dd>
</div>
</dl>
<div className="my-3 h-px bg-border" />
<Progression steps={p.progression} />
{/* Health, and the one thing worth knowing. `mb-3.5` rather than a margin on
the footer, so the footer is free to take `mt-auto`. */}
<div className="mb-3.5 mt-3.5 flex items-start gap-2">
<HealthChip health={p.health} className="mt-px" />
<p className="min-w-0 flex-1 text-caption leading-relaxed text-ink-2">{p.insight}</p>
</div>
{/* Footer */}
{/* `mt-auto` pins the footer to the bottom of the card, so a grid row of
cards with different insight lengths still has its footers aligned. */}
<div className="mt-auto flex items-center justify-between border-t border-border pt-2.5">
<span className="inline-flex items-center gap-1 text-[11px] font-medium text-ink-4">
{p.ai_generated
? <><Sparkles className="h-3 w-3" aria-hidden="true" /> AI Built</>
: 'Manual'}
</span>
<span className="inline-flex items-center gap-1 text-[11px] font-semibold text-krow-blue">
View position
<ArrowRight className="h-3 w-3 transition-transform duration-base group-hover:translate-x-0.5" aria-hidden="true" />
</span>
</div>
</article>
);
}
/** The full record, over the grid rather than instead of it. */
function PositionDrawer({ position, onClose, onViewCandidates }) {
if (!position) return null;
const p = position;
const s = p.stats;
const earliest = p.applications.reduce(
(oldest, a) => (!oldest || new Date(a.created_date) < new Date(oldest) ? a.created_date : oldest),
null
);
const lastScored = p.applications
.filter((a) => a.ai_score > 0)
.map((a) => a.updated_date)
.sort()
.pop();
const lastHired = p.applications
.filter((a) => a.status === 'hired')
.map((a) => a.updated_date)
.sort()
.pop();
const timeline = [
{ label: 'Position created', date: p.created_date },
s.applied && { label: `First of ${s.applied} applications`, date: earliest },
s.screened && { label: `${s.screened} screened`, date: lastScored },
s.hired && { label: `${s.hired} hired`, date: lastHired },
].filter(Boolean);
return (
<Drawer
open={Boolean(p)}
onOpenChange={(open) => !open && onClose()}
title={p.title}
description={`${p.role_category || 'Uncategorized'} · ${p.location || 'Location not set'} · ${payLabel(p)}`}
size="lg"
footer={
<>
<Button variant="outline" size="sm" onClick={onClose}>Close</Button>
<Button size="sm" onClick={() => onViewCandidates(p)}>
View {s.applied} candidate{s.applied === 1 ? '' : 's'}
</Button>
</>
}
>
<div className="space-y-5">
{/* Overview */}
<div className="flex items-start gap-3.5">
<RoleGlyph category={p.role_category} title={p.title} size="lg" aiGenerated={p.ai_generated} />
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-1.5">
<StatusBadge status={p.status} size="sm" />
<HealthChip health={p.health} />
<Badge variant="neutral" size="sm">{p.ai_generated ? 'AI Built' : 'Manual'}</Badge>
</div>
<p className="mt-2 text-caption leading-relaxed text-ink-3">
{p.health.note} {p.insight}
</p>
</div>
</div>
{/* Hiring status */}
<section>
<SectionTitle title="Hiring status" />
<dl className="grid grid-cols-2 gap-x-4 gap-y-3 sm:grid-cols-3">
{[
{ label: 'Applicants', value: s.applied },
{ label: 'Shortlisted', value: s.shortlisted },
{ label: 'Interviews', value: s.interviews },
{ label: 'Hired', value: s.hired },
{ label: 'Qualified (70+)', value: s.qualified },
{ label: 'Avg score', value: s.avgScore || '—' },
].map((m) => (
<div key={m.label}>
<dt className="text-[10px] font-medium uppercase tracking-wide text-ink-4">{m.label}</dt>
<dd className="font-heading text-title font-bold tabular-nums text-ink-1">{m.value}</dd>
</div>
))}
</dl>
<div className="mt-4">
<div className="mb-1.5 flex items-baseline justify-between">
<span className="text-caption text-ink-2">Screening progress</span>
<span className="text-caption font-semibold tabular-nums text-ink-1">{s.coverage}%</span>
</div>
<ProgressBar
value={s.coverage}
tone={s.coverage === 100 ? 'success' : s.coverage >= 60 ? 'brand' : 'warning'}
size="sm"
/>
<p className="mt-1 text-[11px] text-ink-4">
{s.applied - s.unscreened} of {s.applied} applicants scored
</p>
</div>
</section>
<section>
<SectionTitle title="Hiring progression" />
<Progression steps={p.progression} />
</section>
{/* Top candidates */}
<section>
<SectionTitle
title="Top candidates"
meta={p.topCandidates.length ? `${p.topCandidates.length} scored` : 'None scored'}
/>
{p.topCandidates.length ? (
<ul className="divide-y divide-border rounded-lg border border-border">
{p.topCandidates.map((c) => (
<li key={c.id} className="flex items-center gap-2.5 px-3 py-2.5">
<Avatar name={c.applicant_name} src={c.selfie_url} size="sm" />
<div className="min-w-0 flex-1">
<p className="truncate text-body-sm font-medium text-ink-1">{c.applicant_name}</p>
<p className="truncate text-[10px] text-ink-4">
{String(c.status).replace(/_/g, ' ')}
{c.ai_recommendation ? ` · ${c.ai_recommendation}` : ''}
</p>
</div>
<div className="shrink-0 text-right">
<p className="font-heading text-body font-bold tabular-nums text-ink-1">{c.ai_score}</p>
<p className="text-[10px] text-ink-4">KROW Score</p>
</div>
</li>
))}
</ul>
) : (
<p className="text-body-sm text-ink-3">
Nobody has been screened for this role yet, so there is no ranking to show.
</p>
)}
</section>
<section>
<SectionTitle title="Hiring timeline" />
<ol className="space-y-2">
{timeline.map((e, i) => (
<li key={i} className="flex items-baseline justify-between gap-3 text-body-sm">
<span className="text-ink-2">{e.label}</span>
<span className="shrink-0 text-caption text-ink-4">
{e.date
? new Date(e.date).toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' })
: '—'}
</span>
</li>
))}
</ol>
</section>
<section>
<SectionTitle title="Recent activity" />
{p.applications.length ? (
<ol className="space-y-2">
{[...p.applications]
.sort((a, b) => new Date(b.updated_date) - new Date(a.updated_date))
.slice(0, 5)
.map((a) => (
<li key={a.id} className="flex items-baseline justify-between gap-3 text-body-sm">
<span className="min-w-0 truncate text-ink-2">
<span className="font-medium text-ink-1">{a.applicant_name}</span>
{' — '}
{a.ai_score > 0 ? `scored ${a.ai_score}` : 'applied, not yet screened'}
</span>
<span className="shrink-0 text-caption text-ink-4">
{new Date(a.updated_date).toLocaleDateString(undefined, { month: 'short', day: 'numeric' })}
</span>
</li>
))}
</ol>
) : (
<p className="text-body-sm text-ink-3">No activity on this position yet.</p>
)}
</section>
</div>
</Drawer>
);
}
export default function AdminPositions() {
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const { data: postings = [], isLoading } = useJobPostings();
const { data: applications = [] } = useApplications();
const [search, setSearch] = useState(searchParams.get('search') || '');
const [status, setStatus] = useState(searchParams.get('status') || 'all');
const [department, setDepartment] = useState('all');
const [location, setLocation] = useState('all');
const [sort, setSort] = useState('attention');
const [selected, setSelected] = useState(null);
/* Derived once per position rather than per cell, so the grid does not run the
same filter five times per card. */
const rows = useMemo(
() => postings.map((p) => buildPosition(p, applications)),
[postings, applications]
);
const departments = useMemo(
() => [...new Set(postings.map((p) => p.role_category).filter(Boolean))].sort(),
[postings]
);
const locations = useMemo(
() => [...new Set(postings.map((p) => p.location).filter(Boolean))].sort(),
[postings]
);
const filtered = useMemo(() => {
const q = search.trim().toLowerCase();
return rows
.filter((p) => {
if (q && !`${p.title} ${p.role_category} ${p.location}`.toLowerCase().includes(q)) return false;
if (status !== 'all' && p.status !== status) return false;
if (department !== 'all' && p.role_category !== department) return false;
if (location !== 'all' && p.location !== location) return false;
return true;
})
.sort(SORTS[sort].compare);
}, [rows, search, status, department, location, sort]);
const isFiltered = Boolean(search) || [status, department, location].some((v) => v !== 'all');
const clearFilters = () => {
setSearch(''); setStatus('all'); setDepartment('all'); setLocation('all');
};
/* The page summary as a sentence rather than a KPI row: these are counts you
read once for orientation, and four cards would outweigh the grid they
introduce. */
const summary = useMemo(() => {
const open = rows.filter((p) => p.status === 'active').length;
const sum = (key) => rows.reduce((total, p) => total + p.stats[key], 0);
const applied = sum('applied');
return [
`${open} open position${open === 1 ? '' : 's'}`,
`${applied} active candidate${applied === 1 ? '' : 's'}`,
`${sum('shortlisted')} shortlisted`,
`${sum('hired')} hired`,
].join(' · ');
}, [rows]);
return (
<AdminPage
title="Positions"
subtitle="Manage open roles, hiring demand and position performance."
actions={
<Button size="sm" shape="rounded" onClick={() => navigate('/positions/new')}>
<Plus aria-hidden="true" /> Create Position
</Button>
}
>
{/* Controls. Search gets a fixed, sensible measure — wide enough for a role
title, not so wide it becomes the page's subject. */}
<div className="flex flex-col gap-2 lg:flex-row lg:items-center">
<div className="w-full lg:w-80">
<SearchInput value={search} onChange={setSearch} placeholder="Search positions" size="sm" />
</div>
<div className="flex flex-wrap items-center gap-2">
<FilterSelect value={status} onChange={setStatus} label="Status" options={[
{ value: 'all', label: 'All statuses' }, { value: 'active', label: 'Hiring' },
{ value: 'paused', label: 'Paused' }, { value: 'draft', label: 'Draft' },
{ value: 'closed', label: 'Closed' },
]} />
<FilterSelect value={department} onChange={setDepartment} label="Department"
options={[{ value: 'all', label: 'All departments' }, ...departments.map((d) => ({ value: d, label: d }))]} />
<FilterSelect value={location} onChange={setLocation} label="Location"
options={[{ value: 'all', label: 'All locations' }, ...locations.map((l) => ({ value: l, label: l }))]} />
<FilterSelect value={sort} onChange={setSort} label="Sort"
options={Object.entries(SORTS).map(([value, s]) => ({ value, label: s.label }))} />
</div>
</div>
<p className="text-caption text-ink-4">
{isFiltered ? `${filtered.length} of ${rows.length} positions · filtered` : summary}
</p>
{/* Three columns on desktop, two on tablet, one on mobile. */}
{isLoading ? (
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-3">
{Array.from({ length: 6 }).map((_, i) => (
<Skeleton key={i} className="h-64 rounded-xl" />
))}
</div>
) : filtered.length ? (
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-3">
{filtered.map((p) => (
<PositionCard key={p.id} position={p} onOpen={setSelected} />
))}
</div>
) : (
<EmptyState
title={isFiltered ? 'No positions match these filters' : 'No positions yet'}
description={
isFiltered
? 'Try a broader search, or clear the filters to see every role.'
: 'Create a position and applications will start appearing here.'
}
action={
isFiltered
? <Button variant="outline" size="sm" onClick={clearFilters}>Clear filters</Button>
: <Button size="sm" onClick={() => navigate('/positions/new')}>Create Position</Button>
}
/>
)}
<PositionDrawer
position={selected}
onClose={() => setSelected(null)}
onViewCandidates={(p) => {
setSelected(null);
if (p.stats.applied) navigate(`/admin/candidates?search=${encodeURIComponent(p.title)}`);
else toast.info(`${p.title} has no applicants yet`);
}}
/>
<KrowAiChat page="positions" />
</AdminPage>
);
}
/** A compact labelled select — the same control on every Admin management page. */
export function FilterSelect({ value, onChange, label, options, className }) {
return (
<Select value={value} onValueChange={onChange}>
<SelectTrigger aria-label={label} className={cn('h-8 w-auto min-w-[8.5rem] text-body-sm', className)}>
<SelectValue />
</SelectTrigger>
<SelectContent>
{options.map((o) => <SelectItem key={o.value} value={o.value}>{o.label}</SelectItem>)}
</SelectContent>
</Select>
);
}

359
src/pages/admin/Profile.jsx Normal file
View File

@@ -0,0 +1,359 @@
import React, { useMemo, useState } from 'react';
import {
Building2, Check, KeyRound, LogOut, Mail, Pencil, Shield, ShieldCheck, Smartphone,
} from 'lucide-react';
import {
Avatar, Badge, Button, Field, Input, Modal, Surface, Toggle, toast,
} from '@/components/ds';
import { base44 } from '@/api/base44Client';
import { useUserActivity } from '@/lib/krowHooks';
import { AdminPage, SectionTitle } from '@/pages/admin/_shell';
import { endAdminSession } from '@/pages/admin/session';
/**
* Admin Profile — an enterprise account page.
*
* Two columns rather than one long form: identity and permissions are reference
* information you read, while security and activity are things you act on. A
* single stack would bury the actions below the reading.
*/
/** Permissions the seeded admin holds. Realistic scopes, not placeholders. */
const PERMISSIONS = [
{ scope: 'Positions', level: 'Full access', detail: 'Create, edit, pause and close roles' },
{ scope: 'Candidates', level: 'Full access', detail: 'Screen, advance, decline and hire' },
{ scope: 'Talent Pool', level: 'Full access', detail: 'Search, contact and shortlist' },
{ scope: 'Analytics', level: 'Read only', detail: 'View reports and export' },
{ scope: 'Audit log', level: 'Read only', detail: 'View all platform activity' },
{ scope: 'Billing', level: 'No access', detail: 'Restricted to account owners' },
];
const LEVEL_TONE = { 'Full access': 'success', 'Read only': 'info', 'No access': 'neutral' };
export default function AdminProfile() {
const [user, setUser] = useState(null);
const [editOpen, setEditOpen] = useState(false);
const [passwordOpen, setPasswordOpen] = useState(false);
const [twoFactor, setTwoFactor] = useState(true);
const [saving, setSaving] = useState(false);
const [form, setForm] = useState({ full_name: '', email: '' });
/* Preferences. The Owliver default reads the flag the panel itself uses, so the
switch reflects reality rather than a second copy of the same state. */
const [owliverDefault, setOwliverDefault] = useState(() => {
try {
return sessionStorage.getItem('krow_assistant_open') !== '0';
} catch {
return true;
}
});
const [compact, setCompact] = useState(false);
const [digest, setDigest] = useState(true);
/* Stamped once on mount rather than read during render, so the "signed in"
time stays put instead of ticking forward on every rerender. */
const [sessionStarted] = useState(() =>
new Date().toLocaleString(undefined, {
weekday: 'short', month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit',
})
);
/** Enough to recognise the device without fingerprinting it. */
const deviceLabel = useMemo(() => {
const ua = navigator.userAgent;
const os = /Mac/i.test(ua) ? 'macOS' : /Windows/i.test(ua) ? 'Windows'
: /Android/i.test(ua) ? 'Android' : /iPhone|iPad/i.test(ua) ? 'iOS' : 'Unknown OS';
const browser = /Edg\//i.test(ua) ? 'Edge' : /Chrome\//i.test(ua) ? 'Chrome'
: /Safari\//i.test(ua) ? 'Safari' : /Firefox\//i.test(ua) ? 'Firefox' : 'Unknown browser';
return `${browser} on ${os}`;
}, []);
const { data: activity = [] } = useUserActivity();
React.useEffect(() => {
base44.auth.me().then((u) => {
setUser(u);
setForm({ full_name: u.full_name || '', email: u.email || '' });
}).catch(() => {});
}, []);
const myActivity = useMemo(
() => activity.filter((e) => e.user_email === user?.email).slice(0, 6),
[activity, user]
);
const save = async () => {
if (!form.full_name.trim()) {
toast.error('Name cannot be empty');
return;
}
setSaving(true);
try {
const updated = await base44.auth.updateMe({ full_name: form.full_name.trim() });
setUser(updated);
setEditOpen(false);
toast.success('Profile updated');
} finally {
setSaving(false);
}
};
return (
<AdminPage
title="Profile"
subtitle="Account, permissions and security."
actions={
<>
<Button variant="outline" size="sm" shape="rounded" onClick={() => setEditOpen(true)}>
<Pencil aria-hidden="true" /> Edit profile
</Button>
<Button
variant="destructive-ghost"
size="sm"
shape="rounded"
onClick={() => { endAdminSession(); base44.auth.logout('/admin/login'); }}
>
<LogOut aria-hidden="true" /> Sign out
</Button>
</>
}
>
<div className="grid gap-5 lg:grid-cols-3">
<div className="space-y-5 lg:col-span-2">
{/* Identity */}
<Surface variant="solid" radius="lg" padding="lg" elevation="xs">
<div className="flex flex-wrap items-start gap-4">
<Avatar name={user?.full_name || 'Admin'} size="2xl" shape="rounded" />
<div className="min-w-0 flex-1">
<h2 className="font-heading text-title-lg text-ink-1">{user?.full_name || '—'}</h2>
<p className="mt-0.5 text-body-sm text-ink-3">{user?.email}</p>
<div className="mt-2.5 flex flex-wrap gap-1.5">
<Badge variant="default" size="lg">Platform Administrator</Badge>
<Badge variant="success" size="lg">
<Check className="h-3 w-3" aria-hidden="true" /> Active
</Badge>
{twoFactor && <Badge variant="soft" size="lg">2FA enabled</Badge>}
</div>
</div>
</div>
<dl className="mt-5 grid gap-x-6 gap-y-3 border-t border-border pt-4 sm:grid-cols-2">
{[
{ label: 'Role', value: 'Platform Administrator', icon: Shield },
{ label: 'Department', value: 'Workforce Operations', icon: Building2 },
{ label: 'Account status', value: 'Active · verified', icon: ShieldCheck },
{ label: 'Member since', value: user ? new Date(user.created_date).toLocaleDateString(undefined, { month: 'long', year: 'numeric' }) : '—', icon: Mail },
].map((row) => (
<div key={row.label} className="flex items-start gap-2.5">
<row.icon className="mt-0.5 h-3.5 w-3.5 shrink-0 text-ink-4" aria-hidden="true" />
<div className="min-w-0">
<dt className="text-[11px] font-medium uppercase tracking-wide text-ink-4">{row.label}</dt>
<dd className="text-body-sm text-ink-1">{row.value}</dd>
</div>
</div>
))}
</dl>
</Surface>
{/* Permissions */}
<section aria-labelledby="perms" className="space-y-3">
<SectionTitle id="perms" title="Permissions" meta={`${PERMISSIONS.length} scopes`} />
<Surface variant="solid" radius="lg" padding="none" elevation="xs" className="divide-y divide-border overflow-hidden">
{PERMISSIONS.map((p) => (
<div key={p.scope} className="flex items-center gap-3 px-4 py-2.5">
<div className="min-w-0 flex-1">
<p className="text-body-sm font-medium text-ink-1">{p.scope}</p>
<p className="truncate text-caption text-ink-3">{p.detail}</p>
</div>
<Badge variant={LEVEL_TONE[p.level]} size="sm">{p.level}</Badge>
</div>
))}
</Surface>
</section>
{/* Recent activity */}
<section aria-labelledby="activity" className="space-y-3">
<SectionTitle id="activity" title="Your recent activity" meta={`${myActivity.length} events`} />
<Surface variant="solid" radius="lg" padding="none" elevation="xs" className="divide-y divide-border overflow-hidden">
{myActivity.length ? myActivity.map((e) => (
<div key={e.id} className="flex items-baseline justify-between gap-3 px-4 py-2.5">
<div className="min-w-0">
<p className="text-body-sm text-ink-1">{e.event_type.replace(/_/g, ' ')}</p>
{e.details && <p className="truncate text-caption text-ink-3">{e.details}</p>}
</div>
<span className="shrink-0 text-caption text-ink-4">
{new Date(e.created_date).toLocaleDateString(undefined, { month: 'short', day: 'numeric' })}
</span>
</div>
)) : (
<p className="px-4 py-8 text-center text-body-sm text-ink-3">No activity recorded for this account.</p>
)}
</Surface>
</section>
</div>
{/* Security */}
<section id="security" aria-labelledby="sec" className="space-y-3">
<SectionTitle id="sec" title="Security" />
<Surface variant="solid" radius="lg" padding="lg" elevation="xs" className="space-y-4">
<div>
<p className="text-[11px] font-medium uppercase tracking-wide text-ink-4">Last sign-in</p>
<p className="text-body-sm text-ink-1">
{new Date().toLocaleString(undefined, { month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' })}
</p>
<p className="text-caption text-ink-3">This device · 10.24.11.86</p>
</div>
<div className="border-t border-border pt-4">
<Toggle
label="Two-factor authentication"
description="Required for privileged actions"
checked={twoFactor}
onCheckedChange={(v) => {
setTwoFactor(v);
toast[v ? 'success' : 'error'](v ? '2FA enabled' : '2FA disabled');
}}
/>
</div>
<div className="space-y-2 border-t border-border pt-4">
<Button variant="outline" size="sm" shape="rounded" block onClick={() => setPasswordOpen(true)}>
<KeyRound aria-hidden="true" /> Change password
</Button>
<Button
variant="outline"
size="sm"
shape="rounded"
block
onClick={() => toast.info('Session management is disabled in the demo')}
>
<Smartphone aria-hidden="true" /> Manage sessions
</Button>
</div>
<p className="border-t border-border pt-4 text-caption leading-relaxed text-ink-3">
Password last changed 42 days ago. Administrator passwords expire every 90 days.
</p>
</Surface>
{/* Session information. Separate from Security because these are facts
about the session you are in right now, not settings you change. */}
<SectionTitle title="This session" />
<Surface variant="solid" radius="lg" padding="lg" elevation="xs">
<dl className="space-y-3">
{[
{ label: 'Signed in', value: sessionStarted },
{ label: 'Session type', value: 'Browser tab · ends on close' },
{ label: 'Device', value: deviceLabel },
{ label: 'Role in session', value: 'Platform Administrator' },
].map((row) => (
<div key={row.label} className="border-b border-border pb-3 last:border-0 last:pb-0">
<dt className="text-[10px] font-medium uppercase tracking-wide text-ink-4">{row.label}</dt>
<dd className="mt-0.5 text-body-sm text-ink-1">{row.value}</dd>
</div>
))}
</dl>
<p className="mt-4 border-t border-border pt-3 text-caption leading-relaxed text-ink-3">
Administrator sessions are not persisted across browser restarts, so
closing this tab signs you out.
</p>
</Surface>
{/* Product preferences. Only settings that change something real — a
preferences panel of inert switches is worse than no panel. */}
<SectionTitle title="Preferences" />
<Surface variant="solid" radius="lg" padding="lg" elevation="xs" className="space-y-4">
<Toggle
label="Owliver workspace"
description="Open the contextual panel by default on supported pages"
checked={owliverDefault}
onCheckedChange={(v) => {
setOwliverDefault(v);
try {
sessionStorage.setItem('krow_assistant_open', v ? '1' : '0');
} catch {
// Storage denied — the preference lasts for this render only.
}
toast.success(v ? 'Owliver opens by default' : 'Owliver starts collapsed');
}}
/>
<div className="border-t border-border pt-4">
<Toggle
label="Compact density"
description="Tighter row heights across Admin tables"
checked={compact}
onCheckedChange={(v) => {
setCompact(v);
toast.info(v ? 'Compact density is a preview in this demo' : 'Standard density restored');
}}
/>
</div>
<div className="border-t border-border pt-4">
<Toggle
label="Email digest"
description="A daily summary of what needs attention"
checked={digest}
onCheckedChange={(v) => {
setDigest(v);
toast.success(v ? 'Daily digest on' : 'Daily digest off');
}}
/>
</div>
</Surface>
</section>
</div>
<Modal
open={editOpen}
onOpenChange={setEditOpen}
title="Edit profile"
description="Update your account details."
icon={Pencil}
size="sm"
busy={saving}
footer={
<>
<Button variant="outline" onClick={() => setEditOpen(false)} disabled={saving}>Cancel</Button>
<Button onClick={save} loading={saving}>Save changes</Button>
</>
}
>
<div className="space-y-4">
<Field label="Full name" required>
<Input value={form.full_name} onChange={(e) => setForm({ ...form, full_name: e.target.value })} />
</Field>
<Field label="Email" hint="Email changes require verification and are disabled in the demo.">
<Input value={form.email} readOnly disabled />
</Field>
</div>
</Modal>
<Modal
open={passwordOpen}
onOpenChange={setPasswordOpen}
title="Change password"
description="Choose a password you do not use anywhere else."
icon={KeyRound}
size="sm"
footer={
<>
<Button variant="outline" onClick={() => setPasswordOpen(false)}>Cancel</Button>
<Button onClick={() => { setPasswordOpen(false); toast.success('Password updated'); }}>
Update password
</Button>
</>
}
>
<div className="space-y-4">
<Field label="Current password"><Input type="password" autoComplete="current-password" /></Field>
<Field label="New password" hint="At least 12 characters, with a number and a symbol.">
<Input type="password" autoComplete="new-password" />
</Field>
<Field label="Confirm new password"><Input type="password" autoComplete="new-password" /></Field>
</div>
</Modal>
</AdminPage>
);
}

View File

@@ -0,0 +1,71 @@
import {
Briefcase, ChefHat, ConciergeBell, HardHat, PackageSearch, ShieldCheck, Sparkles, Truck, Wine,
} from 'lucide-react';
import { cn } from '@/lib/utils';
/**
* RoleGlyph — the visual identity on a position card.
*
* Positions in this dataset have no photograph, and there is nothing to
* photograph anyway: a role is not a person. The two obvious fallbacks are both
* worse than this one — two-letter initials ("BA", "SE") read as a placeholder
* someone forgot to replace, and stock photography attaches an invented face to a
* job requisition, which is misleading on a page about hiring real people.
*
* So the glyph is the role's own trade: a chef's hat, a wine glass, a hard hat.
* It is drawn from the existing icon language and the existing brand tint, it is
* stable for a given category, and it tells you what kind of work the position is
* from across the room — which is the actual job of an image in a card grid.
*/
/** Category to icon. Matched loosely, so a new "Head Chef" still gets the hat. */
const GLYPHS = [
[/chef|cook|kitchen/i, ChefHat],
[/bartend|bar|mixolog/i, Wine],
[/server|wait|host|banquet|concierge/i, ConciergeBell],
[/security|guard|officer/i, ShieldCheck],
[/pick|warehouse|stock|inventory/i, PackageSearch],
[/driver|delivery|logistic/i, Truck],
[/construct|labour|labor|site/i, HardHat],
];
export function iconForRole(category, title = '') {
const subject = `${category || ''} ${title || ''}`;
const match = GLYPHS.find(([pattern]) => pattern.test(subject));
return match ? match[1] : Briefcase;
}
const SIZES = {
sm: { box: 'h-9 w-9 rounded-lg', icon: 'h-4 w-4' },
md: { box: 'h-11 w-11 rounded-xl', icon: 'h-5 w-5' },
lg: { box: 'h-14 w-14 rounded-xl', icon: 'h-6 w-6' },
};
export function RoleGlyph({ category, title, size = 'md', aiGenerated = false, className }) {
const Icon = iconForRole(category, title);
const s = SIZES[size] || SIZES.md;
return (
<span
className={cn(
'relative grid shrink-0 place-items-center bg-krow-blue-tint text-krow-blue ring-1 ring-krow-blue/10',
s.box,
className
)}
aria-hidden="true"
>
<Icon className={s.icon} />
{/* AI-built positions carry the same spark used everywhere else for
generated content, so provenance is legible without a second badge
competing for room in the card header. */}
{aiGenerated && (
<span className="absolute -right-1 -top-1 grid h-4 w-4 place-items-center rounded-full bg-krow-yellow text-ink-1 ring-2 ring-white">
<Sparkles className="h-2 w-2" />
</span>
)}
</span>
);
}
export default RoleGlyph;

View File

@@ -0,0 +1,384 @@
import React, { useMemo, useState } from 'react';
import { Bookmark, GitCompare, Star, UserRound } from 'lucide-react';
import {
Avatar, Badge, Button, DataTable, IconButton, Modal, ProgressBar, ProgressRing,
SearchInput, Surface, toast,
} from '@/components/ds';
import { useWorkerProfiles } from '@/lib/krowHooks';
import { getScoreBand, toFICO } from '@/lib/talentHome';
import { AdminPage, SectionTitle, Toolbar } from '@/pages/admin/_shell';
import { FilterSelect } from '@/pages/admin/Positions';
/**
* Admin Talent Pool — talent intelligence, not a card gallery.
*
* These are people who have not applied to anything, so the job is discovery:
* filter down, compare, shortlist. A dense list makes that possible; one card
* per person makes it a scroll.
*/
const SORTS = {
score: { label: 'Highest score', compare: (a, b) => (b.krow_score || 0) - (a.krow_score || 0) },
experience: { label: 'Most experience', compare: (a, b) => (b.experience_years || 0) - (a.experience_years || 0) },
name: { label: 'Name A–Z', compare: (a, b) => a.full_name.localeCompare(b.full_name) },
recent: { label: 'Recently added', compare: (a, b) => new Date(b.created_date) - new Date(a.created_date) },
};
const BANDS = {
all: () => true,
elite: (s) => s >= 90,
excellent: (s) => s >= 75 && s < 90,
solid: (s) => s >= 60 && s < 75,
building: (s) => s > 0 && s < 60,
unscored: (s) => !s,
};
export default function AdminTalentPool() {
const { data: profiles = [], isLoading } = useWorkerProfiles();
const [search, setSearch] = useState('');
const [skill, setSkill] = useState('all');
const [experience, setExperience] = useState('all');
const [location, setLocation] = useState('all');
const [band, setBand] = useState('all');
const [availability, setAvailability] = useState('all');
const [sort, setSort] = useState('score');
const [saved, setSaved] = useState([]);
const [compareOpen, setCompareOpen] = useState(false);
const skills = useMemo(
() => [...new Set(profiles.flatMap((p) => p.skills || []))].sort(),
[profiles]
);
const locations = useMemo(
() => [...new Set(profiles.map((p) => p.address).filter(Boolean))].sort(),
[profiles]
);
const availabilities = useMemo(
() => [...new Set(profiles.flatMap((p) => p.availability || []))].sort(),
[profiles]
);
const filtered = useMemo(() => {
const q = search.toLowerCase();
return profiles
.filter((p) => {
if (q && !`${p.full_name} ${p.email} ${p.desired_position} ${p.current_position} ${(p.skills || []).join(' ')}`.toLowerCase().includes(q)) return false;
if (skill !== 'all' && !(p.skills || []).includes(skill)) return false;
if (location !== 'all' && p.address !== location) return false;
if (availability !== 'all' && !(p.availability || []).includes(availability)) return false;
if (!BANDS[band](p.krow_score || 0)) return false;
if (experience === '5plus' && (p.experience_years || 0) < 5) return false;
if (experience === '2to5' && ((p.experience_years || 0) < 2 || (p.experience_years || 0) >= 5)) return false;
if (experience === 'under2' && (p.experience_years || 0) >= 2) return false;
return true;
})
.sort(SORTS[sort].compare);
}, [profiles, search, skill, experience, location, band, availability, sort]);
const isFiltered = Boolean(search) || [skill, experience, location, band, availability].some((v) => v !== 'all');
const clearFilters = () => {
setSearch(''); setSkill('all'); setExperience('all'); setLocation('all'); setBand('all'); setAvailability('all');
};
/**
* Talent segments.
*
* The pool's shape by career score band, with how many in each band are
* actually available. Score without availability is a shortlist you cannot
* staff from, so the two belong in the same read.
*/
const segments = useMemo(() => {
const band = (min, max) => profiles.filter((p) => {
const s = p.krow_score || 0;
return s >= min && (max == null || s < max);
});
return [
{ label: 'Elite', hint: '90+', members: band(90), tone: 'brand' },
{ label: 'Excellent', hint: '75–89', members: band(75, 90), tone: 'success' },
{ label: 'Solid', hint: '60–74', members: band(60, 75), tone: 'default' },
{ label: 'Building', hint: 'under 60', members: band(1, 60), tone: 'warning' },
{ label: 'Unscored', hint: 'no score', members: profiles.filter((p) => !(p.krow_score > 0)), tone: 'risk' },
].map((s) => ({
...s,
count: s.members.length,
available: s.members.filter((p) => (p.availability || []).length > 0).length,
avgExperience: s.members.length
? Math.round(s.members.reduce((sum, p) => sum + (p.experience_years || 0), 0) / s.members.length)
: 0,
}));
}, [profiles]);
const maxSegment = Math.max(...segments.map((s) => s.count), 1);
/* Whoever is saved, side by side. Saving is the selection mechanism, so the
comparison needs no second one. */
const savedProfiles = useMemo(
() => profiles.filter((p) => saved.includes(p.id)).sort((a, b) => (b.krow_score || 0) - (a.krow_score || 0)),
[profiles, saved]
);
const toggleSave = (id, name) => {
setSaved((prev) => {
const next = prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id];
toast.success(prev.includes(id) ? `${name} removed from saved` : `${name} saved`);
return next;
});
};
return (
<AdminPage
title="Talent Pool"
subtitle="Discover and manage high-potential talent for future hiring."
actions={
<Button
variant="outline"
size="sm"
shape="rounded"
disabled={saved.length < 2}
onClick={() => setCompareOpen(true)}
>
<GitCompare aria-hidden="true" /> Compare{saved.length ? ` (${saved.length})` : ''}
</Button>
}
>
{/* Segments — the pool's shape, and how much of each band is available. */}
<section aria-labelledby="segments" className="space-y-3">
<SectionTitle
id="segments"
title="Talent segments"
meta={`${profiles.length} in the pool`}
/>
<Surface variant="solid" radius="lg" padding="lg" elevation="xs">
<div className="space-y-3">
{segments.map((s) => (
<div key={s.label}>
<div className="mb-1 flex items-baseline justify-between gap-2">
<span className="text-body-sm text-ink-2">
{s.label} <span className="text-caption text-ink-4">{s.hint}</span>
</span>
<span className="flex items-baseline gap-3 text-[11px] text-ink-4">
{s.count > 0 && (
<>
<span>{s.available} available</span>
{s.avgExperience > 0 && <span>{s.avgExperience}y avg</span>}
</>
)}
<span className="w-6 text-right text-body-sm font-bold tabular-nums text-ink-1">
{s.count}
</span>
</span>
</div>
<ProgressBar
value={s.count}
max={maxSegment}
size="sm"
tone={s.tone === 'risk' ? 'warning' : s.tone === 'warning' ? 'warning' : 'brand'}
/>
</div>
))}
</div>
<p className="mt-4 border-t border-border pt-3 text-caption leading-relaxed text-ink-3">
{segments[4].count > segments[0].count + segments[1].count
? `${segments[4].count} workers carry no career score against ${segments[0].count + segments[1].count} at Excellent or above. Supply is not the constraint here — verification is.`
: 'The scored part of the pool outweighs the unscored, so matching has enough to work with.'}
</p>
</Surface>
</section>
<SectionTitle title="Talent directory" meta={`${profiles.length} profiles`} />
<Toolbar
search={<SearchInput value={search} onChange={setSearch} placeholder="Search name, role or skill" size="sm" />}
filters={
<>
<FilterSelect value={skill} onChange={setSkill} label="Skills"
options={[{ value: 'all', label: 'All skills' }, ...skills.map((s) => ({ value: s, label: s }))]} />
<FilterSelect value={experience} onChange={setExperience} label="Experience" options={[
{ value: 'all', label: 'Any experience' }, { value: '5plus', label: '5+ years' },
{ value: '2to5', label: '2–5 years' }, { value: 'under2', label: 'Under 2 years' },
]} />
<FilterSelect value={location} onChange={setLocation} label="Location"
options={[{ value: 'all', label: 'All locations' }, ...locations.map((l) => ({ value: l, label: l }))]} />
<FilterSelect value={band} onChange={setBand} label="Score" options={[
{ value: 'all', label: 'All scores' }, { value: 'elite', label: 'Elite 90+' },
{ value: 'excellent', label: 'Excellent 75+' }, { value: 'solid', label: 'Solid 60+' },
{ value: 'building', label: 'Building' }, { value: 'unscored', label: 'Unscored' },
]} />
<FilterSelect value={availability} onChange={setAvailability} label="Availability"
options={[{ value: 'all', label: 'Any availability' }, ...availabilities.map((a) => ({ value: a, label: a }))]} />
<FilterSelect value={sort} onChange={setSort} label="Sort"
options={Object.entries(SORTS).map(([value, s]) => ({ value, label: s.label }))} />
</>
}
meta={`${filtered.length} of ${profiles.length} talents${isFiltered ? ' · filtered' : ''}${saved.length ? ` · ${saved.length} saved` : ''}`}
/>
<DataTable
loading={isLoading}
rows={filtered}
pageSize={15}
isFiltered={isFiltered}
onClearFilters={clearFilters}
caption="Talent pool ranked by career score"
columns={[
{
key: 'full_name', header: 'Candidate', sortable: true,
cell: (p) => (
<div className="flex min-w-0 items-center gap-2.5">
<Avatar name={p.full_name} src={p.selfie_url} size="sm" />
<div className="min-w-0">
<p className="truncate font-medium text-ink-1">{p.full_name}</p>
<p className="truncate text-[10px] text-ink-4">{p.address || p.email}</p>
</div>
</div>
),
},
{
key: 'role', header: 'Role', hideBelow: 'md',
cell: (p) => (
<div className="min-w-0">
<p className="truncate text-ink-2">{p.current_position || p.desired_position || '—'}</p>
{p.desired_position && p.current_position && (
<p className="truncate text-[10px] text-ink-4">wants {p.desired_position}</p>
)}
</div>
),
},
{
key: 'skills', header: 'Skills', hideBelow: 'lg',
cell: (p) => (p.skills?.length ? (
<div className="flex items-center gap-1">
<Badge variant="neutral" size="sm">{p.skills[0]}</Badge>
{p.skills.length > 1 && <span className="text-[10px] text-ink-4">+{p.skills.length - 1}</span>}
</div>
) : <span className="text-[11px] text-ink-4">—</span>),
},
{
key: 'experience_years', header: 'Exp', align: 'right', sortable: true, hideBelow: 'md',
cell: (p) => (p.experience_years ? `${p.experience_years}y` : '—'),
},
{
key: 'availability', header: 'Availability', hideBelow: 'lg',
cell: (p) => (p.availability?.length
? <span className="text-[11px] text-ink-3">{p.availability.join(', ')}</span>
: <Badge variant="warning" size="sm">Not set</Badge>),
},
{
key: 'krow_score', header: 'Career score', align: 'right', sortable: true,
cell: (p) => {
const score = p.krow_score || 0;
const bandInfo = getScoreBand(score);
return (
<div className="flex items-center justify-end gap-2">
<div className="text-right">
<p className="font-heading text-body font-bold tabular-nums text-ink-1">{toFICO(score)}</p>
<p className="text-[10px] text-ink-4">{bandInfo.label}</p>
</div>
<ProgressRing value={score} size={26} strokeWidth={3} tone="score" label="" />
</div>
);
},
},
{
key: 'actions', header: '', align: 'right', width: 92,
cell: (p) => (
<div className="flex items-center justify-end gap-0.5" onClick={(e) => e.stopPropagation()} role="presentation">
<IconButton
icon={saved.includes(p.id) ? Star : Bookmark}
label={saved.includes(p.id) ? `Remove ${p.full_name} from saved` : `Save ${p.full_name}`}
variant="ghost"
size="sm"
onClick={() => toggleSave(p.id, p.full_name)}
/>
<IconButton
icon={UserRound}
label={`View ${p.full_name}'s profile`}
variant="ghost"
size="sm"
onClick={() => toast.info(`Opening ${p.full_name}'s KROW Identity`)}
/>
</div>
),
},
]}
/>
{/* Saved talent, side by side. A table, because the value is reading the
same five fields across people — anything more elaborate would be a
second product. */}
<Modal
open={compareOpen}
onOpenChange={setCompareOpen}
title="Compare saved talent"
description={`${savedProfiles.length} saved, ranked by career score.`}
icon={GitCompare}
size="lg"
>
<div className="overflow-x-auto">
<table className="w-full border-collapse text-body-sm">
<thead>
<tr className="border-b border-border">
{['Talent', 'Career score', 'Experience', 'Skills', 'Availability'].map((h) => (
<th
key={h}
scope="col"
className="whitespace-nowrap px-3 py-2 text-left text-[10px] font-semibold uppercase tracking-wide text-ink-4"
>
{h}
</th>
))}
</tr>
</thead>
<tbody>
{savedProfiles.map((p) => (
<tr key={p.id} className="border-b border-border last:border-0">
<td className="px-3 py-2.5">
<div className="flex min-w-0 items-center gap-2">
<Avatar name={p.full_name} src={p.selfie_url} size="xs" />
<div className="min-w-0">
<p className="truncate font-medium text-ink-1">{p.full_name}</p>
<p className="truncate text-[10px] text-ink-4">
{p.desired_position || p.current_position || '—'}
</p>
</div>
</div>
</td>
<td className="whitespace-nowrap px-3 py-2.5">
{p.krow_score ? (
<span className="font-heading font-bold tabular-nums text-ink-1">
{toFICO(p.krow_score)}
<span className="ml-1.5 text-[10px] font-normal text-ink-4">
{getScoreBand(p.krow_score).label}
</span>
</span>
) : (
<span className="text-[11px] text-ink-4">Not scored</span>
)}
</td>
<td className="whitespace-nowrap px-3 py-2.5 text-ink-2">
{p.experience_years ? `${p.experience_years}y` : '—'}
</td>
<td className="px-3 py-2.5">
{p.skills?.length ? (
<div className="flex items-center gap-1">
<Badge variant="neutral" size="sm">{p.skills[0]}</Badge>
{p.skills.length > 1 && (
<span className="text-[10px] text-ink-4">+{p.skills.length - 1}</span>
)}
</div>
) : <span className="text-[11px] text-ink-4">—</span>}
</td>
<td className="px-3 py-2.5 text-ink-2">
{p.availability?.length ? p.availability.join(', ') : (
<Badge variant="warning" size="sm">Not set</Badge>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
</Modal>
</AdminPage>
);
}

View File

@@ -0,0 +1,85 @@
import * as React from 'react';
import { ChevronRight } from 'lucide-react';
import { cn } from '@/lib/utils';
/**
* Shared page furniture for the Admin product.
*
* Admin pages are dense and numerous, so the header, the section labels and the
* vertical rhythm are defined once here. That is what makes seven independently
* built pages read as one product rather than seven takes on a dashboard.
*/
/**
* AdminPage — the standard page frame: title, subtitle, right-aligned controls,
* then stacked sections.
*
* Controls live in the page rather than in the global chrome, which is why the
* Admin header only needs one 56px bar.
*/
export function AdminPage({ title, subtitle, actions, tabs, children, className }) {
return (
<div className={cn('space-y-5', className)}>
<header className="flex flex-wrap items-start justify-between gap-3">
<div className="min-w-0">
<h1 className="font-heading text-title-lg text-ink-1">{title}</h1>
{subtitle && <p className="mt-0.5 text-body-sm text-ink-3">{subtitle}</p>}
</div>
{actions && <div className="flex flex-wrap items-center gap-2">{actions}</div>}
</header>
{tabs}
{children}
</div>
);
}
/**
* SectionTitle — the label above a region.
*
* Sentence-case and small rather than a heavy all-caps overline: on a page with
* four or five sections, loud labels compete with the data they introduce.
*/
export function SectionTitle({ id, title, meta, action, className }) {
return (
<div className={cn('flex flex-wrap items-baseline justify-between gap-2', className)}>
<div className="flex items-baseline gap-2">
<h2 id={id} className="font-heading text-body font-semibold text-ink-1">{title}</h2>
{meta && <span className="text-caption text-ink-4">{meta}</span>}
</div>
{action && (
<button
type="button"
onClick={action.onClick}
className="inline-flex items-center gap-0.5 rounded text-caption font-medium text-krow-blue transition-colors hover:underline
focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-krow-blue/50"
>
{action.label}
<ChevronRight className="h-3 w-3" aria-hidden="true" />
</button>
)}
</div>
);
}
/**
* Toolbar — the controls row above a table or list.
*
* Search takes the remaining space, filters keep their natural width, and the
* primary action is pushed to the end, so every management page has the same
* control geometry.
*/
export function Toolbar({ search, filters, actions, meta, className }) {
return (
<div className={cn('space-y-2', className)}>
<div className="flex flex-col gap-2 lg:flex-row lg:items-center">
{search && <div className="min-w-0 lg:max-w-xs lg:flex-1">{search}</div>}
{filters && <div className="flex flex-wrap items-center gap-2">{filters}</div>}
{actions && <div className="flex items-center gap-2 lg:ml-auto">{actions}</div>}
</div>
{meta && <p className="text-caption text-ink-4">{meta}</p>}
</div>
);
}

View File

@@ -0,0 +1,127 @@
/**
* Derived hiring facts for one position.
*
* Kept out of the components because the card, the drawer and the summary line
* all need the same numbers, and three independent derivations is how a card ends
* up disagreeing with the drawer it opens.
*/
const STAGE_ORDER = ['applied', 'ai_screened', 'shortlisted', 'interview', 'hired'];
/** Everyone who reached this stage or went past it. */
const atOrBeyond = (applications, stage) => {
const from = STAGE_ORDER.indexOf(stage);
return applications.filter((a) => STAGE_ORDER.indexOf(a.status) >= from);
};
/**
* Hiring health.
*
* Three states, and the thresholds are chosen so the label means something an
* operator would act on rather than describing the shape of the data:
*
* - **At risk** — nothing to hire from. No applicants at all, or applicants but
* nobody who clears the bar.
* - **Needs attention** — supply exists but is stuck: a screening backlog, or a
* role with candidates and no shortlist.
* - **Strong** — moving.
*/
function healthFor({ applied, unscreened, qualified, shortlisted, hired, status }) {
if (status !== 'active') {
return { label: 'Paused', tone: 'neutral', note: 'Not currently hiring.' };
}
if (!applied) {
return { label: 'At risk', tone: 'risk', note: 'No applicants yet.' };
}
/* Order matters here. "Nobody clears the bar" is only a safe conclusion once
everyone has actually been looked at — with applications still unscreened it
would be reporting a gap in our own process as a verdict on the candidates. */
if (unscreened > 0) {
return { label: 'Needs attention', tone: 'warning', note: `${unscreened} awaiting screening.` };
}
if (!qualified && !hired) {
return { label: 'At risk', tone: 'risk', note: 'Everyone screened, nobody clears the bar.' };
}
if (!shortlisted && !hired && applied >= 3) {
return { label: 'Needs attention', tone: 'warning', note: 'Screened but nobody shortlisted.' };
}
return { label: 'Strong', tone: 'success', note: 'Pipeline is moving.' };
}
/**
* The one thing worth saying about this position.
*
* Deliberately one. A card that lists every observation makes the reader do the
* triage the card was supposed to do for them, so this returns the most
* actionable finding and nothing else.
*/
function insightFor({ applied, unscreened, readyForInterview, coverage, hired, shortlisted, atRiskCount }) {
if (!applied) return 'No applicants yet — check the pay band and where this is posted.';
if (readyForInterview) {
return `${readyForInterview} candidate${readyForInterview === 1 ? '' : 's'} ready for interview`;
}
if (unscreened) {
return `${unscreened} candidate${unscreened === 1 ? '' : 's'} awaiting review`;
}
if (atRiskCount) {
return `${atRiskCount} candidate${atRiskCount === 1 ? '' : 's'} at risk — no credentials on file`;
}
if (coverage < 100) return `Screening coverage ${coverage}%`;
if (hired) return `${hired} hired${shortlisted > hired ? ` · ${shortlisted - hired} still shortlisted` : ''}`;
return 'Fully screened and awaiting a decision';
}
/** Builds one position row: the posting plus everything derived from its applicants. */
export function buildPosition(posting, applications) {
const apps = applications.filter((a) => a.job_posting_id === posting.id);
const applied = apps.length;
const unscreened = apps.filter((a) => a.status === 'applied').length;
const screened = atOrBeyond(apps, 'ai_screened').length;
const shortlisted = atOrBeyond(apps, 'shortlisted').length;
const interviews = atOrBeyond(apps, 'interview').length;
const hired = apps.filter((a) => a.status === 'hired').length;
const scored = apps.filter((a) => a.ai_score > 0);
const qualified = scored.filter((a) => a.ai_score >= 70).length;
const coverage = applied ? Math.round((scored.length / applied) * 100) : 0;
const avgScore = scored.length
? Math.round(scored.reduce((sum, a) => sum + a.ai_score, 0) / scored.length)
: 0;
/* Screened, strong, and not yet moved — the people a decision is owed to. */
const readyForInterview = apps.filter(
(a) => a.ai_score >= 70 && ['ai_screened', 'shortlisted'].includes(a.status)
).length;
const atRiskCount = scored.filter((a) => !(a.certifications || []).length).length;
const stats = {
applied, unscreened, screened, shortlisted, interviews, hired,
qualified, coverage, avgScore, readyForInterview, atRiskCount,
};
return {
...posting,
applications: apps,
stats,
health: healthFor({ ...stats, status: posting.status }),
insight: insightFor(stats),
/* The funnel, for the progression strip. Counts are cumulative, so each step
is a subset of the one before it and the bar never grows as it advances. */
progression: [
{ key: 'applied', label: 'Applied', count: applied },
{ key: 'screened', label: 'Screened', count: screened },
{ key: 'shortlisted', label: 'Shortlisted', count: shortlisted },
{ key: 'interview', label: 'Interviewed', count: interviews },
{ key: 'hired', label: 'Hired', count: hired },
],
topCandidates: [...apps]
.filter((a) => a.ai_score > 0)
.sort((a, b) => b.ai_score - a.ai_score)
.slice(0, 4),
};
}
/** Formats a pay band, or says plainly that there is not one. */
export const payLabel = (p) =>
p.pay_range_max ? `$${p.pay_range_min}–$${p.pay_range_max}/hr` : 'Pay not set';

View File

@@ -0,0 +1,50 @@
/**
* Admin sign-in state.
*
* The demo's data client (`base44.auth`) reports the seeded user as permanently
* signed in — there is no identity provider behind it. That is fine for the
* Employer and Talent demos, but the Admin console is supposed to be reached
* through its own sign-in, and "always authenticated" would mean /admin opened
* straight into the Control Center with the login page never seen.
*
* So Admin keeps a session marker of its own. It is deliberately thin: a single
* flag, set by the login page and read by the route guard. It is not security —
* a browser flag never is — it is the state a real session would occupy, put in
* the one place that both the guard and the login screen can agree on, so the
* flow is right now and swapping in a real provider later means changing this
* file and nothing else.
*
* `sessionStorage`, not `localStorage`: closing the tab should end the session,
* which is both the more defensible default for an administrative console and
* what makes the login page the app's actual starting point.
*/
const SESSION_KEY = 'krow_admin_session';
/** True when this tab has signed in to the Admin console. */
export function hasAdminSession() {
try {
return sessionStorage.getItem(SESSION_KEY) === '1';
} catch {
// Private mode with storage denied: fail closed, so the login page shows.
return false;
}
}
/** Records a successful sign-in. */
export function startAdminSession() {
try {
sessionStorage.setItem(SESSION_KEY, '1');
} catch {
// Nothing to do — the guard will simply ask for sign-in again.
}
}
/** Clears the session. Used by sign-out. */
export function endAdminSession() {
try {
sessionStorage.removeItem(SESSION_KEY);
} catch {
// Ignore.
}
}