update ui changes
This commit is contained in:
@@ -72,6 +72,11 @@ const AuthenticatedApp = () => {
|
||||
<Route path="positions/new" element={<CreatePosition />} />
|
||||
<Route path="positions/:id" element={<PositionDetail />} />
|
||||
<Route path="candidates" element={<AdminCandidates />} />
|
||||
{/* The full Candidate Intelligence profile. A page rather than a
|
||||
drawer: the record carries around forty fields and fifteen
|
||||
screening dimensions, which a 480px column cannot show without
|
||||
hiding most of it. */}
|
||||
<Route path="candidates/:id" element={<AdminCandidateProfile />} />
|
||||
<Route path="hired" element={<AdminHiredHistory />} />
|
||||
<Route path="talent-pool" element={<AdminTalentPool />} />
|
||||
<Route path="university" element={<University />} />
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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 (
|
||||
<div className="fixed bottom-5 right-5 z-40 font-sans">
|
||||
{/* Closed State Launcher */}
|
||||
{!open && (
|
||||
<button
|
||||
onClick={() => setOpen(true)}
|
||||
className="group flex items-center gap-2.5 px-4 py-2.5 rounded-full bg-white border border-[#E5E7EB] shadow-lg hover:shadow-xl hover:border-[#0A39DF]/40 transition-all duration-200 cursor-pointer"
|
||||
>
|
||||
<span className="w-7 h-7 rounded-full bg-[#0A39DF] flex items-center justify-center text-white shadow-sm group-hover:scale-105 transition-transform">
|
||||
<Sparkles className="w-4 h-4" />
|
||||
</span>
|
||||
<span className="text-[13px] font-semibold text-[#111827] tracking-tight">KROW AI</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Open State Chat Panel */}
|
||||
{open && (
|
||||
<div className="w-[380px] sm:w-[420px] h-[520px] bg-white border border-[#E5E7EB] rounded-2xl shadow-2xl flex flex-col overflow-hidden animate-in fade-in slide-in-from-bottom-4 duration-200">
|
||||
{/* Header */}
|
||||
<div className="px-4 py-3 bg-[#F8FAFC] border-b border-[#E5E7EB] flex items-center justify-between">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<div className="w-8 h-8 rounded-full bg-[#0A39DF] flex items-center justify-center text-white">
|
||||
<Sparkles className="w-4 h-4" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-[14px] font-bold text-[#111827]">KROW AI</h3>
|
||||
<p className="text-[11px] text-[#6B7280]">Your hiring copilot</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setOpen(false)}
|
||||
className="w-7 h-7 rounded-lg text-[#6B7280] hover:text-[#111827] hover:bg-[#E5E7EB]/60 flex items-center justify-center transition-colors"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Chat Body */}
|
||||
<div className="flex-1 p-4 overflow-y-auto space-y-4 bg-white">
|
||||
{messages.length === 0 ? (
|
||||
<div className="space-y-4 py-2">
|
||||
<div className="bg-[#EFF6FF] rounded-xl p-3.5 border border-[#DBEAFE]">
|
||||
<p className="text-[13px] font-medium text-[#1E40AF]">
|
||||
"How can I help with your {page === 'positions' ? 'positions' : 'candidates'}?"
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="text-[11px] font-semibold text-[#9CA3AF] tracking-wider uppercase mb-2">
|
||||
Suggested prompts
|
||||
</p>
|
||||
<div className="space-y-1.5">
|
||||
{prompts.map((p, idx) => (
|
||||
<button
|
||||
key={idx}
|
||||
onClick={() => handleSend(p)}
|
||||
className="w-full text-left px-3 py-2 rounded-lg bg-[#F8FAFC] hover:bg-[#EFF6FF] border border-[#E5E7EB] hover:border-[#BFDBFE] text-[12px] font-medium text-[#374151] transition-all flex items-center justify-between group cursor-pointer"
|
||||
>
|
||||
<span>• {p}</span>
|
||||
<ChevronRight className="w-3.5 h-3.5 text-[#9CA3AF] group-hover:text-[#0A39DF] transition-colors" />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
messages.map((m) => (
|
||||
<div
|
||||
key={m.id}
|
||||
className={`flex gap-2.5 ${m.sender === 'user' ? 'justify-end' : 'justify-start'}`}
|
||||
>
|
||||
{m.sender === 'bot' && (
|
||||
<div className="w-7 h-7 rounded-full bg-[#0A39DF] text-white flex items-center justify-center shrink-0 mt-0.5">
|
||||
<Sparkles className="w-3.5 h-3.5" />
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
className={`max-w-[82%] px-3.5 py-2.5 rounded-xl text-[13px] leading-relaxed whitespace-pre-line ${
|
||||
m.sender === 'user'
|
||||
? 'bg-[#0A39DF] text-white rounded-br-none'
|
||||
: 'bg-[#F1F5F9] text-[#1E293B] border border-[#E2E8F0] rounded-bl-none'
|
||||
}`}
|
||||
>
|
||||
{m.text}
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
|
||||
{isTyping && (
|
||||
<div className="flex gap-2.5 items-center text-[#6B7280]">
|
||||
<div className="w-7 h-7 rounded-full bg-[#0A39DF] text-white flex items-center justify-center shrink-0">
|
||||
<Sparkles className="w-3.5 h-3.5 animate-spin" />
|
||||
</div>
|
||||
<div className="bg-[#F1F5F9] px-3.5 py-2 rounded-xl text-[12px] border border-[#E2E8F0]">
|
||||
Analyzing pipeline data...
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div ref={chatEndRef} />
|
||||
</div>
|
||||
|
||||
{/* Footer Input */}
|
||||
<div className="p-3 bg-[#F8FAFC] border-t border-[#E5E7EB]">
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
handleSend();
|
||||
}}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
placeholder="Ask KROW AI..."
|
||||
className="flex-1 h-9 px-3.5 rounded-lg border border-[#CBD5E1] bg-white text-[13px] focus:outline-none focus:border-[#0A39DF]"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!input.trim() || isTyping}
|
||||
className="h-9 px-3 rounded-lg bg-[#0A39DF] hover:bg-[#082EB4] disabled:opacity-40 text-white text-[12px] font-medium flex items-center justify-center transition-colors cursor-pointer"
|
||||
>
|
||||
<Send className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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.`;
|
||||
}
|
||||
@@ -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 && (
|
||||
<MessageCandidateModal open={true} onClose={() => setMessageApp(null)} application={messageApp} />
|
||||
)}
|
||||
<KrowAiChat page="candidates" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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() {
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<KrowAiChat page="positions" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -5,7 +5,6 @@ import { useApplications, useInterviews, useJobPostings } from '@/lib/krowHooks'
|
||||
import { AdminPage, Toolbar } from '@/pages/admin/_shell';
|
||||
import { FilterSelect } from '@/pages/admin/Positions';
|
||||
import CandidateCard from '@/components/krow/CandidateCard';
|
||||
import KrowAiChat from '@/components/krow/KrowAiChat';
|
||||
|
||||
const SCORE_BANDS = {
|
||||
all: () => true,
|
||||
@@ -118,7 +117,6 @@ export default function AdminCandidates() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<KrowAiChat page="candidates" />
|
||||
</AdminPage>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -58,13 +58,13 @@ export default function AdminLogin() {
|
||||
const busy = status !== 'idle';
|
||||
|
||||
return (
|
||||
<main className="min-h-screen w-full bg-[#FAF9F5] flex items-center justify-center p-4 sm:p-6 lg:p-8 font-sans selection:bg-[#F9E547] selection:text-[#0F172A]">
|
||||
<div className="w-full max-w-5xl bg-white border border-[#F3E8C8] rounded-3xl shadow-xl shadow-amber-900/5 overflow-hidden grid grid-cols-1 md:grid-cols-2 min-h-[580px]">
|
||||
{/* Left Visual Section — White & Yellow Theme */}
|
||||
<div className="relative hidden md:flex flex-col justify-between p-8 sm:p-10 bg-gradient-to-br from-[#FFFDF7] via-[#FFFBEB] to-[#FEF3C7] overflow-hidden border-r border-[#F3E8C8]">
|
||||
{/* Subtle Decorative Yellow Orb / Accents */}
|
||||
<div className="absolute -top-20 -left-20 w-64 h-64 rounded-full bg-[#F9E547]/30 blur-3xl pointer-events-none" />
|
||||
<div className="absolute -bottom-20 -right-20 w-80 h-80 rounded-full bg-[#F59E0B]/20 blur-3xl pointer-events-none" />
|
||||
<main className="min-h-screen w-full bg-[#F8FAFC] flex items-center justify-center p-4 sm:p-6 lg:p-8 font-sans selection:bg-[#F9E547] selection:text-[#0F172A]">
|
||||
<div className="w-full max-w-5xl bg-white border border-[#E2E8F0] rounded-3xl shadow-xl shadow-slate-900/5 overflow-hidden grid grid-cols-1 md:grid-cols-2 min-h-[580px]">
|
||||
{/* Left Visual Section — Clean White & KROW Yellow Theme */}
|
||||
<div className="relative hidden md:flex flex-col justify-between p-8 sm:p-10 bg-[#FAFAFA] overflow-hidden border-r border-[#E2E8F0]">
|
||||
{/* Subtle Decorative Accents */}
|
||||
<div className="absolute -top-20 -left-20 w-64 h-64 rounded-full bg-[#0A39DF]/5 blur-3xl pointer-events-none" />
|
||||
<div className="absolute -bottom-20 -right-20 w-80 h-80 rounded-full bg-[#F9E547]/10 blur-3xl pointer-events-none" />
|
||||
|
||||
{/* Top Brand & Badge */}
|
||||
<div className="relative z-10 flex items-center justify-between">
|
||||
@@ -78,7 +78,7 @@ export default function AdminLogin() {
|
||||
|
||||
{/* Central KROW Visual Image */}
|
||||
<div className="relative z-10 my-auto py-6 flex flex-col items-center">
|
||||
<div className="relative rounded-2xl overflow-hidden shadow-lg border-2 border-white bg-white p-2 max-w-xs">
|
||||
<div className="relative rounded-2xl overflow-hidden shadow-md border border-[#E2E8F0] bg-white p-2 max-w-xs">
|
||||
<img
|
||||
src="/krow%20logo.jpeg"
|
||||
alt="KROW Logo Visual"
|
||||
@@ -89,11 +89,11 @@ export default function AdminLogin() {
|
||||
|
||||
{/* Bottom Hero Tagline */}
|
||||
<div className="relative z-10 space-y-2">
|
||||
<div className="flex items-center gap-1.5 text-xs font-bold text-[#D97706] uppercase tracking-wider">
|
||||
<Sparkles className="w-3.5 h-3.5" />
|
||||
<div className="flex items-center gap-1.5 text-xs font-bold text-[#0A39DF] uppercase tracking-wider">
|
||||
<Sparkles className="w-3.5 h-3.5 text-[#0A39DF]" />
|
||||
Recruiting Intelligence Platform
|
||||
</div>
|
||||
<h2 className="text-xl font-bold text-[#1E293B] tracking-tight leading-snug">
|
||||
<h2 className="text-xl font-bold text-[#0F172A] tracking-tight leading-snug">
|
||||
Workforce decisions powered by intelligence
|
||||
</h2>
|
||||
<p className="text-xs text-[#64748B] leading-relaxed">
|
||||
|
||||
@@ -10,7 +10,6 @@ import { useApplications, useJobPostings } from '@/lib/krowHooks';
|
||||
import { AdminPage, SectionTitle } from '@/pages/admin/_shell';
|
||||
import { RoleGlyph } from '@/pages/admin/RoleGlyph';
|
||||
import { buildPosition, payLabel } from '@/pages/admin/positionInsights';
|
||||
import KrowAiChat from '@/components/krow/KrowAiChat';
|
||||
|
||||
/**
|
||||
* Admin Positions — a hiring workspace, one card per role.
|
||||
@@ -498,7 +497,6 @@ export default function AdminPositions() {
|
||||
else toast.info(`${p.title} has no applicants yet`);
|
||||
}}
|
||||
/>
|
||||
<KrowAiChat page="positions" />
|
||||
</AdminPage>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user