update hired page
This commit is contained in:
@@ -1,12 +1,14 @@
|
||||
import {
|
||||
Activity, AlertTriangle, ArrowLeftRight, BellRing, Eye, Filter, Gauge, GitBranch,
|
||||
Layers, Lightbulb, ScrollText, ShieldAlert, Target, TrendingUp, UserCheck, Users,
|
||||
Activity, AlertTriangle, ArrowLeftRight, Award, BellRing, Clock, Eye, Filter, Gauge,
|
||||
GitBranch, Layers, Lightbulb, Lock, ScrollText, ShieldAlert, ShieldCheck, Star, Target,
|
||||
TrendingUp, UserCheck, Users,
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
actions, answer, doc, funnel, heading, insights, kpis, list, meters, note, status, table,
|
||||
text, timeline,
|
||||
} from '../blocks';
|
||||
import { plural, verb } from '../insights';
|
||||
import { pct, plural, verb } from '../insights';
|
||||
import { toFICO } from '@/lib/talentHome';
|
||||
|
||||
/**
|
||||
* Admin capabilities — Control Center, Candidates Analysis, Activity.
|
||||
@@ -1351,3 +1353,463 @@ export const ACTIVITY_CAPABILITIES = [
|
||||
{ id: 'unusual-activity', label: 'Unusual Activity', icon: AlertTriangle, run: unusualActivity },
|
||||
{ id: 'security-insights', label: 'Security Insights', icon: ShieldAlert, run: securityInsights },
|
||||
];
|
||||
|
||||
/* ── KROW Forge ─────────────────────────────────────────────────────────── */
|
||||
|
||||
/**
|
||||
* Forge capabilities — workforce skill verification.
|
||||
*
|
||||
* The Forge questions are not hiring questions: they are about proof. What is
|
||||
* verified, what can be proven next, and what is gating the rest. Every figure
|
||||
* comes from `f.forge`, which is derived with the same unlock gate the page
|
||||
* renders from.
|
||||
*/
|
||||
|
||||
const forgeProgress = (f) => {
|
||||
const g = f.forge;
|
||||
return doc(
|
||||
heading('Forge progress', `${plural(g.courses.length, 'challenge')} in the library`),
|
||||
kpis([
|
||||
{ label: 'Verified skills', value: g.verified.length, sub: g.verified.length ? 'proven' : 'nothing proven yet' },
|
||||
{ label: 'In progress', value: g.inProgress.length, sub: 'challenges started' },
|
||||
{ label: 'Ready to start', value: g.ready.length, sub: 'unlocked now' },
|
||||
{ label: 'XP available', value: g.xpAvailable, sub: `across ${plural(g.courses.length - g.completed.length, 'challenge')}` },
|
||||
]),
|
||||
g.verified.length
|
||||
? list(g.verified.map((b) => `**${b.name}** — ${b.level || 'Standard'}, verified`))
|
||||
: text('No skill is verified yet. The first one comes from completing a challenge and submitting proof for Owliver to grade.'),
|
||||
g.byDifficulty.length ? heading('Library by difficulty') : null,
|
||||
meters(g.byDifficulty.map((d) => ({
|
||||
label: d.level.charAt(0).toUpperCase() + d.level.slice(1),
|
||||
value: d.count,
|
||||
max: g.courses.length,
|
||||
}))),
|
||||
g.locked.length
|
||||
? note(`${plural(g.locked.length, 'challenge')} ${verb(g.locked.length, 'is', 'are')} still gated by real-work requirements.`)
|
||||
: null
|
||||
);
|
||||
};
|
||||
|
||||
const forgeNextChallenge = (f) => {
|
||||
const g = f.forge;
|
||||
if (!g.next) return answer('Every active challenge is complete — there is nothing left to start.');
|
||||
|
||||
const c = g.next;
|
||||
const gate = g.blocked.find((b) => b.course.id === c.id);
|
||||
|
||||
return doc(
|
||||
heading(c.title, `${c.proof_skill || c.category} · ${c.difficulty}`),
|
||||
text(c.description),
|
||||
kpis([
|
||||
{ label: 'Duration', value: `${c.estimated_minutes} min` },
|
||||
{ label: 'XP', value: c.xp },
|
||||
{ label: 'Earns', value: c.badge_reward || '—' },
|
||||
{ label: 'State', value: gate ? 'Locked' : 'Ready', tone: gate ? 'warning' : 'success' },
|
||||
]),
|
||||
gate
|
||||
? insights([{
|
||||
tone: 'warning',
|
||||
title: 'Blocked for now',
|
||||
body: `${gate.reasons.join('. ')}. Those are real-work requirements, so they clear through shifts rather than through study.`,
|
||||
}])
|
||||
: insights([{
|
||||
tone: 'success',
|
||||
title: 'Ready to start',
|
||||
body: `Nothing is gating this one. ${plural(c.estimated_minutes, 'minute')} of work is worth ${c.xp} XP${c.badge_reward ? ` and the ${c.badge_reward} badge` : ''}.`,
|
||||
}]),
|
||||
g.ready.length > 1
|
||||
? actions(g.ready.slice(1, 3).map((alt) => ({
|
||||
title: alt.title,
|
||||
body: `${alt.difficulty} · ${alt.estimated_minutes} min · ${alt.xp} XP. An alternative if this one does not fit today.`,
|
||||
})))
|
||||
: null
|
||||
);
|
||||
};
|
||||
|
||||
const forgeBlockers = (f) => {
|
||||
const g = f.forge;
|
||||
if (!g.locked.length) {
|
||||
return answer(`Nothing is locked — all ${plural(g.ready.length, 'remaining challenge')} can be started now.`);
|
||||
}
|
||||
|
||||
return doc(
|
||||
heading(`${plural(g.locked.length, 'challenge')} blocked`, 'and what clears each one'),
|
||||
table(
|
||||
[
|
||||
{ key: 'challenge', label: 'Challenge' },
|
||||
{ key: 'skill', label: 'Skill' },
|
||||
{ key: 'requirement', label: 'Blocked by' },
|
||||
],
|
||||
g.blocked.map(({ course, reasons }) => ({
|
||||
challenge: course.title,
|
||||
skill: course.proof_skill || course.category,
|
||||
requirement: reasons[0] || '—',
|
||||
}))
|
||||
),
|
||||
insights(g.blocked.slice(0, 2).map(({ course, reasons }) => ({
|
||||
tone: 'warning',
|
||||
title: course.title,
|
||||
body: `${reasons.join('. ')}. Worth ${course.xp} XP${course.badge_reward ? ` and the ${course.badge_reward} badge` : ''} once it opens.`,
|
||||
}))),
|
||||
note('Requirements are measured against the profile record, so they update as shifts and reliability are logged.')
|
||||
);
|
||||
};
|
||||
|
||||
const forgeReadyNow = (f) => {
|
||||
const g = f.forge;
|
||||
if (!g.ready.length) {
|
||||
return doc(
|
||||
text('Nothing is unlocked right now.'),
|
||||
g.locked.length ? text(`${plural(g.locked.length, 'challenge')} ${verb(g.locked.length, 'is', 'are')} waiting on real-work requirements.`) : null
|
||||
);
|
||||
}
|
||||
|
||||
return doc(
|
||||
heading(`${plural(g.ready.length, 'challenge')} ready to start`),
|
||||
table(
|
||||
[
|
||||
{ key: 'challenge', label: 'Challenge' },
|
||||
{ key: 'difficulty', label: 'Difficulty' },
|
||||
{ key: 'minutes', label: 'Min', align: 'right' },
|
||||
{ key: 'xp', label: 'XP', align: 'right' },
|
||||
],
|
||||
g.ready.map((c) => ({
|
||||
challenge: c.title,
|
||||
difficulty: c.difficulty,
|
||||
minutes: c.estimated_minutes,
|
||||
xp: c.xp,
|
||||
}))
|
||||
),
|
||||
text(
|
||||
`Cheapest first: **${[...g.ready].sort((a, b) => a.estimated_minutes - b.estimated_minutes)[0].title}**. `
|
||||
+ `Highest value: **${[...g.ready].sort((a, b) => b.xp - a.xp)[0].title}**.`
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
const forgeSkillMap = (f) => {
|
||||
const g = f.forge;
|
||||
const skills = g.courses.map((c) => ({
|
||||
skill: c.proof_skill || c.category || 'Workforce skill',
|
||||
state: g.verified.some((b) => b.course_id === c.id)
|
||||
? 'Verified'
|
||||
: g.locked.some((l) => l.id === c.id) ? 'Locked' : 'Ready',
|
||||
xp: c.xp,
|
||||
}));
|
||||
|
||||
return doc(
|
||||
heading('Skills this library can verify', plural(skills.length, 'skill')),
|
||||
table(
|
||||
[
|
||||
{ key: 'skill', label: 'Skill' },
|
||||
{ key: 'state', label: 'State' },
|
||||
{ key: 'xp', label: 'XP', align: 'right' },
|
||||
],
|
||||
skills
|
||||
),
|
||||
text(
|
||||
g.verified.length
|
||||
? `${plural(g.verified.length, 'skill')} already carries evidence an employer can read.`
|
||||
: 'None of these carry evidence yet. Each verified skill adds a record an employer can read against the profile.'
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
/* ── Talent pool ────────────────────────────────────────────────────────── */
|
||||
|
||||
const talentPoolSummary = (f) => {
|
||||
const t = f.talent;
|
||||
return doc(
|
||||
heading('Talent pool', `${plural(f.profiles.length, 'registered worker')}`),
|
||||
kpis([
|
||||
{ label: 'Profiles', value: f.profiles.length, sub: `${t.available.length} available` },
|
||||
{ label: 'Elite', value: f.elite.length, sub: '90+ career score' },
|
||||
{ label: 'Average score', value: t.avgScore ? toFICO(t.avgScore) : '—', sub: 'scored profiles only' },
|
||||
{ label: 'Unscored', value: f.unscoredProfiles.length, sub: 'cannot be ranked', tone: f.unscoredProfiles.length ? 'warning' : undefined },
|
||||
]),
|
||||
heading('Score bands'),
|
||||
meters(t.bands.map((b) => ({ label: b.label, value: b.count, max: f.profiles.length }))),
|
||||
text(
|
||||
f.unscoredProfiles.length
|
||||
? `${plural(f.unscoredProfiles.length, 'profile')} ${verb(f.unscoredProfiles.length, 'has', 'have')} no career score, so ${verb(f.unscoredProfiles.length, 'it is', 'they are')} invisible to ranking and matching.`
|
||||
: 'Every profile carries a career score, so the whole pool is rankable.'
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
const talentPriorities = (f) => {
|
||||
const t = f.talent;
|
||||
if (!f.profiles.length) return answer('No talent profiles registered yet.');
|
||||
|
||||
return doc(
|
||||
heading('Who to prioritize', 'by career score, availability and verification'),
|
||||
table(
|
||||
[
|
||||
{ key: 'name', label: 'Worker' },
|
||||
{ key: 'score', label: 'Score', align: 'right' },
|
||||
{ key: 'experience', label: 'Years', align: 'right' },
|
||||
{ key: 'availability', label: 'Available' },
|
||||
],
|
||||
t.ranked.slice(0, 6).map((p) => ({
|
||||
name: p.full_name || p.email,
|
||||
/* The page shows career score on the 300–850 scale, so this must too —
|
||||
the same worker cannot be 94 here and 817 in the row beside it. */
|
||||
score: p.krow_score ? toFICO(p.krow_score) : '—',
|
||||
experience: p.experience_years ?? '—',
|
||||
availability: (p.availability || []).length ? (p.availability || []).join(', ') : '—',
|
||||
}))
|
||||
),
|
||||
insights([
|
||||
t.top[0] && {
|
||||
tone: 'success',
|
||||
title: `${t.top[0].full_name || t.top[0].email} leads the pool`,
|
||||
body: `Career score ${toFICO(t.top[0].krow_score || 0)}${t.top[0].experience_years ? `, ${plural(t.top[0].experience_years, 'year')} of experience` : ''}.`,
|
||||
},
|
||||
t.available.length < f.profiles.length && {
|
||||
tone: 'info',
|
||||
title: `${t.available.length} of ${f.profiles.length} list availability`,
|
||||
body: 'The rest cannot be matched to a shift until availability is on the record.',
|
||||
},
|
||||
])
|
||||
);
|
||||
};
|
||||
|
||||
const talentVerification = (f) => {
|
||||
const t = f.talent;
|
||||
if (!t.unverified.length) {
|
||||
return answer(`All ${plural(f.profiles.length, 'profile')} carry at least one verified skill.`);
|
||||
}
|
||||
|
||||
return doc(
|
||||
heading(`${plural(t.unverified.length, 'profile')} without verified skills`),
|
||||
table(
|
||||
[
|
||||
{ key: 'name', label: 'Worker' },
|
||||
{ key: 'score', label: 'Score', align: 'right' },
|
||||
{ key: 'skills', label: 'Claimed skills', align: 'right' },
|
||||
],
|
||||
t.unverified.slice(0, 8).map((p) => ({
|
||||
name: p.full_name || p.email,
|
||||
score: p.krow_score ? toFICO(p.krow_score) : '—',
|
||||
skills: (p.skills || []).length,
|
||||
}))
|
||||
),
|
||||
insights([{
|
||||
tone: 'warning',
|
||||
title: 'Claimed is not proven',
|
||||
body: 'These profiles carry skills nobody has verified. A Forge challenge turns each claim into evidence an employer can read.',
|
||||
}]),
|
||||
note(`${pct(t.unverified.length, f.profiles.length)}% of the pool is unverified.`)
|
||||
);
|
||||
};
|
||||
|
||||
const talentAvailability = (f) => {
|
||||
const t = f.talent;
|
||||
const buckets = Object.values(f.profiles.reduce((acc, p) => {
|
||||
(p.availability || ['Not stated']).forEach((slot) => {
|
||||
acc[slot] ||= { slot, count: 0 };
|
||||
acc[slot].count += 1;
|
||||
});
|
||||
return acc;
|
||||
}, {})).sort((a, b) => b.count - a.count);
|
||||
|
||||
return doc(
|
||||
heading('Availability across the pool', `${t.available.length} of ${f.profiles.length} stated`),
|
||||
meters(buckets.map((b) => ({ label: b.slot, value: b.count, max: f.profiles.length }))),
|
||||
text(
|
||||
buckets[0]
|
||||
? `Deepest cover is **${buckets[0].slot}** at ${plural(buckets[0].count, 'worker')}. Thinnest is **${buckets[buckets.length - 1].slot}**.`
|
||||
: 'No availability recorded yet.'
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
/* ── Hired history ──────────────────────────────────────────────────────── */
|
||||
|
||||
const hiringOutcomes = (f) => {
|
||||
const h = f.hiring;
|
||||
if (!h.hires.length) return answer('Nobody has been hired yet, so there is no history to read.');
|
||||
|
||||
return doc(
|
||||
heading('Hiring outcomes', plural(h.hires.length, 'hire')),
|
||||
kpis([
|
||||
{ label: 'Hires', value: h.hires.length, sub: `${h.onboarding.length} onboarding` },
|
||||
{ label: 'Average quality', value: h.avgQuality || '—', sub: 'AI score at hire' },
|
||||
{ label: 'Average speed', value: h.avgSpeed ? `${h.avgSpeed}d` : '—', sub: 'apply to hire' },
|
||||
{ label: 'Rated', value: `${h.rated.length}/${h.hires.length}`, sub: 'client rating on file', tone: h.unrated.length ? 'warning' : undefined },
|
||||
]),
|
||||
heading('By department'),
|
||||
table(
|
||||
[
|
||||
{ key: 'name', label: 'Department' },
|
||||
{ key: 'count', label: 'Hires', align: 'right' },
|
||||
{ key: 'avgScore', label: 'Avg score', align: 'right' },
|
||||
{ key: 'avgDays', label: 'Avg days', align: 'right' },
|
||||
],
|
||||
h.byDepartment.map((d) => ({
|
||||
name: d.name,
|
||||
count: d.count,
|
||||
avgScore: d.avgScore || '—',
|
||||
avgDays: d.avgDays || '—',
|
||||
}))
|
||||
),
|
||||
h.unrated.length
|
||||
? note(`${plural(h.unrated.length, 'hire')} ${verb(h.unrated.length, 'has', 'have')} no client rating yet, so outcome quality rests on the ${h.rated.length} that do.`)
|
||||
: null
|
||||
);
|
||||
};
|
||||
|
||||
const hiringStrongest = (f) => {
|
||||
const h = f.hiring;
|
||||
if (!h.hires.length) return answer('No hires on record yet.');
|
||||
|
||||
const ranked = [...h.hires].sort((a, b) => (b.score || 0) - (a.score || 0));
|
||||
|
||||
return doc(
|
||||
heading('Strongest hires', 'by score at the point of hire'),
|
||||
table(
|
||||
[
|
||||
{ key: 'name', label: 'Hire' },
|
||||
{ key: 'role', label: 'Role' },
|
||||
{ key: 'department', label: 'Department' },
|
||||
{ key: 'score', label: 'Score', align: 'right' },
|
||||
{ key: 'rating', label: 'Rating', align: 'right' },
|
||||
],
|
||||
ranked.slice(0, 6).map((x) => ({
|
||||
name: x.name,
|
||||
role: x.role,
|
||||
department: x.department,
|
||||
score: x.score || '—',
|
||||
rating: x.client_rating || '—',
|
||||
}))
|
||||
),
|
||||
insights([
|
||||
h.strongest && {
|
||||
tone: 'success',
|
||||
title: `${h.strongest.name} scored highest at ${h.strongest.score}`,
|
||||
body: `${h.strongest.role} in ${h.strongest.department}${h.strongest.client_rating ? `, rated ${h.strongest.client_rating} since` : ', not yet rated by the client'}.`,
|
||||
},
|
||||
h.byRoleTitle[0] && {
|
||||
tone: 'info',
|
||||
title: `${h.byRoleTitle[0].name} is the most-hired role`,
|
||||
body: `${plural(h.byRoleTitle[0].count, 'hire')}, averaging ${h.byRoleTitle[0].avgScore || 0} at hire.`,
|
||||
},
|
||||
])
|
||||
);
|
||||
};
|
||||
|
||||
const hiringPatterns = (f) => {
|
||||
const h = f.hiring;
|
||||
if (!h.hires.length) return answer('No hires on record, so there is no pattern to read.');
|
||||
|
||||
const fastest = [...h.byRoleTitle].filter((r) => r.avgDays).sort((a, b) => a.avgDays - b.avgDays)[0];
|
||||
const slowest = [...h.byRoleTitle].filter((r) => r.avgDays).sort((a, b) => b.avgDays - a.avgDays)[0];
|
||||
|
||||
return doc(
|
||||
heading('What stands out', `${plural(h.hires.length, 'hire')} across ${plural(h.byDepartment.length, 'department')}`),
|
||||
meters(h.byDepartment.map((d) => ({
|
||||
label: d.name,
|
||||
value: d.count,
|
||||
max: Math.max(...h.byDepartment.map((x) => x.count), 1),
|
||||
}))),
|
||||
insights([
|
||||
h.byDepartment[0] && {
|
||||
tone: 'info',
|
||||
title: `${h.byDepartment[0].name} accounts for the most hires`,
|
||||
body: `${plural(h.byDepartment[0].count, 'hire')} of ${h.hires.length} — ${pct(h.byDepartment[0].count, h.hires.length)}% of everyone placed.`,
|
||||
},
|
||||
fastest && slowest && fastest.name !== slowest.name && {
|
||||
tone: 'warning',
|
||||
title: `${slowest.name} takes ${slowest.avgDays - fastest.avgDays} days longer than ${fastest.name}`,
|
||||
body: `${slowest.avgDays} days against ${fastest.avgDays}. The gap is where the process, not the candidate, is the constraint.`,
|
||||
},
|
||||
h.unrated.length && {
|
||||
tone: 'warning',
|
||||
title: `${plural(h.unrated.length, 'hire')} without a client rating`,
|
||||
body: 'Outcome quality cannot be judged on hires nobody has rated.',
|
||||
},
|
||||
]),
|
||||
actions([
|
||||
h.unrated.length ? { title: 'Chase the missing ratings', body: `${plural(h.unrated.length, 'placement')} ${verb(h.unrated.length, 'has', 'have')} no client feedback on file.` } : null,
|
||||
h.avgSpeed ? { title: 'Compare speed against quality', body: `Average ${h.avgSpeed} days to hire at an average score of ${h.avgQuality}. Faster is only better while the score holds.` } : null,
|
||||
])
|
||||
);
|
||||
};
|
||||
|
||||
const hiringRecentHires = (f) => {
|
||||
const h = f.hiring;
|
||||
if (!h.hires.length) return answer('No hires on record yet.');
|
||||
|
||||
const recent = [...h.hires]
|
||||
.filter((x) => x.hire_date)
|
||||
.sort((a, b) => new Date(b.hire_date).getTime() - new Date(a.hire_date).getTime())
|
||||
.slice(0, 6);
|
||||
|
||||
return doc(
|
||||
heading('Recent hires', 'most recent first'),
|
||||
timeline(recent.map((x) => ({
|
||||
title: `${x.name} — ${x.role}`,
|
||||
description: `${x.department}${x.score ? ` · scored ${x.score}` : ''}${x.timeToHire ? ` · ${plural(x.timeToHire, 'day')} to hire` : ''}`,
|
||||
timestamp: x.hire_date,
|
||||
tone: x.status === 'onboarding' ? 'warning' : 'success',
|
||||
}))),
|
||||
h.onboarding.length
|
||||
? note(`${plural(h.onboarding.length, 'hire')} ${verb(h.onboarding.length, 'is', 'are')} still onboarding.`)
|
||||
: null
|
||||
);
|
||||
};
|
||||
|
||||
/* ── Free-text responders for the three new contexts ────────────────────── */
|
||||
|
||||
export const respondForge = (question, f) => {
|
||||
const q = question.toLowerCase();
|
||||
if (has(q, 'block', 'locked', 'lock', 'requirement', 'prerequisite', 'why can', 'unlock')) return forgeBlockers(f);
|
||||
if (has(q, 'next', 'should i', 'which challenge', 'recommend', 'best')) return forgeNextChallenge(f);
|
||||
if (has(q, 'ready', 'start now', 'available', 'can i start', 'active challenge')) return forgeReadyNow(f);
|
||||
if (has(q, 'skill', 'verified', 'proof', 'evidence', 'badge')) return forgeSkillMap(f);
|
||||
if (has(q, 'summar', 'progress', 'how am i', 'xp', 'overview')) return forgeProgress(f);
|
||||
return forgeProgress(f);
|
||||
};
|
||||
|
||||
export const respondTalentPool = (question, f) => {
|
||||
const q = question.toLowerCase();
|
||||
if (has(q, 'verif', 'unverified', 'proof', 'evidence', 'badge')) return talentVerification(f);
|
||||
if (has(q, 'available', 'availability', 'shift', 'weekend', 'evening')) return talentAvailability(f);
|
||||
if (has(q, 'priorit', 'strongest', 'best', 'top', 'who should')) return talentPriorities(f);
|
||||
if (has(q, 'unscored', 'no score', 'missing', 'gap')) return talentPoolSummary(f);
|
||||
if (has(q, 'summar', 'overview', 'pool', 'how many')) return talentPoolSummary(f);
|
||||
return talentPriorities(f);
|
||||
};
|
||||
|
||||
export const respondHiredHistory = (question, f) => {
|
||||
const q = question.toLowerCase();
|
||||
if (has(q, 'pattern', 'stand out', 'trend', 'insight', 'attention', 'concern')) return hiringPatterns(f);
|
||||
if (has(q, 'strongest', 'best', 'top', 'quality', 'highest')) return hiringStrongest(f);
|
||||
if (has(q, 'recent', 'latest', 'summar', 'who did we hire')) return hiringRecentHires(f);
|
||||
if (has(q, 'department', 'position', 'role', 'team', 'most hires')) return hiringOutcomes(f);
|
||||
return hiringOutcomes(f);
|
||||
};
|
||||
|
||||
/* ── Capability manifests for the three new contexts ────────────────────── */
|
||||
|
||||
export const FORGE_CAPABILITIES = [
|
||||
{ id: 'forge-progress', label: 'Forge Progress', icon: Gauge, run: forgeProgress },
|
||||
{ id: 'forge-next', label: 'Next Challenge', icon: Target, run: forgeNextChallenge },
|
||||
{ id: 'forge-ready', label: 'Ready To Start', icon: UserCheck, run: forgeReadyNow },
|
||||
{ id: 'forge-blockers', label: 'What Is Blocking Me', icon: Lock, run: forgeBlockers },
|
||||
{ id: 'forge-skills', label: 'Skill Map', icon: ShieldCheck, run: forgeSkillMap },
|
||||
];
|
||||
|
||||
export const TALENT_POOL_CAPABILITIES = [
|
||||
{ id: 'talent-priorities', label: 'Who To Prioritize', icon: Star, run: talentPriorities },
|
||||
{ id: 'talent-summary', label: 'Pool Summary', icon: Users, run: talentPoolSummary },
|
||||
{ id: 'talent-verification', label: 'Verification Gaps', icon: ShieldCheck, run: talentVerification },
|
||||
{ id: 'talent-availability', label: 'Availability', icon: Clock, run: talentAvailability },
|
||||
];
|
||||
|
||||
export const HIRED_HISTORY_CAPABILITIES = [
|
||||
{ id: 'hiring-outcomes', label: 'Hiring Outcomes', icon: Award, run: hiringOutcomes },
|
||||
{ id: 'hiring-strongest', label: 'Strongest Hires', icon: TrendingUp, run: hiringStrongest },
|
||||
{ id: 'hiring-patterns', label: 'What Stands Out', icon: Layers, run: hiringPatterns },
|
||||
{ id: 'hiring-recent', label: 'Recent Hires', icon: ScrollText, run: hiringRecentHires },
|
||||
];
|
||||
|
||||
@@ -4,9 +4,11 @@ import {
|
||||
} from './capabilities/employer';
|
||||
import {
|
||||
ACTIVITY_CAPABILITIES, ADMIN_ANALYTICS_CAPABILITIES, ADMIN_CANDIDATE_CAPABILITIES,
|
||||
CANDIDATE_LIST_CAPABILITIES, CONTROL_CENTER_CAPABILITIES, POSITIONS_CAPABILITIES,
|
||||
CANDIDATE_LIST_CAPABILITIES, CONTROL_CENTER_CAPABILITIES, FORGE_CAPABILITIES,
|
||||
HIRED_HISTORY_CAPABILITIES, POSITIONS_CAPABILITIES, TALENT_POOL_CAPABILITIES,
|
||||
respondActivity, respondAdminAnalytics, respondAdminCandidates, respondCandidateList,
|
||||
respondControlCenter, respondPositions,
|
||||
respondControlCenter, respondForge, respondHiredHistory, respondPositions,
|
||||
respondTalentPool,
|
||||
} from './capabilities/admin';
|
||||
|
||||
/**
|
||||
@@ -74,6 +76,27 @@ export const ASSISTANT_CONTEXTS = {
|
||||
capabilities: ADMIN_ANALYTICS_CAPABILITIES,
|
||||
respond: respondAdminAnalytics,
|
||||
},
|
||||
/* Forge, Talent Pool and Hired History read the same platform records as the
|
||||
contexts above, but ask different questions of them: proof and progression,
|
||||
supply and readiness, and outcomes after the hire. */
|
||||
'admin.forge': {
|
||||
id: 'admin.forge',
|
||||
page: 'KROW Forge',
|
||||
capabilities: FORGE_CAPABILITIES,
|
||||
respond: respondForge,
|
||||
},
|
||||
'admin.talentPool': {
|
||||
id: 'admin.talentPool',
|
||||
page: 'Talent Pool',
|
||||
capabilities: TALENT_POOL_CAPABILITIES,
|
||||
respond: respondTalentPool,
|
||||
},
|
||||
'admin.hiredHistory': {
|
||||
id: 'admin.hiredHistory',
|
||||
page: 'Hired History',
|
||||
capabilities: HIRED_HISTORY_CAPABILITIES,
|
||||
respond: respondHiredHistory,
|
||||
},
|
||||
'admin.activity': {
|
||||
id: 'admin.activity',
|
||||
page: 'Activity',
|
||||
|
||||
@@ -133,6 +133,38 @@ const GREETINGS = {
|
||||
return `${plural(f.hired.length, 'hire')} from ${plural(f.total, 'applicant')} — ${f.hireRate}% conversion.`;
|
||||
},
|
||||
|
||||
'admin.forge': (f) => {
|
||||
const g = f.forge;
|
||||
if (!g.courses.length) return 'No challenges published yet, so nothing can be proven here.';
|
||||
if (g.locked.length && !g.ready.length) {
|
||||
return `${plural(g.locked.length, 'challenge')} ${verb(g.locked.length, 'is', 'are')} gated by real-work requirements.`;
|
||||
}
|
||||
if (!g.verified.length) {
|
||||
return `Nothing verified yet — ${plural(g.ready.length, 'challenge')} ${verb(g.ready.length, 'is', 'are')} ready to start.`;
|
||||
}
|
||||
return `${plural(g.verified.length, 'skill')} verified, ${plural(g.ready.length, 'challenge')} ready to start.`;
|
||||
},
|
||||
|
||||
'admin.talentPool': (f) => {
|
||||
if (!f.profiles.length) return 'No talent profiles registered yet.';
|
||||
if (f.unscoredProfiles.length) {
|
||||
return `${plural(f.unscoredProfiles.length, 'profile')} ${verb(f.unscoredProfiles.length, 'has', 'have')} no career score and cannot be ranked.`;
|
||||
}
|
||||
if (f.talent.unverified.length) {
|
||||
return `${plural(f.talent.unverified.length, 'profile')} ${verb(f.talent.unverified.length, 'carries', 'carry')} no verified skill.`;
|
||||
}
|
||||
return `${plural(f.profiles.length, 'worker')} in the pool, ${f.elite.length} Elite.`;
|
||||
},
|
||||
|
||||
'admin.hiredHistory': (f) => {
|
||||
const h = f.hiring;
|
||||
if (!h.hires.length) return 'Nobody has been hired yet, so there is no history to read.';
|
||||
if (h.unrated.length) {
|
||||
return `${plural(h.unrated.length, 'hire')} ${verb(h.unrated.length, 'has', 'have')} no client rating on file.`;
|
||||
}
|
||||
return `${plural(h.hires.length, 'hire')} placed, averaging ${h.avgQuality} at hire.`;
|
||||
},
|
||||
|
||||
'admin.activity': (f) => {
|
||||
if (!f.activity.length) return 'No activity logged yet.';
|
||||
const patterns = f.activitySignals.flags.length;
|
||||
@@ -168,6 +200,9 @@ const TITLES = {
|
||||
'admin.candidates': (f, name) => `Good ${timeOfDay(f.today)}${name ? `, ${name}` : ''}`,
|
||||
'admin.analytics': (f, name) => `Good ${timeOfDay(f.today)}${name ? `, ${name}` : ''}`,
|
||||
'admin.activity': (f, name) => `Good ${timeOfDay(f.today)}${name ? `, ${name}` : ''}`,
|
||||
'admin.forge': (f, name) => `Good ${timeOfDay(f.today)}${name ? `, ${name}` : ''}`,
|
||||
'admin.talentPool': (f, name) => `Good ${timeOfDay(f.today)}${name ? `, ${name}` : ''}`,
|
||||
'admin.hiredHistory': (f, name) => `Good ${timeOfDay(f.today)}${name ? `, ${name}` : ''}`,
|
||||
};
|
||||
|
||||
/** Descriptions state what this page's assistant can actually do with the data. */
|
||||
@@ -187,6 +222,12 @@ const DESCRIPTIONS = {
|
||||
'admin.analytics': (f) =>
|
||||
`Reading hiring performance across ${plural(f.openPositions.length, 'position')}, ${plural(f.total, 'candidate')} and ${plural(new Set(f.byRole.map((r) => r.category)).size, 'department')} — conversion, speed and outcomes.`,
|
||||
'admin.activity': (f) => `${plural(f.activity.length, 'logged event')}, reconciled and profiled.`,
|
||||
'admin.forge': (f) =>
|
||||
`Reading ${plural(f.forge.courses.length, 'challenge')} — verified skills, what is ready to start and what is still locked.`,
|
||||
'admin.talentPool': (f) =>
|
||||
`Reading ${plural(f.profiles.length, 'talent profile')} — availability, career score and verification.`,
|
||||
'admin.hiredHistory': (f) =>
|
||||
`Reading ${plural(f.hiring.hires.length, 'hire')} across ${plural(f.hiring.byDepartment.length, 'department')} — quality, speed and outcomes.`,
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -242,6 +283,21 @@ const PLACEHOLDERS = {
|
||||
'Ask Owliver about unusual activity…',
|
||||
'Ask Owliver about security concerns…',
|
||||
],
|
||||
'admin.forge': () => [
|
||||
'Ask Owliver which challenge to take next…',
|
||||
'Ask Owliver what is blocking a skill…',
|
||||
'Ask Owliver about Forge progress…',
|
||||
],
|
||||
'admin.talentPool': () => [
|
||||
'Ask Owliver who to prioritize…',
|
||||
'Ask Owliver about verification gaps…',
|
||||
'Ask Owliver who is available…',
|
||||
],
|
||||
'admin.hiredHistory': () => [
|
||||
'Ask Owliver about hiring outcomes…',
|
||||
'Ask Owliver which roles hired best…',
|
||||
'Ask Owliver what stands out…',
|
||||
],
|
||||
};
|
||||
|
||||
/* ── Suggested prompts ──────────────────────────────────────────────────── */
|
||||
@@ -365,6 +421,58 @@ const PROMPTS = {
|
||||
: { label: 'What needs attention?', prompt: 'What needs attention?', capability: 'attention-required' },
|
||||
],
|
||||
|
||||
/* Forge, Talent Pool and Hired History. Same rule as the contexts above: the
|
||||
first chip is whatever the data has made urgent, so the set reorders itself
|
||||
rather than offering the same questions regardless of state. */
|
||||
'admin.forge': (f) => {
|
||||
const g = f.forge;
|
||||
return [
|
||||
g.next
|
||||
? { label: `Should I take ${g.next.title}?`, prompt: `Which challenge should I complete next? Start with ${g.next.title}`, capability: 'forge-next' }
|
||||
: { label: 'Which challenge should I take next?', prompt: 'Which challenge should I complete next?', capability: 'forge-next' },
|
||||
g.locked.length
|
||||
? { label: `${g.locked.length} blocked by requirements`, prompt: 'What is blocking my next skill?', capability: 'forge-blockers' }
|
||||
: { label: 'What is blocking my next skill?', prompt: 'What is blocking my next skill?', capability: 'forge-blockers' },
|
||||
g.ready.length
|
||||
? { label: `${g.ready.length} ready to start`, prompt: 'Which challenges can I start right now?', capability: 'forge-ready' }
|
||||
: { label: 'Which challenges can I start?', prompt: 'Which challenges can I start right now?', capability: 'forge-ready' },
|
||||
{ label: 'Which skills am I closest to unlocking?', prompt: 'Which skills am I closest to unlocking?', capability: 'forge-skills' },
|
||||
{ label: 'Summarize my Forge progress', prompt: 'Summarize my Forge progress', capability: 'forge-progress' },
|
||||
];
|
||||
},
|
||||
|
||||
'admin.talentPool': (f) => {
|
||||
const t = f.talent;
|
||||
return [
|
||||
t.top[0]
|
||||
? { label: `Why does ${t.top[0].full_name?.split(' ')[0] || 'the top profile'} rank first?`, prompt: 'Which talent should I prioritize?', capability: 'talent-priorities' }
|
||||
: { label: 'Which talent should I prioritize?', prompt: 'Which talent should I prioritize?', capability: 'talent-priorities' },
|
||||
f.unscoredProfiles.length
|
||||
? { label: `${f.unscoredProfiles.length} profiles unscored`, prompt: 'Which profiles are unscored?', capability: 'talent-summary' }
|
||||
: { label: 'Summarize the talent pool', prompt: 'Summarize the talent pool', capability: 'talent-summary' },
|
||||
t.unverified.length
|
||||
? { label: `${t.unverified.length} need verification`, prompt: 'Which talent needs verification?', capability: 'talent-verification' }
|
||||
: { label: 'Which talent needs verification?', prompt: 'Which talent needs verification?', capability: 'talent-verification' },
|
||||
{ label: 'Who is currently available?', prompt: 'Who is currently available?', capability: 'talent-availability' },
|
||||
{ label: 'Who has the strongest score?', prompt: 'Who has the strongest score?', capability: 'talent-priorities' },
|
||||
];
|
||||
},
|
||||
|
||||
'admin.hiredHistory': (f) => {
|
||||
const h = f.hiring;
|
||||
return [
|
||||
h.unrated.length
|
||||
? { label: `${h.unrated.length} hires unrated`, prompt: 'What needs attention in hiring outcomes?', capability: 'hiring-patterns' }
|
||||
: { label: 'What hiring patterns stand out?', prompt: 'What hiring patterns stand out?', capability: 'hiring-patterns' },
|
||||
h.byDepartment[0]
|
||||
? { label: `${h.byDepartment[0].name} leads on hires`, prompt: 'Which department has the most hires?', capability: 'hiring-outcomes' }
|
||||
: { label: 'Which department has the most hires?', prompt: 'Which department has the most hires?', capability: 'hiring-outcomes' },
|
||||
{ label: 'Which positions hired the strongest talent?', prompt: 'Which positions hired the strongest talent?', capability: 'hiring-strongest' },
|
||||
{ label: 'Summarize recent hires', prompt: 'Summarize recent hires', capability: 'hiring-recent' },
|
||||
{ label: 'What hiring outcomes should I review?', prompt: 'What hiring outcomes should I review?', capability: 'hiring-outcomes' },
|
||||
];
|
||||
},
|
||||
|
||||
'admin.activity': (f) => [
|
||||
f.activitySignals.flags.length
|
||||
? { label: `${f.activitySignals.flags.length} unusual patterns`, prompt: 'Show unusual activity on the platform', capability: 'unusual-activity' }
|
||||
|
||||
@@ -9,6 +9,9 @@
|
||||
* transport, no dependency on any AI product.
|
||||
*/
|
||||
|
||||
import { isUnlocked } from '@/lib/provingGround';
|
||||
import { getScoreBand } from '@/lib/talentHome';
|
||||
|
||||
export const pct = (n, d) => (d ? Math.round((n / d) * 100) : 0);
|
||||
|
||||
export const plural = (n, word, irregular) =>
|
||||
@@ -174,6 +177,107 @@ export function buildFacts({
|
||||
};
|
||||
})();
|
||||
|
||||
/* ── KROW Forge ──────────────────────────────────────────────────────────
|
||||
Derived with the same gate (`isUnlocked`) and the same completion record the
|
||||
Forge page renders from, so the panel and the page cannot disagree about
|
||||
what is ready, what is blocked, or why. `profile` is the signed-in worker's
|
||||
record when one is already in cache; without it the counts are the library's
|
||||
rather than the person's, which is still true — just less personal. */
|
||||
const forgeCourses = courses.filter((c) => c.status === 'active');
|
||||
const forgeCompletedIds = new Set((profile?.completed_courses || []).map((c) => c.course_id));
|
||||
const forgeGate = (course) => isUnlocked(course, profile || {});
|
||||
const forgeUnfinished = forgeCourses.filter((c) => !forgeCompletedIds.has(c.id));
|
||||
const forgeReady = forgeUnfinished.filter((c) => forgeGate(c).unlocked);
|
||||
const forgeLocked = forgeUnfinished.filter((c) => !forgeGate(c).unlocked);
|
||||
const forge = {
|
||||
courses: forgeCourses,
|
||||
verified: profile?.earned_badges || [],
|
||||
completed: (profile?.completed_courses || []).filter((c) => c.status !== 'in_progress'),
|
||||
inProgress: (profile?.completed_courses || []).filter((c) => c.status === 'in_progress'),
|
||||
ready: forgeReady,
|
||||
locked: forgeLocked,
|
||||
/* The page features the first unfinished challenge whether or not it is
|
||||
unlocked, so the panel names the same one — a chip that recommends a
|
||||
different challenge than the one on screen reads as a second opinion. */
|
||||
next: forgeUnfinished[0] || null,
|
||||
xpAvailable: forgeUnfinished.reduce((sum, c) => sum + (c.xp || 0), 0),
|
||||
/* Every blocked challenge with the reasons that block it, in page order. */
|
||||
blocked: forgeLocked.map((course) => ({ course, reasons: forgeGate(course).reasons })),
|
||||
byDifficulty: ['beginner', 'intermediate', 'advanced'].map((level) => ({
|
||||
level,
|
||||
count: forgeCourses.filter((c) => c.difficulty === level).length,
|
||||
})).filter((d) => d.count),
|
||||
};
|
||||
|
||||
/* ── Talent pool ─────────────────────────────────────────────────────────
|
||||
The same score bands the Talent Pool page labels profiles with. */
|
||||
const rankedProfiles = [...profiles].sort((a, b) => (b.krow_score || 0) - (a.krow_score || 0));
|
||||
const talent = {
|
||||
ranked: rankedProfiles,
|
||||
top: rankedProfiles.slice(0, 3),
|
||||
available: profiles.filter((p) => (p.availability || []).length > 0),
|
||||
unverified: profiles.filter(
|
||||
(p) => !(p.earned_badges || []).length
|
||||
&& !(p.capabilities || []).some((c) => c.status === 'verified')
|
||||
),
|
||||
experienced: profiles.filter((p) => (p.experience_years || 0) >= 3),
|
||||
avgScore: avg(profiles.filter((p) => p.krow_score > 0).map((p) => p.krow_score)),
|
||||
bands: Object.values(profiles.reduce((acc, p) => {
|
||||
const { label } = getScoreBand(p.krow_score || 0);
|
||||
acc[label] ||= { label, count: 0 };
|
||||
acc[label].count += 1;
|
||||
return acc;
|
||||
}, {})).sort((a, b) => b.count - a.count),
|
||||
};
|
||||
|
||||
/* ── Hired history ───────────────────────────────────────────────────────
|
||||
Staff joined to the application and posting they came from — the same join
|
||||
the Hired History page performs, so department, speed and score match. */
|
||||
const hires = staff.map((s) => {
|
||||
const app = applications.find((a) => a.id === s.application_id);
|
||||
const posting = postings.find((p) => p.id === s.job_posting_id);
|
||||
return {
|
||||
...s,
|
||||
department: posting?.role_category || '—',
|
||||
timeToHire: app
|
||||
? Math.max(1, Math.round(
|
||||
(new Date(app.updated_date).getTime() - new Date(app.created_date).getTime()) / DAY_MS
|
||||
))
|
||||
: null,
|
||||
score: s.ai_score || app?.ai_score || 0,
|
||||
};
|
||||
});
|
||||
|
||||
const groupHires = (key) => Object.values(hires.reduce((acc, h) => {
|
||||
const name = h[key] || 'Unspecified';
|
||||
acc[name] ||= { name, count: 0, scores: [], days: [], ratings: [] };
|
||||
acc[name].count += 1;
|
||||
if (h.score) acc[name].scores.push(h.score);
|
||||
if (h.timeToHire) acc[name].days.push(h.timeToHire);
|
||||
if (h.client_rating) acc[name].ratings.push(h.client_rating);
|
||||
return acc;
|
||||
}, {})).map((e) => ({
|
||||
name: e.name,
|
||||
count: e.count,
|
||||
avgScore: avg(e.scores),
|
||||
avgDays: avg(e.days),
|
||||
avgRating: e.ratings.length
|
||||
? Number((e.ratings.reduce((a, b) => a + b, 0) / e.ratings.length).toFixed(1))
|
||||
: null,
|
||||
})).sort((a, b) => b.count - a.count);
|
||||
|
||||
const hiring = {
|
||||
hires,
|
||||
byDepartment: groupHires('department'),
|
||||
byRoleTitle: groupHires('role'),
|
||||
unrated: hires.filter((h) => !h.client_rating),
|
||||
rated: hires.filter((h) => h.client_rating),
|
||||
avgQuality: avg(hires.map((h) => h.score).filter(Boolean)),
|
||||
avgSpeed: avg(hires.map((h) => h.timeToHire).filter(Boolean)),
|
||||
strongest: [...hires].sort((a, b) => (b.score || 0) - (a.score || 0))[0] || null,
|
||||
onboarding: hires.filter((h) => h.status === 'onboarding'),
|
||||
};
|
||||
|
||||
return {
|
||||
today,
|
||||
|
||||
@@ -248,9 +352,12 @@ export function buildFacts({
|
||||
eventCounts,
|
||||
activitySignals,
|
||||
|
||||
/* Signed-in worker (talent context only) */
|
||||
/* Signed-in worker, and the pages built on these records */
|
||||
profile,
|
||||
courses,
|
||||
forge,
|
||||
talent,
|
||||
hiring,
|
||||
|
||||
/* Economics — mirrors ImpactMetrics */
|
||||
costSaved: scored.length * 45 + interviews.length * 80,
|
||||
|
||||
@@ -19,6 +19,9 @@ const PLACEMENT = {
|
||||
'/admin/candidates-analysis': 'admin.candidates',
|
||||
'/admin/analytics': 'admin.analytics',
|
||||
'/admin/activity': 'admin.activity',
|
||||
'/admin/university': 'admin.forge',
|
||||
'/admin/talent-pool': 'admin.talentPool',
|
||||
'/admin/hired': 'admin.hiredHistory',
|
||||
},
|
||||
};
|
||||
|
||||
@@ -26,9 +29,9 @@ const PLACEMENT = {
|
||||
* Pages that must never carry the assistant, listed explicitly so the intent is
|
||||
* documented rather than implied by omission.
|
||||
*
|
||||
* Admin: the reporting and reference pages, plus login. The working surfaces —
|
||||
* Control Center, Positions, Candidates, Candidates Analysis, Analytics
|
||||
* and Activity — all carry the panel.
|
||||
* Admin: profile and login only. Every working surface — Control Center,
|
||||
* Positions, Candidates, Candidates Analysis, Analytics, Activity,
|
||||
* KROW Forge, Talent Pool and Hired History — carries the panel.
|
||||
* Employer: every page. The contextual panel is an Admin capability.
|
||||
* Talent: every page. The talent portal has the standalone Owliver product,
|
||||
* with its own voice experience, branding and workflow.
|
||||
@@ -39,8 +42,8 @@ export const EXCLUDED_ROUTES = [
|
||||
'/hired', '/talent-pool', '/analytics', '/profile', '/apply',
|
||||
'/university', '/university/:id', '/me', '/identity', '/employee',
|
||||
'/owliver', '/design-system',
|
||||
// Admin pages that are a report or a form rather than a workflow.
|
||||
'/admin/hired', '/admin/talent-pool', '/admin/profile', '/admin/login',
|
||||
// Admin pages that are a form rather than a workflow.
|
||||
'/admin/profile', '/admin/login',
|
||||
// The full candidate profile is one person's record, not a page-level question.
|
||||
'/admin/candidates/:id',
|
||||
];
|
||||
|
||||
@@ -3,7 +3,7 @@ import { useQuery } from '@tanstack/react-query';
|
||||
import { base44 } from '@/api/base44Client';
|
||||
import {
|
||||
useApplications, useCourses, useInterviews, useJobPostings, useStaff,
|
||||
useUserActivity, useWorkerProfiles,
|
||||
useUserActivity, useWorkerProfile, useWorkerProfiles,
|
||||
} from '@/lib/krowHooks';
|
||||
import { buildFacts } from './insights';
|
||||
import { createAssistantProvider } from './provider';
|
||||
@@ -25,12 +25,16 @@ export function useAssistantFacts() {
|
||||
const { data: activity = [] } = useUserActivity();
|
||||
const { data: courses = [] } = useCourses();
|
||||
|
||||
// Krow AI serves Employer and Admin only, so it never reads the signed-in
|
||||
// worker's own profile — that query also creates the record if absent, and it
|
||||
// belongs to the talent portal, not here.
|
||||
// The signed-in worker's own record, read from cache only: `enabled: false`
|
||||
// means this never runs the query, which would *create* a WorkerProfile for an
|
||||
// admin who has never opened Forge. So the Forge context is personal on pages
|
||||
// that already loaded it, and falls back to library-level facts elsewhere
|
||||
// rather than manufacturing a record as a side effect of opening a panel.
|
||||
const { data: profile = null } = useWorkerProfile({ enabled: false });
|
||||
|
||||
return React.useMemo(
|
||||
() => buildFacts({ applications, postings, interviews, staff, profiles, activity, courses }),
|
||||
[applications, postings, interviews, staff, profiles, activity, courses]
|
||||
() => buildFacts({ applications, postings, interviews, staff, profiles, activity, courses, profile }),
|
||||
[applications, postings, interviews, staff, profiles, activity, courses, profile]
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
93
src/components/charts/DepartmentPerformance.jsx
Normal file
93
src/components/charts/DepartmentPerformance.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
114
src/components/charts/HiringFlow.jsx
Normal file
114
src/components/charts/HiringFlow.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
60
src/components/charts/HiringTrendChart.jsx
Normal file
60
src/components/charts/HiringTrendChart.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
86
src/components/charts/muiChartTheme.js
Normal file
86
src/components/charts/muiChartTheme.js
Normal 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.
|
||||
*/
|
||||
191
src/components/forge/ChallengeDirectory.jsx
Normal file
191
src/components/forge/ChallengeDirectory.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
44
src/components/forge/ForgeHeader.jsx
Normal file
44
src/components/forge/ForgeHeader.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
41
src/components/forge/ForgeInsight.jsx
Normal file
41
src/components/forge/ForgeInsight.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
96
src/components/forge/ForgeProgress.jsx
Normal file
96
src/components/forge/ForgeProgress.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
267
src/components/forge/NextSkill.jsx
Normal file
267
src/components/forge/NextSkill.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
128
src/components/forge/YourForge.jsx
Normal file
128
src/components/forge/YourForge.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
92
src/components/forge/challengeMeta.js
Normal file
92
src/components/forge/challengeMeta.js
Normal 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'] : [];
|
||||
}
|
||||
24
src/components/forge/index.js
Normal file
24
src/components/forge/index.js
Normal 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';
|
||||
@@ -1,12 +1,33 @@
|
||||
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 UniversityCourseCard from '@/components/krow/UniversityCourseCard';
|
||||
import { isUnlocked } from '@/lib/provingGround';
|
||||
import {
|
||||
Sparkles, CheckCircle2, ShieldCheck, ArrowRight, Search, Zap, Clock,
|
||||
Lock, TrendingUp, Compass, Award, Loader2, Filter
|
||||
} from 'lucide-react';
|
||||
buildRequirements, ChallengeDirectory, ForgeHeader, ForgeInsight, ForgeProgress, NextSkill,
|
||||
YourForge,
|
||||
} 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 = [
|
||||
{ v: 'all', label: 'All' },
|
||||
@@ -15,6 +36,15 @@ const DIFFS = [
|
||||
{ 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() {
|
||||
const { data: profile } = useWorkerProfile();
|
||||
const { data: courses = [], isLoading: loadingCourses } = useCourses();
|
||||
@@ -23,7 +53,7 @@ export default function University() {
|
||||
const [search, setSearch] = useState('');
|
||||
const [diffFilter, setDiffFilter] = useState('all');
|
||||
const challengesRef = useRef(null);
|
||||
const nextSkillsRef = useRef(null);
|
||||
const nextSkillRef = useRef(null);
|
||||
|
||||
const completedIds = useMemo(
|
||||
() => new Set((profile?.completed_courses || []).map((c) => c.course_id)),
|
||||
@@ -34,14 +64,6 @@ export default function University() {
|
||||
[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(() => {
|
||||
return profile?.earned_badges || [];
|
||||
}, [profile]);
|
||||
@@ -57,9 +79,59 @@ export default function University() {
|
||||
};
|
||||
|
||||
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) {
|
||||
return (
|
||||
<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 inProgressCount = (profile?.completed_courses || []).filter(c => c.status === 'in_progress').length || 2;
|
||||
const nextUnlockCount = recommendedCourses.length || 4;
|
||||
const firstActiveCourse = recommendedCourses[0] || activeCourses[0];
|
||||
const figures = [
|
||||
{ label: 'Verified skills', value: verifiedCount, tone: verifiedCount ? 'text-success' : 'text-ink-1' },
|
||||
{ label: 'In progress', value: inProgressCount },
|
||||
{ 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 (
|
||||
<div className="max-w-5xl mx-auto space-y-8 pb-12 font-sans selection:bg-[#0A39DF] selection:text-white">
|
||||
{/* 1. Page Hero & Progress Visualization */}
|
||||
<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">
|
||||
<div className="absolute top-0 right-0 w-96 h-96 bg-[#0A39DF]/30 rounded-full blur-3xl pointer-events-none" />
|
||||
/* No max-width here: the Admin shell already supplies the gutters
|
||||
(px-4 lg:px-6 on a flex-1 main), which is how every other Admin page is
|
||||
sized. This div only owns vertical rhythm. */
|
||||
<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">
|
||||
<div className="md:col-span-2 space-y-3">
|
||||
<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>
|
||||
{/* 2 ── Where the worker stands, and where it leads. */}
|
||||
<ForgeProgress figures={figures} stages={stages} loading={loadingCourses} />
|
||||
|
||||
{/* Right Compact Progress Card */}
|
||||
<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">
|
||||
<div className="flex items-center justify-between border-b border-white/10 pb-2">
|
||||
<span className="text-[11px] font-bold uppercase tracking-wider text-slate-300">
|
||||
FORGE PROGRESS
|
||||
</span>
|
||||
<ShieldCheck className="w-4 h-4 text-[#F9E547]" />
|
||||
</div>
|
||||
{/* 3 ── The record, or the process that creates it. */}
|
||||
<YourForge
|
||||
skills={verifiedSkills}
|
||||
evidenceCount={evidence.length}
|
||||
onBrowse={scrollToChallenges}
|
||||
formatDate={earnedOn}
|
||||
/>
|
||||
|
||||
<div className="grid grid-cols-3 gap-2 text-center py-1">
|
||||
<div>
|
||||
<p className="text-xl font-extrabold text-[#F9E547]">{verifiedCount}</p>
|
||||
<p className="text-[10px] text-slate-300 font-medium leading-tight">Verified skills</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xl font-extrabold text-white">{inProgressCount}</p>
|
||||
<p className="text-[10px] text-slate-300 font-medium leading-tight">In progress</p>
|
||||
</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>
|
||||
{/* 4 ── The skill to prove now. The page's focus. */}
|
||||
<div ref={nextSkillRef}>
|
||||
<NextSkill
|
||||
course={lead}
|
||||
locked={leadLocked}
|
||||
requirements={leadRequirements}
|
||||
actionLabel={leadAction}
|
||||
index={verifiedCount + 1}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={scrollToNext}
|
||||
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"
|
||||
>
|
||||
View skill map →
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
{/* 5 ── Everything else. */}
|
||||
<div ref={challengesRef}>
|
||||
<ChallengeDirectory
|
||||
courses={otherCourses}
|
||||
results={filtered}
|
||||
search={search}
|
||||
onSearch={setSearch}
|
||||
difficulty={diffFilter}
|
||||
onDifficulty={setDiffFilter}
|
||||
difficulties={DIFFS}
|
||||
loading={loadingCourses}
|
||||
filtered={isFiltered}
|
||||
onClear={clearFilters}
|
||||
completedIds={completedIds}
|
||||
profile={profile}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 2. Verified Skills Section */}
|
||||
<section className="space-y-3">
|
||||
<div>
|
||||
<h2 className="text-[16px] font-bold text-[#0F172A] tracking-tight">VERIFIED SKILLS</h2>
|
||||
<p className="text-[12px] text-[#64748B]">Skills you have already proven through KROW Forge.</p>
|
||||
</div>
|
||||
|
||||
{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>
|
||||
{/* 6 ── The remark. */}
|
||||
<ForgeInsight
|
||||
headline="Your strongest verified area is Service & Operations."
|
||||
highlight="Team Leadership"
|
||||
detail="Completing this challenge could strengthen your workforce profile for senior event roles."
|
||||
actionLabel="Explore recommended skill"
|
||||
onAction={scrollToNext}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
} from '@/components/ds';
|
||||
import { useApplications, useInterviews, useJobPostings, useStaff, useWorkerProfiles } from '@/lib/krowHooks';
|
||||
import { buildFacts } from '@/components/ai-assistant/insights';
|
||||
import { HiringFlow } from '@/components/charts/HiringFlow';
|
||||
import { AdminPage, SectionTitle } from '@/pages/admin/_shell';
|
||||
|
||||
/**
|
||||
@@ -185,50 +186,32 @@ export default function AdminAnalytics() {
|
||||
</ResponsiveContainer>
|
||||
</section>
|
||||
|
||||
<div className="grid gap-6 lg:grid-cols-2">
|
||||
{/* Pipeline conversion — stage-to-stage rates are the point, so each
|
||||
step shows its own pass-through rather than only its count. */}
|
||||
<section aria-labelledby="conversion" className="space-y-3">
|
||||
<SectionTitle id="conversion" title="Pipeline conversion" meta={`${f.total} entered`} />
|
||||
<div className="space-y-3">
|
||||
{f.funnel.map((stage, i) => {
|
||||
const rate = i === 0 ? 100 : f.transitions[i - 1]?.rate ?? 0;
|
||||
const lost = i === 0 ? 0 : f.transitions[i - 1]?.lost ?? 0;
|
||||
return (
|
||||
<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>
|
||||
{/* The funnel takes the full width: it is a horizontal sequence, and in a
|
||||
half-width column the stage strip truncates its own labels. */}
|
||||
<section aria-labelledby="conversion" className="space-y-3">
|
||||
<SectionTitle id="conversion" title="Pipeline conversion" meta={`${f.total} entered`} />
|
||||
<HiringFlow
|
||||
stages={f.funnel}
|
||||
transitions={f.transitions}
|
||||
weakestKey={f.bottleneck?.to}
|
||||
/>
|
||||
</section>
|
||||
|
||||
{/* Score distribution — where the hireable pool actually sits. */}
|
||||
<section aria-labelledby="quality" className="space-y-3">
|
||||
<SectionTitle id="quality" title="Hiring quality" meta={`${f.scored.length} scored · avg ${f.avgScore}`} />
|
||||
<ResponsiveContainer width="100%" height={196}>
|
||||
<BarChart data={scoreDistribution}>
|
||||
<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 />} />
|
||||
<Bar dataKey="count" name="Candidates" radius={[4, 4, 0, 0]} maxBarSize={56}>
|
||||
{scoreDistribution.map((d) => <Cell key={d.label} fill={bandColor(d.label)} />)}
|
||||
</Bar>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</section>
|
||||
</div>
|
||||
{/* Score distribution — where the hireable pool actually sits. */}
|
||||
<section aria-labelledby="quality" className="space-y-3">
|
||||
<SectionTitle id="quality" title="Hiring quality" meta={`${f.scored.length} scored · avg ${f.avgScore}`} />
|
||||
<ResponsiveContainer width="100%" height={196}>
|
||||
<BarChart data={scoreDistribution}>
|
||||
<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 />} />
|
||||
<Bar dataKey="count" name="Candidates" radius={[4, 4, 0, 0]} maxBarSize={56}>
|
||||
{scoreDistribution.map((d) => <Cell key={d.label} fill={bandColor(d.label)} />)}
|
||||
</Bar>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</section>
|
||||
|
||||
{/* Velocity and screening efficiency. Both are ratios rather than shapes,
|
||||
so they read better as figures with an explanation than as another
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
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 { cn } from '@/lib/utils';
|
||||
import {
|
||||
AXIS_PROPS, Avatar, Badge, CHART_TONES, ChartTooltip, DataTable, MetricStrip,
|
||||
ProgressBar, StatusBadge, Surface,
|
||||
} from '@/components/ds';
|
||||
import { Avatar, Badge, DataTable, MetricStrip, StatusBadge, Surface } from '@/components/ds';
|
||||
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';
|
||||
|
||||
/**
|
||||
@@ -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);
|
||||
}, [hires]);
|
||||
|
||||
const maxDept = Math.max(...byDepartment.map((d) => d.count), 1);
|
||||
|
||||
/**
|
||||
* Outcomes per position: how many were hired, how fast, and how well they
|
||||
* 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">
|
||||
<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
|
||||
hires a line chart draws a single dot in an empty grid, which reads
|
||||
as broken rather than as "not enough history yet" — so say the
|
||||
latter, and give the figure that one month does support. */}
|
||||
{trend.length < 2 ? (
|
||||
{/* 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
|
||||
broken rather than as "not enough history yet" — so say the latter,
|
||||
and give the figure that one month does support. */}
|
||||
<HiringTrendChart
|
||||
points={trend}
|
||||
emptyState={(
|
||||
<div className="px-4 py-8 text-center">
|
||||
<p className="font-heading text-body font-semibold text-ink-1">
|
||||
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.'}
|
||||
</p>
|
||||
</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 aria-labelledby="dept" className="space-y-3">
|
||||
<SectionTitle id="dept" title="Department breakdown" meta={`${byDepartment.length} departments`} />
|
||||
<Surface variant="solid" radius="lg" padding="lg" elevation="xs">
|
||||
<div className="space-y-3">
|
||||
{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>
|
||||
<DepartmentPerformance
|
||||
items={byDepartment.map((d) => ({ name: d.department, count: d.count, avgScore: d.avgScore }))}
|
||||
/>
|
||||
</section>
|
||||
|
||||
{/* Position outcomes — the retrospective read Positions cannot give. */}
|
||||
|
||||
Reference in New Issue
Block a user