update position page design
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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'}
|
||||
|
||||
@@ -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) => (
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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} />}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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'}
|
||||
|
||||
@@ -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'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}
|
||||
|
||||
Reference in New Issue
Block a user