update position page design

This commit is contained in:
2026-08-13 21:41:14 +05:30
parent 6340962907
commit 1c653264b5
124 changed files with 5746 additions and 596 deletions

View File

@@ -8,13 +8,13 @@ import { Briefcase, Users, Brain, ClipboardCheck, UserCheck, BadgeCheck, Gauge,
import { AssistantTrigger } from '@/components/ai-assistant';
const METRICS = [
{ icon: Briefcase, label: 'Open Positions', bg: 'bg-[#0A39DF]', glow: 'shadow-blue-600/20' },
{ icon: Briefcase, label: 'Open Positions', bg: 'bg-[#0838E0]', glow: 'shadow-blue-600/20' },
{ icon: Users, label: 'Applicants', bg: 'bg-[#333F48]', glow: 'shadow-slate-600/20' },
{ icon: Brain, label: 'AI Screened', bg: 'bg-[#0A39DF]', glow: 'shadow-blue-600/20' },
{ icon: Brain, label: 'AI Screened', bg: 'bg-[#0838E0]', glow: 'shadow-blue-600/20' },
{ icon: ClipboardCheck, label: 'Shortlisted', bg: 'bg-[#F9E547]', glow: 'shadow-yellow-400/30' },
{ icon: UserCheck, label: 'Hired', bg: 'bg-[#0A39DF]', glow: 'shadow-blue-600/20' },
{ icon: UserCheck, label: 'Hired', bg: 'bg-[#0838E0]', glow: 'shadow-blue-600/20' },
{ icon: BadgeCheck, label: 'Active Staff', bg: 'bg-[#333F48]', glow: 'shadow-slate-600/20' },
{ icon: Gauge, label: 'Avg KROW Score', bg: 'bg-[#0A39DF]', glow: 'shadow-blue-600/20' },
{ icon: Gauge, label: 'Avg KROW Score', bg: 'bg-[#0838E0]', glow: 'shadow-blue-600/20' },
{ icon: Sparkles, label: 'AI Interviews', bg: 'bg-[#F9E547]', glow: 'shadow-yellow-400/30' },
];
@@ -29,7 +29,7 @@ function MetricCard({ icon: Icon, value, label, bg, glow, index }) {
className="glass-card border border-white/60 rounded-2xl p-4 shadow-sm hover:shadow-lg transition-shadow"
>
<div className={`w-10 h-10 rounded-xl ${bg} flex items-center justify-center shadow-lg ${glow} mb-3`}>
<Icon className={`w-5 h-5 ${isYellow ? 'text-[#0A39DF]' : 'text-white'}`} />
<Icon className={`w-5 h-5 ${isYellow ? 'text-[#0838E0]' : 'text-white'}`} />
</div>
<div className="text-3xl font-heading font-bold gradient-text">{value}</div>
<div className="text-[11px] text-[#9CA3AF] font-medium tracking-wide mt-0.5">{label}</div>
@@ -75,7 +75,7 @@ export default function AdminDashboard() {
{/* Hero */}
<motion.div initial={{ opacity: 0, y: -12 }} animate={{ opacity: 1, y: 0 }} transition={{ duration: 0.5 }} className="flex items-center justify-between flex-wrap gap-4">
<div>
<span className="text-[11px] font-medium tracking-wide text-[#0A39DF] bg-[#EFF6FF] px-2.5 py-1 rounded-full">Admin · Employer</span>
<span className="text-[11px] font-medium tracking-wide text-[#0838E0] bg-[#EEF3FE] px-2.5 py-1 rounded-full">Admin · Employer</span>
<h1 className="text-3xl font-heading font-bold text-[#111827] mt-2">Workforce <span className="gradient-text">Control Center</span></h1>
<p className="text-[14px] text-[#6B7280] mt-1">Full hiring pipeline, staffing & talent intelligence in one view</p>
</div>
@@ -98,7 +98,7 @@ export default function AdminDashboard() {
<motion.div initial={{ opacity: 0, x: 16 }} animate={{ opacity: 1, x: 0 }} transition={{ delay: 0.3 }} className="glass-card border border-white/60 rounded-2xl p-6 shadow-sm">
<div className="flex items-center justify-between mb-5">
<h3 className="font-heading font-semibold text-[#111827] text-[16px]">Active Positions</h3>
<button onClick={() => navigate('/positions')} className="text-[12px] text-[#0A39DF] hover:underline inline-flex items-center gap-1 font-medium">View all <ChevronRight className="w-3 h-3" /></button>
<button onClick={() => navigate('/positions')} className="text-[12px] text-[#0838E0] hover:underline inline-flex items-center gap-1 font-medium">View all <ChevronRight className="w-3 h-3" /></button>
</div>
<div className="space-y-1">
{openPositions.length === 0 && (
@@ -111,12 +111,12 @@ export default function AdminDashboard() {
const s = statsForPosting(p.id);
return (
<motion.div key={p.id} initial={{ opacity: 0, x: 8 }} animate={{ opacity: 1, x: 0 }} transition={{ delay: 0.4 + idx * 0.05 }} onClick={() => navigate(`/positions/${p.id}`)} className="flex items-center gap-3 p-2.5 rounded-xl hover:bg-white/50 transition-colors cursor-pointer group">
<div className="w-1 h-9 rounded-full bg-[#0A39DF] shrink-0" />
<div className="w-1 h-9 rounded-full bg-[#0838E0] shrink-0" />
<div className="flex-1 min-w-0">
<div className="text-[13px] font-medium text-[#111827] truncate">{p.title}</div>
<div className="text-[11px] text-[#6B7280]">{s.applied} applied · {s.screened} screened · {s.hired} hired</div>
</div>
<ChevronRight className="w-4 h-4 text-[#D1D5DB] group-hover:text-[#0A39DF] transition-colors" />
<ChevronRight className="w-4 h-4 text-[#D1D5DB] group-hover:text-[#0838E0] transition-colors" />
</motion.div>
);
})}
@@ -147,7 +147,7 @@ export default function AdminDashboard() {
</div>
<div className="flex-1 min-w-0">
<div className="text-[13px] font-medium text-[#111827] truncate">{s.name}</div>
<div className="text-[11px] text-[#6B7280] truncate">{s.role} · <span className="text-[#0A39DF] font-medium">{s.profile_tier}</span></div>
<div className="text-[11px] text-[#6B7280] truncate">{s.role} · <span className="text-[#0838E0] font-medium">{s.profile_tier}</span></div>
</div>
<span className={`text-[10px] px-2 py-0.5 rounded-full font-medium ${s.status === 'active' ? 'bg-[#ECFDF5] text-[#065F46]' : 'bg-[#FFFBEB] text-[#92400E]'}`}>{s.status}</span>
</div>

View File

@@ -31,7 +31,7 @@ export default function Analytics() {
transition={{ duration: 0.4 }}
className="flex items-center gap-3"
>
<div className="w-10 h-10 rounded-xl bg-[#0A39DF] flex items-center justify-center shadow-lg shadow-blue-500/20">
<div className="w-10 h-10 rounded-xl bg-[#0838E0] flex items-center justify-center shadow-lg shadow-blue-500/20">
<BarChart3 className="w-5 h-5 text-white" />
</div>
<div>

View File

@@ -1,7 +1,9 @@
import React, { useState, useEffect } from 'react';
import { useNavigate, useSearchParams } from 'react-router-dom';
import { ChevronRight, Clock, Mic, Gauge, ArrowLeft } from 'lucide-react';
import { useJobPostings, useCreateApplication } from '@/lib/krowHooks';
import { useJobPostings, useCreateApplication, useCourses } from '@/lib/krowHooks';
import { YourSkillFit } from '@/components/krow/MatchedCandidates';
import { verifiedLevels } from '@/lib/skillGraph';
import ApplyIdentityCard from '@/components/krow/ApplyIdentityCard';
import AIInterviewModal from '@/components/krow/AIInterviewModal';
import { useWorkerProfile } from '@/lib/krowHooks';
@@ -14,6 +16,7 @@ export default function Apply() {
const { data: postings = [], isLoading } = useJobPostings();
const createApplication = useCreateApplication();
const { data: profile, isLoading: profileLoading } = useWorkerProfile();
const { data: courses = [] } = useCourses();
const [selectedJob, setSelectedJob] = useState(null);
const [showForm, setShowForm] = useState(false);
const [interviewApp, setInterviewApp] = useState(null);
@@ -96,7 +99,7 @@ export default function Apply() {
</span>
))}
</div>
<button onClick={() => navigate('/owliver')} className="mt-5 inline-flex items-center gap-2 bg-[#0A39DF] hover:bg-[#082EB4] text-white text-[13px] font-semibold px-5 py-2.5 rounded-full transition-all">
<button onClick={() => navigate('/owliver')} className="mt-5 inline-flex items-center gap-2 bg-[#0838E0] hover:bg-[#062BAF] text-white text-[13px] font-semibold px-5 py-2.5 rounded-full transition-all">
Create your KROW Identity <ChevronRight className="w-4 h-4" />
</button>
</div>
@@ -111,6 +114,12 @@ export default function Apply() {
onStart={handleStartInterview}
onBuildIdentity={() => navigate((profile?.profile_completion || 0) < 40 ? '/owliver' : '/identity')}
/>
{/* The candidate's own fit against this role — their skills, this
position's requirements, and nothing about anybody else. The
recruiter's ranked comparison of every candidate is the same
arithmetic and stays on the admin position page. */}
<YourSkillFit job={selectedJob} held={verifiedLevels(courses, profile || {})} />
<button onClick={() => { setShowForm(false); setSelectedJob(null); navigate('/apply', { replace: true }); }} className="text-[13px] text-[#6B7280] hover:text-[#111827] font-medium flex items-center gap-1">
<ArrowLeft className="w-3.5 h-3.5" /> Back to positions
</button>

View File

@@ -95,7 +95,7 @@ export default function Candidates() {
<button
onClick={handleScreenAll}
disabled={screenAll.isPending}
className="inline-flex items-center gap-1.5 bg-[#0A39DF] hover:bg-[#082EB4] disabled:opacity-50 text-white text-[13px] font-medium px-3 py-2 rounded-lg transition-colors"
className="inline-flex items-center gap-1.5 bg-[#0838E0] hover:bg-[#062BAF] disabled:opacity-50 text-white text-[13px] font-medium px-3 py-2 rounded-lg transition-colors"
>
{screenAll.isPending ? 'Screening...' : 'AI Screen All'}
</button>
@@ -109,21 +109,21 @@ export default function Candidates() {
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Search candidates..."
className="w-full h-10 pl-4 pr-4 rounded-lg border border-[#E5E7EB] bg-white text-[13px] focus:border-[#0A39DF] focus:outline-none"
className="w-full h-10 pl-4 pr-4 rounded-lg border border-[#E5E7EB] bg-white text-[13px] focus:border-[#0838E0] focus:outline-none"
/>
</div>
<select value={stageFilter} onChange={(e) => setStageFilter(e.target.value)} className="h-10 px-4 rounded-lg border border-[#E5E7EB] bg-white text-[13px] focus:border-[#0A39DF] focus:outline-none">
<select value={stageFilter} onChange={(e) => setStageFilter(e.target.value)} className="h-10 px-4 rounded-lg border border-[#E5E7EB] bg-white text-[13px] focus:border-[#0838E0] focus:outline-none">
<option value="all">All Stages</option>
<option value="applied">Applied</option>
<option value="ai_screened">AI Screened</option>
<option value="interview">Interviewing</option>
<option value="hired">Hired</option>
</select>
<select value={positionFilter} onChange={(e) => setPositionFilter(e.target.value)} className="h-10 px-4 rounded-lg border border-[#E5E7EB] bg-white text-[13px] focus:border-[#0A39DF] focus:outline-none max-w-[200px] truncate">
<select value={positionFilter} onChange={(e) => setPositionFilter(e.target.value)} className="h-10 px-4 rounded-lg border border-[#E5E7EB] bg-white text-[13px] focus:border-[#0838E0] focus:outline-none max-w-[200px] truncate">
<option value="all">All Positions</option>
{positionOptions.map(p => <option key={p} value={p}>{p}</option>)}
</select>
<select value={scoreFilter} onChange={(e) => setScoreFilter(e.target.value)} className="h-10 px-4 rounded-lg border border-[#E5E7EB] bg-white text-[13px] focus:border-[#0A39DF] focus:outline-none">
<select value={scoreFilter} onChange={(e) => setScoreFilter(e.target.value)} className="h-10 px-4 rounded-lg border border-[#E5E7EB] bg-white text-[13px] focus:border-[#0838E0] focus:outline-none">
<option value="all">All Scores</option>
<option value="high">80+ (Top Talent)</option>
<option value="mid">60-79 (Strong Fit)</option>

View File

@@ -1,7 +1,8 @@
import React, { useState } from 'react';
import { useParams, Link, useNavigate } from 'react-router-dom';
import { useCourse, useCourses, useLearningPaths, useWorkerProfile, useJobPostings } from '@/lib/krowHooks';
import React, { useRef, useState } from 'react';
import { useParams, Link, useNavigate, useSearchParams } from 'react-router-dom';
import { useCourse, useCourses, useLearningPaths, useWorkerProfile, useWorkerProfiles, useJobPostings } from '@/lib/krowHooks';
import { recommendNextCourse, recommendJobs } from '@/lib/krowScore';
import { levelLabel, skillStateFor, unlocksLabel } from '@/lib/skillGraph';
import UniversityCourseCard from '@/components/krow/UniversityCourseCard';
import ChallengeRunner from '@/components/krow/proving/ChallengeRunner';
import { ArrowLeft, Clock, Zap, Award, CheckCircle2, Briefcase, Loader2, MessageSquare, Camera, Video, ScanLine, BookOpen } from 'lucide-react';
@@ -20,14 +21,37 @@ export default function CourseDetail() {
const { id } = useParams();
const navigate = useNavigate();
const { data: course, isLoading } = useCourse(id);
const { data: profile } = useWorkerProfile();
/**
* Who this training is for.
*
* Normally the signed-in worker. Forge opens the module with `?as=<profile>`
* when an administrator is working through a specific person's ladder, and the
* evidence, Owliver's verdict and the level it updates all follow that id — so
* completing a module never credits the wrong record.
*/
const [params] = useSearchParams();
const asId = params.get('as');
const { data: profiles = [] } = useWorkerProfiles();
const { data: ownProfile } = useWorkerProfile({ enabled: !asId });
const profile = asId ? profiles.find((p) => p.id === asId) : ownProfile;
const { data: allCourses = [] } = useCourses();
const { data: paths = [] } = useLearningPaths();
const { data: postings = [] } = useJobPostings();
const [passed, setPassed] = useState(false);
if (isLoading) return <div className="flex justify-center py-20"><Loader2 className="w-6 h-6 animate-spin text-[#0A39DF]" /></div>;
if (!course) return <div className="py-20 text-center text-[#6B7280]">Challenge not found. <Link to="/university" className="text-[#0A39DF] underline">Back to KROW Forge</Link></div>;
/**
* The level this person held when they arrived, captured once.
*
* Owliver's verdict writes to the profile, the profile refetches and the
* derived level moves — so by the time there is something to announce, the
* "before" is already gone. Holding it here is what lets the page say
* *Intermediate → Advanced* rather than just *Advanced*.
*/
const levelOnArrival = useRef(undefined);
if (isLoading) return <div className="flex justify-center py-20"><Loader2 className="w-6 h-6 animate-spin text-[#0838E0]" /></div>;
if (!course) return <div className="py-20 text-center text-[#6B7280]">Challenge not found. <Link to="/university" className="text-[#0838E0] underline">Back to KROW Forge</Link></div>;
const isVerified = (profile?.capabilities || []).some((c) => c.skill === (course.proof_skill || course.title) && c.status === 'verified');
const showRecs = isVerified || passed;
@@ -36,12 +60,19 @@ export default function CourseDetail() {
const type = course?.challenge?.type || 'roleplay';
const Icon = TYPE_ICON[type] || MessageSquare;
/* What this module does to the record, before and after. */
const skillState = course?.skill_id ? skillStateFor(course.skill_id, allCourses, profile || {}) : null;
if (skillState && levelOnArrival.current === undefined) {
levelOnArrival.current = skillState.verifiedLevel;
}
const levelChanged = skillState && levelOnArrival.current !== skillState.verifiedLevel;
return (
<div className="max-w-3xl mx-auto space-y-5">
<button onClick={() => navigate('/university')} className="inline-flex items-center gap-1 text-[13px] text-[#6B7280] hover:text-[#111827] font-medium"><ArrowLeft className="w-3.5 h-3.5" /> Back to KROW Forge</button>
{/* Header */}
<div className="rounded-3xl bg-[#0A39DF] text-white p-6 relative overflow-hidden">
<div className="rounded-3xl bg-[#0838E0] text-white p-6 relative overflow-hidden">
<div aria-hidden className="absolute -right-10 -top-10 w-36 h-36 rounded-full bg-[#F9E547]/20 blur-2xl" />
<div className="flex items-center gap-2 text-[11px] font-medium text-[#9DBFFF] mb-2 flex-wrap">
<span className="px-2 py-0.5 rounded-full bg-white/15">{course.category || 'Skill'}</span>
@@ -58,7 +89,7 @@ export default function CourseDetail() {
{/* How you'll prove it */}
<div className="rounded-2xl border border-[#E5E7EB] bg-white p-5 flex items-center gap-4">
<div className="w-12 h-12 rounded-2xl bg-[#EFF6FF] flex items-center justify-center shrink-0"><Icon className="w-6 h-6 text-[#0A39DF]" /></div>
<div className="w-12 h-12 rounded-2xl bg-[#EEF3FE] flex items-center justify-center shrink-0"><Icon className="w-6 h-6 text-[#0838E0]" /></div>
<div className="min-w-0">
<p className="text-[11px] font-semibold uppercase tracking-wide text-[#94A3B8]">How you’ll prove it</p>
<p className="text-[15px] font-semibold text-[#0F172A]">{TYPE_LABEL[type]}</p>
@@ -69,7 +100,7 @@ export default function CourseDetail() {
{/* Lesson */}
{course.content && (
<div className="rounded-2xl border border-[#E5E7EB] bg-white p-5">
<div className="flex items-center gap-2 mb-3"><BookOpen className="w-4 h-4 text-[#0A39DF]" /><p className="text-[13px] font-semibold text-[#0F172A]">Lesson · {course.estimated_minutes} min</p></div>
<div className="flex items-center gap-2 mb-3"><BookOpen className="w-4 h-4 text-[#0838E0]" /><p className="text-[13px] font-semibold text-[#0F172A]">Lesson · {course.estimated_minutes} min</p></div>
<div className="text-[14px] text-[#374151] leading-relaxed space-y-2 [&_h3]:font-semibold [&_h3]:text-[#111827] [&_ul]:list-disc [&_ul]:pl-5 [&_strong]:text-[#111827]">
<ReactMarkdown>{course.content}</ReactMarkdown>
</div>
@@ -81,6 +112,33 @@ export default function CourseDetail() {
<ChallengeRunner course={course} profile={profile} onPassed={() => setPassed(true)} />
{/* What completing this is worth, stated before it is attempted — the
reason to take this module rather than another one. */}
{skillState && !isVerified && !passed && (
<div className="rounded-2xl border border-[#DBEAFE] bg-[#EEF3FE] p-5">
<p className="text-[11px] font-semibold uppercase tracking-wide text-[#1E40AF]">Unlocks</p>
<p className="mt-0.5 text-[15px] font-semibold text-[#0F172A]">{unlocksLabel(course)}</p>
<p className="mt-1 text-[12px] text-[#1E3A8A]">
You hold {skillState.verifiedLabel}. Owliver checks{' '}
{(course.verification_criteria || course.challenge?.rubric || []).length} criteria before this level is verified.
</p>
</div>
)}
{/* The loop closing: the level moved, and the matches moved with it. */}
{levelChanged && (
<div className="rounded-2xl border border-[#A7F3D0] bg-[#ECFDF5] p-5">
<p className="text-[11px] font-semibold uppercase tracking-wide text-[#047857]">Skill verified</p>
<p className="mt-0.5 text-[15px] font-semibold text-[#065F46]">
{skillState.name}: {levelLabel(levelOnArrival.current) }{' → '}{skillState.verifiedLabel}
</p>
<p className="mt-1 text-[12px] text-[#047857]">
Owliver passed your evidence, so this level is now backed by it — and every position
match has been recalculated.
</p>
</div>
)}
{showRecs && (
<div className="space-y-3">
{course.badge_reward && (isVerified || passed) && (
@@ -96,13 +154,13 @@ export default function CourseDetail() {
</div>
)}
<div className="bg-white border border-[#E5E7EB] rounded-2xl p-4">
<p className="text-[12px] font-semibold text-[#111827] mb-1 flex items-center gap-1.5"><Briefcase className="w-4 h-4 text-[#0A39DF]" /> Opportunities you’re now better matched for</p>
<p className="text-[12px] font-semibold text-[#111827] mb-1 flex items-center gap-1.5"><Briefcase className="w-4 h-4 text-[#0838E0]" /> Opportunities you’re now better matched for</p>
<p className="text-[11px] text-[#6B7280] mb-3">Your verified Identity matches against open Opportunities automatically.</p>
{jobRecs.length === 0 ? <p className="text-[12px] text-[#6B7280]">No open positions match yet — keep proving skills to improve your matches.</p> : (
<div className="space-y-2">{jobRecs.map((r) => (
<Link key={r.job.id} to={`/positions/${r.job.id}`} className="flex items-center justify-between gap-3 p-3 rounded-lg border border-[#E5E7EB] hover:border-[#0A39DF] transition-colors">
<Link key={r.job.id} to={`/positions/${r.job.id}`} className="flex items-center justify-between gap-3 p-3 rounded-lg border border-[#E5E7EB] hover:border-[#0838E0] transition-colors">
<div><p className="text-[13px] font-medium text-[#111827]">{r.job.title}</p><p className="text-[11px] text-[#6B7280]">{r.job.role_category} · {r.job.location}</p></div>
<span className="text-[13px] font-bold text-[#0A39DF]">{r.match}% match</span>
<span className="text-[13px] font-bold text-[#0838E0]">{r.match}% match</span>
</Link>
))}</div>
)}

View File

@@ -5,6 +5,7 @@ import { useCreateJobPosting, useUpdateJobPosting, useGenerateJobDescription, us
import { toast } from 'react-hot-toast';
import { ROLE_CATEGORIES } from '@/lib/roleCategories';
import { CERT_OPTIONS, CRITERIA_LABELS, ENGLISH_LEVELS, defaultPosition } from '@/lib/positionModel';
import { SkillRequirementsField } from '@/components/krow/SkillRequirementsField';
/**
* Create a Position.
@@ -133,8 +134,8 @@ export default function CreatePosition({ prefill: prefillProp, embedded = false,
: 'bg-white border border-[#E5E7EB] rounded-2xl p-6 md:p-8 space-y-6'}>
{/* Section Header */}
<div className="flex items-center gap-2">
<div className="w-7 h-7 rounded-lg bg-[#EFF6FF] flex items-center justify-center">
<Sparkles className="w-4 h-4 text-[#0A39DF]" />
<div className="w-7 h-7 rounded-lg bg-[#EEF3FE] flex items-center justify-center">
<Sparkles className="w-4 h-4 text-[#0838E0]" />
</div>
<h2 className="text-[16px] font-semibold text-[#111827]">Create Job Posting</h2>
</div>
@@ -143,19 +144,19 @@ export default function CreatePosition({ prefill: prefillProp, embedded = false,
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="text-[13px] font-medium text-[#374151]">Role Category *</label>
<select value={form.role_category} onChange={(e) => update('role_category', e.target.value)} className="w-full mt-1.5 h-10 rounded-lg border border-[#E5E7EB] bg-white px-3 text-[13px] focus:border-[#0A39DF] focus:outline-none">
<select value={form.role_category} onChange={(e) => update('role_category', e.target.value)} className="w-full mt-1.5 h-10 rounded-lg border border-[#E5E7EB] bg-white px-3 text-[13px] focus:border-[#0838E0] focus:outline-none">
{allCategories.map(r => <option key={r} value={r}>{r}</option>)}
</select>
</div>
<div>
<label className="text-[13px] font-medium text-[#374151]">Job Title *</label>
<input value={form.title} onChange={(e) => update('title', e.target.value)} placeholder="e.g. Experienced Server" className="w-full mt-1.5 h-10 rounded-lg border border-[#E5E7EB] bg-white px-3 text-[13px] focus:border-[#0A39DF] focus:outline-none" />
<input value={form.title} onChange={(e) => update('title', e.target.value)} placeholder="e.g. Experienced Server" className="w-full mt-1.5 h-10 rounded-lg border border-[#E5E7EB] bg-white px-3 text-[13px] focus:border-[#0838E0] focus:outline-none" />
</div>
</div>
{/* Add new category */}
<div className="flex gap-2">
<input value={newCategory} onChange={(e) => setNewCategory(e.target.value)} placeholder="Create a new role category (saved for reuse)..." className="flex-1 h-10 rounded-lg border border-[#E5E7EB] px-3 text-[13px] focus:border-[#0A39DF] focus:outline-none" />
<input value={newCategory} onChange={(e) => setNewCategory(e.target.value)} placeholder="Create a new role category (saved for reuse)..." className="flex-1 h-10 rounded-lg border border-[#E5E7EB] px-3 text-[13px] focus:border-[#0838E0] focus:outline-none" />
<button
onClick={async () => {
if (newCategory.trim()) {
@@ -167,7 +168,7 @@ export default function CreatePosition({ prefill: prefillProp, embedded = false,
}
}}
disabled={createRoleCategory.isPending || !newCategory.trim()}
className="px-4 bg-[#0A39DF] text-white text-[13px] font-medium rounded-lg hover:bg-[#082EB4] disabled:opacity-50 whitespace-nowrap"
className="px-4 bg-[#0838E0] text-white text-[13px] font-medium rounded-lg hover:bg-[#062BAF] disabled:opacity-50 whitespace-nowrap"
>
+ Add
</button>
@@ -176,22 +177,22 @@ export default function CreatePosition({ prefill: prefillProp, embedded = false,
{/* Requirements */}
<div>
<label className="text-[13px] font-medium text-[#374151]">Custom Requirements (AI will use this)</label>
<textarea value={form.custom_requirements} onChange={(e) => update('custom_requirements', e.target.value)} placeholder="e.g. Must have fine dining experience..." className="w-full mt-1.5 min-h-[70px] p-3 text-[13px] rounded-lg border border-[#E5E7EB] focus:border-[#0A39DF] focus:outline-none resize-none" />
<textarea value={form.custom_requirements} onChange={(e) => update('custom_requirements', e.target.value)} placeholder="e.g. Must have fine dining experience..." className="w-full mt-1.5 min-h-[70px] p-3 text-[13px] rounded-lg border border-[#E5E7EB] focus:border-[#0838E0] focus:outline-none resize-none" />
</div>
{/* Physical / Leadership / Attendance Expectations */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div>
<label className="text-[13px] font-medium text-[#374151]">Physical Requirements</label>
<input value={form.physical_requirements} onChange={(e) => update('physical_requirements', e.target.value)} placeholder="e.g. Lift 50 lbs, stand 8 hrs" className="w-full mt-1.5 h-10 rounded-lg border border-[#E5E7EB] bg-white px-3 text-[13px] focus:border-[#0A39DF] focus:outline-none" />
<input value={form.physical_requirements} onChange={(e) => update('physical_requirements', e.target.value)} placeholder="e.g. Lift 50 lbs, stand 8 hrs" className="w-full mt-1.5 h-10 rounded-lg border border-[#E5E7EB] bg-white px-3 text-[13px] focus:border-[#0838E0] focus:outline-none" />
</div>
<div>
<label className="text-[13px] font-medium text-[#374151]">Leadership Expectations</label>
<input value={form.leadership_expectations} onChange={(e) => update('leadership_expectations', e.target.value)} placeholder="e.g. Lead a team of 5" className="w-full mt-1.5 h-10 rounded-lg border border-[#E5E7EB] bg-white px-3 text-[13px] focus:border-[#0A39DF] focus:outline-none" />
<input value={form.leadership_expectations} onChange={(e) => update('leadership_expectations', e.target.value)} placeholder="e.g. Lead a team of 5" className="w-full mt-1.5 h-10 rounded-lg border border-[#E5E7EB] bg-white px-3 text-[13px] focus:border-[#0838E0] focus:outline-none" />
</div>
<div>
<label className="text-[13px] font-medium text-[#374151]">Attendance Expectations</label>
<input value={form.attendance_expectations} onChange={(e) => update('attendance_expectations', e.target.value)} placeholder="e.g. On-time, no weekends off" className="w-full mt-1.5 h-10 rounded-lg border border-[#E5E7EB] bg-white px-3 text-[13px] focus:border-[#0A39DF] focus:outline-none" />
<input value={form.attendance_expectations} onChange={(e) => update('attendance_expectations', e.target.value)} placeholder="e.g. On-time, no weekends off" className="w-full mt-1.5 h-10 rounded-lg border border-[#E5E7EB] bg-white px-3 text-[13px] focus:border-[#0838E0] focus:outline-none" />
</div>
</div>
@@ -199,17 +200,17 @@ export default function CreatePosition({ prefill: prefillProp, embedded = false,
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div>
<label className="text-[13px] font-medium text-[#374151]">Min. Experience (yrs)</label>
<input type="number" min="0" value={form.min_experience_years} onChange={(e) => update('min_experience_years', parseInt(e.target.value) || 0)} className="w-full mt-1.5 h-10 rounded-lg border border-[#E5E7EB] px-3 text-[13px] focus:border-[#0A39DF] focus:outline-none" />
<input type="number" min="0" value={form.min_experience_years} onChange={(e) => update('min_experience_years', parseInt(e.target.value) || 0)} className="w-full mt-1.5 h-10 rounded-lg border border-[#E5E7EB] px-3 text-[13px] focus:border-[#0838E0] focus:outline-none" />
</div>
<div>
<label className="text-[13px] font-medium text-[#374151]">English Level</label>
<select value={form.english_required} onChange={(e) => update('english_required', e.target.value)} className="w-full mt-1.5 h-10 rounded-lg border border-[#E5E7EB] bg-white px-3 text-[13px] focus:border-[#0A39DF] focus:outline-none">
<select value={form.english_required} onChange={(e) => update('english_required', e.target.value)} className="w-full mt-1.5 h-10 rounded-lg border border-[#E5E7EB] bg-white px-3 text-[13px] focus:border-[#0838E0] focus:outline-none">
{ENGLISH_LEVELS.map(l => <option key={l.value} value={l.value}>{l.label}</option>)}
</select>
</div>
<div>
<label className="text-[13px] font-medium text-[#374151]">Location</label>
<input value={form.location} onChange={(e) => update('location', e.target.value)} placeholder="Bay Area, LA..." className="w-full mt-1.5 h-10 rounded-lg border border-[#E5E7EB] px-3 text-[13px] focus:border-[#0A39DF] focus:outline-none" />
<input value={form.location} onChange={(e) => update('location', e.target.value)} placeholder="Bay Area, LA..." className="w-full mt-1.5 h-10 rounded-lg border border-[#E5E7EB] px-3 text-[13px] focus:border-[#0838E0] focus:outline-none" />
</div>
</div>
@@ -217,11 +218,11 @@ export default function CreatePosition({ prefill: prefillProp, embedded = false,
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="text-[13px] font-medium text-[#374151]">Pay Min ($/hr)</label>
<input type="number" min="0" value={form.pay_range_min} onChange={(e) => update('pay_range_min', e.target.value)} placeholder="e.g. 18" className="w-full mt-1.5 h-10 rounded-lg border border-[#E5E7EB] px-3 text-[13px] focus:border-[#0A39DF] focus:outline-none" />
<input type="number" min="0" value={form.pay_range_min} onChange={(e) => update('pay_range_min', e.target.value)} placeholder="e.g. 18" className="w-full mt-1.5 h-10 rounded-lg border border-[#E5E7EB] px-3 text-[13px] focus:border-[#0838E0] focus:outline-none" />
</div>
<div>
<label className="text-[13px] font-medium text-[#374151]">Pay Max ($/hr)</label>
<input type="number" min="0" value={form.pay_range_max} onChange={(e) => update('pay_range_max', e.target.value)} placeholder="e.g. 28" className="w-full mt-1.5 h-10 rounded-lg border border-[#E5E7EB] px-3 text-[13px] focus:border-[#0A39DF] focus:outline-none" />
<input type="number" min="0" value={form.pay_range_max} onChange={(e) => update('pay_range_max', e.target.value)} placeholder="e.g. 28" className="w-full mt-1.5 h-10 rounded-lg border border-[#E5E7EB] px-3 text-[13px] focus:border-[#0838E0] focus:outline-none" />
</div>
</div>
@@ -235,7 +236,7 @@ export default function CreatePosition({ prefill: prefillProp, embedded = false,
type="button"
onClick={() => toggleCert(cert)}
className={`px-3 py-1.5 rounded-full text-[12px] font-medium border transition-all ${
form.certifications_required.includes(cert) ? 'bg-[#0A39DF] border-[#0A39DF] text-white' : 'border-[#E5E7EB] text-[#6B7280] hover:border-[#D1D5DB]'
form.certifications_required.includes(cert) ? 'bg-[#0838E0] border-[#0838E0] text-white' : 'border-[#E5E7EB] text-[#6B7280] hover:border-[#D1D5DB]'
}`}
>
{cert}
@@ -244,11 +245,19 @@ export default function CreatePosition({ prefill: prefillProp, embedded = false,
</div>
</div>
{/* Required skills — what the matching engine reads. Placed next to the
certifications it sits beside conceptually: both are "what a person
must hold", one asserted on a résumé and one verified in Forge. */}
<SkillRequirementsField
value={form.skill_requirements}
onChange={(next) => update('skill_requirements', next)}
/>
{/* AI Generate Button */}
<button
onClick={handleGenerateDescription}
disabled={generateDesc.isPending || createJob.isPending}
className="w-full bg-[#0A39DF] hover:bg-[#082EB4] disabled:opacity-60 text-white font-medium py-3 rounded-xl text-[14px] flex items-center justify-center gap-2 transition-colors"
className="w-full bg-[#0838E0] hover:bg-[#062BAF] disabled:opacity-60 text-white font-medium py-3 rounded-xl text-[14px] flex items-center justify-center gap-2 transition-colors"
>
{generateDesc.isPending || createJob.isPending ? (
<><Loader2 className="w-4 h-4 animate-spin" /> Generating...</>
@@ -259,7 +268,7 @@ export default function CreatePosition({ prefill: prefillProp, embedded = false,
{/* AI Result Preview */}
{aiResult && (
<div className="rounded-xl border border-[#DBEAFE] bg-[#EFF6FF] p-4 space-y-3">
<div className="rounded-xl border border-[#DBEAFE] bg-[#EEF3FE] p-4 space-y-3">
<div className="flex items-center gap-1.5 text-[#1E40AF] text-[13px] font-medium">
<Sparkles className="w-3.5 h-3.5" /> AI-Generated Description
</div>
@@ -292,7 +301,7 @@ export default function CreatePosition({ prefill: prefillProp, embedded = false,
<label className="text-[12px] text-[#374151]">{CRITERIA_LABELS[key]}</label>
<span className={`text-[12px] font-medium ${totalWeight === 100 ? 'text-[#059669]' : 'text-[#F59E0B]'}`}>{val}%</span>
</div>
<input type="range" min="0" max="100" value={val} onChange={(e) => updateWeight(key, e.target.value)} className="w-full accent-[#0A39DF]" />
<input type="range" min="0" max="100" value={val} onChange={(e) => updateWeight(key, e.target.value)} className="w-full accent-[#0838E0]" />
</div>
))}
<div className="md:col-span-2 text-[12px] text-[#6B7280]">
@@ -308,7 +317,7 @@ export default function CreatePosition({ prefill: prefillProp, embedded = false,
<button onClick={() => handleSave('draft')} disabled={createJob.isPending || updateJob.isPending} className="border border-[#E5E7EB] hover:bg-[#F9FAFB] text-[#374151] font-medium py-3 rounded-xl text-[14px] transition-colors disabled:opacity-50">
Save as Draft
</button>
<button onClick={() => handleSave('active')} disabled={createJob.isPending || updateJob.isPending} className="bg-[#0A39DF] hover:bg-[#082EB4] text-white font-medium py-3 rounded-xl text-[14px] transition-colors disabled:opacity-50">
<button onClick={() => handleSave('active')} disabled={createJob.isPending || updateJob.isPending} className="bg-[#0838E0] hover:bg-[#062BAF] text-white font-medium py-3 rounded-xl text-[14px] transition-colors disabled:opacity-50">
Publish Job Posting
</button>
</div>

View File

@@ -113,7 +113,7 @@ export default function DesignSystem() {
<div className="space-y-4">
<Row label="Brand">
{[
['krow-blue', '#0A39DF'], ['krow-blue-dark', '#082EB4'], ['krow-yellow', '#F9E547'],
['krow-blue', '#0838E0'], ['krow-blue-dark', '#062BAF'], ['krow-yellow', '#F9E547'],
['krow-yellow-pale', '#F8E08E'], ['krow-navy', '#333F48'], ['krow-mint', '#D1E0D7'],
].map(([name, hex]) => (
<div key={name} className="text-center">

View File

@@ -15,7 +15,7 @@ export default function EmployeeDashboard() {
const { data: paths = [] } = useLearningPaths();
if (isLoading || !profile) {
return <div className="flex justify-center py-20"><Loader2 className="w-6 h-6 animate-spin text-[#0A39DF]" /></div>;
return <div className="flex justify-center py-20"><Loader2 className="w-6 h-6 animate-spin text-[#0838E0]" /></div>;
}
const jobRecs = recommendJobs(profile, postings, 6);

View File

@@ -6,7 +6,7 @@ import RateEndorseModal from '@/components/krow/RateEndorseModal';
import RetentionMetrics from '@/components/krow/RetentionMetrics';
const TIER_STYLES = {
Skilled: 'bg-[#EFF6FF] text-[#1E40AF]',
Skilled: 'bg-[#EEF3FE] text-[#1E40AF]',
'Cross-Trained': 'bg-[#F0F4F8] text-[#1E56C1]',
Beginner: 'bg-[#F3F4F6] text-[#6B7280]',
};
@@ -99,16 +99,16 @@ export default function HiredHistory() {
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Search hires by name, email, or role..."
className="w-full h-10 pl-9 pr-4 rounded-lg border border-[#E5E7EB] bg-white text-[13px] focus:border-[#0A39DF] focus:outline-none"
className="w-full h-10 pl-9 pr-4 rounded-lg border border-[#E5E7EB] bg-white text-[13px] focus:border-[#0838E0] focus:outline-none"
/>
</div>
<select value={tierFilter} onChange={(e) => setTierFilter(e.target.value)} className="h-10 px-4 rounded-lg border border-[#E5E7EB] bg-white text-[13px] focus:border-[#0A39DF] focus:outline-none">
<select value={tierFilter} onChange={(e) => setTierFilter(e.target.value)} className="h-10 px-4 rounded-lg border border-[#E5E7EB] bg-white text-[13px] focus:border-[#0838E0] focus:outline-none">
<option value="all">All Tiers</option>
<option value="Skilled">Skilled</option>
<option value="Cross-Trained">Cross-Trained</option>
<option value="Beginner">Beginner</option>
</select>
<select value={reviewFilter} onChange={(e) => setReviewFilter(e.target.value)} className="h-10 px-4 rounded-lg border border-[#E5E7EB] bg-white text-[13px] focus:border-[#0A39DF] focus:outline-none">
<select value={reviewFilter} onChange={(e) => setReviewFilter(e.target.value)} className="h-10 px-4 rounded-lg border border-[#E5E7EB] bg-white text-[13px] focus:border-[#0838E0] focus:outline-none">
<option value="all">All Reviews</option>
<option value="rated">Rated</option>
<option value="pending">Pending Review</option>
@@ -136,7 +136,7 @@ export default function HiredHistory() {
<div key={s.id} className="bg-white border border-[#E5E7EB] rounded-xl p-4">
<div className="flex flex-col sm:flex-row sm:items-center gap-3">
<div className="flex items-center gap-3 flex-1 min-w-0">
<div className="w-11 h-11 rounded-full bg-[#EFF6FF] text-[#0A39DF] flex items-center justify-center font-bold text-[14px] shrink-0">
<div className="w-11 h-11 rounded-full bg-[#EEF3FE] text-[#0838E0] flex items-center justify-center font-bold text-[14px] shrink-0">
{(s.name || '?').split(' ').map(w => w[0]).slice(0, 2).join('').toUpperCase()}
</div>
<div className="min-w-0 flex-1">
@@ -169,7 +169,7 @@ export default function HiredHistory() {
</div>
<button
onClick={() => setRateStaff(s)}
className="inline-flex items-center gap-1.5 bg-[#0A39DF] hover:bg-[#082EB4] text-white text-[12px] font-medium px-3 py-2 rounded-lg transition-colors"
className="inline-flex items-center gap-1.5 bg-[#0838E0] hover:bg-[#062BAF] text-white text-[12px] font-medium px-3 py-2 rounded-lg transition-colors"
>
<Award className="w-3.5 h-3.5" />
{rating > 0 ? 'Update' : 'Rate & Endorse'}

View File

@@ -41,7 +41,7 @@ export default function KrowIdentity() {
}, [profile, form]);
if (isLoading || !profile || !form) {
return <div className="flex justify-center py-20"><Loader2 className="w-6 h-6 animate-spin text-[#0A39DF]" /></div>;
return <div className="flex justify-center py-20"><Loader2 className="w-6 h-6 animate-spin text-[#0838E0]" /></div>;
}
const set = (k, v) => setForm({ ...form, [k]: v });
@@ -102,7 +102,7 @@ export default function KrowIdentity() {
<RefreshCw className="w-3.5 h-3.5" /> Retake
</button>
) : (
<label className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-[#0A39DF] hover:bg-[#082EB4] text-white text-[12px] font-medium cursor-pointer">
<label className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-[#0838E0] hover:bg-[#062BAF] text-white text-[12px] font-medium cursor-pointer">
{uploadingSelfie ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Camera className="w-3.5 h-3.5" />}
{uploadingSelfie ? 'Uploading...' : 'Take selfie'}
<input type="file" accept="image/*" capture="user" onChange={handleSelfie} disabled={uploadingSelfie} className="hidden" />
@@ -124,7 +124,7 @@ export default function KrowIdentity() {
<div className="bg-white border border-[#E5E7EB] rounded-2xl p-5 sm:p-6 space-y-2">
<Label className="text-[13px] text-[#374151]">About you</Label>
<p className="text-[11px] text-[#6B7280]">One or two lines. What you do and what you're good at. This replaces the resume summary.</p>
<textarea value={form.career_goals} onChange={e => set('career_goals', e.target.value)} className="w-full min-h-[80px] p-3 text-[13px] rounded-lg border border-[#E5E7EB] focus:border-[#0A39DF] focus:outline-none resize-none" placeholder="e.g. Line cook with 4 years in fast-casual kitchens. Fast, clean, reliable." />
<textarea value={form.career_goals} onChange={e => set('career_goals', e.target.value)} className="w-full min-h-[80px] p-3 text-[13px] rounded-lg border border-[#E5E7EB] focus:border-[#0838E0] focus:outline-none resize-none" placeholder="e.g. Line cook with 4 years in fast-casual kitchens. Fast, clean, reliable." />
</div>
{/* Skills */}
@@ -164,12 +164,12 @@ export default function KrowIdentity() {
<Label className="text-[13px] text-[#374151]">Certifications</Label>
<div className="flex flex-wrap gap-2 mt-1.5">
{certOptions.map(c => (
<button key={c} type="button" onClick={() => toggleCert(c)} className={`px-3 py-1.5 rounded-full text-[12px] font-medium border transition-all ${form.certifications.includes(c) ? 'bg-[#0A39DF] border-[#0A39DF] text-white' : 'border-[#E5E7EB] text-[#6B7280] hover:border-[#D1D5DB]'}`}>{c}</button>
<button key={c} type="button" onClick={() => toggleCert(c)} className={`px-3 py-1.5 rounded-full text-[12px] font-medium border transition-all ${form.certifications.includes(c) ? 'bg-[#0838E0] border-[#0838E0] text-white' : 'border-[#E5E7EB] text-[#6B7280] hover:border-[#D1D5DB]'}`}>{c}</button>
))}
</div>
</div>
<Button onClick={save} disabled={update.isPending} className="w-full h-11 text-[14px] bg-[#0A39DF] hover:bg-[#082EB4]">
<Button onClick={save} disabled={update.isPending} className="w-full h-11 text-[14px] bg-[#0838E0] hover:bg-[#062BAF]">
{update.isPending ? <Loader2 className="w-4 h-4 mr-2 animate-spin" /> : <Sparkles className="w-4 h-4 mr-2" />}
Create Identity <ArrowRight className="w-4 h-4 ml-1" />
</Button>
@@ -179,7 +179,7 @@ export default function KrowIdentity() {
function Chips({ items, onRemove, tone = 'gray' }) {
if (!items?.length) return null;
const cls = tone === 'blue' ? 'bg-[#EFF6FF] text-[#1E40AF] border-[#DBEAFE]' : 'bg-[#F3F4F6] text-[#374151]';
const cls = tone === 'blue' ? 'bg-[#EEF3FE] text-[#1E40AF] border-[#DBEAFE]' : 'bg-[#F3F4F6] text-[#374151]';
return (
<div className="flex flex-wrap gap-1.5 mt-2">
{items.map((s, i) => (

View File

@@ -6,12 +6,12 @@ import KROWCandidateLeaderboardReal from '@/components/krow/KROWCandidateLeaderb
import { Briefcase, Users, Brain, Clock, TrendingUp, Award, ChevronRight } from 'lucide-react';
const METRICS = [
{ icon: Briefcase, label: 'Open Positions', bg: 'bg-[#0A39DF]', glow: 'shadow-blue-600/20' },
{ icon: Briefcase, label: 'Open Positions', bg: 'bg-[#0838E0]', glow: 'shadow-blue-600/20' },
{ icon: Users, label: 'Total Candidates', bg: 'bg-[#333F48]', glow: 'shadow-slate-600/20' },
{ icon: Brain, label: 'AI Screened', bg: 'bg-[#0A39DF]', glow: 'shadow-blue-600/20' },
{ icon: Brain, label: 'AI Screened', bg: 'bg-[#0838E0]', glow: 'shadow-blue-600/20' },
{ icon: Clock, label: 'Pending Screen', bg: 'bg-[#F9E547]', glow: 'shadow-yellow-400/30' },
{ icon: TrendingUp, label: 'Avg AI Score', bg: 'bg-[#333F48]', glow: 'shadow-slate-600/20' },
{ icon: Award, label: 'Hired', bg: 'bg-[#0A39DF]', glow: 'shadow-blue-600/20' },
{ icon: Award, label: 'Hired', bg: 'bg-[#0838E0]', glow: 'shadow-blue-600/20' },
];
@@ -27,7 +27,7 @@ function MetricCard({ icon: Icon, value, label, bg, glow, index }) {
className="glass-card border border-white/60 rounded-2xl p-4 shadow-sm hover:shadow-lg transition-shadow"
>
<div className={`w-10 h-10 rounded-xl ${bg} flex items-center justify-center shadow-lg ${glow} mb-3`}>
<Icon className={`w-5 h-5 ${isYellow ? 'text-[#0A39DF]' : 'text-white'}`} />
<Icon className={`w-5 h-5 ${isYellow ? 'text-[#0838E0]' : 'text-white'}`} />
</div>
<motion.div
key={value}
@@ -103,7 +103,7 @@ export default function Overview() {
>
<div className="flex items-center justify-between mb-5">
<h3 className="font-heading font-semibold text-[#111827] text-[16px]">Top Candidates</h3>
<button onClick={() => navigate('/candidates')} className="text-[12px] text-[#0A39DF] hover:underline inline-flex items-center gap-1 font-medium">
<button onClick={() => navigate('/candidates')} className="text-[12px] text-[#0838E0] hover:underline inline-flex items-center gap-1 font-medium">
View all <ChevronRight className="w-3 h-3" />
</button>
</div>
@@ -126,7 +126,7 @@ export default function Overview() {
onClick={() => navigate('/candidates')}
>
<span className="text-[13px] font-heading font-bold text-[#9CA3AF] w-7">{idx + 1}</span>
<div className="w-9 h-9 rounded-full bg-[#0A39DF] flex items-center justify-center text-white text-[12px] font-semibold shrink-0">
<div className="w-9 h-9 rounded-full bg-[#0838E0] flex items-center justify-center text-white text-[12px] font-semibold shrink-0">
{app.applicant_name?.split(' ').map(w => w[0]).slice(0, 2).join('').toUpperCase()}
</div>
<div className="flex-1 min-w-0">
@@ -135,7 +135,7 @@ export default function Overview() {
</div>
<div className="flex items-center gap-2">
<div className="w-16 h-1.5 rounded-full bg-gray-100 overflow-hidden">
<div className="h-full bg-[#0A39DF] rounded-full" style={{ width: `${app.ai_score}%` }} />
<div className="h-full bg-[#0838E0] rounded-full" style={{ width: `${app.ai_score}%` }} />
</div>
<span className="text-[14px] font-heading font-bold gradient-text w-7 text-right">{app.ai_score}</span>
</div>
@@ -153,7 +153,7 @@ export default function Overview() {
>
<div className="flex items-center justify-between mb-5">
<h3 className="font-heading font-semibold text-[#111827] text-[16px]">Open Positions</h3>
<button onClick={() => navigate('/positions')} className="text-[12px] text-[#0A39DF] hover:underline inline-flex items-center gap-1 font-medium">
<button onClick={() => navigate('/positions')} className="text-[12px] text-[#0838E0] hover:underline inline-flex items-center gap-1 font-medium">
View all <ChevronRight className="w-3 h-3" />
</button>
</div>
@@ -178,12 +178,12 @@ export default function Overview() {
className="flex items-center gap-3 p-2.5 rounded-xl hover:bg-white/50 transition-colors cursor-pointer group"
>
<span className="text-[13px] font-heading font-bold text-[#9CA3AF] w-7">{idx + 1}</span>
<div className="w-1 h-9 rounded-full bg-[#0A39DF] shrink-0" />
<div className="w-1 h-9 rounded-full bg-[#0838E0] shrink-0" />
<div className="flex-1 min-w-0">
<div className="text-[13px] font-medium text-[#111827] truncate">{posting.title}</div>
<div className="text-[11px] text-[#6B7280]">{stats.applied} applicants · {stats.screened} screened</div>
</div>
<ChevronRight className="w-4 h-4 text-[#D1D5DB] group-hover:text-[#0A39DF] transition-colors" />
<ChevronRight className="w-4 h-4 text-[#D1D5DB] group-hover:text-[#0838E0] transition-colors" />
</motion.div>
);
})}

View File

@@ -305,14 +305,14 @@ export default function Owliver({ compact = false }) {
}, [stopListening]);
if (isLoading) {
return <div className="flex justify-center py-24"><Loader2 className="w-6 h-6 animate-spin text-[#0A39DF]" /></div>;
return <div className="flex justify-center py-24"><Loader2 className="w-6 h-6 animate-spin text-[#0838E0]" /></div>;
}
return (
<div className={cn('max-w-2xl mx-auto space-y-6', compact && 'h-full flex flex-col')}>
{/* Hero — page only */}
{!compact && (
<div className="rounded-2xl bg-[#0A39DF] p-6 sm:p-8 text-white">
<div className="rounded-2xl bg-[#0838E0] p-6 sm:p-8 text-white">
<span className="inline-flex items-center gap-1.5 text-[11px] font-medium tracking-wide opacity-90 bg-white/15 px-2.5 py-1 rounded-full">
🦉 {t.badge}
</span>
@@ -330,15 +330,15 @@ export default function Owliver({ compact = false }) {
<p className="text-[13px] text-[#6B7280]">{t.introP}</p>
<div className="space-y-3 mt-4">
<div className="flex items-center justify-between flex-wrap gap-3">
<button onClick={() => setTtsEnabled(!ttsEnabled)} className="inline-flex items-center gap-1.5 text-[13px] font-medium text-[#0A39DF]">
<button onClick={() => setTtsEnabled(!ttsEnabled)} className="inline-flex items-center gap-1.5 text-[13px] font-medium text-[#0838E0]">
{ttsEnabled ? <Volume2 className="w-4 h-4" /> : <VolumeX className="w-4 h-4" />} {ttsEnabled ? t.speaks : t.muted}
</button>
<div className="inline-flex rounded-full border border-[#E5E7EB] overflow-hidden">
<button onClick={() => setLanguage('en')} className={cn('px-3 py-1 text-[12px] font-semibold', language === 'en' ? 'bg-[#0A39DF] text-white' : 'bg-white text-[#6B7280]')}>EN</button>
<button onClick={() => setLanguage('es')} className={cn('px-3 py-1 text-[12px] font-semibold', language === 'es' ? 'bg-[#0A39DF] text-white' : 'bg-white text-[#6B7280]')}>ES</button>
<button onClick={() => setLanguage('en')} className={cn('px-3 py-1 text-[12px] font-semibold', language === 'en' ? 'bg-[#0838E0] text-white' : 'bg-white text-[#6B7280]')}>EN</button>
<button onClick={() => setLanguage('es')} className={cn('px-3 py-1 text-[12px] font-semibold', language === 'es' ? 'bg-[#0838E0] text-white' : 'bg-white text-[#6B7280]')}>ES</button>
</div>
</div>
<Button onClick={startChat} className="w-full h-12 text-[14px] bg-[#0A39DF] hover:bg-[#082EB4]">
<Button onClick={startChat} className="w-full h-12 text-[14px] bg-[#0838E0] hover:bg-[#062BAF]">
<Mic className="w-4 h-4 mr-2" /> {t.start}
</Button>
</div>
@@ -359,16 +359,16 @@ export default function Owliver({ compact = false }) {
</div>
<div className="flex items-center justify-between flex-wrap gap-3">
<button onClick={() => setTtsEnabled(!ttsEnabled)} className="inline-flex items-center gap-1.5 text-[13px] font-medium text-[#0A39DF]">
<button onClick={() => setTtsEnabled(!ttsEnabled)} className="inline-flex items-center gap-1.5 text-[13px] font-medium text-[#0838E0]">
{ttsEnabled ? <Volume2 className="w-4 h-4" /> : <VolumeX className="w-4 h-4" />} {ttsEnabled ? t.speaks : t.muted}
</button>
<div className="inline-flex rounded-full border border-[#E5E7EB] overflow-hidden">
<button onClick={() => setLanguage('en')} className={cn('px-3 py-1 text-[12px] font-semibold', language === 'en' ? 'bg-[#0A39DF] text-white' : 'bg-white text-[#6B7280]')}>EN</button>
<button onClick={() => setLanguage('es')} className={cn('px-3 py-1 text-[12px] font-semibold', language === 'es' ? 'bg-[#0A39DF] text-white' : 'bg-white text-[#6B7280]')}>ES</button>
<button onClick={() => setLanguage('en')} className={cn('px-3 py-1 text-[12px] font-semibold', language === 'en' ? 'bg-[#0838E0] text-white' : 'bg-white text-[#6B7280]')}>EN</button>
<button onClick={() => setLanguage('es')} className={cn('px-3 py-1 text-[12px] font-semibold', language === 'es' ? 'bg-[#0838E0] text-white' : 'bg-white text-[#6B7280]')}>ES</button>
</div>
</div>
<Button onClick={startChat} className="w-full h-12 text-[14px] bg-[#0A39DF] hover:bg-[#082EB4]">
<Button onClick={startChat} className="w-full h-12 text-[14px] bg-[#0838E0] hover:bg-[#062BAF]">
<Mic className="w-4 h-4 mr-2" /> {t.start}
</Button>
</div>
@@ -389,7 +389,7 @@ export default function Owliver({ compact = false }) {
</div>
<div className="h-1.5 w-full rounded-full bg-[#F3F4F6] overflow-hidden">
<div className="h-full bg-[#0A39DF] transition-all duration-500" style={{ width: `${Math.min(100, (messages.filter(m => m.role === 'user').length / QUESTION_COUNT) * 100)}%` }} />
<div className="h-full bg-[#0838E0] transition-all duration-500" style={{ width: `${Math.min(100, (messages.filter(m => m.role === 'user').length / QUESTION_COUNT) * 100)}%` }} />
</div>
<div ref={scrollRef} className="h-[320px] overflow-y-auto space-y-3 p-1">
@@ -400,7 +400,7 @@ export default function Owliver({ compact = false }) {
) : (
<div className={cn(
'max-w-[82%] rounded-2xl px-4 py-2.5 text-[14px] leading-snug',
msg.role === 'user' ? 'bg-[#0A39DF] text-white rounded-br-sm' : 'bg-[#F3F4F6] text-[#111827] rounded-bl-sm'
msg.role === 'user' ? 'bg-[#0838E0] text-white rounded-br-sm' : 'bg-[#F3F4F6] text-[#111827] rounded-bl-sm'
)}>
<p>{msg.content}</p>
</div>
@@ -425,7 +425,7 @@ export default function Owliver({ compact = false }) {
value={transcript}
onChange={(e) => setTranscript(e.target.value)}
placeholder={isListening ? t.listening : t.placeholder}
className="w-full min-h-[60px] p-3 text-[14px] rounded-lg border border-[#E5E7EB] focus:border-[#0A39DF] focus:outline-none resize-none"
className="w-full min-h-[60px] p-3 text-[14px] rounded-lg border border-[#E5E7EB] focus:border-[#0838E0] focus:outline-none resize-none"
onKeyDown={(e) => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); submitAnswer(); } }}
/>
<div className="flex items-center justify-between gap-2 mt-2">
@@ -433,7 +433,7 @@ export default function Owliver({ compact = false }) {
onClick={isListening ? stopListening : startListening}
className={cn(
'inline-flex items-center gap-2 px-4 py-2.5 rounded-xl text-[13px] font-semibold transition-colors',
isListening ? 'bg-[#FEE2E2] text-[#991B1B]' : 'bg-[#0A39DF] text-white hover:bg-[#082EB4]'
isListening ? 'bg-[#FEE2E2] text-[#991B1B]' : 'bg-[#0838E0] text-white hover:bg-[#062BAF]'
)}
>
{isListening ? <MicOff className="w-4 h-4" /> : <Mic className="w-4 h-4" />}
@@ -446,7 +446,7 @@ export default function Owliver({ compact = false }) {
>
{t.skip} <ArrowRight className="w-3.5 h-3.5" />
</button>
<Button onClick={submitAnswer} disabled={!transcript.trim()} className="bg-[#0A39DF] hover:bg-[#082EB4]">
<Button onClick={submitAnswer} disabled={!transcript.trim()} className="bg-[#0838E0] hover:bg-[#062BAF]">
<Send className="w-4 h-4 mr-1.5" /> {t.send}
</Button>
</div>
@@ -462,7 +462,7 @@ export default function Owliver({ compact = false }) {
<h2 className="text-[18px] font-semibold text-[#111827]">{t.notEnoughTitle}</h2>
<p className="text-[13px] text-[#6B7280] max-w-sm mx-auto">{t.notEnoughBody}</p>
<div className="flex flex-col sm:flex-row gap-2 justify-center pt-2">
<Button onClick={startChat} className="bg-[#0A39DF] hover:bg-[#082EB4]">
<Button onClick={startChat} className="bg-[#0838E0] hover:bg-[#062BAF]">
<RefreshCw className="w-4 h-4 mr-2" /> {t.chatAgain}
</Button>
<Button variant="outline" onClick={() => navigate('/me')}>
@@ -476,7 +476,7 @@ export default function Owliver({ compact = false }) {
{phase === 'building' && (
<div className="bg-white border border-[#E5E7EB] rounded-2xl p-10 text-center">
<OwliverAvatar className="w-20 h-20 mx-auto mb-4 animate-pulse" />
<Loader2 className="w-7 h-7 text-[#0A39DF] animate-spin mx-auto mb-3" />
<Loader2 className="w-7 h-7 text-[#0838E0] animate-spin mx-auto mb-3" />
<p className="text-[14px] font-medium text-[#111827]">{t.building1}</p>
<p className="text-[12px] text-[#6B7280] mt-1">{t.building2}</p>
</div>
@@ -507,9 +507,9 @@ export default function Owliver({ compact = false }) {
</div>
{extracted.career_dna && (
<div className="rounded-xl border border-[#0A39DF]/15 bg-[#EFF6FF]/60 p-4 space-y-3">
<div className="rounded-xl border border-[#0838E0]/15 bg-[#EEF3FE]/60 p-4 space-y-3">
<div>
<p className="text-[13px] font-bold text-[#0A39DF] flex items-center gap-1.5"><Sparkles className="w-3.5 h-3.5" /> {t.dnaHeader}</p>
<p className="text-[13px] font-bold text-[#0838E0] flex items-center gap-1.5"><Sparkles className="w-3.5 h-3.5" /> {t.dnaHeader}</p>
<p className="text-[11px] text-[#6B7280] mt-0.5">{t.dnaSub}</p>
</div>
{extracted.career_dna.identity && (
@@ -532,7 +532,7 @@ export default function Owliver({ compact = false }) {
</div>
)}
<Button onClick={() => navigate('/me')} className="w-full h-11 bg-[#0A39DF] hover:bg-[#082EB4]">
<Button onClick={() => navigate('/me')} className="w-full h-11 bg-[#0838E0] hover:bg-[#062BAF]">
<Sparkles className="w-4 h-4 mr-2" /> {t.seeId} <ArrowRight className="w-4 h-4 ml-1" />
</Button>
</div>
@@ -553,7 +553,7 @@ function SummaryField({ label, value, full, empty = '—' }) {
function DnaScore({ label, value }) {
const v = typeof value === 'number' ? value : 0;
const tone = v >= 75 ? 'text-[#16A34A]' : v >= 50 ? 'text-[#0A39DF]' : v > 0 ? 'text-[#D97706]' : 'text-[#9CA3AF]';
const tone = v >= 75 ? 'text-[#16A34A]' : v >= 50 ? 'text-[#0838E0]' : v > 0 ? 'text-[#D97706]' : 'text-[#9CA3AF]';
return (
<div className="rounded-lg bg-white p-2.5 border border-[#E5E7EB]">
<div className="text-[10px] text-[#6B7280] uppercase tracking-wide">{label}</div>

View File

@@ -1,6 +1,9 @@
import React, { useState } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { useJobPosting, useApplications, useScreenAllCandidates, useUpdateJobPosting, useHireCandidate, useUpdateApplication, useCreateApplication } from '@/lib/krowHooks';
import { useJobPosting, useApplications, useScreenAllCandidates, useUpdateJobPosting, useHireCandidate, useUpdateApplication, useCreateApplication, useWorkerProfiles, useCourses, useAssignments, useStaff } from '@/lib/krowHooks';
import { demandLabel } from '@/lib/positionModel';
import { workforceStatusFor } from '@/lib/workforce';
import { MatchedCandidates } from '@/components/krow/MatchedCandidates';
import { base44 } from '@/api/base44Client';
import CandidateCard from '@/components/krow/CandidateCard';
import AIInterviewModal from '@/components/krow/AIInterviewModal';
@@ -13,16 +16,120 @@ import { cn } from '@/lib/utils';
import { UserPlus } from 'lucide-react';
const STATUS_ACTIONS = [
{ status: 'active', label: 'Hiring', color: 'bg-[#0A39DF] text-white shadow-sm' },
{ status: 'paused', label: 'Paused', color: 'bg-[#0A39DF] text-white shadow-sm' },
{ status: 'closed', label: 'Closed', color: 'bg-[#0A39DF] text-white shadow-sm' },
{ status: 'active', label: 'Hiring', color: 'bg-[#0838E0] text-white shadow-sm' },
{ status: 'paused', label: 'Paused', color: 'bg-[#0838E0] text-white shadow-sm' },
{ status: 'closed', label: 'Closed', color: 'bg-[#0838E0] text-white shadow-sm' },
];
/**
* The workforce picture behind this position.
*
* Reads what `workforceStatusFor` already computed — availability, today's
* arrivals, the pipeline as the application records actually stand, and the
* interviews on file. Nothing is derived here; sections that have nothing to
* report say so in one line rather than reserving a panel for it.
*/
function WorkforceStatus({ workforce, job }) {
const { demand, existingAvailable, applicants, newToday, strong, interviewReady } = workforce;
/* Coverage figures depend on a declared headcount; the rest are facts about
this position either way, so they are always shown. */
const figures = [
{ label: 'Available now', value: existingAvailable.length, hint: 'free when this starts' },
demand.declared && { label: 'Currently assigned', value: demand.assigned, hint: 'on the roster' },
{ label: 'New applicants', value: newToday.length, hint: 'today' },
{ label: 'Strong matches', value: strong.length, hint: 'qualified and free' },
{ label: 'Interview ready', value: interviewReady.length, hint: 'at interview stage' },
demand.declared && { label: 'Remaining gap', value: workforce.gap, hint: 'after strong matches' },
].filter(Boolean);
/* The pipeline, counted off the application statuses that already exist —
not a second status vocabulary invented for this panel. */
const pipeline = [
{ label: 'Applied', value: applicants.filter((a) => a.application.status === 'applied').length },
{ label: 'Screening', value: applicants.filter((a) => a.application.status === 'ai_screened').length },
{ label: 'Interview', value: interviewReady.length },
{ label: 'Hired', value: workforce.hired.length },
];
return (
<div className="bg-white border border-[#E5E7EB] rounded-2xl p-6 space-y-5">
<div>
<h2 className="text-[15px] font-bold text-[#111827]">Workforce status</h2>
{/* `demandLabel` already states the start, so it is not repeated. */}
<p className="text-[12px] text-[#6B7280] mt-0.5">{demandLabel(job)}</p>
</div>
<dl className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-6">
{figures.map((f) => (
<div key={f.label} className="rounded-xl border border-[#E5E7EB] bg-[#F9FAFB] px-3 py-2.5">
<dt className="text-[10px] font-semibold uppercase tracking-wide text-[#9CA3AF]">{f.label}</dt>
<dd className="mt-1 text-[18px] font-bold tabular-nums text-[#111827]">{f.value}</dd>
<p className="text-[10px] text-[#9CA3AF]">{f.hint}</p>
</div>
))}
</dl>
<div className="grid gap-5 lg:grid-cols-2">
<section>
<h3 className="text-[11px] font-bold uppercase tracking-wide text-[#9CA3AF] mb-2">New today</h3>
{newToday.length ? (
<ul className="divide-y divide-[#F3F4F6] rounded-xl border border-[#E5E7EB]">
{newToday.slice(0, 5).map((row) => (
<li key={row.application.id} className="flex items-center justify-between gap-3 px-3 py-2">
<div className="min-w-0">
<p className="truncate text-[13px] font-medium text-[#111827]">{row.name}</p>
<p className="truncate text-[11px] text-[#9CA3AF]">
Applied {new Date(row.application.created_date).toLocaleTimeString(undefined, { hour: 'numeric', minute: '2-digit' })}
{/* Availability is only known for people we hold a worker
profile for; an external applicant has none, and the
line simply stops rather than guessing at one. */}
{row.match ? ` · ${row.match.availability.reason}` : ''}
</p>
</div>
{row.match && (
<span className="shrink-0 text-[12px] font-semibold tabular-nums text-[#0838E0]">
{row.match.score}%
</span>
)}
</li>
))}
</ul>
) : (
<p className="text-[13px] text-[#6B7280]">No applications today.</p>
)}
</section>
<section>
<h3 className="text-[11px] font-bold uppercase tracking-wide text-[#9CA3AF] mb-2">Hiring pipeline</h3>
<ul className="grid grid-cols-4 gap-2">
{pipeline.map((stage) => (
<li key={stage.label} className="rounded-xl border border-[#E5E7EB] px-2 py-2 text-center">
<p className="text-[16px] font-bold tabular-nums text-[#111827]">{stage.value}</p>
<p className="text-[10px] uppercase tracking-wide text-[#9CA3AF]">{stage.label}</p>
</li>
))}
</ul>
</section>
</div>
</div>
);
}
export default function PositionDetail() {
const { id } = useParams();
const navigate = useNavigate();
const { data: job, isLoading } = useJobPosting(id);
const { data: applications = [] } = useApplications(id);
/* Matching reads the workforce and the training library — the same two
collections Forge reads, so a level earned there is a match here with no
copy of the number in between. */
const { data: profiles = [] } = useWorkerProfiles();
const { data: courses = [] } = useCourses();
/* Assignments and hired staff are what make "assigned" a real number rather
than a count of hires — the same two collections the Positions grid reads. */
const { data: assignments = [] } = useAssignments();
const { data: staff = [] } = useStaff();
const screenAll = useScreenAllCandidates();
const updateJob = useUpdateJobPosting();
const hireCandidate = useHireCandidate();
@@ -89,11 +196,8 @@ export default function PositionDetail() {
const jobApps = applications.filter(a => a.job_posting_id === id);
const sortedApps = [...jobApps].sort((a, b) => (b.ai_score || 0) - (a.ai_score || 0));
const stats = {
applied: jobApps.length,
screened: jobApps.filter(a => a.status !== 'applied').length,
hired: jobApps.filter(a => a.status === 'hired').length,
};
/* The header's counts now come from the workforce engine rather than being
recounted here — one source, so the card and this page agree. */
const handleStatusChange = async (newStatus) => {
try {
@@ -153,8 +257,15 @@ export default function PositionDetail() {
}
};
/* One engine call, shared by the demand row and the workforce panel. Computed
after the loading guards below would run, so it is guarded for a missing
job rather than assuming one. */
const workforce = job
? workforceStatusFor(job, { profiles, applications, assignments, courses, staff })
: null;
if (isLoading) {
return <div className="flex items-center justify-center py-20"><div className="w-6 h-6 border-3 border-[#E5E7EB] border-t-[#0A39DF] rounded-full animate-spin" /></div>;
return <div className="flex items-center justify-center py-20"><div className="w-6 h-6 border-3 border-[#E5E7EB] border-t-[#0838E0] rounded-full animate-spin" /></div>;
}
if (!job) {
@@ -173,9 +284,23 @@ export default function PositionDetail() {
<div className="flex items-start justify-between gap-4 flex-wrap">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<h1 className="text-[22px] font-bold text-[#111827]">{job.title}</h1>
{/* Company leads: on a workforce page the client asking for the
people is as much the identity of the role as its title. */}
<h1 className="text-[22px] font-bold text-[#111827]">
{job.company ? `${job.company} — ${job.title}` : job.title}
</h1>
{job.priority && job.priority !== 'normal' && (
<span className={cn(
'px-2 py-0.5 rounded-full text-[11px] font-bold capitalize',
job.priority === 'urgent'
? 'bg-[#FDECEC] text-[#D64545]'
: 'bg-[#FEF6E0] text-[#B45309]'
)}>
{job.priority}
</span>
)}
{job.ai_generated && (
<span className="px-2 py-0.5 rounded-full bg-[#EFF6FF] text-[#0A39DF] text-[11px] font-medium">
<span className="px-2 py-0.5 rounded-full bg-[#EEF3FE] text-[#0838E0] text-[11px] font-medium">
AI Built
</span>
)}
@@ -191,7 +316,7 @@ export default function PositionDetail() {
<div className="flex items-center gap-2 flex-wrap">
<button
onClick={() => setInviteOpen(true)}
className="inline-flex items-center gap-1.5 bg-[#F9E547] text-[#0A39DF] text-[12px] font-bold px-4 py-1.5 rounded-full hover:scale-[1.03] transition-transform"
className="inline-flex items-center gap-1.5 bg-[#F9E547] text-[#0838E0] text-[12px] font-bold px-4 py-1.5 rounded-full hover:scale-[1.03] transition-transform"
>
<UserPlus className="w-4 h-4" />
Invite Applicants
@@ -205,7 +330,7 @@ export default function PositionDetail() {
'px-4 py-1.5 rounded-full text-[12px] font-semibold transition-all disabled:opacity-50',
job.status === s.status
? s.color
: 'border border-[#E5E7EB] bg-white text-[#6B7280] hover:border-[#0A39DF] hover:text-[#0A39DF]'
: 'border border-[#E5E7EB] bg-white text-[#6B7280] hover:border-[#0838E0] hover:text-[#0838E0]'
)}
>
{s.label}
@@ -214,23 +339,66 @@ export default function PositionDetail() {
</div>
</div>
{/* Stats */}
<div className="grid grid-cols-3 gap-4 mt-5 pt-5 border-t border-[#F3F4F6]">
<div>
<div className="text-2xl font-bold text-[#111827]">{stats.applied}</div>
<div className="text-[11px] text-[#9CA3AF] tracking-wide">APPLIED</div>
{/* Demand — required, assigned, remaining. The same three numbers the
position card leads with, from the same engine call, so the card and
the page it opens can never disagree. Shown only when this position
states a headcount; otherwise the pipeline below is what there is. */}
{workforce.demand.declared ? (
<div className="grid grid-cols-3 gap-4 mt-5 pt-5 border-t border-[#F3F4F6]">
<div>
<div className="text-2xl font-bold text-[#111827]">{workforce.demand.required}</div>
<div className="text-[11px] text-[#9CA3AF] tracking-wide">REQUIRED</div>
</div>
<div>
<div className="text-2xl font-bold text-[#111827]">{workforce.demand.assigned}</div>
<div className="text-[11px] text-[#9CA3AF] tracking-wide">ASSIGNED</div>
</div>
<div>
<div className={cn('text-2xl font-bold', workforce.demand.remaining ? 'text-[#D64545]' : 'text-[#16A765]')}>
{workforce.demand.remaining}
</div>
<div className="text-[11px] text-[#9CA3AF] tracking-wide">REMAINING</div>
</div>
</div>
<div>
<div className="text-2xl font-bold text-[#111827]">{stats.screened}</div>
<div className="text-[11px] text-[#9CA3AF] tracking-wide">SCREENED</div>
) : (
<div className="grid grid-cols-3 gap-4 mt-5 pt-5 border-t border-[#F3F4F6]">
<div>
<div className="text-2xl font-bold text-[#111827]">{workforce.applicants.length}</div>
<div className="text-[11px] text-[#9CA3AF] tracking-wide">APPLICANTS</div>
</div>
<div>
<div className="text-2xl font-bold text-[#111827]">{workforce.strong.length}</div>
<div className="text-[11px] text-[#9CA3AF] tracking-wide">STRONG MATCHES</div>
</div>
<div>
<div className="text-2xl font-bold text-[#0838E0]">{workforce.hired.length}</div>
<div className="text-[11px] text-[#9CA3AF] tracking-wide">HIRED</div>
</div>
</div>
<div>
<div className="text-2xl font-bold text-[#0A39DF]">{stats.hired}</div>
<div className="text-[11px] text-[#9CA3AF] tracking-wide">HIRED</div>
</div>
</div>
)}
</div>
<WorkforceStatus workforce={workforce} job={job} />
{/* Which people can actually do this work, scored against the position's
own skill requirements. Above the applicant list on purpose: the best
match for a role is often somebody who has not applied yet. */}
<MatchedCandidates
job={job}
profiles={profiles}
courses={courses}
applications={applications}
busy={updateApp.isPending}
onInterview={async (app) => {
try {
await updateApp.mutateAsync({ id: app.id, data: { status: 'interview' } });
toast.success(`${app.applicant_name} moved to interview`);
} catch {
toast.error('Could not move this candidate');
}
}}
/>
{/* Suggested Talent — proactive matching, no waiting for applications */}
{job.status === 'active' && (
<SuggestedTalent
@@ -266,7 +434,7 @@ export default function PositionDetail() {
<button
onClick={handleScreenAll}
disabled={screenAll.isPending}
className="inline-flex items-center gap-1.5 bg-[#0A39DF] hover:bg-[#082EB4] disabled:opacity-50 text-white text-[12px] font-medium px-3 py-1.5 rounded-lg transition-colors"
className="inline-flex items-center gap-1.5 bg-[#0838E0] hover:bg-[#062BAF] disabled:opacity-50 text-white text-[12px] font-medium px-3 py-1.5 rounded-lg transition-colors"
>
{screenAll.isPending ? 'Screening...' : 'AI Screen All'}
</button>

View File

@@ -39,7 +39,7 @@ export default function Profile() {
if (loading) {
return (
<div className="flex items-center justify-center py-20">
<div className="w-8 h-8 border-4 border-slate-200 border-t-[#0A39DF] rounded-full animate-spin" />
<div className="w-8 h-8 border-4 border-slate-200 border-t-[#0838E0] rounded-full animate-spin" />
</div>
);
}
@@ -59,7 +59,7 @@ export default function Profile() {
<div className="max-w-3xl mx-auto">
{/* Profile header */}
<div className="flex items-center gap-4 mb-8">
<div className="w-20 h-20 rounded-full bg-[#0A39DF] flex items-center justify-center text-white text-2xl font-bold">
<div className="w-20 h-20 rounded-full bg-[#0838E0] flex items-center justify-center text-white text-2xl font-bold">
{initials}
</div>
<div>

View File

@@ -45,8 +45,8 @@ export default function TalentPool() {
<div className="space-y-5">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<div className="w-9 h-9 rounded-xl bg-[#EFF6FF] flex items-center justify-center">
<Users className="w-5 h-5 text-[#0A39DF]" />
<div className="w-9 h-9 rounded-xl bg-[#EEF3FE] flex items-center justify-center">
<Users className="w-5 h-5 text-[#0838E0]" />
</div>
<div>
<h1 className="text-[22px] font-bold text-[#111827]">Talent Pool</h1>
@@ -59,7 +59,7 @@ export default function TalentPool() {
{/* Band summary */}
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
{[
{ key: 'elite', label: 'Elite', color: '#0A39DF' },
{ key: 'elite', label: 'Elite', color: '#0838E0' },
{ key: 'excellent', label: 'Excellent', color: '#16A34A' },
{ key: 'solid', label: 'Solid', color: '#2563EB' },
{ key: 'building', label: 'Building', color: '#D97706' },
@@ -80,12 +80,12 @@ export default function TalentPool() {
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Search by name, skill, or desired role..."
className="flex-1 h-10 px-4 rounded-lg border border-[#E5E7EB] bg-white text-[13px] focus:border-[#0A39DF] focus:outline-none"
className="flex-1 h-10 px-4 rounded-lg border border-[#E5E7EB] bg-white text-[13px] focus:border-[#0838E0] focus:outline-none"
/>
<select
value={scoreFilter}
onChange={(e) => setScoreFilter(e.target.value)}
className="h-10 px-4 rounded-lg border border-[#E5E7EB] bg-white text-[13px] focus:border-[#0A39DF] focus:outline-none"
className="h-10 px-4 rounded-lg border border-[#E5E7EB] bg-white text-[13px] focus:border-[#0838E0] focus:outline-none"
>
<option value="all">All Scores</option>
<option value="elite">Elite (90+)</option>

View File

@@ -5,8 +5,11 @@ import { useAssistantPanel } from '@/components/ai-assistant';
import { useCourses, useCreateCourse, useUpdateCourse, useWorkerProfiles } from '@/lib/krowHooks';
import {
AddSkillTraining, ForgeHeader, ForgeProgress, ForgeRail, SkillDetail, SkillLibrary,
statusOf, workforceUsage,
SkillProgressCard, SkillProgressDetail, statusOf, workforceUsage,
} from '@/components/forge';
import { SectionTitle } from '@/pages/admin/_shell';
import { FilterSelect } from '@/pages/admin/Positions';
import { completedCourseIds, skillStates } from '@/lib/skillGraph';
/**
* KROW Forge — workforce skill verification, from the managing side.
@@ -50,6 +53,8 @@ export default function University() {
const [creating, setCreating] = useState(false);
const [intent, setIntent] = useState(null);
const [selected, setSelected] = useState(null);
const [openSkill, setOpenSkill] = useState(null);
const [personId, setPersonId] = useState('');
const libraryRef = useRef(null);
/**
@@ -103,46 +108,62 @@ export default function University() {
}, [courses, search, category, status]);
/**
* The four figures.
* Whose progression this page is showing.
*
* Each counts rows: skills in the library, skills carrying training material,
* skills carrying a challenge, and verifications the workforce has actually
* earned. The last one reads worker profiles rather than the library, because
* a verification is something a person holds, not something a skill declares.
* Forge is one page read by two people. An administrator asks what the ladders
* require and how the workforce is moving up them; a worker asks where they
* are and what is next. Both questions are answered by the same ladder against
* one person, so the page picks a person rather than splitting in two.
*
* The default is the worker closest to their next level — the one whose
* progression is worth looking at, rather than whoever sorts first.
*/
const person = useMemo(() => {
if (personId) return profiles.find((p) => p.id === personId) || null;
const ranked = profiles
.map((p) => {
const rows = skillStates(courses, p);
return {
p,
/* Closest to a level: skills one module short, then how far up the
next rung they already are. */
nearly: rows.filter((s) => s.nearlyThere).length,
progress: rows.reduce((n, s) => n + (s.nextRequirement ? s.progressToNext : 0), 0),
done: rows.reduce((n, s) => n + s.totalCompleted, 0),
};
})
.filter((row) => row.done > 0)
.sort((a, b) => (b.nearly - a.nearly) || (b.progress - a.progress) || (b.done - a.done));
return ranked[0]?.p || profiles[0] || null;
}, [personId, profiles, courses]);
const states = useMemo(() => (person ? skillStates(courses, person) : []), [courses, person]);
/** What this person has already completed — drives every module's state. */
const completed = useMemo(() => completedCourseIds(person), [person]);
/**
* The five figures.
*
* Progression, not inventory: how many skills exist to climb, how many this
* person is moving on, how many they hold, how many are one module from the
* next level, and how many modules are queued up next. Each is a count of
* records or derived states — nothing here is a target or an estimate.
*/
const figures = useMemo(() => {
const published = courses.filter((c) => statusOf(c) === 'Published');
const withTraining = courses.filter((c) => c.training_outline?.length || c.quiz?.length);
const withChallenge = courses.filter((c) => c.challenge?.type);
const libraryIds = new Set(courses.map((c) => c.id));
const verified = profiles.reduce(
(sum, p) => sum + (p.earned_badges || []).filter((b) => libraryIds.has(b.course_id)).length,
0
);
const verified = states.filter((s) => s.verifiedLevel).length;
const inProgress = states.filter((s) => s.status === 'in_progress' || s.status === 'advancing').length;
const ready = states.filter((s) => s.nearlyThere).length;
const recommended = states.filter((s) => s.nextRequirement).length;
return [
{
label: 'Active skills',
value: published.length,
sub: `${courses.length} in the library`,
},
{
label: 'Training modules',
value: withTraining.length,
sub: 'skills carrying material',
},
{
label: 'Challenges',
value: withChallenge.length,
sub: 'evidence-based',
},
{
label: 'Verified skills',
value: verified,
sub: verified ? 'earned by the workforce' : 'none earned yet',
},
{ label: 'Active skills', value: states.length, sub: `${courses.filter((c) => statusOf(c) === 'Published').length} published modules` },
{ label: 'Skills in progress', value: inProgress, sub: inProgress ? 'training under way' : 'nothing started' },
{ label: 'Skills verified', value: verified, sub: verified ? 'levels backed by evidence' : 'none earned yet' },
{ label: 'Next level ready', value: ready, sub: ready ? 'one module from the next level' : 'none within one module' },
{ label: 'Recommended training', value: recommended, sub: recommended ? 'modules queued next' : 'nothing outstanding' },
];
}, [courses, profiles]);
}, [states, courses]);
/* The rail's recent list is the library's own newest records. */
const recent = useMemo(() => courses.slice(0, 4), [courses]);
@@ -185,6 +206,35 @@ export default function University() {
<ForgeProgress figures={figures} loading={isLoading} />
{/* Skill progression — the primary experience. The library below is the
authoring surface; this is the part that answers "where am I and what
is next", which is the question both audiences arrive with. */}
<section className="space-y-4">
<SectionTitle
title="Skill progression"
meta={person ? `${person.full_name} · ${states.filter((s) => s.verifiedLevel).length} of ${states.length} skills verified` : undefined}
/>
{profiles.length > 1 && (
<div className="flex flex-wrap items-center gap-2">
<span className="text-caption text-ink-4">Progress for</span>
<FilterSelect
value={person?.id || ''}
onChange={setPersonId}
label="Worker"
options={profiles.map((p) => ({ value: p.id, label: p.full_name }))}
className="min-w-[11rem]"
/>
</div>
)}
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
{states.map((state) => (
<SkillProgressCard key={state.skillId} state={state} onOpen={setOpenSkill} />
))}
</div>
</section>
{/* The library gets the full column. Owliver already holds the right of
the screen on this page, and a second rail beside it left the skill
cards about two hundred pixels wide — so the guidance goes underneath,
@@ -207,6 +257,11 @@ export default function University() {
onClear={clearFilters}
onOpen={setSelected}
onCreate={openCreate}
moduleStateFor={(course) =>
(completed.has(course.id) ? 'completed'
: states.find((s) => s.skillId === course.skill_id)?.nextRequirement?.id === course.id ? 'available'
: course.skill_id ? 'locked' : undefined)
}
/>
<ForgeRail
@@ -217,6 +272,18 @@ export default function University() {
/>
</div>
{/* Starting a module hands off to the challenge page that already exists,
carrying who it is for — so the evidence, Owliver's verdict and the
level it updates all land on the right record. */}
<SkillProgressDetail
state={openSkill}
open={Boolean(openSkill)}
onOpenChange={(next) => !next && setOpenSkill(null)}
onStartTraining={(module) =>
navigate(`/admin/university/${module.id}${person ? `?as=${person.id}` : ''}`)
}
/>
<SkillDetail
course={selected}
open={Boolean(selected)}

View File

@@ -8,7 +8,7 @@ export default function WorkerProfile() {
const { data: profile, isLoading } = useWorkerProfile();
if (isLoading || !profile) {
return <div className="flex justify-center py-20"><Loader2 className="w-6 h-6 animate-spin text-[#0A39DF]" /></div>;
return <div className="flex justify-center py-20"><Loader2 className="w-6 h-6 animate-spin text-[#0838E0]" /></div>;
}
return (

View File

@@ -8,7 +8,9 @@ import { cn } from '@/lib/utils';
import {
Avatar, Badge, Button, EmptyState, ProgressBar, StatusBadge, toast,
} from '@/components/ds';
import { useApplications, useInterviews, useJobPostings } from '@/lib/krowHooks';
import { useApplications, useCourses, useInterviews, useJobPostings, useWorkerProfiles } from '@/lib/krowHooks';
import { VerifiedSkills } from '@/components/krow/VerifiedSkills';
import { usePageSkills } from '@/lib/skills/usePageSkills';
import {
ALL_DIMENSIONS, SCORE_GROUPS, activityTimeline, careerScore, careerTier,
communicationLabel, endorsements, marketValue, nextPromotion, recommendationVerdict,
@@ -685,8 +687,12 @@ function Endorsements({ candidate }) {
}
/** Experience: history, availability, credentials and the applied position. */
function Experience({ candidate, job, interview }) {
function Experience({ candidate, job, interview, workerProfiles = [], courses = [], positions = [] }) {
const c = candidate;
/* What Forge has attached to the Candidates page. Nothing here names a skill —
a path authored tomorrow with `pages: candidates` appears without this
component changing. */
const pageSkills = usePageSkills('candidates');
return (
<div className="space-y-8">
@@ -738,6 +744,27 @@ function Experience({ candidate, job, interview }) {
)}
</section>
{/* Verified capability first, self-reported skills after it. Both belong
on the page — but one is backed by evidence Owliver graded, and the
order should say which.
Which skills appear is the Forge definitions' decision: only paths
attached to `candidates` are surfaced here, and when none are, the
heading goes with them rather than sitting over an empty panel. */}
{pageSkills.skills.length > 0 && (
<section>
<SectionLabel meta={`${pageSkills.skills.length} attached`}>Verified skills</SectionLabel>
<VerifiedSkills
pageId="candidates"
email={c.email}
profiles={workerProfiles}
courses={courses}
positions={positions}
customSources={pageSkills.customSources}
/>
</section>
)}
<SkillsAndCerts candidate={c} />
<section>
@@ -835,6 +862,11 @@ export default function CandidateProfile() {
const { data: applications = [], isLoading } = useApplications();
const { data: postings = [] } = useJobPostings();
const { data: interviews = [] } = useInterviews();
/* The workforce and the training library, for the verified-skill panel: a
candidate's real capability lives on the worker profile behind the
application, not on the application itself. */
const { data: workerProfiles = [] } = useWorkerProfiles();
const { data: courses = [] } = useCourses();
const candidate = useMemo(() => applications.find((a) => a.id === id), [applications, id]);
const job = useMemo(
@@ -946,7 +978,14 @@ export default function CandidateProfile() {
{tab === 'reputation' && <Reputation candidate={candidate} />}
{tab === 'experience' && (
<Experience candidate={candidate} job={job} interview={interview} />
<Experience
candidate={candidate}
job={job}
interview={interview}
workerProfiles={workerProfiles}
courses={courses}
positions={postings}
/>
)}
{tab === 'endorsements' && <Endorsements candidate={candidate} />}
{tab === 'activity' && <Activity candidate={candidate} interview={interview} />}

View File

@@ -1,9 +1,14 @@
import React, { useMemo, useState } from 'react';
import { Button, SearchInput } from '@/components/ds';
import { useApplications, useInterviews, useJobPostings } from '@/lib/krowHooks';
import { useApplications, useInterviews, useJobPostings, useUpdateApplication, useHireCandidate } from '@/lib/krowHooks';
import { base44 } from '@/api/base44Client';
import { toast } from 'react-hot-toast';
import { AdminPage, Toolbar } from '@/pages/admin/_shell';
import { FilterSelect } from '@/pages/admin/Positions';
import CandidateCard from '@/components/krow/CandidateCard';
import AIInterviewModal from '@/components/krow/AIInterviewModal';
import ScheduleInterviewModal from '@/components/krow/ScheduleInterviewModal';
import MessageCandidateModal from '@/components/krow/MessageCandidateModal';
const SCORE_BANDS = {
all: () => true,
@@ -24,6 +29,8 @@ export default function AdminCandidates() {
const { data: applications = [], isLoading } = useApplications();
const { data: postings = [] } = useJobPostings();
const { data: interviews = [] } = useInterviews();
const updateApp = useUpdateApplication();
const hireCandidate = useHireCandidate();
const [search, setSearch] = useState('');
const [position, setPosition] = useState('all');
@@ -31,6 +38,46 @@ export default function AdminCandidates() {
const [band, setBand] = useState('all');
const [sort, setSort] = useState('score');
const [interviewApp, setInterviewApp] = useState(null);
const [scheduleApp, setScheduleApp] = useState(null);
const [messageApp, setMessageApp] = useState(null);
const handleDecline = async (app) => {
try {
await updateApp.mutateAsync({ id: app.id, data: { status: 'rejected' } });
toast.success(`${app.applicant_name} declined`);
} catch (e) {
toast.error('Failed to decline candidate');
}
};
const handleDelete = async (app) => {
try {
await base44.entities.JobApplication.delete(app.id);
toast.success(`${app.applicant_name} deleted`);
} catch (e) {
toast.error('Failed to delete candidate');
}
};
const handleHire = async (app) => {
try {
const job = postings.find(p => p.id === app.job_posting_id);
await hireCandidate.mutateAsync({ application: app, job });
toast.success(`${app.applicant_name} hired!`);
} catch (e) {
toast.error('Failed to hire candidate');
}
};
const handleAction = (app) => {
if (app.status === 'interview') {
setInterviewApp(app);
} else {
setScheduleApp(app);
}
};
const positions = useMemo(
() => [...new Set(applications.map((a) => a.job_title).filter(Boolean))].sort(),
[applications]
@@ -106,11 +153,28 @@ export default function AdminCandidates() {
application={app}
jobTitle={resolveTitle(app)}
rank={idx + 1}
onAction={handleAction}
onMessage={setMessageApp}
onCall={setMessageApp}
onSchedule={setScheduleApp}
onDecline={handleDecline}
onDelete={handleDelete}
onHire={handleHire}
/>
))}
</div>
)}
{/* Modals */}
{interviewApp && (
<AIInterviewModal open={true} onClose={() => setInterviewApp(null)} application={interviewApp} job={postings.find(p => p.id === interviewApp.job_posting_id)} />
)}
{scheduleApp && (
<ScheduleInterviewModal open={true} onClose={() => setScheduleApp(null)} application={scheduleApp} />
)}
{messageApp && (
<MessageCandidateModal open={true} onClose={() => setMessageApp(null)} application={messageApp} />
)}
</AdminPage>
);
}

View File

@@ -63,7 +63,7 @@ export default function AdminLogin() {
{/* Left Visual Section — Clean White & KROW Yellow Theme */}
<div className="relative hidden md:flex flex-col justify-between p-8 sm:p-10 bg-[#FAFAFA] overflow-hidden border-r border-[#E2E8F0]">
{/* Subtle Decorative Accents */}
<div className="absolute -top-20 -left-20 w-64 h-64 rounded-full bg-[#0A39DF]/5 blur-3xl pointer-events-none" />
<div className="absolute -top-20 -left-20 w-64 h-64 rounded-full bg-[#0838E0]/5 blur-3xl pointer-events-none" />
<div className="absolute -bottom-20 -right-20 w-80 h-80 rounded-full bg-[#F9E547]/10 blur-3xl pointer-events-none" />
{/* Top Brand & Badge */}
@@ -89,8 +89,8 @@ export default function AdminLogin() {
{/* Bottom Hero Tagline */}
<div className="relative z-10 space-y-2">
<div className="flex items-center gap-1.5 text-xs font-bold text-[#0A39DF] uppercase tracking-wider">
<Sparkles className="w-3.5 h-3.5 text-[#0A39DF]" />
<div className="flex items-center gap-1.5 text-xs font-bold text-[#0838E0] uppercase tracking-wider">
<Sparkles className="w-3.5 h-3.5 text-[#0838E0]" />
Recruiting Intelligence Platform
</div>
<h2 className="text-xl font-bold text-[#0F172A] tracking-tight leading-snug">

View File

@@ -1,14 +1,17 @@
import React, { useMemo, useState } from 'react';
import { useNavigate, useSearchParams } from 'react-router-dom';
import {
ArrowRight, CheckCircle2, MapPin, MessageSquare, Sparkles, Star, Users, Wallet,
ArrowRight, CheckCircle2, LayoutGrid, List, MapPin, MessageSquare, Sparkles, Star, Users, Wallet,
} from 'lucide-react';
import { cn } from '@/lib/utils';
import {
Avatar, Badge, Button, Drawer, EmptyState, ProgressBar, SearchInput,
Avatar, Badge, Button, Drawer, EmptyState, ProgressBar, SearchInput, SegmentedToggle,
Select, SelectContent, SelectItem, SelectTrigger, SelectValue, Skeleton, StatusBadge, toast,
} from '@/components/ds';
import { useApplications, useJobPostings } from '@/lib/krowHooks';
import {
useApplications, useAssignments, useCourses, useJobPostings, useStaff, useWorkerProfiles,
} from '@/lib/krowHooks';
import { workforceStatusFor } from '@/lib/workforce';
import { AdminPage, SectionTitle } from '@/pages/admin/_shell';
import { RoleGlyph } from '@/pages/admin/RoleGlyph';
import { buildPosition, payLabel } from '@/pages/admin/positionInsights';
@@ -179,6 +182,223 @@ function Progression({ steps, className }) {
);
}
/**
* The workforce line — one compact row under the hiring pipeline.
*
* Deliberately a single line of text rather than a panel. The pipeline above it
* is what this card has always been about; workforce demand is a second, quieter
* fact, and giving it equal weight turned the card into a dashboard.
*
* Every clause appears only if the data behind it exists. A position that
* declares no headcount has no coverage to report, and one with no strong
* matches simply omits that clause instead of announcing an absence. If nothing
* is known, the row does not render at all.
*/
function WorkforceRow({ workforce }) {
if (!workforce) return null;
const { demand, strong, newToday } = workforce;
const parts = [
demand.declared && (
<span key="demand" className="tabular-nums">
<span className="font-semibold text-ink-1">{demand.required}</span> required
<span className="text-ink-4"> · </span>
<span className="font-semibold text-ink-1">{demand.assigned}</span> assigned
<span className="text-ink-4"> · </span>
<span className={cn('font-semibold', demand.remaining ? 'text-warning' : 'text-success')}>
{demand.remaining}
</span> remaining
</span>
),
strong.length > 0 && (
<span key="strong" className="tabular-nums">
<span className="font-semibold text-ink-1">{strong.length}</span> strong
{strong.length === 1 ? ' match' : ' matches'}
</span>
),
newToday.length > 0 && (
<span key="new" className="tabular-nums font-semibold text-krow-blue">
{newToday.length} new today
</span>
),
].filter(Boolean);
if (!parts.length) return null;
return (
<div className="mt-2.5 flex flex-wrap items-center gap-x-2 gap-y-0.5 rounded-lg bg-surface-subtle px-2.5 py-1.5 text-caption text-ink-3">
<Users className="h-3 w-3 shrink-0 text-ink-4" aria-hidden="true" />
{parts.map((part, i) => (
<React.Fragment key={part.key}>
{i > 0 && <span className="text-ink-4/60">|</span>}
{part}
</React.Fragment>
))}
</div>
);
}
/**
* The chosen presentation, remembered.
*
* localStorage rather than the account preferences the rest of the app uses:
* this is how one person likes to look at one page on one machine, not a
* setting worth a write to the user record.
*/
const VIEW_KEY = 'krow:positions:view';
function useViewMode() {
const [view, setView] = useState(() => {
try {
return localStorage.getItem(VIEW_KEY) === 'list' ? 'list' : 'grid';
} catch {
return 'grid';
}
});
return [view, (next) => {
setView(next);
try {
localStorage.setItem(VIEW_KEY, next);
} catch {
/* Preference is not persisted; the page still works. */
}
}];
}
/**
* The same positions, as a table.
*
* Takes the rows the page already built, filtered and sorted — the same objects
* the cards render, carrying both the pipeline and the workforce reading. It
* derives nothing, so a number here cannot differ from the same number there.
*
* Columns follow the data: the workforce columns appear only if some position on
* screen actually declares a headcount, rather than filling a column with dashes.
*/
function PositionsTable({ rows, onOpen }) {
const showDemand = rows.some((p) => p.workforce.demand.declared);
const columns = [
{ key: 'position', label: 'Position' },
{ key: 'applied', label: 'Applied', align: 'right' },
{ key: 'screened', label: 'Screened', align: 'right' },
{ key: 'shortlisted', label: 'Shortlist', align: 'right' },
{ key: 'interviews', label: 'Interview', align: 'right' },
{ key: 'hired', label: 'Hired', align: 'right' },
showDemand && { key: 'required', label: 'Required', align: 'right' },
showDemand && { key: 'assigned', label: 'Assigned', align: 'right' },
showDemand && { key: 'remaining', label: 'Remaining', align: 'right' },
{ key: 'strong', label: 'Strong', align: 'right' },
{ key: 'health', label: 'Status' },
{ key: 'action', label: '' },
].filter(Boolean);
return (
<div className="overflow-hidden rounded-xl border border-border bg-surface shadow-xs">
<table className="hidden w-full border-collapse text-body-sm lg:table">
<caption className="sr-only">Open positions, their hiring pipeline and workforce demand</caption>
<thead>
<tr className="border-b border-border bg-surface-subtle">
{columns.map((c) => (
<th
key={c.key}
scope="col"
className={cn(
'whitespace-nowrap px-3 py-2.5 text-[10px] font-bold uppercase tracking-wide text-ink-4',
c.align === 'right' ? 'text-right' : 'text-left'
)}
>
{c.label}
</th>
))}
</tr>
</thead>
<tbody>
{rows.map((p) => {
const { demand, strong } = p.workforce;
return (
<tr
key={p.id}
onClick={() => onOpen(p)}
className="cursor-pointer border-b border-border last:border-0 transition-colors hover:bg-surface-subtle"
>
<td className="px-3 py-2.5">
<p className="font-medium text-ink-1">{p.title}</p>
<p className="text-caption text-ink-4">
{p.role_category}
{p.location ? ` · ${p.location}` : ''}
</p>
</td>
<td className="px-3 py-2.5 text-right font-semibold tabular-nums text-ink-1">{p.stats.applied}</td>
<td className="px-3 py-2.5 text-right tabular-nums text-ink-2">{p.stats.screened}</td>
<td className="px-3 py-2.5 text-right tabular-nums text-ink-2">{p.stats.shortlisted}</td>
<td className="px-3 py-2.5 text-right tabular-nums text-ink-2">{p.stats.interviews}</td>
<td className="px-3 py-2.5 text-right tabular-nums text-ink-2">{p.stats.hired}</td>
{showDemand && (
<>
<td className="px-3 py-2.5 text-right tabular-nums text-ink-2">
{demand.declared ? demand.required : <span className="text-ink-4">—</span>}
</td>
<td className="px-3 py-2.5 text-right tabular-nums text-ink-2">
{demand.declared ? demand.assigned : <span className="text-ink-4">—</span>}
</td>
<td className={cn('px-3 py-2.5 text-right font-semibold tabular-nums', demand.declared && demand.remaining ? 'text-warning' : 'text-ink-2')}>
{demand.declared ? demand.remaining : <span className="font-normal text-ink-4">—</span>}
</td>
</>
)}
<td className="px-3 py-2.5 text-right tabular-nums text-ink-2">{strong.length}</td>
<td className="px-3 py-2.5"><HealthChip health={p.health} /></td>
<td className="px-3 py-2.5 text-right">
<span className="inline-flex items-center gap-1 text-caption font-semibold text-krow-blue">
View <ArrowRight className="h-3 w-3" aria-hidden="true" />
</span>
</td>
</tr>
);
})}
</tbody>
</table>
{/* Below `lg` the same rows stack: twelve columns cannot be read on a
phone, and a table that scrolls the page sideways is worse than one
that changes shape. */}
<ul className="divide-y divide-border lg:hidden">
{rows.map((p) => (
<li key={p.id}>
<button
type="button"
onClick={() => onOpen(p)}
className="flex w-full flex-col gap-2 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"
>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<p className="truncate font-medium text-ink-1">{p.title}</p>
<p className="truncate text-caption text-ink-4">
{p.role_category}
{p.location ? ` · ${p.location}` : ''}
</p>
</div>
<HealthChip health={p.health} />
</div>
<p className="flex flex-wrap items-center gap-x-2 text-caption tabular-nums text-ink-3">
<span><span className="font-semibold text-ink-1">{p.stats.applied}</span> applied</span>
<span className="text-ink-4">·</span>
<span><span className="font-semibold text-ink-1">{p.stats.interviews}</span> interview</span>
<span className="text-ink-4">·</span>
<span><span className="font-semibold text-ink-1">{p.stats.hired}</span> hired</span>
</p>
<p className="text-caption text-ink-2">{p.insight}</p>
</button>
</li>
))}
</ul>
</div>
);
}
/** One position. The whole card is the target, so there is no hunting for a link. */
function PositionCard({ position, onOpen }) {
const p = position;
@@ -232,6 +452,8 @@ function PositionCard({ position, onOpen }) {
<Progression steps={p.progression} />
<WorkforceRow workforce={p.workforce} />
{/* 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">
@@ -445,6 +667,12 @@ export default function AdminPositions() {
const [searchParams] = useSearchParams();
const { data: postings = [], isLoading } = useJobPostings();
const { data: applications = [] } = useApplications();
/* The workforce side of the record. Read alongside the pipeline, never in
place of it — the two describe different things about the same position. */
const { data: assignments = [] } = useAssignments();
const { data: profiles = [] } = useWorkerProfiles();
const { data: courses = [] } = useCourses();
const { data: staff = [] } = useStaff();
const [search, setSearch] = useState(searchParams.get('search') || '');
const [status, setStatus] = useState(searchParams.get('status') || 'all');
@@ -452,12 +680,29 @@ export default function AdminPositions() {
const [location, setLocation] = useState('all');
const [sort, setSort] = useState('attention');
const [selected, setSelected] = useState(null);
const [view, setView] = useViewMode();
/**
* Derived once per position rather than per cell, so the grid does not run the
* same filter five times per card.
*
* `buildPosition` owns the hiring pipeline — applied, screened, shortlisted,
* interviewed, hired — exactly as it always has. `workforce` is attached
* beside it, not merged into it: a position can have four applicants moving
* through screening and no strong workforce match at the same time, and both
* of those are true. Collapsing them would make one of the two a lie.
*/
const workforceContext = useMemo(
() => ({ profiles, applications, assignments, courses, staff }),
[profiles, applications, assignments, courses, staff]
);
/* 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]
() => postings.map((p) => ({
...buildPosition(p, applications),
workforce: workforceStatusFor(p, workforceContext),
})),
[postings, applications, workforceContext]
);
const departments = useMemo(
@@ -494,12 +739,33 @@ export default function AdminPositions() {
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 [
/* The pipeline sentence the page has always shown, unchanged. */
const parts = [
`${open} open position${open === 1 ? '' : 's'}`,
`${applied} active candidate${applied === 1 ? '' : 's'}`,
`${sum('shortlisted')} shortlisted`,
`${sum('hired')} hired`,
].join(' · ');
];
/* Workforce demand joins it only where positions actually declare one.
Summing a schema default across every row would produce a total nobody
could reconcile against the cards. */
const declared = rows.filter((p) => p.status === 'active' && p.workforce.demand.declared);
if (declared.length) {
const required = declared.reduce((n, p) => n + p.workforce.demand.required, 0);
const assignedTotal = declared.reduce((n, p) => n + p.workforce.demand.assigned, 0);
parts.push(
`${required} required`,
`${assignedTotal} assigned`,
`${Math.max(0, required - assignedTotal)} remaining`
);
}
const newToday = rows.reduce((n, p) => n + p.workforce.newToday.length, 0);
if (newToday) parts.push(`${newToday} new today`);
return parts.join(' · ');
}, [rows]);
return (
@@ -527,6 +793,21 @@ export default function AdminPositions() {
<FilterSelect value={sort} onChange={setSort} label="Sort"
options={Object.entries(SORTS).map(([value, s]) => ({ value, label: s.label }))} />
</div>
{/* Presentation, at the end of the same toolbar. It changes how the rows
are drawn, never which rows or what they say. */}
<div className="lg:ml-auto">
<SegmentedToggle
variant="surface"
ariaLabel="View mode"
value={view}
onChange={setView}
options={[
{ value: 'grid', icon: LayoutGrid, ariaLabel: 'Grid view' },
{ value: 'list', icon: List, ariaLabel: 'List view' },
]}
/>
</div>
</div>
<p className="text-caption text-ink-4">
@@ -541,11 +822,16 @@ export default function AdminPositions() {
))}
</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>
/* One filtered array, two drawings of it. */
view === 'grid' ? (
<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>
) : (
<PositionsTable rows={filtered} onOpen={setSelected} />
)
) : (
<EmptyState
title={isFiltered ? 'No positions match these filters' : 'No positions yet'}

View File

@@ -1,4 +1,5 @@
import React, { useMemo, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import {
Building2, Check, KeyRound, LogOut, Mail, Pencil, Shield, ShieldCheck, Smartphone,
} from 'lucide-react';
@@ -8,8 +9,12 @@ import {
import { base44 } from '@/api/base44Client';
import { useAssistantPanel } from '@/components/ai-assistant';
import {
useCurrentUser, usePreferences, useUpdatePreferences, useUpdateProfile, useUserActivity,
useCourses, useCurrentUser, usePreferences, useUpdatePreferences,
useUpdateProfile, useUserActivity, useWorkerProfiles,
} from '@/lib/krowHooks';
import { usePageSkills } from '@/lib/skills/usePageSkills';
import { profileForEmail } from '@/lib/skillGraph';
import { SkillProgressCard, SkillProgressDetail } from '@/components/forge';
import { AdminPage, SectionTitle } from '@/pages/admin/_shell';
import { endAdminSession } from '@/pages/admin/session';
import { LEVEL_TONE, PERMISSIONS } from '@/pages/admin/permissions';
@@ -26,6 +31,7 @@ import { ASSISTANT_CONTEXTS } from '@/components/ai-assistant/contexts';
*/
export default function AdminProfile() {
const navigate = useNavigate();
const { data: user } = useCurrentUser();
const updateProfile = useUpdateProfile();
const preferences = usePreferences();
@@ -93,6 +99,28 @@ export default function AdminProfile() {
}
};
/**
* Skill development — the workforce paths Forge has attached to this page.
*
* The worker record is looked up by email rather than fetched with
* `useWorkerProfile()`, which creates one on demand: an account opening its
* own settings should not be written into the workforce as a side effect. With
* no record the ladders still render, at "Not started" — which is the true
* state of someone who has completed no training, not a placeholder.
*/
const { data: courses = [] } = useCourses();
const { data: workerProfiles = [] } = useWorkerProfiles();
const profileSkills = usePageSkills('profile');
const myWorkerProfile = useMemo(
() => profileForEmail(workerProfiles, user?.email),
[workerProfiles, user?.email]
);
const skillStates = useMemo(
() => profileSkills.statesFor(courses, myWorkerProfile),
[profileSkills, courses, myWorkerProfile]
);
const [openSkill, setOpenSkill] = useState(null);
/* Owliver skills, read from the Markdown registry. Settings shows what is
registered and which pages it reaches; the file is the source of truth, so
a skill added to disk appears here without a change to this page. */
@@ -205,6 +233,35 @@ export default function AdminProfile() {
)}
</Surface>
</section>
{/* Skill development.
Rendered only when Forge has attached a workforce path to this
page, and not rendered at all otherwise — an empty skills panel
teaches the reader the feature is broken. Nothing here names a
skill: the list is whatever carries `pages: profile`. */}
{skillStates.length > 0 && (
<section aria-labelledby="development" className="space-y-3">
<SectionTitle
id="development"
title="Skill development"
meta={`${skillStates.length} ${skillStates.length === 1 ? 'path' : 'paths'}`}
/>
<p className="-mt-1 text-caption text-ink-3">
Where you stand on each path, and the training that moves you to the next level.
Levels are earned by completing the modules and passing Owliver&apos;s verification —
they are never set by hand.
</p>
<div className="grid gap-4 [grid-template-columns:repeat(auto-fill,minmax(min(100%,16rem),1fr))]">
{skillStates.map(({ definition, state }) => (
<SkillProgressCard
key={definition.id}
state={state}
onOpen={() => setOpenSkill({ definition, state })}
/>
))}
</div>
</section>
)}
{/* Owliver skills */}
<section aria-labelledby="skills" className="space-y-3">
<SectionTitle
@@ -235,9 +292,21 @@ export default function AdminProfile() {
</Badge>
</div>
<p className="mt-0.5 text-caption text-ink-3">{skill.description}</p>
{/* What the attachment does, not just what it says. A
workforce path names the capability it governs and the
rungs it defines, because on this list those are the
things that change what the other pages render. */}
{skill.kind === 'workforce' && (
<p className="mt-1 text-[11px] text-ink-4">
Workforce path · <span className="text-ink-3">{skill.skillId}</span>
{skill.levels.length > 0 && (
<> · {skill.levels.map((l) => l.label).join(' → ')}</>
)}
</p>
)}
{pages.length > 0 && (
<p className="mt-1.5 text-[11px] text-ink-4">
Pages: <span className="text-ink-3">{pages.join(', ')}</span>
Surfaces on: <span className="text-ink-3">{pages.join(', ')}</span>
</p>
)}
{skill.capabilities.length > 0 && (
@@ -377,6 +446,19 @@ export default function AdminProfile() {
<AddSkillDialog open={addSkillOpen} onOpenChange={setAddSkillOpen} />
{/* The whole ladder for one path. The same drawer Forge opens, so the
employee reads their progression in the surface that authored it
rather than in a second copy of it. */}
<SkillProgressDetail
state={openSkill?.state}
open={Boolean(openSkill)}
onOpenChange={(next) => !next && setOpenSkill(null)}
onStartTraining={(module) => {
setOpenSkill(null);
navigate(`/admin/university/${module.id}`);
}}
/>
<Modal
open={editOpen}
onOpenChange={setEditOpen}