update ui changes

This commit is contained in:
2026-08-10 19:51:49 +05:30
parent 5cd49a1655
commit 7427359967
13 changed files with 388 additions and 271 deletions

View File

@@ -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 />} />

View File

@@ -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" />

View File

@@ -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">

View File

@@ -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 },

View File

@@ -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',

View File

@@ -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' }

View File

@@ -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. */

View File

@@ -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.`;
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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">

View File

@@ -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>
);
}