chore: clean up and restructure repository
This commit is contained in:
@@ -1,160 +0,0 @@
|
||||
import React from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { motion } from 'framer-motion';
|
||||
import { useJobPostings, useApplications, useStaff } from '@/lib/krowHooks';
|
||||
import PipelineFunnel from '@/components/krow/PipelineFunnel';
|
||||
import KROWCandidateLeaderboard from '@/components/krow/KROWCandidateLeaderboard';
|
||||
import { Briefcase, Users, Brain, ClipboardCheck, UserCheck, BadgeCheck, Gauge, ChevronRight, Sparkles } from 'lucide-react';
|
||||
import { AssistantTrigger } from '@/components/ai-assistant';
|
||||
|
||||
const METRICS = [
|
||||
{ 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-[#0838E0]', glow: 'shadow-blue-600/20' },
|
||||
{ icon: ClipboardCheck, label: 'Shortlisted', bg: 'bg-[#F9E547]', glow: 'shadow-yellow-400/30' },
|
||||
{ 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-[#0838E0]', glow: 'shadow-blue-600/20' },
|
||||
{ icon: Sparkles, label: 'AI Interviews', bg: 'bg-[#F9E547]', glow: 'shadow-yellow-400/30' },
|
||||
];
|
||||
|
||||
function MetricCard({ icon: Icon, value, label, bg, glow, index }) {
|
||||
const isYellow = bg === 'bg-[#F9E547]';
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 16 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: index * 0.05, duration: 0.4, ease: 'easeOut' }}
|
||||
whileHover={{ y: -4, transition: { duration: 0.2 } }}
|
||||
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-[#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>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function AdminDashboard() {
|
||||
const navigate = useNavigate();
|
||||
const { data: postings = [] } = useJobPostings();
|
||||
const { data: applications = [] } = useApplications();
|
||||
const { data: staff = [] } = useStaff();
|
||||
|
||||
const openPositions = postings.filter(p => p.status === 'active');
|
||||
const screened = applications.filter(a => ['ai_screened', 'shortlisted', 'interview', 'hired'].includes(a.status));
|
||||
const shortlisted = applications.filter(a => ['shortlisted', 'interview', 'hired'].includes(a.status));
|
||||
const hired = applications.filter(a => a.status === 'hired');
|
||||
const activeStaff = staff.filter(s => s.status === 'active' || s.status === 'onboarding');
|
||||
const scored = applications.filter(a => a.ai_score > 0);
|
||||
const avgScore = scored.length ? Math.round(scored.reduce((s, a) => s + a.ai_score, 0) / scored.length) : 0;
|
||||
const interviews = applications.filter(a => a.status === 'interview' || a.interview_id).length;
|
||||
|
||||
const values = [
|
||||
openPositions.length,
|
||||
applications.length,
|
||||
screened.length,
|
||||
shortlisted.length,
|
||||
hired.length,
|
||||
activeStaff.length,
|
||||
avgScore,
|
||||
interviews,
|
||||
];
|
||||
|
||||
const statsForPosting = (id) => {
|
||||
const apps = applications.filter(a => a.job_posting_id === id);
|
||||
return { applied: apps.length, screened: apps.filter(a => a.status !== 'applied').length, hired: apps.filter(a => a.status === 'hired').length };
|
||||
};
|
||||
|
||||
const recentStaff = [...activeStaff].sort((a, b) => new Date(b.hire_date || 0).getTime() - new Date(a.hire_date || 0).getTime()).slice(0, 6);
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
{/* 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-[#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>
|
||||
<div className="flex items-center gap-2">
|
||||
<AssistantTrigger />
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{/* Metrics */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
{METRICS.map((m, i) => (
|
||||
<MetricCard key={m.label} icon={m.icon} value={values[i]} label={m.label} bg={m.bg} glow={m.glow} index={i} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Pipeline funnel + Active positions */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<PipelineFunnel applications={applications} />
|
||||
|
||||
<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-[#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 && (
|
||||
<div className="text-center py-10">
|
||||
<div className="w-12 h-12 rounded-full bg-white/50 flex items-center justify-center mx-auto mb-3"><Briefcase className="w-5 h-5 text-[#9CA3AF]" /></div>
|
||||
<p className="text-[13px] text-[#9CA3AF]">No active positions</p>
|
||||
</div>
|
||||
)}
|
||||
{openPositions.slice(0, 6).map((p, idx) => {
|
||||
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-[#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-[#0838E0] transition-colors" />
|
||||
</motion.div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
|
||||
{/* Leaderboard + Active staff */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<KROWCandidateLeaderboard applications={applications} />
|
||||
|
||||
<motion.div initial={{ opacity: 0, x: 16 }} animate={{ opacity: 1, x: 0 }} transition={{ delay: 0.4 }} 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 Staff</h3>
|
||||
<span className="text-[12px] text-[#6B7280]">{activeStaff.length} on roster</span>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
{recentStaff.length === 0 && (
|
||||
<div className="text-center py-10">
|
||||
<div className="w-12 h-12 rounded-full bg-white/50 flex items-center justify-center mx-auto mb-3"><UserCheck className="w-5 h-5 text-[#9CA3AF]" /></div>
|
||||
<p className="text-[13px] text-[#9CA3AF]">No hired staff yet</p>
|
||||
</div>
|
||||
)}
|
||||
{recentStaff.map((s, _idx) => (
|
||||
<div key={s.id} className="flex items-center gap-3 p-2.5 rounded-xl hover:bg-white/50 transition-colors">
|
||||
<div className="w-9 h-9 rounded-full bg-[#333F48] flex items-center justify-center text-white text-[12px] font-semibold shrink-0">
|
||||
{s.name?.split(' ').map(w => w[0]).slice(0, 2).join('').toUpperCase()}
|
||||
</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-[#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>
|
||||
))}
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -13,7 +13,7 @@ export default function Analytics() {
|
||||
const { data: applications = [], isLoading: appsLoading } = useApplications();
|
||||
const { data: interviews = [], isLoading: intLoading } = useInterviews();
|
||||
const { data: staff = [], isLoading: staffLoading } = useStaff();
|
||||
const { data: postings = [] } = useJobPostings();
|
||||
useJobPostings();
|
||||
|
||||
if (appsLoading || intLoading || staffLoading) {
|
||||
return (
|
||||
|
||||
@@ -15,7 +15,7 @@ export default function Apply() {
|
||||
const jobId = searchParams.get('job');
|
||||
const { data: postings = [], isLoading } = useJobPostings();
|
||||
const createApplication = useCreateApplication();
|
||||
const { data: profile, isLoading: profileLoading } = useWorkerProfile();
|
||||
const { data: profile } = useWorkerProfile();
|
||||
const { data: courses = [] } = useCourses();
|
||||
const [selectedJob, setSelectedJob] = useState(null);
|
||||
const [showForm, setShowForm] = useState(false);
|
||||
@@ -65,7 +65,7 @@ export default function Apply() {
|
||||
toast.success('Application created from your KROW Identity!');
|
||||
setShowForm(false);
|
||||
setInterviewApp({ ...app, job_posting_id: selectedJob.id });
|
||||
} catch (e) {
|
||||
} catch {
|
||||
toast.error('Failed to start application');
|
||||
} finally {
|
||||
setStarting(false);
|
||||
|
||||
@@ -24,7 +24,7 @@ export default function Candidates() {
|
||||
try {
|
||||
await updateApp.mutateAsync({ id: app.id, data: { status: 'rejected' } });
|
||||
toast.success(`${app.applicant_name} declined`);
|
||||
} catch (e) {
|
||||
} catch {
|
||||
toast.error('Failed to decline candidate');
|
||||
}
|
||||
};
|
||||
@@ -33,7 +33,7 @@ export default function Candidates() {
|
||||
try {
|
||||
await base44.entities.JobApplication.delete(app.id);
|
||||
toast.success(`${app.applicant_name} deleted`);
|
||||
} catch (e) {
|
||||
} catch {
|
||||
toast.error('Failed to delete candidate');
|
||||
}
|
||||
};
|
||||
@@ -79,7 +79,7 @@ export default function Candidates() {
|
||||
try {
|
||||
await screenAll.mutateAsync({ applications: unscreened, job: firstJob });
|
||||
toast.success(`Screened ${unscreened.length} candidates`);
|
||||
} catch (e) {
|
||||
} catch {
|
||||
toast.error('Screening failed');
|
||||
}
|
||||
};
|
||||
|
||||
@@ -137,7 +137,7 @@ export default function CreatePosition({ prefill: prefillProp, embedded = false,
|
||||
const result = await generateDesc.mutateAsync({ data: { ...form, pay_range_min: Number(form.pay_range_min) || 0, pay_range_max: Number(form.pay_range_max) || 0 }, draftId: id });
|
||||
setAiResult(result);
|
||||
toast.success('Job description generated!');
|
||||
} catch (e) {
|
||||
} catch {
|
||||
toast.error('AI generation failed. Please try again.');
|
||||
}
|
||||
};
|
||||
@@ -181,7 +181,7 @@ export default function CreatePosition({ prefill: prefillProp, embedded = false,
|
||||
} else {
|
||||
navigate('/admin/positions');
|
||||
}
|
||||
} catch (e) {
|
||||
} catch {
|
||||
toast.error('Failed to save');
|
||||
}
|
||||
};
|
||||
|
||||
@@ -13,19 +13,6 @@ const QUESTION_COUNT = 6;
|
||||
const AVAIL_TOKENS = ['Weekdays', 'Weekends', 'Evenings', 'Mornings', 'Overnight', 'On-Call'];
|
||||
|
||||
// Owliver's wisdom — Career DNA: a living professional identity, built from signals not claims
|
||||
const LEARNS = [
|
||||
'Identity — who they are',
|
||||
'Skills — what they can do',
|
||||
'Experience — what they proved',
|
||||
'Learning speed — how fast they grow',
|
||||
'Reliability — can employers trust them',
|
||||
'Communication — can they explain',
|
||||
'Leadership — do people follow them',
|
||||
'Adaptability — can they pivot',
|
||||
'Work style — builder, operator, leader',
|
||||
'Culture match — where they thrive',
|
||||
'Reputation — verified over time',
|
||||
];
|
||||
|
||||
const UI = {
|
||||
en: {
|
||||
@@ -106,7 +93,7 @@ export default function Owliver({ compact = false }) {
|
||||
const [transcript, setTranscript] = useState('');
|
||||
const [isListening, setIsListening] = useState(false);
|
||||
const [isSpeaking, setIsSpeaking] = useState(false);
|
||||
const [timeLeft, setTimeLeft] = useState(TOTAL_SECONDS);
|
||||
const [, setTimeLeft] = useState(TOTAL_SECONDS);
|
||||
const [extracted, setExtracted] = useState(null);
|
||||
const [error, setError] = useState('');
|
||||
const [ttsEnabled, setTtsEnabled] = useState(true);
|
||||
@@ -169,7 +156,7 @@ export default function Owliver({ compact = false }) {
|
||||
setMessages(newMessages);
|
||||
lastUserTime.current = Date.now();
|
||||
await speak(question);
|
||||
} catch (e) {
|
||||
} catch {
|
||||
setError('Owliver lost his train of thought. Try again.');
|
||||
}
|
||||
}, [workerName, speak, language]);
|
||||
@@ -266,7 +253,7 @@ export default function Owliver({ compact = false }) {
|
||||
}
|
||||
setExtracted({ ...result, availability: normalizeAvail(result.availability) });
|
||||
setPhase('done');
|
||||
} catch (e) {
|
||||
} catch {
|
||||
setError('Owliver could not finish building your profile. Try again.');
|
||||
setPhase('active');
|
||||
}
|
||||
|
||||
@@ -174,7 +174,7 @@ export default function PositionDetail() {
|
||||
try {
|
||||
const app = await admitTalent(profile);
|
||||
setMessageApp(app);
|
||||
} catch (e) {
|
||||
} catch {
|
||||
toast.error('Could not open talent');
|
||||
}
|
||||
};
|
||||
@@ -183,7 +183,7 @@ export default function PositionDetail() {
|
||||
try {
|
||||
const app = await admitTalent(profile, 'interview');
|
||||
setScheduleApp(app);
|
||||
} catch (e) {
|
||||
} catch {
|
||||
toast.error('Could not schedule talent');
|
||||
}
|
||||
};
|
||||
@@ -193,7 +193,7 @@ export default function PositionDetail() {
|
||||
const app = await admitTalent(profile, 'interview');
|
||||
await hireCandidate.mutateAsync({ application: app, job });
|
||||
toast.success(`${profile.full_name} hired from the talent pool!`);
|
||||
} catch (e) {
|
||||
} catch {
|
||||
toast.error('Failed to hire');
|
||||
}
|
||||
};
|
||||
@@ -209,7 +209,7 @@ export default function PositionDetail() {
|
||||
try {
|
||||
await updateJob.mutateAsync({ id, data: { status: newStatus } });
|
||||
toast.success(`Position ${newStatus === 'active' ? 'is now hiring' : newStatus}`);
|
||||
} catch (e) {
|
||||
} catch {
|
||||
toast.error('Failed to update status');
|
||||
}
|
||||
};
|
||||
@@ -223,7 +223,7 @@ export default function PositionDetail() {
|
||||
try {
|
||||
await screenAll.mutateAsync({ applications: unscreened, job });
|
||||
toast.success(`Screened ${unscreened.length} applicants`);
|
||||
} catch (e) {
|
||||
} catch {
|
||||
toast.error('Screening failed');
|
||||
}
|
||||
};
|
||||
@@ -232,7 +232,7 @@ export default function PositionDetail() {
|
||||
try {
|
||||
await hireCandidate.mutateAsync({ application: app, job });
|
||||
toast.success(`${app.applicant_name} hired! Staff record created.`);
|
||||
} catch (e) {
|
||||
} catch {
|
||||
toast.error('Failed to hire');
|
||||
}
|
||||
};
|
||||
@@ -249,7 +249,7 @@ export default function PositionDetail() {
|
||||
try {
|
||||
await updateApp.mutateAsync({ id: app.id, data: { status: 'rejected' } });
|
||||
toast.success(`${app.applicant_name} declined`);
|
||||
} catch (e) {
|
||||
} catch {
|
||||
toast.error('Failed to decline candidate');
|
||||
}
|
||||
};
|
||||
@@ -258,7 +258,7 @@ export default function PositionDetail() {
|
||||
try {
|
||||
await base44.entities.JobApplication.delete(app.id);
|
||||
toast.success(`${app.applicant_name} deleted`);
|
||||
} catch (e) {
|
||||
} catch {
|
||||
toast.error('Failed to delete candidate');
|
||||
}
|
||||
};
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
AddSkillTraining, ForgeHeader, ForgeProgress, ForgeRail, SkillDetail, SkillLibrary,
|
||||
SkillProgressCard, SkillProgressDetail, statusOf, workforceUsage,
|
||||
} from '@/components/forge';
|
||||
import { SectionTitle } from '@/pages/admin/_shell';
|
||||
import { SectionTitle } from '@/components/admin/PageShell';
|
||||
import { FilterSelect } from '@/pages/admin/Positions';
|
||||
import { completedCourseIds, skillStates } from '@/lib/skillGraph';
|
||||
import { SkillSurface } from '@/components/skills/SkillSurface';
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
Avatar, Badge, DataTable, MetricStrip, SearchInput, Surface, Timeline,
|
||||
} from '@/components/ds';
|
||||
import { useUserActivity } from '@/lib/krowHooks';
|
||||
import { AdminPage, SectionTitle, Toolbar } from '@/pages/admin/_shell';
|
||||
import { AdminPage, SectionTitle, Toolbar } from '@/components/admin/PageShell';
|
||||
import { FilterSelect } from '@/pages/admin/Positions';
|
||||
import { SkillSurface } from '@/components/skills/SkillSurface';
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Navigate, Outlet, useLocation } from 'react-router-dom';
|
||||
import { hasAdminSession } from './session';
|
||||
import { hasAdminSession } from '@/lib/admin/session';
|
||||
|
||||
/**
|
||||
* Gate on the Admin console.
|
||||
|
||||
@@ -9,7 +9,7 @@ import { DepartmentPerformance } from '@/components/charts/DepartmentPerformance
|
||||
import { HiringFlow } from '@/components/charts/HiringFlow';
|
||||
import { HiringTrendChart } from '@/components/charts/HiringTrendChart';
|
||||
import { useSize } from '@/hooks/use-size';
|
||||
import { AdminPage, SectionTitle } from '@/pages/admin/_shell';
|
||||
import { AdminPage, SectionTitle } from '@/components/admin/PageShell';
|
||||
import { SkillSurface } from '@/components/skills/SkillSurface';
|
||||
import {
|
||||
buildEfficiency, buildFunnel, buildInsights, buildTrend, byDepartment, byPosition,
|
||||
|
||||
@@ -3,7 +3,7 @@ import { Button, SearchInput } from '@/components/ds';
|
||||
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 { AdminPage, Toolbar } from '@/components/admin/PageShell';
|
||||
import { FilterSelect } from '@/pages/admin/Positions';
|
||||
import CandidateCard from '@/components/krow/CandidateCard';
|
||||
import { SkillSurface } from '@/components/skills/SkillSurface';
|
||||
@@ -29,7 +29,7 @@ const SORTS = {
|
||||
export default function AdminCandidates() {
|
||||
const { data: applications = [], isLoading } = useApplications();
|
||||
const { data: postings = [] } = useJobPostings();
|
||||
const { data: interviews = [] } = useInterviews();
|
||||
useInterviews();
|
||||
const updateApp = useUpdateApplication();
|
||||
const hireCandidate = useHireCandidate();
|
||||
|
||||
@@ -47,7 +47,7 @@ export default function AdminCandidates() {
|
||||
try {
|
||||
await updateApp.mutateAsync({ id: app.id, data: { status: 'rejected' } });
|
||||
toast.success(`${app.applicant_name} declined`);
|
||||
} catch (e) {
|
||||
} catch {
|
||||
toast.error('Failed to decline candidate');
|
||||
}
|
||||
};
|
||||
@@ -56,7 +56,7 @@ export default function AdminCandidates() {
|
||||
try {
|
||||
await base44.entities.JobApplication.delete(app.id);
|
||||
toast.success(`${app.applicant_name} deleted`);
|
||||
} catch (e) {
|
||||
} catch {
|
||||
toast.error('Failed to delete candidate');
|
||||
}
|
||||
};
|
||||
@@ -66,7 +66,7 @@ export default function AdminCandidates() {
|
||||
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) {
|
||||
} catch {
|
||||
toast.error('Failed to hire candidate');
|
||||
}
|
||||
};
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
} from '@/components/ds';
|
||||
import { useApplications, useInterviews, useJobPostings, useStaff, useWorkerProfiles } from '@/lib/krowHooks';
|
||||
import { buildFacts } from '@/components/ai-assistant/insights';
|
||||
import { AdminPage, SectionTitle } from '@/pages/admin/_shell';
|
||||
import { AdminPage, SectionTitle } from '@/components/admin/PageShell';
|
||||
import { SkillSurface } from '@/components/skills/SkillSurface';
|
||||
|
||||
/**
|
||||
|
||||
@@ -16,7 +16,7 @@ import {
|
||||
useApplications, useInterviews, useJobPostings, useStaff, useUserActivity, useWorkerProfiles,
|
||||
} from '@/lib/krowHooks';
|
||||
import { buildFacts } from '@/components/ai-assistant/insights';
|
||||
import { AdminPage } from '@/pages/admin/_shell';
|
||||
import { AdminPage } from '@/components/admin/PageShell';
|
||||
import { SkillSurface } from '@/components/skills/SkillSurface';
|
||||
|
||||
/**
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
Surface, Timeline,
|
||||
} from '@/components/ds';
|
||||
import { useApplications, useJobPostings, useStaff } from '@/lib/krowHooks';
|
||||
import { AdminPage, SectionTitle } from '@/pages/admin/_shell';
|
||||
import { AdminPage, SectionTitle } from '@/components/admin/PageShell';
|
||||
import { SkillSurface } from '@/components/skills/SkillSurface';
|
||||
import { hiresWithFill, summarise } from '@/lib/hiringRecords';
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ import { cn } from '@/lib/utils';
|
||||
import { KROW_LOGO_URL } from '@/assets/brand';
|
||||
import { Checkbox } from '@/components/ds';
|
||||
import { base44 } from '@/api/base44Client';
|
||||
import { startAdminSession } from '@/pages/admin/session';
|
||||
import { startAdminSession } from '@/lib/admin/session';
|
||||
|
||||
const DEMO_EMAIL = 'demo@krow.app';
|
||||
|
||||
@@ -80,7 +80,7 @@ export default function AdminLogin() {
|
||||
<div className="relative z-10 my-auto py-6 flex flex-col items-center">
|
||||
<div className="relative rounded-2xl overflow-hidden shadow-md border border-[#E2E8F0] bg-white p-2 max-w-xs">
|
||||
<img
|
||||
src="/krow%20logo.jpeg"
|
||||
src="/krow-logo-visual.jpeg"
|
||||
alt="KROW Logo Visual"
|
||||
className="w-full h-auto object-cover rounded-xl transition-transform duration-500 hover:scale-105"
|
||||
/>
|
||||
|
||||
@@ -1,72 +0,0 @@
|
||||
import React from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { ChevronRight } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Surface } from '@/components/ds';
|
||||
import { SectionTitle } from '@/pages/admin/_shell';
|
||||
|
||||
/**
|
||||
* The entry-point row, and the group it lives in.
|
||||
*
|
||||
* Settings and Workspace & Skills are two different subjects, but they are the
|
||||
* same *kind* of page: a short list of destinations, each saying what it manages
|
||||
* and how much of it there is. Sharing the furniture is what stops the two from
|
||||
* drifting into different-looking navigation for the same job — and it is the
|
||||
* reason a row added to either one needs no styling decisions.
|
||||
*
|
||||
* `meta` is always a real figure counted from real records. A row with nothing
|
||||
* to count simply omits it rather than showing a zero that means "not measured".
|
||||
*/
|
||||
|
||||
/** One destination. The whole row is the target, so there is no small link. */
|
||||
export function NavRow({ icon: Icon, title, description, meta = null, to, action = null }) {
|
||||
return (
|
||||
<Link
|
||||
to={to}
|
||||
className="group flex items-center gap-3.5 px-4 py-3.5 transition-colors hover:bg-surface-subtle
|
||||
focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-krow-blue/50"
|
||||
>
|
||||
<span className="grid h-9 w-9 shrink-0 place-items-center rounded-xl bg-krow-blue-tint text-krow-blue">
|
||||
<Icon className="h-4 w-4" aria-hidden="true" />
|
||||
</span>
|
||||
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block text-body-sm font-semibold text-ink-1">{title}</span>
|
||||
<span className="block text-caption leading-relaxed text-ink-3">{description}</span>
|
||||
{/* Below `sm` the count sits under the description rather than being
|
||||
dropped: on a phone it is the same fact, just in the only place
|
||||
there is room for it. */}
|
||||
{meta && <span className="mt-0.5 block text-caption tabular-nums text-ink-4 sm:hidden">{meta}</span>}
|
||||
</span>
|
||||
|
||||
{meta && <span className="hidden shrink-0 text-caption tabular-nums text-ink-4 sm:block">{meta}</span>}
|
||||
|
||||
{action && (
|
||||
<span className="hidden shrink-0 text-caption font-semibold text-krow-blue md:block">{action}</span>
|
||||
)}
|
||||
|
||||
<ChevronRight
|
||||
className="h-4 w-4 shrink-0 text-ink-4 transition-transform duration-base group-hover:translate-x-0.5"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
/** One group of rows. Same container and spacing in every group, on both pages. */
|
||||
export function NavGroup({ id, title, children, className }) {
|
||||
return (
|
||||
<section aria-labelledby={id} className={cn('space-y-3', className)}>
|
||||
<SectionTitle id={id} title={title} />
|
||||
<Surface
|
||||
variant="solid"
|
||||
radius="lg"
|
||||
padding="none"
|
||||
elevation="xs"
|
||||
className="divide-y divide-border overflow-hidden"
|
||||
>
|
||||
{children}
|
||||
</Surface>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -19,7 +19,7 @@ import {
|
||||
periodLabel, sectionTypeLabel, shapeForCapability, sourceSupportsOption, sourcesForShape,
|
||||
surfaceFor,
|
||||
} from '@/lib/skills/surfaces';
|
||||
import { AdminPage, SectionTitle } from '@/pages/admin/_shell';
|
||||
import { AdminPage, SectionTitle } from '@/components/admin/PageShell';
|
||||
|
||||
/**
|
||||
* Add or edit an Owliver skill.
|
||||
|
||||
@@ -21,9 +21,9 @@ import {
|
||||
import { SkillSurface } from '@/components/skills/SkillSurface';
|
||||
import { useAssistantPanel, usePublishPageContext } from '@/components/ai-assistant';
|
||||
import { continueDraftRequest } from '@/lib/skills/draftFlow';
|
||||
import { AdminPage, SectionTitle } from '@/pages/admin/_shell';
|
||||
import { RoleGlyph } from '@/pages/admin/RoleGlyph';
|
||||
import { buildPosition, payLabel } from '@/pages/admin/positionInsights';
|
||||
import { AdminPage, SectionTitle } from '@/components/admin/PageShell';
|
||||
import { RoleGlyph } from '@/components/admin/RoleGlyph';
|
||||
import { buildPosition, payLabel } from '@/lib/admin/positionInsights';
|
||||
|
||||
/**
|
||||
* Admin Positions — a hiring workspace, one card per role.
|
||||
|
||||
@@ -9,9 +9,9 @@ import { Button, Field, Input, Modal, Toggle, toast,
|
||||
} from '@/components/ds';
|
||||
import { base44 } from '@/api/base44Client';
|
||||
import { useCurrentUser, useUpdateProfile, useUserActivity } from '@/lib/krowHooks';
|
||||
import { AdminPage } from '@/pages/admin/_shell';
|
||||
import { endAdminSession } from '@/pages/admin/session';
|
||||
import { PERMISSIONS } from '@/pages/admin/permissions';
|
||||
import { AdminPage } from '@/components/admin/PageShell';
|
||||
import { endAdminSession } from '@/lib/admin/session';
|
||||
import { PERMISSIONS } from '@/lib/admin/permissions';
|
||||
import { SkillSurface } from '@/components/skills/SkillSurface';
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,73 +0,0 @@
|
||||
import {
|
||||
Briefcase, ChefHat, ConciergeBell, HardHat, PackageSearch, ShieldCheck, Sparkles, Truck, Wine,
|
||||
} from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
/**
|
||||
* RoleGlyph — the visual identity on a position card.
|
||||
*
|
||||
* Positions in this dataset have no photograph, and there is nothing to
|
||||
* photograph anyway: a role is not a person. The two obvious fallbacks are both
|
||||
* worse than this one — two-letter initials ("BA", "SE") read as a placeholder
|
||||
* someone forgot to replace, and stock photography attaches an invented face to a
|
||||
* job requisition, which is misleading on a page about hiring real people.
|
||||
*
|
||||
* So the glyph is the role's own trade: a chef's hat, a wine glass, a hard hat.
|
||||
* It is drawn from the existing icon language and the existing brand tint, it is
|
||||
* stable for a given category, and it tells you what kind of work the position is
|
||||
* from across the room — which is the actual job of an image in a card grid.
|
||||
*/
|
||||
|
||||
/** Category to icon. Matched loosely, so a new "Head Chef" still gets the hat. */
|
||||
/** @type {[RegExp, React.ComponentType<any>][]} */
|
||||
const GLYPHS = [
|
||||
[/chef|cook|kitchen/i, ChefHat],
|
||||
[/bartend|bar|mixolog/i, Wine],
|
||||
[/server|wait|host|banquet|concierge/i, ConciergeBell],
|
||||
[/security|guard|officer/i, ShieldCheck],
|
||||
[/pick|warehouse|stock|inventory/i, PackageSearch],
|
||||
[/driver|delivery|logistic/i, Truck],
|
||||
[/construct|labour|labor|site/i, HardHat],
|
||||
];
|
||||
|
||||
export function iconForRole(category, title = '') {
|
||||
const subject = `${category || ''} ${title || ''}`;
|
||||
const match = GLYPHS.find(([pattern]) => pattern.test(subject));
|
||||
return match ? match[1] : Briefcase;
|
||||
}
|
||||
|
||||
const SIZES = {
|
||||
sm: { box: 'h-9 w-9 rounded-lg', icon: 'h-4 w-4' },
|
||||
md: { box: 'h-11 w-11 rounded-xl', icon: 'h-5 w-5' },
|
||||
lg: { box: 'h-14 w-14 rounded-xl', icon: 'h-6 w-6' },
|
||||
};
|
||||
|
||||
/** @param {any} props */
|
||||
export function RoleGlyph({ category, title, size = 'md', aiGenerated = false, className = '' }) {
|
||||
const Icon = iconForRole(category, title);
|
||||
const s = SIZES[size] || SIZES.md;
|
||||
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
'relative grid shrink-0 place-items-center bg-krow-blue-tint text-krow-blue ring-1 ring-krow-blue/10',
|
||||
s.box,
|
||||
className
|
||||
)}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<Icon className={s.icon} />
|
||||
|
||||
{/* AI-built positions carry the same spark used everywhere else for
|
||||
generated content, so provenance is legible without a second badge
|
||||
competing for room in the card header. */}
|
||||
{aiGenerated && (
|
||||
<span className="absolute -right-1 -top-1 grid h-4 w-4 place-items-center rounded-full bg-krow-yellow text-ink-1 ring-2 ring-white">
|
||||
<Sparkles className="h-2 w-2" />
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export default RoleGlyph;
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import { useNavigate, Link } from 'react-router-dom';
|
||||
import { Link } from 'react-router-dom';
|
||||
import {
|
||||
Activity, Bell, Building2, KeyRound, Shield, User as UserIcon, Users,
|
||||
ChevronRight, Sliders,
|
||||
@@ -10,14 +10,13 @@ import { useAssistantPanel } from '@/components/ai-assistant';
|
||||
import {
|
||||
useJobPostings, usePreferences, useStaff, useUpdatePreferences,
|
||||
} from '@/lib/krowHooks';
|
||||
import { AdminPage } from '@/pages/admin/_shell';
|
||||
import { AdminPage } from '@/components/admin/PageShell';
|
||||
|
||||
/**
|
||||
* Admin Settings — Full-width platform configuration control center.
|
||||
*/
|
||||
|
||||
export default function AdminSettings() {
|
||||
const navigate = useNavigate();
|
||||
const preferences = usePreferences();
|
||||
const updatePreferences = useUpdatePreferences();
|
||||
const panel = useAssistantPanel();
|
||||
|
||||
@@ -7,7 +7,7 @@ import { useCourses, useCurrentUser, useWorkerProfiles } from '@/lib/krowHooks';
|
||||
import { useWorkforcePaths } from '@/lib/skills/usePageSkills';
|
||||
import { profileForEmail } from '@/lib/skillGraph';
|
||||
import { SkillProgressDetail } from '@/components/forge';
|
||||
import { AdminPage } from '@/pages/admin/_shell';
|
||||
import { AdminPage } from '@/components/admin/PageShell';
|
||||
|
||||
/**
|
||||
* Skill development — every training path, as a management list.
|
||||
|
||||
@@ -19,7 +19,7 @@ import {
|
||||
owliverCapabilityLabel, periodLabel, placementLabel, sectionTypeLabel, sourceSupportsOption,
|
||||
sourcesForShape, surfaceFor,
|
||||
} from '@/lib/skills/surfaces';
|
||||
import { AdminPage, SectionTitle } from '@/pages/admin/_shell';
|
||||
import { AdminPage, SectionTitle } from '@/components/admin/PageShell';
|
||||
|
||||
/**
|
||||
* Add or edit a UI skill, as a page.
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
} from '@/components/ds';
|
||||
import { useWorkerProfiles } from '@/lib/krowHooks';
|
||||
import { getScoreBand, toFICO } from '@/lib/talentHome';
|
||||
import { AdminPage, SectionTitle, Toolbar } from '@/pages/admin/_shell';
|
||||
import { AdminPage, SectionTitle, Toolbar } from '@/components/admin/PageShell';
|
||||
import { FilterSelect } from '@/pages/admin/Positions';
|
||||
import { SkillSurface } from '@/components/skills/SkillSurface';
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
import { usePreferences } from '@/lib/krowHooks';
|
||||
import { aiAgentSkills, allSkills, skillsWithFacet } from '@/lib/skills/registry';
|
||||
import { allAgents } from '@/lib/agents/registry';
|
||||
import { AdminPage } from '@/pages/admin/_shell';
|
||||
import { AdminPage } from '@/components/admin/PageShell';
|
||||
import OwliverAvatar from '@/components/krow/OwliverAvatar';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ import { useAgents } from '@/lib/agents/useAgents';
|
||||
import { searchAgents } from '@/lib/agents/registry';
|
||||
import { agentIconFor } from '@/components/agents/icons';
|
||||
import OwliverAvatar from '@/components/krow/OwliverAvatar';
|
||||
import { AdminPage } from '@/pages/admin/_shell';
|
||||
import { AdminPage } from '@/components/admin/PageShell';
|
||||
|
||||
/**
|
||||
* Agents — the workspace management list.
|
||||
|
||||
@@ -21,7 +21,7 @@ import { countSections } from '@/lib/skills/uiConfig';
|
||||
import {
|
||||
owliverCapabilityLabel, placementLabel, sectionTypeLabel, surfaceFor,
|
||||
} from '@/lib/skills/surfaces';
|
||||
import { AdminPage } from '@/pages/admin/_shell';
|
||||
import { AdminPage } from '@/components/admin/PageShell';
|
||||
|
||||
/**
|
||||
* Skills — the management page.
|
||||
|
||||
@@ -1,97 +0,0 @@
|
||||
import * as React from 'react';
|
||||
import { ChevronRight } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
/**
|
||||
* Shared page furniture for the Admin product.
|
||||
*
|
||||
* Admin pages are dense and numerous, so the header, the section labels and the
|
||||
* vertical rhythm are defined once here. That is what makes seven independently
|
||||
* built pages read as one product rather than seven takes on a dashboard.
|
||||
*/
|
||||
|
||||
/**
|
||||
* AdminPage — the standard page frame: title, subtitle, right-aligned controls,
|
||||
* then stacked sections.
|
||||
*
|
||||
* Controls live in the page rather than in the global chrome, which is why the
|
||||
* Admin header only needs one 56px bar.
|
||||
*/
|
||||
/** @param {any} props */
|
||||
export function AdminPage({ title, subtitle, meta, actions, tabs, children, className }) {
|
||||
return (
|
||||
<div className={cn('space-y-6', className)}>
|
||||
<header className="flex flex-wrap items-center justify-between gap-4 border-b border-border/50 pb-4">
|
||||
<div className="min-w-0 space-y-1">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<h1 className="font-heading text-title-lg font-bold tracking-tight text-ink-1 sm:text-2xl">{title}</h1>
|
||||
{/* A count of whatever the page is a list of, stated beside its
|
||||
name rather than only inside the list. */}
|
||||
{meta && <span className="text-body-sm tabular-nums text-ink-4">{meta}</span>}
|
||||
<span className="inline-flex items-center gap-1.5 rounded-full bg-blue-50 px-2.5 py-0.5 text-[11px] font-semibold text-blue-600 dark:bg-blue-950/60 dark:text-blue-400 border border-blue-200/60 dark:border-blue-800/40">
|
||||
<span className="h-1.5 w-1.5 rounded-full bg-blue-600 dark:bg-blue-400" />
|
||||
Live System
|
||||
</span>
|
||||
</div>
|
||||
{subtitle && <p className="text-body-sm text-ink-3 font-normal">{subtitle}</p>}
|
||||
</div>
|
||||
{actions && <div className="flex flex-wrap items-center gap-2.5">{actions}</div>}
|
||||
</header>
|
||||
|
||||
{tabs}
|
||||
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* SectionTitle — the label above a region.
|
||||
*
|
||||
* Sentence-case and small rather than a heavy all-caps overline: on a page with
|
||||
* four or five sections, loud labels compete with the data they introduce.
|
||||
*/
|
||||
/** @param {any} props */
|
||||
export function SectionTitle({ id, title, meta, action, className }) {
|
||||
return (
|
||||
<div className={cn('flex flex-wrap items-baseline justify-between gap-2', className)}>
|
||||
<div className="flex items-baseline gap-2">
|
||||
<h2 id={id} className="font-heading text-body font-semibold text-ink-1">{title}</h2>
|
||||
{meta && <span className="text-caption text-ink-4">{meta}</span>}
|
||||
</div>
|
||||
|
||||
{action && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={action.onClick}
|
||||
className="inline-flex items-center gap-0.5 rounded text-caption font-medium text-krow-blue transition-colors hover:underline
|
||||
focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-krow-blue/50"
|
||||
>
|
||||
{action.label}
|
||||
<ChevronRight className="h-3 w-3" aria-hidden="true" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Toolbar — the controls row above a table or list.
|
||||
*
|
||||
* Search takes the remaining space, filters keep their natural width, and the
|
||||
* primary action is pushed to the end, so every management page has the same
|
||||
* control geometry.
|
||||
*/
|
||||
/** @param {any} props */
|
||||
export function Toolbar({ search = null, filters = null, actions = null, meta = null, className = '' }) {
|
||||
return (
|
||||
<div className={cn('space-y-2', className)}>
|
||||
<div className="flex flex-col gap-2 lg:flex-row lg:items-center">
|
||||
{search && <div className="min-w-0 lg:max-w-xs lg:flex-1">{search}</div>}
|
||||
{filters && <div className="flex flex-wrap items-center gap-2">{filters}</div>}
|
||||
{actions && <div className="flex items-center gap-2 lg:ml-auto">{actions}</div>}
|
||||
</div>
|
||||
{meta && <p className="text-caption text-ink-4">{meta}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
/**
|
||||
* The administrator's permission scopes.
|
||||
*
|
||||
* Lives outside the page because two things now read it: the Profile page
|
||||
* renders it, and Owliver answers "what are my permissions?" from it. One list,
|
||||
* so the panel can never describe a scope the page does not show.
|
||||
*/
|
||||
export const PERMISSIONS = [
|
||||
{ scope: 'Positions', level: 'Full access', detail: 'Create, edit, pause and close roles' },
|
||||
{ scope: 'Candidates', level: 'Full access', detail: 'Screen, advance, decline and hire' },
|
||||
{ scope: 'Talent Pool', level: 'Full access', detail: 'Search, contact and shortlist' },
|
||||
{ scope: 'Analytics', level: 'Read only', detail: 'View reports and export' },
|
||||
{ scope: 'Audit log', level: 'Read only', detail: 'View all platform activity' },
|
||||
{ scope: 'Billing', level: 'No access', detail: 'Restricted to account owners' },
|
||||
];
|
||||
|
||||
export const LEVEL_TONE = { 'Full access': 'success', 'Read only': 'info', 'No access': 'neutral' };
|
||||
|
||||
/** What the Profile page can actually do, for the assistant to point at. */
|
||||
export const PROFILE_ACTIONS = [
|
||||
{ label: 'Edit profile', detail: 'Change your display name' },
|
||||
{ label: 'Change password', detail: 'Rotate your administrator password' },
|
||||
{ label: 'Manage sessions', detail: 'Review devices signed in to this account' },
|
||||
{ label: 'Two-factor authentication', detail: 'Required for privileged actions' },
|
||||
{ label: 'Owliver workspace', detail: 'Open the contextual panel by default on supported pages' },
|
||||
{ label: 'Compact density', detail: 'Tighter row heights across Admin tables' },
|
||||
{ label: 'Email digest', detail: 'A daily summary of what needs attention' },
|
||||
];
|
||||
@@ -1,127 +0,0 @@
|
||||
/**
|
||||
* Derived hiring facts for one position.
|
||||
*
|
||||
* Kept out of the components because the card, the drawer and the summary line
|
||||
* all need the same numbers, and three independent derivations is how a card ends
|
||||
* up disagreeing with the drawer it opens.
|
||||
*/
|
||||
|
||||
const STAGE_ORDER = ['applied', 'ai_screened', 'shortlisted', 'interview', 'hired'];
|
||||
|
||||
/** Everyone who reached this stage or went past it. */
|
||||
const atOrBeyond = (applications, stage) => {
|
||||
const from = STAGE_ORDER.indexOf(stage);
|
||||
return applications.filter((a) => STAGE_ORDER.indexOf(a.status) >= from);
|
||||
};
|
||||
|
||||
/**
|
||||
* Hiring health.
|
||||
*
|
||||
* Three states, and the thresholds are chosen so the label means something an
|
||||
* operator would act on rather than describing the shape of the data:
|
||||
*
|
||||
* - **At risk** — nothing to hire from. No applicants at all, or applicants but
|
||||
* nobody who clears the bar.
|
||||
* - **Needs attention** — supply exists but is stuck: a screening backlog, or a
|
||||
* role with candidates and no shortlist.
|
||||
* - **Strong** — moving.
|
||||
*/
|
||||
function healthFor({ applied, unscreened, qualified, shortlisted, hired, status }) {
|
||||
if (status !== 'active') {
|
||||
return { label: 'Paused', tone: 'neutral', note: 'Not currently hiring.' };
|
||||
}
|
||||
if (!applied) {
|
||||
return { label: 'At risk', tone: 'risk', note: 'No applicants yet.' };
|
||||
}
|
||||
/* Order matters here. "Nobody clears the bar" is only a safe conclusion once
|
||||
everyone has actually been looked at — with applications still unscreened it
|
||||
would be reporting a gap in our own process as a verdict on the candidates. */
|
||||
if (unscreened > 0) {
|
||||
return { label: 'Needs attention', tone: 'warning', note: `${unscreened} awaiting screening.` };
|
||||
}
|
||||
if (!qualified && !hired) {
|
||||
return { label: 'At risk', tone: 'risk', note: 'Everyone screened, nobody clears the bar.' };
|
||||
}
|
||||
if (!shortlisted && !hired && applied >= 3) {
|
||||
return { label: 'Needs attention', tone: 'warning', note: 'Screened but nobody shortlisted.' };
|
||||
}
|
||||
return { label: 'Strong', tone: 'success', note: 'Pipeline is moving.' };
|
||||
}
|
||||
|
||||
/**
|
||||
* The one thing worth saying about this position.
|
||||
*
|
||||
* Deliberately one. A card that lists every observation makes the reader do the
|
||||
* triage the card was supposed to do for them, so this returns the most
|
||||
* actionable finding and nothing else.
|
||||
*/
|
||||
function insightFor({ applied, unscreened, readyForInterview, coverage, hired, shortlisted, atRiskCount }) {
|
||||
if (!applied) return 'No applicants yet — check the pay band and where this is posted.';
|
||||
if (readyForInterview) {
|
||||
return `${readyForInterview} candidate${readyForInterview === 1 ? '' : 's'} ready for interview`;
|
||||
}
|
||||
if (unscreened) {
|
||||
return `${unscreened} candidate${unscreened === 1 ? '' : 's'} awaiting review`;
|
||||
}
|
||||
if (atRiskCount) {
|
||||
return `${atRiskCount} candidate${atRiskCount === 1 ? '' : 's'} at risk — no credentials on file`;
|
||||
}
|
||||
if (coverage < 100) return `Screening coverage ${coverage}%`;
|
||||
if (hired) return `${hired} hired${shortlisted > hired ? ` · ${shortlisted - hired} still shortlisted` : ''}`;
|
||||
return 'Fully screened and awaiting a decision';
|
||||
}
|
||||
|
||||
/** Builds one position row: the posting plus everything derived from its applicants. */
|
||||
export function buildPosition(posting, applications) {
|
||||
const apps = applications.filter((a) => a.job_posting_id === posting.id);
|
||||
|
||||
const applied = apps.length;
|
||||
const unscreened = apps.filter((a) => a.status === 'applied').length;
|
||||
const screened = atOrBeyond(apps, 'ai_screened').length;
|
||||
const shortlisted = atOrBeyond(apps, 'shortlisted').length;
|
||||
const interviews = atOrBeyond(apps, 'interview').length;
|
||||
const hired = apps.filter((a) => a.status === 'hired').length;
|
||||
|
||||
const scored = apps.filter((a) => a.ai_score > 0);
|
||||
const qualified = scored.filter((a) => a.ai_score >= 70).length;
|
||||
const coverage = applied ? Math.round((scored.length / applied) * 100) : 0;
|
||||
const avgScore = scored.length
|
||||
? Math.round(scored.reduce((sum, a) => sum + a.ai_score, 0) / scored.length)
|
||||
: 0;
|
||||
|
||||
/* Screened, strong, and not yet moved — the people a decision is owed to. */
|
||||
const readyForInterview = apps.filter(
|
||||
(a) => a.ai_score >= 70 && ['ai_screened', 'shortlisted'].includes(a.status)
|
||||
).length;
|
||||
const atRiskCount = scored.filter((a) => !(a.certifications || []).length).length;
|
||||
|
||||
const stats = {
|
||||
applied, unscreened, screened, shortlisted, interviews, hired,
|
||||
qualified, coverage, avgScore, readyForInterview, atRiskCount,
|
||||
};
|
||||
|
||||
return {
|
||||
...posting,
|
||||
applications: apps,
|
||||
stats,
|
||||
health: healthFor({ ...stats, status: posting.status }),
|
||||
insight: insightFor(stats),
|
||||
/* The funnel, for the progression strip. Counts are cumulative, so each step
|
||||
is a subset of the one before it and the bar never grows as it advances. */
|
||||
progression: [
|
||||
{ key: 'applied', label: 'Applied', count: applied },
|
||||
{ key: 'screened', label: 'Screened', count: screened },
|
||||
{ key: 'shortlisted', label: 'Shortlisted', count: shortlisted },
|
||||
{ key: 'interview', label: 'Interviewed', count: interviews },
|
||||
{ key: 'hired', label: 'Hired', count: hired },
|
||||
],
|
||||
topCandidates: [...apps]
|
||||
.filter((a) => a.ai_score > 0)
|
||||
.sort((a, b) => b.ai_score - a.ai_score)
|
||||
.slice(0, 4),
|
||||
};
|
||||
}
|
||||
|
||||
/** Formats a pay band, or says plainly that there is not one. */
|
||||
export const payLabel = (p) =>
|
||||
p.pay_range_max ? `$${p.pay_range_min}–$${p.pay_range_max}/hr` : 'Pay not set';
|
||||
@@ -1,50 +0,0 @@
|
||||
/**
|
||||
* Admin sign-in state.
|
||||
*
|
||||
* The demo's data client (`base44.auth`) reports the seeded user as permanently
|
||||
* signed in — there is no identity provider behind it. That is fine for the
|
||||
* Employer and Talent demos, but the Admin console is supposed to be reached
|
||||
* through its own sign-in, and "always authenticated" would mean /admin opened
|
||||
* straight into the Control Center with the login page never seen.
|
||||
*
|
||||
* So Admin keeps a session marker of its own. It is deliberately thin: a single
|
||||
* flag, set by the login page and read by the route guard. It is not security —
|
||||
* a browser flag never is — it is the state a real session would occupy, put in
|
||||
* the one place that both the guard and the login screen can agree on, so the
|
||||
* flow is right now and swapping in a real provider later means changing this
|
||||
* file and nothing else.
|
||||
*
|
||||
* `sessionStorage`, not `localStorage`: closing the tab should end the session,
|
||||
* which is both the more defensible default for an administrative console and
|
||||
* what makes the login page the app's actual starting point.
|
||||
*/
|
||||
|
||||
const SESSION_KEY = 'krow_admin_session';
|
||||
|
||||
/** True when this tab has signed in to the Admin console. */
|
||||
export function hasAdminSession() {
|
||||
try {
|
||||
return sessionStorage.getItem(SESSION_KEY) === '1';
|
||||
} catch {
|
||||
// Private mode with storage denied: fail closed, so the login page shows.
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Records a successful sign-in. */
|
||||
export function startAdminSession() {
|
||||
try {
|
||||
sessionStorage.setItem(SESSION_KEY, '1');
|
||||
} catch {
|
||||
// Nothing to do — the guard will simply ask for sign-in again.
|
||||
}
|
||||
}
|
||||
|
||||
/** Clears the session. Used by sign-out. */
|
||||
export function endAdminSession() {
|
||||
try {
|
||||
sessionStorage.removeItem(SESSION_KEY);
|
||||
} catch {
|
||||
// Ignore.
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user