fix talent pool ui
This commit is contained in:
14
src/App.jsx
14
src/App.jsx
@@ -1,7 +1,7 @@
|
||||
import { Toaster } from "@/components/ui/toaster"
|
||||
import { QueryClientProvider } from '@tanstack/react-query'
|
||||
import { queryClientInstance } from '@/lib/query-client'
|
||||
import { Navigate, BrowserRouter as Router, Route, Routes } from 'react-router-dom';
|
||||
import { Navigate, BrowserRouter as Router, Route, Routes, useParams } from 'react-router-dom';
|
||||
import PageNotFound from './lib/PageNotFound';
|
||||
import { AuthProvider, useAuth } from '@/lib/AuthContext';
|
||||
import ScrollToTop from './components/ScrollToTop';
|
||||
@@ -42,6 +42,12 @@ import AdminProfile from '@/pages/admin/Profile';
|
||||
import AdminLogin from '@/pages/admin/Login';
|
||||
import AdminRoute from '@/pages/admin/AdminRoute';
|
||||
|
||||
/** `/university/:id` → the same challenge inside the Admin shell, id preserved. */
|
||||
const LegacyCourseRedirect = () => {
|
||||
const { id } = useParams();
|
||||
return <Navigate to={`/admin/university/${id}`} replace />;
|
||||
};
|
||||
|
||||
const AuthenticatedApp = () => {
|
||||
const { isLoadingAuth, isLoadingPublicSettings } = useAuth();
|
||||
|
||||
@@ -94,7 +100,11 @@ const AuthenticatedApp = () => {
|
||||
<Route path="/hired" element={<Navigate to="/admin/hired" replace />} />
|
||||
<Route path="/talent-pool" element={<Navigate to="/admin/talent-pool" replace />} />
|
||||
<Route path="/university" element={<Navigate to="/admin/university" replace />} />
|
||||
<Route path="/university/:id" element={<CourseDetail />} />
|
||||
{/* Same treatment as every other legacy alias: the challenge opens inside
|
||||
the application shell rather than on a bare page, so Forge → challenge
|
||||
keeps its navigation, header and page frame. Existing links to
|
||||
/university/:id are unchanged and still resolve here. */}
|
||||
<Route path="/university/:id" element={<LegacyCourseRedirect />} />
|
||||
<Route path="/analytics" element={<Navigate to="/admin/analytics" replace />} />
|
||||
<Route path="/activity" element={<Navigate to="/admin/activity" replace />} />
|
||||
<Route path="/tracking" element={<Navigate to="/admin/activity" replace />} />
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import {
|
||||
Activity, AlertTriangle, ArrowLeftRight, BellRing, Eye, Filter, Gauge, GitBranch,
|
||||
Lightbulb, ScrollText, ShieldAlert, Target, TrendingUp, UserCheck, Users,
|
||||
Layers, Lightbulb, ScrollText, ShieldAlert, Target, TrendingUp, UserCheck, Users,
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
actions, answer, doc, funnel, heading, insights, kpis, list, meters, note, status, table,
|
||||
@@ -1099,6 +1099,136 @@ const candidatePipelineSummary = (f) => {
|
||||
);
|
||||
};
|
||||
|
||||
/* ── Analytics ──────────────────────────────────────────────────────────── */
|
||||
|
||||
const DAY_MS = 1000 * 60 * 60 * 24;
|
||||
|
||||
/**
|
||||
* Hiring trend — direction of travel, not another chart.
|
||||
*
|
||||
* The page already draws the line; what it cannot say is whether the line is
|
||||
* going up. So this compares the last four weeks against the four before them
|
||||
* and reports the change, using the same created/updated dates the chart buckets.
|
||||
*/
|
||||
const hiringTrend = (f) => {
|
||||
if (!f.total) return answer('No applications yet, so there is no trend to read.');
|
||||
|
||||
const inWindow = (value, from, to) => {
|
||||
const t = new Date(value).getTime();
|
||||
return t >= f.today.getTime() - from * DAY_MS && t < f.today.getTime() - to * DAY_MS;
|
||||
};
|
||||
const applied = (from, to) => f.applications.filter((a) => inWindow(a.created_date, from, to)).length;
|
||||
/* Screening is dated by the update that carried the score, exactly as the
|
||||
Hiring trend chart buckets it. */
|
||||
const screened = (from, to) => f.scored.filter((a) => inWindow(a.updated_date, from, to)).length;
|
||||
|
||||
const recent = applied(28, 0);
|
||||
const previous = applied(56, 28);
|
||||
const change = previous ? Math.round(((recent - previous) / previous) * 100) : null;
|
||||
|
||||
const weeks = [3, 2, 1, 0].map((w) => ({
|
||||
week: w === 0 ? 'This week' : `${w} week${w === 1 ? '' : 's'} ago`,
|
||||
applied: applied((w + 1) * 7, w * 7),
|
||||
screened: screened((w + 1) * 7, w * 7),
|
||||
}));
|
||||
|
||||
return doc(
|
||||
heading('Hiring trend', 'Last 4 weeks against the 4 before'),
|
||||
kpis([
|
||||
{ label: 'Applications', value: recent, sub: `${previous} in the prior period` },
|
||||
{
|
||||
label: 'Change',
|
||||
value: change === null ? '—' : `${change > 0 ? '+' : ''}${change}%`,
|
||||
tone: change === null ? undefined : change >= 0 ? 'success' : 'warning',
|
||||
},
|
||||
{ label: 'Screened', value: screened(28, 0), sub: `${f.standardizedPct}% coverage` },
|
||||
{ label: 'Hires', value: f.hired.length, sub: f.timeToHire ? `${f.timeToHire}d to close` : 'none closed' },
|
||||
]),
|
||||
heading('Week by week'),
|
||||
table(
|
||||
[
|
||||
{ key: 'week', label: 'Week' },
|
||||
{ key: 'applied', label: 'Applied', align: 'right' },
|
||||
{ key: 'screened', label: 'Screened', align: 'right' },
|
||||
],
|
||||
weeks
|
||||
),
|
||||
insights([
|
||||
change !== null && {
|
||||
tone: change >= 0 ? 'success' : 'warning',
|
||||
title: change >= 0
|
||||
? `Applications up ${change}% on the previous four weeks`
|
||||
: `Applications down ${Math.abs(change)}% on the previous four weeks`,
|
||||
body: change >= 0
|
||||
? 'Supply is growing, so the constraint moves to screening throughput rather than reach.'
|
||||
: 'Fewer people are arriving. Distribution and pay band are the two things that move this, not screening.',
|
||||
},
|
||||
f.unscreened.length && {
|
||||
tone: 'warning',
|
||||
title: `${plural(f.unscreened.length, 'application')} still unscored`,
|
||||
body: 'The screened line lags the applied line by exactly this much — the gap is a queue, not a rejection rate.',
|
||||
},
|
||||
]),
|
||||
note('Windows are counted from today, so this is independent of the 7d/30d/90d control on the chart.')
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Department performance — which role category is actually converting.
|
||||
*
|
||||
* The page's department section ranks by volume. Volume is demand, not
|
||||
* performance: the category worth copying is the one turning applicants into
|
||||
* qualified candidates, which is a rate.
|
||||
*/
|
||||
const departmentPerformance = (f) => {
|
||||
if (!f.byRole.length) return answer('No open positions, so there are no departments to compare.');
|
||||
|
||||
const rows = Object.values(f.byRole.reduce((acc, r) => {
|
||||
acc[r.category] ||= { category: r.category, roles: 0, applied: 0, qualified: 0, hired: 0 };
|
||||
acc[r.category].roles += 1;
|
||||
acc[r.category].applied += r.applied;
|
||||
acc[r.category].qualified += r.qualified;
|
||||
acc[r.category].hired += r.hired;
|
||||
return acc;
|
||||
}, {})).map((d) => ({ ...d, rate: d.applied ? Math.round((d.qualified / d.applied) * 100) : 0 }))
|
||||
.sort((a, b) => b.rate - a.rate || b.applied - a.applied);
|
||||
|
||||
const best = rows.find((d) => d.applied > 0);
|
||||
const starved = rows.filter((d) => d.applied === 0);
|
||||
|
||||
return doc(
|
||||
heading('Department performance', `${plural(rows.length, 'category')}`),
|
||||
table(
|
||||
[
|
||||
{ key: 'category', label: 'Category' },
|
||||
{ key: 'applied', label: 'Applied', align: 'right' },
|
||||
{ key: 'qualified', label: '70+', align: 'right' },
|
||||
{ key: 'hired', label: 'Hired', align: 'right' },
|
||||
{ key: 'rate', label: 'Qualified', align: 'right' },
|
||||
],
|
||||
rows.map((d) => ({
|
||||
category: d.category,
|
||||
applied: d.applied ? d.applied : { value: 0, tone: 'risk' },
|
||||
qualified: d.qualified,
|
||||
hired: d.hired || '—',
|
||||
rate: { value: `${d.rate}%`, tone: d.rate >= 50 ? 'success' : d.rate > 0 ? 'warning' : 'risk' },
|
||||
}))
|
||||
),
|
||||
insights([
|
||||
best && {
|
||||
tone: 'success',
|
||||
title: `${best.category} is performing best`,
|
||||
body: `${best.qualified} of ${best.applied} applicants clear 70 — ${best.rate}%${best.hired ? `, and ${plural(best.hired, 'hire')} closed` : ''}. Whatever sourcing this category uses is the one worth copying.`,
|
||||
},
|
||||
starved.length && {
|
||||
tone: 'risk',
|
||||
title: `${plural(starved.length, 'category')} attracting nobody`,
|
||||
body: `${starved.map((d) => d.category).join(', ')}. A conversion rate cannot be read off zero applicants.`,
|
||||
},
|
||||
])
|
||||
);
|
||||
};
|
||||
|
||||
/* ── Free-text responders ───────────────────────────────────────────────── */
|
||||
|
||||
/**
|
||||
@@ -1151,6 +1281,16 @@ export const respondAdminCandidates = (question, f) => {
|
||||
return recruitmentInsights(f);
|
||||
};
|
||||
|
||||
export const respondAdminAnalytics = (question, f) => {
|
||||
const q = question.toLowerCase();
|
||||
if (has(q, 'trend', 'over time', 'growing', 'slow', 'week', 'month')) return hiringTrend(f);
|
||||
if (has(q, 'department', 'category', 'team', 'best perform')) return departmentPerformance(f);
|
||||
if (has(q, 'bottleneck', 'funnel', 'drop', 'conversion', 'stuck', 'lose')) return pipelineHealth(f);
|
||||
if (has(q, 'position', 'role', 'converting', 'strongest')) return positionStrength(f);
|
||||
if (has(q, 'attention', 'urgent', 'wrong', 'fix', 'risk')) return attentionRequired(f);
|
||||
return hiringOperations(f);
|
||||
};
|
||||
|
||||
export const respondActivity = (question, f) => {
|
||||
const q = question.toLowerCase();
|
||||
if (has(q, 'security', 'breach', 'attributab', 'compliance', 'integrity', 'trace')) return securityInsights(f);
|
||||
@@ -1196,6 +1336,15 @@ export const ADMIN_CANDIDATE_CAPABILITIES = [
|
||||
{ id: 'top-candidates', label: 'Compare Top Candidates', icon: ArrowLeftRight, run: topCandidates },
|
||||
];
|
||||
|
||||
export const ADMIN_ANALYTICS_CAPABILITIES = [
|
||||
{ id: 'hiring-trend', label: 'Hiring Trend', icon: TrendingUp, run: hiringTrend },
|
||||
{ id: 'department-performance', label: 'Department Performance', icon: Layers, run: departmentPerformance },
|
||||
{ id: 'pipeline-health', label: 'Hiring Bottlenecks', icon: GitBranch, run: pipelineHealth },
|
||||
{ id: 'position-conversion', label: 'Position Conversion', icon: Target, run: positionStrength },
|
||||
{ id: 'hiring-operations', label: 'Hiring Performance', icon: Activity, run: hiringOperations },
|
||||
{ id: 'attention-required', label: 'Needs Attention', icon: BellRing, run: attentionRequired },
|
||||
];
|
||||
|
||||
export const ACTIVITY_CAPABILITIES = [
|
||||
{ id: 'audit-summary', label: 'Audit Summary', icon: ScrollText, run: auditSummary },
|
||||
{ id: 'user-activity', label: 'User Activity', icon: Eye, run: userActivityInsights },
|
||||
|
||||
@@ -3,10 +3,10 @@ import {
|
||||
respondAnalytics, respondCandidates, respondOverview,
|
||||
} from './capabilities/employer';
|
||||
import {
|
||||
ACTIVITY_CAPABILITIES, ADMIN_CANDIDATE_CAPABILITIES, CANDIDATE_LIST_CAPABILITIES,
|
||||
CONTROL_CENTER_CAPABILITIES, POSITIONS_CAPABILITIES,
|
||||
respondActivity, respondAdminCandidates, respondCandidateList, respondControlCenter,
|
||||
respondPositions,
|
||||
ACTIVITY_CAPABILITIES, ADMIN_ANALYTICS_CAPABILITIES, ADMIN_CANDIDATE_CAPABILITIES,
|
||||
CANDIDATE_LIST_CAPABILITIES, CONTROL_CENTER_CAPABILITIES, POSITIONS_CAPABILITIES,
|
||||
respondActivity, respondAdminAnalytics, respondAdminCandidates, respondCandidateList,
|
||||
respondControlCenter, respondPositions,
|
||||
} from './capabilities/admin';
|
||||
|
||||
/**
|
||||
@@ -66,6 +66,14 @@ export const ASSISTANT_CONTEXTS = {
|
||||
capabilities: ADMIN_CANDIDATE_CAPABILITIES,
|
||||
respond: respondAdminCandidates,
|
||||
},
|
||||
/* Analytics reads the same records as the Control Center, but as performance
|
||||
over time rather than as a state to act on — hence its own capabilities. */
|
||||
'admin.analytics': {
|
||||
id: 'admin.analytics',
|
||||
page: 'Analytics',
|
||||
capabilities: ADMIN_ANALYTICS_CAPABILITIES,
|
||||
respond: respondAdminAnalytics,
|
||||
},
|
||||
'admin.activity': {
|
||||
id: 'admin.activity',
|
||||
page: 'Activity',
|
||||
|
||||
@@ -122,6 +122,17 @@ const GREETINGS = {
|
||||
return `All ${f.total} candidates are scored, credentialed and comparable.`;
|
||||
},
|
||||
|
||||
'admin.analytics': (f) => {
|
||||
if (!f.total) return 'Nothing has been applied for yet, so the charts have nothing to report.';
|
||||
if (f.bottleneck && f.bottleneck.lost > 0) {
|
||||
return `Conversion is weakest between ${f.bottleneck.from} and ${f.bottleneck.to}, at ${f.bottleneck.rate}%.`;
|
||||
}
|
||||
if (f.standardizedPct < 80) {
|
||||
return `Screening covers ${f.standardizedPct}% of applications, so the rest of these figures rest on a partial pool.`;
|
||||
}
|
||||
return `${plural(f.hired.length, 'hire')} from ${plural(f.total, 'applicant')} — ${f.hireRate}% conversion.`;
|
||||
},
|
||||
|
||||
'admin.activity': (f) => {
|
||||
if (!f.activity.length) return 'No activity logged yet.';
|
||||
const patterns = f.activitySignals.flags.length;
|
||||
@@ -155,6 +166,7 @@ const TITLES = {
|
||||
'admin.positions': (f, name) => `Good ${timeOfDay(f.today)}${name ? `, ${name}` : ''}`,
|
||||
'admin.candidatesList': (f, name) => `Good ${timeOfDay(f.today)}${name ? `, ${name}` : ''}`,
|
||||
'admin.candidates': (f, name) => `Good ${timeOfDay(f.today)}${name ? `, ${name}` : ''}`,
|
||||
'admin.analytics': (f, name) => `Good ${timeOfDay(f.today)}${name ? `, ${name}` : ''}`,
|
||||
'admin.activity': (f, name) => `Good ${timeOfDay(f.today)}${name ? `, ${name}` : ''}`,
|
||||
};
|
||||
|
||||
@@ -172,6 +184,8 @@ const DESCRIPTIONS = {
|
||||
`Reading ${plural(f.total, 'candidate')} across ${plural(f.postings.length, 'position')} — match quality, screening and hiring readiness.`,
|
||||
'admin.candidates': (f) =>
|
||||
`${plural(f.total, 'candidate')} and ${plural(f.profiles.length, 'talent profile')} — supply, quality and risk.`,
|
||||
'admin.analytics': (f) =>
|
||||
`Reading hiring performance across ${plural(f.openPositions.length, 'position')}, ${plural(f.total, 'candidate')} and ${plural(new Set(f.byRole.map((r) => r.category)).size, 'department')} — conversion, speed and outcomes.`,
|
||||
'admin.activity': (f) => `${plural(f.activity.length, 'logged event')}, reconciled and profiled.`,
|
||||
};
|
||||
|
||||
@@ -218,6 +232,11 @@ const PLACEHOLDERS = {
|
||||
'Ask Owliver about screening gaps…',
|
||||
'Ask Owliver where the risk is…',
|
||||
],
|
||||
'admin.analytics': () => [
|
||||
'Ask Owliver about hiring performance…',
|
||||
'Ask Owliver about the hiring trend…',
|
||||
'Ask Owliver where the bottleneck is…',
|
||||
],
|
||||
'admin.activity': () => [
|
||||
'Ask Owliver about recent activity…',
|
||||
'Ask Owliver about unusual activity…',
|
||||
@@ -333,6 +352,19 @@ const PROMPTS = {
|
||||
{ label: 'Recruitment insights', prompt: 'Show recruitment insights across the platform', capability: 'recruitment-insights' },
|
||||
],
|
||||
|
||||
'admin.analytics': (f) => [
|
||||
{ label: 'What is the hiring trend?', prompt: 'What is the hiring trend?', capability: 'hiring-trend' },
|
||||
{ label: 'Which department is performing best?', prompt: 'Which department is performing best?', capability: 'department-performance' },
|
||||
f.bottleneck && f.bottleneck.lost > 0
|
||||
? { label: `Biggest drop at ${f.bottleneck.to}`, prompt: `Where is the biggest hiring bottleneck — why do candidates drop between ${f.bottleneck.from} and ${f.bottleneck.to}?`, capability: 'pipeline-health' }
|
||||
: { label: 'Where is the biggest bottleneck?', prompt: 'Where is the biggest hiring bottleneck?', capability: 'pipeline-health' },
|
||||
{ label: 'Which positions are converting best?', prompt: 'Which positions are converting best?', capability: 'position-conversion' },
|
||||
{ label: 'Summarize hiring performance', prompt: 'Summarize hiring performance', capability: 'hiring-operations' },
|
||||
f.unscreened.length
|
||||
? { label: `${f.unscreened.length} unscored applications`, prompt: 'What needs attention?', capability: 'attention-required' }
|
||||
: { label: 'What needs attention?', prompt: 'What needs attention?', capability: 'attention-required' },
|
||||
],
|
||||
|
||||
'admin.activity': (f) => [
|
||||
f.activitySignals.flags.length
|
||||
? { label: `${f.activitySignals.flags.length} unusual patterns`, prompt: 'Show unusual activity on the platform', capability: 'unusual-activity' }
|
||||
|
||||
@@ -17,6 +17,7 @@ const PLACEMENT = {
|
||||
'/admin/positions': 'admin.positions',
|
||||
'/admin/candidates': 'admin.candidatesList',
|
||||
'/admin/candidates-analysis': 'admin.candidates',
|
||||
'/admin/analytics': 'admin.analytics',
|
||||
'/admin/activity': 'admin.activity',
|
||||
},
|
||||
};
|
||||
@@ -26,8 +27,8 @@ const PLACEMENT = {
|
||||
* documented rather than implied by omission.
|
||||
*
|
||||
* Admin: the reporting and reference pages, plus login. The working surfaces —
|
||||
* Control Center, Positions, Candidates, Candidates Analysis and
|
||||
* Activity — all carry the panel.
|
||||
* Control Center, Positions, Candidates, Candidates Analysis, Analytics
|
||||
* and Activity — all carry the panel.
|
||||
* Employer: every page. The contextual panel is an Admin capability.
|
||||
* Talent: every page. The talent portal has the standalone Owliver product,
|
||||
* with its own voice experience, branding and workflow.
|
||||
@@ -39,8 +40,7 @@ export const EXCLUDED_ROUTES = [
|
||||
'/university', '/university/:id', '/me', '/identity', '/employee',
|
||||
'/owliver', '/design-system',
|
||||
// Admin pages that are a report or a form rather than a workflow.
|
||||
'/admin/hired', '/admin/talent-pool', '/admin/analytics',
|
||||
'/admin/profile', '/admin/login',
|
||||
'/admin/hired', '/admin/talent-pool', '/admin/profile', '/admin/login',
|
||||
// The full candidate profile is one person's record, not a page-level question.
|
||||
'/admin/candidates/:id',
|
||||
];
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Lock, Video, Camera, MessageSquare, ScanLine, Loader2, XCircle, CheckCircle2, ShieldCheck, Award, Eye, ArrowRight } from 'lucide-react';
|
||||
import { Lock, Video, Camera, MessageSquare, ScanLine, XCircle, CheckCircle2, ShieldCheck, Award, Eye, ArrowRight } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Button, Spinner, Surface } from '@/components/ds';
|
||||
import { isUnlocked } from '@/lib/provingGround';
|
||||
import { useSubmitChallenge, useVerifyEvidence } from '@/lib/krowHooks';
|
||||
import RoleplayChallenge from './RoleplayChallenge';
|
||||
@@ -8,23 +10,43 @@ import VideoRecorder from './VideoRecorder';
|
||||
import HazardIdentifier from './HazardIdentifier';
|
||||
import OwliverAvatar from '@/components/krow/OwliverAvatar';
|
||||
|
||||
/**
|
||||
* The challenge itself: learn, demonstrate, verify, proof.
|
||||
*
|
||||
* Presentation only — every step, transition and mutation below is the original
|
||||
* flow. Styling comes from the design system so the runner reads as part of the
|
||||
* Forge workspace it is embedded in rather than a widget dropped into it.
|
||||
*/
|
||||
|
||||
const STEPS = ['Learn', 'Demonstrate', 'Verify', 'Proof'];
|
||||
const TYPE_ICON = { roleplay: MessageSquare, photo: Camera, video: Video, photo_identify: ScanLine };
|
||||
const TYPE_LABEL = { roleplay: 'Chat with Owliver', photo: 'Photo Proof', video: 'Record Yourself', photo_identify: 'Identify Hazards' };
|
||||
|
||||
function Stepper({ active }) {
|
||||
return (
|
||||
<div className="flex items-center gap-1 mb-6">
|
||||
<div className="mb-5 flex items-center gap-1.5">
|
||||
{STEPS.map((s, i) => {
|
||||
const done = i < active;
|
||||
const cur = i === active;
|
||||
return (
|
||||
<React.Fragment key={s}>
|
||||
<div className={`flex items-center gap-1.5 ${cur || done ? '' : 'opacity-50'}`}>
|
||||
<div className={`w-6 h-6 rounded-full flex items-center justify-center text-[11px] font-bold ${done ? 'bg-[#0A39DF] text-white' : cur ? 'bg-[#0A39DF] text-white ring-4 ring-[#EFF6FF]' : 'bg-[#F1F5F9] text-[#94A3B8]'}`}>{done ? '✓' : i + 1}</div>
|
||||
<span className={`text-[12px] font-medium ${cur || done ? 'text-[#0F172A]' : 'text-[#94A3B8]'}`}>{s}</span>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span
|
||||
className={cn(
|
||||
'grid h-5 w-5 place-items-center rounded-full text-[10px] font-bold',
|
||||
done || cur ? 'bg-krow-blue text-white' : 'bg-surface-sunken text-ink-4',
|
||||
cur && 'ring-4 ring-krow-blue-tint'
|
||||
)}
|
||||
>
|
||||
{done ? '✓' : i + 1}
|
||||
</span>
|
||||
<span className={cn('text-caption font-medium', done || cur ? 'text-ink-1' : 'text-ink-4')}>
|
||||
{s}
|
||||
</span>
|
||||
</div>
|
||||
{i < STEPS.length - 1 && <div className={`flex-1 h-px ${done ? 'bg-[#0A39DF]' : 'bg-[#E5E7EB]'}`} />}
|
||||
{i < STEPS.length - 1 && (
|
||||
<span className={cn('h-px flex-1', done ? 'bg-krow-blue' : 'bg-border')} aria-hidden="true" />
|
||||
)}
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
@@ -32,6 +54,15 @@ function Stepper({ active }) {
|
||||
);
|
||||
}
|
||||
|
||||
/** The runner's shell — one surface, one padding, every step. */
|
||||
function Frame({ children, className }) {
|
||||
return (
|
||||
<Surface variant="solid" radius="lg" padding="none" elevation="xs" className={cn('px-5 py-4', className)}>
|
||||
{children}
|
||||
</Surface>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ChallengeRunner({ course, profile, onPassed }) {
|
||||
const [step, setStep] = useState(0);
|
||||
const [evaluating, setEvaluating] = useState(false);
|
||||
@@ -78,62 +109,81 @@ export default function ChallengeRunner({ course, profile, onPassed }) {
|
||||
// 0 — Learn
|
||||
if (step === 0) {
|
||||
return (
|
||||
<div className="rounded-2xl border border-[#E5E7EB] bg-white p-6">
|
||||
<Frame>
|
||||
<Stepper active={0} />
|
||||
{!lock.unlocked ? (
|
||||
<>
|
||||
<div className="flex items-center gap-2 text-[#0F172A] mb-2"><Lock className="w-4 h-4" /><span className="font-semibold text-[15px]">Locked — earn this one</span></div>
|
||||
<p className="text-[13px] text-[#64748B] mb-3">Real work unlocks learning. Complete these first:</p>
|
||||
<ul className="space-y-1.5">
|
||||
{lock.reasons.map((r, i) => (<li key={i} className="text-[13px] text-[#475569] flex items-center gap-2"><span className="w-1.5 h-1.5 rounded-full bg-[#94A3B8]" />{r}</li>))}
|
||||
<p className="flex items-center gap-2 text-body font-semibold text-ink-1">
|
||||
<Lock className="h-4 w-4 text-warning" aria-hidden="true" /> Locked — earn this one
|
||||
</p>
|
||||
<p className="mt-1 text-body-sm text-ink-3">Real work unlocks learning. Complete these first:</p>
|
||||
<ul className="mt-2.5 space-y-1.5">
|
||||
{lock.reasons.map((r, i) => (
|
||||
<li key={i} className="flex items-start gap-2 text-body-sm text-ink-2">
|
||||
<span className="mt-1.5 h-1.5 w-1.5 shrink-0 rounded-full bg-warning" aria-hidden="true" />
|
||||
{r}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className="w-11 h-11 rounded-2xl bg-[#EFF6FF] flex items-center justify-center"><Icon className="w-5 h-5 text-[#0A39DF]" /></div>
|
||||
<div>
|
||||
<p className="text-[11px] font-medium uppercase tracking-wide text-[#94A3B8]">Demonstration</p>
|
||||
<p className="text-[16px] font-semibold text-[#0F172A]">{TYPE_LABEL[type]}</p>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="grid h-10 w-10 shrink-0 place-items-center rounded-xl bg-krow-blue-tint text-krow-blue">
|
||||
<Icon className="h-5 w-5" aria-hidden="true" />
|
||||
</span>
|
||||
<div className="min-w-0">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-wide text-ink-4">Demonstration</p>
|
||||
<p className="text-body font-semibold text-ink-1">{TYPE_LABEL[type]}</p>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-[14px] text-[#374151] leading-relaxed mb-4">{ch.prompt || course?.description}</p>
|
||||
<button onClick={() => setStep(1)} className="w-full h-11 rounded-xl bg-[#0A39DF] hover:bg-[#082EB4] text-white text-[14px] font-semibold flex items-center justify-center gap-2">Show me <ArrowRight className="w-4 h-4" /></button>
|
||||
<p className="mt-3 max-w-prose text-body-sm leading-relaxed text-ink-2">
|
||||
{ch.prompt || course?.description}
|
||||
</p>
|
||||
<Button shape="rounded" className="mt-4 w-full" onClick={() => setStep(1)}>
|
||||
Show me <ArrowRight aria-hidden="true" />
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</Frame>
|
||||
);
|
||||
}
|
||||
|
||||
// Evaluating
|
||||
if (evaluating) {
|
||||
return (
|
||||
<div className="rounded-2xl border border-[#E5E7EB] bg-white p-10 flex flex-col items-center">
|
||||
<Frame className="text-center">
|
||||
<Stepper active={1} />
|
||||
<Loader2 className="w-8 h-8 animate-spin text-[#0A39DF] mb-3" />
|
||||
<p className="text-[14px] font-medium text-[#0F172A]">Owliver is evaluating your proof…</p>
|
||||
<p className="text-[12px] text-[#64748B] mt-1">Measuring whether you can actually perform.</p>
|
||||
</div>
|
||||
<div className="flex flex-col items-center gap-2 py-6">
|
||||
<Spinner size="lg" />
|
||||
<p className="text-body-sm font-medium text-ink-1">Owliver is evaluating your proof…</p>
|
||||
<p className="text-caption text-ink-3">Measuring whether you can actually perform.</p>
|
||||
</div>
|
||||
</Frame>
|
||||
);
|
||||
}
|
||||
|
||||
// 1 — Demonstrate
|
||||
if (step === 1) {
|
||||
return (
|
||||
<div className="rounded-2xl border border-[#E5E7EB] bg-white p-6">
|
||||
<Frame>
|
||||
<Stepper active={1} />
|
||||
<div className="rounded-xl bg-[#F8FAFC] border border-[#F1F5F9] p-3 mb-4 flex items-start gap-2.5">
|
||||
<OwliverAvatar className="w-7 h-7 shrink-0" rounded="rounded-full" />
|
||||
<div>
|
||||
<p className="text-[11px] font-semibold text-[#0A39DF] mb-0.5">Owliver</p>
|
||||
<p className="text-[13px] text-[#374151]">{ch.prompt || course?.description}</p>
|
||||
{/* The roleplay widget opens with this same prompt as its first message,
|
||||
so repeating it above the transcript would say it twice. */}
|
||||
{type !== 'roleplay' && (
|
||||
<div className="mb-4 flex items-start gap-2.5 rounded-xl border border-border bg-surface-subtle p-3">
|
||||
<OwliverAvatar className="h-7 w-7 shrink-0" rounded="rounded-full" />
|
||||
<div className="min-w-0">
|
||||
<p className="text-[11px] font-semibold text-krow-blue">Owliver</p>
|
||||
<p className="text-body-sm leading-relaxed text-ink-2">{ch.prompt || course?.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{type === 'roleplay' && <RoleplayChallenge course={course} onSubmit={handleSubmit} submitting={submit.isPending} />}
|
||||
{type === 'photo' && <MediaChallenge course={course} onSubmit={handleSubmit} submitting={submit.isPending} />}
|
||||
{type === 'video' && <VideoRecorder onSubmit={handleSubmit} submitting={submit.isPending} />}
|
||||
{type === 'photo_identify' && <HazardIdentifier course={course} onSubmit={handleSubmit} submitting={submit.isPending} />}
|
||||
</div>
|
||||
</Frame>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -141,69 +191,120 @@ export default function ChallengeRunner({ course, profile, onPassed }) {
|
||||
if (step === 2) {
|
||||
const rubric = result?.rubric || {};
|
||||
return (
|
||||
<div className="rounded-2xl border border-[#E5E7EB] bg-white p-6">
|
||||
<Frame>
|
||||
<Stepper active={2} />
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
{passed ? <CheckCircle2 className="w-9 h-9 text-[#16A34A]" /> : <XCircle className="w-9 h-9 text-[#EF4444]" />}
|
||||
<div>
|
||||
<p className="text-[17px] font-semibold text-[#0F172A]">{passed ? 'Verified by Owliver' : 'Needs work'}</p>
|
||||
<p className="text-[12px] text-[#64748B]">AI confidence: {Math.round(evidence?.ai_score || 0)}/100</p>
|
||||
<div className="flex items-center gap-3">
|
||||
{passed
|
||||
? <CheckCircle2 className="h-8 w-8 shrink-0 text-success" aria-hidden="true" />
|
||||
: <XCircle className="h-8 w-8 shrink-0 text-destructive" aria-hidden="true" />}
|
||||
<div className="min-w-0">
|
||||
<p className="font-heading text-title text-ink-1">{passed ? 'Verified by Owliver' : 'Needs work'}</p>
|
||||
<p className="text-caption tabular-nums text-ink-3">
|
||||
AI confidence: {Math.round(evidence?.ai_score || 0)}/100
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{result?.feedback && <p className="text-[13px] text-[#374151] leading-relaxed mb-4">{result.feedback}</p>}
|
||||
|
||||
{result?.feedback && (
|
||||
<p className="mt-3 max-w-prose text-body-sm leading-relaxed text-ink-2">{result.feedback}</p>
|
||||
)}
|
||||
|
||||
{Object.keys(rubric).length > 0 && (
|
||||
<div className="space-y-2 mb-4">
|
||||
<div className="mt-4 space-y-2">
|
||||
{Object.entries(rubric).map(([k, v]) => (
|
||||
<div key={k}>
|
||||
<div className="flex justify-between text-[12px] mb-1"><span className="text-[#475569] capitalize">{k.replace(/_/g, ' ')}</span><span className="font-semibold text-[#0F172A]">{Math.round(v)}/100</span></div>
|
||||
<div className="h-1.5 rounded-full bg-[#F1F5F9] overflow-hidden"><div className="h-full bg-[#0A39DF]" style={{ width: `${Math.min(100, Math.round(v))}%` }} /></div>
|
||||
<div className="mb-1 flex justify-between text-caption">
|
||||
<span className="capitalize text-ink-3">{k.replace(/_/g, ' ')}</span>
|
||||
<span className="font-semibold tabular-nums text-ink-1">{Math.round(v)}/100</span>
|
||||
</div>
|
||||
<div className="h-1.5 overflow-hidden rounded-full bg-surface-sunken">
|
||||
<div className="h-full rounded-full bg-krow-blue" style={{ width: `${Math.min(100, Math.round(v))}%` }} />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className={`rounded-xl border p-4 flex items-center gap-3 mb-3 ${supervisorVerified ? 'bg-[#F0FDF4] border-[#BBF7D0]' : 'bg-[#FFFBEB] border-[#FDE68A]'}`}>
|
||||
<ShieldCheck className={`w-5 h-5 ${supervisorVerified ? 'text-[#16A34A]' : 'text-[#D97706]'}`} />
|
||||
<div className="flex-1">
|
||||
<p className="text-[13px] font-medium text-[#0F172A]">{supervisorVerified ? 'Supervisor verified' : 'Supervisor verification'}</p>
|
||||
<p className="text-[11px] text-[#64748B]">{supervisorVerified ? 'A supervisor confirmed this demonstration in person.' : 'A supervisor can confirm this demonstration on the floor.'}</p>
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
'mt-4 flex items-center gap-3 rounded-xl border px-3.5 py-3',
|
||||
supervisorVerified ? 'border-success/30 bg-success-muted' : 'border-warning/30 bg-warning-muted'
|
||||
)}
|
||||
>
|
||||
<ShieldCheck
|
||||
className={cn('h-4 w-4 shrink-0', supervisorVerified ? 'text-success' : 'text-warning')}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-body-sm font-medium text-ink-1">
|
||||
{supervisorVerified ? 'Supervisor verified' : 'Supervisor verification'}
|
||||
</p>
|
||||
<p className="text-caption text-ink-3">
|
||||
{supervisorVerified
|
||||
? 'A supervisor confirmed this demonstration in person.'
|
||||
: 'A supervisor can confirm this demonstration on the floor.'}
|
||||
</p>
|
||||
</div>
|
||||
{!supervisorVerified ? (
|
||||
<button onClick={doVerify} disabled={verify.isPending} className="h-8 px-3 rounded-lg bg-[#0F172A] text-white text-[12px] font-semibold disabled:opacity-50">{verify.isPending ? '…' : 'Verify'}</button>
|
||||
<Button variant="navy" size="sm" shape="rounded" onClick={doVerify} disabled={verify.isPending}>
|
||||
{verify.isPending ? '…' : 'Verify'}
|
||||
</Button>
|
||||
) : (
|
||||
<CheckCircle2 className="w-5 h-5 text-[#16A34A]" />
|
||||
<CheckCircle2 className="h-4 w-4 text-success" aria-hidden="true" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{passed ? (
|
||||
<button onClick={() => setStep(3)} className="w-full h-11 rounded-xl bg-[#0A39DF] text-white text-[14px] font-semibold flex items-center justify-center gap-2">See your proof <ArrowRight className="w-4 h-4" /></button>
|
||||
<Button shape="rounded" className="mt-3 w-full" onClick={() => setStep(3)}>
|
||||
See your proof <ArrowRight aria-hidden="true" />
|
||||
</Button>
|
||||
) : (
|
||||
<button onClick={() => setStep(1)} className="w-full h-10 rounded-xl border border-[#E5E7EB] text-[13px] font-semibold text-[#0F172A] hover:bg-[#F8FAFC]">Try the demonstration again</button>
|
||||
<Button variant="outline" shape="rounded" className="mt-3 w-full" onClick={() => setStep(1)}>
|
||||
Try the demonstration again
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</Frame>
|
||||
);
|
||||
}
|
||||
|
||||
// 3 — Proof
|
||||
return (
|
||||
<div className="rounded-2xl border border-[#E5E7EB] bg-white p-6">
|
||||
<Frame>
|
||||
<Stepper active={3} />
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className="w-11 h-11 rounded-2xl bg-[#F9E547] flex items-center justify-center"><Award className="w-5 h-5 text-[#92400E]" /></div>
|
||||
<div>
|
||||
<p className="text-[16px] font-semibold text-[#0F172A]">Proof stored</p>
|
||||
<p className="text-[12px] text-[#64748B]">Capability: {course.proof_skill || course.title}</p>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="grid h-10 w-10 shrink-0 place-items-center rounded-xl bg-krow-yellow text-krow-blue">
|
||||
<Award className="h-5 w-5" aria-hidden="true" />
|
||||
</span>
|
||||
<div className="min-w-0">
|
||||
<p className="text-body font-semibold text-ink-1">Proof stored</p>
|
||||
<p className="text-caption text-ink-3">Capability: {course.proof_skill || course.title}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-xl bg-[#F8FAFC] border border-[#F1F5F9] p-4 space-y-2 mb-4">
|
||||
<div className="flex justify-between text-[12px]"><span className="text-[#64748B]">AI verdict</span><span className="font-semibold text-[#16A34A] capitalize">{evidence?.ai_verdict}</span></div>
|
||||
<div className="flex justify-between text-[12px]"><span className="text-[#64748B]">Score</span><span className="font-semibold text-[#0F172A]">{Math.round(evidence?.ai_score || 0)}/100</span></div>
|
||||
<div className="flex justify-between text-[12px]"><span className="text-[#64748B]">Supervisor</span><span className="font-semibold text-[#0F172A]">{supervisorVerified ? 'Verified' : 'Pending'}</span></div>
|
||||
<div className="flex justify-between text-[12px]"><span className="text-[#64748B]">Proof type</span><span className="font-semibold text-[#0F172A]">{TYPE_LABEL[type]}</span></div>
|
||||
</div>
|
||||
<div className="rounded-xl bg-[#EFF6FF] border border-[#BFDBFE] p-4 flex items-start gap-2.5 mb-4">
|
||||
<Eye className="w-4 h-4 text-[#0A39DF] mt-0.5 shrink-0" />
|
||||
<p className="text-[12px] text-[#1E40AF]">Employers can now see this proof in your Career Wallet. Every skill is backed by evidence.</p>
|
||||
</div>
|
||||
<button onClick={() => setStep(0)} className="w-full h-10 rounded-xl border border-[#E5E7EB] text-[13px] font-semibold text-[#0F172A] hover:bg-[#F8FAFC]">Back to start</button>
|
||||
</div>
|
||||
|
||||
<dl className="mt-4 divide-y divide-border overflow-hidden rounded-xl border border-border bg-surface-subtle">
|
||||
{[
|
||||
{ label: 'AI verdict', value: evidence?.ai_verdict, tone: 'text-success', capitalize: true },
|
||||
{ label: 'Score', value: `${Math.round(evidence?.ai_score || 0)}/100` },
|
||||
{ label: 'Supervisor', value: supervisorVerified ? 'Verified' : 'Pending' },
|
||||
{ label: 'Proof type', value: TYPE_LABEL[type] },
|
||||
].map((row) => (
|
||||
<div key={row.label} className="flex items-baseline justify-between gap-3 px-3.5 py-2">
|
||||
<dt className="text-caption text-ink-3">{row.label}</dt>
|
||||
<dd className={cn('text-caption font-semibold', row.capitalize && 'capitalize', row.tone || 'text-ink-1')}>
|
||||
{row.value}
|
||||
</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
|
||||
<p className="mt-3 flex items-start gap-2 rounded-xl border border-border bg-krow-blue-tint px-3.5 py-3 text-caption leading-relaxed text-krow-blue">
|
||||
<Eye className="mt-0.5 h-3.5 w-3.5 shrink-0" aria-hidden="true" />
|
||||
Employers can now see this proof in your Career Wallet. Every skill is backed by evidence.
|
||||
</p>
|
||||
|
||||
<Button variant="outline" shape="rounded" className="mt-3 w-full" onClick={() => setStep(0)}>
|
||||
Back to start
|
||||
</Button>
|
||||
</Frame>
|
||||
);
|
||||
}
|
||||
@@ -38,43 +38,43 @@ export default function HazardIdentifier({ course, onSubmit, submitting }) {
|
||||
|
||||
return (
|
||||
<div>
|
||||
<p className="text-[13px] text-[#64748B] mb-3">Tap anywhere on the kitchen photo to mark a hazard. Label each one, then submit for AI evaluation.</p>
|
||||
<div className="relative rounded-2xl overflow-hidden border border-[#E5E7EB] mb-3 select-none cursor-crosshair" onClick={addMarker}>
|
||||
<p className="text-[13px] text-ink-3 mb-3">Tap anywhere on the kitchen photo to mark a hazard. Label each one, then submit for AI evaluation.</p>
|
||||
<div className="relative rounded-xl overflow-hidden border border-border mb-3 select-none cursor-crosshair" onClick={addMarker}>
|
||||
{imageUrl ? (
|
||||
<Image src={imageUrl} alt="Kitchen scene" className="w-full max-h-80 object-cover" fittingType="fill" />
|
||||
) : (
|
||||
<div className="w-full h-64 bg-[#F1F5F9] flex items-center justify-center text-[13px] text-[#94A3B8]">No scene image configured</div>
|
||||
<div className="w-full h-64 bg-surface-sunken flex items-center justify-center text-[13px] text-ink-4">No scene image configured</div>
|
||||
)}
|
||||
{markers.map((m, i) => (
|
||||
<div key={i} className="absolute -translate-x-1/2 -translate-y-1/2 group" style={{ left: `${m.x * 100}%`, top: `${m.y * 100}%` }} onClick={(e) => e.stopPropagation()}>
|
||||
<span className="flex items-center justify-center w-6 h-6 rounded-full bg-red-500 text-white text-[11px] font-bold shadow ring-2 ring-white">{i + 1}</span>
|
||||
<span className="absolute left-1/2 -translate-x-1/2 top-7 whitespace-nowrap text-[10px] bg-white/95 rounded px-1.5 py-0.5 shadow text-[#0F172A]">{m.label}</span>
|
||||
<button onClick={() => remove(i)} className="absolute -top-1.5 -right-1.5 w-4 h-4 rounded-full bg-white text-red-500 shadow flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity"><Trash2 className="w-2.5 h-2.5" /></button>
|
||||
<span className="flex items-center justify-center w-6 h-6 rounded-full bg-destructive text-white text-[11px] font-bold shadow ring-2 ring-white">{i + 1}</span>
|
||||
<span className="absolute left-1/2 -translate-x-1/2 top-7 whitespace-nowrap text-[10px] bg-white/95 rounded px-1.5 py-0.5 shadow text-ink-1">{m.label}</span>
|
||||
<button onClick={() => remove(i)} className="absolute -top-1.5 -right-1.5 w-4 h-4 rounded-full bg-white text-destructive shadow flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity"><Trash2 className="w-2.5 h-2.5" /></button>
|
||||
</div>
|
||||
))}
|
||||
{pending && (
|
||||
<div className="absolute -translate-x-1/2 -translate-y-1/2 pointer-events-none" style={{ left: `${pending.x * 100}%`, top: `${pending.y * 100}%` }}>
|
||||
<span className="block w-6 h-6 rounded-full bg-[#0A39DF] ring-4 ring-white animate-pulse" />
|
||||
<span className="block w-6 h-6 rounded-full bg-krow-blue ring-4 ring-white animate-pulse" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{pending && (
|
||||
<div className="rounded-xl border border-[#0A39DF] bg-[#EFF6FF] p-3 mb-3">
|
||||
<p className="text-[12px] font-medium text-[#1E40AF] mb-2">Label this hazard:</p>
|
||||
<div className="rounded-xl border border-krow-blue bg-krow-blue-tint p-3 mb-3">
|
||||
<p className="text-[12px] font-medium text-krow-blue mb-2">Label this hazard:</p>
|
||||
<div className="flex flex-wrap gap-1.5 mb-2">
|
||||
{PRESET_TAGS.map((t) => (
|
||||
<button key={t} onClick={() => confirm(t)} className="text-[11px] px-2.5 py-1 rounded-full bg-white border border-[#E5E7EB] text-[#374151] hover:border-[#0A39DF]">{t}</button>
|
||||
<button key={t} onClick={() => confirm(t)} className="text-[11px] px-2.5 py-1 rounded-full bg-white border border-border text-ink-2 hover:border-krow-blue">{t}</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<input value={customLabel} onChange={(e) => setCustomLabel(e.target.value)} placeholder="Or type your own…" className="flex-1 h-8 px-2.5 text-[12px] rounded-lg border border-[#E5E7EB] focus:border-[#0A39DF] focus:outline-none" />
|
||||
<button onClick={() => customLabel.trim() && confirm(customLabel.trim())} className="h-8 px-3 rounded-lg bg-[#0A39DF] text-white text-[12px] font-medium flex items-center gap-1"><Plus className="w-3.5 h-3.5" /> Add</button>
|
||||
<input value={customLabel} onChange={(e) => setCustomLabel(e.target.value)} placeholder="Or type your own…" className="flex-1 h-8 px-2.5 text-[12px] rounded-lg border border-border focus:border-krow-blue focus:outline-none" />
|
||||
<button onClick={() => customLabel.trim() && confirm(customLabel.trim())} className="h-8 px-3 rounded-lg bg-krow-blue text-white text-[12px] font-medium flex items-center gap-1"><Plus className="w-3.5 h-3.5" /> Add</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button onClick={() => onSubmit(markers)} disabled={markers.length === 0 || submitting || !!pending} className="w-full h-10 rounded-xl bg-[#0F172A] text-white text-[13px] font-semibold disabled:opacity-40">
|
||||
<button onClick={() => onSubmit(markers)} disabled={markers.length === 0 || submitting || !!pending} className="w-full h-10 rounded-xl bg-krow-navy text-white text-[13px] font-semibold disabled:opacity-40">
|
||||
Submit {markers.length} hazard{markers.length !== 1 ? 's' : ''} for AI evaluation
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -28,11 +28,11 @@ export default function MediaChallenge({ course, onSubmit, submitting }) {
|
||||
|
||||
return (
|
||||
<div>
|
||||
<p className="text-[13px] text-[#64748B] mb-3">
|
||||
<p className="text-[13px] text-ink-3 mb-3">
|
||||
{type === 'video' ? 'Record or upload a short video demonstrating the challenge.' : 'Take or upload a photo showing your work.'}
|
||||
</p>
|
||||
{mediaUrl ? (
|
||||
<div className="mb-3 rounded-xl overflow-hidden border border-[#E5E7EB]">
|
||||
<div className="mb-3 rounded-xl overflow-hidden border border-border">
|
||||
{type === 'video' ? (
|
||||
<video src={mediaUrl} controls className="w-full max-h-64 object-cover" />
|
||||
) : (
|
||||
@@ -40,13 +40,13 @@ export default function MediaChallenge({ course, onSubmit, submitting }) {
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<label className="flex flex-col items-center justify-center h-40 rounded-xl border-2 border-dashed border-[#CBD5E1] cursor-pointer hover:border-[#0A39DF] transition-colors">
|
||||
<label className="flex flex-col items-center justify-center h-40 rounded-xl border-2 border-dashed border-border cursor-pointer hover:border-krow-blue transition-colors">
|
||||
{uploading ? (
|
||||
<Loader2 className="w-6 h-6 animate-spin text-[#0A39DF]" />
|
||||
<Loader2 className="w-6 h-6 animate-spin text-krow-blue" />
|
||||
) : (
|
||||
<>
|
||||
<Camera className="w-6 h-6 text-[#94A3B8] mb-2" />
|
||||
<span className="text-[13px] text-[#64748B]">Tap to {type === 'video' ? 'record' : 'capture'}</span>
|
||||
<Camera className="w-6 h-6 text-ink-4 mb-2" />
|
||||
<span className="text-[13px] text-ink-3">Tap to {type === 'video' ? 'record' : 'capture'}</span>
|
||||
</>
|
||||
)}
|
||||
<input type="file" accept={accept} capture={capture} onChange={handleFile} className="hidden" disabled={uploading || submitting} />
|
||||
@@ -54,12 +54,12 @@ export default function MediaChallenge({ course, onSubmit, submitting }) {
|
||||
)}
|
||||
<div className="flex gap-2">
|
||||
{mediaUrl && (
|
||||
<button onClick={() => setMediaUrl('')} className="h-10 px-4 rounded-xl border border-[#E5E7EB] text-[13px] font-medium text-[#64748B]">Retake</button>
|
||||
<button onClick={() => setMediaUrl('')} className="h-10 px-4 rounded-xl border border-border text-[13px] font-medium text-ink-3">Retake</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => onSubmit(mediaUrl)}
|
||||
disabled={!mediaUrl || uploading || submitting}
|
||||
className="flex-1 h-10 rounded-xl bg-[#0F172A] text-white text-[13px] font-semibold disabled:opacity-40"
|
||||
className="flex-1 h-10 rounded-xl bg-krow-navy text-white text-[13px] font-semibold disabled:opacity-40"
|
||||
>
|
||||
Submit proof for AI evaluation
|
||||
</button>
|
||||
|
||||
@@ -39,8 +39,8 @@ export default function RoleplayChallenge({ course, onSubmit, submitting }) {
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<OwliverAvatar className="w-8 h-8" rounded="rounded-full" />
|
||||
<div>
|
||||
<p className="text-[13px] font-semibold text-[#0F172A]">Owliver</p>
|
||||
<p className="text-[10px] text-[#94A3B8]">Your AI skills coach</p>
|
||||
<p className="text-[13px] font-semibold text-ink-1">Owliver</p>
|
||||
<p className="text-[10px] text-ink-4">Your AI skills coach</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-3 mb-4 max-h-72 overflow-y-auto pr-1">
|
||||
@@ -49,8 +49,8 @@ export default function RoleplayChallenge({ course, onSubmit, submitting }) {
|
||||
return (
|
||||
<div key={i} className={`flex items-end gap-2 ${isAI ? 'justify-start' : 'justify-end'}`}>
|
||||
{isAI && <OwliverAvatar className="w-7 h-7 shrink-0" rounded="rounded-full" />}
|
||||
<div className={`max-w-[80%] px-3.5 py-2.5 rounded-2xl text-[13px] leading-relaxed ${
|
||||
m.role === 'user' ? 'bg-[#0A39DF] text-white rounded-br-sm' : 'bg-[#F1F5F9] text-[#0F172A] rounded-bl-sm'
|
||||
<div className={`max-w-[80%] px-3.5 py-2.5 rounded-xl text-[13px] leading-relaxed ${
|
||||
m.role === 'user' ? 'bg-krow-blue text-white rounded-br-sm' : 'bg-surface-sunken text-ink-1 rounded-bl-sm'
|
||||
}`}>{m.content}</div>
|
||||
</div>
|
||||
);
|
||||
@@ -64,16 +64,16 @@ export default function RoleplayChallenge({ course, onSubmit, submitting }) {
|
||||
onKeyDown={(e) => e.key === 'Enter' && send()}
|
||||
disabled={submitting || busy}
|
||||
placeholder="Type your response…"
|
||||
className="flex-1 h-9 bg-transparent px-1 text-[13px] focus:outline-none placeholder:text-[#9CA3AF]"
|
||||
className="flex-1 h-9 bg-transparent px-1 text-[13px] focus:outline-none placeholder:text-ink-4"
|
||||
/>
|
||||
<button onClick={send} disabled={busy || submitting} className="w-9 h-9 rounded-full bg-[#0A39DF] text-white flex items-center justify-center disabled:opacity-50 shrink-0">
|
||||
<button onClick={send} disabled={busy || submitting} className="w-9 h-9 rounded-full bg-krow-blue text-white flex items-center justify-center disabled:opacity-50 shrink-0">
|
||||
{busy ? <Loader2 className="w-4 h-4 animate-spin" /> : <Send className="w-4 h-4" />}
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => onSubmit(transcript)}
|
||||
disabled={!hasResponded || submitting || busy}
|
||||
className="mt-3 w-full h-10 rounded-xl bg-[#0F172A] text-white text-[13px] font-semibold disabled:opacity-40"
|
||||
className="mt-3 w-full h-10 rounded-xl bg-krow-navy text-white text-[13px] font-semibold disabled:opacity-40"
|
||||
>
|
||||
Submit proof for AI evaluation
|
||||
</button>
|
||||
|
||||
@@ -72,8 +72,8 @@ export default function VideoRecorder({ onSubmit, submitting }) {
|
||||
|
||||
return (
|
||||
<div>
|
||||
{error && <p className="text-[12px] text-[#EF4444] mb-3">{error}</p>}
|
||||
<div className="relative rounded-2xl overflow-hidden bg-black aspect-video mb-3">
|
||||
{error && <p className="text-[12px] text-destructive mb-3">{error}</p>}
|
||||
<div className="relative rounded-xl overflow-hidden bg-black aspect-video mb-3">
|
||||
{recordedUrl ? (
|
||||
<video src={recordedUrl} controls className="w-full h-full object-cover" />
|
||||
) : (
|
||||
@@ -81,19 +81,19 @@ export default function VideoRecorder({ onSubmit, submitting }) {
|
||||
)}
|
||||
{recording && (
|
||||
<div className="absolute top-3 left-3 flex items-center gap-1.5 bg-black/60 rounded-full px-2.5 py-1">
|
||||
<span className="w-2 h-2 rounded-full bg-red-500 animate-pulse" />
|
||||
<span className="w-2 h-2 rounded-full bg-destructive animate-pulse" />
|
||||
<span className="text-[12px] text-white font-mono">{seconds}s</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{!recordedUrl ? (
|
||||
<button onClick={recording ? stop : start} disabled={uploading || submitting} className={`w-full h-11 rounded-xl font-semibold text-[14px] flex items-center justify-center gap-2 text-white ${recording ? 'bg-red-500' : 'bg-[#0A39DF]'}`}>
|
||||
<button onClick={recording ? stop : start} disabled={uploading || submitting} className={`w-full h-11 rounded-xl font-semibold text-[14px] flex items-center justify-center gap-2 text-white ${recording ? 'bg-destructive' : 'bg-krow-blue'}`}>
|
||||
{recording ? <><Square className="w-4 h-4" /> Stop recording</> : <><Video className="w-4 h-4" /> Record yourself</>}
|
||||
</button>
|
||||
) : (
|
||||
<div className="flex gap-2">
|
||||
<button onClick={reset} className="h-11 px-4 rounded-xl border border-[#E5E7EB] text-[13px] font-medium text-[#64748B] flex items-center gap-1.5"><RotateCcw className="w-4 h-4" /> Retake</button>
|
||||
<button onClick={submit} disabled={uploading || submitting} className="flex-1 h-11 rounded-xl bg-[#0F172A] text-white text-[13px] font-semibold flex items-center justify-center gap-2 disabled:opacity-50">
|
||||
<button onClick={reset} className="h-11 px-4 rounded-xl border border-border text-[13px] font-medium text-ink-3 flex items-center gap-1.5"><RotateCcw className="w-4 h-4" /> Retake</button>
|
||||
<button onClick={submit} disabled={uploading || submitting} className="flex-1 h-11 rounded-xl bg-krow-navy text-white text-[13px] font-semibold flex items-center justify-center gap-2 disabled:opacity-50">
|
||||
{uploading ? <Loader2 className="w-4 h-4 animate-spin" /> : null} Submit proof for AI evaluation
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import { Brain } from 'lucide-react';
|
||||
import { Button, SearchInput, toast } from '@/components/ds';
|
||||
import { Button, SearchInput } from '@/components/ds';
|
||||
import { useApplications, useInterviews, useJobPostings } from '@/lib/krowHooks';
|
||||
import { AdminPage, Toolbar } from '@/pages/admin/_shell';
|
||||
import { FilterSelect } from '@/pages/admin/Positions';
|
||||
@@ -43,8 +42,8 @@ export default function AdminCandidates() {
|
||||
.filter((a) => {
|
||||
if (q && !`${a.applicant_name} ${a.email} ${a.job_title} ${(a.skills || []).join(' ')}`.toLowerCase().includes(q)) return false;
|
||||
if (position !== 'all' && a.job_title !== position) return false;
|
||||
if (stage !== 'all' && a.status !== stage) return false;
|
||||
if (!SCORE_BANDS[band](a.ai_score || 0)) return false;
|
||||
if (stage !== 'all' && a.stage !== stage) return false;
|
||||
if (band !== 'all' && !SCORE_BANDS[band](a.ai_score)) return false;
|
||||
return true;
|
||||
})
|
||||
.sort(SORTS[sort].compare);
|
||||
@@ -60,11 +59,6 @@ export default function AdminCandidates() {
|
||||
<AdminPage
|
||||
title="Candidates"
|
||||
subtitle="Review, compare and manage the candidate pipeline."
|
||||
actions={
|
||||
<Button variant="outline" size="sm" shape="rounded" onClick={() => toast.info('Batch screening is disabled in the demo')}>
|
||||
<Brain aria-hidden="true" /> Screen unscored
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<Toolbar
|
||||
search={<SearchInput value={search} onChange={setSearch} placeholder="Search candidates..." size="sm" />}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import { Bookmark, GitCompare, Star, UserRound } from 'lucide-react';
|
||||
import { Bookmark, Star, UserRound } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import {
|
||||
Avatar, Badge, Button, DataTable, IconButton, Modal, ProgressBar, ProgressRing,
|
||||
SearchInput, Surface, toast,
|
||||
Avatar, Badge, DataTable, IconButton, ProgressRing, SearchInput, toast,
|
||||
} from '@/components/ds';
|
||||
import { useWorkerProfiles } from '@/lib/krowHooks';
|
||||
import { getScoreBand, toFICO } from '@/lib/talentHome';
|
||||
@@ -44,7 +44,6 @@ export default function AdminTalentPool() {
|
||||
const [availability, setAvailability] = useState('all');
|
||||
const [sort, setSort] = useState('score');
|
||||
const [saved, setSaved] = useState([]);
|
||||
const [compareOpen, setCompareOpen] = useState(false);
|
||||
|
||||
const skills = useMemo(
|
||||
() => [...new Set(profiles.flatMap((p) => p.skills || []))].sort(),
|
||||
@@ -109,15 +108,6 @@ export default function AdminTalentPool() {
|
||||
}));
|
||||
}, [profiles]);
|
||||
|
||||
const maxSegment = Math.max(...segments.map((s) => s.count), 1);
|
||||
|
||||
/* Whoever is saved, side by side. Saving is the selection mechanism, so the
|
||||
comparison needs no second one. */
|
||||
const savedProfiles = useMemo(
|
||||
() => profiles.filter((p) => saved.includes(p.id)).sort((a, b) => (b.krow_score || 0) - (a.krow_score || 0)),
|
||||
[profiles, saved]
|
||||
);
|
||||
|
||||
const toggleSave = (id, name) => {
|
||||
setSaved((prev) => {
|
||||
const next = prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id];
|
||||
@@ -130,17 +120,6 @@ export default function AdminTalentPool() {
|
||||
<AdminPage
|
||||
title="Talent Pool"
|
||||
subtitle="Discover and manage high-potential talent for future hiring."
|
||||
actions={
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
shape="rounded"
|
||||
disabled={saved.length < 2}
|
||||
onClick={() => setCompareOpen(true)}
|
||||
>
|
||||
<GitCompare aria-hidden="true" /> Compare{saved.length ? ` (${saved.length})` : ''}
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
{/* Segments — the pool's shape, and how much of each band is available. */}
|
||||
<section aria-labelledby="segments" className="space-y-3">
|
||||
@@ -149,41 +128,62 @@ export default function AdminTalentPool() {
|
||||
title="Talent segments"
|
||||
meta={`${profiles.length} in the pool`}
|
||||
/>
|
||||
<Surface variant="solid" radius="lg" padding="lg" elevation="xs">
|
||||
<div className="space-y-3">
|
||||
{segments.map((s) => (
|
||||
<div key={s.label}>
|
||||
<div className="mb-1 flex items-baseline justify-between gap-2">
|
||||
<span className="text-body-sm text-ink-2">
|
||||
{s.label} <span className="text-caption text-ink-4">{s.hint}</span>
|
||||
</span>
|
||||
<span className="flex items-baseline gap-3 text-[11px] text-ink-4">
|
||||
{s.count > 0 && (
|
||||
<>
|
||||
<span>{s.available} available</span>
|
||||
{s.avgExperience > 0 && <span>{s.avgExperience}y avg</span>}
|
||||
</>
|
||||
{/* Five compact cards, one per band, across the page's content width — the
|
||||
same measure the directory below uses, so the two sections share a left
|
||||
and right edge. A band is read one at a time, so each gets its own small
|
||||
surface with the count directly above the detail that qualifies it. */}
|
||||
<div>
|
||||
<ul className="grid grid-cols-1 gap-2.5 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-5">
|
||||
{segments.map((s) => {
|
||||
const actionable = s.tone === 'risk';
|
||||
const empty = s.count === 0;
|
||||
return (
|
||||
<li
|
||||
key={s.label}
|
||||
className="rounded-xl border border-border bg-surface px-3.5 py-3 transition-colors duration-base hover:border-ink-4/40"
|
||||
>
|
||||
<div className="flex items-baseline gap-1.5">
|
||||
<span className="truncate text-body-sm font-semibold text-ink-1">{s.label}</span>
|
||||
<span className="truncate text-caption text-ink-4">{s.hint}</span>
|
||||
{/* The one accent in the card: a 5px dot, warning where the
|
||||
band is the actionable one. */}
|
||||
{!empty && (
|
||||
<span
|
||||
className={cn(
|
||||
'ml-auto h-1.5 w-1.5 shrink-0 translate-y-[-1px] rounded-full',
|
||||
actionable ? 'bg-warning' : s.tone === 'brand' ? 'bg-krow-blue' : 'bg-ink-4/40'
|
||||
)}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)}
|
||||
<span className="w-6 text-right text-body-sm font-bold tabular-nums text-ink-1">
|
||||
{s.count}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
<ProgressBar
|
||||
value={s.count}
|
||||
max={maxSegment}
|
||||
size="sm"
|
||||
tone={s.tone === 'risk' ? 'warning' : s.tone === 'warning' ? 'warning' : 'brand'}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<p className="mt-4 border-t border-border pt-3 text-caption leading-relaxed text-ink-3">
|
||||
</div>
|
||||
|
||||
<p
|
||||
className={cn(
|
||||
'mt-2 font-heading text-title-lg font-bold leading-none tabular-nums',
|
||||
empty ? 'text-ink-4' : actionable ? 'text-warning' : 'text-ink-1'
|
||||
)}
|
||||
>
|
||||
{s.count}
|
||||
</p>
|
||||
|
||||
<p className="mt-1.5 truncate text-[11px] text-ink-4">
|
||||
{empty ? 'No profiles' : [
|
||||
`${s.available} available`,
|
||||
actionable ? 'needs verification' : s.avgExperience > 0 ? `${s.avgExperience}y avg` : null,
|
||||
].filter(Boolean).join(' · ')}
|
||||
</p>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
|
||||
<p className="mt-3 border-t border-border pt-3 text-caption leading-relaxed text-ink-3">
|
||||
{segments[4].count > segments[0].count + segments[1].count
|
||||
? `${segments[4].count} workers carry no career score against ${segments[0].count + segments[1].count} at Excellent or above. Supply is not the constraint here — verification is.`
|
||||
: 'The scored part of the pool outweighs the unscored, so matching has enough to work with.'}
|
||||
</p>
|
||||
</Surface>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<SectionTitle title="Talent directory" meta={`${profiles.length} profiles`} />
|
||||
@@ -302,83 +302,6 @@ export default function AdminTalentPool() {
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
{/* Saved talent, side by side. A table, because the value is reading the
|
||||
same five fields across people — anything more elaborate would be a
|
||||
second product. */}
|
||||
<Modal
|
||||
open={compareOpen}
|
||||
onOpenChange={setCompareOpen}
|
||||
title="Compare saved talent"
|
||||
description={`${savedProfiles.length} saved, ranked by career score.`}
|
||||
icon={GitCompare}
|
||||
size="lg"
|
||||
>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full border-collapse text-body-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-border">
|
||||
{['Talent', 'Career score', 'Experience', 'Skills', 'Availability'].map((h) => (
|
||||
<th
|
||||
key={h}
|
||||
scope="col"
|
||||
className="whitespace-nowrap px-3 py-2 text-left text-[10px] font-semibold uppercase tracking-wide text-ink-4"
|
||||
>
|
||||
{h}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{savedProfiles.map((p) => (
|
||||
<tr key={p.id} className="border-b border-border last:border-0">
|
||||
<td className="px-3 py-2.5">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<Avatar name={p.full_name} src={p.selfie_url} size="xs" />
|
||||
<div className="min-w-0">
|
||||
<p className="truncate font-medium text-ink-1">{p.full_name}</p>
|
||||
<p className="truncate text-[10px] text-ink-4">
|
||||
{p.desired_position || p.current_position || '—'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="whitespace-nowrap px-3 py-2.5">
|
||||
{p.krow_score ? (
|
||||
<span className="font-heading font-bold tabular-nums text-ink-1">
|
||||
{toFICO(p.krow_score)}
|
||||
<span className="ml-1.5 text-[10px] font-normal text-ink-4">
|
||||
{getScoreBand(p.krow_score).label}
|
||||
</span>
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-[11px] text-ink-4">Not scored</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="whitespace-nowrap px-3 py-2.5 text-ink-2">
|
||||
{p.experience_years ? `${p.experience_years}y` : '—'}
|
||||
</td>
|
||||
<td className="px-3 py-2.5">
|
||||
{p.skills?.length ? (
|
||||
<div className="flex items-center gap-1">
|
||||
<Badge variant="neutral" size="sm">{p.skills[0]}</Badge>
|
||||
{p.skills.length > 1 && (
|
||||
<span className="text-[10px] text-ink-4">+{p.skills.length - 1}</span>
|
||||
)}
|
||||
</div>
|
||||
) : <span className="text-[11px] text-ink-4">—</span>}
|
||||
</td>
|
||||
<td className="px-3 py-2.5 text-ink-2">
|
||||
{p.availability?.length ? p.availability.join(', ') : (
|
||||
<Badge variant="warning" size="sm">Not set</Badge>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Modal>
|
||||
</AdminPage>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user