update hired page
This commit is contained in:
@@ -1,12 +1,14 @@
|
||||
import {
|
||||
Activity, AlertTriangle, ArrowLeftRight, BellRing, Eye, Filter, Gauge, GitBranch,
|
||||
Layers, Lightbulb, ScrollText, ShieldAlert, Target, TrendingUp, UserCheck, Users,
|
||||
Activity, AlertTriangle, ArrowLeftRight, Award, BellRing, Clock, Eye, 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 { plural, verb } from '../insights';
|
||||
import { pct, plural, verb } from '../insights';
|
||||
import { toFICO } from '@/lib/talentHome';
|
||||
|
||||
/**
|
||||
* Admin capabilities — Control Center, Candidates Analysis, Activity.
|
||||
@@ -1351,3 +1353,463 @@ export const ACTIVITY_CAPABILITIES = [
|
||||
{ id: 'unusual-activity', label: 'Unusual Activity', icon: AlertTriangle, run: unusualActivity },
|
||||
{ id: 'security-insights', label: 'Security Insights', icon: ShieldAlert, run: securityInsights },
|
||||
];
|
||||
|
||||
/* ── KROW Forge ─────────────────────────────────────────────────────────── */
|
||||
|
||||
/**
|
||||
* Forge capabilities — workforce skill verification.
|
||||
*
|
||||
* The Forge questions are not hiring questions: they are about proof. What is
|
||||
* verified, what can be proven next, and what is gating the rest. Every figure
|
||||
* comes from `f.forge`, which is derived with the same unlock gate the page
|
||||
* renders from.
|
||||
*/
|
||||
|
||||
const forgeProgress = (f) => {
|
||||
const g = f.forge;
|
||||
return doc(
|
||||
heading('Forge progress', `${plural(g.courses.length, 'challenge')} in the library`),
|
||||
kpis([
|
||||
{ label: 'Verified skills', value: g.verified.length, sub: g.verified.length ? 'proven' : 'nothing proven yet' },
|
||||
{ label: 'In progress', value: g.inProgress.length, sub: 'challenges started' },
|
||||
{ label: 'Ready to start', value: g.ready.length, sub: 'unlocked now' },
|
||||
{ label: 'XP available', value: g.xpAvailable, sub: `across ${plural(g.courses.length - g.completed.length, 'challenge')}` },
|
||||
]),
|
||||
g.verified.length
|
||||
? list(g.verified.map((b) => `**${b.name}** — ${b.level || 'Standard'}, verified`))
|
||||
: text('No skill is verified yet. The first one comes from completing a challenge and submitting proof for Owliver to grade.'),
|
||||
g.byDifficulty.length ? heading('Library by difficulty') : null,
|
||||
meters(g.byDifficulty.map((d) => ({
|
||||
label: d.level.charAt(0).toUpperCase() + d.level.slice(1),
|
||||
value: d.count,
|
||||
max: g.courses.length,
|
||||
}))),
|
||||
g.locked.length
|
||||
? note(`${plural(g.locked.length, 'challenge')} ${verb(g.locked.length, 'is', 'are')} still gated by real-work requirements.`)
|
||||
: null
|
||||
);
|
||||
};
|
||||
|
||||
const forgeNextChallenge = (f) => {
|
||||
const g = f.forge;
|
||||
if (!g.next) return answer('Every active challenge is complete — there is nothing left to start.');
|
||||
|
||||
const c = g.next;
|
||||
const gate = g.blocked.find((b) => b.course.id === c.id);
|
||||
|
||||
return doc(
|
||||
heading(c.title, `${c.proof_skill || c.category} · ${c.difficulty}`),
|
||||
text(c.description),
|
||||
kpis([
|
||||
{ label: 'Duration', value: `${c.estimated_minutes} min` },
|
||||
{ label: 'XP', value: c.xp },
|
||||
{ label: 'Earns', value: c.badge_reward || '—' },
|
||||
{ label: 'State', value: gate ? 'Locked' : 'Ready', tone: gate ? 'warning' : 'success' },
|
||||
]),
|
||||
gate
|
||||
? insights([{
|
||||
tone: 'warning',
|
||||
title: 'Blocked for now',
|
||||
body: `${gate.reasons.join('. ')}. Those are real-work requirements, so they clear through shifts rather than through study.`,
|
||||
}])
|
||||
: insights([{
|
||||
tone: 'success',
|
||||
title: 'Ready to start',
|
||||
body: `Nothing is gating this one. ${plural(c.estimated_minutes, 'minute')} of work is worth ${c.xp} XP${c.badge_reward ? ` and the ${c.badge_reward} badge` : ''}.`,
|
||||
}]),
|
||||
g.ready.length > 1
|
||||
? actions(g.ready.slice(1, 3).map((alt) => ({
|
||||
title: alt.title,
|
||||
body: `${alt.difficulty} · ${alt.estimated_minutes} min · ${alt.xp} XP. An alternative if this one does not fit today.`,
|
||||
})))
|
||||
: null
|
||||
);
|
||||
};
|
||||
|
||||
const forgeBlockers = (f) => {
|
||||
const g = f.forge;
|
||||
if (!g.locked.length) {
|
||||
return answer(`Nothing is locked — all ${plural(g.ready.length, 'remaining challenge')} can be started now.`);
|
||||
}
|
||||
|
||||
return doc(
|
||||
heading(`${plural(g.locked.length, 'challenge')} blocked`, 'and what clears each one'),
|
||||
table(
|
||||
[
|
||||
{ key: 'challenge', label: 'Challenge' },
|
||||
{ key: 'skill', label: 'Skill' },
|
||||
{ key: 'requirement', label: 'Blocked by' },
|
||||
],
|
||||
g.blocked.map(({ course, reasons }) => ({
|
||||
challenge: course.title,
|
||||
skill: course.proof_skill || course.category,
|
||||
requirement: reasons[0] || '—',
|
||||
}))
|
||||
),
|
||||
insights(g.blocked.slice(0, 2).map(({ course, reasons }) => ({
|
||||
tone: 'warning',
|
||||
title: course.title,
|
||||
body: `${reasons.join('. ')}. Worth ${course.xp} XP${course.badge_reward ? ` and the ${course.badge_reward} badge` : ''} once it opens.`,
|
||||
}))),
|
||||
note('Requirements are measured against the profile record, so they update as shifts and reliability are logged.')
|
||||
);
|
||||
};
|
||||
|
||||
const forgeReadyNow = (f) => {
|
||||
const g = f.forge;
|
||||
if (!g.ready.length) {
|
||||
return doc(
|
||||
text('Nothing is unlocked right now.'),
|
||||
g.locked.length ? text(`${plural(g.locked.length, 'challenge')} ${verb(g.locked.length, 'is', 'are')} waiting on real-work requirements.`) : null
|
||||
);
|
||||
}
|
||||
|
||||
return doc(
|
||||
heading(`${plural(g.ready.length, 'challenge')} ready to start`),
|
||||
table(
|
||||
[
|
||||
{ key: 'challenge', label: 'Challenge' },
|
||||
{ key: 'difficulty', label: 'Difficulty' },
|
||||
{ key: 'minutes', label: 'Min', align: 'right' },
|
||||
{ key: 'xp', label: 'XP', align: 'right' },
|
||||
],
|
||||
g.ready.map((c) => ({
|
||||
challenge: c.title,
|
||||
difficulty: c.difficulty,
|
||||
minutes: c.estimated_minutes,
|
||||
xp: c.xp,
|
||||
}))
|
||||
),
|
||||
text(
|
||||
`Cheapest first: **${[...g.ready].sort((a, b) => a.estimated_minutes - b.estimated_minutes)[0].title}**. `
|
||||
+ `Highest value: **${[...g.ready].sort((a, b) => b.xp - a.xp)[0].title}**.`
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
const forgeSkillMap = (f) => {
|
||||
const g = f.forge;
|
||||
const skills = g.courses.map((c) => ({
|
||||
skill: c.proof_skill || c.category || 'Workforce skill',
|
||||
state: g.verified.some((b) => b.course_id === c.id)
|
||||
? 'Verified'
|
||||
: g.locked.some((l) => l.id === c.id) ? 'Locked' : 'Ready',
|
||||
xp: c.xp,
|
||||
}));
|
||||
|
||||
return doc(
|
||||
heading('Skills this library can verify', plural(skills.length, 'skill')),
|
||||
table(
|
||||
[
|
||||
{ key: 'skill', label: 'Skill' },
|
||||
{ key: 'state', label: 'State' },
|
||||
{ key: 'xp', label: 'XP', align: 'right' },
|
||||
],
|
||||
skills
|
||||
),
|
||||
text(
|
||||
g.verified.length
|
||||
? `${plural(g.verified.length, 'skill')} already carries evidence an employer can read.`
|
||||
: 'None of these carry evidence yet. Each verified skill adds a record an employer can read against the profile.'
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
/* ── 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, 'block', 'locked', 'lock', 'requirement', 'prerequisite', 'why can', 'unlock')) return forgeBlockers(f);
|
||||
if (has(q, 'next', 'should i', 'which challenge', 'recommend', 'best')) return forgeNextChallenge(f);
|
||||
if (has(q, 'ready', 'start now', 'available', 'can i start', 'active challenge')) return forgeReadyNow(f);
|
||||
if (has(q, 'skill', 'verified', 'proof', 'evidence', 'badge')) return forgeSkillMap(f);
|
||||
if (has(q, 'summar', 'progress', 'how am i', 'xp', 'overview')) return forgeProgress(f);
|
||||
return forgeProgress(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-progress', label: 'Forge Progress', icon: Gauge, run: forgeProgress },
|
||||
{ id: 'forge-next', label: 'Next Challenge', icon: Target, run: forgeNextChallenge },
|
||||
{ id: 'forge-ready', label: 'Ready To Start', icon: UserCheck, run: forgeReadyNow },
|
||||
{ id: 'forge-blockers', label: 'What Is Blocking Me', icon: Lock, run: forgeBlockers },
|
||||
{ id: 'forge-skills', label: 'Skill Map', icon: ShieldCheck, run: forgeSkillMap },
|
||||
];
|
||||
|
||||
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 },
|
||||
];
|
||||
|
||||
@@ -4,9 +4,11 @@ import {
|
||||
} from './capabilities/employer';
|
||||
import {
|
||||
ACTIVITY_CAPABILITIES, ADMIN_ANALYTICS_CAPABILITIES, ADMIN_CANDIDATE_CAPABILITIES,
|
||||
CANDIDATE_LIST_CAPABILITIES, CONTROL_CENTER_CAPABILITIES, POSITIONS_CAPABILITIES,
|
||||
CANDIDATE_LIST_CAPABILITIES, CONTROL_CENTER_CAPABILITIES, FORGE_CAPABILITIES,
|
||||
HIRED_HISTORY_CAPABILITIES, POSITIONS_CAPABILITIES, TALENT_POOL_CAPABILITIES,
|
||||
respondActivity, respondAdminAnalytics, respondAdminCandidates, respondCandidateList,
|
||||
respondControlCenter, respondPositions,
|
||||
respondControlCenter, respondForge, respondHiredHistory, respondPositions,
|
||||
respondTalentPool,
|
||||
} from './capabilities/admin';
|
||||
|
||||
/**
|
||||
@@ -74,6 +76,27 @@ export const ASSISTANT_CONTEXTS = {
|
||||
capabilities: ADMIN_ANALYTICS_CAPABILITIES,
|
||||
respond: respondAdminAnalytics,
|
||||
},
|
||||
/* Forge, Talent Pool and Hired History read the same platform records as the
|
||||
contexts above, but ask different questions of them: proof and progression,
|
||||
supply and readiness, and outcomes after the hire. */
|
||||
'admin.forge': {
|
||||
id: 'admin.forge',
|
||||
page: 'KROW Forge',
|
||||
capabilities: FORGE_CAPABILITIES,
|
||||
respond: respondForge,
|
||||
},
|
||||
'admin.talentPool': {
|
||||
id: 'admin.talentPool',
|
||||
page: 'Talent Pool',
|
||||
capabilities: TALENT_POOL_CAPABILITIES,
|
||||
respond: respondTalentPool,
|
||||
},
|
||||
'admin.hiredHistory': {
|
||||
id: 'admin.hiredHistory',
|
||||
page: 'Hired History',
|
||||
capabilities: HIRED_HISTORY_CAPABILITIES,
|
||||
respond: respondHiredHistory,
|
||||
},
|
||||
'admin.activity': {
|
||||
id: 'admin.activity',
|
||||
page: 'Activity',
|
||||
|
||||
@@ -133,6 +133,38 @@ const GREETINGS = {
|
||||
return `${plural(f.hired.length, 'hire')} from ${plural(f.total, 'applicant')} — ${f.hireRate}% conversion.`;
|
||||
},
|
||||
|
||||
'admin.forge': (f) => {
|
||||
const g = f.forge;
|
||||
if (!g.courses.length) return 'No challenges published yet, so nothing can be proven here.';
|
||||
if (g.locked.length && !g.ready.length) {
|
||||
return `${plural(g.locked.length, 'challenge')} ${verb(g.locked.length, 'is', 'are')} gated by real-work requirements.`;
|
||||
}
|
||||
if (!g.verified.length) {
|
||||
return `Nothing verified yet — ${plural(g.ready.length, 'challenge')} ${verb(g.ready.length, 'is', 'are')} ready to start.`;
|
||||
}
|
||||
return `${plural(g.verified.length, 'skill')} verified, ${plural(g.ready.length, 'challenge')} ready to start.`;
|
||||
},
|
||||
|
||||
'admin.talentPool': (f) => {
|
||||
if (!f.profiles.length) return 'No talent profiles registered yet.';
|
||||
if (f.unscoredProfiles.length) {
|
||||
return `${plural(f.unscoredProfiles.length, 'profile')} ${verb(f.unscoredProfiles.length, 'has', 'have')} no career score and cannot be ranked.`;
|
||||
}
|
||||
if (f.talent.unverified.length) {
|
||||
return `${plural(f.talent.unverified.length, 'profile')} ${verb(f.talent.unverified.length, 'carries', 'carry')} no verified skill.`;
|
||||
}
|
||||
return `${plural(f.profiles.length, 'worker')} in the pool, ${f.elite.length} Elite.`;
|
||||
},
|
||||
|
||||
'admin.hiredHistory': (f) => {
|
||||
const h = f.hiring;
|
||||
if (!h.hires.length) return 'Nobody has been hired yet, so there is no history to read.';
|
||||
if (h.unrated.length) {
|
||||
return `${plural(h.unrated.length, 'hire')} ${verb(h.unrated.length, 'has', 'have')} no client rating on file.`;
|
||||
}
|
||||
return `${plural(h.hires.length, 'hire')} placed, averaging ${h.avgQuality} at hire.`;
|
||||
},
|
||||
|
||||
'admin.activity': (f) => {
|
||||
if (!f.activity.length) return 'No activity logged yet.';
|
||||
const patterns = f.activitySignals.flags.length;
|
||||
@@ -168,6 +200,9 @@ const TITLES = {
|
||||
'admin.candidates': (f, name) => `Good ${timeOfDay(f.today)}${name ? `, ${name}` : ''}`,
|
||||
'admin.analytics': (f, name) => `Good ${timeOfDay(f.today)}${name ? `, ${name}` : ''}`,
|
||||
'admin.activity': (f, name) => `Good ${timeOfDay(f.today)}${name ? `, ${name}` : ''}`,
|
||||
'admin.forge': (f, name) => `Good ${timeOfDay(f.today)}${name ? `, ${name}` : ''}`,
|
||||
'admin.talentPool': (f, name) => `Good ${timeOfDay(f.today)}${name ? `, ${name}` : ''}`,
|
||||
'admin.hiredHistory': (f, name) => `Good ${timeOfDay(f.today)}${name ? `, ${name}` : ''}`,
|
||||
};
|
||||
|
||||
/** Descriptions state what this page's assistant can actually do with the data. */
|
||||
@@ -187,6 +222,12 @@ const DESCRIPTIONS = {
|
||||
'admin.analytics': (f) =>
|
||||
`Reading hiring performance across ${plural(f.openPositions.length, 'position')}, ${plural(f.total, 'candidate')} and ${plural(new Set(f.byRole.map((r) => r.category)).size, 'department')} — conversion, speed and outcomes.`,
|
||||
'admin.activity': (f) => `${plural(f.activity.length, 'logged event')}, reconciled and profiled.`,
|
||||
'admin.forge': (f) =>
|
||||
`Reading ${plural(f.forge.courses.length, 'challenge')} — verified skills, what is ready to start and what is still locked.`,
|
||||
'admin.talentPool': (f) =>
|
||||
`Reading ${plural(f.profiles.length, 'talent profile')} — availability, career score and verification.`,
|
||||
'admin.hiredHistory': (f) =>
|
||||
`Reading ${plural(f.hiring.hires.length, 'hire')} across ${plural(f.hiring.byDepartment.length, 'department')} — quality, speed and outcomes.`,
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -242,6 +283,21 @@ const PLACEHOLDERS = {
|
||||
'Ask Owliver about unusual activity…',
|
||||
'Ask Owliver about security concerns…',
|
||||
],
|
||||
'admin.forge': () => [
|
||||
'Ask Owliver which challenge to take next…',
|
||||
'Ask Owliver what is blocking a skill…',
|
||||
'Ask Owliver about Forge progress…',
|
||||
],
|
||||
'admin.talentPool': () => [
|
||||
'Ask Owliver who to prioritize…',
|
||||
'Ask Owliver about verification gaps…',
|
||||
'Ask Owliver who is available…',
|
||||
],
|
||||
'admin.hiredHistory': () => [
|
||||
'Ask Owliver about hiring outcomes…',
|
||||
'Ask Owliver which roles hired best…',
|
||||
'Ask Owliver what stands out…',
|
||||
],
|
||||
};
|
||||
|
||||
/* ── Suggested prompts ──────────────────────────────────────────────────── */
|
||||
@@ -365,6 +421,58 @@ const PROMPTS = {
|
||||
: { label: 'What needs attention?', prompt: 'What needs attention?', capability: 'attention-required' },
|
||||
],
|
||||
|
||||
/* Forge, Talent Pool and Hired History. Same rule as the contexts above: the
|
||||
first chip is whatever the data has made urgent, so the set reorders itself
|
||||
rather than offering the same questions regardless of state. */
|
||||
'admin.forge': (f) => {
|
||||
const g = f.forge;
|
||||
return [
|
||||
g.next
|
||||
? { label: `Should I take ${g.next.title}?`, prompt: `Which challenge should I complete next? Start with ${g.next.title}`, capability: 'forge-next' }
|
||||
: { label: 'Which challenge should I take next?', prompt: 'Which challenge should I complete next?', capability: 'forge-next' },
|
||||
g.locked.length
|
||||
? { label: `${g.locked.length} blocked by requirements`, prompt: 'What is blocking my next skill?', capability: 'forge-blockers' }
|
||||
: { label: 'What is blocking my next skill?', prompt: 'What is blocking my next skill?', capability: 'forge-blockers' },
|
||||
g.ready.length
|
||||
? { label: `${g.ready.length} ready to start`, prompt: 'Which challenges can I start right now?', capability: 'forge-ready' }
|
||||
: { label: 'Which challenges can I start?', prompt: 'Which challenges can I start right now?', capability: 'forge-ready' },
|
||||
{ label: 'Which skills am I closest to unlocking?', prompt: 'Which skills am I closest to unlocking?', capability: 'forge-skills' },
|
||||
{ label: 'Summarize my Forge progress', prompt: 'Summarize my Forge progress', capability: 'forge-progress' },
|
||||
];
|
||||
},
|
||||
|
||||
'admin.talentPool': (f) => {
|
||||
const t = f.talent;
|
||||
return [
|
||||
t.top[0]
|
||||
? { label: `Why does ${t.top[0].full_name?.split(' ')[0] || 'the top profile'} rank first?`, prompt: 'Which talent should I prioritize?', capability: 'talent-priorities' }
|
||||
: { label: 'Which talent should I prioritize?', prompt: 'Which talent should I prioritize?', capability: 'talent-priorities' },
|
||||
f.unscoredProfiles.length
|
||||
? { label: `${f.unscoredProfiles.length} profiles unscored`, prompt: 'Which profiles are unscored?', capability: 'talent-summary' }
|
||||
: { label: 'Summarize the talent pool', prompt: 'Summarize the talent pool', capability: 'talent-summary' },
|
||||
t.unverified.length
|
||||
? { label: `${t.unverified.length} need verification`, prompt: 'Which talent needs verification?', capability: 'talent-verification' }
|
||||
: { label: 'Which talent needs verification?', prompt: 'Which talent needs verification?', capability: 'talent-verification' },
|
||||
{ label: 'Who is currently available?', prompt: 'Who is currently available?', capability: 'talent-availability' },
|
||||
{ label: 'Who has the strongest score?', prompt: 'Who has the strongest score?', capability: 'talent-priorities' },
|
||||
];
|
||||
},
|
||||
|
||||
'admin.hiredHistory': (f) => {
|
||||
const h = f.hiring;
|
||||
return [
|
||||
h.unrated.length
|
||||
? { label: `${h.unrated.length} hires unrated`, prompt: 'What needs attention in hiring outcomes?', capability: 'hiring-patterns' }
|
||||
: { label: 'What hiring patterns stand out?', prompt: 'What hiring patterns stand out?', capability: 'hiring-patterns' },
|
||||
h.byDepartment[0]
|
||||
? { label: `${h.byDepartment[0].name} leads on hires`, prompt: 'Which department has the most hires?', capability: 'hiring-outcomes' }
|
||||
: { label: 'Which department has the most hires?', prompt: 'Which department has the most hires?', capability: 'hiring-outcomes' },
|
||||
{ label: 'Which positions hired the strongest talent?', prompt: 'Which positions hired the strongest talent?', capability: 'hiring-strongest' },
|
||||
{ label: 'Summarize recent hires', prompt: 'Summarize recent hires', capability: 'hiring-recent' },
|
||||
{ label: 'What hiring outcomes should I review?', prompt: 'What hiring outcomes should I review?', capability: 'hiring-outcomes' },
|
||||
];
|
||||
},
|
||||
|
||||
'admin.activity': (f) => [
|
||||
f.activitySignals.flags.length
|
||||
? { label: `${f.activitySignals.flags.length} unusual patterns`, prompt: 'Show unusual activity on the platform', capability: 'unusual-activity' }
|
||||
|
||||
@@ -9,6 +9,9 @@
|
||||
* transport, no dependency on any AI product.
|
||||
*/
|
||||
|
||||
import { isUnlocked } from '@/lib/provingGround';
|
||||
import { getScoreBand } from '@/lib/talentHome';
|
||||
|
||||
export const pct = (n, d) => (d ? Math.round((n / d) * 100) : 0);
|
||||
|
||||
export const plural = (n, word, irregular) =>
|
||||
@@ -174,6 +177,107 @@ export function buildFacts({
|
||||
};
|
||||
})();
|
||||
|
||||
/* ── KROW Forge ──────────────────────────────────────────────────────────
|
||||
Derived with the same gate (`isUnlocked`) and the same completion record the
|
||||
Forge page renders from, so the panel and the page cannot disagree about
|
||||
what is ready, what is blocked, or why. `profile` is the signed-in worker's
|
||||
record when one is already in cache; without it the counts are the library's
|
||||
rather than the person's, which is still true — just less personal. */
|
||||
const forgeCourses = courses.filter((c) => c.status === 'active');
|
||||
const forgeCompletedIds = new Set((profile?.completed_courses || []).map((c) => c.course_id));
|
||||
const forgeGate = (course) => isUnlocked(course, profile || {});
|
||||
const forgeUnfinished = forgeCourses.filter((c) => !forgeCompletedIds.has(c.id));
|
||||
const forgeReady = forgeUnfinished.filter((c) => forgeGate(c).unlocked);
|
||||
const forgeLocked = forgeUnfinished.filter((c) => !forgeGate(c).unlocked);
|
||||
const forge = {
|
||||
courses: forgeCourses,
|
||||
verified: profile?.earned_badges || [],
|
||||
completed: (profile?.completed_courses || []).filter((c) => c.status !== 'in_progress'),
|
||||
inProgress: (profile?.completed_courses || []).filter((c) => c.status === 'in_progress'),
|
||||
ready: forgeReady,
|
||||
locked: forgeLocked,
|
||||
/* The page features the first unfinished challenge whether or not it is
|
||||
unlocked, so the panel names the same one — a chip that recommends a
|
||||
different challenge than the one on screen reads as a second opinion. */
|
||||
next: forgeUnfinished[0] || null,
|
||||
xpAvailable: forgeUnfinished.reduce((sum, c) => sum + (c.xp || 0), 0),
|
||||
/* Every blocked challenge with the reasons that block it, in page order. */
|
||||
blocked: forgeLocked.map((course) => ({ course, reasons: forgeGate(course).reasons })),
|
||||
byDifficulty: ['beginner', 'intermediate', 'advanced'].map((level) => ({
|
||||
level,
|
||||
count: forgeCourses.filter((c) => c.difficulty === level).length,
|
||||
})).filter((d) => d.count),
|
||||
};
|
||||
|
||||
/* ── Talent pool ─────────────────────────────────────────────────────────
|
||||
The same score bands the Talent Pool page labels profiles with. */
|
||||
const rankedProfiles = [...profiles].sort((a, b) => (b.krow_score || 0) - (a.krow_score || 0));
|
||||
const talent = {
|
||||
ranked: rankedProfiles,
|
||||
top: rankedProfiles.slice(0, 3),
|
||||
available: profiles.filter((p) => (p.availability || []).length > 0),
|
||||
unverified: profiles.filter(
|
||||
(p) => !(p.earned_badges || []).length
|
||||
&& !(p.capabilities || []).some((c) => c.status === 'verified')
|
||||
),
|
||||
experienced: profiles.filter((p) => (p.experience_years || 0) >= 3),
|
||||
avgScore: avg(profiles.filter((p) => p.krow_score > 0).map((p) => p.krow_score)),
|
||||
bands: Object.values(profiles.reduce((acc, p) => {
|
||||
const { label } = getScoreBand(p.krow_score || 0);
|
||||
acc[label] ||= { label, count: 0 };
|
||||
acc[label].count += 1;
|
||||
return acc;
|
||||
}, {})).sort((a, b) => b.count - a.count),
|
||||
};
|
||||
|
||||
/* ── Hired history ───────────────────────────────────────────────────────
|
||||
Staff joined to the application and posting they came from — the same join
|
||||
the Hired History page performs, so department, speed and score match. */
|
||||
const hires = staff.map((s) => {
|
||||
const app = applications.find((a) => a.id === s.application_id);
|
||||
const posting = postings.find((p) => p.id === s.job_posting_id);
|
||||
return {
|
||||
...s,
|
||||
department: posting?.role_category || '—',
|
||||
timeToHire: app
|
||||
? Math.max(1, Math.round(
|
||||
(new Date(app.updated_date).getTime() - new Date(app.created_date).getTime()) / DAY_MS
|
||||
))
|
||||
: null,
|
||||
score: s.ai_score || app?.ai_score || 0,
|
||||
};
|
||||
});
|
||||
|
||||
const groupHires = (key) => Object.values(hires.reduce((acc, h) => {
|
||||
const name = h[key] || 'Unspecified';
|
||||
acc[name] ||= { name, count: 0, scores: [], days: [], ratings: [] };
|
||||
acc[name].count += 1;
|
||||
if (h.score) acc[name].scores.push(h.score);
|
||||
if (h.timeToHire) acc[name].days.push(h.timeToHire);
|
||||
if (h.client_rating) acc[name].ratings.push(h.client_rating);
|
||||
return acc;
|
||||
}, {})).map((e) => ({
|
||||
name: e.name,
|
||||
count: e.count,
|
||||
avgScore: avg(e.scores),
|
||||
avgDays: avg(e.days),
|
||||
avgRating: e.ratings.length
|
||||
? Number((e.ratings.reduce((a, b) => a + b, 0) / e.ratings.length).toFixed(1))
|
||||
: null,
|
||||
})).sort((a, b) => b.count - a.count);
|
||||
|
||||
const hiring = {
|
||||
hires,
|
||||
byDepartment: groupHires('department'),
|
||||
byRoleTitle: groupHires('role'),
|
||||
unrated: hires.filter((h) => !h.client_rating),
|
||||
rated: hires.filter((h) => h.client_rating),
|
||||
avgQuality: avg(hires.map((h) => h.score).filter(Boolean)),
|
||||
avgSpeed: avg(hires.map((h) => h.timeToHire).filter(Boolean)),
|
||||
strongest: [...hires].sort((a, b) => (b.score || 0) - (a.score || 0))[0] || null,
|
||||
onboarding: hires.filter((h) => h.status === 'onboarding'),
|
||||
};
|
||||
|
||||
return {
|
||||
today,
|
||||
|
||||
@@ -248,9 +352,12 @@ export function buildFacts({
|
||||
eventCounts,
|
||||
activitySignals,
|
||||
|
||||
/* Signed-in worker (talent context only) */
|
||||
/* Signed-in worker, and the pages built on these records */
|
||||
profile,
|
||||
courses,
|
||||
forge,
|
||||
talent,
|
||||
hiring,
|
||||
|
||||
/* Economics — mirrors ImpactMetrics */
|
||||
costSaved: scored.length * 45 + interviews.length * 80,
|
||||
|
||||
@@ -19,6 +19,9 @@ const PLACEMENT = {
|
||||
'/admin/candidates-analysis': 'admin.candidates',
|
||||
'/admin/analytics': 'admin.analytics',
|
||||
'/admin/activity': 'admin.activity',
|
||||
'/admin/university': 'admin.forge',
|
||||
'/admin/talent-pool': 'admin.talentPool',
|
||||
'/admin/hired': 'admin.hiredHistory',
|
||||
},
|
||||
};
|
||||
|
||||
@@ -26,9 +29,9 @@ const PLACEMENT = {
|
||||
* Pages that must never carry the assistant, listed explicitly so the intent is
|
||||
* documented rather than implied by omission.
|
||||
*
|
||||
* Admin: the reporting and reference pages, plus login. The working surfaces —
|
||||
* Control Center, Positions, Candidates, Candidates Analysis, Analytics
|
||||
* and Activity — all carry the panel.
|
||||
* Admin: profile and login only. Every working surface — Control Center,
|
||||
* Positions, Candidates, Candidates Analysis, Analytics, Activity,
|
||||
* KROW Forge, Talent Pool and Hired History — carries the panel.
|
||||
* Employer: every page. The contextual panel is an Admin capability.
|
||||
* Talent: every page. The talent portal has the standalone Owliver product,
|
||||
* with its own voice experience, branding and workflow.
|
||||
@@ -39,8 +42,8 @@ export const EXCLUDED_ROUTES = [
|
||||
'/hired', '/talent-pool', '/analytics', '/profile', '/apply',
|
||||
'/university', '/university/:id', '/me', '/identity', '/employee',
|
||||
'/owliver', '/design-system',
|
||||
// Admin pages that are a report or a form rather than a workflow.
|
||||
'/admin/hired', '/admin/talent-pool', '/admin/profile', '/admin/login',
|
||||
// Admin pages that are a form rather than a workflow.
|
||||
'/admin/profile', '/admin/login',
|
||||
// The full candidate profile is one person's record, not a page-level question.
|
||||
'/admin/candidates/:id',
|
||||
];
|
||||
|
||||
@@ -3,7 +3,7 @@ import { useQuery } from '@tanstack/react-query';
|
||||
import { base44 } from '@/api/base44Client';
|
||||
import {
|
||||
useApplications, useCourses, useInterviews, useJobPostings, useStaff,
|
||||
useUserActivity, useWorkerProfiles,
|
||||
useUserActivity, useWorkerProfile, useWorkerProfiles,
|
||||
} from '@/lib/krowHooks';
|
||||
import { buildFacts } from './insights';
|
||||
import { createAssistantProvider } from './provider';
|
||||
@@ -25,12 +25,16 @@ export function useAssistantFacts() {
|
||||
const { data: activity = [] } = useUserActivity();
|
||||
const { data: courses = [] } = useCourses();
|
||||
|
||||
// Krow AI serves Employer and Admin only, so it never reads the signed-in
|
||||
// worker's own profile — that query also creates the record if absent, and it
|
||||
// belongs to the talent portal, not here.
|
||||
// The signed-in worker's own record, read from cache only: `enabled: false`
|
||||
// means this never runs the query, which would *create* a WorkerProfile for an
|
||||
// admin who has never opened Forge. So the Forge context is personal on pages
|
||||
// that already loaded it, and falls back to library-level facts elsewhere
|
||||
// rather than manufacturing a record as a side effect of opening a panel.
|
||||
const { data: profile = null } = useWorkerProfile({ enabled: false });
|
||||
|
||||
return React.useMemo(
|
||||
() => buildFacts({ applications, postings, interviews, staff, profiles, activity, courses }),
|
||||
[applications, postings, interviews, staff, profiles, activity, courses]
|
||||
() => buildFacts({ applications, postings, interviews, staff, profiles, activity, courses, profile }),
|
||||
[applications, postings, interviews, staff, profiles, activity, courses, profile]
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user