diff --git a/src/components/ai-assistant/capabilities/admin.js b/src/components/ai-assistant/capabilities/admin.js
index 528f595..b9eea25 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, TrendingUp, Users,
+ Lightbulb, ScrollText, ShieldAlert, Target, TrendingUp, UserCheck, Users,
} from 'lucide-react';
import {
actions, answer, doc, funnel, heading, insights, kpis, list, meters, note, status, table,
@@ -871,6 +871,234 @@ const securityInsights = (f) => {
);
};
+/* ── Positions ──────────────────────────────────────────────────────────── */
+
+/**
+ * Position strength — which roles are actually working.
+ *
+ * Ranked by qualified conversion rather than raw applicants: a role with twelve
+ * applications and nobody above the bar is in worse shape than one with two who
+ * both clear it, and sorting by volume hides exactly that.
+ */
+const positionStrength = (f) => {
+ if (!f.openPositions.length) return answer('There are no open positions to compare.');
+
+ const rows = f.byRole
+ .map((r) => ({ ...r, rate: r.applied ? Math.round((r.qualified / r.applied) * 100) : 0 }))
+ .sort((a, b) => b.rate - a.rate || b.applied - a.applied);
+
+ const best = rows.find((r) => r.applied > 0);
+
+ return doc(
+ heading('Pipeline strength by position', `${plural(rows.length, 'open role')}`),
+ table(
+ [
+ { key: 'title', label: 'Role' },
+ { key: 'applied', label: 'Applied', align: 'right' },
+ { key: 'qualified', label: '70+', align: 'right' },
+ { key: 'rate', label: 'Qualified', align: 'right' },
+ ],
+ rows.map((r) => ({
+ title: r.title,
+ applied: r.applied ? r.applied : { value: 0, tone: 'risk' },
+ qualified: r.qualified,
+ rate: { value: `${r.rate}%`, tone: r.rate >= 50 ? 'success' : r.rate > 0 ? 'warning' : 'risk' },
+ }))
+ ),
+ insights([
+ best && {
+ tone: 'success',
+ title: `${best.title} has the strongest pipeline`,
+ body: `${best.qualified} of ${best.applied} applicants clear the bar — ${best.rate}%. If you are filling one role first, this is the one that can actually close.`,
+ },
+ f.starvedPositions.length && {
+ tone: 'risk',
+ title: `${plural(f.starvedPositions.length, 'role')} with no supply at all`,
+ body: `${f.starvedPositions.map((p) => p.title).join(', ')}. No amount of screening improves a role nobody has applied to.`,
+ },
+ ])
+ );
+};
+
+/** Which roles need a person, and why. */
+const positionsNeedingAttention = (f) => {
+ const items = [
+ f.starvedPositions.length && {
+ tone: 'risk',
+ title: `${plural(f.starvedPositions.length, 'role')} with no applicants`,
+ body: `${f.starvedPositions.map((p) => p.title).join(', ')}. Usually a pay band or distribution problem rather than a hiring one.`,
+ },
+ f.singleCandidateRoles.length && {
+ tone: 'warning',
+ title: `${plural(f.singleCandidateRoles.length, 'role')} resting on one viable candidate`,
+ body: `${f.singleCandidateRoles.map((e) => e.posting.title).join(', ')}. One withdrawal reopens the search.`,
+ },
+ f.unscreened.length && {
+ tone: 'warning',
+ title: `${plural(f.unscreened.length, 'applicant')} waiting on screening`,
+ body: 'Spread across the open roles. Until they are scored the funnels below them are incomplete.',
+ },
+ f.stalled.length && {
+ tone: 'risk',
+ title: `${plural(f.stalled.length, 'strong candidate')} not actioned`,
+ body: `${f.stalled.map((a) => a.applicant_name).join(', ')} scored 80 or above and ${verb(f.stalled.length, 'is', 'are')} still waiting.`,
+ },
+ ].filter(Boolean);
+
+ if (!items.length) {
+ return answer('Every open role has candidates, everyone is screened, and no role depends on a single person. Nothing needs intervention.');
+ }
+
+ return doc(
+ heading(`${plural(items.length, 'position issue')}`, 'Ordered by cost of leaving it'),
+ insights(items),
+ note('Ranked on supply and screening state. A role can look healthy here and still be hard to fill for reasons the platform cannot see.')
+ );
+};
+
+/** Which role to fill first. */
+const hiringPriority = (f) => {
+ const rows = f.byRole
+ .map((r) => ({
+ ...r,
+ ready: r.qualified,
+ rate: r.applied ? Math.round((r.qualified / r.applied) * 100) : 0,
+ }))
+ .sort((a, b) => b.ready - a.ready || b.rate - a.rate);
+
+ if (!rows.length) return answer('No open positions to prioritise.');
+
+ const first = rows[0];
+
+ return doc(
+ heading('What to fill first'),
+ text(
+ first.ready
+ ? `**${first.title}** — ${plural(first.ready, 'candidate')} already clear the bar, so this is the role where a decision converts into a hire today rather than starting a search.`
+ : 'No role currently has a candidate above the bar, so the first move is screening rather than hiring.'
+ ),
+ heading('Ranked by candidates ready now'),
+ table(
+ [
+ { key: 'title', label: 'Role' },
+ { key: 'ready', label: 'Ready', align: 'right' },
+ { key: 'applied', label: 'Applied', align: 'right' },
+ { key: 'hired', label: 'Hired', align: 'right' },
+ ],
+ rows.map((r) => ({
+ title: r.title,
+ ready: r.ready ? { value: r.ready, tone: 'success' } : '0',
+ applied: r.applied,
+ hired: r.hired || '—',
+ }))
+ )
+ );
+};
+
+/* ── Candidates list ────────────────────────────────────────────────────── */
+
+/** Who is waiting on a decision, most consequential first. */
+const candidatesNeedingAttention = (f) => {
+ const items = [
+ f.stalled.length && {
+ tone: 'risk',
+ title: `${plural(f.stalled.length, 'candidate')} at 80+ waiting on a decision`,
+ body: `${f.stalled.map((a) => a.applicant_name).join(', ')}. Screened, strong, and still sitting at AI Screened — the delay, not the assessment, is what loses these people.`,
+ },
+ f.unscreened.length && {
+ tone: 'warning',
+ title: `${plural(f.unscreened.length, 'candidate')} with no score`,
+ body: 'Cannot be ranked or compared until screening runs, so they are invisible to every sort on this page.',
+ },
+ f.missingCredentials.length && {
+ tone: 'warning',
+ title: `${plural(f.missingCredentials.length, 'scored candidate')} with no credentials`,
+ body: 'Compliance exposure if placed on a role that requires certification.',
+ },
+ f.narrowAvailability.length && {
+ tone: 'info',
+ title: `${plural(f.narrowAvailability.length, 'candidate')} with narrow availability`,
+ body: 'One slot or none recorded — the placements most likely to fall through on the day.',
+ },
+ ].filter(Boolean);
+
+ if (!items.length) {
+ return answer('Nobody is waiting. Every candidate is scored, credentialed, and either actioned or already moving.');
+ }
+
+ return doc(
+ heading(`${plural(items.length, 'thing')} to look at`),
+ insights(items),
+ text(`Start with ${items[0].title.toLowerCase()} — it is the one where waiting costs the most.`)
+ );
+};
+
+/** Who is ready to interview right now. */
+const interviewReady = (f) => {
+ const ready = f.ranked.filter(
+ (a) => a.ai_score >= 70 && ['ai_screened', 'shortlisted'].includes(a.status)
+ );
+
+ if (!ready.length) {
+ return answer('Nobody is interview-ready: no scored candidate above 70 is sitting at a stage where an interview is the next step.');
+ }
+
+ return doc(
+ heading('Ready to interview', `${plural(ready.length, 'candidate')} scoring 70 or above`),
+ table(
+ [
+ { key: 'name', label: 'Candidate' },
+ { key: 'role', label: 'Applied for' },
+ { key: 'score', label: 'Score', align: 'right' },
+ { key: 'stage', label: 'Stage', align: 'right' },
+ ],
+ ready.map((a) => ({
+ name: a.applicant_name,
+ role: a.job_title,
+ score: { value: a.ai_score, tone: a.ai_score >= 80 ? 'success' : 'info' },
+ stage: { badge: String(a.status).replace(/_/g, ' '), tone: 'neutral' },
+ }))
+ ),
+ note('Screened and above the bar. Availability still needs checking against the event calendar.')
+ );
+};
+
+/** The pipeline as it stands, by stage and score band. */
+const candidatePipelineSummary = (f) => {
+ if (!f.total) return answer('No candidates in the pipeline yet.');
+
+ const band = (min, max) =>
+ f.scored.filter((a) => a.ai_score >= min && (max == null || a.ai_score < max)).length;
+
+ return doc(
+ heading('Candidate pipeline', `${plural(f.total, 'candidate')}`),
+ kpis([
+ { label: 'Scored', value: f.scored.length, sub: `${f.standardizedPct}% coverage` },
+ { label: 'At 80+', value: band(80), tone: 'success' },
+ { label: 'Interviewing', value: f.interviewing.length },
+ { label: 'Hired', value: f.hired.length, tone: 'info' },
+ ]),
+ heading('By score band'),
+ table(
+ [{ key: 'band', label: 'Band' }, { key: 'count', label: 'Candidates', align: 'right' }],
+ [
+ { band: 'Strong (80+)', count: { value: band(80), tone: 'success' } },
+ { band: 'Good (60–79)', count: band(60, 80) },
+ { band: 'Review (40–59)', count: { value: band(40, 60), tone: 'warning' } },
+ { band: 'Low (under 40)', count: band(1, 40) },
+ { band: 'Not scored', count: { value: f.unscreened.length, tone: 'risk' } },
+ ]
+ ),
+ heading('By stage'),
+ funnel(f.funnel.map((stage, i) => ({
+ label: stage.label,
+ count: stage.count,
+ rate: i === 0 ? null : f.transitions[i - 1]?.rate,
+ lost: i === 0 ? 0 : f.transitions[i - 1]?.lost,
+ })))
+ );
+};
+
/* ── Free-text responders ───────────────────────────────────────────────── */
/**
@@ -893,6 +1121,27 @@ export const respondControlCenter = (question, f) => {
return platformHealth(f);
};
+export const respondPositions = (question, f) => {
+ const q = question.toLowerCase();
+ if (has(q, 'attention', 'urgent', 'problem', 'wrong', 'risk')) return positionsNeedingAttention(f);
+ if (has(q, 'first', 'priorit', 'which position', 'should i hire')) return hiringPriority(f);
+ if (has(q, 'bottleneck', 'funnel', 'drop', 'conversion')) return pipelineHealth(f);
+ if (has(q, 'waiting', 'review', 'screen')) return candidatesNeedingAttention(f);
+ if (has(q, 'activity', 'summar', 'operation', 'velocity')) return hiringOperations(f);
+ return positionStrength(f);
+};
+
+export const respondCandidateList = (question, f) => {
+ const q = question.toLowerCase();
+ if (has(q, 'attention', 'waiting', 'need', 'stuck')) return candidatesNeedingAttention(f);
+ if (has(q, 'interview', 'ready')) return interviewReady(f);
+ if (has(q, 'no score', 'unscored', 'not scored', 'missing')) return screeningGaps(f);
+ if (has(q, 'risk', 'flag', 'concern', 'compliance')) return candidateRiskAnalysis(f);
+ if (has(q, 'strongest', 'best', 'top', 'compare')) return topCandidates(f);
+ if (has(q, 'summar', 'pipeline', 'overview')) return candidatePipelineSummary(f);
+ return topCandidates(f);
+};
+
export const respondAdminCandidates = (question, f) => {
const q = question.toLowerCase();
if (has(q, 'gap', 'missing', 'incomplete', 'coverage', 'unscored')) return screeningGaps(f);
@@ -921,6 +1170,24 @@ export const CONTROL_CENTER_CAPABILITIES = [
{ id: 'recommendations', label: 'Recommendations', icon: Lightbulb, run: recommendations },
];
+export const POSITIONS_CAPABILITIES = [
+ { id: 'position-strength', label: 'Pipeline Strength', icon: TrendingUp, run: positionStrength },
+ { id: 'positions-attention', label: 'Needs Attention', icon: BellRing, run: positionsNeedingAttention },
+ { id: 'hiring-priority', label: 'What To Fill First', icon: Target, run: hiringPriority },
+ { id: 'pipeline-health', label: 'Hiring Bottlenecks', icon: GitBranch, run: pipelineHealth },
+ { id: 'hiring-operations', label: 'Hiring Activity', icon: Activity, run: hiringOperations },
+ { id: 'candidates-waiting', label: 'Candidates Waiting', icon: Users, run: candidatesNeedingAttention },
+];
+
+export const CANDIDATE_LIST_CAPABILITIES = [
+ { id: 'candidates-attention', label: 'Who Needs Attention', icon: BellRing, run: candidatesNeedingAttention },
+ { id: 'top-candidates', label: 'Strongest Candidates', icon: ArrowLeftRight, run: topCandidates },
+ { id: 'interview-ready', label: 'Interview Ready', icon: UserCheck, run: interviewReady },
+ { id: 'screening-gaps', label: 'Unscored Candidates', icon: Filter, run: screeningGaps },
+ { id: 'pipeline-summary', label: 'Pipeline Summary', icon: GitBranch, run: candidatePipelineSummary },
+ { id: 'candidate-risk', label: 'Candidate Risks', icon: AlertTriangle, run: candidateRiskAnalysis },
+];
+
export const ADMIN_CANDIDATE_CAPABILITIES = [
{ id: 'candidate-risk', label: 'Candidate Risk', icon: AlertTriangle, run: candidateRiskAnalysis },
{ id: 'recruitment-insights', label: 'Recruitment Insights', icon: TrendingUp, run: recruitmentInsights },
diff --git a/src/components/ai-assistant/contexts.js b/src/components/ai-assistant/contexts.js
index cbeca71..41d54a9 100644
--- a/src/components/ai-assistant/contexts.js
+++ b/src/components/ai-assistant/contexts.js
@@ -3,8 +3,10 @@ import {
respondAnalytics, respondCandidates, respondOverview,
} from './capabilities/employer';
import {
- ACTIVITY_CAPABILITIES, ADMIN_CANDIDATE_CAPABILITIES, CONTROL_CENTER_CAPABILITIES,
- respondActivity, respondAdminCandidates, respondControlCenter,
+ ACTIVITY_CAPABILITIES, ADMIN_CANDIDATE_CAPABILITIES, CANDIDATE_LIST_CAPABILITIES,
+ CONTROL_CENTER_CAPABILITIES, POSITIONS_CAPABILITIES,
+ respondActivity, respondAdminCandidates, respondCandidateList, respondControlCenter,
+ respondPositions,
} from './capabilities/admin';
/**
@@ -43,6 +45,21 @@ export const ASSISTANT_CONTEXTS = {
capabilities: CONTROL_CENTER_CAPABILITIES,
respond: respondControlCenter,
},
+ 'admin.positions': {
+ id: 'admin.positions',
+ page: 'Positions',
+ capabilities: POSITIONS_CAPABILITIES,
+ respond: respondPositions,
+ },
+ /* The Candidates list and Candidates Analysis are separate contexts because they
+ ask different questions of the same records: the list is triage — who needs a
+ decision — and Analysis reads the pool as a whole. */
+ 'admin.candidatesList': {
+ id: 'admin.candidatesList',
+ page: 'Candidates',
+ capabilities: CANDIDATE_LIST_CAPABILITIES,
+ respond: respondCandidateList,
+ },
'admin.candidates': {
id: 'admin.candidates',
page: 'Candidates Analysis',
diff --git a/src/components/ai-assistant/dynamic.js b/src/components/ai-assistant/dynamic.js
index 1f0cfd1..feb6225 100644
--- a/src/components/ai-assistant/dynamic.js
+++ b/src/components/ai-assistant/dynamic.js
@@ -83,6 +83,34 @@ const GREETINGS = {
return `Platform is steady across ${plural(f.postings.length, 'position')} and ${plural(f.total, 'application')}.`;
},
+ 'admin.positions': (f) => {
+ if (!f.openPositions.length) return 'No open positions yet — publish a role and I will start tracking it.';
+ if (f.starvedPositions.length) {
+ return `${plural(f.starvedPositions.length, 'open position')} ${verb(f.starvedPositions.length, 'has', 'have')} no applicants and ${verb(f.starvedPositions.length, 'needs', 'need')} attention.`;
+ }
+ if (f.singleCandidateRoles.length) {
+ return `${plural(f.singleCandidateRoles.length, 'role')} ${verb(f.singleCandidateRoles.length, 'rests', 'rest')} on a single viable candidate.`;
+ }
+ if (f.unscreened.length) {
+ return `${plural(f.unscreened.length, 'applicant')} across your roles ${verb(f.unscreened.length, 'is', 'are')} waiting to be screened.`;
+ }
+ return `All ${f.openPositions.length} open roles have candidates and a moving pipeline.`;
+ },
+
+ 'admin.candidatesList': (f) => {
+ if (!f.total) return 'No candidates in the pipeline yet.';
+ if (f.stalled.length) {
+ return `${plural(f.stalled.length, 'strong candidate')} ${verb(f.stalled.length, 'is', 'are')} screened and waiting on a decision.`;
+ }
+ if (f.unscreened.length) {
+ return `${plural(f.unscreened.length, 'candidate')} ${verb(f.unscreened.length, 'has', 'have')} no score and cannot be ranked.`;
+ }
+ if (f.missingCredentials.length) {
+ return `${plural(f.missingCredentials.length, 'scored candidate')} ${verb(f.missingCredentials.length, 'has', 'have')} no credentials on file.`;
+ }
+ return `All ${f.total} candidates are scored and actioned.`;
+ },
+
'admin.candidates': (f) => {
if (!f.total) return 'Nothing in the pipeline yet, so there is no pool to analyse.';
if (f.needsDeeperReview.length) {
@@ -124,6 +152,8 @@ const TITLES = {
'employer.candidates': () => 'Candidate intelligence',
'employer.analytics': () => 'Analytics assistant',
'admin.controlCenter': (f, name) => `Good ${timeOfDay(f.today)}${name ? `, ${name}` : ''}`,
+ '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.activity': (f, name) => `Good ${timeOfDay(f.today)}${name ? `, ${name}` : ''}`,
};
@@ -136,6 +166,10 @@ const DESCRIPTIONS = {
'employer.analytics': () => 'Every figure on this page, explained or forecast.',
'admin.controlCenter': (f) =>
`Reading ${plural(f.total, 'application')} across ${plural(f.postings.length, 'position')} — health, operations and what needs intervention.`,
+ 'admin.positions': (f) =>
+ `Reading ${plural(f.total, 'application')} across ${plural(f.postings.length, 'position')} — hiring health, pipeline strength and what needs intervention.`,
+ 'admin.candidatesList': (f) =>
+ `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.activity': (f) => `${plural(f.activity.length, 'logged event')}, reconciled and profiled.`,
@@ -169,6 +203,16 @@ const PLACEHOLDERS = {
'Ask Owliver what needs attention…',
'Ask Owliver about hiring bottlenecks…',
],
+ 'admin.positions': () => [
+ 'Ask Owliver about your positions…',
+ 'Ask Owliver which role to fill first…',
+ 'Ask Owliver about hiring bottlenecks…',
+ ],
+ 'admin.candidatesList': () => [
+ 'Ask Owliver about candidates…',
+ 'Ask Owliver who is interview ready…',
+ 'Ask Owliver about candidate risks…',
+ ],
'admin.candidates': () => [
'Ask Owliver about candidates…',
'Ask Owliver about screening gaps…',
@@ -249,6 +293,32 @@ const PROMPTS = {
{ label: 'Identify operational risks', prompt: 'Identify operational risks and what I should fix first', capability: 'recommendations' },
],
+ 'admin.positions': (f) => [
+ f.starvedPositions.length
+ ? { label: `${f.starvedPositions.length} roles need attention`, prompt: 'What needs attention across my positions?', capability: 'positions-attention' }
+ : { label: 'What needs attention?', prompt: 'What needs attention across my positions?', capability: 'positions-attention' },
+ { label: 'Which position has the strongest pipeline?', prompt: 'Which position has the strongest pipeline?', capability: 'position-strength' },
+ f.unscreened.length
+ ? { label: `${f.unscreened.length} waiting for review`, prompt: 'Show candidates waiting for review', capability: 'candidates-waiting' }
+ : { label: 'Show candidates waiting for review', prompt: 'Show candidates waiting for review', capability: 'candidates-waiting' },
+ { label: 'Which position should I hire for first?', prompt: 'Which position should I hire for first?', capability: 'hiring-priority' },
+ { label: 'Summarize hiring activity', prompt: 'Summarize hiring activity across my positions', capability: 'hiring-operations' },
+ { label: 'Identify hiring bottlenecks', prompt: 'Identify hiring bottlenecks', capability: 'pipeline-health' },
+ ],
+
+ 'admin.candidatesList': (f) => [
+ f.stalled.length
+ ? { label: `${f.stalled.length} waiting on a decision`, prompt: 'Who needs attention?', capability: 'candidates-attention' }
+ : { label: 'Who needs attention?', prompt: 'Who needs attention?', capability: 'candidates-attention' },
+ { label: 'Who are the strongest candidates?', prompt: 'Who are the strongest candidates?', capability: 'top-candidates' },
+ { label: 'Which candidates are ready for interview?', prompt: 'Which candidates are ready for interview?', capability: 'interview-ready' },
+ f.unscreened.length
+ ? { label: `${f.unscreened.length} have no score`, prompt: 'Which candidates have no score?', capability: 'screening-gaps' }
+ : { label: 'Which candidates have no score?', prompt: 'Which candidates have no score?', capability: 'screening-gaps' },
+ { label: 'Summarize the candidate pipeline', prompt: 'Summarize the candidate pipeline', capability: 'pipeline-summary' },
+ { label: 'Identify candidate risks', prompt: 'Identify candidate risks', capability: 'candidate-risk' },
+ ],
+
'admin.candidates': (f) => [
f.ranked.length >= 2
? { label: `Compare ${f.ranked[0].applicant_name.split(' ')[0]} & ${f.ranked[1].applicant_name.split(' ')[0]}`, prompt: 'Compare the top candidates in the pool', capability: 'top-candidates' }
diff --git a/src/components/ai-assistant/placement.js b/src/components/ai-assistant/placement.js
index 97d0527..31ed195 100644
--- a/src/components/ai-assistant/placement.js
+++ b/src/components/ai-assistant/placement.js
@@ -14,6 +14,8 @@ import { ASSISTANT_CONTEXTS } from './contexts';
const PLACEMENT = {
admin: {
'/admin': 'admin.controlCenter',
+ '/admin/positions': 'admin.positions',
+ '/admin/candidates': 'admin.candidatesList',
'/admin/candidates-analysis': 'admin.candidates',
'/admin/activity': 'admin.activity',
},
@@ -23,20 +25,24 @@ const PLACEMENT = {
* Pages that must never carry the assistant, listed explicitly so the intent is
* documented rather than implied by omission.
*
- * Admin: every page except Control Center, Candidates Analysis and Activity.
+ * Admin: the reporting and reference pages, plus login. The working surfaces —
+ * Control Center, Positions, Candidates, Candidates Analysis 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.
*/
export const EXCLUDED_ROUTES = [
// Every Employer and Talent route: the contextual panel is Admin-only.
- '/', '/positions', '/positions/new', '/positions/:id', '/candidates',
+ '/', '/overview', '/positions', '/positions/new', '/positions/:id', '/candidates',
'/hired', '/talent-pool', '/analytics', '/profile', '/apply',
'/university', '/university/:id', '/me', '/identity', '/employee',
'/owliver', '/design-system',
- // Admin management surfaces stay focused on their own workflow.
- '/admin/positions', '/admin/candidates', '/admin/hired',
- '/admin/talent-pool', '/admin/analytics', '/admin/profile', '/admin/login',
+ // Admin pages that are a report or a form rather than a workflow.
+ '/admin/hired', '/admin/talent-pool', '/admin/analytics',
+ '/admin/profile', '/admin/login',
+ // The full candidate profile is one person's record, not a page-level question.
+ '/admin/candidates/:id',
];
/** Returns the context for a role and path, or `null` to render no assistant. */
diff --git a/src/components/krow/KrowAiChat.jsx b/src/components/krow/KrowAiChat.jsx
deleted file mode 100644
index 821fedf..0000000
--- a/src/components/krow/KrowAiChat.jsx
+++ /dev/null
@@ -1,240 +0,0 @@
-import React, { useState, useEffect, useRef } from 'react';
-import { Sparkles, X, Send, ChevronRight } from 'lucide-react';
-import { useJobPostings, useApplications, useInterviews } from '@/lib/krowHooks';
-
-const POSITIONS_PROMPTS = [
- "Which positions need attention?",
- "Which role has the strongest pipeline?",
- "Show candidates waiting for review",
- "Which position should I hire for first?",
-];
-
-const CANDIDATES_PROMPTS = [
- "Who are the strongest candidates?",
- "Which candidates need review?",
- "Show candidates with the highest match score",
- "Who is ready for interview?",
- "Which candidates have gaps?",
- "Summarize today's candidate activity",
-];
-
-export default function KrowAiChat({ page = 'positions' }) {
- const [open, setOpen] = useState(false);
- const [messages, setMessages] = useState([]);
- const [input, setInput] = useState('');
- const [isTyping, setIsTyping] = useState(false);
- const chatEndRef = useRef(null);
-
- const { data: postings = [] } = useJobPostings();
- const { data: applications = [] } = useApplications();
- const { data: interviews = [] } = useInterviews();
-
- const prompts = page === 'positions' ? POSITIONS_PROMPTS : CANDIDATES_PROMPTS;
-
- useEffect(() => {
- if (open) {
- chatEndRef.current?.scrollIntoView({ behavior: 'smooth' });
- }
- }, [messages, open, isTyping]);
-
- const handleSend = (textToSend) => {
- const query = (textToSend || input).trim();
- if (!query) return;
-
- const userMsg = { id: Date.now(), sender: 'user', text: query };
- setMessages((prev) => [...prev, userMsg]);
- setInput('');
- setIsTyping(true);
-
- setTimeout(() => {
- const responseText = generateResponse(query, page, postings, applications, interviews);
- const botMsg = { id: Date.now() + 1, sender: 'bot', text: responseText };
- setMessages((prev) => [...prev, botMsg]);
- setIsTyping(false);
- }, 600);
- };
-
- return (
-
- {/* Closed State Launcher */}
- {!open && (
-
- )}
-
- {/* Open State Chat Panel */}
- {open && (
-
- {/* Header */}
-
-
-
-
-
-
-
KROW AI
-
Your hiring copilot
-
-
-
-
-
- {/* Chat Body */}
-
- {messages.length === 0 ? (
-
-
-
- "How can I help with your {page === 'positions' ? 'positions' : 'candidates'}?"
-
-
-
-
-
- Suggested prompts
-
-
- {prompts.map((p, idx) => (
-
- ))}
-
-
-
- ) : (
- messages.map((m) => (
-
- {m.sender === 'bot' && (
-
-
-
- )}
-
- {m.text}
-
-
- ))
- )}
-
- {isTyping && (
-
-
-
-
-
- Analyzing pipeline data...
-
-
- )}
-
-
-
- {/* Footer Input */}
-
-
-
-
- )}
-
- );
-}
-
-function generateResponse(query, page, postings, applications, interviews) {
- const q = query.toLowerCase();
-
- if (page === 'positions') {
- if (q.includes('attention') || q.includes('prioritize') || q.includes('first')) {
- const emptyRoles = postings.filter((p) => (p.applicant_count || 0) === 0);
- if (emptyRoles.length > 0) {
- return `⚠️ **${emptyRoles[0].title}** needs urgent attention as it currently has 0 applicants. Consider boosting distribution or reviewing job requirements.`;
- }
- return `📊 Currently, **${postings[0]?.title || 'Executive Chef'}** has the highest applicant backlog requiring initial screening.`;
- }
-
- if (q.includes('strongest') || q.includes('pipeline')) {
- const topRole = [...postings].sort((a, b) => (b.applicant_count || 0) - (a.applicant_count || 0))[0];
- return `💪 **${topRole?.title || 'Lead Catering Manager'}** has the strongest pipeline with **${topRole?.applicant_count || 12} total candidates** and high average match scores.`;
- }
-
- if (q.includes('waiting') || q.includes('review')) {
- const unscreened = applications.filter((a) => a.status === 'applied');
- return `📋 You have **${unscreened.length || 4} candidates** waiting for initial AI screening and review across open positions.`;
- }
-
- return `Positions Overview: You have ${postings.length || 5} total job postings active. Total applicants: ${applications.length || 18}.`;
- }
-
- // Candidates page context
- if (q.includes('strongest') || q.includes('highest match') || q.includes('top')) {
- const topCand = [...applications].sort((a, b) => (b.ai_score || 0) - (a.ai_score || 0))[0];
- if (topCand) {
- return `🌟 Top candidate: **${topCand.applicant_name}** with a match score of **${topCand.ai_score}/100** (${topCand.ai_match_label || 'Excellent Match'}) for ${topCand.job_title}.`;
- }
- return `🌟 **Chef Antoine Dubois** is your top candidate with a **97/100 match score** and Elite career tier status.`;
- }
-
- if (q.includes('review') || q.includes('waiting')) {
- const applied = applications.filter((a) => a.status === 'applied');
- return `🔍 You have **${applied.length || 3} unscreened candidates** awaiting your review in the candidate pipeline.`;
- }
-
- if (q.includes('interview') || q.includes('ready')) {
- const interviewing = applications.filter((a) => a.status === 'interview' || a.status === 'ai_screened');
- return `🎙️ **${interviewing.length || 5} candidates** have passed AI screening and are ready for interview scheduling.`;
- }
-
- if (q.includes('gap') || q.includes('gaps')) {
- const withGaps = applications.filter((a) => a.ai_gaps && a.ai_gaps.length > 0);
- return `⚠️ Candidates with noted gaps: **${withGaps.map(c => c.applicant_name).join(', ') || 'Chef Antoine Dubois (ServSafe Certificate update needed)'}**. None of these are disqualifying gaps.`;
- }
-
- return `Candidate Summary: **${applications.length || 18} candidates** active in pipeline. Average score: **84/100**. ${interviews.length || 4} interviews conducted to date.`;
-}
diff --git a/src/pages/Candidates.jsx b/src/pages/Candidates.jsx
index 777bf95..e421a44 100644
--- a/src/pages/Candidates.jsx
+++ b/src/pages/Candidates.jsx
@@ -5,7 +5,6 @@ import CandidateCard from '@/components/krow/CandidateCard';
import AIInterviewModal from '@/components/krow/AIInterviewModal';
import ScheduleInterviewModal from '@/components/krow/ScheduleInterviewModal';
import MessageCandidateModal from '@/components/krow/MessageCandidateModal';
-import KrowAiChat from '@/components/krow/KrowAiChat';
import { toast } from 'react-hot-toast';
export default function Candidates() {
@@ -170,7 +169,6 @@ export default function Candidates() {
{messageApp && (
setMessageApp(null)} application={messageApp} />
)}
-
);
}
\ No newline at end of file
diff --git a/src/pages/Positions.jsx b/src/pages/Positions.jsx
index d3002c6..988045f 100644
--- a/src/pages/Positions.jsx
+++ b/src/pages/Positions.jsx
@@ -5,7 +5,6 @@ import { useJobPostings, useApplications } from '@/lib/krowHooks';
import { base44 } from '@/api/base44Client';
import KROWJobPostingCard from '@/components/krow/KROWJobPostingCard';
import CertificationManager from '@/components/krow/CertificationManager';
-import KrowAiChat from '@/components/krow/KrowAiChat';
export default function Positions() {
const navigate = useNavigate();
@@ -91,7 +90,6 @@ export default function Positions() {
))}