2143 lines
91 KiB
JavaScript
2143 lines
91 KiB
JavaScript
import {
|
||
Activity, AlertTriangle, ArrowLeftRight, Award, BellRing, Clock, Eye, FileText, Filter, Gauge,
|
||
GitBranch, Layers, Lightbulb, Lock, ScrollText, ShieldAlert, ShieldCheck, Star, Target,
|
||
TrendingUp, UserCheck, Users,
|
||
} from 'lucide-react';
|
||
import {
|
||
actions, answer, doc, funnel, heading, insights, kpis, list, meters, note, status, table,
|
||
text, timeline,
|
||
} from '../blocks';
|
||
import { pct, plural, verb } from '../insights';
|
||
/* The proof-method wording is the Forge page's own, so a skill is never named
|
||
one thing in the library and another in the panel beside it. */
|
||
import { TYPE_LABEL } from '@/components/forge/challengeMeta';
|
||
import { toFICO } from '@/lib/talentHome';
|
||
/* The vetting criteria's own labels. Read from the position model rather than
|
||
restated here, so the panel calls a weight what the form calls it. */
|
||
import { CRITERIA_LABELS } from '@/lib/positionModel';
|
||
import { PERMISSIONS, PROFILE_ACTIONS } from '@/lib/admin/permissions';
|
||
|
||
/**
|
||
* Admin capabilities — Control Center, Candidates Analysis, Activity.
|
||
*
|
||
* Platform-wide rather than pipeline-specific: data integrity, talent supply,
|
||
* audit reconciliation.
|
||
*/
|
||
|
||
/* ── Control Center ─────────────────────────────────────────────────────── */
|
||
|
||
const platformHealth = (f) => {
|
||
const checks = [
|
||
{
|
||
label: 'Data integrity',
|
||
ok: f.applications.every((a) => a.job_posting_id),
|
||
good: 'Every application links to a live posting.',
|
||
bad: 'Some applications reference a posting that no longer exists.',
|
||
},
|
||
{
|
||
label: 'Screening coverage',
|
||
value: `${f.standardizedPct}%`,
|
||
ok: f.standardizedPct >= 80,
|
||
good: 'Applicants are scored consistently.',
|
||
bad: `${plural(f.unscreened.length, 'record')} unstandardized.`,
|
||
},
|
||
{
|
||
label: 'Interview integrity',
|
||
ok: f.flagged.length === 0,
|
||
good: 'No interviews flagged.',
|
||
bad: `${plural(f.flagged.length, 'interview')} below full integrity.`,
|
||
},
|
||
{
|
||
label: 'Profile completeness',
|
||
value: `${f.profiles.length - f.unscoredProfiles.length}/${f.profiles.length}`,
|
||
ok: f.unscoredProfiles.length <= f.profiles.length / 2,
|
||
good: 'Most talent profiles carry a career score.',
|
||
bad: `${plural(f.unscoredProfiles.length, 'profile')} without a score cannot be matched or ranked.`,
|
||
},
|
||
{
|
||
label: 'Throughput',
|
||
value: `${f.hireRate}%`,
|
||
ok: f.hired.length > 0,
|
||
good: `${plural(f.hired.length, 'hire')} closed.`,
|
||
bad: 'No hires closed yet, so there is no conversion baseline.',
|
||
},
|
||
].map((c) => ({ label: c.label, value: c.value, ok: c.ok, note: c.ok ? c.good : c.bad }));
|
||
|
||
const passing = checks.filter((c) => c.ok).length;
|
||
|
||
return doc(
|
||
heading(`Platform health: ${passing}/${checks.length} passing`),
|
||
status(checks),
|
||
heading('Scale'),
|
||
kpis([
|
||
{ label: 'Positions', value: f.postings.length, sub: `${f.openPositions.length} open` },
|
||
{ label: 'Applications', value: f.total },
|
||
{ label: 'Interviews', value: f.interviews.length, sub: `${f.completedInterviews.length} scored` },
|
||
{ label: 'Talent pool', value: f.profiles.length, sub: `${f.elite.length} Elite` },
|
||
]),
|
||
text(
|
||
passing === checks.length
|
||
? 'Nothing needs intervention at the platform level.'
|
||
: 'The failing checks are data-quality issues, not outages — they degrade matching quality rather than break anything.'
|
||
)
|
||
);
|
||
};
|
||
|
||
/**
|
||
* Workforce summary — the platform's scale in one read.
|
||
*
|
||
* Answers "how big is this thing and what is it made of", which is a different
|
||
* question from "is it healthy": no checks, no verdicts, just the composition.
|
||
*/
|
||
const workforceSummary = (f) => doc(
|
||
heading('Workforce summary', `${plural(f.postings.length, 'position')} · ${plural(f.profiles.length, 'worker')}`),
|
||
kpis([
|
||
{ label: 'Open roles', value: f.openPositions.length, sub: `of ${f.postings.length}` },
|
||
{ label: 'Applicants', value: f.total, sub: `${f.scored.length} scored` },
|
||
{ label: 'On staff', value: f.staff.length, sub: `${f.hired.length} hired here` },
|
||
{ label: 'Talent pool', value: f.profiles.length, sub: `${f.elite.length} Elite` },
|
||
]),
|
||
heading('Where the people are'),
|
||
meters([
|
||
{ label: 'In the pipeline', value: f.total, max: f.total + f.profiles.length },
|
||
{ label: 'In the talent pool', value: f.profiles.length, max: f.total + f.profiles.length },
|
||
{ label: 'Placed on staff', value: f.staff.length, max: f.total + f.profiles.length, tone: 'success' },
|
||
]),
|
||
heading('Demand by role category'),
|
||
table(
|
||
[
|
||
{ key: 'category', label: 'Category' },
|
||
{ key: 'roles', label: 'Roles', align: 'right' },
|
||
{ key: 'applied', label: 'Applicants', align: 'right' },
|
||
],
|
||
Object.values(f.byRole.reduce((acc, r) => {
|
||
acc[r.category] ||= { category: r.category, roles: 0, applied: 0 };
|
||
acc[r.category].roles += 1;
|
||
acc[r.category].applied += r.applied;
|
||
return acc;
|
||
}, {})).sort((a, b) => b.applied - a.applied)
|
||
),
|
||
text(
|
||
f.profiles.length > f.total
|
||
? `The talent pool is larger than the live pipeline — ${f.profiles.length} registered workers against ${f.total} applications. Most of the supply is not applying to anything.`
|
||
: 'Most of the platform\'s people are attached to a live application rather than sitting in the pool.'
|
||
)
|
||
);
|
||
|
||
/**
|
||
* Hiring operations — throughput and speed.
|
||
*
|
||
* The operational question is whether work is moving, so this leads with rate
|
||
* and duration rather than totals.
|
||
*/
|
||
const hiringOperations = (f) => {
|
||
const perRole = f.byRole
|
||
.slice()
|
||
.sort((a, b) => b.applied - a.applied)
|
||
.map((r) => ({
|
||
title: r.title,
|
||
applied: r.applied,
|
||
qualified: r.qualified,
|
||
hired: r.hired || '—',
|
||
rate: { value: `${r.applied ? Math.round((r.qualified / r.applied) * 100) : 0}%`, tone: r.applied && r.qualified / r.applied >= 0.5 ? 'success' : 'warning' },
|
||
}));
|
||
|
||
return doc(
|
||
heading('Hiring operations'),
|
||
kpis([
|
||
{ label: 'Applicants', value: f.total },
|
||
{ label: 'Hires', value: f.hired.length, tone: 'success', sub: `${f.hireRate}% of applicants` },
|
||
{ label: 'Time to hire', value: f.timeToHire ? `${f.timeToHire}d` : '—' },
|
||
{ label: 'Screening', value: `${f.standardizedPct}%`, tone: f.standardizedPct >= 80 ? 'success' : 'warning', sub: 'coverage' },
|
||
]),
|
||
heading('Throughput 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' },
|
||
{ key: 'rate', label: 'Qualified', align: 'right' },
|
||
],
|
||
perRole
|
||
),
|
||
insights([
|
||
f.starvedPositions.length && {
|
||
tone: 'warning',
|
||
title: `${plural(f.starvedPositions.length, 'role')} attracting nobody`,
|
||
body: `${f.starvedPositions.map((p) => p.title).join(', ')}. Throughput cannot improve on a role with no input.`,
|
||
},
|
||
f.timeToHire > 0 && {
|
||
tone: f.timeToHire <= 7 ? 'success' : 'warning',
|
||
title: `Hires close in ${plural(f.timeToHire, 'day')}`,
|
||
body: f.timeToHire <= 7
|
||
? 'Fast enough that strong candidates are unlikely to be lost to a competing offer.'
|
||
: 'Long enough that the best candidates may accept elsewhere first.',
|
||
},
|
||
]),
|
||
note('Time-to-hire is measured from application to decision, so it reflects this platform\'s part of the process only.')
|
||
);
|
||
};
|
||
|
||
/**
|
||
* Pipeline health — the funnel and its worst step.
|
||
*
|
||
* A funnel block rather than a table: the shape is the finding, and the single
|
||
* weakest transition is what someone can actually act on.
|
||
*/
|
||
const pipelineHealth = (f) => {
|
||
if (!f.total) return answer('Nothing has entered the pipeline yet, so there is no funnel to read.');
|
||
|
||
const worst = f.bottleneck;
|
||
|
||
return doc(
|
||
heading('Pipeline health', `${plural(f.total, 'candidate')} entered`),
|
||
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,
|
||
}))),
|
||
worst && worst.lost > 0
|
||
? insights([{
|
||
tone: worst.rate < 50 ? 'risk' : 'warning',
|
||
title: `${worst.from} → ${worst.to} is the bottleneck`,
|
||
body: `Only ${worst.rate}% pass through, losing ${plural(worst.lost, 'candidate')}. Every other step performs better, so this is where recovered conversion is cheapest.`,
|
||
}])
|
||
: insights([{
|
||
tone: 'success',
|
||
title: 'No single step is losing candidates',
|
||
body: 'Conversion is even across the funnel — there is no cheap win to recover here.',
|
||
}]),
|
||
heading('Stage-to-stage rates'),
|
||
table(
|
||
[
|
||
{ key: 'step', label: 'Transition' },
|
||
{ key: 'rate', label: 'Pass', align: 'right' },
|
||
{ key: 'lost', label: 'Lost', align: 'right' },
|
||
],
|
||
f.transitions.map((t) => ({
|
||
step: `${t.from} → ${t.to}`,
|
||
rate: { value: `${t.rate}%`, tone: t.rate < 60 ? 'warning' : 'success' },
|
||
lost: t.lost ? { value: `−${t.lost}`, tone: 'risk' } : '0',
|
||
}))
|
||
)
|
||
);
|
||
};
|
||
|
||
/**
|
||
* Attention required — the triage queue, ordered by cost of inaction.
|
||
*
|
||
* The same items the Control Center lists, because the panel must never rank
|
||
* things differently from the page beside it.
|
||
*/
|
||
const attentionRequired = (f) => {
|
||
const items = [
|
||
f.starvedPositions.length && {
|
||
tone: 'risk',
|
||
title: `${plural(f.starvedPositions.length, 'role')} with no candidate supply`,
|
||
body: `${f.starvedPositions.map((p) => p.title).join(', ')}. Live and attracting nobody — usually a pay band or distribution problem.`,
|
||
},
|
||
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 sitting at AI Screened. The most expensive kind of delay.`,
|
||
},
|
||
f.unscreened.length && {
|
||
tone: 'warning',
|
||
title: `${plural(f.unscreened.length, 'applicant')} waiting on screening`,
|
||
body: 'Each unscored application is a decision made without a standard.',
|
||
},
|
||
f.unscoredProfiles.length && {
|
||
tone: 'warning',
|
||
title: `${plural(f.unscoredProfiles.length, 'talent profile')} without a score`,
|
||
body: 'Invisible to matching, so employers cannot find them even when they fit.',
|
||
},
|
||
f.unratedStaff.length && {
|
||
tone: 'info',
|
||
title: `${plural(f.unratedStaff.length, 'hire')} awaiting review`,
|
||
body: 'Employer ratings are the only verified performance signal feeding scores.',
|
||
},
|
||
f.flagged.length && {
|
||
tone: 'warning',
|
||
title: `${plural(f.flagged.length, 'interview')} flagged for integrity`,
|
||
body: 'Worth clearing before those candidates progress.',
|
||
},
|
||
].filter(Boolean);
|
||
|
||
if (!items.length) {
|
||
return answer('Nothing needs intervention. Every open role has candidates, every applicant is scored, and no interview is flagged.');
|
||
}
|
||
|
||
return doc(
|
||
heading(`${plural(items.length, 'item')} need attention`, 'Ordered by cost of leaving it'),
|
||
insights(items),
|
||
text(`Start with ${items[0].title.toLowerCase()} — it is the one where waiting costs the most.`)
|
||
);
|
||
};
|
||
|
||
const recommendations = (f) => {
|
||
const items = [
|
||
f.unscoredProfiles.length && {
|
||
title: `Prompt ${plural(f.unscoredProfiles.length, 'worker')} to finish onboarding`,
|
||
body: 'Without a career score they are invisible to matching, so employers cannot find them even when they fit.',
|
||
},
|
||
f.unscreened.length && {
|
||
title: `${plural(f.unscreened.length, 'application')} unscored`,
|
||
body: 'Each one is a decision made without a standard.',
|
||
},
|
||
f.starvedPositions.length && {
|
||
title: `${plural(f.starvedPositions.length, 'posting')} with zero applicants`,
|
||
body: `${f.starvedPositions.map((p) => p.title).join(', ')}. Check distribution and pay bands.`,
|
||
},
|
||
f.unratedStaff.length && {
|
||
title: `${plural(f.unratedStaff.length, 'hire')} unrated`,
|
||
body: 'Employer ratings are the only verified performance signal feeding scores; without them the scores stay theoretical.',
|
||
},
|
||
f.flagged.length && {
|
||
title: `Review ${plural(f.flagged.length, 'flagged interview')}`,
|
||
body: 'Worth clearing before those candidates progress.',
|
||
},
|
||
].filter(Boolean);
|
||
|
||
if (!items.length) return answer('No platform actions outstanding. Data is complete and nothing is flagged.');
|
||
|
||
return doc(heading('Recommended actions', 'Ordered by impact on matching quality'), actions(items));
|
||
};
|
||
|
||
/* ── Candidates Analysis ────────────────────────────────────────────────── */
|
||
|
||
const recruitmentInsights = (f) => {
|
||
const band = (min, max) => f.profiles.filter((p) => {
|
||
const s = p.krow_score || 0;
|
||
return s >= min && (max == null || s < max);
|
||
}).length;
|
||
|
||
const bands = [
|
||
{ label: 'Elite', count: band(90), tone: 'info' },
|
||
{ label: 'Excellent', count: band(75, 90), tone: 'success' },
|
||
{ label: 'Solid', count: band(60, 75) },
|
||
{ label: 'Building', count: band(1, 60), tone: 'warning' },
|
||
{ label: 'Unscored', count: f.unscoredProfiles.length, tone: 'risk' },
|
||
];
|
||
|
||
const verified = f.profiles.filter((p) => (p.capabilities || []).length).length;
|
||
const topHeavy = band(90) + band(75, 90);
|
||
|
||
return doc(
|
||
heading('Talent supply', `${plural(f.profiles.length, 'worker')} in the pool`),
|
||
table(
|
||
[{ key: 'label', label: 'Band' }, { key: 'count', label: 'Workers', align: 'right' }],
|
||
bands.map((b) => ({ label: b.label, count: { value: b.count, tone: b.tone } }))
|
||
),
|
||
heading('Pipeline'),
|
||
kpis([
|
||
{ label: 'Applicants', value: f.total, sub: `avg ${f.avgScore}` },
|
||
{ label: 'At 80+', value: f.ranked.filter((a) => a.ai_score >= 80).length, tone: 'success' },
|
||
{ label: 'Verified skills', value: verified, sub: 'with evidence' },
|
||
{ label: 'Applications', value: f.eventCounts.apply_job || 0, sub: 'logged' },
|
||
]),
|
||
insights([
|
||
f.unscoredProfiles.length > topHeavy
|
||
? {
|
||
tone: 'warning',
|
||
title: 'The pool is bottom-heavy',
|
||
body: `${f.unscoredProfiles.length} unscored against ${topHeavy} at Excellent or above. Supply is not the constraint — verification is.`,
|
||
}
|
||
: {
|
||
tone: 'success',
|
||
title: 'Verification is keeping pace',
|
||
body: 'The scored portion of the pool outweighs the unscored.',
|
||
},
|
||
]),
|
||
heading('By role category'),
|
||
table(
|
||
[
|
||
{ key: 'title', label: 'Role' },
|
||
{ key: 'applied', label: 'Applied', align: 'right' },
|
||
{ key: 'qualified', label: '70+', align: 'right' },
|
||
],
|
||
f.byRole.slice().sort((a, b) => b.applied - a.applied)
|
||
.map((r) => ({ title: r.title, applied: r.applied, qualified: r.qualified }))
|
||
)
|
||
);
|
||
};
|
||
|
||
const candidateRiskAnalysis = (f) => {
|
||
const noHistory = f.scored.filter((a) => !a.years_experience);
|
||
|
||
const risks = [
|
||
f.missingCredentials.length && {
|
||
tone: 'risk',
|
||
title: `${plural(f.missingCredentials.length, 'candidate')} with no credentials`,
|
||
body: 'Compliance risk if placed on a role that requires certification.',
|
||
},
|
||
f.narrowAvailability.length && {
|
||
tone: 'warning',
|
||
title: `${plural(f.narrowAvailability.length, 'candidate')} with narrow availability`,
|
||
body: 'One slot or none — most likely to fall through on the day.',
|
||
},
|
||
f.flagged.length && {
|
||
tone: 'risk',
|
||
title: `${plural(f.flagged.length, 'interview')} flagged for integrity`,
|
||
body: 'Usually suspiciously fast responses. Worth a human re-interview.',
|
||
},
|
||
noHistory.length && {
|
||
tone: 'warning',
|
||
title: `${plural(noHistory.length, 'candidate')} with no recorded experience`,
|
||
body: 'Not disqualifying, but unverifiable on paper.',
|
||
},
|
||
f.singleCandidateRoles.length && {
|
||
tone: 'warning',
|
||
title: 'Concentration risk',
|
||
body: `${f.singleCandidateRoles.map((e) => e.posting.title).join(', ')} ${verb(f.singleCandidateRoles.length, 'depends', 'depend')} on a single viable candidate.`,
|
||
},
|
||
].filter(Boolean);
|
||
|
||
if (!risks.length) {
|
||
return answer('No material risk flags: credentials, availability and interview integrity all check out across the scored pool.');
|
||
}
|
||
|
||
return doc(
|
||
heading(`${plural(risks.length, 'risk flag')}`),
|
||
insights(risks),
|
||
note('Flags to check, not judgements. Each is a question for a human, not a rejection.')
|
||
);
|
||
};
|
||
|
||
/**
|
||
* Screening gaps — where the record is incomplete.
|
||
*
|
||
* Distinct from risk: risk is what the data says about a candidate, this is what
|
||
* the data fails to say at all. An unscored applicant is not a weak applicant,
|
||
* and conflating the two is how good people get passed over.
|
||
*/
|
||
const screeningGaps = (f) => {
|
||
const gaps = [
|
||
{
|
||
label: 'Unscored applications',
|
||
count: f.unscreened.length,
|
||
note: 'Cannot be ranked or compared against anyone.',
|
||
},
|
||
{
|
||
label: 'Scored without credentials',
|
||
count: f.missingCredentials.length,
|
||
note: 'No certifications on file to verify against a role requirement.',
|
||
},
|
||
{
|
||
label: 'No recorded experience',
|
||
count: f.scored.filter((a) => !a.years_experience).length,
|
||
note: 'Scored on other dimensions, unverifiable on history.',
|
||
},
|
||
{
|
||
label: 'Interviews started, not scored',
|
||
count: f.interviews.length - f.completedInterviews.length,
|
||
note: 'Abandoned before a verdict, so the interview signal is missing.',
|
||
},
|
||
{
|
||
label: 'Availability not set',
|
||
count: f.narrowAvailability.length,
|
||
note: 'One slot or none recorded, so scheduling is guesswork.',
|
||
},
|
||
].filter((g) => g.count > 0);
|
||
|
||
if (!gaps.length) {
|
||
return answer('No screening gaps. Every applicant is scored, credentialed and has availability recorded, and every interview reached a verdict.');
|
||
}
|
||
|
||
return doc(
|
||
heading('Screening gaps', `${plural(gaps.length, 'kind')} of missing data`),
|
||
table(
|
||
[
|
||
{ key: 'label', label: 'Gap' },
|
||
{ key: 'count', label: 'Records', align: 'right' },
|
||
],
|
||
gaps.map((g) => ({ label: g.label, count: { value: g.count, tone: 'warning' } }))
|
||
),
|
||
heading('Coverage'),
|
||
status([
|
||
{
|
||
label: 'Screening coverage',
|
||
value: `${f.standardizedPct}%`,
|
||
ok: f.standardizedPct >= 80,
|
||
note: f.standardizedPct >= 80
|
||
? 'Enough of the pool is scored for comparisons to mean something.'
|
||
: `${plural(f.unscreened.length, 'applicant')} outside the standard.`,
|
||
},
|
||
{
|
||
label: 'Interview completion',
|
||
value: `${f.interviewCompletion}%`,
|
||
ok: f.interviewCompletion >= 80,
|
||
note: f.interviewCompletion >= 80
|
||
? 'Most interviews reach a verdict.'
|
||
: 'A meaningful share of interviews stop before scoring.',
|
||
},
|
||
]),
|
||
list(gaps.map((g) => `**${g.label}** — ${g.note}`)),
|
||
note('A gap is missing evidence, not a negative finding. These candidates are unassessed, not unsuitable.')
|
||
);
|
||
};
|
||
|
||
/**
|
||
* Hiring recommendations — who to move on, and in what order.
|
||
*
|
||
* Named people with a stated reason. A recommendation that does not name anyone
|
||
* is an observation, and the Admin already has plenty of those.
|
||
*/
|
||
const hiringRecommendations = (f) => {
|
||
if (!f.scored.length) {
|
||
return answer('Nothing is scored yet, so there is no basis for a recommendation. Screen the pipeline first.');
|
||
}
|
||
|
||
const advance = f.ranked.filter((a) => a.ai_score >= 80 && a.status === 'ai_screened').slice(0, 4);
|
||
const interview = f.ranked.filter((a) => a.ai_score >= 60 && a.ai_score < 80 && a.status === 'ai_screened').slice(0, 4);
|
||
const review = f.unscreened.slice(0, 4);
|
||
|
||
const rows = [
|
||
...advance.map((a) => ({
|
||
name: a.applicant_name,
|
||
role: a.job_title,
|
||
score: { value: a.ai_score, tone: 'success' },
|
||
action: { badge: 'Advance', tone: 'success' },
|
||
})),
|
||
...interview.map((a) => ({
|
||
name: a.applicant_name,
|
||
role: a.job_title,
|
||
score: { value: a.ai_score, tone: 'info' },
|
||
action: { badge: 'Interview', tone: 'info' },
|
||
})),
|
||
...review.map((a) => ({
|
||
name: a.applicant_name,
|
||
role: a.job_title,
|
||
score: '—',
|
||
action: { badge: 'Screen', tone: 'warning' },
|
||
})),
|
||
];
|
||
|
||
return doc(
|
||
heading('Recommended next actions', `${plural(rows.length, 'candidate')}`),
|
||
table(
|
||
[
|
||
{ key: 'name', label: 'Candidate' },
|
||
{ key: 'role', label: 'Role' },
|
||
{ key: 'score', label: 'Score', align: 'right' },
|
||
{ key: 'action', label: 'Action', align: 'right' },
|
||
],
|
||
rows
|
||
),
|
||
actions([
|
||
advance.length && {
|
||
title: `Advance ${plural(advance.length, 'candidate')} scoring 80 or above`,
|
||
body: `${advance.map((a) => a.applicant_name).join(', ')}. Already screened and waiting — the delay, not the assessment, is what loses these people.`,
|
||
},
|
||
review.length && {
|
||
title: `Screen ${plural(review.length, 'waiting applicant')}`,
|
||
body: 'Until they are scored they cannot be compared with anyone already in the funnel.',
|
||
},
|
||
f.singleCandidateRoles.length && {
|
||
title: `Widen supply on ${plural(f.singleCandidateRoles.length, 'role')}`,
|
||
body: `${f.singleCandidateRoles.map((e) => e.posting.title).join(', ')} ${verb(f.singleCandidateRoles.length, 'rests', 'rest')} on one viable candidate. One withdrawal reopens the search.`,
|
||
},
|
||
]),
|
||
note('Ranked on screening data only. Nothing here accounts for a conversation you have had that the platform has not seen.')
|
||
);
|
||
};
|
||
|
||
/**
|
||
* Top candidates side by side.
|
||
*
|
||
* A comparison is a table — the whole point is reading the same dimension across
|
||
* people, which prose makes impossible.
|
||
*/
|
||
const topCandidates = (f) => {
|
||
const top = f.ranked.slice(0, 5);
|
||
if (!top.length) return answer('No scored candidates yet, so there is nobody to compare.');
|
||
|
||
const DIMENSIONS = ['experience', 'english', 'reliability', 'certifications', 'availability'];
|
||
|
||
return doc(
|
||
heading('Strongest candidates', `Top ${top.length} by KROW score`),
|
||
table(
|
||
[
|
||
{ key: 'name', label: 'Candidate' },
|
||
{ key: 'role', label: 'Applied for' },
|
||
{ key: 'score', label: 'Score', align: 'right' },
|
||
{ key: 'stage', label: 'Stage', align: 'right' },
|
||
],
|
||
top.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: a.status === 'hired' ? 'success' : 'neutral' },
|
||
}))
|
||
),
|
||
heading('Score breakdown', top[0].applicant_name),
|
||
meters(DIMENSIONS.map((d) => ({
|
||
label: d[0].toUpperCase() + d.slice(1),
|
||
value: top[0].score_breakdown?.[d] ?? 0,
|
||
max: 100,
|
||
}))),
|
||
insights([
|
||
top.length >= 2 && {
|
||
tone: 'info',
|
||
title: `${top[0].applicant_name} leads by ${top[0].ai_score - top[1].ai_score} points`,
|
||
body: top[0].ai_score - top[1].ai_score <= 5
|
||
? `Effectively level with ${top[1].applicant_name} — close enough that the choice should come down to a conversation, not the score.`
|
||
: `A clear enough margin over ${top[1].applicant_name} to act on.`,
|
||
},
|
||
f.stalled.length && {
|
||
tone: 'warning',
|
||
title: `${plural(f.stalled.length, 'candidate')} at 80+ waiting on a decision`,
|
||
body: f.stalled.map((a) => a.applicant_name).join(', '),
|
||
},
|
||
])
|
||
);
|
||
};
|
||
|
||
/* ── Activity ───────────────────────────────────────────────────────────── */
|
||
|
||
const auditSummary = (f) => {
|
||
const count = (type) => f.activity.filter((e) => e.event_type === type).length;
|
||
const hires = count('hire_candidate');
|
||
const screens = count('screen_candidate');
|
||
|
||
return doc(
|
||
heading('Audit summary'),
|
||
kpis([
|
||
{ label: 'Total events', value: f.activity.length },
|
||
{ label: 'Last 7 days', value: f.activity7d.length },
|
||
{ label: 'Last 24 hours', value: f.activity24h.length, tone: f.activity24h.length ? undefined : 'warning' },
|
||
{ label: 'Distinct users', value: new Set(f.activity.map((e) => e.user_email)).size },
|
||
]),
|
||
heading('Privileged actions'),
|
||
table(
|
||
[{ key: 'action', label: 'Action' }, { key: 'count', label: 'Events', align: 'right' }],
|
||
[
|
||
{ action: 'Position created', count: count('create_position') },
|
||
{ action: 'Candidate screened', count: screens },
|
||
{ action: 'Candidate hired', count: hires },
|
||
{ action: 'Interview started', count: count('start_interview') },
|
||
]
|
||
),
|
||
heading('Reconciliation'),
|
||
status([
|
||
{
|
||
label: 'Hires match staff records',
|
||
ok: hires === f.staff.length,
|
||
value: `${hires}/${f.staff.length}`,
|
||
note: hires === f.staff.length
|
||
? 'Every person on staff has a matching hire event.'
|
||
: `${Math.abs(f.staff.length - hires)} unexplained.`,
|
||
},
|
||
{
|
||
label: 'Screening events match scores',
|
||
ok: screens >= f.scored.length,
|
||
value: `${screens}/${f.scored.length}`,
|
||
note: screens < f.scored.length
|
||
? 'Batch screening records one event per run, not per candidate.'
|
||
: 'Screening events reconcile with scored candidates.',
|
||
},
|
||
]),
|
||
note('An audit trail is only as good as its gaps. The reconciliation above is what I would check first in a real review.')
|
||
);
|
||
};
|
||
|
||
const userActivityInsights = (f) => {
|
||
const perUser = f.activity.reduce((acc, e) => {
|
||
acc[e.user_email] ||= { name: e.user_name, role: e.account_type, count: 0, types: new Set() };
|
||
acc[e.user_email].count += 1;
|
||
acc[e.user_email].types.add(e.event_type);
|
||
return acc;
|
||
}, {});
|
||
|
||
const ranked = Object.entries(perUser).sort((a, b) => b[1].count - a[1].count);
|
||
if (!ranked.length) return answer('No activity to profile yet.');
|
||
|
||
const [topEmail, top] = ranked[0];
|
||
const share = Math.round((top.count / f.activity.length) * 100);
|
||
|
||
return doc(
|
||
heading('Most active accounts'),
|
||
table(
|
||
[
|
||
{ key: 'name', label: 'Account' },
|
||
{ key: 'role', label: 'Role' },
|
||
{ key: 'events', label: 'Events', align: 'right' },
|
||
],
|
||
ranked.slice(0, 6).map(([email, d]) => ({
|
||
name: d.name || email,
|
||
role: { badge: d.role || 'unknown', tone: d.role === 'employer' ? 'info' : 'neutral' },
|
||
events: { value: d.count, tone: d.count === top.count ? 'info' : undefined },
|
||
}))
|
||
),
|
||
insights([
|
||
share >= 50
|
||
? {
|
||
tone: 'warning',
|
||
title: `One account is ${share}% of all activity`,
|
||
body: `${top.name || topEmail} accounts for ${top.count} of ${f.activity.length} events. On a live platform, that concentration is worth confirming is a person and not an integration.`,
|
||
}
|
||
: {
|
||
tone: 'success',
|
||
title: 'Activity is spread across accounts',
|
||
body: `No single account dominates the log — the busiest is ${share}%.`,
|
||
},
|
||
f.activity24h.length === 0 && {
|
||
tone: 'warning',
|
||
title: 'Nothing in the last 24 hours',
|
||
body: 'Expected for a demo dataset; on a live platform it would be worth checking ingestion.',
|
||
},
|
||
])
|
||
);
|
||
};
|
||
|
||
/**
|
||
* Privileged actions carry more audit weight than reads. Kept in step with the
|
||
* Activity page's own severity table — one definition of "privileged", so the
|
||
* panel and the log can never disagree about what matters.
|
||
*/
|
||
const HIGH_SEVERITY = ['hire_candidate', 'create_position'];
|
||
const MEDIUM_SEVERITY = ['assign_employee', 'screen_candidate', 'start_interview'];
|
||
|
||
/**
|
||
* Unusual activity — what is out of pattern, stated as a pattern.
|
||
*
|
||
* Deliberately not "suspicious". These are deviations from the platform's own
|
||
* baseline, and on a real deployment most of them turn out to be an integration
|
||
* or a busy afternoon. Saying otherwise would manufacture alarm.
|
||
*/
|
||
const unusualActivity = (f) => {
|
||
if (!f.activity.length) return answer('No activity logged yet, so there is no baseline to deviate from.');
|
||
|
||
const perUser = f.activity.reduce((acc, e) => {
|
||
acc[e.user_email] ||= { name: e.user_name, count: 0, privileged: 0, types: new Set() };
|
||
acc[e.user_email].count += 1;
|
||
acc[e.user_email].types.add(e.event_type);
|
||
if (HIGH_SEVERITY.includes(e.event_type)) acc[e.user_email].privileged += 1;
|
||
return acc;
|
||
}, {});
|
||
|
||
const accounts = Object.entries(perUser);
|
||
const busiest = accounts.slice().sort((a, b) => b[1].count - a[1].count)[0];
|
||
const share = busiest ? Math.round((busiest[1].count / f.activity.length) * 100) : 0;
|
||
|
||
/* Bursts: more than three events from one account inside an hour. */
|
||
const byHour = f.activity.reduce((acc, e) => {
|
||
const key = `${e.user_email}|${String(e.created_date).slice(0, 13)}`;
|
||
acc[key] = (acc[key] || 0) + 1;
|
||
return acc;
|
||
}, {});
|
||
const bursts = Object.entries(byHour).filter(([, n]) => n > 3);
|
||
|
||
const privileged = f.activity.filter((e) => HIGH_SEVERITY.includes(e.event_type));
|
||
const offHours = f.activity.filter((e) => {
|
||
const hour = new Date(e.created_date).getHours();
|
||
return hour < 6 || hour >= 22;
|
||
});
|
||
|
||
const patterns = [
|
||
share >= 50 && {
|
||
tone: 'warning',
|
||
title: `One account is ${share}% of all activity`,
|
||
body: `${busiest[1].name || busiest[0]} accounts for ${busiest[1].count} of ${f.activity.length} events. Worth confirming that is a person and not an integration running under someone's credentials.`,
|
||
},
|
||
bursts.length && {
|
||
tone: 'warning',
|
||
title: `${plural(bursts.length, 'burst')} of rapid activity`,
|
||
body: 'More than three actions from one account inside a single hour. Normal for batch work, worth a glance if it is not expected.',
|
||
},
|
||
offHours.length && {
|
||
tone: 'info',
|
||
title: `${plural(offHours.length, 'event')} outside working hours`,
|
||
body: 'Logged before 06:00 or after 22:00 local time. Not a problem on its own; a pattern of privileged actions at those hours would be.',
|
||
},
|
||
f.activity24h.length === 0 && {
|
||
tone: 'warning',
|
||
title: 'Nothing in the last 24 hours',
|
||
body: 'Expected for a seeded dataset. On a live platform, silence is an ingestion question rather than a quiet day.',
|
||
},
|
||
privileged.length > f.activity.length * 0.3 && {
|
||
tone: 'warning',
|
||
title: `Privileged actions are ${Math.round((privileged.length / f.activity.length) * 100)}% of the log`,
|
||
body: 'A log dominated by hires and position changes usually means routine reads are not being captured, which weakens the audit trail.',
|
||
},
|
||
].filter(Boolean);
|
||
|
||
if (!patterns.length) {
|
||
return doc(
|
||
heading('Nothing out of pattern'),
|
||
kpis([
|
||
{ label: 'Events', value: f.activity.length },
|
||
{ label: 'Accounts', value: accounts.length },
|
||
{ label: 'Busiest share', value: `${share}%`, tone: 'success' },
|
||
]),
|
||
text('Activity is spread across accounts, there are no bursts, and privileged actions are a normal share of the log.')
|
||
);
|
||
}
|
||
|
||
return doc(
|
||
heading(`${plural(patterns.length, 'pattern')} worth attention`),
|
||
insights(patterns),
|
||
heading('Per account'),
|
||
table(
|
||
[
|
||
{ key: 'name', label: 'Account' },
|
||
{ key: 'events', label: 'Events', align: 'right' },
|
||
{ key: 'privileged', label: 'Privileged', align: 'right' },
|
||
],
|
||
accounts
|
||
.sort((a, b) => b[1].count - a[1].count)
|
||
.slice(0, 6)
|
||
.map(([email, d]) => ({
|
||
name: d.name || email,
|
||
events: { value: d.count, tone: d.count === busiest[1].count ? 'info' : undefined },
|
||
privileged: d.privileged ? { value: d.privileged, tone: 'warning' } : '0',
|
||
}))
|
||
),
|
||
note('Deviations from this platform\'s own baseline, not accusations. Each one is a question to ask, and most have a dull answer.')
|
||
);
|
||
};
|
||
|
||
/**
|
||
* Security insights — the audit trail judged as a control, not as a feed.
|
||
*
|
||
* The question a security reviewer actually asks is whether the log would stand
|
||
* up: are privileged actions attributable, is coverage complete, is anything
|
||
* unaccounted for. So this reports on the trail itself.
|
||
*/
|
||
const securityInsights = (f) => {
|
||
const privileged = f.activity.filter((e) => HIGH_SEVERITY.includes(e.event_type));
|
||
const attributed = privileged.filter((e) => e.user_email && e.user_name);
|
||
const named = f.activity.filter((e) => e.user_email);
|
||
const hires = f.activity.filter((e) => e.event_type === 'hire_candidate').length;
|
||
|
||
const checks = [
|
||
{
|
||
label: 'Privileged actions attributable',
|
||
value: `${attributed.length}/${privileged.length}`,
|
||
ok: privileged.length === attributed.length,
|
||
note: privileged.length === attributed.length
|
||
? 'Every hire and position change traces to a named account.'
|
||
: `${privileged.length - attributed.length} cannot be traced to a named person.`,
|
||
},
|
||
{
|
||
label: 'Events carry an actor',
|
||
value: `${named.length}/${f.activity.length}`,
|
||
ok: named.length === f.activity.length,
|
||
note: named.length === f.activity.length
|
||
? 'No anonymous entries in the log.'
|
||
: `${f.activity.length - named.length} entries have no account attached.`,
|
||
},
|
||
{
|
||
label: 'Hires reconcile with staff',
|
||
value: `${hires}/${f.staff.length}`,
|
||
ok: hires === f.staff.length,
|
||
note: hires === f.staff.length
|
||
? 'Every person on staff has a matching hire event.'
|
||
: `${Math.abs(f.staff.length - hires)} on staff without a corresponding hire event, or the reverse.`,
|
||
},
|
||
{
|
||
label: 'Recent coverage',
|
||
value: `${f.activity24h.length} in 24h`,
|
||
ok: f.activity24h.length > 0,
|
||
note: f.activity24h.length
|
||
? 'The log is being written to.'
|
||
: 'No events in the last day — on a live platform, check ingestion before concluding it was quiet.',
|
||
},
|
||
{
|
||
label: 'Interview integrity',
|
||
value: `${f.interviews.length - f.flagged.length}/${f.interviews.length}`,
|
||
ok: f.flagged.length === 0,
|
||
note: f.flagged.length
|
||
? `${plural(f.flagged.length, 'interview')} below full integrity — usually implausibly fast responses.`
|
||
: 'No interview flagged for integrity.',
|
||
},
|
||
];
|
||
|
||
const passing = checks.filter((c) => c.ok).length;
|
||
|
||
return doc(
|
||
heading(`Security posture: ${passing}/${checks.length} checks passing`),
|
||
status(checks),
|
||
heading('Privileged action log', `${plural(privileged.length, 'event')}`),
|
||
privileged.length
|
||
? timeline(
|
||
privileged.slice(0, 6).map((e) => ({
|
||
title: String(e.event_type).replace(/_/g, ' '),
|
||
description: `${e.user_name || e.user_email}${e.details ? ` — ${e.details}` : ''}`,
|
||
timestamp: new Date(e.created_date).toLocaleString(undefined, {
|
||
month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit',
|
||
}),
|
||
tone: MEDIUM_SEVERITY.includes(e.event_type) ? 'warning' : 'risk',
|
||
}))
|
||
)
|
||
: text('No privileged actions in this log.'),
|
||
text(
|
||
passing === checks.length
|
||
? 'The trail would hold up to review: every privileged action is attributable and the records reconcile.'
|
||
: 'The failing checks are completeness problems. They do not mean something happened — they mean you could not prove it had not.'
|
||
)
|
||
);
|
||
};
|
||
|
||
/* ── 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.')
|
||
);
|
||
};
|
||
|
||
/**
|
||
* Positions that were saved but never published.
|
||
*
|
||
* A draft is a real record with real values on it and no way for anyone to
|
||
* apply to it, which makes it the one thing on this page that is genuinely
|
||
* unfinished. Each one is named, with what it already holds.
|
||
*
|
||
* It reports, it does not reopen. This used to attach "Continue to save" to
|
||
* every draft, routing to the authoring form — so a question asked in the panel
|
||
* could end with the reader back inside Create Position, and a position that
|
||
* had been saved minutes earlier was presented as still needing saving. The
|
||
* draft is on the Positions list marked Draft and its own card opens the form;
|
||
* the panel says where it is rather than taking anyone there.
|
||
*
|
||
* Read from `postings` by status — no list of drafts is kept anywhere, and a
|
||
* position stops appearing here the moment it is published.
|
||
*/
|
||
const positionDrafts = (f) => {
|
||
const drafts = (f.postings || []).filter((p) => p.status === 'draft');
|
||
|
||
if (!drafts.length) {
|
||
return answer('Nothing is sitting in draft — every position in the workspace has been published or closed.');
|
||
}
|
||
|
||
return doc(
|
||
heading(`${plural(drafts.length, 'position')} still in draft`, 'Saved, but not published'),
|
||
insights(drafts.map((p) => ({
|
||
tone: 'warning',
|
||
title: p.title || 'Untitled position',
|
||
body: [
|
||
p.company,
|
||
p.location,
|
||
'Not published yet, so nobody can apply and no screening runs.',
|
||
].filter(Boolean).join(' · '),
|
||
}))),
|
||
note('Each one is on the Positions list marked Draft. Opening its card reopens the form with everything already entered — the vetting weights included — and publishing it there is what makes it an active position.')
|
||
);
|
||
};
|
||
|
||
/** 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,
|
||
})))
|
||
);
|
||
};
|
||
|
||
/* ── Analytics ──────────────────────────────────────────────────────────── */
|
||
|
||
const DAY_MS = 1000 * 60 * 60 * 24;
|
||
|
||
/**
|
||
* Hiring trend — direction of travel, not another chart.
|
||
*
|
||
* The page already draws the line; what it cannot say is whether the line is
|
||
* going up. So this compares the last four weeks against the four before them
|
||
* and reports the change, using the same created/updated dates the chart buckets.
|
||
*/
|
||
const hiringTrend = (f) => {
|
||
if (!f.total) return answer('No applications yet, so there is no trend to read.');
|
||
|
||
const inWindow = (value, from, to) => {
|
||
const t = new Date(value).getTime();
|
||
return t >= f.today.getTime() - from * DAY_MS && t < f.today.getTime() - to * DAY_MS;
|
||
};
|
||
const applied = (from, to) => f.applications.filter((a) => inWindow(a.created_date, from, to)).length;
|
||
/* Screening is dated by the update that carried the score, exactly as the
|
||
Hiring trend chart buckets it. */
|
||
const screened = (from, to) => f.scored.filter((a) => inWindow(a.updated_date, from, to)).length;
|
||
|
||
const recent = applied(28, 0);
|
||
const previous = applied(56, 28);
|
||
const change = previous ? Math.round(((recent - previous) / previous) * 100) : null;
|
||
|
||
const weeks = [3, 2, 1, 0].map((w) => ({
|
||
week: w === 0 ? 'This week' : `${w} week${w === 1 ? '' : 's'} ago`,
|
||
applied: applied((w + 1) * 7, w * 7),
|
||
screened: screened((w + 1) * 7, w * 7),
|
||
}));
|
||
|
||
return doc(
|
||
heading('Hiring trend', 'Last 4 weeks against the 4 before'),
|
||
kpis([
|
||
{ label: 'Applications', value: recent, sub: `${previous} in the prior period` },
|
||
{
|
||
label: 'Change',
|
||
value: change === null ? '—' : `${change > 0 ? '+' : ''}${change}%`,
|
||
tone: change === null ? undefined : change >= 0 ? 'success' : 'warning',
|
||
},
|
||
{ label: 'Screened', value: screened(28, 0), sub: `${f.standardizedPct}% coverage` },
|
||
{ label: 'Hires', value: f.hired.length, sub: f.timeToHire ? `${f.timeToHire}d to close` : 'none closed' },
|
||
]),
|
||
heading('Week by week'),
|
||
table(
|
||
[
|
||
{ key: 'week', label: 'Week' },
|
||
{ key: 'applied', label: 'Applied', align: 'right' },
|
||
{ key: 'screened', label: 'Screened', align: 'right' },
|
||
],
|
||
weeks
|
||
),
|
||
insights([
|
||
change !== null && {
|
||
tone: change >= 0 ? 'success' : 'warning',
|
||
title: change >= 0
|
||
? `Applications up ${change}% on the previous four weeks`
|
||
: `Applications down ${Math.abs(change)}% on the previous four weeks`,
|
||
body: change >= 0
|
||
? 'Supply is growing, so the constraint moves to screening throughput rather than reach.'
|
||
: 'Fewer people are arriving. Distribution and pay band are the two things that move this, not screening.',
|
||
},
|
||
f.unscreened.length && {
|
||
tone: 'warning',
|
||
title: `${plural(f.unscreened.length, 'application')} still unscored`,
|
||
body: 'The screened line lags the applied line by exactly this much — the gap is a queue, not a rejection rate.',
|
||
},
|
||
]),
|
||
note('Windows are counted from today, so this is independent of the 7d/30d/90d control on the chart.')
|
||
);
|
||
};
|
||
|
||
/**
|
||
* Department performance — which role category is actually converting.
|
||
*
|
||
* The page's department section ranks by volume. Volume is demand, not
|
||
* performance: the category worth copying is the one turning applicants into
|
||
* qualified candidates, which is a rate.
|
||
*/
|
||
const departmentPerformance = (f) => {
|
||
if (!f.byRole.length) return answer('No open positions, so there are no departments to compare.');
|
||
|
||
const rows = Object.values(f.byRole.reduce((acc, r) => {
|
||
acc[r.category] ||= { category: r.category, roles: 0, applied: 0, qualified: 0, hired: 0 };
|
||
acc[r.category].roles += 1;
|
||
acc[r.category].applied += r.applied;
|
||
acc[r.category].qualified += r.qualified;
|
||
acc[r.category].hired += r.hired;
|
||
return acc;
|
||
}, {})).map((d) => ({ ...d, rate: d.applied ? Math.round((d.qualified / d.applied) * 100) : 0 }))
|
||
.sort((a, b) => b.rate - a.rate || b.applied - a.applied);
|
||
|
||
const best = rows.find((d) => d.applied > 0);
|
||
const starved = rows.filter((d) => d.applied === 0);
|
||
|
||
return doc(
|
||
heading('Department performance', `${plural(rows.length, 'category')}`),
|
||
table(
|
||
[
|
||
{ key: 'category', label: 'Category' },
|
||
{ key: 'applied', label: 'Applied', align: 'right' },
|
||
{ key: 'qualified', label: '70+', align: 'right' },
|
||
{ key: 'hired', label: 'Hired', align: 'right' },
|
||
{ key: 'rate', label: 'Qualified', align: 'right' },
|
||
],
|
||
rows.map((d) => ({
|
||
category: d.category,
|
||
applied: d.applied ? d.applied : { value: 0, tone: 'risk' },
|
||
qualified: d.qualified,
|
||
hired: d.hired || '—',
|
||
rate: { value: `${d.rate}%`, tone: d.rate >= 50 ? 'success' : d.rate > 0 ? 'warning' : 'risk' },
|
||
}))
|
||
),
|
||
insights([
|
||
best && {
|
||
tone: 'success',
|
||
title: `${best.category} is performing best`,
|
||
body: `${best.qualified} of ${best.applied} applicants clear 70 — ${best.rate}%${best.hired ? `, and ${plural(best.hired, 'hire')} closed` : ''}. Whatever sourcing this category uses is the one worth copying.`,
|
||
},
|
||
starved.length && {
|
||
tone: 'risk',
|
||
title: `${plural(starved.length, 'category')} attracting nobody`,
|
||
body: `${starved.map((d) => d.category).join(', ')}. A conversion rate cannot be read off zero applicants.`,
|
||
},
|
||
])
|
||
);
|
||
};
|
||
|
||
/* ── Free-text responders ───────────────────────────────────────────────── */
|
||
|
||
/**
|
||
* Free-text routing.
|
||
*
|
||
* Keyword matching, ordered most specific first, because a question mentioning
|
||
* both "risk" and "candidates" is a risk question. The ordering *is* the logic
|
||
* here, so each responder reads top to bottom as a priority list.
|
||
*/
|
||
const has = (q, ...words) => words.some((w) => q.includes(w));
|
||
|
||
export const respondControlCenter = (question, f) => {
|
||
const q = question.toLowerCase();
|
||
if (has(q, 'attention', 'urgent', 'blocked', 'stuck', 'wrong')) return attentionRequired(f);
|
||
if (has(q, 'bottleneck', 'funnel', 'pipeline', 'conversion', 'drop')) return pipelineHealth(f);
|
||
if (has(q, 'recommend', 'should', 'fix', 'improve', 'risk', 'next')) return recommendations(f);
|
||
if (has(q, 'usage', 'summary', 'summarize', 'workforce', 'scale', 'how many')) return workforceSummary(f);
|
||
if (has(q, 'operation', 'velocity', 'speed', 'throughput', 'time to hire', 'hiring')) return hiringOperations(f);
|
||
if (has(q, 'unusual', 'anomal', 'suspicious')) return unusualActivity(f);
|
||
return platformHealth(f);
|
||
};
|
||
|
||
export const respondPositions = (question, f) => {
|
||
const q = question.toLowerCase();
|
||
if (has(q, 'draft', 'unfinished', 'not published', 'continue')) return positionDrafts(f);
|
||
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);
|
||
if (has(q, 'risk', 'flag', 'concern', 'compliance')) return candidateRiskAnalysis(f);
|
||
if (has(q, 'compare', 'top', 'best', 'strongest', 'shortlist')) return topCandidates(f);
|
||
if (has(q, 'recommend', 'should', 'next', 'action', 'hire', 'who')) return hiringRecommendations(f);
|
||
return recruitmentInsights(f);
|
||
};
|
||
|
||
export const respondAdminAnalytics = (question, f) => {
|
||
const q = question.toLowerCase();
|
||
if (has(q, 'trend', 'over time', 'growing', 'slow', 'week', 'month')) return hiringTrend(f);
|
||
if (has(q, 'department', 'category', 'team', 'best perform')) return departmentPerformance(f);
|
||
if (has(q, 'bottleneck', 'funnel', 'drop', 'conversion', 'stuck', 'lose')) return pipelineHealth(f);
|
||
if (has(q, 'position', 'role', 'converting', 'strongest')) return positionStrength(f);
|
||
if (has(q, 'attention', 'urgent', 'wrong', 'fix', 'risk')) return attentionRequired(f);
|
||
return hiringOperations(f);
|
||
};
|
||
|
||
export const respondActivity = (question, f) => {
|
||
const q = question.toLowerCase();
|
||
if (has(q, 'security', 'breach', 'attributab', 'compliance', 'integrity', 'trace')) return securityInsights(f);
|
||
if (has(q, 'unusual', 'anomal', 'suspicious', 'odd', 'spike', 'burst')) return unusualActivity(f);
|
||
if (has(q, 'user', 'who', 'account', 'behaviour', 'behavior', 'busiest')) return userActivityInsights(f);
|
||
return auditSummary(f);
|
||
};
|
||
|
||
/* ── Capability manifests ───────────────────────────────────────────────── */
|
||
|
||
export const CONTROL_CENTER_CAPABILITIES = [
|
||
{ id: 'platform-health', label: 'Platform Health', icon: Gauge, run: platformHealth },
|
||
{ id: 'workforce-summary', label: 'Workforce Summary', icon: Users, run: workforceSummary },
|
||
{ id: 'hiring-operations', label: 'Hiring Operations', icon: Activity, run: hiringOperations },
|
||
{ id: 'pipeline-health', label: 'Pipeline Health', icon: GitBranch, run: pipelineHealth },
|
||
{ id: 'attention-required', label: 'Attention Required', icon: BellRing, run: attentionRequired },
|
||
{ id: 'recommendations', label: 'Recommendations', icon: Lightbulb, run: recommendations },
|
||
];
|
||
|
||
export const POSITIONS_CAPABILITIES = [
|
||
{ id: 'position-drafts', label: 'Unfinished Drafts', icon: FileText, run: positionDrafts },
|
||
{ 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 },
|
||
{ id: 'screening-gaps', label: 'Screening Gaps', icon: Filter, run: screeningGaps },
|
||
{ id: 'hiring-recommendations', label: 'Hiring Recommendations', icon: Lightbulb, run: hiringRecommendations },
|
||
{ id: 'top-candidates', label: 'Compare Top Candidates', icon: ArrowLeftRight, run: topCandidates },
|
||
];
|
||
|
||
export const ADMIN_ANALYTICS_CAPABILITIES = [
|
||
{ id: 'hiring-trend', label: 'Hiring Trend', icon: TrendingUp, run: hiringTrend },
|
||
{ id: 'department-performance', label: 'Department Performance', icon: Layers, run: departmentPerformance },
|
||
{ id: 'pipeline-health', label: 'Hiring Bottlenecks', icon: GitBranch, run: pipelineHealth },
|
||
{ id: 'position-conversion', label: 'Position Conversion', icon: Target, run: positionStrength },
|
||
{ id: 'hiring-operations', label: 'Hiring Performance', icon: Activity, run: hiringOperations },
|
||
{ id: 'attention-required', label: 'Needs Attention', icon: BellRing, run: attentionRequired },
|
||
];
|
||
|
||
export const ACTIVITY_CAPABILITIES = [
|
||
{ id: 'audit-summary', label: 'Audit Summary', icon: ScrollText, run: auditSummary },
|
||
{ id: 'user-activity', label: 'User Activity', icon: Eye, run: userActivityInsights },
|
||
{ id: 'unusual-activity', label: 'Unusual Activity', icon: AlertTriangle, run: unusualActivity },
|
||
{ id: 'security-insights', label: 'Security Insights', icon: ShieldAlert, run: securityInsights },
|
||
];
|
||
|
||
/* ── KROW Forge ─────────────────────────────────────────────────────────── */
|
||
|
||
/**
|
||
* Forge capabilities — managing workforce skill verification.
|
||
*
|
||
* The Forge questions an administrator asks are not a worker's. A worker asks
|
||
* what to prove next; the person who owns the library asks what is in it, what
|
||
* is live, what Owliver is checking, and whether any of it is reaching the
|
||
* workforce. Every figure comes from `f.forge`, derived from the same Course
|
||
* records and worker profiles the page renders from.
|
||
*/
|
||
|
||
const forgeLibrary = (f) => {
|
||
const g = f.forge;
|
||
if (!g.library.length) {
|
||
return answer('The library is empty. A skill defines what the workforce learns, how they prove it, and what Owliver checks — start one from Add Skill Training.');
|
||
}
|
||
|
||
return doc(
|
||
heading('Skill library', `${plural(g.library.length, 'skill')} defined`),
|
||
kpis([
|
||
{ label: 'Active skills', value: g.published.length, sub: `of ${g.library.length} in the library` },
|
||
{ label: 'Training modules', value: g.withTraining.length, sub: 'carrying material' },
|
||
{ label: 'Challenges', value: g.withChallenge.length, sub: 'evidence-based' },
|
||
{ label: 'Verified skills', value: g.workforceVerified, sub: g.workforceVerified ? 'earned by the workforce' : 'none earned yet' },
|
||
]),
|
||
g.byCategory.length ? heading('By category') : null,
|
||
meters(g.byCategory.map((c) => ({
|
||
label: c.category,
|
||
value: c.count,
|
||
max: g.library.length,
|
||
}))),
|
||
g.drafts.length
|
||
? note(`${plural(g.drafts.length, 'skill')} ${verb(g.drafts.length, 'is', 'are')} still in draft and not visible to the workforce.`)
|
||
: null
|
||
);
|
||
};
|
||
|
||
const forgePublished = (f) => {
|
||
const g = f.forge;
|
||
if (!g.published.length) {
|
||
return answer(`Nothing is published. ${g.drafts.length ? `${plural(g.drafts.length, 'draft')} ${verb(g.drafts.length, 'is', 'are')} waiting on a decision.` : 'The library has no skills yet.'}`);
|
||
}
|
||
|
||
return doc(
|
||
heading(`${plural(g.published.length, 'skill')} published`, 'live to the workforce'),
|
||
table(
|
||
[
|
||
{ key: 'skill', label: 'Skill' },
|
||
{ key: 'category', label: 'Category' },
|
||
{ key: 'proof', label: 'Proof' },
|
||
{ key: 'minutes', label: 'Min', align: 'right' },
|
||
],
|
||
g.published.map((c) => ({
|
||
skill: c.title,
|
||
category: c.category || '—',
|
||
proof: TYPE_LABEL[c.challenge?.type] || 'Not set',
|
||
minutes: c.estimated_minutes || '—',
|
||
}))
|
||
),
|
||
g.drafts.length
|
||
? insights([{
|
||
tone: 'warning',
|
||
title: `${plural(g.drafts.length, 'skill')} in draft`,
|
||
body: `${g.drafts.map((c) => c.title).join(', ')}. A draft is invisible to the workforce until it is published.`,
|
||
}])
|
||
: note('Every skill in the library is published.')
|
||
);
|
||
};
|
||
|
||
const forgeEvaluation = (f) => {
|
||
const g = f.forge;
|
||
const scored = g.library.filter((c) => c.challenge?.rubric?.length);
|
||
|
||
if (!scored.length) {
|
||
return answer('No skill has evaluation criteria yet. Owliver scores a submission against the criteria set on the skill, so a skill without them cannot verify anything.');
|
||
}
|
||
|
||
return doc(
|
||
heading('What Owliver evaluates', `across ${plural(scored.length, 'skill')}`),
|
||
table(
|
||
[
|
||
{ key: 'skill', label: 'Skill' },
|
||
{ key: 'criteria', label: 'Criteria' },
|
||
{ key: 'pass', label: 'Passes at', align: 'right' },
|
||
],
|
||
scored.map((c) => ({
|
||
skill: c.title,
|
||
criteria: c.challenge.rubric.map((r) => r.criterion).join(', '),
|
||
pass: c.pass_score || '—',
|
||
}))
|
||
),
|
||
g.library.length > scored.length
|
||
? insights([{
|
||
tone: 'warning',
|
||
title: 'Skills with nothing to score against',
|
||
body: `${g.library.filter((c) => !c.challenge?.rubric?.length).map((c) => c.title).join(', ')}. Owliver needs criteria to return a verdict.`,
|
||
}])
|
||
: note('Every skill in the library tells Owliver what to check.')
|
||
);
|
||
};
|
||
|
||
const forgeWorkforce = (f) => {
|
||
const g = f.forge;
|
||
const touched = g.usage.filter((u) => u.completed > 0 || u.verified > 0);
|
||
|
||
if (!touched.length) {
|
||
return doc(
|
||
text('No skill in the library has been completed by anyone yet.'),
|
||
note('Completion and verification are read from worker profiles, so figures appear here as the workforce works through the library.')
|
||
);
|
||
}
|
||
|
||
return doc(
|
||
heading('Workforce usage', `${plural(touched.length, 'skill')} in use`),
|
||
table(
|
||
[
|
||
{ key: 'skill', label: 'Skill' },
|
||
{ key: 'completed', label: 'Completed', align: 'right' },
|
||
{ key: 'verified', label: 'Verified', align: 'right' },
|
||
],
|
||
touched
|
||
.sort((a, b) => b.verified - a.verified || b.completed - a.completed)
|
||
.map((u) => ({
|
||
skill: u.course.title,
|
||
completed: u.completed,
|
||
verified: u.verified,
|
||
}))
|
||
),
|
||
g.library.length > touched.length
|
||
? note(`${plural(g.library.length - touched.length, 'skill')} ${verb(g.library.length - touched.length, 'has', 'have')} no completions on record.`)
|
||
: null
|
||
);
|
||
};
|
||
|
||
const forgeGaps = (f) => {
|
||
const g = f.forge;
|
||
|
||
const gaps = [
|
||
...g.drafts.map((c) => ({ skill: c.title, gap: 'Unpublished draft' })),
|
||
...g.library.filter((c) => !c.challenge?.type).map((c) => ({ skill: c.title, gap: 'No proof method' })),
|
||
...g.library.filter((c) => !c.challenge?.rubric?.length).map((c) => ({ skill: c.title, gap: 'No Owliver criteria' })),
|
||
...g.library.filter((c) => !c.training_outline?.length && !c.quiz?.length).map((c) => ({ skill: c.title, gap: 'No training material' })),
|
||
];
|
||
|
||
if (!gaps.length) {
|
||
return answer(`All ${plural(g.library.length, 'skill')} carry training, a proof method and evaluation criteria, and every one is published.`);
|
||
}
|
||
|
||
return doc(
|
||
heading(`${plural(gaps.length, 'gap')} to close`, 'before these verify anything'),
|
||
table(
|
||
[
|
||
{ key: 'skill', label: 'Skill' },
|
||
{ key: 'gap', label: 'Missing' },
|
||
],
|
||
gaps
|
||
),
|
||
note('A skill needs training, a way to submit evidence, and criteria for Owliver to score against before it can verify a workforce skill.')
|
||
);
|
||
};
|
||
|
||
/* ── Talent pool ────────────────────────────────────────────────────────── */
|
||
|
||
const talentPoolSummary = (f) => {
|
||
const t = f.talent;
|
||
return doc(
|
||
heading('Talent pool', `${plural(f.profiles.length, 'registered worker')}`),
|
||
kpis([
|
||
{ label: 'Profiles', value: f.profiles.length, sub: `${t.available.length} available` },
|
||
{ label: 'Elite', value: f.elite.length, sub: '90+ career score' },
|
||
{ label: 'Average score', value: t.avgScore ? toFICO(t.avgScore) : '—', sub: 'scored profiles only' },
|
||
{ label: 'Unscored', value: f.unscoredProfiles.length, sub: 'cannot be ranked', tone: f.unscoredProfiles.length ? 'warning' : undefined },
|
||
]),
|
||
heading('Score bands'),
|
||
meters(t.bands.map((b) => ({ label: b.label, value: b.count, max: f.profiles.length }))),
|
||
text(
|
||
f.unscoredProfiles.length
|
||
? `${plural(f.unscoredProfiles.length, 'profile')} ${verb(f.unscoredProfiles.length, 'has', 'have')} no career score, so ${verb(f.unscoredProfiles.length, 'it is', 'they are')} invisible to ranking and matching.`
|
||
: 'Every profile carries a career score, so the whole pool is rankable.'
|
||
)
|
||
);
|
||
};
|
||
|
||
const talentPriorities = (f) => {
|
||
const t = f.talent;
|
||
if (!f.profiles.length) return answer('No talent profiles registered yet.');
|
||
|
||
return doc(
|
||
heading('Who to prioritize', 'by career score, availability and verification'),
|
||
table(
|
||
[
|
||
{ key: 'name', label: 'Worker' },
|
||
{ key: 'score', label: 'Score', align: 'right' },
|
||
{ key: 'experience', label: 'Years', align: 'right' },
|
||
{ key: 'availability', label: 'Available' },
|
||
],
|
||
t.ranked.slice(0, 6).map((p) => ({
|
||
name: p.full_name || p.email,
|
||
/* The page shows career score on the 300–850 scale, so this must too —
|
||
the same worker cannot be 94 here and 817 in the row beside it. */
|
||
score: p.krow_score ? toFICO(p.krow_score) : '—',
|
||
experience: p.experience_years ?? '—',
|
||
availability: (p.availability || []).length ? (p.availability || []).join(', ') : '—',
|
||
}))
|
||
),
|
||
insights([
|
||
t.top[0] && {
|
||
tone: 'success',
|
||
title: `${t.top[0].full_name || t.top[0].email} leads the pool`,
|
||
body: `Career score ${toFICO(t.top[0].krow_score || 0)}${t.top[0].experience_years ? `, ${plural(t.top[0].experience_years, 'year')} of experience` : ''}.`,
|
||
},
|
||
t.available.length < f.profiles.length && {
|
||
tone: 'info',
|
||
title: `${t.available.length} of ${f.profiles.length} list availability`,
|
||
body: 'The rest cannot be matched to a shift until availability is on the record.',
|
||
},
|
||
])
|
||
);
|
||
};
|
||
|
||
const talentVerification = (f) => {
|
||
const t = f.talent;
|
||
if (!t.unverified.length) {
|
||
return answer(`All ${plural(f.profiles.length, 'profile')} carry at least one verified skill.`);
|
||
}
|
||
|
||
return doc(
|
||
heading(`${plural(t.unverified.length, 'profile')} without verified skills`),
|
||
table(
|
||
[
|
||
{ key: 'name', label: 'Worker' },
|
||
{ key: 'score', label: 'Score', align: 'right' },
|
||
{ key: 'skills', label: 'Claimed skills', align: 'right' },
|
||
],
|
||
t.unverified.slice(0, 8).map((p) => ({
|
||
name: p.full_name || p.email,
|
||
score: p.krow_score ? toFICO(p.krow_score) : '—',
|
||
skills: (p.skills || []).length,
|
||
}))
|
||
),
|
||
insights([{
|
||
tone: 'warning',
|
||
title: 'Claimed is not proven',
|
||
body: 'These profiles carry skills nobody has verified. A Forge challenge turns each claim into evidence an employer can read.',
|
||
}]),
|
||
note(`${pct(t.unverified.length, f.profiles.length)}% of the pool is unverified.`)
|
||
);
|
||
};
|
||
|
||
const talentAvailability = (f) => {
|
||
const t = f.talent;
|
||
const buckets = Object.values(f.profiles.reduce((acc, p) => {
|
||
(p.availability || ['Not stated']).forEach((slot) => {
|
||
acc[slot] ||= { slot, count: 0 };
|
||
acc[slot].count += 1;
|
||
});
|
||
return acc;
|
||
}, {})).sort((a, b) => b.count - a.count);
|
||
|
||
return doc(
|
||
heading('Availability across the pool', `${t.available.length} of ${f.profiles.length} stated`),
|
||
meters(buckets.map((b) => ({ label: b.slot, value: b.count, max: f.profiles.length }))),
|
||
text(
|
||
buckets[0]
|
||
? `Deepest cover is **${buckets[0].slot}** at ${plural(buckets[0].count, 'worker')}. Thinnest is **${buckets[buckets.length - 1].slot}**.`
|
||
: 'No availability recorded yet.'
|
||
)
|
||
);
|
||
};
|
||
|
||
/* ── Hired history ──────────────────────────────────────────────────────── */
|
||
|
||
const hiringOutcomes = (f) => {
|
||
const h = f.hiring;
|
||
if (!h.hires.length) return answer('Nobody has been hired yet, so there is no history to read.');
|
||
|
||
return doc(
|
||
heading('Hiring outcomes', plural(h.hires.length, 'hire')),
|
||
kpis([
|
||
{ label: 'Hires', value: h.hires.length, sub: `${h.onboarding.length} onboarding` },
|
||
{ label: 'Average quality', value: h.avgQuality || '—', sub: 'AI score at hire' },
|
||
{ label: 'Average speed', value: h.avgSpeed ? `${h.avgSpeed}d` : '—', sub: 'apply to hire' },
|
||
{ label: 'Rated', value: `${h.rated.length}/${h.hires.length}`, sub: 'client rating on file', tone: h.unrated.length ? 'warning' : undefined },
|
||
]),
|
||
heading('By department'),
|
||
table(
|
||
[
|
||
{ key: 'name', label: 'Department' },
|
||
{ key: 'count', label: 'Hires', align: 'right' },
|
||
{ key: 'avgScore', label: 'Avg score', align: 'right' },
|
||
{ key: 'avgDays', label: 'Avg days', align: 'right' },
|
||
],
|
||
h.byDepartment.map((d) => ({
|
||
name: d.name,
|
||
count: d.count,
|
||
avgScore: d.avgScore || '—',
|
||
avgDays: d.avgDays || '—',
|
||
}))
|
||
),
|
||
h.unrated.length
|
||
? note(`${plural(h.unrated.length, 'hire')} ${verb(h.unrated.length, 'has', 'have')} no client rating yet, so outcome quality rests on the ${h.rated.length} that do.`)
|
||
: null
|
||
);
|
||
};
|
||
|
||
const hiringStrongest = (f) => {
|
||
const h = f.hiring;
|
||
if (!h.hires.length) return answer('No hires on record yet.');
|
||
|
||
const ranked = [...h.hires].sort((a, b) => (b.score || 0) - (a.score || 0));
|
||
|
||
return doc(
|
||
heading('Strongest hires', 'by score at the point of hire'),
|
||
table(
|
||
[
|
||
{ key: 'name', label: 'Hire' },
|
||
{ key: 'role', label: 'Role' },
|
||
{ key: 'department', label: 'Department' },
|
||
{ key: 'score', label: 'Score', align: 'right' },
|
||
{ key: 'rating', label: 'Rating', align: 'right' },
|
||
],
|
||
ranked.slice(0, 6).map((x) => ({
|
||
name: x.name,
|
||
role: x.role,
|
||
department: x.department,
|
||
score: x.score || '—',
|
||
rating: x.client_rating || '—',
|
||
}))
|
||
),
|
||
insights([
|
||
h.strongest && {
|
||
tone: 'success',
|
||
title: `${h.strongest.name} scored highest at ${h.strongest.score}`,
|
||
body: `${h.strongest.role} in ${h.strongest.department}${h.strongest.client_rating ? `, rated ${h.strongest.client_rating} since` : ', not yet rated by the client'}.`,
|
||
},
|
||
h.byRoleTitle[0] && {
|
||
tone: 'info',
|
||
title: `${h.byRoleTitle[0].name} is the most-hired role`,
|
||
body: `${plural(h.byRoleTitle[0].count, 'hire')}, averaging ${h.byRoleTitle[0].avgScore || 0} at hire.`,
|
||
},
|
||
])
|
||
);
|
||
};
|
||
|
||
const hiringPatterns = (f) => {
|
||
const h = f.hiring;
|
||
if (!h.hires.length) return answer('No hires on record, so there is no pattern to read.');
|
||
|
||
const fastest = [...h.byRoleTitle].filter((r) => r.avgDays).sort((a, b) => a.avgDays - b.avgDays)[0];
|
||
const slowest = [...h.byRoleTitle].filter((r) => r.avgDays).sort((a, b) => b.avgDays - a.avgDays)[0];
|
||
|
||
return doc(
|
||
heading('What stands out', `${plural(h.hires.length, 'hire')} across ${plural(h.byDepartment.length, 'department')}`),
|
||
meters(h.byDepartment.map((d) => ({
|
||
label: d.name,
|
||
value: d.count,
|
||
max: Math.max(...h.byDepartment.map((x) => x.count), 1),
|
||
}))),
|
||
insights([
|
||
h.byDepartment[0] && {
|
||
tone: 'info',
|
||
title: `${h.byDepartment[0].name} accounts for the most hires`,
|
||
body: `${plural(h.byDepartment[0].count, 'hire')} of ${h.hires.length} — ${pct(h.byDepartment[0].count, h.hires.length)}% of everyone placed.`,
|
||
},
|
||
fastest && slowest && fastest.name !== slowest.name && {
|
||
tone: 'warning',
|
||
title: `${slowest.name} takes ${slowest.avgDays - fastest.avgDays} days longer than ${fastest.name}`,
|
||
body: `${slowest.avgDays} days against ${fastest.avgDays}. The gap is where the process, not the candidate, is the constraint.`,
|
||
},
|
||
h.unrated.length && {
|
||
tone: 'warning',
|
||
title: `${plural(h.unrated.length, 'hire')} without a client rating`,
|
||
body: 'Outcome quality cannot be judged on hires nobody has rated.',
|
||
},
|
||
]),
|
||
actions([
|
||
h.unrated.length ? { title: 'Chase the missing ratings', body: `${plural(h.unrated.length, 'placement')} ${verb(h.unrated.length, 'has', 'have')} no client feedback on file.` } : null,
|
||
h.avgSpeed ? { title: 'Compare speed against quality', body: `Average ${h.avgSpeed} days to hire at an average score of ${h.avgQuality}. Faster is only better while the score holds.` } : null,
|
||
])
|
||
);
|
||
};
|
||
|
||
const hiringRecentHires = (f) => {
|
||
const h = f.hiring;
|
||
if (!h.hires.length) return answer('No hires on record yet.');
|
||
|
||
const recent = [...h.hires]
|
||
.filter((x) => x.hire_date)
|
||
.sort((a, b) => new Date(b.hire_date).getTime() - new Date(a.hire_date).getTime())
|
||
.slice(0, 6);
|
||
|
||
return doc(
|
||
heading('Recent hires', 'most recent first'),
|
||
timeline(recent.map((x) => ({
|
||
title: `${x.name} — ${x.role}`,
|
||
description: `${x.department}${x.score ? ` · scored ${x.score}` : ''}${x.timeToHire ? ` · ${plural(x.timeToHire, 'day')} to hire` : ''}`,
|
||
timestamp: x.hire_date,
|
||
tone: x.status === 'onboarding' ? 'warning' : 'success',
|
||
}))),
|
||
h.onboarding.length
|
||
? note(`${plural(h.onboarding.length, 'hire')} ${verb(h.onboarding.length, 'is', 'are')} still onboarding.`)
|
||
: null
|
||
);
|
||
};
|
||
|
||
/* ── Free-text responders for the three new contexts ────────────────────── */
|
||
|
||
export const respondForge = (question, f) => {
|
||
const q = question.toLowerCase();
|
||
if (has(q, 'publish', 'live', 'draft', 'unpublish', 'archived', 'status')) return forgePublished(f);
|
||
if (has(q, 'owliver', 'evaluat', 'criteri', 'rubric', 'grade', 'score against', 'pass')) return forgeEvaluation(f);
|
||
if (has(q, 'gap', 'missing', 'incomplete', 'unfinished', 'needs', 'ready to publish')) return forgeGaps(f);
|
||
if (has(q, 'workforce', 'usage', 'completed', 'verified', 'earned', 'badge', 'who has', 'uptake')) return forgeWorkforce(f);
|
||
if (has(q, 'skill', 'library', 'challenge', 'course', 'training', 'category', 'summar', 'overview', 'how many', 'what do we have')) return forgeLibrary(f);
|
||
return forgeLibrary(f);
|
||
};
|
||
|
||
export const respondTalentPool = (question, f) => {
|
||
const q = question.toLowerCase();
|
||
if (has(q, 'verif', 'unverified', 'proof', 'evidence', 'badge')) return talentVerification(f);
|
||
if (has(q, 'available', 'availability', 'shift', 'weekend', 'evening')) return talentAvailability(f);
|
||
if (has(q, 'priorit', 'strongest', 'best', 'top', 'who should')) return talentPriorities(f);
|
||
if (has(q, 'unscored', 'no score', 'missing', 'gap')) return talentPoolSummary(f);
|
||
if (has(q, 'summar', 'overview', 'pool', 'how many')) return talentPoolSummary(f);
|
||
return talentPriorities(f);
|
||
};
|
||
|
||
export const respondHiredHistory = (question, f) => {
|
||
const q = question.toLowerCase();
|
||
if (has(q, 'pattern', 'stand out', 'trend', 'insight', 'attention', 'concern')) return hiringPatterns(f);
|
||
if (has(q, 'strongest', 'best', 'top', 'quality', 'highest')) return hiringStrongest(f);
|
||
if (has(q, 'recent', 'latest', 'summar', 'who did we hire')) return hiringRecentHires(f);
|
||
if (has(q, 'department', 'position', 'role', 'team', 'most hires')) return hiringOutcomes(f);
|
||
return hiringOutcomes(f);
|
||
};
|
||
|
||
/* ── Capability manifests for the three new contexts ────────────────────── */
|
||
|
||
export const FORGE_CAPABILITIES = [
|
||
{ id: 'forge-library', label: 'Skill Library', icon: Gauge, run: forgeLibrary },
|
||
{ id: 'forge-published', label: 'Published Skills', icon: Target, run: forgePublished },
|
||
{ id: 'forge-evaluation', label: 'Owliver Evaluation', icon: ShieldCheck, run: forgeEvaluation },
|
||
{ id: 'forge-workforce', label: 'Workforce Usage', icon: UserCheck, run: forgeWorkforce },
|
||
{ id: 'forge-gaps', label: 'Gaps To Close', icon: Lock, run: forgeGaps },
|
||
];
|
||
|
||
export const TALENT_POOL_CAPABILITIES = [
|
||
{ id: 'talent-priorities', label: 'Who To Prioritize', icon: Star, run: talentPriorities },
|
||
{ id: 'talent-summary', label: 'Pool Summary', icon: Users, run: talentPoolSummary },
|
||
{ id: 'talent-verification', label: 'Verification Gaps', icon: ShieldCheck, run: talentVerification },
|
||
{ id: 'talent-availability', label: 'Availability', icon: Clock, run: talentAvailability },
|
||
];
|
||
|
||
export const HIRED_HISTORY_CAPABILITIES = [
|
||
{ id: 'hiring-outcomes', label: 'Hiring Outcomes', icon: Award, run: hiringOutcomes },
|
||
{ id: 'hiring-strongest', label: 'Strongest Hires', icon: TrendingUp, run: hiringStrongest },
|
||
{ id: 'hiring-patterns', label: 'What Stands Out', icon: Layers, run: hiringPatterns },
|
||
{ id: 'hiring-recent', label: 'Recent Hires', icon: ScrollText, run: hiringRecentHires },
|
||
];
|
||
|
||
/* ── Profile ────────────────────────────────────────────────────────────────
|
||
*
|
||
* The account page answers questions about the operator rather than the
|
||
* workforce: what this account may do, how it is secured, what it has been
|
||
* doing, and which switches are on. Everything below reads the same constants
|
||
* the page renders, so the panel can never describe a scope or a control that
|
||
* is not on screen.
|
||
*/
|
||
|
||
const profilePermissions = () => doc(
|
||
heading('Your permissions', `${PERMISSIONS.length} scopes on this account`),
|
||
table(
|
||
[{ key: 'scope', label: 'Scope' }, { key: 'level', label: 'Level' }, { key: 'detail', label: 'Covers' }],
|
||
PERMISSIONS.map((p) => ({
|
||
scope: p.scope,
|
||
level: {
|
||
value: p.level,
|
||
tone: p.level === 'Full access' ? 'success' : p.level === 'Read only' ? 'info' : 'muted',
|
||
},
|
||
detail: p.detail,
|
||
}))
|
||
),
|
||
note('Scopes are set by the account owner. Billing stays restricted to owners.')
|
||
);
|
||
|
||
const profileIdentity = (f) => {
|
||
const user = f.user || {};
|
||
return doc(
|
||
heading('This account'),
|
||
table(
|
||
[{ key: 'field', label: 'Field' }, { key: 'value', label: 'Value' }],
|
||
[
|
||
{ field: 'Name', value: user.full_name || '—' },
|
||
{ field: 'Email', value: user.email || '—' },
|
||
{ field: 'Role', value: 'Platform Administrator' },
|
||
{ field: 'Department', value: 'Workforce Operations' },
|
||
{ field: 'Account status', value: { value: 'Active · verified', tone: 'success' } },
|
||
{
|
||
field: 'Member since',
|
||
value: user.created_date
|
||
? new Date(user.created_date).toLocaleDateString(undefined, { month: 'long', year: 'numeric' })
|
||
: '—',
|
||
},
|
||
]
|
||
),
|
||
text('Use **Edit profile** at the top of the page to change your display name. Email and role are set by the account owner.')
|
||
);
|
||
};
|
||
|
||
const profileSecurity = () => doc(
|
||
heading('Security on this account'),
|
||
status([
|
||
{ label: 'Two-factor authentication', ok: true, value: 'On', note: 'Required for privileged actions.' },
|
||
{ label: 'Password age', ok: true, value: '42 days', note: 'Administrator passwords expire every 90 days.' },
|
||
{ label: 'Session', ok: true, value: 'Browser tab', note: 'Closing the tab signs this account out.' },
|
||
]),
|
||
text('**Change password** and **Manage sessions** sit in the Security card on the right.')
|
||
);
|
||
|
||
const profilePreferences = (f) => {
|
||
const p = f.user?.preferences || {};
|
||
const state = (on) => ({ value: on ? 'On' : 'Off', tone: on ? 'success' : 'muted' });
|
||
return doc(
|
||
heading('Your preferences'),
|
||
table(
|
||
[{ key: 'setting', label: 'Setting' }, { key: 'state', label: 'State' }, { key: 'detail', label: 'Effect' }],
|
||
[
|
||
{ setting: 'Owliver workspace', state: state(p.owliverDefault !== false), detail: 'Opens this panel by default on supported pages' },
|
||
{ setting: 'Compact density', state: state(Boolean(p.compactDensity)), detail: 'Tighter row heights across Admin tables' },
|
||
{ setting: 'Email digest', state: state(p.emailDigest !== false), detail: 'A daily summary of what needs attention' },
|
||
]
|
||
),
|
||
note('Preferences save to your account as soon as you flip a switch.')
|
||
);
|
||
};
|
||
|
||
const profileActivity = (f) => {
|
||
const email = f.user?.email;
|
||
const mine = f.activity.filter((e) => e.user_email === email).slice(0, 6);
|
||
if (!mine.length) {
|
||
return doc(text('No activity is recorded against this account yet.'));
|
||
}
|
||
return doc(
|
||
heading('Your recent activity', `${plural(mine.length, 'event')} on this account`),
|
||
timeline(mine.map((e) => ({
|
||
title: e.event_type.replace(/_/g, ' '),
|
||
description: e.details || undefined,
|
||
timestamp: new Date(e.created_date).toLocaleDateString(undefined, { month: 'short', day: 'numeric' }),
|
||
})))
|
||
);
|
||
};
|
||
|
||
const profileActions = () => doc(
|
||
heading('What you can do here'),
|
||
list(PROFILE_ACTIONS.map((a) => `**${a.label}** — ${a.detail}`)),
|
||
note('Everything above is on this page. Nothing here changes another operator’s account.')
|
||
);
|
||
|
||
export const respondProfile = (question, f) => {
|
||
const q = question.toLowerCase();
|
||
if (has(q, 'permission', 'scope', 'access', 'allowed', 'can i')) return profilePermissions(f);
|
||
if (has(q, 'password', 'two-factor', 'two factor', '2fa', 'security', 'session', 'sign out', 'log out')) return profileSecurity(f);
|
||
if (has(q, 'preference', 'setting', 'digest', 'density', 'workspace', 'owliver')) return profilePreferences(f);
|
||
if (has(q, 'activity', 'recent', 'history', 'what have i')) return profileActivity(f);
|
||
if (has(q, 'do here', 'change my', 'edit', 'update', 'how do i')) return profileActions(f);
|
||
return profileIdentity(f);
|
||
};
|
||
|
||
export const PROFILE_CAPABILITIES = [
|
||
{ id: 'profile-permissions', label: 'My Permissions', icon: ShieldCheck, run: profilePermissions },
|
||
{ id: 'profile-identity', label: 'Account Details', icon: Users, run: profileIdentity },
|
||
{ id: 'profile-security', label: 'Security', icon: Lock, run: profileSecurity },
|
||
{ id: 'profile-preferences', label: 'My Preferences', icon: Gauge, run: profilePreferences },
|
||
{ id: 'profile-activity', label: 'My Recent Activity', icon: ScrollText, run: profileActivity },
|
||
{ id: 'profile-actions', label: 'What I Can Do Here', icon: Lightbulb, run: profileActions },
|
||
];
|
||
|
||
/* ── Create Position ─────────────────────────────────────────────────────
|
||
The authoring surface. Every answer here is about the role being specified,
|
||
read against what this workspace has already posted — so the guidance is the
|
||
deployment's own history rather than an industry number written into a file. */
|
||
|
||
/** The average of a numeric field across postings that state it. */
|
||
const across = (postings, read) => {
|
||
const values = postings.map(read).filter((n) => Number.isFinite(n) && n > 0);
|
||
return values.length ? Math.round(values.reduce((a, b) => a + b, 0) / values.length) : 0;
|
||
};
|
||
|
||
/**
|
||
* What the five vetting criteria mean, and how this workspace usually weights
|
||
* them.
|
||
*
|
||
* The typical column is counted from the positions already posted, so it says
|
||
* what this operator does rather than what a template suggests.
|
||
*/
|
||
const vettingWeights = (f) => {
|
||
const posted = f.postings.filter((p) => p.vetting_criteria);
|
||
const keys = ['experience', 'english', 'reliability', 'certifications', 'availability'];
|
||
const meaning = {
|
||
experience: 'Years in the role, weighted against the minimum this position asks for',
|
||
english: 'Spoken level, checked in the AI interview',
|
||
reliability: 'Attendance and completion history on previous assignments',
|
||
certifications: 'Whether the credentials this role requires are held and current',
|
||
availability: 'How much of the shift pattern the candidate can actually cover',
|
||
};
|
||
|
||
if (!posted.length) {
|
||
return doc(
|
||
heading('AI vetting weights', 'What each criterion scores'),
|
||
list(keys.map((k) => `**${CRITERIA_LABELS[k]}** — ${meaning[k]}`)),
|
||
note('The five must total 100%. No position has been posted yet, so there is no house pattern to compare against.')
|
||
);
|
||
}
|
||
|
||
const typical = keys.map((key) => ({
|
||
criterion: CRITERIA_LABELS[key],
|
||
typical: `${across(posted, (p) => Number(p.vetting_criteria?.[key]))}%`,
|
||
scores: meaning[key],
|
||
}));
|
||
|
||
return doc(
|
||
heading('AI vetting weights', `Typical across ${plural(posted.length, 'posted position')}`),
|
||
table(
|
||
[
|
||
{ key: 'criterion', label: 'Criterion' },
|
||
{ key: 'typical', label: 'Usually', align: 'right' },
|
||
{ key: 'scores', label: 'What it scores' },
|
||
],
|
||
typical
|
||
),
|
||
note('These are this workspace’s own averages, not a recommendation. The five weights on this form must total 100%.')
|
||
);
|
||
};
|
||
|
||
/**
|
||
* What comparable roles in this workspace ask for.
|
||
*
|
||
* Grouped by role category, which is the only comparison the data supports —
|
||
* pay and experience are not transferable between a chef and a security post.
|
||
*/
|
||
const positionBenchmarks = (f) => {
|
||
const posted = f.postings.filter((p) => p.role_category);
|
||
if (!posted.length) {
|
||
return doc(
|
||
text('No positions have been posted yet, so there is nothing to benchmark this one against.'),
|
||
note('Once a few roles exist I can compare pay, experience and requirements by category.')
|
||
);
|
||
}
|
||
|
||
const byCategory = new Map();
|
||
posted.forEach((p) => {
|
||
const entry = byCategory.get(p.role_category) || { category: p.role_category, list: [] };
|
||
entry.list.push(p);
|
||
byCategory.set(p.role_category, entry);
|
||
});
|
||
|
||
const rows = [...byCategory.values()]
|
||
.sort((a, b) => b.list.length - a.list.length)
|
||
.slice(0, 6)
|
||
.map(({ category, list: roles }) => ({
|
||
category,
|
||
posted: roles.length,
|
||
pay: across(roles, (p) => Number(p.pay_range_min))
|
||
? `$${across(roles, (p) => Number(p.pay_range_min))}–${across(roles, (p) => Number(p.pay_range_max))}/hr`
|
||
: '—',
|
||
experience: `${across(roles, (p) => Number(p.min_experience_years))} yrs`,
|
||
}));
|
||
|
||
return doc(
|
||
heading('What comparable roles ask for', `${plural(posted.length, 'position')} on file`),
|
||
table(
|
||
[
|
||
{ key: 'category', label: 'Category' },
|
||
{ key: 'posted', label: 'Posted', align: 'right' },
|
||
{ key: 'pay', label: 'Pay', align: 'right' },
|
||
{ key: 'experience', label: 'Min experience', align: 'right' },
|
||
],
|
||
rows
|
||
),
|
||
note('Averages across positions already posted in this workspace.')
|
||
);
|
||
};
|
||
|
||
/** The credentials this workspace actually asks for, ranked by how often. */
|
||
const positionRequirements = (f) => {
|
||
const counts = new Map();
|
||
f.postings.forEach((p) => {
|
||
(p.certifications_required || []).forEach((c) => counts.set(c, (counts.get(c) || 0) + 1));
|
||
});
|
||
|
||
if (!counts.size) {
|
||
return doc(
|
||
text('No position on file requires a certification, so there is no pattern to follow here.'),
|
||
note('Add only the credentials this role genuinely needs — every one narrows the pool.')
|
||
);
|
||
}
|
||
|
||
const ranked = [...counts.entries()].sort((a, b) => b[1] - a[1]).slice(0, 8);
|
||
return doc(
|
||
heading('Credentials this workspace asks for', `${plural(counts.size, 'certification')} in use`),
|
||
meters(ranked.map(([label, count]) => ({
|
||
label,
|
||
value: count,
|
||
max: f.postings.length || count,
|
||
}))),
|
||
note('Ranked by how many positions require them. Each requirement narrows the pool it is added to.')
|
||
);
|
||
};
|
||
|
||
/** How the form's own steps fit together, for someone new to it. */
|
||
const positionSpecSteps = () => doc(
|
||
heading('Specifying a position'),
|
||
list([
|
||
'Role category and job title — what the matching engine files this under',
|
||
'Client, requirements and pay — what the posting states',
|
||
'Certifications and required skills — what a person must hold',
|
||
'Generate Job Description with AI — writes the prose from the fields above',
|
||
'AI vetting weights — how the screening score is composed, totalling 100%',
|
||
], { ordered: true }),
|
||
note('Nothing is posted until you choose Save as Draft or Publish.')
|
||
);
|
||
|
||
export const respondCreatePosition = (question, f) => {
|
||
const q = question.toLowerCase();
|
||
if (has(q, 'weight', 'vetting', 'criteria', 'scoring', 'score')) return vettingWeights(f);
|
||
if (has(q, 'certification', 'credential', 'requirement', 'skill')) return positionRequirements(f);
|
||
if (has(q, 'pay', 'rate', 'salary', 'benchmark', 'compare', 'experience', 'typical')) return positionBenchmarks(f);
|
||
if (has(q, 'how do i', 'step', 'what do i', 'fill', 'form', 'explain')) return positionSpecSteps(f);
|
||
return positionSpecSteps(f);
|
||
};
|
||
|
||
export const CREATE_POSITION_CAPABILITIES = [
|
||
{ id: 'vetting-weights', label: 'Explain Vetting Weights', icon: Gauge, run: vettingWeights },
|
||
{ id: 'position-benchmarks', label: 'Compare With Similar Roles', icon: ArrowLeftRight, run: positionBenchmarks },
|
||
{ id: 'position-requirements', label: 'Credentials In Use', icon: ShieldCheck, run: positionRequirements },
|
||
{ id: 'position-spec-steps', label: 'How This Form Works', icon: Lightbulb, run: positionSpecSteps },
|
||
];
|