chore: clean up and restructure repository

This commit is contained in:
2026-08-21 11:11:21 +05:30
parent b2e6868824
commit dcf9770ada
106 changed files with 239 additions and 5654 deletions

View File

@@ -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>
);
}

View File

@@ -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 (

View File

@@ -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);

View File

@@ -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');
}
};

View File

@@ -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');
}
};

View File

@@ -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');
}

View File

@@ -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');
}
};

View File

@@ -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';

View File

@@ -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';

View File

@@ -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.

View File

@@ -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,

View File

@@ -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');
}
};

View File

@@ -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';
/**

View File

@@ -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';
/**

View File

@@ -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';

View File

@@ -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"
/>

View File

@@ -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>
);
}

View File

@@ -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.

View File

@@ -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.

View File

@@ -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';
/**

View File

@@ -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;

View File

@@ -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();

View File

@@ -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.

View File

@@ -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.

View File

@@ -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';

View File

@@ -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';

View File

@@ -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.

View File

@@ -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.

View File

@@ -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>
);
}

View File

@@ -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' },
];

View File

@@ -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';

View File

@@ -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.
}
}