import { AlertTriangle, BarChart3, ClipboardCheck, FileSpreadsheet, FileText, GitCompare, HeartPulse, LineChart, ListChecks, MessageSquareQuote, Sparkles, Target, Layers, } from 'lucide-react'; import { bullets, compose, heading, numbered, plural, verb } from '../insights'; /** * Employer capabilities — Overview, Candidates, Analytics. * * Each capability is a pure function of the fact sheet. Keeping them pure is * what allows the same code to run against local data today and against a * remote AI service later without the UI changing. */ /* ── Overview ───────────────────────────────────────────────────────────── */ const hiringSummary = (f) => { if (!f.total) return 'Nothing in the pipeline yet. Publish a position and I will start tracking it here.'; return compose( heading('Where hiring stands'), bullets([ `${plural(f.total, 'applicant')} across ${plural(f.openPositions.length, 'open position')}`, `${f.screened.length} screened · ${f.unscreened.length} awaiting a score`, `${plural(f.interviewing.length, 'candidate')} mid-interview`, `${plural(f.hired.length, 'hire')} averaging ${f.hiredAvgScore}/100`, ]), '', f.stalled.length ? `Most urgent: ${plural(f.stalled.length, 'candidate')} scored 80+ and ${verb(f.stalled.length, 'is', 'are')} still sitting at AI Screened.` : f.unscreened.length ? `Most urgent: ${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 being 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 compose( heading(`${passing} of ${checks.length} signals healthy`), '', checks.map((c) => `${c.ok ? '✓' : '⚠'} **${c.label}** — ${c.value}\n ${c.note}`).join('\n\n') ); }; const pendingActions = (f) => { const actions = []; if (f.stalled.length) { actions.push(`**Move your 80+ candidates.** ${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.`); } if (f.unscreened.length) { actions.push(`**Screen the backlog.** ${plural(f.unscreened.length, 'applicant')} ${verb(f.unscreened.length, 'has', 'have')} no score. One pass clears it.`); } if (f.starvedPositions.length) { actions.push(`**Fix the postings nobody applies to.** ${f.starvedPositions.map((p) => p.title).join(' and ')} — usually pay range or reach, not the description.`); } if (f.unratedStaff.length) { actions.push(`**Rate your hires.** ${plural(f.unratedStaff.length, 'person', 'people')} ${verb(f.unratedStaff.length, 'has', 'have')} no rating. Ratings sharpen future matching.`); } if (f.elite.length) { actions.push(`**Reach out directly.** ${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.`); } if (!actions.length) return 'Nothing pending. Everyone is screened, strong candidates are actioned, and every open role has applicants.'; return compose(heading('Pending actions'), '', numbered(actions.slice(0, 4))); }; const hiringRisks = (f) => { const risks = []; if (f.singleCandidateRoles.length) { risks.push(`**Single-candidate roles.** ${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.`); } if (f.stalled.length) { risks.push(`**Losing strong candidates to delay.** ${plural(f.stalled.length, 'candidate')} at 80+ ${verb(f.stalled.length, 'has', 'have')} been screened but not moved. This is where good hires quietly disappear.`); } if (f.missingCredentials.length) { risks.push(`**Compliance exposure.** ${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.`); } if (f.narrowAvailability.length) { risks.push(`**Availability risk.** ${plural(f.narrowAvailability.length, 'candidate')} listed one slot or none. ${verb(f.narrowAvailability.length, 'This is the placement', 'These are the placements')} most likely to fall through on the day.`); } if (f.starvedPositions.length) { risks.push(`**Unfillable as posted.** ${plural(f.starvedPositions.length, 'open role')} with zero applicants after being live.`); } if (f.flagged.length) { risks.push(`**Interview integrity.** ${plural(f.flagged.length, 'interview')} scored below full integrity. Worth a human re-interview before progressing.`); } if (!risks.length) return 'No material risks in the current pipeline: coverage is good, no role depends on a single candidate, and credentials check out.'; return compose( heading(`${plural(risks.length, 'risk')} to watch`), '', numbered(risks), '', '_Flags to check, not conclusions. Each is a question for a human._' ); }; /* ── Candidates ─────────────────────────────────────────────────────────── */ const resumeAnalysis = (f) => { const subject = f.top[0]; if (!subject) return 'No scored candidates yet. Screen the pipeline and I will analyse the strongest.'; return compose( heading(`${subject.applicant_name} — ${subject.ai_score}/100`), `${subject.job_title} · ${plural(subject.years_experience, 'year')} experience · ${subject.english_level} English`, '', subject.ai_summary || '', '', subject.ai_strengths?.length ? compose(heading('What holds up'), bullets(subject.ai_strengths)) : '', '', subject.ai_gaps?.length ? compose(heading('What to probe'), bullets(subject.ai_gaps)) : '', '', subject.certifications?.length ? `**Credentials:** ${subject.certifications.join(', ')} — verify before the first shift. A lapsed card is the most common surprise.` : '**No credentials on file.** Request these before scheduling.' ); }; const candidateComparison = (f) => { const pair = f.ranked.slice(0, 2); if (pair.length < 2) return 'I need at least two scored candidates to compare.'; const [a, b] = pair; const gap = a.ai_score - b.ai_score; const dims = ['experience', 'english', 'reliability', 'certifications', 'availability']; const rows = dims.map((dim) => { const av = a.score_breakdown?.[dim] ?? 0; const bv = b.score_breakdown?.[dim] ?? 0; const lead = av === bv ? 'tied' : (av > bv ? a : b).applicant_name.split(' ')[0]; return `${dim.charAt(0).toUpperCase() + dim.slice(1)} — ${av} vs ${bv} · ${lead}`; }); const biggest = dims.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, dims[0]); return compose( heading(`${a.applicant_name} (${a.ai_score}) vs ${b.applicant_name} (${b.ai_score})`), '', bullets(rows), '', 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}, driven mostly by ${biggest}.` ); }; const interviewQuestions = (f) => { const subject = f.stalled[0] || f.ranked[0]; if (!subject) return 'Screen a candidate and I will build questions around their specific gaps.'; const gaps = subject.ai_gaps || []; return compose( heading(`Questions for ${subject.applicant_name}`), 'Built from their gaps rather than a generic list, so the interview tests what the score could not.', '', numbered([ `Walk me through your busiest ${subject.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?', subject.certifications?.length ? `Your ${subject.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?', ]) ); }; const skillGapAnalysis = (f) => { if (!f.scored.length) return '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 'No recurring gaps across your scored candidates — the pool matches your postings well.'; return compose( heading('Recurring gaps'), '', bullets(ranked.map(([gap, count]) => `${gap} — ${count} of ${f.scored.length}`)), '', 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 'Nothing scored yet, so I have no basis for a recommendation.'; const open = (a) => !['hired', 'rejected'].includes(a.status); const shortlist = f.ranked.filter((a) => a.ai_score >= 80 && open(a)); const interview = f.ranked.filter((a) => a.ai_score >= 60 && a.ai_score < 80 && open(a)); const pass = f.weak.filter(open); return compose( heading('Recommendation'), '', shortlist.length ? `**Shortlist** — ${shortlist.map((a) => `${a.applicant_name} (${a.ai_score})`).join(', ')}` : '**Shortlist** — nobody currently clears 80.', '', interview.length ? `**Interview** — ${interview.map((a) => `${a.applicant_name} (${a.ai_score})`).join(', ')}` : '', '', pass.length ? `**Likely pass** — ${pass.map((a) => `${a.applicant_name} (${a.ai_score})`).join(', ')}` : '', '', '_A recommendation, not a decision. I score what is on file — I have not met these people._' ); }; /* ── Analytics ──────────────────────────────────────────────────────────── */ const explainCharts = (f) => compose( heading('What these charts are saying'), '', `**Pipeline Stages** — ${f.total} applied, ${f.screened.length} screened, ${f.hired.length} hired. Bars are cumulative, so "AI Screened" includes everyone who moved past it.`, '', `**Candidate Scores** — ${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.`, '', `**Interview Completion** — ${f.completedInterviews.length} of ${f.interviews.length} fully scored (${f.interviewCompletion}%). Unscored means started and abandoned.`, '', `**Cost Saved** — $${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.` ); 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 compose( heading('Forecast'), '', bullets([ `${plural(available.length, 'candidate')} at 60+ still in play`, `At your ${conversion}% hire rate that projects to about ${plural(expected, 'hire')}`, f.timeToHire ? `At a ${f.timeToHire}-day average, those land within a week of you deciding` : 'No time-to-hire baseline yet', `${plural(f.openPositions.length, 'open position')} to fill`, ]), '', short > 0 ? `That leaves you roughly ${plural(short, 'role')} short. 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.` : 'That covers your open roles, assuming the strong candidates do not go elsewhere first.', '', '_Projected from your own conversion rate, not a promise — small pools move a lot._' ); }; const departmentInsights = (f) => { if (!f.byRole.length) return 'No open positions to break down.'; const byCategory = {}; f.byRole.forEach((r) => { byCategory[r.category] ||= { applied: 0, hired: 0, qualified: 0, roles: [] }; byCategory[r.category].applied += r.applied; byCategory[r.category].hired += r.hired; byCategory[r.category].qualified += r.qualified; byCategory[r.category].roles.push(r.title); }); const entries = Object.entries(byCategory).sort((a, b) => b[1].applied - a[1].applied); const starved = entries.filter(([, d]) => d.applied === 0); return compose( heading('By role category'), '', entries.map(([category, d]) => `**${category}** — ${plural(d.applied, 'applicant')}, ${d.qualified} at 70+, ${plural(d.hired, 'hire')} across ${plural(d.roles.length, 'role')}` ).join('\n'), '', 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) => { const stamp = f.today.toISOString().slice(0, 10); return compose( heading(`Hiring report — ${stamp}`), '', heading('Volume'), bullets([ `${plural(f.total, 'applicant')} received`, `${f.screened.length} screened (${f.standardizedPct}% coverage)`, `${plural(f.interviews.length, 'AI interview')} conducted, ${f.completedInterviews.length} scored`, `${plural(f.hired.length, 'hire')} closed`, ]), '', heading('Quality'), bullets([ `Average candidate score ${f.avgScore}`, `Average hire score ${f.hiredAvgScore}`, `Hire rate ${f.hireRate}%`, `Average time-to-hire ${f.timeToHire ? `${f.timeToHire} days` : 'no baseline'}`, ]), '', heading('Efficiency'), bullets([ `$${f.costSaved.toLocaleString()} saved against manual screening and interviewing`, `≈${f.hoursSaved} recruiter hours returned`, ]), '', heading('By role'), bullets(f.byRole.map((r) => `${r.title} — ${r.applied} applied, ${r.qualified} at 70+, ${r.hired} hired`)), '', f.bottleneck ? `**Headline finding.** The ${f.bottleneck.from} → ${f.bottleneck.to} step passes only ${f.bottleneck.rate}%, losing ${plural(f.bottleneck.lost, 'candidate')}. That is the single biggest recoverable loss in the funnel.` : '**Headline finding.** The funnel is passing through evenly with no single bottleneck.', '', '_Copy this into your own template — export to PDF and CSV is not wired up in this build._' ); }; /* ── Free-text responders ───────────────────────────────────────────────── */ const has = (q, ...words) => words.some((w) => q.includes(w)); const stateOfPlay = (f) => `I do not have a specific read on that. What I can see: ${plural(f.total, 'applicant')} across ${plural(f.openPositions.length, 'open position')}, ${f.screened.length} screened, ${plural(f.interviewing.length, 'candidate')} interviewing, ${plural(f.hired.length, 'hire')}. Ask about any of those and I will go deeper.`; const bottleneckAnswer = (f) => { if (!f.bottleneck) return '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 in the funnel 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 compose( heading(`Bottleneck: ${b.from} → ${b.to}`), `Only ${b.rate}% pass through, losing ${plural(b.lost, 'candidate')}.`, '', diagnosis || '', '', bullets(f.transitions.map((t) => `${t.from} → ${t.to}: ${t.rate}%${t.lost ? ` (−${t.lost})` : ''}`)) ); }; 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')) return pendingActions(f); if (has(q, 'health', 'how are we', 'how is hiring', 'doing')) return hiringHealth(f); if (has(q, 'summary', 'today', 'brief', 'catch me up', 'overview')) 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, 'resume', 'résumé', 'cv', 'analyse', 'analyze')) return resumeAnalysis(f); 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, 'unscreened', 'not screened', 'pending')) { return f.unscreened.length ? compose( heading(`${plural(f.unscreened.length, 'applicant')} unscreened`), '', bullets(f.unscreened.slice(0, 8).map((a) => `${a.applicant_name} — ${a.job_title}`)), f.unscreened.length > 8 ? `…and ${f.unscreened.length - 8} more` : '' ) : 'Everyone has been screened.'; } return stateOfPlay(f); }; export const respondAnalytics = (question, f) => { const q = question.toLowerCase(); if (has(q, 'explain', 'what does', 'mean', 'chart', 'graph')) return explainCharts(f); if (has(q, 'forecast', 'predict', 'projection', 'will i')) return forecastHiring(f); if (has(q, 'department', 'category', 'role', 'team')) return departmentInsights(f); if (has(q, 'report', 'export', 'summar', 'download')) return generateReports(f); if (has(q, 'bottleneck', 'drop', 'stuck', 'funnel', 'lose')) return bottleneckAnswer(f); if (has(q, 'hire rate', 'conversion')) { return `${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 `$${f.costSaved.toLocaleString()} — ${plural(f.scored.length, 'AI screen')} at the $45 a manual screen costs, plus ${plural(f.interviews.length, 'automated interview')} at $80 each. Also about ${f.hoursSaved} recruiter hours you did not spend.`; } 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 }, ];