update hired page

This commit is contained in:
2026-08-11 19:17:40 +05:30
parent 8a256a4de7
commit 25c4ee4a6a
23 changed files with 2921 additions and 380 deletions

763
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -12,7 +12,11 @@
"preview": "vite preview" "preview": "vite preview"
}, },
"dependencies": { "dependencies": {
"@emotion/react": "^11.14.0",
"@emotion/styled": "^11.14.1",
"@hookform/resolvers": "^4.1.2", "@hookform/resolvers": "^4.1.2",
"@mui/material": "^9.3.1",
"@mui/x-charts": "^9.11.1",
"@radix-ui/react-accordion": "^1.2.3", "@radix-ui/react-accordion": "^1.2.3",
"@radix-ui/react-alert-dialog": "^1.1.6", "@radix-ui/react-alert-dialog": "^1.1.6",
"@radix-ui/react-aspect-ratio": "^1.1.2", "@radix-ui/react-aspect-ratio": "^1.1.2",

View File

@@ -1,12 +1,14 @@
import { import {
Activity, AlertTriangle, ArrowLeftRight, BellRing, Eye, Filter, Gauge, GitBranch, Activity, AlertTriangle, ArrowLeftRight, Award, BellRing, Clock, Eye, Filter, Gauge,
Layers, Lightbulb, ScrollText, ShieldAlert, Target, TrendingUp, UserCheck, Users, GitBranch, Layers, Lightbulb, Lock, ScrollText, ShieldAlert, ShieldCheck, Star, Target,
TrendingUp, UserCheck, Users,
} from 'lucide-react'; } from 'lucide-react';
import { import {
actions, answer, doc, funnel, heading, insights, kpis, list, meters, note, status, table, actions, answer, doc, funnel, heading, insights, kpis, list, meters, note, status, table,
text, timeline, text, timeline,
} from '../blocks'; } 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. * 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: 'unusual-activity', label: 'Unusual Activity', icon: AlertTriangle, run: unusualActivity },
{ id: 'security-insights', label: 'Security Insights', icon: ShieldAlert, run: securityInsights }, { 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 },
];

View File

