import { AlertTriangle, BarChart3, ClipboardCheck, FileSpreadsheet, FileText, GitCompare, HeartPulse, Layers, LineChart, ListChecks, MessageSquareQuote, Sparkles, Target, } from 'lucide-react'; import { actions, answer, badges, doc, funnel, heading, insights, kpis, list, meters, note, status, table, text, } from '../blocks'; import { plural, verb } from '../insights'; /** * Employer capabilities — Overview, Candidates, Analytics. * * Each returns a block document rather than prose. The shape is chosen by what * the information is: a comparison is a table, a health check is a status list, * a pipeline is a funnel. Prose is reserved for the judgement a table cannot * carry. */ /* ── Overview ───────────────────────────────────────────────────────────── */ const hiringSummary = (f) => { if (!f.total) return answer('Nothing in the pipeline yet. Publish a position and I will start tracking it.'); return doc( kpis([ { label: 'Applicants', value: f.total, sub: `${f.openPositions.length} open roles` }, { label: 'Screened', value: f.screened.length, sub: `${f.standardizedPct}% coverage`, tone: f.standardizedPct >= 80 ? 'success' : 'warning' }, { label: 'Interviewing', value: f.interviewing.length }, { label: 'Hired', value: f.hired.length, sub: f.hiredAvgScore ? `avg ${f.hiredAvgScore}/100` : undefined, tone: 'info' }, ]), heading('Pipeline'), funnel(f.funnel.map((stage, i) => ({ label: stage.label, count: stage.count, rate: i === 0 ? undefined : f.transitions[i - 1]?.rate, }))), text( f.stalled.length ? `The first thing I would fix: ${plural(f.stalled.length, 'candidate')} scored 80+ and ${verb(f.stalled.length, 'is', 'are')} still at AI Screened.` : f.unscreened.length ? `The first thing I would fix: ${plural(f.unscreened.length, 'applicant')} ${verb(f.unscreened.length, 'has', 'have')} never been scored.` : 'Nothing is stuck — every applicant is screened and every strong candidate has been actioned.' ) ); }; const hiringHealth = (f) => { const checks = [ { label: 'Screening coverage', value: `${f.standardizedPct}%`, ok: f.standardizedPct >= 80, note: f.standardizedPct >= 80 ? 'Applicants are scored consistently.' : `${plural(f.unscreened.length, 'applicant')} never got a score, so those decisions are unstandardized.`, }, { label: 'Response speed', value: f.timeToHire ? `${f.timeToHire}d` : '—', ok: f.timeToHire > 0 && f.timeToHire <= 3, note: f.timeToHire && f.timeToHire <= 3 ? 'Inside the window where good candidates are still available.' : 'Event staff accept other work within days — past 3 days you lose people.', }, { label: 'Quality of hire', value: f.hiredAvgScore ? `${f.hiredAvgScore}/100` : '—', ok: f.hiredAvgScore >= 80, note: f.hiredAvgScore >= 80 ? 'You are hiring from the top of your own pool.' : 'Hires are scoring mid-pack — widen the funnel before lowering the bar.', }, { label: 'Pipeline supply', value: `${f.openPositions.length} open`, ok: f.starvedPositions.length === 0, note: f.starvedPositions.length ? `${f.starvedPositions.map((p) => p.title).join(', ')} ${verb(f.starvedPositions.length, 'has', 'have')} no applicants.` : 'Every open position has applicants.', }, ]; const passing = checks.filter((c) => c.ok).length; return doc( heading(`${passing} of ${checks.length} signals healthy`), status(checks), note('Thresholds are event-staffing norms, not universal benchmarks.') ); }; const buildActions = (f) => [ f.stalled.length && { title: 'Move your 80+ candidates', body: `${f.stalled.slice(0, 3).map((a) => `**${a.applicant_name}** (${a.ai_score})`).join(', ')} ${verb(f.stalled.length, 'is', 'are')} screened and waiting on you.`, }, f.unscreened.length && { title: `Screen ${plural(f.unscreened.length, 'applicant')}`, body: 'One pass clears the backlog and costs nothing.', }, f.starvedPositions.length && { title: 'Fix the postings nobody applies to', body: `${f.starvedPositions.map((p) => p.title).join(' and ')} — usually pay range or reach, not the description.`, }, f.unratedStaff.length && { title: `Rate ${plural(f.unratedStaff.length, 'hire')}`, body: 'Ratings feed KROW scores and sharpen future matching.', }, f.elite.length && { title: 'Reach out to the pool directly', body: `${f.elite.map((p) => p.full_name).join(', ')} ${verb(f.elite.length, 'is', 'are')} Elite and ${verb(f.elite.length, 'has', 'have')} not applied to anything.`, }, ].filter(Boolean); const pendingActions = (f) => { const items = buildActions(f); if (!items.length) { return answer('Nothing pending. Everyone is screened, strong candidates are actioned, and every open role has applicants.'); } return doc(heading('Pending actions', 'Ordered by what costs you most'), actions(items.slice(0, 4))); }; const hiringRisks = (f) => { const risks = [ f.singleCandidateRoles.length && { tone: 'risk', title: 'Single-candidate roles', body: `${f.singleCandidateRoles.map((e) => e.posting.title).join(', ')} ${verb(f.singleCandidateRoles.length, 'rests', 'rest')} on one viable candidate. If they withdraw, the role reopens from zero.`, }, f.stalled.length && { tone: 'warning', title: 'Losing strong candidates to delay', body: `${plural(f.stalled.length, 'candidate')} at 80+ screened but not moved. This is where good hires quietly disappear.`, }, f.missingCredentials.length && { tone: 'risk', title: 'Compliance exposure', body: `${plural(f.missingCredentials.length, 'scored candidate')} ${verb(f.missingCredentials.length, 'has', 'have')} no certification on file. Placing them on a role that requires one is a real liability.`, }, f.narrowAvailability.length && { tone: 'warning', title: 'Availability risk', body: `${plural(f.narrowAvailability.length, 'candidate')} listed one slot or none — most likely to fall through on the day.`, }, f.starvedPositions.length && { tone: 'warning', title: 'Unfillable as posted', body: `${plural(f.starvedPositions.length, 'open role')} with zero applicants after going live.`, }, f.flagged.length && { tone: 'risk', title: 'Interview integrity', body: `${plural(f.flagged.length, 'interview')} scored below full integrity. Worth a human re-interview before progressing.`, }, ].filter(Boolean); if (!risks.length) { return answer('No material risks: coverage is good, no role depends on a single candidate, and credentials check out.'); } return doc( heading(`${plural(risks.length, 'risk')} to watch`), insights(risks), note('Flags to check, not conclusions. Each is a question for a human.') ); }; /* ── Candidates ─────────────────────────────────────────────────────────── */ const DIMENSIONS = ['experience', 'english', 'reliability', 'certifications', 'availability']; const resumeAnalysis = (f) => { const c = f.top[0]; if (!c) return answer('No scored candidates yet. Screen the pipeline and I will analyse the strongest.'); return doc( heading(c.applicant_name, `${c.job_title} · ${plural(c.years_experience, 'year')} · ${c.english_level} English`), kpis([ { label: 'AI score', value: `${c.ai_score}`, tone: c.ai_score >= 80 ? 'success' : c.ai_score >= 60 ? 'info' : 'warning' }, { label: 'Verdict', value: c.ai_match_label || '—' }, ]), c.ai_summary && text(c.ai_summary), c.certifications?.length ? [heading('Credentials'), badges(c.certifications.map((label) => ({ label, tone: 'success' })))] : [heading('Credentials'), badges([{ label: 'None on file', tone: 'risk' }])], heading('Score breakdown'), meters(DIMENSIONS.map((d) => ({ label: d[0].toUpperCase() + d.slice(1), value: c.score_breakdown?.[d] ?? 0 }))), c.ai_strengths?.length && [heading('Strengths'), list(c.ai_strengths)], c.ai_gaps?.length && [heading('Probe these'), list(c.ai_gaps)], note( c.certifications?.length ? 'Verify credentials before the first shift — a lapsed card is the most common surprise.' : 'Request credentials before scheduling.' ) ); }; const candidateComparison = (f) => { const [a, b] = f.ranked; if (!a || !b) return answer('I need at least two scored candidates to compare.'); const gap = a.ai_score - b.ai_score; const first = (n) => n.split(' ')[0]; const rows = DIMENSIONS.map((d) => { const av = a.score_breakdown?.[d] ?? 0; const bv = b.score_breakdown?.[d] ?? 0; return { dimension: d[0].toUpperCase() + d.slice(1), a: { value: av, tone: av > bv ? 'success' : av < bv ? 'neutral' : undefined }, b: { value: bv, tone: bv > av ? 'success' : bv < av ? 'neutral' : undefined }, }; }); const biggest = DIMENSIONS.reduce((best, d) => Math.abs((a.score_breakdown?.[d] ?? 0) - (b.score_breakdown?.[d] ?? 0)) > Math.abs((a.score_breakdown?.[best] ?? 0) - (b.score_breakdown?.[best] ?? 0)) ? d : best, DIMENSIONS[0]); return doc( heading(`${a.applicant_name} vs ${b.applicant_name}`), table( [ { key: 'dimension', label: '' }, { key: 'a', label: first(a.applicant_name), align: 'right' }, { key: 'b', label: first(b.applicant_name), align: 'right' }, ], [ { dimension: 'Overall', a: { value: a.ai_score, tone: 'info' }, b: { value: b.ai_score, tone: 'info' } }, ...rows, ], { caption: 'Score comparison by dimension' } ), heading('Where each one wins'), insights([ { tone: 'success', title: `${first(a.applicant_name)} leads on ${biggest}`, body: a.ai_strengths?.[0] || `Scores ${a.score_breakdown?.[biggest] ?? 0} against ${b.score_breakdown?.[biggest] ?? 0}.`, }, b.ai_strengths?.[0] && { tone: 'info', title: `${first(b.applicant_name)} brings`, body: b.ai_strengths[0], }, ]), text( gap <= 3 ? `Effectively tied — ${plural(gap, 'point')} apart is inside the noise. Decide on availability and the interview, not the score.` : `${a.applicant_name} leads by ${gap} points, driven mostly by ${biggest}.` ), note('Recommendation only. Neither has been met in person.') ); }; const interviewQuestions = (f) => { const c = f.stalled[0] || f.ranked[0]; if (!c) return answer('Screen a candidate and I will build questions around their specific gaps.'); const gaps = c.ai_gaps || []; return doc( heading(`Questions for ${c.applicant_name}`, 'Built from their gaps, not a generic list'), list([ `Walk me through your busiest ${c.job_title?.toLowerCase() || 'shift'}. What happened, and what did you decide?`, gaps[0] ? `I noticed ${gaps[0].toLowerCase()}. How have you handled that in practice?` : 'Tell me about a shift that went wrong. What was your part in it?', 'Something breaks mid-service and your lead is unreachable. What are your next three moves?', c.certifications?.length ? `Your ${c.certifications[0]} is on file — when did you last apply it on a live shift?` : 'Which certifications are you working toward, and why that one?', 'What does your real availability look like over the next eight weeks?', ], { ordered: true }), note('The scenario question is the one that separates candidates. Let them think.') ); }; const skillGapAnalysis = (f) => { if (!f.scored.length) return answer('No scored candidates yet, so there are no gaps to analyse.'); const tally = {}; f.scored.forEach((a) => (a.ai_gaps || []).forEach((gap) => { const key = gap.replace(/^Missing certifications?:\s*/i, 'Missing credential: '); tally[key] = (tally[key] || 0) + 1; })); const ranked = Object.entries(tally).sort((a, b) => b[1] - a[1]).slice(0, 5); if (!ranked.length) return answer('No recurring gaps — the pool matches your postings well.'); return doc( heading('Recurring gaps', `Across ${plural(f.scored.length, 'scored candidate')}`), table( [{ key: 'gap', label: 'Gap' }, { key: 'count', label: 'Candidates', align: 'right' }], ranked.map(([gap, count]) => ({ gap, count: { value: `${count}/${f.scored.length}`, tone: count > f.scored.length / 2 ? 'risk' : undefined }, })) ), text( f.missingCredentials.length >= 2 ? `${f.missingCredentials.length} candidates are short a required credential. That is usually a posting problem: if the certification is trainable, requiring it up front filters out people you could hire this week.` : 'Gaps are spread thin, which means your requirements are well matched to who is applying.' ) ); }; const hiringRecommendation = (f) => { if (!f.scored.length) return answer('Nothing scored yet, so I have no basis for a recommendation.'); const open = (a) => !['hired', 'rejected'].includes(a.status); const groups = [ { verdict: 'Shortlist', tone: 'success', people: f.ranked.filter((a) => a.ai_score >= 80 && open(a)) }, { verdict: 'Interview', tone: 'info', people: f.ranked.filter((a) => a.ai_score >= 60 && a.ai_score < 80 && open(a)) }, { verdict: 'Likely pass', tone: 'risk', people: f.weak.filter(open) }, ].filter((g) => g.people.length); if (!groups.length) return answer('Every scored candidate has already been actioned.'); return doc( heading('Recommendation'), table( [ { key: 'name', label: 'Candidate' }, { key: 'score', label: 'Score', align: 'right' }, { key: 'verdict', label: 'Verdict', align: 'right' }, ], groups.flatMap((g) => g.people.map((p) => ({ name: p.applicant_name, score: { value: p.ai_score, tone: g.tone }, verdict: { badge: g.verdict, tone: g.tone }, }))) ), note('A recommendation, not a decision. I score what is on file — I have not met these people.') ); }; /* ── Analytics ──────────────────────────────────────────────────────────── */ const explainCharts = (f) => doc( heading('What these charts are saying'), insights([ { tone: 'info', title: 'Pipeline Stages', body: `${f.total} applied, ${f.screened.length} screened, ${f.hired.length} hired. Bars are cumulative, so "AI Screened" includes everyone who moved past it.`, }, { tone: 'info', title: 'Candidate Scores', body: `${plural(f.scored.length, 'candidate')} scored, averaging ${f.avgScore}. The 80–100 bucket holds ${f.ranked.filter((a) => a.ai_score >= 80).length} — that bucket is your real hiring pool.`, }, { tone: f.interviewCompletion >= 70 ? 'success' : 'warning', title: 'Interview Completion', body: `${f.completedInterviews.length} of ${f.interviews.length} fully scored (${f.interviewCompletion}%). Unscored means started and abandoned.`, }, { tone: 'success', title: 'Cost Saved', body: `$${f.costSaved.toLocaleString()} from ${plural(f.scored.length, 'screen')} at $45 and ${plural(f.interviews.length, 'interview')} at $80 — industry per-unit costs for doing it manually, so treat it as a floor.`, }, ]), f.bottleneck && [ heading('Biggest drop-off'), funnel(f.transitions.map((t) => ({ label: `${t.from} → ${t.to}`, count: t.rate, rate: t.rate }))), text(`Most people are lost between **${f.bottleneck.from}** and **${f.bottleneck.to}** — only ${f.bottleneck.rate}% pass through, costing ${plural(f.bottleneck.lost, 'candidate')}.`), ] ); const forecastHiring = (f) => { const available = f.ranked.filter((a) => a.ai_score >= 60 && !['hired', 'rejected'].includes(a.status)); const conversion = f.hireRate || 14; const expected = Math.round((available.length * conversion) / 100) || (available.length >= 3 ? 1 : 0); const short = f.openPositions.length - expected; return doc( heading('Forecast'), kpis([ { label: 'In play at 60+', value: available.length }, { label: 'Projected hires', value: expected, tone: short > 0 ? 'warning' : 'success' }, { label: 'Open roles', value: f.openPositions.length }, { label: 'Conversion', value: `${conversion}%` }, ]), short > 0 ? insights([{ tone: 'warning', title: `Roughly ${plural(short, 'role')} short`, body: `Two ways to close it: screen the ${f.unscreened.length} unscored applicants, or source directly from the ${plural(f.profiles.length, 'profile')} in the talent pool.`, }]) : insights([{ tone: 'success', title: 'Current pool covers your open roles', body: 'Assuming the strong candidates do not go elsewhere first.', }]), note('Projected from your own conversion rate, not a promise — small pools move a lot.') ); }; const departmentInsights = (f) => { if (!f.byRole.length) return answer('No open positions to break down.'); const byCategory = {}; f.byRole.forEach((r) => { byCategory[r.category] ||= { applied: 0, hired: 0, qualified: 0, roles: 0 }; byCategory[r.category].applied += r.applied; byCategory[r.category].hired += r.hired; byCategory[r.category].qualified += r.qualified; byCategory[r.category].roles += 1; }); const entries = Object.entries(byCategory).sort((a, b) => b[1].applied - a[1].applied); const starved = entries.filter(([, d]) => d.applied === 0); return doc( heading('By role category'), table( [ { key: 'category', label: 'Category' }, { key: 'applied', label: 'Applied', align: 'right' }, { key: 'qualified', label: '70+', align: 'right' }, { key: 'hired', label: 'Hired', align: 'right' }, ], entries.map(([category, d]) => ({ category, applied: { value: d.applied, tone: d.applied === 0 ? 'risk' : undefined }, qualified: d.qualified, hired: d.hired, })) ), text( starved.length ? `**${starved.map(([c]) => c).join(' and ')}** ${verb(starved.length, 'is', 'are')} attracting nobody. Compare the pay range against the categories that are filling.` : 'Every category has applicants, so your reach is working across the board.' ) ); }; const generateReports = (f) => doc( // Local date, not toISOString — UTC would show yesterday for anyone behind it. heading('Hiring report', f.today.toLocaleDateString(undefined, { year: 'numeric', month: 'long', day: 'numeric', })), kpis([ { label: 'Applicants', value: f.total }, { label: 'Screened', value: `${f.standardizedPct}%` }, { label: 'Hires', value: f.hired.length }, { label: 'Hire rate', value: `${f.hireRate}%` }, { label: 'Avg hire score', value: f.hiredAvgScore || '—', tone: 'success' }, { label: 'Time to hire', value: f.timeToHire ? `${f.timeToHire}d` : '—' }, ]), heading('Funnel'), funnel(f.funnel.map((s, i) => ({ label: s.label, count: s.count, rate: i === 0 ? undefined : f.transitions[i - 1]?.rate }))), heading('By role'), table( [ { key: 'title', label: 'Role' }, { key: 'applied', label: 'Applied', align: 'right' }, { key: 'qualified', label: '70+', align: 'right' }, { key: 'hired', label: 'Hired', align: 'right' }, ], f.byRole.map((r) => ({ title: r.title, applied: r.applied, qualified: r.qualified, hired: r.hired })) ), heading('Efficiency'), kpis([ { label: 'Cost saved', value: `$${f.costSaved.toLocaleString()}`, tone: 'success' }, { label: 'Recruiter hours', value: `${f.hoursSaved}h`, tone: 'success' }, ]), heading('Headline finding'), insights([ f.bottleneck ? { tone: 'warning', title: `${f.bottleneck.from} → ${f.bottleneck.to} passes only ${f.bottleneck.rate}%`, body: `Losing ${plural(f.bottleneck.lost, 'candidate')} — the single biggest recoverable loss in the funnel.`, } : { tone: 'success', title: 'No single bottleneck', body: 'The funnel is passing through evenly.' }, ]), buildActions(f).length && [heading('Recommended actions'), actions(buildActions(f).slice(0, 3))], note('Copy this into your own template — PDF and CSV export are not wired up in this build.') ); /* ── Free-text responders ───────────────────────────────────────────────── */ const has = (q, ...words) => words.some((w) => q.includes(w)); const stateOfPlay = (f) => doc( text('I do not have a specific read on that. Here is where things stand:'), kpis([ { label: 'Applicants', value: f.total }, { label: 'Screened', value: f.screened.length }, { label: 'Interviewing', value: f.interviewing.length }, { label: 'Hired', value: f.hired.length }, ]), text('Ask me about any of those and I will go deeper.') ); const bottleneckAnswer = (f) => { if (!f.bottleneck) return answer('Not enough pipeline movement yet to locate a bottleneck.'); const b = f.bottleneck; const diagnosis = { 'AI Screened': `${plural(f.unscreened.length, 'applicant')} ${verb(f.unscreened.length, 'has', 'have')} never been scored — the cheapest gap to close.`, Shortlisted: 'Candidates are screened but not shortlisted. Either the scores are not being read, or the bar is above what the pool can deliver.', Interviewed: 'Shortlisted candidates are not reaching interview — usually scheduling friction rather than a decision.', Hired: 'Interviews happen but offers are not closing. Check pay against market and how long the decision takes.', }[b.to]; return doc( heading(`Bottleneck: ${b.from} → ${b.to}`, `${b.rate}% pass through`), funnel(f.transitions.map((t) => ({ label: `${t.from} → ${t.to}`, count: t.rate, rate: t.rate }))), insights([{ tone: 'warning', title: `Losing ${plural(b.lost, 'candidate')} here`, body: diagnosis }]) ); }; export const respondOverview = (question, f) => { const q = question.toLowerCase(); if (has(q, 'risk', 'exposure', 'worry', 'concern')) return hiringRisks(f); if (has(q, 'attention', 'urgent', 'priorit', 'pending', 'what should i', 'next', 'slow')) return pendingActions(f); if (has(q, 'health', 'how are we', 'how is hiring', 'doing')) return hiringHealth(f); if (has(q, 'summar', 'today', 'brief', 'catch me up', 'overview', 'morning', 'afternoon', 'evening')) return hiringSummary(f); if (has(q, 'pipeline', 'funnel', 'stuck', 'bottleneck', 'drop')) return bottleneckAnswer(f); if (has(q, 'position', 'posting', 'role', 'opening')) return departmentInsights(f); return stateOfPlay(f); }; export const respondCandidates = (question, f) => { const q = question.toLowerCase(); if (has(q, 'compare', 'versus', ' vs ', 'between', '&')) return candidateComparison(f); if (has(q, 'question', 'ask them', 'interview prep')) return interviewQuestions(f); if (has(q, 'gap', 'missing', 'weakness', 'skill')) return skillGapAnalysis(f); if (has(q, 'recommend', 'should i hire', 'who should', 'shortlist', 'pass', 'interview next')) return hiringRecommendation(f); if (has(q, 'resume', 'résumé', 'cv', 'analyse', 'analyze', 'review')) return resumeAnalysis(f); if (has(q, 'unscreened', 'not screened', 'pending')) { return f.unscreened.length ? doc( heading(`${plural(f.unscreened.length, 'applicant')} unscreened`), table( [{ key: 'name', label: 'Candidate' }, { key: 'role', label: 'Applied for' }], f.unscreened.slice(0, 8).map((a) => ({ name: a.applicant_name, role: a.job_title })) ), f.unscreened.length > 8 && text(`…and ${f.unscreened.length - 8} more.`) ) : answer('Everyone has been screened.'); } return stateOfPlay(f); }; export const respondAnalytics = (question, f) => { const q = question.toLowerCase(); if (has(q, 'forecast', 'predict', 'projection', 'will i', 'expect')) return forecastHiring(f); if (has(q, 'report', 'export', 'executive', 'download')) return generateReports(f); if (has(q, 'bottleneck', 'drop', 'stuck', 'lose', 'why do')) return bottleneckAnswer(f); if (has(q, 'department', 'category', 'role', 'team', 'break down')) return departmentInsights(f); if (has(q, 'explain', 'what does', 'mean', 'chart', 'graph')) return explainCharts(f); if (has(q, 'hire rate', 'conversion')) { return answer(`${f.hireRate}% — ${plural(f.hired.length, 'hire')} from ${plural(f.total, 'applicant')}. Healthy for event staffing; the norm sits between 2% and 8% because most applicants are never properly screened.`); } if (has(q, 'cost', 'saved', 'roi', 'money')) { return doc( kpis([ { label: 'Cost saved', value: `$${f.costSaved.toLocaleString()}`, tone: 'success' }, { label: 'Hours returned', value: `${f.hoursSaved}h`, tone: 'success' }, ]), text(`From ${plural(f.scored.length, 'AI screen')} at the $45 a manual screen costs, plus ${plural(f.interviews.length, 'automated interview')} at $80 each.`) ); } return stateOfPlay(f); }; /* ── Capability manifests ───────────────────────────────────────────────── */ export const OVERVIEW_CAPABILITIES = [ { id: 'hiring-summary', label: 'Hiring Summary', icon: Sparkles, run: hiringSummary }, { id: 'hiring-health', label: 'Hiring Health', icon: HeartPulse, run: hiringHealth }, { id: 'pending-actions', label: 'Pending Actions', icon: ListChecks, run: pendingActions }, { id: 'hiring-risks', label: 'Hiring Risks', icon: AlertTriangle, run: hiringRisks }, ]; export const CANDIDATE_CAPABILITIES = [ { id: 'resume-analysis', label: 'Resume Analysis', icon: FileText, run: resumeAnalysis }, { id: 'candidate-comparison', label: 'Candidate Comparison', icon: GitCompare, run: candidateComparison }, { id: 'interview-questions', label: 'Interview Question Generator', icon: MessageSquareQuote, run: interviewQuestions }, { id: 'skill-gap', label: 'Skill Gap Analysis', icon: Target, run: skillGapAnalysis }, { id: 'hiring-recommendation', label: 'Hiring Recommendation', icon: ClipboardCheck, run: hiringRecommendation }, ]; export const ANALYTICS_CAPABILITIES = [ { id: 'explain-charts', label: 'Explain Charts', icon: BarChart3, run: explainCharts }, { id: 'forecast-hiring', label: 'Forecast Hiring', icon: LineChart, run: forecastHiring }, { id: 'department-insights', label: 'Department Insights', icon: Layers, run: departmentInsights }, { id: 'generate-reports', label: 'Generate Reports', icon: FileSpreadsheet, run: generateReports }, ];