update ui changes
This commit is contained in:
@@ -68,8 +68,8 @@ function CollapsedTrigger({ page, onRestore }) {
|
||||
hover:border-krow-blue/40 hover:shadow-lg
|
||||
focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-krow-blue/50"
|
||||
>
|
||||
<span className="grid h-7 w-7 shrink-0 place-items-center rounded-full bg-krow-blue text-white shadow-brand">
|
||||
<Sparkles className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
<span className="grid h-7 w-7 shrink-0 place-items-center rounded-full bg-white overflow-hidden shadow-brand border border-border">
|
||||
<img src="/krow logo.jpeg" alt="KROW Logo" className="h-full w-full object-cover" />
|
||||
</span>
|
||||
<span className="text-body-sm font-semibold text-ink-1">Owliver</span>
|
||||
<PanelLeftOpen className="h-3.5 w-3.5 shrink-0 text-ink-4 group-hover:text-krow-blue" aria-hidden="true" />
|
||||
|
||||
@@ -105,8 +105,8 @@ export default function KrowAssistant({
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex shrink-0 items-center gap-2.5 border-b border-white/60 px-3.5 py-2.5">
|
||||
<span className="grid h-7 w-7 shrink-0 place-items-center rounded-lg bg-krow-blue text-white shadow-brand">
|
||||
<Sparkles className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
<span className="grid h-7 w-7 shrink-0 place-items-center rounded-lg bg-white overflow-hidden shadow-brand border border-border">
|
||||
<img src="/krow logo.jpeg" alt="KROW Logo" className="h-full w-full object-cover" />
|
||||
</span>
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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' }
|
||||
|
||||
@@ -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. */
|
||||
|
||||
Reference in New Issue
Block a user