From 8a256a4de70eaad094ea0ef3281b8139b52baa36 Mon Sep 17 00:00:00 2001 From: Aravind Date: Tue, 11 Aug 2026 17:09:53 +0530 Subject: [PATCH] fix talent pool ui --- src/App.jsx | 14 +- .../ai-assistant/capabilities/admin.js | 151 ++++++++++- src/components/ai-assistant/contexts.js | 16 +- src/components/ai-assistant/dynamic.js | 32 +++ src/components/ai-assistant/placement.js | 8 +- .../krow/proving/ChallengeRunner.jsx | 243 +++++++++++++----- .../krow/proving/HazardIdentifier.jsx | 26 +- .../krow/proving/MediaChallenge.jsx | 16 +- .../krow/proving/RoleplayChallenge.jsx | 14 +- src/components/krow/proving/VideoRecorder.jsx | 12 +- src/pages/admin/Candidates.jsx | 12 +- src/pages/admin/TalentPool.jsx | 185 ++++--------- 12 files changed, 473 insertions(+), 256 deletions(-) diff --git a/src/App.jsx b/src/App.jsx index e65df8d..aaad173 100644 --- a/src/App.jsx +++ b/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 ; +}; + const AuthenticatedApp = () => { const { isLoadingAuth, isLoadingPublicSettings } = useAuth(); @@ -94,7 +100,11 @@ const AuthenticatedApp = () => { } /> } /> } /> - } /> + {/* 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. */} + } /> } /> } /> } /> diff --git a/src/components/ai-assistant/capabilities/admin.js b/src/components/ai-assistant/capabilities/admin.js index b9eea25..5450639 100644 --- a/src/components/ai-assistant/capabilities/admin.js +++ b/src/components/ai-assistant/capabilities/admin.js @@ -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 }, diff --git a/src/components/ai-assistant/contexts.js b/src/components/ai-assistant/contexts.js index 41d54a9..2e01544 100644 --- a/src/components/ai-assistant/contexts.js +++ b/src/components/ai-assistant/contexts.js @@ -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', diff --git a/src/components/ai-assistant/dynamic.js b/src/components/ai-assistant/dynamic.js index feb6225..0c140f3 100644 --- a/src/components/ai-assistant/dynamic.js +++ b/src/components/ai-assistant/dynamic.js @@ -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' } diff --git a/src/components/ai-assistant/placement.js b/src/components/ai-assistant/placement.js index 31ed195..860127e 100644 --- a/src/components/ai-assistant/placement.js +++ b/src/components/ai-assistant/placement.js @@ -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', ]; diff --git a/src/components/krow/proving/ChallengeRunner.jsx b/src/components/krow/proving/ChallengeRunner.jsx index 1da297c..66b9d16 100644 --- a/src/components/krow/proving/ChallengeRunner.jsx +++ b/src/components/krow/proving/ChallengeRunner.jsx @@ -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 ( -
+
{STEPS.map((s, i) => { const done = i < active; const cur = i === active; return ( -
-
{done ? '✓' : i + 1}
- {s} +
+ + {done ? '✓' : i + 1} + + + {s} +
- {i < STEPS.length - 1 &&
} + {i < STEPS.length - 1 && ( +