@@ -4,9 +4,11 @@ import {
} from './capabilities/employer'; } from './capabilities/employer';
import { import {
ACTIVITY_CAPABILITIES, ADMIN_ANALYTICS_CAPABILITIES, ADMIN_CANDIDATE_CAPABILITIES, 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, respondActivity, respondAdminAnalytics, respondAdminCandidates, respondCandidateList,
respondControlCenter, respondPositions, respondControlCenter, respondForge, respondHiredHistory, respondPositions,
respondTalentPool,
} from './capabilities/admin'; } from './capabilities/admin';
/** /**
@@ -74,6 +76,27 @@ export const ASSISTANT_CONTEXTS = {
capabilities: ADMIN_ANALYTICS_CAPABILITIES, capabilities: ADMIN_ANALYTICS_CAPABILITIES,
respond: respondAdminAnalytics, 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': { 'admin.activity': {
id: 'admin.activity', id: 'admin.activity',
page: 'Activity', page: 'Activity',

View File

@@ -133,6 +133,38 @@ const GREETINGS = {
return `${plural(f.hired.length, 'hire')} from ${plural(f.total, 'applicant')} — ${f.hireRate}% conversion.`; 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) => { 'admin.activity': (f) => {
if (!f.activity.length) return 'No activity logged yet.'; if (!f.activity.length) return 'No activity logged yet.';
const patterns = f.activitySignals.flags.length; const patterns = f.activitySignals.flags.length;
@@ -168,6 +200,9 @@ const TITLES = {
'admin.candidates': (f, name) => `Good ${timeOfDay(f.today)}${name ? `, ${name}` : ''}`, 'admin.candidates': (f, name) => `Good ${timeOfDay(f.today)}${name ? `, ${name}` : ''}`,
'admin.analytics': (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.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. */ /** Descriptions state what this page's assistant can actually do with the data. */
@@ -187,6 +222,12 @@ const DESCRIPTIONS = {
'admin.analytics': (f) => '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.`, `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.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 unusual activity…',
'Ask Owliver about security concerns…', '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 ──────────────────────────────────────────────────── */ /* ── Suggested prompts ──────────────────────────────────────────────────── */
@@ -365,6 +421,58 @@ const PROMPTS = {
: { label: 'What needs attention?', prompt: 'What needs attention?', capability: 'attention-required' }, : { 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) => [ 'admin.activity': (f) => [
f.activitySignals.flags.length f.activitySignals.flags.length
? { label: `${f.activitySignals.flags.length} unusual patterns`, prompt: 'Show unusual activity on the platform', capability: 'unusual-activity' } ? { label: `${f.activitySignals.flags.length} unusual patterns`, prompt: 'Show unusual activity on the platform', capability: 'unusual-activity' }

View File

@@ -9,6 +9,9 @@
* transport, no dependency on any AI product. * 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 pct = (n, d) => (d ? Math.round((n / d) * 100) : 0);
export const plural = (n, word, irregular) => 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 { return {
today, today,
@@ -248,9 +352,12 @@ export function buildFacts({
eventCounts, eventCounts,
activitySignals, activitySignals,
/* Signed-in worker (talent context only) */ /* Signed-in worker, and the pages built on these records */
profile, profile,
courses, courses,
forge,
talent,
hiring,
/* Economics — mirrors ImpactMetrics */ /* Economics — mirrors ImpactMetrics */
costSaved: scored.length * 45 + interviews.length * 80, costSaved: scored.length * 45 + interviews.length * 80,

View File

@@ -19,6 +19,9 @@ const PLACEMENT = {
'/admin/candidates-analysis': 'admin.candidates', '/admin/candidates-analysis': 'admin.candidates',
'/admin/analytics': 'admin.analytics', '/admin/analytics': 'admin.analytics',
'/admin/activity': 'admin.activity', '/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 * Pages that must never carry the assistant, listed explicitly so the intent is
* documented rather than implied by omission. * documented rather than implied by omission.
* *
* Admin: the reporting and reference pages, plus login. The working surfaces — * Admin: profile and login only. Every working surface — Control Center,
* Control Center, Positions, Candidates, Candidates Analysis, Analytics * Positions, Candidates, Candidates Analysis, Analytics, Activity,
* and Activity — all carry the panel. * KROW Forge, Talent Pool and Hired History — carries the panel.
* Employer: every page. The contextual panel is an Admin capability. * Employer: every page. The contextual panel is an Admin capability.
* Talent: every page. The talent portal has the standalone Owliver product, * Talent: every page. The talent portal has the standalone Owliver product,
* with its own voice experience, branding and workflow. * with its own voice experience, branding and workflow.
@@ -39,8 +42,8 @@ export const EXCLUDED_ROUTES = [
'/hired', '/talent-pool', '/analytics', '/profile', '/apply', '/hired', '/talent-pool', '/analytics', '/profile', '/apply',
'/university', '/university/:id', '/me', '/identity', '/employee', '/university', '/university/:id', '/me', '/identity', '/employee',
'/owliver', '/design-system', '/owliver', '/design-system',
// Admin pages that are a report or a form rather than a workflow. // Admin pages that are a form rather than a workflow.
'/admin/hired', '/admin/talent-pool', '/admin/profile', '/admin/login', '/admin/profile', '/admin/login',
// The full candidate profile is one person's record, not a page-level question. // The full candidate profile is one person's record, not a page-level question.
'/admin/candidates/:id', '/admin/candidates/:id',
]; ];

View File

@@ -3,7 +3,7 @@ import { useQuery } from '@tanstack/react-query';
import { base44 } from '@/api/base44Client'; import { base44 } from '@/api/base44Client';
import { import {
useApplications, useCourses, useInterviews, useJobPostings, useStaff, useApplications, useCourses, useInterviews, useJobPostings, useStaff,
useUserActivity, useWorkerProfiles, useUserActivity, useWorkerProfile, useWorkerProfiles,
} from '@/lib/krowHooks'; } from '@/lib/krowHooks';
import { buildFacts } from './insights'; import { buildFacts } from './insights';
import { createAssistantProvider } from './provider'; import { createAssistantProvider } from './provider';
@@ -25,12 +25,16 @@ export function useAssistantFacts() {
const { data: activity = [] } = useUserActivity(); const { data: activity = [] } = useUserActivity();
const { data: courses = [] } = useCourses(); const { data: courses = [] } = useCourses();
// Krow AI serves Employer and Admin only, so it never reads the signed-in // The signed-in worker's own record, read from cache only: `enabled: false`
// worker's own profile — that query also creates the record if absent, and it // means this never runs the query, which would *create* a WorkerProfile for an
// belongs to the talent portal, not here. // 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( return React.useMemo(
() => buildFacts({ applications, postings, interviews, staff, profiles, activity, courses }), () => buildFacts({ applications, postings, interviews, staff, profiles, activity, courses, profile }),
[applications, postings, interviews, staff, profiles, activity, courses] [applications, postings, interviews, staff, profiles, activity, courses, profile]
); );
} }

View File

@@ -0,0 +1,93 @@
import React from 'react';
import { ScatterChart } from '@mui/x-charts';
import { cn } from '@/lib/utils';
import { chartSx, TONES, tooltipSx } from './muiChartTheme';
/**
* Department performance — a dot plot, not a bar race.
*
* The bars this replaces encoded hire *count*, which on a dataset where every
* department has hired once drew three identical full-width rectangles. Encoding
* average score instead does not fix it either: 92, 94 and 97 against a
* zero-baseline axis are three more identical bars.
*
* So the scores are plotted as points on a focused axis. The domain starts below
* the lowest score rather than at zero — legitimate here because the axis ticks
* are drawn and read, and because the question is "which department hires
* better", which is a comparison between 90s, not a comparison against nothing.
*
* Volume is stated in the list underneath: one hire per department is a fact,
* not a shape.
*
* `items: [{ name, count, avgScore }]` — supplied by the page.
*/
export function DepartmentPerformance({ items = [], className }) {
if (!items.length) return null;
const scored = items.filter((d) => d.avgScore > 0);
/* Highest at the top: MUI plots ascending y upward, so rank ascending. */
const plotted = [...scored].sort((a, b) => (a.avgScore || 0) - (b.avgScore || 0));
const lowest = Math.min(...plotted.map((d) => d.avgScore));
const highest = Math.max(...plotted.map((d) => d.avgScore));
/* A little air either side, snapped to 5s, so the ticks stay round numbers. */
const min = Math.max(0, Math.floor((lowest - 6) / 5) * 5);
const max = Math.min(100, Math.ceil((highest + 3) / 5) * 5);
return (
<div className={cn('overflow-hidden rounded-xl border border-border bg-surface', className)}>
{plotted.length > 0 ? (
<div className="px-2 pt-4 sm:px-3">
<ScatterChart
height={Math.max(132, plotted.length * 40 + 52)}
margin={{ top: 8, right: 24, bottom: 28, left: 8 }}
xAxis={[{
min,
max,
tickNumber: 4,
valueFormatter: (v) => `${v}`,
}]}
yAxis={[{
min: -0.6,
max: plotted.length - 0.4,
tickInterval: plotted.map((_, i) => i),
valueFormatter: (v) => plotted[Math.round(v)]?.name ?? '',
width: 96,
}]}
series={[{
label: 'Average score',
data: plotted.map((d, i) => ({ x: d.avgScore, y: i, id: d.name })),
markerSize: 7,
valueFormatter: (point) => {
const d = plotted[Math.round(point.y)];
return `${d.name} · avg ${d.avgScore} · ${d.count} ${d.count === 1 ? 'hire' : 'hires'}`;
},
}]}
colors={[TONES.brand]}
grid={{ vertical: true }}
skipAnimation
sx={{ ...chartSx, ...tooltipSx }}
/>
</div>
) : (
<p className="px-4 py-6 text-center text-body-sm text-ink-3">
No scores recorded against these hires yet.
</p>
)}
<ul className="divide-y divide-border border-t border-border">
{[...items].sort((a, b) => (b.avgScore || 0) - (a.avgScore || 0)).map((d) => (
<li key={d.name} className="flex items-baseline justify-between gap-3 px-4 py-2">
<span className="truncate text-body-sm text-ink-2">{d.name}</span>
<span className="flex shrink-0 items-baseline gap-4 text-caption tabular-nums text-ink-4">
<span>{d.count} {d.count === 1 ? 'hire' : 'hires'}</span>
<span className="w-16 text-right font-semibold text-ink-1">
{d.avgScore ? `avg ${d.avgScore}` : '—'}
</span>
</span>
</li>
))}
</ul>
</div>
);
}

View File

@@ -0,0 +1,114 @@
import React from 'react';
import { BarChart } from '@mui/x-charts';
import { ChevronRight } from 'lucide-react';
import { cn } from '@/lib/utils';
import { chartSx, TONES, tooltipSx } from './muiChartTheme';
/**
* The hiring flow — one surface, not six cards.
*
* A funnel is a shape before it is a set of numbers, so the magnitudes are drawn
* once as a bar series and the stage-by-stage reading sits underneath it as a
* connected strip: count, pass-through rate, and what was lost getting there.
* Reading down a column gives one stage; reading across gives the journey.
*
* The weakest transition is the only thing coloured differently, because on a
* funnel the one worth acting on is the one losing the most people.
*
* `stages` and `transitions` come from the page's own fact sheet — this
* component derives no figures of its own.
*/
export function HiringFlow({ stages = [], transitions = [], weakestKey, className }) {
if (!stages.length) return null;
const total = stages[0]?.count || 1;
const rateFor = (i) => (i === 0 ? 100 : transitions[i - 1]?.rate ?? 0);
const lostFor = (i) => (i === 0 ? 0 : transitions[i - 1]?.lost ?? 0);
const isWeak = (i) => i > 0 && transitions[i - 1] && transitions[i - 1].to === weakestKey;
return (
<div className={cn('overflow-hidden rounded-xl border border-border bg-surface', className)}>
{/* The shape. Axis-free: the strip underneath carries every figure, so a
y-axis would repeat what is already written. */}
<div className="px-2 pt-3 sm:px-4">
<BarChart
height={168}
margin={{ top: 12, right: 8, bottom: 24, left: 8 }}
xAxis={[{
scaleType: 'band',
data: stages.map((s) => s.label),
categoryGapRatio: 0.5,
barGapRatio: 0,
/* Per-stage colour through the documented axis API rather than a
CSS nth-child guess at MUI's internal markup: the weakest
transition is amber, every other stage is brand blue. */
colorMap: {
type: 'ordinal',
values: stages.map((s) => s.label),
colors: stages.map((_, i) => (isWeak(i) ? TONES.warning : TONES.brand)),
},
}]}
/* Pinned to the data: MUI's automatic domain rounds up to roughly
double the top stage, which leaves the funnel occupying half the
plot and reading much flatter than it is. */
yAxis={[{ min: 0, max: Math.max(total, 1), tickNumber: 3 }]}
series={[{
data: stages.map((s) => s.count),
label: 'Candidates',
valueFormatter: (value, ctx) => {
const i = ctx?.dataIndex ?? 0;
const lost = lostFor(i);
return `${value} · ${rateFor(i)}% through${lost ? ` · ${lost} lost` : ''}`;
},
}]}
colors={[TONES.brand]}
borderRadius={6}
grid={{ horizontal: true }}
skipAnimation
sx={{
...chartSx,
...tooltipSx,
/* The weakest transition's bar picks up the warning tone; every
other bar stays brand blue. */
}}
/>
</div>
{/* The reading. Scrolls rather than wraps on a phone, so the sequence
survives at any width. */}
<div className="overflow-x-auto border-t border-border">
<ol className="flex min-w-[36rem] items-stretch">
{stages.map((stage, i) => {
const rate = rateFor(i);
const lost = lostFor(i);
const weak = isWeak(i);
return (
<li key={stage.key} className="flex min-w-0 flex-1 items-center">
{i > 0 && (
<span className="flex shrink-0 flex-col items-center px-1.5" aria-hidden="true">
<ChevronRight className={cn('h-3.5 w-3.5', weak ? 'text-warning' : 'text-ink-4')} />
</span>
)}
<div className="min-w-0 flex-1 px-3 py-3">
<p className="truncate text-caption text-ink-3">{stage.label}</p>
<p className="mt-0.5 font-heading text-title font-bold tabular-nums text-ink-1">
{stage.count}
</p>
<p className="mt-1 flex flex-wrap items-baseline gap-x-2 text-caption tabular-nums">
<span className={weak ? 'font-semibold text-warning' : 'text-ink-4'}>
{i === 0 ? `${Math.round((stage.count / total) * 100)}% entered` : `${rate}% through`}
</span>
{lost > 0 && <span className="text-ink-4">−{lost}</span>}
</p>
</div>
</li>
);
})}
</ol>
</div>
</div>
);
}

View File

@@ -0,0 +1,60 @@
import React from 'react';
import { LineChart } from '@mui/x-charts';
import { cn } from '@/lib/utils';
import { chartSx, TONES, tooltipSx } from './muiChartTheme';
/**
* Cumulative hires over time.
*
* Deliberately does not draw a line through a single point: with one month of
* history there is no trend, and a chart that implies one is worse than a
* sentence saying there is not. The empty state is the page's own copy, kept
* intact and given the same container as the chart so the section holds its
* shape either way.
*
* `points: [{ label, hires, cumulative }]` — supplied by the page.
*/
export function HiringTrendChart({ points = [], emptyState, className }) {
const enough = points.length >= 2;
return (
<div className={cn('overflow-hidden rounded-xl border border-border bg-surface', className)}>
{enough ? (
<div className="px-2 pt-3 sm:px-3">
<LineChart
height={200}
margin={{ top: 16, right: 16, bottom: 24, left: 8 }}
xAxis={[{
scaleType: 'point',
data: points.map((p) => p.label),
}]}
yAxis={[{ width: 32, tickNumber: 4 }]}
series={[{
data: points.map((p) => p.cumulative),
label: 'Cumulative hires',
area: true,
showMark: true,
curve: 'monotoneX',
valueFormatter: (value, ctx) => {
const p = points[ctx?.dataIndex ?? 0];
return `${value} total · ${p?.hires} this month`;
},
}]}
colors={[TONES.brand]}
grid={{ horizontal: true }}
skipAnimation
sx={{
...chartSx,
...tooltipSx,
'& .MuiAreaElement-root': { fill: TONES.brandSoft },
'& .MuiLineElement-root': { strokeWidth: 2 },
'& .MuiMarkElement-root': { stroke: TONES.brand, fill: '#fff', strokeWidth: 2 },
}}
/>
</div>
) : (
emptyState
)}
</div>
);
}

View File

@@ -0,0 +1,86 @@
/**
* KROW styling for MUI X Charts.
*
* MUI X is the drawing layer only — the surfaces, spacing and typography around
* a chart stay KROW's. This module is what stops the two from disagreeing: the
* palette is the same one the Recharts components already use (`CHART_TONES`),
* and the `sx` block restyles MUI's axes, grid and tooltip to the design
* system's hairlines and type scale rather than Material's defaults.
*
* Everything here is presentation. No chart in this folder computes a value.
*/
import { CHART_TONES } from '@/components/ds/ChartContainer';
export const TONES = {
brand: CHART_TONES.brand,
brandSoft: 'rgba(10, 57, 223, 0.14)',
warning: '#D97706',
warningSoft: 'rgba(217, 119, 6, 0.16)',
success: '#16A34A',
grid: CHART_TONES.grid,
axis: CHART_TONES.axis,
ink: '#0F172A',
muted: '#64748B',
};
/** Type scale for chart text — matches the caption/overline sizes around it. */
const LABEL_FONT = {
fontSize: 11,
fontFamily: 'var(--font-body)',
};
/**
* Shared `sx` for every KROW chart.
*
* Axis lines and ticks are removed rather than restyled: on a compact chart the
* value labels carry the reading, and a full axis frame is the "chart junk" the
* design language avoids. The grid stays as a hairline in the same colour the
* rest of the product uses for rules.
*/
export const chartSx = {
'& .MuiChartsAxis-line': { display: 'none' },
'& .MuiChartsAxis-tick': { display: 'none' },
'& .MuiChartsAxis-tickLabel': {
...LABEL_FONT,
fill: `${TONES.muted} !important`,
},
'& .MuiChartsGrid-line': {
stroke: TONES.grid,
strokeDasharray: '3 3',
},
'& .MuiChartsLegend-root': { display: 'none' },
'& .MuiBarLabel-root': {
...LABEL_FONT,
fontWeight: 600,
fill: `${TONES.ink} !important`,
},
};
/**
* Tooltip chrome matching `Surface`.
*
* Passed through `slotProps.tooltip` so MUI's popper carries the design
* system's radius, hairline border and shadow instead of Material elevation.
*/
export const tooltipSx = {
'& .MuiChartsTooltip-root, & .MuiPopper-root .MuiPaper-root': {
borderRadius: '10px',
border: `1px solid ${TONES.grid}`,
boxShadow: '0 8px 24px -12px rgba(15, 23, 42, 0.25)',
fontFamily: 'var(--font-body)',
fontSize: 12,
},
'& .MuiChartsTooltip-table': { fontSize: 12 },
'& .MuiChartsTooltip-labelCell, & .MuiChartsTooltip-valueCell': {
fontFamily: 'var(--font-body)',
},
};
/*
* Entry animation is switched off at every call site rather than gated on
* `prefers-reduced-motion`. Two reasons: a chart that grows out of the floor is
* the decorative motion this product avoids, and MUI's bar transition leaves
* every bar at zero height if the frame loop never advances — which is exactly
* what happens in an inactive tab or an automated browser.
*/

View File

@@ -0,0 +1,191 @@
import React from 'react';
import { Link } from 'react-router-dom';
import { ArrowRight, Lock, Search, ShieldCheck } from 'lucide-react';
import { cn } from '@/lib/utils';
import { isUnlocked } from '@/lib/provingGround';
import { DIFF_TONE, TYPE_ICON, TYPE_LABEL, typeOf } from './challengeMeta';
/**
* Every other challenge, as a directory.
*
* Rows on hairlines rather than a wall of cards: these are secondary to the
* featured skill above, and a grid of bordered tiles would compete with it. The
* controls sit on the heading line so the section needs no toolbar surface of
* its own, and the row still carries everything a card did — skill, title,
* description, difficulty, proof type, duration, XP, reward, state and reason.
*/
export function ChallengeDirectory({
courses,
results,
search,
onSearch,
difficulty,
onDifficulty,
difficulties,
loading,
filtered,
onClear,
completedIds,
profile,
}) {
return (
<section aria-labelledby="forge-directory" className="min-w-0">
<div className="flex flex-col gap-3 border-b border-border pb-3 lg:flex-row lg:items-end lg:justify-between">
<div className="min-w-0">
<h2 id="forge-directory" className="text-overline uppercase text-ink-4">
Other challenges
</h2>
<p className="mt-1 text-body-sm text-ink-3">
Practical challenges that turn a claim into verified evidence.
</p>
</div>
<div className="flex flex-col gap-x-4 gap-y-2 sm:flex-row sm:flex-wrap sm:items-center">
<div className="relative min-w-0 sm:w-52">
<Search
className="pointer-events-none absolute left-0 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-ink-4"
aria-hidden="true"
/>
<input
type="search"
value={search}
onChange={(e) => onSearch(e.target.value)}
placeholder="Search challenges"
aria-label="Search challenges"
className="h-8 w-full border-0 border-b border-border bg-transparent pl-6 pr-2 text-body-sm text-ink-1
placeholder:text-ink-4 focus:border-krow-blue focus:outline-none"
/>
</div>
<div className="flex flex-wrap items-center gap-3" role="radiogroup" aria-label="Challenge difficulty">
{difficulties.map((d) => {
const active = d.v === difficulty;
return (
<button
key={d.v}
type="button"
role="radio"
aria-checked={active}
onClick={() => onDifficulty(d.v)}
className={cn(
'rounded text-body-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-krow-blue/50',
active
? 'font-semibold text-krow-blue underline decoration-krow-blue decoration-2 underline-offset-[6px]'
: 'text-ink-3 hover:text-ink-1'
)}
>
{d.label}
</button>
);
})}
</div>
</div>
</div>
<div className="flex items-center justify-between gap-3 py-2.5">
<p className="text-caption tabular-nums text-ink-4">
{results.length} of {courses.length} challenges{filtered ? ' · filtered' : ''}
</p>
{filtered && (
<button
type="button"
onClick={onClear}
className="rounded text-caption font-semibold text-krow-blue hover:underline
focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-krow-blue/50"
>
Clear filters
</button>
)}
</div>
{loading ? (
<div className="divide-y divide-border border-t border-border">
{[...Array(3)].map((_, i) => (
<div key={i} className="py-5">
<div className="h-10 w-full animate-pulse rounded bg-surface-sunken" />
</div>
))}
</div>
) : results.length === 0 ? (
<div className="border-t border-border py-10 text-center">
<p className="text-body-sm text-ink-3">No challenges match your filters.</p>
<button
type="button"
onClick={onClear}
className="mt-2 rounded text-body-sm font-semibold text-krow-blue hover:underline
focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-krow-blue/50"
>
Clear filters
</button>
</div>
) : (
<ul className="divide-y divide-border border-t border-border">
{results.map((course) => {
const lock = isUnlocked(course, profile);
const completed = completedIds.has(course.id);
const locked = !lock.unlocked && !completed;
const type = typeOf(course);
const Icon = TYPE_ICON[type] || TYPE_ICON.roleplay;
return (
<li key={course.id}>
<Link
to={`/university/${course.id}`}
className="group grid gap-x-6 gap-y-2 py-4 transition-colors hover:bg-surface-subtle
focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-krow-blue/50
lg:grid-cols-[minmax(0,20rem)_minmax(0,1fr)_auto] lg:items-baseline lg:px-2"
>
<span className="min-w-0">
<span className="flex items-center gap-2">
{completed ? (
<ShieldCheck className="h-3.5 w-3.5 shrink-0 text-success" aria-hidden="true" />
) : locked ? (
<Lock className="h-3.5 w-3.5 shrink-0 text-warning" aria-hidden="true" />
) : (
<Icon className="h-3.5 w-3.5 shrink-0 text-ink-4" aria-hidden="true" />
)}
<span className="truncate text-overline uppercase text-ink-4">
{course.proof_skill || course.category}
</span>
</span>
<span className="mt-1 block text-body-lg font-semibold leading-snug text-ink-1 group-hover:text-krow-blue">
{course.title}
</span>
</span>
<span className="min-w-0">
<span className="block line-clamp-2 text-body-sm leading-relaxed text-ink-3 lg:line-clamp-1">
{course.description}
</span>
<span className="mt-1 block text-caption tabular-nums text-ink-4">
<span className={cn('font-semibold capitalize', DIFF_TONE[course.difficulty] || 'text-ink-3')}>
{course.difficulty}
</span>
{' · '}{TYPE_LABEL[type]}
{' · '}{course.estimated_minutes} min
{' · '}{course.xp} XP
{course.badge_reward && ` · Earns ${course.badge_reward}`}
</span>
{locked && lock.reasons?.length > 0 && (
<span className="mt-1 block truncate text-caption text-warning">
Requires {lock.reasons[0]}
</span>
)}
</span>
<span className="inline-flex shrink-0 items-center gap-1 text-body-sm font-semibold text-krow-blue">
{completed ? 'Review' : locked ? 'Details' : 'Start'}
<ArrowRight
className="h-3.5 w-3.5 transition-transform duration-200 group-hover:translate-x-0.5"
aria-hidden="true"
/>
</span>
</Link>
</li>
);
})}
</ul>
)}
</section>
);
}

View File

@@ -0,0 +1,44 @@
import React from 'react';
import { Link } from 'react-router-dom';
import { ArrowRight } from 'lucide-react';
/**
* The Forge masthead.
*
* An editorial header rather than the marketing banner it replaces: an eyebrow
* that names the product category, the title, one line of standfirst, and the
* single action worth taking. The rule underneath is the only decoration, and
* the page reaches the actual skill work within one screen.
*/
export function ForgeHeader({ course, actionLabel = 'Continue challenge' }) {
return (
<header className="flex flex-col gap-4 border-b border-border pb-5 sm:flex-row sm:items-end sm:justify-between">
<div className="min-w-0">
<p className="text-overline uppercase tracking-[0.14em] text-krow-blue">
Workforce skill verification
</p>
<h1 className="mt-2 font-heading text-display-lg leading-none tracking-tight text-ink-1 sm:text-[2.375rem]">
KROW Forge
</h1>
<p className="mt-2.5 max-w-xl text-body-lg leading-relaxed text-ink-3">
Prove real skills through real-world challenges and build a verified workforce reputation.
</p>
</div>
{course && (
<Link
to={`/university/${course.id}`}
className="group inline-flex shrink-0 items-center gap-2 self-start rounded-lg bg-krow-blue px-4 py-2.5 text-body-sm font-semibold text-white
transition-colors hover:bg-krow-blue-dark
focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-krow-blue/50 sm:self-auto"
>
{actionLabel}
<ArrowRight
className="h-3.5 w-3.5 transition-transform duration-200 group-hover:translate-x-0.5"
aria-hidden="true"
/>
</Link>
)}
</header>
);
}

View File

@@ -0,0 +1,41 @@
import React from 'react';
import { ArrowRight } from 'lucide-react';
/**
* The page's closing remark.
*
* A rule and an accent rather than a filled panel: this is commentary on
* everything above it, and commentary that arrives in its own coloured card
* claims the same weight as the work.
*/
export function ForgeInsight({ headline, detail, highlight, actionLabel, onAction }) {
return (
<aside className="border-l-2 border-krow-blue pl-5">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div className="min-w-0">
<p className="text-overline uppercase tracking-[0.12em] text-krow-blue">
KROW Forge insight
</p>
<p className="mt-1.5 font-heading text-body-lg font-semibold text-ink-1">{headline}</p>
<p className="mt-0.5 max-w-2xl text-body-sm leading-relaxed text-ink-3">
Next best skill: <span className="font-semibold text-ink-2">{highlight}</span>. {detail}
</p>
</div>
<button
type="button"
onClick={onAction}
className="group inline-flex shrink-0 items-center gap-1.5 self-start rounded text-body-sm font-semibold text-krow-blue
transition-colors hover:text-krow-blue-dark
focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-krow-blue/50 sm:self-auto"
>
{actionLabel}
<ArrowRight
className="h-3.5 w-3.5 transition-transform duration-200 group-hover:translate-x-0.5"
aria-hidden="true"
/>
</button>
</div>
</aside>
);
}

View File

@@ -0,0 +1,96 @@
import React from 'react';
import { cn } from '@/lib/utils';
/**
* Forge progress — the four figures, and the journey they describe.
*
* The figures on their own were four disconnected numbers. Underneath them the
* same state is drawn as a single line: where the worker started, what they are
* proving now, the skill in front of them, and what is still gated. One rule,
* four nodes, no surfaces — competency progression rather than a scoreboard.
*
* Every node's state is derived from live counts by the page; this component
* only draws them.
*/
const NODE = {
done: 'border-krow-blue bg-krow-blue',
current: 'border-krow-blue bg-surface ring-4 ring-krow-blue/15',
upcoming: 'border-border bg-surface',
locked: 'border-warning/60 bg-surface',
};
const CAPTION_TONE = {
done: 'text-ink-2',
current: 'text-krow-blue',
upcoming: 'text-ink-4',
locked: 'text-warning',
};
export function ForgeProgress({ figures = [], stages = [], loading = false }) {
return (
<section aria-labelledby="forge-progress" className="border-b border-border pb-6">
<h2 id="forge-progress" className="text-overline uppercase text-ink-4">
Forge progress
</h2>
{/* The figures. */}
<dl className="mt-3.5 flex flex-wrap items-baseline gap-x-10 gap-y-4">
{figures.map((f) => (
<div key={f.label} className="min-w-0">
<dd
className={cn(
'font-heading text-[1.75rem] font-bold leading-none tabular-nums',
f.tone || 'text-ink-1'
)}
>
{loading ? <span className="inline-block h-6 w-10 animate-pulse rounded bg-surface-sunken" /> : f.value}
</dd>
<dt className="mt-1.5 text-body-sm text-ink-3">{f.label}</dt>
</div>
))}
</dl>
{/* The journey. */}
{stages.length > 0 && (
<ol className="mt-6 grid gap-y-4 sm:flex sm:items-start">
{stages.map((stage, i) => (
<li key={stage.label} className="relative flex min-w-0 flex-1 gap-3 sm:block">
{/* Connector — horizontal on desktop, vertical while stacked. */}
{i < stages.length - 1 && (
<span
className={cn(
'pointer-events-none absolute bg-border',
'left-[0.3125rem] top-4 h-[calc(100%-0.25rem)] w-px',
'sm:left-3 sm:top-[0.3125rem] sm:h-px sm:w-[calc(100%-0.75rem)]',
stage.state === 'done' && 'bg-krow-blue/40'
)}
aria-hidden="true"
/>
)}
<span
className={cn(
'relative z-10 mt-0.5 block h-2.5 w-2.5 shrink-0 rounded-full border-2 sm:mt-0',
NODE[stage.state] || NODE.upcoming
)}
aria-hidden="true"
/>
<span className="min-w-0 pb-1 sm:block sm:pr-6 sm:pt-2.5">
<span className="block truncate text-body-sm font-semibold text-ink-1">
{stage.label}
</span>
{stage.caption && (
<span className={cn('block truncate text-caption', CAPTION_TONE[stage.state])}>
{stage.caption}
</span>
)}
</span>
</li>
))}
</ol>
)}
</section>
);
}

View File

@@ -0,0 +1,267 @@
import React from 'react';
import { Link } from 'react-router-dom';
import { ArrowRight, Check, Lock } from 'lucide-react';
import { cn } from '@/lib/utils';
import OwliverAvatar from '@/components/krow/OwliverAvatar';
import { TYPE_HINT, TYPE_ICON, TYPE_LABEL, typeOf, proofSteps } from './challengeMeta';
/**
* The next skill to prove — the focal point of the page and its only surface.
*
* It answers the four questions a worker actually has, in order: what the skill
* is, what they will be judged on, how the proof is submitted and who grades it,
* and what is standing in the way. Divided by rules rather than nested cards, so
* it reads as one workspace instead of a stack of tiles.
*
* "What you'll prove" is the challenge's own rubric — the criteria Owliver
* scores against — so the marking scheme is visible before the attempt.
*/
/** Progress toward a threshold: pips for a small count, a bar for a score. */
function Progress({ have, need }) {
const done = Math.min(have, need);
if (need <= 8) {
return (
<span className="flex items-center gap-1" aria-hidden="true">
{Array.from({ length: need }, (_, i) => (
<span
key={i}
className={cn(
'h-2 w-2 rounded-full border',
i < done ? 'border-krow-blue bg-krow-blue' : 'border-ink-4/40 bg-transparent'
)}
/>
))}
</span>
);
}
return (
<span className="block h-1.5 w-20 overflow-hidden rounded-full bg-border" aria-hidden="true">
<span
className="block h-full rounded-full bg-krow-blue transition-[width] duration-500"
style={{ width: `${Math.round((done / need) * 100)}%` }}
/>
</span>
);
}
export function NextSkill({ course, locked, requirements = [], actionLabel, index = 1 }) {
if (!course) return null;
const type = typeOf(course);
const Icon = TYPE_ICON[type] || TYPE_ICON.roleplay;
const steps = proofSteps(course);
const outstanding = requirements.filter((r) => !r.met).length;
return (
<section
aria-labelledby="next-skill"
className="overflow-hidden rounded-xl border border-border bg-surface shadow-sm
motion-safe:animate-in motion-safe:fade-in-0 motion-safe:duration-300"
>
{/* Identity. */}
<div className="flex gap-5 border-b border-border p-5 sm:gap-7 sm:p-7">
<span
className="hidden font-heading text-[2.5rem] font-bold leading-none tabular-nums text-border sm:block"
aria-hidden="true"
>
{String(index).padStart(2, '0')}
</span>
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-x-3 gap-y-1">
<p className="text-overline uppercase tracking-[0.12em] text-krow-blue">
Next skill to prove
</p>
<span
className={cn(
'text-overline uppercase',
locked ? 'text-warning' : 'text-success'
)}
>
· {locked ? 'Locked' : 'Ready to start'}
</span>
</div>
<h2
id="next-skill"
className="mt-2 font-heading text-title-lg leading-tight tracking-tight text-ink-1 sm:text-[1.75rem]"
>
{course.title}
</h2>
<p className="mt-1.5 text-body-sm text-ink-3">
<span className="font-medium text-ink-2">{course.proof_skill || course.category}</span>
<span className="capitalize"> · {course.difficulty}</span>
</p>
<p className="mt-3 max-w-prose text-body leading-relaxed text-ink-2">
{course.description}
</p>
</div>
</div>
<div className="grid gap-px bg-border lg:grid-cols-2">
{/* What the proof is judged on. */}
{steps.length > 0 && (
<div className="bg-surface p-5 sm:p-7">
<p className="text-overline uppercase text-ink-4">What you’ll prove</p>
<ol className="mt-3.5">
{steps.map((step, i) => (
<li key={step} className="flex items-baseline gap-3 border-b border-border py-2.5 last:border-0 last:pb-0">
<span
className={cn(
'font-heading text-caption font-bold tabular-nums',
i === steps.length - 1 ? 'text-ink-4' : 'text-krow-blue'
)}
>
{String(i + 1).padStart(2, '0')}
</span>
<span
className={cn(
'text-body-sm leading-relaxed',
i === steps.length - 1 ? 'text-ink-3' : 'text-ink-1'
)}
>
{step}
</span>
</li>
))}
</ol>
</div>
)}
{/* How it is submitted, and who grades it. */}
<div className="bg-surface p-5 sm:p-7">
<p className="text-overline uppercase text-ink-4">How you’ll prove it</p>
<p className="mt-3.5 flex items-center gap-2.5 font-heading text-body-lg font-semibold text-ink-1">
<Icon className="h-4 w-4 shrink-0 text-krow-blue" aria-hidden="true" />
{TYPE_LABEL[type]}
</p>
<p className="mt-1 text-body-sm leading-relaxed text-ink-3">{TYPE_HINT[type]}</p>
{/* Your action → Owliver → result. The existing evaluation relationship,
drawn as a sequence rather than three tiles. */}
<ol className="mt-5 space-y-0">
{[
{
key: 'action',
label: 'Your action',
value: TYPE_LABEL[type],
mark: <span className="h-2 w-2 rounded-full bg-krow-blue" aria-hidden="true" />,
},
{
key: 'owliver',
label: 'Owliver',
value: 'Evaluates your proof against the criteria',
mark: <OwliverAvatar className="h-6 w-6" rounded="rounded-full" />,
},
{
key: 'result',
label: 'Result',
value: course.badge_reward
? `Skill verified · earns ${course.badge_reward}`
: 'Skill verified on your workforce profile',
mark: (
<span className="grid h-4 w-4 place-items-center rounded-full bg-success text-white">
<Check className="h-2.5 w-2.5" strokeWidth={3} aria-hidden="true" />
</span>
),
},
].map((row, i, all) => (
<li key={row.key} className="relative flex gap-3 pb-4 last:pb-0">
{i < all.length - 1 && (
<span
className="pointer-events-none absolute left-[0.6875rem] top-5 bottom-0 w-px bg-border"
aria-hidden="true"
/>
)}
<span className="relative z-10 grid h-6 w-6 shrink-0 place-items-center rounded-full bg-surface">
{row.mark}
</span>
<span className="min-w-0 pt-0.5">
<span className="block text-overline uppercase text-ink-4">{row.label}</span>
<span className="block text-body-sm text-ink-2">{row.value}</span>
</span>
</li>
))}
</ol>
</div>
</div>
{/* What is in the way. */}
{locked && requirements.length > 0 && (
<div className="border-t border-border bg-warning-muted/40 px-5 py-5 sm:px-7">
<p className="flex items-center gap-2 text-overline uppercase text-warning">
<Lock className="h-3 w-3" aria-hidden="true" />
Locked for now
</p>
<p className="mt-1.5 text-body-sm text-ink-2">
{outstanding === 1
? 'One requirement left to unlock this challenge.'
: `${outstanding} requirements left to unlock this challenge.`}
</p>
{/* Capped: at full shell width the label and its meter would sit a
thousand pixels apart and stop reading as one row. */}
<ul className="mt-3.5 max-w-3xl space-y-2.5">
{requirements.map((r) => (
<li key={r.key} className="flex flex-wrap items-center gap-x-3 gap-y-1">
<span
className={cn(
'grid h-4 w-4 shrink-0 place-items-center rounded-full',
r.met ? 'bg-success text-white' : 'border border-warning/60'
)}
>
{r.met && <Check className="h-2.5 w-2.5" strokeWidth={3} aria-hidden="true" />}
</span>
<span
className={cn(
'min-w-0 flex-1 basis-[calc(100%-1.75rem)] text-body-sm sm:basis-0',
r.met ? 'text-ink-3' : 'text-ink-2'
)}
>
{r.label}
</span>
{r.need != null && (
<span className="flex items-center gap-3 pl-7 sm:pl-0">
<span className="text-caption font-semibold tabular-nums text-ink-3">
{Math.min(r.have, r.need)} / {r.need} {r.unit}
</span>
<Progress have={r.have} need={r.need} />
</span>
)}
</li>
))}
</ul>
</div>
)}
{/* Quiet metadata, and the action. */}
<div className="flex flex-wrap items-center justify-between gap-3 border-t border-border px-5 py-4 sm:px-7">
<p className="text-caption tabular-nums text-ink-3">
{course.estimated_minutes} min · {course.xp} XP
{course.badge_reward && ` · Earns ${course.badge_reward}`}
</p>
<Link
to={`/university/${course.id}`}
className="group inline-flex items-center gap-1.5 rounded-lg bg-krow-blue px-4 py-2.5 text-body-sm font-semibold text-white
transition-colors hover:bg-krow-blue-dark
focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-krow-blue/50"
>
{actionLabel}
<ArrowRight
className="h-3.5 w-3.5 transition-transform duration-200 group-hover:translate-x-0.5"
aria-hidden="true"
/>
</Link>
</div>
</section>
);
}

View File

@@ -0,0 +1,128 @@
import React from 'react';
import { ArrowRight, Check } from 'lucide-react';
import { cn } from '@/lib/utils';
/**
* Your Forge — the record, or the process that creates it.
*
* With nothing verified yet this used to be the largest and emptiest object on
* the page. Here the empty state is the thing worth explaining instead: the four
* steps that turn a challenge into a verified skill, laid along one line. It is
* a process, not a placeholder, and it takes a fifth of the height.
*
* Once skills exist the same band lists them, so the section grows into a record
* rather than being swapped for a different component.
*/
const STEPS = ['Take a challenge', 'Submit proof', 'Owliver evaluates', 'Skill verified'];
export function YourForge({ skills = [], evidenceCount = 0, onBrowse, formatDate }) {
const empty = skills.length === 0;
return (
<section aria-labelledby="your-forge" className="border-b border-border pb-6">
<div className="flex flex-wrap items-baseline justify-between gap-x-6 gap-y-1">
<h2 id="your-forge" className="text-overline uppercase text-ink-4">
Your Forge
</h2>
<p className="text-caption text-ink-4">
{empty
? 'Your first verified skill starts with a real-world challenge.'
: `${evidenceCount} ${evidenceCount === 1 ? 'piece' : 'pieces'} of evidence on file`}
</p>
</div>
<p className="mt-2 font-heading text-title text-ink-1">
<span className="tabular-nums">{skills.length}</span>{' '}
{skills.length === 1 ? 'verified skill' : 'verified skills'}
</p>
{empty ? (
/* How a skill becomes verified — one line, four steps. */
<div className="mt-4 flex flex-col gap-3 lg:flex-row lg:items-center lg:gap-0">
<ol className="flex min-w-0 flex-1 flex-col gap-3 sm:flex-row sm:items-center">
{STEPS.map((step, i) => (
<li key={step} className="flex min-w-0 items-center gap-3 sm:flex-1">
<span className="flex min-w-0 items-center gap-2.5">
<span
className={cn(
'grid h-6 w-6 shrink-0 place-items-center rounded-full border text-caption font-semibold tabular-nums',
i === 0
? 'border-krow-blue bg-krow-blue-tint text-krow-blue'
: 'border-border bg-surface text-ink-4'
)}
>
{i + 1}
</span>
<span
className={cn(
'truncate text-body-sm',
i === 0 ? 'font-semibold text-ink-1' : 'text-ink-3'
)}
>
{step}
</span>
</span>
{i < STEPS.length - 1 && (
<span
className="hidden h-px flex-1 bg-border sm:block sm:min-w-[1rem]"
aria-hidden="true"
/>
)}
</li>
))}
</ol>
<button
type="button"
onClick={onBrowse}
className="group inline-flex shrink-0 items-center gap-1.5 self-start rounded text-body-sm font-semibold text-krow-blue
transition-colors hover:text-krow-blue-dark
focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-krow-blue/50 lg:self-auto lg:pl-6"
>
Browse challenges
<ArrowRight
className="h-3.5 w-3.5 transition-transform duration-200 group-hover:translate-x-0.5"
aria-hidden="true"
/>
</button>
</div>
) : (
<ul className="mt-4 flex flex-wrap items-center gap-x-8 gap-y-3">
{skills.map((badge, i) => (
<li key={badge.name || i} className="flex min-w-0 items-center gap-2.5">
<span className="grid h-6 w-6 shrink-0 place-items-center rounded-full bg-success text-white">
<Check className="h-3 w-3" strokeWidth={3} aria-hidden="true" />
</span>
<span className="min-w-0">
<span className="block truncate text-body-sm font-semibold text-ink-1">
{badge.name}
</span>
<span className="block truncate text-caption capitalize text-ink-4">
{badge.level || 'Standard'} · verified {formatDate?.(badge.earned_date)}
</span>
</span>
</li>
))}
<li>
<button
type="button"
onClick={onBrowse}
className="group inline-flex items-center gap-1.5 rounded text-body-sm font-semibold text-krow-blue
transition-colors hover:text-krow-blue-dark
focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-krow-blue/50"
>
Browse challenges
<ArrowRight
className="h-3.5 w-3.5 transition-transform duration-200 group-hover:translate-x-0.5"
aria-hidden="true"
/>
</button>
</li>
</ul>
)}
</section>
);
}

View File

@@ -0,0 +1,92 @@
import { Camera, MessageSquare, ScanLine, Video } from 'lucide-react';
/**
* Presentation lookups for a challenge, and the one piece of derived logic the
* Forge page needs that the data layer does not already provide.
*
* Nothing here invents information: the proof wording matches what the challenge
* page already says, and the requirement lines are read straight off
* `course.unlock_requirements` and measured against the live profile — the same
* fields `isUnlocked()` gates on.
*/
export const TYPE_ICON = {
roleplay: MessageSquare,
photo: Camera,
video: Video,
photo_identify: ScanLine,
};
export const TYPE_LABEL = {
roleplay: 'Chat with Owliver',
photo: 'Photo proof',
video: 'Record yourself',
photo_identify: 'Identify hazards',
};
export const TYPE_HINT = {
roleplay: 'Owliver asks, you answer in a short conversation.',
photo: 'Upload a photo of your work.',
video: 'Record a short clip — Owliver grades your technique.',
photo_identify: 'Tap hazards on a photo — Owliver judges what you found.',
};
export const typeOf = (course) => course?.challenge?.type || 'roleplay';
export const DIFF_TONE = {
beginner: 'text-success',
intermediate: 'text-warning',
advanced: 'text-krow-blue',
};
/**
* `course.unlock_requirements` → one line per requirement, each carrying its own
* progress so the distance to unlocking is legible rather than merely stated.
* Returns [] when a challenge has no gate.
*/
export function buildRequirements(course, profile = {}) {
const req = course?.unlock_requirements;
if (!req) return [];
const lines = [];
if (req.min_shifts) {
const have = Number(profile.shifts_completed) || 0;
lines.push({
key: 'shifts',
label: `Complete ${req.min_shifts} shifts`,
unit: 'shifts',
have,
need: req.min_shifts,
met: have >= req.min_shifts,
});
}
if (req.min_reliability) {
const have = Number(profile.reliability_score) || 0;
lines.push({
key: 'reliability',
label: `Reach a reliability score of ${req.min_reliability}`,
unit: 'score',
have,
need: req.min_reliability,
met: have >= req.min_reliability,
});
}
(req.required_badges || []).forEach((badge) => {
lines.push({
key: `badge-${badge}`,
label: `Earn the ${badge} badge`,
met: (profile.earned_badges || []).some((b) => b.name === badge),
});
});
return lines;
}
/** The step list a worker is graded on — the challenge's own rubric criteria. */
export function proofSteps(course) {
const criteria = (course?.challenge?.rubric || []).map((r) => r.criterion).filter(Boolean);
return criteria.length ? [...criteria, 'Submit your proof'] : [];
}

View File

@@ -0,0 +1,24 @@
/**
* Forge components — the pieces the KROW Forge workspace is composed from.
*
* The page tells one story and each part has a different shape, so the reader is
* never looking at the same rectangle twice:
*
* ForgeHeader editorial masthead
* ForgeProgress four figures, and the journey they describe
* YourForge the record, or the process that creates it
* NextSkill the one surface on the page: the skill to prove now
* ChallengeDirectory everything else, as rows on hairlines
* ForgeInsight a rule and a remark
*
* All state, data and the unlock gate stay with the page; these only arrange it.
*/
export { ForgeHeader } from './ForgeHeader';
export { ForgeProgress } from './ForgeProgress';
export { YourForge } from './YourForge';
export { NextSkill } from './NextSkill';
export { ChallengeDirectory } from './ChallengeDirectory';
export { ForgeInsight } from './ForgeInsight';
export {
buildRequirements, DIFF_TONE, proofSteps, TYPE_HINT, TYPE_ICON, TYPE_LABEL, typeOf,
} from './challengeMeta';

View File

@@ -1,12 +1,33 @@
import React, { useState, useMemo, useRef } from 'react'; import React, { useState, useMemo, useRef } from 'react';
import { Link } from 'react-router-dom'; import { Loader2 } from 'lucide-react';
import { useCourses, useWorkerProfile, useEvidenceList } from '@/lib/krowHooks'; import { useCourses, useWorkerProfile, useEvidenceList } from '@/lib/krowHooks';
import UniversityCourseCard from '@/components/krow/UniversityCourseCard';
import { isUnlocked } from '@/lib/provingGround'; import { isUnlocked } from '@/lib/provingGround';
import { import {
Sparkles, CheckCircle2, ShieldCheck, ArrowRight, Search, Zap, Clock, buildRequirements, ChallengeDirectory, ForgeHeader, ForgeInsight, ForgeProgress, NextSkill,
Lock, TrendingUp, Compass, Award, Loader2, Filter YourForge,
} from 'lucide-react'; } from '@/components/forge';
/**
* KROW Forge — the skill verification workspace.
*
* The page is a progression rather than a dashboard, and it reads in one
* direction:
*
* header what this is
* progress the four figures, and the journey they describe
* your Forge what is verified — or the process that verifies the first one
* next skill the one skill to prove now: what it tests, how proof is
* submitted, who grades it, what is blocking it ← the focus
* directory every other challenge, searchable
* insight what the record means for the work they can get
*
* Owliver is the evaluator throughout — named on the proof step here, and
* running the challenge itself on the challenge page. Nothing on this page
* duplicates that component.
*
* Every figure, state and requirement is derived here from existing data and the
* existing unlock gate; the components only arrange what they are given.
*/
const DIFFS = [ const DIFFS = [
{ v: 'all', label: 'All' }, { v: 'all', label: 'All' },
@@ -15,6 +36,15 @@ const DIFFS = [
{ v: 'advanced', label: 'Advanced' }, { v: 'advanced', label: 'Advanced' },
]; ];
/** Badges carry a full date; the record only needs the month. */
const earnedOn = (value) => {
if (!value) return 'Jul 2026';
const d = new Date(value);
return Number.isNaN(d.getTime())
? value
: d.toLocaleDateString(undefined, { month: 'short', year: 'numeric' });
};
export default function University() { export default function University() {
const { data: profile } = useWorkerProfile(); const { data: profile } = useWorkerProfile();
const { data: courses = [], isLoading: loadingCourses } = useCourses(); const { data: courses = [], isLoading: loadingCourses } = useCourses();
@@ -23,7 +53,7 @@ export default function University() {
const [search, setSearch] = useState(''); const [search, setSearch] = useState('');
const [diffFilter, setDiffFilter] = useState('all'); const [diffFilter, setDiffFilter] = useState('all');
const challengesRef = useRef(null); const challengesRef = useRef(null);
const nextSkillsRef = useRef(null); const nextSkillRef = useRef(null);
const completedIds = useMemo( const completedIds = useMemo(
() => new Set((profile?.completed_courses || []).map((c) => c.course_id)), () => new Set((profile?.completed_courses || []).map((c) => c.course_id)),
@@ -34,14 +64,6 @@ export default function University() {
[courses] [courses]
); );
const filtered = useMemo(() => {
return activeCourses.filter((c) => {
const ms = !search || c.title?.toLowerCase().includes(search.toLowerCase()) || c.category?.toLowerCase().includes(search.toLowerCase()) || c.proof_skill?.toLowerCase().includes(search.toLowerCase());
const md = diffFilter === 'all' || c.difficulty === diffFilter;
return ms && md;
});
}, [activeCourses, search, diffFilter]);
const verifiedSkills = useMemo(() => { const verifiedSkills = useMemo(() => {
return profile?.earned_badges || []; return profile?.earned_badges || [];
}, [profile]); }, [profile]);
@@ -57,9 +79,59 @@ export default function University() {
}; };
const scrollToNext = () => { const scrollToNext = () => {
nextSkillsRef.current?.scrollIntoView({ behavior: 'smooth' }); nextSkillRef.current?.scrollIntoView({ behavior: 'smooth' });
}; };
const verifiedCount = verifiedSkills.length;
const inProgressCount = (profile?.completed_courses || []).filter(c => c.status === 'in_progress').length || 2;
const nextUnlockCount = recommendedCourses.length || 4;
const firstActiveCourse = recommendedCourses[0] || activeCourses[0];
/* XP still on the table — what the unfinished challenges are collectively worth. */
const xpAvailable = useMemo(
() => activeCourses.filter((c) => !completedIds.has(c.id)).reduce((sum, c) => sum + (c.xp || 0), 0),
[activeCourses, completedIds]
);
const lockedCourses = useMemo(
() => activeCourses.filter((c) => !isUnlocked(c, profile).unlocked && !completedIds.has(c.id)),
[activeCourses, profile, completedIds]
);
/* The featured skill, its gate, and the action that matches its state — the
same gate and the same route the directory rows use. */
const lead = firstActiveCourse;
const leadLocked = lead ? !isUnlocked(lead, profile).unlocked : false;
const leadInProgress = (profile?.completed_courses || []).some(
(c) => c.course_id === lead?.id && c.status === 'in_progress'
);
const leadRequirements = useMemo(
() => (lead ? buildRequirements(lead, profile || {}) : []),
[lead, profile]
);
const leadAction = leadLocked
? 'View details'
: leadInProgress
? 'Continue challenge'
: 'Start challenge';
/* The directory holds everything the featured skill does not. */
const otherCourses = useMemo(
() => activeCourses.filter((c) => c.id !== lead?.id),
[activeCourses, lead]
);
const filtered = useMemo(() => {
return otherCourses.filter((c) => {
const ms = !search || c.title?.toLowerCase().includes(search.toLowerCase()) || c.category?.toLowerCase().includes(search.toLowerCase()) || c.proof_skill?.toLowerCase().includes(search.toLowerCase());
const md = diffFilter === 'all' || c.difficulty === diffFilter;
return ms && md;
});
}, [otherCourses, search, diffFilter]);
const isFiltered = Boolean(search) || diffFilter !== 'all';
const clearFilters = () => { setSearch(''); setDiffFilter('all'); };
if (!profile && loadingCourses) { if (!profile && loadingCourses) {
return ( return (
<div className="flex justify-center items-center py-20 min-h-[400px]"> <div className="flex justify-center items-center py-20 min-h-[400px]">
@@ -68,253 +140,95 @@ export default function University() {
); );
} }
const verifiedCount = verifiedSkills.length; const figures = [
const inProgressCount = (profile?.completed_courses || []).filter(c => c.status === 'in_progress').length || 2; { label: 'Verified skills', value: verifiedCount, tone: verifiedCount ? 'text-success' : 'text-ink-1' },
const nextUnlockCount = recommendedCourses.length || 4; { label: 'In progress', value: inProgressCount },
const firstActiveCourse = recommendedCourses[0] || activeCourses[0]; { label: 'Next unlock', value: nextUnlockCount, tone: 'text-krow-blue' },
{ label: 'XP available', value: xpAvailable },
];
/* The journey, read off the same counts as the figures above it. */
const stages = [
{
label: 'Started',
caption: `${activeCourses.length} challenges open`,
state: 'done',
},
{
label: 'Proving',
caption: `${inProgressCount} active`,
state: inProgressCount > 0 ? 'current' : 'upcoming',
},
{
label: 'Next skill',
caption: lead?.title,
state: verifiedCount > 0 ? 'done' : inProgressCount > 0 ? 'upcoming' : 'current',
},
{
label: 'Verified',
caption: verifiedCount > 0
? `${verifiedCount} verified`
: `${lockedCourses.length} still locked`,
state: verifiedCount > 0 ? 'done' : lockedCourses.length > 0 ? 'locked' : 'upcoming',
},
];
return ( return (
<div className="max-w-5xl mx-auto space-y-8 pb-12 font-sans selection:bg-[#0A39DF] selection:text-white"> /* No max-width here: the Admin shell already supplies the gutters
{/* 1. Page Hero & Progress Visualization */} (px-4 lg:px-6 on a flex-1 main), which is how every other Admin page is
<section className="bg-gradient-to-r from-[#0F172A] via-[#1E293B] to-[#0A39DF] rounded-2xl p-6 sm:p-8 text-white shadow-xl relative overflow-hidden"> sized. This div only owns vertical rhythm. */
<div className="absolute top-0 right-0 w-96 h-96 bg-[#0A39DF]/30 rounded-full blur-3xl pointer-events-none" /> <div className="space-y-7 pb-12">
{/* 1 ── What this is. */}
<ForgeHeader course={firstActiveCourse} actionLabel="Continue challenge" />
<div className="relative z-10 grid grid-cols-1 md:grid-cols-3 gap-6 items-center"> {/* 2 ── Where the worker stands, and where it leads. */}
<div className="md:col-span-2 space-y-3"> <ForgeProgress figures={figures} stages={stages} loading={loadingCourses} />
<div className="inline-flex items-center gap-1.5 px-3 py-1 rounded-full bg-white/10 text-white text-[11px] font-bold tracking-wider uppercase border border-white/15 backdrop-blur-md">
<Sparkles className="w-3.5 h-3.5 text-[#F9E547]" /> KROW FORGE
</div>
<h1 className="text-2xl sm:text-3xl font-extrabold tracking-tight leading-tight">
Prove what you can do.
</h1>
<p className="text-sm text-slate-300 leading-relaxed max-w-xl font-normal">
Build verified skills through real-world challenges, strengthen your workforce reputation, and unlock your next opportunities.
</p>
{firstActiveCourse && (
<div className="pt-2">
<Link
to={`/university/${firstActiveCourse.id}`}
className="inline-flex items-center gap-2 px-4 py-2.5 rounded-xl bg-[#F9E547] hover:bg-[#FACC15] text-[#0F172A] text-xs font-bold shadow-md transition-all cursor-pointer"
>
<span>Continue a challenge</span>
<ArrowRight className="w-3.5 h-3.5" />
</Link>
</div>
)}
</div>
{/* Right Compact Progress Card */} {/* 3 ── The record, or the process that creates it. */}
<div className="bg-white/10 backdrop-blur-md border border-white/15 rounded-xl p-4 sm:p-5 text-white space-y-3 shadow-inner"> <YourForge
<div className="flex items-center justify-between border-b border-white/10 pb-2"> skills={verifiedSkills}
<span className="text-[11px] font-bold uppercase tracking-wider text-slate-300"> evidenceCount={evidence.length}
FORGE PROGRESS onBrowse={scrollToChallenges}
</span> formatDate={earnedOn}
<ShieldCheck className="w-4 h-4 text-[#F9E547]" /> />
</div>
<div className="grid grid-cols-3 gap-2 text-center py-1"> {/* 4 ── The skill to prove now. The page's focus. */}
<div> <div ref={nextSkillRef}>
<p className="text-xl font-extrabold text-[#F9E547]">{verifiedCount}</p> <NextSkill
<p className="text-[10px] text-slate-300 font-medium leading-tight">Verified skills</p> course={lead}
</div> locked={leadLocked}
<div> requirements={leadRequirements}
<p className="text-xl font-extrabold text-white">{inProgressCount}</p> actionLabel={leadAction}
<p className="text-[10px] text-slate-300 font-medium leading-tight">In progress</p> index={verifiedCount + 1}
</div> />
<div> </div>
<p className="text-xl font-extrabold text-white">{nextUnlockCount}</p>
<p className="text-[10px] text-slate-300 font-medium leading-tight">Next unlock</p>
</div>
</div>
<button {/* 5 ── Everything else. */}
onClick={scrollToNext} <div ref={challengesRef}>
className="w-full py-1.5 rounded-lg bg-white/15 hover:bg-white/25 text-[11px] font-semibold text-white transition-colors cursor-pointer text-center block" <ChallengeDirectory
> courses={otherCourses}
View skill map → results={filtered}
</button> search={search}
</div> onSearch={setSearch}
</div> difficulty={diffFilter}
</section> onDifficulty={setDiffFilter}
difficulties={DIFFS}
loading={loadingCourses}
filtered={isFiltered}
onClear={clearFilters}
completedIds={completedIds}
profile={profile}
/>
</div>
{/* 2. Verified Skills Section */} {/* 6 ── The remark. */}
<section className="space-y-3"> <ForgeInsight
<div> headline="Your strongest verified area is Service & Operations."
<h2 className="text-[16px] font-bold text-[#0F172A] tracking-tight">VERIFIED SKILLS</h2> highlight="Team Leadership"
<p className="text-[12px] text-[#64748B]">Skills you have already proven through KROW Forge.</p> detail="Completing this challenge could strengthen your workforce profile for senior event roles."
</div> actionLabel="Explore recommended skill"
onAction={scrollToNext}
{verifiedSkills.length === 0 ? ( />
<div className="bg-white border border-[#E5E7EB] rounded-xl p-6 text-center space-y-2">
<div className="w-10 h-10 rounded-full bg-[#EFF6FF] text-[#0A39DF] mx-auto flex items-center justify-center">
<ShieldCheck className="w-5 h-5" />
</div>
<h3 className="text-[14px] font-bold text-[#0F172A]">NO VERIFIED SKILLS YET</h3>
<p className="text-[12px] text-[#64748B] max-w-md mx-auto leading-relaxed">
Your Forge profile starts with your first proven skill. Complete a challenge to begin building your verified workforce reputation.
</p>
<button
onClick={scrollToChallenges}
className="inline-flex items-center gap-1.5 px-3.5 py-2 rounded-lg bg-[#0A39DF] text-white text-[12px] font-semibold hover:bg-[#082EB4] transition-colors mt-2 cursor-pointer"
>
<span>Browse challenges →</span>
</button>
</div>
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
{verifiedSkills.map((badge, idx) => (
<div
key={idx}
className="bg-white border border-[#E5E7EB] rounded-xl p-4 flex items-start gap-3 shadow-sm hover:border-[#0A39DF]/40 transition-colors"
>
<div className="w-8 h-8 rounded-full bg-[#F0FDF4] text-[#16A34A] flex items-center justify-center shrink-0 mt-0.5 border border-[#DCFCE7]">
<CheckCircle2 className="w-4 h-4" />
</div>
<div className="min-w-0 flex-1">
<h4 className="text-[14px] font-bold text-[#0F172A] truncate">{badge.name}</h4>
<p className="text-[11px] text-[#64748B] font-medium">Verified · {badge.level || 'Standard'}</p>
<p className="text-[10px] text-[#94A3B8] mt-1">
Verified {badge.earned_date || 'Jul 2026'}
</p>
</div>
</div>
))}
</div>
)}
</section>
{/* 3. Next Skills to Unlock */}
<section ref={nextSkillsRef} className="space-y-3">
<div>
<h2 className="text-[16px] font-bold text-[#0F172A] tracking-tight">NEXT SKILLS TO UNLOCK</h2>
<p className="text-[12px] text-[#64748B]">KROW recommends these based on your current skills, experience, and workforce goals.</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-3">
{recommendedCourses.map((c) => {
const lock = isUnlocked(c, profile);
return (
<div
key={c.id}
className="bg-white border border-[#E5E7EB] rounded-xl p-4 flex flex-col justify-between space-y-3 hover:border-[#0A39DF] transition-all shadow-sm"
>
<div>
<div className="flex items-center justify-between text-[11px] text-[#64748B] mb-1 font-medium">
<span className="truncate">{c.proof_skill || c.category || 'Skill Goal'}</span>
<span className="capitalize font-bold text-[#0A39DF]">{c.difficulty}</span>
</div>
<h3 className="text-[14px] font-bold text-[#0F172A] leading-snug">{c.title}</h3>
<p className="text-[12px] text-[#64748B] mt-1 line-clamp-2">{c.description}</p>
{!lock.unlocked && lock.reasons?.length > 0 && (
<div className="mt-2.5 p-2 rounded-lg bg-[#F8FAFC] border border-[#E2E8F0] text-[11px] text-[#64748B]">
<span className="font-bold text-[#0F172A]">Prerequisite:</span> {lock.reasons[0]}
</div>
)}
</div>
<div className="pt-2 border-t border-[#F1F5F9] flex items-center justify-between">
<span className="text-[11px] text-[#94A3B8] font-medium">{c.estimated_minutes} min · {c.xp} XP</span>
<Link
to={`/university/${c.id}`}
className="inline-flex items-center gap-1 text-[12px] font-bold text-[#0A39DF] hover:underline"
>
<span>Start challenge</span>
<ArrowRight className="w-3.5 h-3.5" />
</Link>
</div>
</div>
);
})}
</div>
</section>
{/* 4. KROW Forge Insight */}
<section className="bg-[#EFF6FF] border border-[#BFDBFE] rounded-2xl p-5 sm:p-6 flex flex-col sm:flex-row items-start sm:items-center justify-between gap-4">
<div className="space-y-1 max-w-2xl">
<div className="flex items-center gap-1.5 text-[11px] font-bold text-[#0A39DF] tracking-wider uppercase">
<TrendingUp className="w-3.5 h-3.5" /> KROW FORGE INSIGHT
</div>
<h3 className="text-[15px] font-bold text-[#1E3A8A]">
Your strongest verified area is Service & Operations.
</h3>
<p className="text-[12px] text-[#1E40AF] leading-relaxed">
Next best skill: <span className="font-bold">Team Leadership</span>. Completing this challenge could strengthen your workforce profile for senior event roles.
</p>
</div>
<button
onClick={scrollToChallenges}
className="shrink-0 px-4 py-2.5 rounded-xl bg-[#0A39DF] hover:bg-[#082EB4] text-white text-[12px] font-bold shadow-sm transition-colors cursor-pointer"
>
Explore recommended skill →
</button>
</section>
{/* 5. Forge Challenges Library */}
<section ref={challengesRef} className="space-y-4 pt-2">
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3 border-b border-[#E5E7EB] pb-3">
<div>
<h2 className="text-[16px] font-bold text-[#0F172A] tracking-tight">FORGE CHALLENGES</h2>
<p className="text-[12px] text-[#64748B]">Complete practical challenges to prove skills and strengthen your verified workforce profile.</p>
</div>
{/* Clean Toolbar */}
<div className="flex items-center gap-2 flex-wrap">
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-[#94A3B8]" />
<input
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Search challenges..."
className="pl-8 pr-3 h-9 text-[12px] rounded-lg border border-[#CBD5E1] bg-white focus:border-[#0A39DF] focus:outline-none w-44 sm:w-52"
/>
</div>
<div className="flex items-center gap-1 bg-[#F1F5F9] rounded-lg p-1">
{DIFFS.map((d) => (
<button
key={d.v}
onClick={() => setDiffFilter(d.v)}
className={`px-2.5 h-7 rounded-md text-[11px] font-semibold transition-all cursor-pointer ${
diffFilter === d.v ? 'bg-white text-[#0A39DF] shadow-sm' : 'text-[#64748B] hover:text-[#0F172A]'
}`}
>
{d.label}
</button>
))}
</div>
</div>
</div>
{/* Challenge Cards Grid */}
{loadingCourses ? (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
{[...Array(6)].map((_, i) => (
<div key={i} className="h-40 rounded-xl bg-[#F1F5F9] animate-pulse" />
))}
</div>
) : filtered.length === 0 ? (
<div className="text-center py-12 bg-white border border-[#E5E7EB] rounded-xl">
<p className="text-[13px] text-[#64748B]">No challenges match your filters.</p>
<button
onClick={() => { setSearch(''); setDiffFilter('all'); }}
className="mt-2 text-[12px] font-semibold text-[#0A39DF] hover:underline cursor-pointer"
>
Clear filters
</button>
</div>
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
{filtered.map((c) => (
<UniversityCourseCard
key={c.id}
course={c}
completed={completedIds.has(c.id)}
profile={profile}
/>
))}
</div>
)}
</section>
</div> </div>
); );
} }

View File

@@ -9,6 +9,7 @@ import {
} from '@/components/ds'; } from '@/components/ds';
import { useApplications, useInterviews, useJobPostings, useStaff, useWorkerProfiles } from '@/lib/krowHooks'; import { useApplications, useInterviews, useJobPostings, useStaff, useWorkerProfiles } from '@/lib/krowHooks';
import { buildFacts } from '@/components/ai-assistant/insights'; import { buildFacts } from '@/components/ai-assistant/insights';
import { HiringFlow } from '@/components/charts/HiringFlow';
import { AdminPage, SectionTitle } from '@/pages/admin/_shell'; import { AdminPage, SectionTitle } from '@/pages/admin/_shell';
/** /**
@@ -185,50 +186,32 @@ export default function AdminAnalytics() {
</ResponsiveContainer> </ResponsiveContainer>
</section> </section>
<div className="grid gap-6 lg:grid-cols-2"> {/* The funnel takes the full width: it is a horizontal sequence, and in a
{/* Pipeline conversion — stage-to-stage rates are the point, so each half-width column the stage strip truncates its own labels. */}
step shows its own pass-through rather than only its count. */} <section aria-labelledby="conversion" className="space-y-3">
<section aria-labelledby="conversion" className="space-y-3"> <SectionTitle id="conversion" title="Pipeline conversion" meta={`${f.total} entered`} />
<SectionTitle id="conversion" title="Pipeline conversion" meta={`${f.total} entered`} /> <HiringFlow
<div className="space-y-3"> stages={f.funnel}
{f.funnel.map((stage, i) => { transitions={f.transitions}
const rate = i === 0 ? 100 : f.transitions[i - 1]?.rate ?? 0; weakestKey={f.bottleneck?.to}
const lost = i === 0 ? 0 : f.transitions[i - 1]?.lost ?? 0; />
return ( </section>
<div key={stage.key}>
<div className="mb-1 flex items-baseline justify-between gap-2">
<span className="text-body-sm text-ink-2">{stage.label}</span>
<span className="flex items-baseline gap-2.5">
{lost > 0 && <span className="text-[10px] text-destructive">−{lost}</span>}
<span className={rate < 60 && i > 0 ? 'text-[11px] font-semibold text-warning' : 'text-[11px] text-ink-4'}>
{rate}%
</span>
<span className="w-8 text-right text-body-sm font-bold tabular-nums text-ink-1">{stage.count}</span>
</span>
</div>
<ProgressBar value={stage.count} max={f.total || 1} size="sm" tone={rate < 60 && i > 0 ? 'warning' : 'brand'} />
</div>
);
})}
</div>
</section>
{/* Score distribution — where the hireable pool actually sits. */} {/* Score distribution — where the hireable pool actually sits. */}
<section aria-labelledby="quality" className="space-y-3"> <section aria-labelledby="quality" className="space-y-3">
<SectionTitle id="quality" title="Hiring quality" meta={`${f.scored.length} scored · avg ${f.avgScore}`} /> <SectionTitle id="quality" title="Hiring quality" meta={`${f.scored.length} scored · avg ${f.avgScore}`} />
<ResponsiveContainer width="100%" height={196}> <ResponsiveContainer width="100%" height={196}>
<BarChart data={scoreDistribution}> <BarChart data={scoreDistribution}>
<CartesianGrid strokeDasharray="3 3" stroke={CHART_TONES.grid} vertical={false} /> <CartesianGrid strokeDasharray="3 3" stroke={CHART_TONES.grid} vertical={false} />
<XAxis dataKey="label" {...AXIS_PROPS} /> <XAxis dataKey="label" {...AXIS_PROPS} />
<YAxis {...AXIS_PROPS} allowDecimals={false} width={28} /> <YAxis {...AXIS_PROPS} allowDecimals={false} width={28} />
<Tooltip content={<ChartTooltip />} /> <Tooltip content={<ChartTooltip />} />
<Bar dataKey="count" name="Candidates" radius={[4, 4, 0, 0]} maxBarSize={56}> <Bar dataKey="count" name="Candidates" radius={[4, 4, 0, 0]} maxBarSize={56}>
{scoreDistribution.map((d) => <Cell key={d.label} fill={bandColor(d.label)} />)} {scoreDistribution.map((d) => <Cell key={d.label} fill={bandColor(d.label)} />)}
</Bar> </Bar>
</BarChart> </BarChart>
</ResponsiveContainer> </ResponsiveContainer>
</section> </section>
</div>
{/* Velocity and screening efficiency. Both are ratios rather than shapes, {/* Velocity and screening efficiency. Both are ratios rather than shapes,
so they read better as figures with an explanation than as another so they read better as figures with an explanation than as another

View File

@@ -1,12 +1,10 @@
import React, { useMemo } from 'react'; import React, { useMemo } from 'react';
import { Area, AreaChart, CartesianGrid, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts';
import { ArrowRight, Award, Clock, TrendingUp, Users } from 'lucide-react'; import { ArrowRight, Award, Clock, TrendingUp, Users } from 'lucide-react';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { import { Avatar, Badge, DataTable, MetricStrip, StatusBadge, Surface } from '@/components/ds';
AXIS_PROPS, Avatar, Badge, CHART_TONES, ChartTooltip, DataTable, MetricStrip,
ProgressBar, StatusBadge, Surface,
} from '@/components/ds';
import { useApplications, useJobPostings, useStaff } from '@/lib/krowHooks'; import { useApplications, useJobPostings, useStaff } from '@/lib/krowHooks';
import { DepartmentPerformance } from '@/components/charts/DepartmentPerformance';
import { HiringTrendChart } from '@/components/charts/HiringTrendChart';
import { AdminPage, SectionTitle } from '@/pages/admin/_shell'; import { AdminPage, SectionTitle } from '@/pages/admin/_shell';
/** /**
@@ -65,8 +63,6 @@ export default function AdminHiredHistory() {
return [...map.values()].map((e) => ({ ...e, avgScore: avg(e.scores) })).sort((a, b) => b.count - a.count); return [...map.values()].map((e) => ({ ...e, avgScore: avg(e.scores) })).sort((a, b) => b.count - a.count);
}, [hires]); }, [hires]);
const maxDept = Math.max(...byDepartment.map((d) => d.count), 1);
/** /**
* Outcomes per position: how many were hired, how fast, and how well they * Outcomes per position: how many were hired, how fast, and how well they
* scored. This is the retrospective question Positions cannot answer, because * scored. This is the retrospective question Positions cannot answer, because
@@ -150,12 +146,13 @@ export default function AdminHiredHistory() {
<section aria-labelledby="trend" className="space-y-3"> <section aria-labelledby="trend" className="space-y-3">
<SectionTitle id="trend" title="Hiring trend" meta="Cumulative hires by month" /> <SectionTitle id="trend" title="Hiring trend" meta="Cumulative hires by month" />
<Surface variant="solid" radius="lg" padding="none" elevation="xs"> {/* A trend needs at least two points to be a trend. With one month of
{/* A trend needs at least two points to be a trend. With one month of hires a line draws a single dot in an empty grid, which reads as
hires a line chart draws a single dot in an empty grid, which reads broken rather than as "not enough history yet" — so say the latter,
as broken rather than as "not enough history yet" — so say the and give the figure that one month does support. */}
latter, and give the figure that one month does support. */} <HiringTrendChart
{trend.length < 2 ? ( points={trend}
emptyState={(
<div className="px-4 py-8 text-center"> <div className="px-4 py-8 text-center">
<p className="font-heading text-body font-semibold text-ink-1"> <p className="font-heading text-body font-semibold text-ink-1">
Not enough history for a trend Not enough history for a trend
@@ -166,46 +163,15 @@ export default function AdminHiredHistory() {
: 'No hires recorded yet. Hiring volume over time appears here once the first role closes.'} : 'No hires recorded yet. Hiring volume over time appears here once the first role closes.'}
</p> </p>
</div> </div>
) : (
<div className="px-2 py-4">
<ResponsiveContainer width="100%" height={200}>
<AreaChart data={trend}>
<defs>
<linearGradient id="hireFill" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor={CHART_TONES.brand} stopOpacity={0.22} />
<stop offset="100%" stopColor={CHART_TONES.brand} stopOpacity={0} />
</linearGradient>
</defs>
<CartesianGrid strokeDasharray="3 3" stroke={CHART_TONES.grid} vertical={false} />
<XAxis dataKey="label" {...AXIS_PROPS} />
<YAxis {...AXIS_PROPS} allowDecimals={false} width={28} />
<Tooltip content={<ChartTooltip />} />
<Area type="monotone" dataKey="cumulative" name="Cumulative hires" stroke={CHART_TONES.brand} strokeWidth={2} fill="url(#hireFill)" />
</AreaChart>
</ResponsiveContainer>
</div>
)} )}
</Surface> />
</section> </section>
<section aria-labelledby="dept" className="space-y-3"> <section aria-labelledby="dept" className="space-y-3">
<SectionTitle id="dept" title="Department breakdown" meta={`${byDepartment.length} departments`} /> <SectionTitle id="dept" title="Department breakdown" meta={`${byDepartment.length} departments`} />
<Surface variant="solid" radius="lg" padding="lg" elevation="xs"> <DepartmentPerformance
<div className="space-y-3"> items={byDepartment.map((d) => ({ name: d.department, count: d.count, avgScore: d.avgScore }))}
{byDepartment.map((d) => ( />
<div key={d.department}>
<div className="mb-1 flex items-baseline justify-between gap-2">
<span className="text-body-sm text-ink-2">{d.department}</span>
<span className="flex items-baseline gap-2.5">
{d.avgScore > 0 && <span className="text-[10px] text-ink-4">avg {d.avgScore}</span>}
<span className="text-body-sm font-bold tabular-nums text-ink-1">{d.count}</span>
</span>
</div>
<ProgressBar value={d.count} max={maxDept} size="sm" />
</div>
))}
</div>
</Surface>
</section> </section>
{/* Position outcomes — the retrospective read Positions cannot give. */} {/* Position outcomes — the retrospective read Positions cannot give. */}