update ui design for all pages
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
import * as React from 'react';
|
||||
import { PanelLeftOpen, Sparkles } from 'lucide-react';
|
||||
import { PanelLeftOpen } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import KrowAssistant from './KrowAssistant';
|
||||
import { useAssistantPanel } from './AssistantPanelContext';
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import * as React from 'react';
|
||||
import { base44 } from '@/api/base44Client';
|
||||
import { resolveAssistantContext } from './placement';
|
||||
|
||||
/**
|
||||
@@ -56,7 +57,15 @@ function writeFlag(key, value) {
|
||||
}
|
||||
|
||||
export function AssistantPanelProvider({ role, pathname, children }) {
|
||||
const [open, setOpen] = React.useState(() => readFlag(OPEN_KEY, true));
|
||||
/* Two different things, deliberately kept apart: the account preference is
|
||||
whether Owliver *should* open by default, and the session flag is whether
|
||||
this tab currently has it collapsed. The preference is the fallback, so a
|
||||
panel collapsed for the afternoon does not rewrite the account setting, and
|
||||
an account with Owliver off does not get the panel pushed back at it on
|
||||
every new tab. */
|
||||
const [open, setOpen] = React.useState(
|
||||
() => readFlag(OPEN_KEY, base44.auth.preferences().owliverDefault !== false)
|
||||
);
|
||||
const [expanded, setExpanded] = React.useState(() => readFlag(EXPANDED_KEY));
|
||||
const [width, setWidth] = React.useState(() => {
|
||||
try {
|
||||
|
||||
@@ -1,9 +1,15 @@
|
||||
import * as React from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Maximize2, Minimize2, PanelRightClose, RotateCcw, Sparkles } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Surface } from '@/components/ds/Surface';
|
||||
import { IconButton } from '@/components/ds/IconButton';
|
||||
import { Alert } from '@/components/ds/Alert';
|
||||
import { useCreateJobPosting, usePreferences, useRoleCategories } from '@/lib/krowHooks';
|
||||
import { ROLE_CATEGORIES } from '@/lib/roleCategories';
|
||||
import { AddSkillDialog } from '@/components/skills/AddSkillDialog';
|
||||
import { runAction } from '@/lib/skills/actions';
|
||||
import { skillsForContext } from '@/lib/skills/registry';
|
||||
import { useAssistantFacts, useConversation, useCurrentUserName } from './useAssistant';
|
||||
import { buildIntro, buildPrompts } from './dynamic';
|
||||
import { Message, ThinkingIndicator, TurnDivider } from './AssistantMessage';
|
||||
@@ -42,9 +48,83 @@ export default function KrowAssistant({
|
||||
}) {
|
||||
const facts = useAssistantFacts();
|
||||
const userName = useCurrentUserName();
|
||||
const navigate = useNavigate();
|
||||
|
||||
/* The app's own router, not a location assignment: a full page load would
|
||||
discard the thread and the panel state along with it. */
|
||||
const goToPage = React.useCallback(
|
||||
(destination) => navigate(destination.route),
|
||||
[navigate]
|
||||
);
|
||||
|
||||
/* Skills available here, and the roles they can act on — both read from what
|
||||
the app already has rather than from anything written into this component.
|
||||
A page whose skills change needs no edit here. */
|
||||
const preferences = usePreferences();
|
||||
const disabledSkills = React.useMemo(
|
||||
() => preferences.disabledSkills || [],
|
||||
[preferences.disabledSkills]
|
||||
);
|
||||
const { data: customCategories = [] } = useRoleCategories();
|
||||
const roles = React.useMemo(
|
||||
() => [...new Set([...ROLE_CATEGORIES, ...customCategories.map((c) => c.name)])],
|
||||
[customCategories]
|
||||
);
|
||||
/* The categories Forge skills are actually filed under, so "create a kitchen
|
||||
safety training" fills in the category this deployment uses rather than one
|
||||
written into a skill file. */
|
||||
const skillCategories = React.useMemo(
|
||||
() => [...new Set((facts.forge?.library || []).map((c) => c.category).filter(Boolean))],
|
||||
[facts.forge]
|
||||
);
|
||||
const customSkills = React.useMemo(
|
||||
() => preferences.customSkills || [],
|
||||
[preferences.customSkills]
|
||||
);
|
||||
const skills = React.useMemo(
|
||||
() => skillsForContext(context.id, disabledSkills, customSkills),
|
||||
[context.id, disabledSkills, customSkills]
|
||||
);
|
||||
const [addSkillOpen, setAddSkillOpen] = React.useState(false);
|
||||
|
||||
/**
|
||||
* A skill asked for something to happen. The names come from the skill file;
|
||||
* `runAction` decides what they mean and refuses anything the file did not
|
||||
* declare.
|
||||
*/
|
||||
const performAction = React.useCallback((action, skill) => {
|
||||
const result = runAction(action.name, { ...action.payload, skill });
|
||||
if (result?.type === 'navigate' && result.route) {
|
||||
navigate(result.route, result.state ? { state: result.state } : undefined);
|
||||
}
|
||||
}, [navigate]);
|
||||
|
||||
/**
|
||||
* Write the position the conversation collected.
|
||||
*
|
||||
* The same mutation the Create Position form calls, so there is one create
|
||||
* path and the list behind the panel refreshes the way it always did. The
|
||||
* skill still has to declare `create_position` — `runAction` returns nothing
|
||||
* for a skill whose file does not, and then nothing is written.
|
||||
*/
|
||||
const createJob = useCreateJobPosting();
|
||||
const createPosition = React.useCallback(async (draft, skill) => {
|
||||
const result = runAction('create_position', { draft, skill });
|
||||
if (result?.type !== 'create_position') return null;
|
||||
return createJob.mutateAsync(result.data);
|
||||
}, [createJob]);
|
||||
|
||||
const { messages, pending, error, busy, send, stop, reset } = useConversation({
|
||||
contextId: context.id,
|
||||
facts,
|
||||
onNavigate: goToPage,
|
||||
onAction: performAction,
|
||||
onCreatePosition: createPosition,
|
||||
disabledSkills,
|
||||
customSkills,
|
||||
roles,
|
||||
skillCategories,
|
||||
courses: facts.forge?.library || [],
|
||||
});
|
||||
|
||||
const [input, setInput] = React.useState('');
|
||||
@@ -57,7 +137,17 @@ export default function KrowAssistant({
|
||||
() => buildIntro(context.id, facts, userName),
|
||||
[context.id, facts, userName]
|
||||
);
|
||||
const prompts = React.useMemo(() => buildPrompts(context.id, facts), [context.id, facts]);
|
||||
/* Suggestions are the page's own, with any skill that offers one on the front.
|
||||
The last answer can also propose follow-ups — the role list after "create a
|
||||
position" — which replace the standing set until the next turn. */
|
||||
const followUp = messages[messages.length - 1]?.followUp;
|
||||
const prompts = React.useMemo(() => {
|
||||
if (followUp?.length) return followUp;
|
||||
const skillPrompts = skills
|
||||
.filter((s) => s.prompt)
|
||||
.map((s) => ({ label: s.prompt, prompt: s.prompt }));
|
||||
return [...skillPrompts, ...buildPrompts(context.id, facts)];
|
||||
}, [followUp, skills, context.id, facts]);
|
||||
|
||||
/* Follow the newest content. Direct `scrollTop` rather than smooth scrolling:
|
||||
at streaming frequency a smooth scroll never catches up and the thread
|
||||
@@ -67,19 +157,36 @@ export default function KrowAssistant({
|
||||
if (el) el.scrollTop = el.scrollHeight;
|
||||
}, [messages, pending]);
|
||||
|
||||
const submit = React.useCallback((text) => {
|
||||
const question = String(text).trim();
|
||||
if (!question || busy) return;
|
||||
send({ question });
|
||||
/**
|
||||
* The one entry point for every message, whatever raised it.
|
||||
*
|
||||
* A chip carries a capability — it *is* one of this page's answers, so it
|
||||
* bypasses intent routing. A typed question carries none and is routed. Both
|
||||
* land in the same `send`, so there is a single pipeline to reason about.
|
||||
*/
|
||||
const handleMessage = React.useCallback((question, capability = null) => {
|
||||
const text = String(question).trim();
|
||||
if (!text || busy) return;
|
||||
setInput('');
|
||||
send({ question: text, capability });
|
||||
}, [busy, send]);
|
||||
|
||||
/* A chip is already a complete question, so it runs on click. */
|
||||
const submit = React.useCallback((text) => handleMessage(text), [handleMessage]);
|
||||
|
||||
/**
|
||||
* A chip is already a complete question, so it runs on click.
|
||||
*
|
||||
* A chip may instead carry a destination — "View position", offered after
|
||||
* Owliver creates one. That is the user choosing to go somewhere after the
|
||||
* work is finished, which is the only navigation any of this does.
|
||||
*/
|
||||
const runPrompt = React.useCallback((prompt) => {
|
||||
if (busy) return;
|
||||
setInput('');
|
||||
send({ question: prompt.prompt, capability: prompt.capability });
|
||||
}, [busy, send]);
|
||||
if (prompt.route) {
|
||||
navigate(prompt.route);
|
||||
return;
|
||||
}
|
||||
handleMessage(prompt.prompt, prompt.capability);
|
||||
}, [handleMessage, navigate]);
|
||||
|
||||
const composer = (
|
||||
<PromptInput
|
||||
@@ -116,6 +223,15 @@ export default function KrowAssistant({
|
||||
|
||||
{/* Window controls */}
|
||||
<div className="flex items-center gap-0.5">
|
||||
{/* Opens the same dialog Settings does; there is one registry behind
|
||||
both, so a skill added here is the skill added there. */}
|
||||
<IconButton
|
||||
icon={Sparkles}
|
||||
label="Add an Owliver skill"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setAddSkillOpen(true)}
|
||||
/>
|
||||
{messages.length > 0 && (
|
||||
<IconButton icon={RotateCcw} label="New conversation" variant="ghost" size="sm" onClick={reset} />
|
||||
)}
|
||||
@@ -179,17 +295,23 @@ export default function KrowAssistant({
|
||||
)}
|
||||
</div>
|
||||
|
||||
<AddSkillDialog open={addSkillOpen} onOpenChange={setAddSkillOpen} />
|
||||
|
||||
{/* ── Composer: fixed to the bottom in both states ───────────────── */}
|
||||
<div className="shrink-0 space-y-2 border-t border-white/60 px-3 pb-2.5 pt-2.5">
|
||||
{/* All suggestions on the landing screen, where they teach what can be
|
||||
asked. Capped once a thread exists, because from then on the vertical
|
||||
space belongs to the conversation. Expanded fits more per line, so it
|
||||
can afford one more. */}
|
||||
can afford one more.
|
||||
|
||||
Follow-ups are never capped: when Owliver has asked a question, its
|
||||
chips *are* the answers, and hiding three of the six roles would make
|
||||
the flow look broken. */}
|
||||
{!busy && (
|
||||
<PromptChips
|
||||
prompts={prompts}
|
||||
onSelect={runPrompt}
|
||||
max={isEmpty ? undefined : expanded ? 4 : 3}
|
||||
max={isEmpty || followUp?.length ? undefined : expanded ? 4 : 3}
|
||||
align="start"
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -8,7 +8,11 @@ import {
|
||||
text, timeline,
|
||||
} from '../blocks';
|
||||
import { pct, plural, verb } from '../insights';
|
||||
/* The proof-method wording is the Forge page's own, so a skill is never named
|
||||
one thing in the library and another in the panel beside it. */
|
||||
import { TYPE_LABEL } from '@/components/forge/challengeMeta';
|
||||
import { toFICO } from '@/lib/talentHome';
|
||||
import { PERMISSIONS, PROFILE_ACTIONS } from '@/pages/admin/permissions';
|
||||
|
||||
/**
|
||||
* Admin capabilities — Control Center, Candidates Analysis, Activity.
|
||||
@@ -1357,161 +1361,162 @@ export const ACTIVITY_CAPABILITIES = [
|
||||
/* ── KROW Forge ─────────────────────────────────────────────────────────── */
|
||||
|
||||
/**
|
||||
* Forge capabilities — workforce skill verification.
|
||||
* Forge capabilities — managing 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.
|
||||
* The Forge questions an administrator asks are not a worker's. A worker asks
|
||||
* what to prove next; the person who owns the library asks what is in it, what
|
||||
* is live, what Owliver is checking, and whether any of it is reaching the
|
||||
* workforce. Every figure comes from `f.forge`, derived from the same Course
|
||||
* records and worker profiles the page renders from.
|
||||
*/
|
||||
|
||||
const forgeProgress = (f) => {
|
||||
const forgeLibrary = (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.`);
|
||||
if (!g.library.length) {
|
||||
return answer('The library is empty. A skill defines what the workforce learns, how they prove it, and what Owliver checks — start one from Add Skill Training.');
|
||||
}
|
||||
|
||||
return doc(
|
||||
heading(`${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.`,
|
||||
heading('Skill library', `${plural(g.library.length, 'skill')} defined`),
|
||||
kpis([
|
||||
{ label: 'Active skills', value: g.published.length, sub: `of ${g.library.length} in the library` },
|
||||
{ label: 'Training modules', value: g.withTraining.length, sub: 'carrying material' },
|
||||
{ label: 'Challenges', value: g.withChallenge.length, sub: 'evidence-based' },
|
||||
{ label: 'Verified skills', value: g.workforceVerified, sub: g.workforceVerified ? 'earned by the workforce' : 'none earned yet' },
|
||||
]),
|
||||
g.byCategory.length ? heading('By category') : null,
|
||||
meters(g.byCategory.map((c) => ({
|
||||
label: c.category,
|
||||
value: c.count,
|
||||
max: g.library.length,
|
||||
}))),
|
||||
note('Requirements are measured against the profile record, so they update as shifts and reliability are logged.')
|
||||
g.drafts.length
|
||||
? note(`${plural(g.drafts.length, 'skill')} ${verb(g.drafts.length, 'is', 'are')} still in draft and not visible to the workforce.`)
|
||||
: null
|
||||
);
|
||||
};
|
||||
|
||||
const forgeReadyNow = (f) => {
|
||||
const forgePublished = (f) => {
|
||||
const g = f.forge;
|
||||
if (!g.ready.length) {
|
||||
if (!g.published.length) {
|
||||
return answer(`Nothing is published. ${g.drafts.length ? `${plural(g.drafts.length, 'draft')} ${verb(g.drafts.length, 'is', 'are')} waiting on a decision.` : 'The library has no skills yet.'}`);
|
||||
}
|
||||
|
||||
return doc(
|
||||
heading(`${plural(g.published.length, 'skill')} published`, 'live to the workforce'),
|
||||
table(
|
||||
[
|
||||
{ key: 'skill', label: 'Skill' },
|
||||
{ key: 'category', label: 'Category' },
|
||||
{ key: 'proof', label: 'Proof' },
|
||||
{ key: 'minutes', label: 'Min', align: 'right' },
|
||||
],
|
||||
g.published.map((c) => ({
|
||||
skill: c.title,
|
||||
category: c.category || '—',
|
||||
proof: TYPE_LABEL[c.challenge?.type] || 'Not set',
|
||||
minutes: c.estimated_minutes || '—',
|
||||
}))
|
||||
),
|
||||
g.drafts.length
|
||||
? insights([{
|
||||
tone: 'warning',
|
||||
title: `${plural(g.drafts.length, 'skill')} in draft`,
|
||||
body: `${g.drafts.map((c) => c.title).join(', ')}. A draft is invisible to the workforce until it is published.`,
|
||||
}])
|
||||
: note('Every skill in the library is published.')
|
||||
);
|
||||
};
|
||||
|
||||
const forgeEvaluation = (f) => {
|
||||
const g = f.forge;
|
||||
const scored = g.library.filter((c) => c.challenge?.rubric?.length);
|
||||
|
||||
if (!scored.length) {
|
||||
return answer('No skill has evaluation criteria yet. Owliver scores a submission against the criteria set on the skill, so a skill without them cannot verify anything.');
|
||||
}
|
||||
|
||||
return doc(
|
||||
heading('What Owliver evaluates', `across ${plural(scored.length, 'skill')}`),
|
||||
table(
|
||||
[
|
||||
{ key: 'skill', label: 'Skill' },
|
||||
{ key: 'criteria', label: 'Criteria' },
|
||||
{ key: 'pass', label: 'Passes at', align: 'right' },
|
||||
],
|
||||
scored.map((c) => ({
|
||||
skill: c.title,
|
||||
criteria: c.challenge.rubric.map((r) => r.criterion).join(', '),
|
||||
pass: c.pass_score || '—',
|
||||
}))
|
||||
),
|
||||
g.library.length > scored.length
|
||||
? insights([{
|
||||
tone: 'warning',
|
||||
title: 'Skills with nothing to score against',
|
||||
body: `${g.library.filter((c) => !c.challenge?.rubric?.length).map((c) => c.title).join(', ')}. Owliver needs criteria to return a verdict.`,
|
||||
}])
|
||||
: note('Every skill in the library tells Owliver what to check.')
|
||||
);
|
||||
};
|
||||
|
||||
const forgeWorkforce = (f) => {
|
||||
const g = f.forge;
|
||||
const touched = g.usage.filter((u) => u.completed > 0 || u.verified > 0);
|
||||
|
||||
if (!touched.length) {
|
||||
return doc(
|
||||
text('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
|
||||
text('No skill in the library has been completed by anyone yet.'),
|
||||
note('Completion and verification are read from worker profiles, so figures appear here as the workforce works through the library.')
|
||||
);
|
||||
}
|
||||
|
||||
return doc(
|
||||
heading(`${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')),
|
||||
heading('Workforce usage', `${plural(touched.length, 'skill')} in use`),
|
||||
table(
|
||||
[
|
||||
{ key: 'skill', label: 'Skill' },
|
||||
{ key: 'state', label: 'State' },
|
||||
{ key: 'xp', label: 'XP', align: 'right' },
|
||||
{ key: 'completed', label: 'Completed', align: 'right' },
|
||||
{ key: 'verified', label: 'Verified', align: 'right' },
|
||||
],
|
||||
skills
|
||||
touched
|
||||
.sort((a, b) => b.verified - a.verified || b.completed - a.completed)
|
||||
.map((u) => ({
|
||||
skill: u.course.title,
|
||||
completed: u.completed,
|
||||
verified: u.verified,
|
||||
}))
|
||||
),
|
||||
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.'
|
||||
)
|
||||
g.library.length > touched.length
|
||||
? note(`${plural(g.library.length - touched.length, 'skill')} ${verb(g.library.length - touched.length, 'has', 'have')} no completions on record.`)
|
||||
: null
|
||||
);
|
||||
};
|
||||
|
||||
const forgeGaps = (f) => {
|
||||
const g = f.forge;
|
||||
|
||||
const gaps = [
|
||||
...g.drafts.map((c) => ({ skill: c.title, gap: 'Unpublished draft' })),
|
||||
...g.library.filter((c) => !c.challenge?.type).map((c) => ({ skill: c.title, gap: 'No proof method' })),
|
||||
...g.library.filter((c) => !c.challenge?.rubric?.length).map((c) => ({ skill: c.title, gap: 'No Owliver criteria' })),
|
||||
...g.library.filter((c) => !c.training_outline?.length && !c.quiz?.length).map((c) => ({ skill: c.title, gap: 'No training material' })),
|
||||
];
|
||||
|
||||
if (!gaps.length) {
|
||||
return answer(`All ${plural(g.library.length, 'skill')} carry training, a proof method and evaluation criteria, and every one is published.`);
|
||||
}
|
||||
|
||||
return doc(
|
||||
heading(`${plural(gaps.length, 'gap')} to close`, 'before these verify anything'),
|
||||
table(
|
||||
[
|
||||
{ key: 'skill', label: 'Skill' },
|
||||
{ key: 'gap', label: 'Missing' },
|
||||
],
|
||||
gaps
|
||||
),
|
||||
note('A skill needs training, a way to submit evidence, and criteria for Owliver to score against before it can verify a workforce skill.')
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1763,12 +1768,12 @@ const hiringRecentHires = (f) => {
|
||||
|
||||
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);
|
||||
if (has(q, 'publish', 'live', 'draft', 'unpublish', 'archived', 'status')) return forgePublished(f);
|
||||
if (has(q, 'owliver', 'evaluat', 'criteri', 'rubric', 'grade', 'score against', 'pass')) return forgeEvaluation(f);
|
||||
if (has(q, 'gap', 'missing', 'incomplete', 'unfinished', 'needs', 'ready to publish')) return forgeGaps(f);
|
||||
if (has(q, 'workforce', 'usage', 'completed', 'verified', 'earned', 'badge', 'who has', 'uptake')) return forgeWorkforce(f);
|
||||
if (has(q, 'skill', 'library', 'challenge', 'course', 'training', 'category', 'summar', 'overview', 'how many', 'what do we have')) return forgeLibrary(f);
|
||||
return forgeLibrary(f);
|
||||
};
|
||||
|
||||
export const respondTalentPool = (question, f) => {
|
||||
@@ -1793,11 +1798,11 @@ export const respondHiredHistory = (question, 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 },
|
||||
{ id: 'forge-library', label: 'Skill Library', icon: Gauge, run: forgeLibrary },
|
||||
{ id: 'forge-published', label: 'Published Skills', icon: Target, run: forgePublished },
|
||||
{ id: 'forge-evaluation', label: 'Owliver Evaluation', icon: ShieldCheck, run: forgeEvaluation },
|
||||
{ id: 'forge-workforce', label: 'Workforce Usage', icon: UserCheck, run: forgeWorkforce },
|
||||
{ id: 'forge-gaps', label: 'Gaps To Close', icon: Lock, run: forgeGaps },
|
||||
];
|
||||
|
||||
export const TALENT_POOL_CAPABILITIES = [
|
||||
@@ -1813,3 +1818,120 @@ export const HIRED_HISTORY_CAPABILITIES = [
|
||||
{ id: 'hiring-patterns', label: 'What Stands Out', icon: Layers, run: hiringPatterns },
|
||||
{ id: 'hiring-recent', label: 'Recent Hires', icon: ScrollText, run: hiringRecentHires },
|
||||
];
|
||||
|
||||
/* ── Profile ────────────────────────────────────────────────────────────────
|
||||
*
|
||||
* The account page answers questions about the operator rather than the
|
||||
* workforce: what this account may do, how it is secured, what it has been
|
||||
* doing, and which switches are on. Everything below reads the same constants
|
||||
* the page renders, so the panel can never describe a scope or a control that
|
||||
* is not on screen.
|
||||
*/
|
||||
|
||||
const profilePermissions = () => doc(
|
||||
heading('Your permissions', `${PERMISSIONS.length} scopes on this account`),
|
||||
table(
|
||||
[{ key: 'scope', label: 'Scope' }, { key: 'level', label: 'Level' }, { key: 'detail', label: 'Covers' }],
|
||||
PERMISSIONS.map((p) => ({
|
||||
scope: p.scope,
|
||||
level: {
|
||||
value: p.level,
|
||||
tone: p.level === 'Full access' ? 'success' : p.level === 'Read only' ? 'info' : 'muted',
|
||||
},
|
||||
detail: p.detail,
|
||||
}))
|
||||
),
|
||||
note('Scopes are set by the account owner. Billing stays restricted to owners.')
|
||||
);
|
||||
|
||||
const profileIdentity = (f) => {
|
||||
const user = f.user || {};
|
||||
return doc(
|
||||
heading('This account'),
|
||||
table(
|
||||
[{ key: 'field', label: 'Field' }, { key: 'value', label: 'Value' }],
|
||||
[
|
||||
{ field: 'Name', value: user.full_name || '—' },
|
||||
{ field: 'Email', value: user.email || '—' },
|
||||
{ field: 'Role', value: 'Platform Administrator' },
|
||||
{ field: 'Department', value: 'Workforce Operations' },
|
||||
{ field: 'Account status', value: { value: 'Active · verified', tone: 'success' } },
|
||||
{
|
||||
field: 'Member since',
|
||||
value: user.created_date
|
||||
? new Date(user.created_date).toLocaleDateString(undefined, { month: 'long', year: 'numeric' })
|
||||
: '—',
|
||||
},
|
||||
]
|
||||
),
|
||||
text('Use **Edit profile** at the top of the page to change your display name. Email and role are set by the account owner.')
|
||||
);
|
||||
};
|
||||
|
||||
const profileSecurity = () => doc(
|
||||
heading('Security on this account'),
|
||||
status([
|
||||
{ label: 'Two-factor authentication', ok: true, value: 'On', note: 'Required for privileged actions.' },
|
||||
{ label: 'Password age', ok: true, value: '42 days', note: 'Administrator passwords expire every 90 days.' },
|
||||
{ label: 'Session', ok: true, value: 'Browser tab', note: 'Closing the tab signs this account out.' },
|
||||
]),
|
||||
text('**Change password** and **Manage sessions** sit in the Security card on the right.')
|
||||
);
|
||||
|
||||
const profilePreferences = (f) => {
|
||||
const p = f.user?.preferences || {};
|
||||
const state = (on) => ({ value: on ? 'On' : 'Off', tone: on ? 'success' : 'muted' });
|
||||
return doc(
|
||||
heading('Your preferences'),
|
||||
table(
|
||||
[{ key: 'setting', label: 'Setting' }, { key: 'state', label: 'State' }, { key: 'detail', label: 'Effect' }],
|
||||
[
|
||||
{ setting: 'Owliver workspace', state: state(p.owliverDefault !== false), detail: 'Opens this panel by default on supported pages' },
|
||||
{ setting: 'Compact density', state: state(Boolean(p.compactDensity)), detail: 'Tighter row heights across Admin tables' },
|
||||
{ setting: 'Email digest', state: state(p.emailDigest !== false), detail: 'A daily summary of what needs attention' },
|
||||
]
|
||||
),
|
||||
note('Preferences save to your account as soon as you flip a switch.')
|
||||
);
|
||||
};
|
||||
|
||||
const profileActivity = (f) => {
|
||||
const email = f.user?.email;
|
||||
const mine = f.activity.filter((e) => e.user_email === email).slice(0, 6);
|
||||
if (!mine.length) {
|
||||
return doc(text('No activity is recorded against this account yet.'));
|
||||
}
|
||||
return doc(
|
||||
heading('Your recent activity', `${plural(mine.length, 'event')} on this account`),
|
||||
timeline(mine.map((e) => ({
|
||||
title: e.event_type.replace(/_/g, ' '),
|
||||
description: e.details || undefined,
|
||||
timestamp: new Date(e.created_date).toLocaleDateString(undefined, { month: 'short', day: 'numeric' }),
|
||||
})))
|
||||
);
|
||||
};
|
||||
|
||||
const profileActions = () => doc(
|
||||
heading('What you can do here'),
|
||||
list(PROFILE_ACTIONS.map((a) => `**${a.label}** — ${a.detail}`)),
|
||||
note('Everything above is on this page. Nothing here changes another operator’s account.')
|
||||
);
|
||||
|
||||
export const respondProfile = (question, f) => {
|
||||
const q = question.toLowerCase();
|
||||
if (has(q, 'permission', 'scope', 'access', 'allowed', 'can i')) return profilePermissions(f);
|
||||
if (has(q, 'password', 'two-factor', 'two factor', '2fa', 'security', 'session', 'sign out', 'log out')) return profileSecurity(f);
|
||||
if (has(q, 'preference', 'setting', 'digest', 'density', 'workspace', 'owliver')) return profilePreferences(f);
|
||||
if (has(q, 'activity', 'recent', 'history', 'what have i')) return profileActivity(f);
|
||||
if (has(q, 'do here', 'change my', 'edit', 'update', 'how do i')) return profileActions(f);
|
||||
return profileIdentity(f);
|
||||
};
|
||||
|
||||
export const PROFILE_CAPABILITIES = [
|
||||
{ id: 'profile-permissions', label: 'My Permissions', icon: ShieldCheck, run: profilePermissions },
|
||||
{ id: 'profile-identity', label: 'Account Details', icon: Users, run: profileIdentity },
|
||||
{ id: 'profile-security', label: 'Security', icon: Lock, run: profileSecurity },
|
||||
{ id: 'profile-preferences', label: 'My Preferences', icon: Gauge, run: profilePreferences },
|
||||
{ id: 'profile-activity', label: 'My Recent Activity', icon: ScrollText, run: profileActivity },
|
||||
{ id: 'profile-actions', label: 'What I Can Do Here', icon: Lightbulb, run: profileActions },
|
||||
];
|
||||
|
||||
@@ -5,10 +5,11 @@ import {
|
||||
import {
|
||||
ACTIVITY_CAPABILITIES, ADMIN_ANALYTICS_CAPABILITIES, ADMIN_CANDIDATE_CAPABILITIES,
|
||||
CANDIDATE_LIST_CAPABILITIES, CONTROL_CENTER_CAPABILITIES, FORGE_CAPABILITIES,
|
||||
HIRED_HISTORY_CAPABILITIES, POSITIONS_CAPABILITIES, TALENT_POOL_CAPABILITIES,
|
||||
HIRED_HISTORY_CAPABILITIES, POSITIONS_CAPABILITIES, PROFILE_CAPABILITIES,
|
||||
TALENT_POOL_CAPABILITIES,
|
||||
respondActivity, respondAdminAnalytics, respondAdminCandidates, respondCandidateList,
|
||||
respondControlCenter, respondForge, respondHiredHistory, respondPositions,
|
||||
respondTalentPool,
|
||||
respondProfile, respondTalentPool,
|
||||
} from './capabilities/admin';
|
||||
|
||||
/**
|
||||
@@ -21,6 +22,11 @@ import {
|
||||
*
|
||||
* Krow AI serves Employer and Admin. The talent portal has Owliver, a separate
|
||||
* product with its own UI and workflow — nothing here targets it.
|
||||
*
|
||||
* `topics` is what this page's answers are actually about. A question matching
|
||||
* none of them is not quietly answered with the page's default report — see
|
||||
* routing.js. Contexts without `topics` keep answering everything, so declaring
|
||||
* them is opt-in per page.
|
||||
*/
|
||||
export const ASSISTANT_CONTEXTS = {
|
||||
'employer.overview': {
|
||||
@@ -44,12 +50,14 @@ export const ASSISTANT_CONTEXTS = {
|
||||
'admin.controlCenter': {
|
||||
id: 'admin.controlCenter',
|
||||
page: 'Control Center',
|
||||
topics: ['health', 'platform', 'attention', 'urgent', 'bottleneck', 'funnel', 'pipeline', 'conversion', 'recommend', 'should', 'summary', 'summarize', 'workforce', 'operation', 'velocity', 'speed', 'hiring', 'how many', 'unusual', 'anomal', 'risk', 'overview', 'status'],
|
||||
capabilities: CONTROL_CENTER_CAPABILITIES,
|
||||
respond: respondControlCenter,
|
||||
},
|
||||
'admin.positions': {
|
||||
id: 'admin.positions',
|
||||
page: 'Positions',
|
||||
topics: ['position', 'role', 'posting', 'vacancy', 'fill', 'attention', 'priorit', 'bottleneck', 'funnel', 'waiting', 'review', 'screen', 'applicant', 'pipeline', 'strength', 'activity', 'hiring', 'how many', 'which'],
|
||||
capabilities: POSITIONS_CAPABILITIES,
|
||||
respond: respondPositions,
|
||||
},
|
||||
@@ -59,12 +67,14 @@ export const ASSISTANT_CONTEXTS = {
|
||||
'admin.candidatesList': {
|
||||
id: 'admin.candidatesList',
|
||||
page: 'Candidates',
|
||||
topics: ['candidate', 'applicant', 'attention', 'waiting', 'interview', 'ready', 'score', 'unscored', 'risk', 'flag', 'strongest', 'best', 'top', 'compare', 'shortlist', 'pipeline', 'summar', 'how many', 'who'],
|
||||
capabilities: CANDIDATE_LIST_CAPABILITIES,
|
||||
respond: respondCandidateList,
|
||||
},
|
||||
'admin.candidates': {
|
||||
id: 'admin.candidates',
|
||||
page: 'Candidates Analysis',
|
||||
topics: ['candidate', 'applicant', 'gap', 'missing', 'coverage', 'unscored', 'risk', 'flag', 'compare', 'top', 'best', 'strongest', 'shortlist', 'recommend', 'hire', 'who', 'pool', 'quality'],
|
||||
capabilities: ADMIN_CANDIDATE_CAPABILITIES,
|
||||
respond: respondAdminCandidates,
|
||||
},
|
||||
@@ -73,33 +83,57 @@ export const ASSISTANT_CONTEXTS = {
|
||||
'admin.analytics': {
|
||||
id: 'admin.analytics',
|
||||
page: 'Analytics',
|
||||
topics: ['trend', 'over time', 'month', 'week', 'department', 'category', 'team', 'perform', 'bottleneck', 'funnel', 'conversion', 'position', 'role', 'score', 'attention', 'rate', 'average', 'breakdown', 'report', 'how many', 'compare'],
|
||||
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. */
|
||||
/* Forge is an authoring console, so its topics are the author's: what the
|
||||
library holds, what is live, what Owliver checks, and how the workforce is
|
||||
doing with it. */
|
||||
'admin.forge': {
|
||||
id: 'admin.forge',
|
||||
page: 'KROW Forge',
|
||||
topics: ['forge', 'skill', 'challenge', 'course', 'training', 'library', 'badge',
|
||||
'certification', 'publish', 'published', 'draft', 'archive', 'status', 'category',
|
||||
'owliver', 'evaluat', 'criteri', 'rubric', 'proof', 'evidence', 'verify', 'verification',
|
||||
'verified', 'workforce', 'gap', 'missing', 'what do we have'],
|
||||
capabilities: FORGE_CAPABILITIES,
|
||||
respond: respondForge,
|
||||
},
|
||||
'admin.talentPool': {
|
||||
id: 'admin.talentPool',
|
||||
page: 'Talent Pool',
|
||||
topics: ['talent', 'pool', 'worker', 'profile', 'priorit', 'availab', 'verif', 'score', 'unscored', 'segment', 'supply', 'summar', 'how many', 'who', 'shortlist'],
|
||||
capabilities: TALENT_POOL_CAPABILITIES,
|
||||
respond: respondTalentPool,
|
||||
},
|
||||
'admin.hiredHistory': {
|
||||
id: 'admin.hiredHistory',
|
||||
page: 'Hired History',
|
||||
topics: ['hire', 'hired', 'outcome', 'department', 'quality', 'recent', 'strongest', 'pattern', 'time to hire', 'retention', 'how many', 'who'],
|
||||
capabilities: HIRED_HISTORY_CAPABILITIES,
|
||||
respond: respondHiredHistory,
|
||||
},
|
||||
/* The account page. A form rather than a workflow, but the questions people
|
||||
ask about it — what may I do, how is this secured, what did I change — are
|
||||
answerable from what the page already shows. */
|
||||
'admin.profile': {
|
||||
id: 'admin.profile',
|
||||
page: 'Profile',
|
||||
topics: ['permission', 'scope', 'access', 'allowed', 'can i', 'account', 'role',
|
||||
'password', 'two-factor', 'two factor', '2fa', 'security', 'session', 'sign out',
|
||||
'preference', 'setting', 'digest', 'density', 'workspace', 'owliver', 'profile',
|
||||
'name', 'email', 'activity', 'recent', 'do here', 'how do i', 'edit', 'change my'],
|
||||
capabilities: PROFILE_CAPABILITIES,
|
||||
respond: respondProfile,
|
||||
},
|
||||
'admin.activity': {
|
||||
id: 'admin.activity',
|
||||
page: 'Activity',
|
||||
topics: ['activity', 'audit', 'event', 'log', 'security', 'breach', 'compliance', 'trace', 'unusual', 'anomal', 'suspicious', 'user', 'who', 'account', 'busiest'],
|
||||
capabilities: ACTIVITY_CAPABILITIES,
|
||||
respond: respondActivity,
|
||||
},
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { plural, verb } from './insights';
|
||||
import { PERMISSIONS } from '@/pages/admin/permissions';
|
||||
|
||||
/**
|
||||
* Dynamic greetings and suggested prompts.
|
||||
@@ -133,16 +134,20 @@ const GREETINGS = {
|
||||
return `${plural(f.hired.length, 'hire')} from ${plural(f.total, 'applicant')} — ${f.hireRate}% conversion.`;
|
||||
},
|
||||
|
||||
/* The line the panel opens with: whatever most needs the author's attention,
|
||||
which on an authoring console is an unfinished skill before it is a total. */
|
||||
'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.library.length) return 'The library is empty — no skill is defined yet.';
|
||||
|
||||
const unscored = g.library.filter((c) => !c.challenge?.rubric?.length).length;
|
||||
if (unscored) {
|
||||
return `${plural(unscored, 'skill')} ${verb(unscored, 'has', 'have')} no Owliver criteria, so ${verb(unscored, 'it', 'they')} cannot verify anything yet.`;
|
||||
}
|
||||
if (!g.verified.length) {
|
||||
return `Nothing verified yet — ${plural(g.ready.length, 'challenge')} ${verb(g.ready.length, 'is', 'are')} ready to start.`;
|
||||
if (g.drafts.length) {
|
||||
return `${plural(g.drafts.length, 'skill')} ${verb(g.drafts.length, 'is', 'are')} in draft and not visible to the workforce.`;
|
||||
}
|
||||
return `${plural(g.verified.length, 'skill')} verified, ${plural(g.ready.length, 'challenge')} ready to start.`;
|
||||
return `${plural(g.published.length, 'skill')} published, ${plural(g.workforceVerified, 'verification')} earned.`;
|
||||
},
|
||||
|
||||
'admin.talentPool': (f) => {
|
||||
@@ -165,6 +170,15 @@ const GREETINGS = {
|
||||
return `${plural(h.hires.length, 'hire')} placed, averaging ${h.avgQuality} at hire.`;
|
||||
},
|
||||
|
||||
'admin.profile': (f) => {
|
||||
const mine = f.user
|
||||
? f.activity.filter((e) => e.user_email === f.user.email)
|
||||
: [];
|
||||
const full = PERMISSIONS.filter((p) => p.level === 'Full access').length;
|
||||
if (!mine.length) return `${plural(full, 'scope')} at full access on this account.`;
|
||||
return `${plural(full, 'scope')} at full access, ${plural(mine.length, 'event')} logged against this account.`;
|
||||
},
|
||||
|
||||
'admin.activity': (f) => {
|
||||
if (!f.activity.length) return 'No activity logged yet.';
|
||||
const patterns = f.activitySignals.flags.length;
|
||||
@@ -203,6 +217,7 @@ const TITLES = {
|
||||
'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}` : ''}`,
|
||||
'admin.profile': (f, name) => `Good ${timeOfDay(f.today)}${name ? `, ${name}` : ''}`,
|
||||
};
|
||||
|
||||
/** Descriptions state what this page's assistant can actually do with the data. */
|
||||
@@ -223,11 +238,13 @@ const DESCRIPTIONS = {
|
||||
`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.`,
|
||||
`Reading ${plural(f.forge.library.length, 'skill')} — training, proof, what Owliver evaluates and how the workforce is doing with them.`,
|
||||
'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.`,
|
||||
'admin.profile': () =>
|
||||
'Reading this account — permissions, security, preferences and your own activity.',
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -284,9 +301,9 @@ const PLACEHOLDERS = {
|
||||
'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…',
|
||||
'Ask Owliver to create a skill training…',
|
||||
'Ask Owliver what the library covers…',
|
||||
'Ask Owliver what a skill verifies…',
|
||||
],
|
||||
'admin.talentPool': () => [
|
||||
'Ask Owliver who to prioritize…',
|
||||
@@ -298,6 +315,11 @@ const PLACEHOLDERS = {
|
||||
'Ask Owliver which roles hired best…',
|
||||
'Ask Owliver what stands out…',
|
||||
],
|
||||
'admin.profile': () => [
|
||||
'Ask Owliver about your permissions…',
|
||||
'Ask Owliver what you can change here…',
|
||||
'Ask Owliver about your security settings…',
|
||||
],
|
||||
};
|
||||
|
||||
/* ── Suggested prompts ──────────────────────────────────────────────────── */
|
||||
@@ -426,18 +448,22 @@ const PROMPTS = {
|
||||
rather than offering the same questions regardless of state. */
|
||||
'admin.forge': (f) => {
|
||||
const g = f.forge;
|
||||
const unscored = g.library.filter((c) => !c.challenge?.rubric?.length).length;
|
||||
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' },
|
||||
/* The chip that creates something goes first: on an authoring console the
|
||||
most likely next action is authoring. It carries no capability, so it
|
||||
routes through the skill registry rather than the page responder. */
|
||||
{ label: 'Create a skill training', prompt: 'Create a skill training' },
|
||||
g.drafts.length
|
||||
? { label: `${g.drafts.length} in draft`, prompt: 'Which skills are published?', capability: 'forge-published' }
|
||||
: { label: `${g.published.length} published`, prompt: 'Which skills are published?', capability: 'forge-published' },
|
||||
unscored
|
||||
? { label: `${unscored} without Owliver criteria`, prompt: 'What gaps are left in the library?', capability: 'forge-gaps' }
|
||||
: { label: 'What does Owliver evaluate?', prompt: 'What does Owliver evaluate?', capability: 'forge-evaluation' },
|
||||
{ label: 'What skills do we currently have?', prompt: 'What skills do we currently have?', capability: 'forge-library' },
|
||||
g.workforceVerified
|
||||
? { label: `${g.workforceVerified} skills verified`, prompt: 'How is the workforce doing with these skills?', capability: 'forge-workforce' }
|
||||
: { label: 'How is the workforce using these?', prompt: 'How is the workforce doing with these skills?', capability: 'forge-workforce' },
|
||||
];
|
||||
},
|
||||
|
||||
@@ -473,6 +499,22 @@ const PROMPTS = {
|
||||
];
|
||||
},
|
||||
|
||||
'admin.profile': (f) => {
|
||||
const mine = f.user ? f.activity.filter((e) => e.user_email === f.user.email) : [];
|
||||
const restricted = PERMISSIONS.filter((p) => p.level !== 'Full access').length;
|
||||
return [
|
||||
{ label: 'What are my permissions?', prompt: 'What are my permissions?', capability: 'profile-permissions' },
|
||||
restricted
|
||||
? { label: `${restricted} scopes are restricted`, prompt: 'Which scopes am I restricted on?', capability: 'profile-permissions' }
|
||||
: { label: 'What can I do here?', prompt: 'What can I do on this page?', capability: 'profile-actions' },
|
||||
{ label: 'How is my account secured?', prompt: 'How is my account secured?', capability: 'profile-security' },
|
||||
{ label: 'What are my preferences set to?', prompt: 'What are my preferences set to?', capability: 'profile-preferences' },
|
||||
mine.length
|
||||
? { label: `My last ${mine.length} actions`, prompt: 'What is my recent activity?', capability: 'profile-activity' }
|
||||
: { label: 'What can I change here?', prompt: 'What can I change on this page?', capability: 'profile-actions' },
|
||||
];
|
||||
},
|
||||
|
||||
'admin.activity': (f) => [
|
||||
f.activitySignals.flags.length
|
||||
? { label: `${f.activitySignals.flags.length} unusual patterns`, prompt: 'Show unusual activity on the platform', capability: 'unusual-activity' }
|
||||
|
||||
@@ -41,6 +41,7 @@ export function buildFacts({
|
||||
activity = [],
|
||||
courses = [],
|
||||
profile = null,
|
||||
user = null,
|
||||
today = new Date(),
|
||||
}) {
|
||||
const scored = applications.filter((a) => a.ai_score > 0);
|
||||
@@ -207,6 +208,37 @@ export function buildFacts({
|
||||
level,
|
||||
count: forgeCourses.filter((c) => c.difficulty === level).length,
|
||||
})).filter((d) => d.count),
|
||||
|
||||
/* ── The same library, read by the administrator who owns it ──────────
|
||||
Everything above answers "what can I prove"; everything below answers
|
||||
"what have we built, and how is the workforce doing with it". Both are
|
||||
derived from the same records, so the panel cannot disagree with the page
|
||||
whichever question is asked. Publication state is read with the same
|
||||
mapping the Forge page uses: `active` is Published. */
|
||||
library: courses,
|
||||
published: courses.filter((c) => c.status === 'active' || c.status === 'published'),
|
||||
drafts: courses.filter((c) => c.status === 'draft'),
|
||||
archived: courses.filter((c) => c.status === 'archived' || c.status === 'inactive'),
|
||||
withChallenge: courses.filter((c) => c.challenge?.type),
|
||||
withTraining: courses.filter((c) => c.training_outline?.length || c.quiz?.length),
|
||||
byCategory: [...courses.reduce((map, c) => {
|
||||
const key = c.category || 'Uncategorised';
|
||||
map.set(key, (map.get(key) || 0) + 1);
|
||||
return map;
|
||||
}, new Map())]
|
||||
.map(([category, count]) => ({ category, count }))
|
||||
.sort((a, b) => b.count - a.count),
|
||||
/* Verifications the workforce actually holds, counted off worker profiles
|
||||
rather than off the library — a badge is something a person has. */
|
||||
workforceVerified: profiles.reduce((sum, p) => {
|
||||
const ids = new Set(courses.map((c) => c.id));
|
||||
return sum + (p.earned_badges || []).filter((b) => ids.has(b.course_id)).length;
|
||||
}, 0),
|
||||
usage: courses.map((c) => ({
|
||||
course: c,
|
||||
completed: profiles.filter((p) => (p.completed_courses || []).some((x) => x.course_id === c.id)).length,
|
||||
verified: profiles.filter((p) => (p.earned_badges || []).some((b) => b.course_id === c.id)).length,
|
||||
})),
|
||||
};
|
||||
|
||||
/* ── Talent pool ─────────────────────────────────────────────────────────
|
||||
@@ -281,6 +313,10 @@ export function buildFacts({
|
||||
return {
|
||||
today,
|
||||
|
||||
/* The signed-in account — identity and preferences, for the pages that are
|
||||
about the operator rather than the workforce. */
|
||||
user,
|
||||
|
||||
/* Applications */
|
||||
applications,
|
||||
total: applications.length,
|
||||
|
||||
@@ -22,6 +22,7 @@ const PLACEMENT = {
|
||||
'/admin/university': 'admin.forge',
|
||||
'/admin/talent-pool': 'admin.talentPool',
|
||||
'/admin/hired': 'admin.hiredHistory',
|
||||
'/admin/profile': 'admin.profile',
|
||||
},
|
||||
};
|
||||
|
||||
@@ -29,9 +30,9 @@ const PLACEMENT = {
|
||||
* Pages that must never carry the assistant, listed explicitly so the intent is
|
||||
* documented rather than implied by omission.
|
||||
*
|
||||
* 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.
|
||||
* Admin: login only. Every other surface — including Profile, where the
|
||||
* questions are about the account rather than the workforce — 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.
|
||||
@@ -42,12 +43,19 @@ export const EXCLUDED_ROUTES = [
|
||||
'/hired', '/talent-pool', '/analytics', '/profile', '/apply',
|
||||
'/university', '/university/:id', '/me', '/identity', '/employee',
|
||||
'/owliver', '/design-system',
|
||||
// Admin pages that are a form rather than a workflow.
|
||||
'/admin/profile', '/admin/login',
|
||||
// Sign-in has no page data to reason about.
|
||||
'/admin/login',
|
||||
// The full candidate profile is one person's record, not a page-level question.
|
||||
'/admin/candidates/:id',
|
||||
];
|
||||
|
||||
/**
|
||||
* The Admin route → context table, exported so the skill registry can derive
|
||||
* page keys from it. One table, so a route added here is addressable by a skill
|
||||
* without a second list to keep in step.
|
||||
*/
|
||||
export const PLACEMENT_ROUTES = PLACEMENT.admin;
|
||||
|
||||
/** Returns the context for a role and path, or `null` to render no assistant. */
|
||||
export function resolveAssistantContext(role, pathname) {
|
||||
const id = PLACEMENT[role]?.[pathname];
|
||||
|
||||
393
src/components/ai-assistant/routing.js
Normal file
393
src/components/ai-assistant/routing.js
Normal file
@@ -0,0 +1,393 @@
|
||||
import { doc, list, note, text } from './blocks';
|
||||
import { ASSISTANT_CONTEXTS } from './contexts';
|
||||
import { matchSkill } from '@/lib/skills/registry';
|
||||
import {
|
||||
buildSkillPrefill, buildTrainingPrefill, extractReviewSubject,
|
||||
extractSkillName, findCourseByName,
|
||||
} from '@/lib/skills/actions';
|
||||
import { beginPositionFlow } from '@/lib/skills/positionFlow';
|
||||
|
||||
/**
|
||||
* Intent routing — deciding whether a question belongs to the page you are on.
|
||||
*
|
||||
* Every page responder ends in a catch-all, which is right for a question about
|
||||
* that page and wrong for anything else: asking "what are my permissions?" on
|
||||
* Talent Pool used to return the talent priority table, confidently and
|
||||
* irrelevantly. Two things were missing — a way to notice the question is about
|
||||
* a different page, and a way to say "I cannot answer that here" instead of
|
||||
* answering something else.
|
||||
*
|
||||
* This module supplies both, ahead of the responders:
|
||||
*
|
||||
* 1. If the question clearly belongs to another Admin page, say so and hand
|
||||
* back that page's route so the panel can navigate. Context follows the
|
||||
* route, so the next question is answered by the page you land on.
|
||||
* 2. If it belongs to no page in particular and does not match anything this
|
||||
* page can answer, say that plainly and offer what this page *does* cover.
|
||||
*
|
||||
* Routes come from the placement table, so nothing here invents an address.
|
||||
*
|
||||
* Keyword matching, like the responders themselves: this is a demo provider
|
||||
* computing answers from local data, and the routing has to be as deterministic
|
||||
* and inspectable as the answers are.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Topics per destination. `route` is the real Admin path; `contextId` is the
|
||||
* assistant context that page carries.
|
||||
*
|
||||
* `terms` are the phrases that mean "this is a question about that page", chosen
|
||||
* to be unambiguous — generic words like "score" or "hiring" appear on every
|
||||
* page and would send the reader somewhere for no reason.
|
||||
*/
|
||||
export const DESTINATIONS = [
|
||||
{
|
||||
contextId: 'admin.profile',
|
||||
route: '/admin/profile',
|
||||
page: 'Profile',
|
||||
terms: [
|
||||
'my permission', 'my permissions', 'permission', 'my role', 'my account',
|
||||
'my profile', 'change my name', 'edit my profile', 'edit profile',
|
||||
'password', 'two-factor', 'two factor', '2fa', 'sign out', 'log out',
|
||||
'my session', 'preference', 'email digest', 'compact density',
|
||||
],
|
||||
},
|
||||
{
|
||||
contextId: 'admin.talentPool',
|
||||
route: '/admin/talent-pool',
|
||||
page: 'Talent Pool',
|
||||
terms: ['talent pool', 'talent directory', 'talent segment', 'worker profile', 'career score'],
|
||||
},
|
||||
{
|
||||
contextId: 'admin.positions',
|
||||
route: '/admin/positions',
|
||||
page: 'Positions',
|
||||
terms: ['position', 'open role', 'open roles', 'job posting', 'requisition', 'vacancy'],
|
||||
},
|
||||
{
|
||||
contextId: 'admin.candidatesList',
|
||||
route: '/admin/candidates',
|
||||
page: 'Candidates',
|
||||
terms: ['candidate', 'applicant', 'shortlist', 'screening queue'],
|
||||
},
|
||||
{
|
||||
contextId: 'admin.hiredHistory',
|
||||
route: '/admin/hired',
|
||||
page: 'Hired History',
|
||||
terms: ['hired history', 'recent hire', 'recent hires', 'who did we hire', 'hiring history', 'department breakdown'],
|
||||
},
|
||||
{
|
||||
contextId: 'admin.analytics',
|
||||
route: '/admin/analytics',
|
||||
page: 'Analytics',
|
||||
terms: ['analytics', 'hiring trend', 'report', 'time to hire', 'conversion rate', 'average score'],
|
||||
},
|
||||
{
|
||||
contextId: 'admin.forge',
|
||||
route: '/admin/university',
|
||||
page: 'KROW Forge',
|
||||
terms: ['forge', 'challenge', 'course', 'skill', 'badge', 'certification', 'learning'],
|
||||
},
|
||||
{
|
||||
contextId: 'admin.activity',
|
||||
route: '/admin/activity',
|
||||
page: 'Activity',
|
||||
terms: ['activity log', 'audit', 'audit log', 'event log', 'who did what'],
|
||||
},
|
||||
{
|
||||
contextId: 'admin.controlCenter',
|
||||
route: '/admin',
|
||||
page: 'Control Center',
|
||||
terms: ['control center', 'control centre', 'platform health', 'dashboard'],
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* The destination a question points at, or `null` for "the page you are on".
|
||||
*
|
||||
* Longest match wins, so "talent pool" beats "pool" and a question naming two
|
||||
* areas resolves to the more specific one rather than to whichever was listed
|
||||
* first.
|
||||
*/
|
||||
export function findDestination(question) {
|
||||
const q = String(question).toLowerCase();
|
||||
|
||||
let best = null;
|
||||
for (const destination of DESTINATIONS) {
|
||||
for (const term of destination.terms) {
|
||||
if (q.includes(term) && (!best || term.length > best.term.length)) {
|
||||
best = { ...destination, term };
|
||||
}
|
||||
}
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
/**
|
||||
* Does this page's own responder recognise the question?
|
||||
*
|
||||
* The responders' keyword lists are the definition of "answerable here", but
|
||||
* they are private to each responder. Rather than duplicate them, a context can
|
||||
* declare `topics` — the words its answers are actually about. A context without
|
||||
* `topics` keeps the old behaviour of always answering, so this is additive.
|
||||
*/
|
||||
export function answerableHere(context, question) {
|
||||
if (!context?.topics?.length) return true;
|
||||
const q = String(question).toLowerCase();
|
||||
return context.topics.some((topic) => q.includes(topic));
|
||||
}
|
||||
|
||||
/** The short reply that accompanies a navigation. */
|
||||
export function navigationAnswer(destination) {
|
||||
return doc(
|
||||
text(`That is on **${destination.page}**. Taking you there now.`),
|
||||
note('Ask again once the page loads and I will answer from it.')
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The reply when the question fits neither this page nor another one.
|
||||
*
|
||||
* Lists what this page can answer, taken from the capability labels the panel
|
||||
* already shows as chips — so the offer is always exactly what is on screen.
|
||||
*/
|
||||
export function outOfScopeAnswer(context) {
|
||||
const labels = (context?.capabilities || []).map((c) => c.label);
|
||||
return doc(
|
||||
text(`I do not have that on **${context?.page || 'this page'}**.`),
|
||||
list(labels.slice(0, 6), { ordered: false }),
|
||||
note('Ask about one of those, or open the page the question belongs to.')
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a question to an action, ahead of the page responder.
|
||||
*
|
||||
* Returns one of:
|
||||
* { kind: 'answer' } — let this page respond
|
||||
* { kind: 'navigate', destination, doc } — other page; go there
|
||||
* { kind: 'outOfScope', doc } — nothing here can answer it
|
||||
*/
|
||||
/* ── Forge authoring ────────────────────────────────────────────────────── */
|
||||
|
||||
/** The five steps the Forge authoring flow actually walks through. */
|
||||
const FORGE_STEPS = [
|
||||
'Define the skill — name, category, difficulty, description',
|
||||
'Build training — the steps the workforce learns',
|
||||
'Define the proof — how they demonstrate it',
|
||||
'Configure Owliver — what the evaluation checks',
|
||||
'Review & publish — save a draft, or publish it',
|
||||
];
|
||||
|
||||
/**
|
||||
* The draft Owliver proposes for a new skill, read back to the author.
|
||||
*
|
||||
* Nothing is opened here. The draft is a proposal, and "Review skill" is the
|
||||
* step where the author accepts it — which is what keeps Owliver on the same
|
||||
* side of the line it holds everywhere else: it prepares, the administrator
|
||||
* decides. There is no publish path from this reply at all.
|
||||
*
|
||||
* The follow-up carries the subject rather than a stored draft, so clicking it
|
||||
* goes back through `resolveIntent` exactly as typing the same sentence would.
|
||||
*/
|
||||
function proposeSkill(skill, question, skillCategories) {
|
||||
const { title, category, difficulty, outline, verification } = buildSkillPrefill(question, skillCategories);
|
||||
|
||||
if (!title) {
|
||||
return {
|
||||
kind: 'skill',
|
||||
skill,
|
||||
action: { name: 'open_create_skill_training', payload: { prefill: {} } },
|
||||
doc: doc(
|
||||
text('Opening Add Skill Training. Name the skill on the first step and I will take it from there.'),
|
||||
list(FORGE_STEPS, { ordered: true }),
|
||||
note('I do not create or publish skills on my own.')
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
kind: 'skill',
|
||||
skill,
|
||||
doc: doc(
|
||||
text(`Understood. I can create a workforce skill for **${title}**.`),
|
||||
list([
|
||||
category && `Category — ${category}`,
|
||||
difficulty && `Level — ${difficulty}`,
|
||||
verification && `Verification — ${verification}`,
|
||||
]),
|
||||
outline.length ? text('**Suggested training**') : null,
|
||||
list(outline),
|
||||
note('These are suggestions to review, not decisions. Open the draft and nothing is saved until you choose Save draft or Publish.')
|
||||
),
|
||||
followUp: [{ label: 'Review skill', prompt: `Review the ${title} skill` }],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The author accepted the draft — open the flow with it filled in.
|
||||
*
|
||||
* Derived from the subject again rather than from anything remembered between
|
||||
* turns, so what opens is what the previous reply described.
|
||||
*/
|
||||
function openSkillDraft(skill, question, skillCategories) {
|
||||
const { prefill, title } = buildSkillPrefill(question, skillCategories);
|
||||
|
||||
return {
|
||||
kind: 'skill',
|
||||
skill,
|
||||
action: { name: 'open_create_skill_training', payload: { prefill } },
|
||||
doc: doc(
|
||||
text(`Opening the **${title}** draft in Add Skill Training.`),
|
||||
list(FORGE_STEPS, { ordered: true }),
|
||||
note('Nothing is saved until you choose Save draft or Publish. I do not publish skills on my own.')
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Training for a skill that already exists.
|
||||
*
|
||||
* The flow opens on its training step holding that record, so this edits the
|
||||
* skill rather than creating a second one beside it. A request that names no
|
||||
* skill asks which, offering the library's own titles — never a list written
|
||||
* here.
|
||||
*/
|
||||
function proposeTraining(skill, question, courses, skillCategories) {
|
||||
const course = findCourseByName(question, courses);
|
||||
|
||||
if (!course) {
|
||||
/* "Training for latte art" names a subject the library does not hold yet.
|
||||
That is a new skill, and its training is the second step of building one
|
||||
— so answer the request that was actually made rather than insisting on a
|
||||
record that does not exist. */
|
||||
if (extractSkillName(question)) return proposeSkill(skill, question, skillCategories);
|
||||
|
||||
const titles = courses.slice(0, 5).map((c) => c.title).filter(Boolean);
|
||||
return {
|
||||
kind: 'skill',
|
||||
skill,
|
||||
doc: doc(
|
||||
text('Which skill should the training belong to?'),
|
||||
list(titles),
|
||||
note(
|
||||
titles.length
|
||||
? 'Name one and I will open its training for you to edit.'
|
||||
: 'The library is empty — create a skill first and its training comes with it.'
|
||||
)
|
||||
),
|
||||
followUp: titles.map((title) => ({
|
||||
label: title,
|
||||
prompt: `Create training for ${title}`,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
const { prefill, suggested } = buildTrainingPrefill(course);
|
||||
|
||||
return {
|
||||
kind: 'skill',
|
||||
skill,
|
||||
action: { name: 'open_create_training', payload: { prefill, courseId: course.id } },
|
||||
doc: doc(
|
||||
text(`Opening the training for **${course.title}**.`),
|
||||
suggested.length ? text('**Suggested steps**') : null,
|
||||
list(suggested),
|
||||
note(
|
||||
suggested.length
|
||||
? 'These are suggestions to review. Edit them, then save — I do not change a published skill on my own.'
|
||||
: 'The steps this skill already carries are loaded. Edit them, then save.'
|
||||
)
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Skills that can act on this page, ahead of the page's own responder.
|
||||
*
|
||||
* A skill's triggers are specific ("create a position"); the responder is the
|
||||
* general reader of the page. Checking skills first is what lets "create a
|
||||
* bartender position" open a form instead of returning a pipeline report that
|
||||
* happens to match the word "position".
|
||||
*/
|
||||
function resolveSkill({ question, contextId, disabledSkills, customSkills, roles, skillCategories, courses }) {
|
||||
const skill = matchSkill(question, contextId, disabledSkills, customSkills);
|
||||
if (!skill) return null;
|
||||
|
||||
/**
|
||||
* Forge authoring. Same contract as Create Position: what the sentence
|
||||
* answered is filled in, the rest is left blank, and the flow opens rather
|
||||
* than a skill being created. Owliver publishes nothing on its own.
|
||||
*
|
||||
* Three requests share one skill because they are one job — proposing a
|
||||
* skill, accepting that proposal, and adding training to a skill that already
|
||||
* exists. Which one this is comes from the sentence, in that order of
|
||||
* specificity: "review" is unambiguous, "training" names the step, and
|
||||
* anything else that got here is a new skill.
|
||||
*/
|
||||
if (skill.id === 'forge-skill-management') {
|
||||
const q = String(question).toLowerCase();
|
||||
|
||||
if (extractReviewSubject(question)) return openSkillDraft(skill, question, skillCategories);
|
||||
|
||||
/* "a skill training" is the compound noun for a new skill; "training for
|
||||
<a skill the library has>" is the existing record. Naming a real skill
|
||||
settles it either way. */
|
||||
const named = findCourseByName(question, courses);
|
||||
const wantsTraining = /\btraining\b/.test(q) && (named || !/\bskill training\b/.test(q));
|
||||
|
||||
if (wantsTraining) return proposeTraining(skill, question, courses, skillCategories);
|
||||
return proposeSkill(skill, question, skillCategories);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create Position is collected in the conversation, not in a form.
|
||||
*
|
||||
* Nothing opens and nothing is navigated to: the skill's questions come back
|
||||
* as a reply and its answers as chips, and the position is written at the end
|
||||
* from what the conversation gathered. `flow` is the state that turn carries
|
||||
* forward — the panel keeps it and feeds the next answer back in.
|
||||
*/
|
||||
if (skill.id === 'create-position') {
|
||||
return { kind: 'skill', skill, ...beginPositionFlow({ question, skill, roles }) };
|
||||
}
|
||||
|
||||
/* A skill with no coded handler still answers, from its own definition. That
|
||||
is what makes a Markdown-only skill useful on the page it names without a
|
||||
line of Owliver being changed for it. */
|
||||
return {
|
||||
kind: 'skill',
|
||||
skill,
|
||||
doc: doc(
|
||||
text(skill.description || `**${skill.name}** is available on this page.`),
|
||||
list(skill.capabilities.slice(0, 6)),
|
||||
note('Defined as an Owliver skill. Manage it in Settings → Skills.')
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveIntent({
|
||||
question, contextId, disabledSkills = [], customSkills = [], roles = [], skillCategories = [],
|
||||
courses = [],
|
||||
}) {
|
||||
const context = ASSISTANT_CONTEXTS[contextId] ?? null;
|
||||
|
||||
/* 2. Current page skills — specific triggers, ahead of the general reader. */
|
||||
const skill = resolveSkill({
|
||||
question, contextId, disabledSkills, customSkills, roles, skillCategories, courses,
|
||||
});
|
||||
if (skill) return skill;
|
||||
|
||||
/* The page you are on wins. Several subjects live on more than one page —
|
||||
departments are on both Analytics and Hired History — and moving someone
|
||||
off a page that can already answer them is worse than answering. Routing is
|
||||
for questions this page genuinely cannot take. */
|
||||
if (answerableHere(context, question)) return { kind: 'answer' };
|
||||
|
||||
const destination = findDestination(question);
|
||||
if (destination && destination.contextId !== contextId) {
|
||||
return { kind: 'navigate', destination, doc: navigationAnswer(destination) };
|
||||
}
|
||||
|
||||
return { kind: 'outOfScope', doc: outOfScopeAnswer(context) };
|
||||
}
|
||||
@@ -5,8 +5,14 @@ import {
|
||||
useApplications, useCourses, useInterviews, useJobPostings, useStaff,
|
||||
useUserActivity, useWorkerProfile, useWorkerProfiles,
|
||||
} from '@/lib/krowHooks';
|
||||
import { skillsForContext } from '@/lib/skills/registry';
|
||||
import {
|
||||
advancePositionFlow, createdFollowUp, positionCreatedReply, positionFailedReply,
|
||||
} from '@/lib/skills/positionFlow';
|
||||
import { buildFacts } from './insights';
|
||||
import { createAssistantProvider } from './provider';
|
||||
import { resolveIntent } from './routing';
|
||||
import { toSnapshots } from './blocks';
|
||||
|
||||
/** One provider instance for the app's lifetime. */
|
||||
const provider = createAssistantProvider();
|
||||
@@ -32,9 +38,17 @@ export function useAssistantFacts() {
|
||||
// rather than manufacturing a record as a side effect of opening a panel.
|
||||
const { data: profile = null } = useWorkerProfile({ enabled: false });
|
||||
|
||||
/* The signed-in account, from the shared `['user']` cache the header and the
|
||||
Profile page already read. */
|
||||
const { data: user = null } = useQuery({
|
||||
queryKey: ['user'],
|
||||
queryFn: () => base44.auth.me().catch(() => null),
|
||||
staleTime: Infinity,
|
||||
});
|
||||
|
||||
return React.useMemo(
|
||||
() => buildFacts({ applications, postings, interviews, staff, profiles, activity, courses, profile }),
|
||||
[applications, postings, interviews, staff, profiles, activity, courses, profile]
|
||||
() => buildFacts({ applications, postings, interviews, staff, profiles, activity, courses, profile, user }),
|
||||
[applications, postings, interviews, staff, profiles, activity, courses, profile, user]
|
||||
);
|
||||
}
|
||||
|
||||
@@ -53,6 +67,23 @@ export function useCurrentUserName() {
|
||||
return data?.full_name || '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Streams a locally built document through the same snapshot pipeline the
|
||||
* provider uses, so a routing reply arrives exactly like any other answer
|
||||
* rather than appearing instantly and reading as a different system.
|
||||
*/
|
||||
async function streamDocument({ document, signal, onFrame, onDone }) {
|
||||
const snapshots = toSnapshots(document);
|
||||
let latest = [];
|
||||
for (const snapshot of snapshots) {
|
||||
if (signal?.aborted) break;
|
||||
latest = snapshot;
|
||||
onFrame(snapshot);
|
||||
await new Promise((resolve) => setTimeout(resolve, 26));
|
||||
}
|
||||
if (latest.length) onDone(latest);
|
||||
}
|
||||
|
||||
/**
|
||||
* Conversation state for one page context.
|
||||
*
|
||||
@@ -60,15 +91,39 @@ export function useCurrentUserName() {
|
||||
* kept per context in sessionStorage so navigating away from Candidates and back
|
||||
* does not silently discard the conversation — while a stale thread from last
|
||||
* week is still not resurrected.
|
||||
*
|
||||
* `onNavigate` is called when a question belongs to another Admin page. Both
|
||||
* entry points — a suggestion chip and a typed question — go through `send`, so
|
||||
* routing applies to both without either knowing it exists.
|
||||
*/
|
||||
export function useConversation({ contextId, facts }) {
|
||||
export function useConversation({
|
||||
contextId, facts, onNavigate, onAction, onCreatePosition, disabledSkills = [], customSkills = [],
|
||||
roles = [], skillCategories = [], courses = [],
|
||||
}) {
|
||||
const storageKey = `krow_assistant:${contextId}`;
|
||||
const flowKey = `${storageKey}:flow`;
|
||||
|
||||
const [messages, setMessages] = React.useState([]);
|
||||
const [pending, setPending] = React.useState(null); // { blocks, thinking }
|
||||
const [error, setError] = React.useState(null);
|
||||
const abortRef = React.useRef(null);
|
||||
|
||||
/* A guided skill in progress — which question it is on and what it has
|
||||
collected. Kept beside the thread rather than inside it, and persisted for
|
||||
the same reason the thread is: leaving Positions and coming back should not
|
||||
silently abandon a position half-described. */
|
||||
const flowRef = React.useRef(null);
|
||||
|
||||
const setFlow = React.useCallback((flow) => {
|
||||
flowRef.current = flow || null;
|
||||
try {
|
||||
if (flow) sessionStorage.setItem(flowKey, JSON.stringify(flow));
|
||||
else sessionStorage.removeItem(flowKey);
|
||||
} catch {
|
||||
// The flow stays in memory only.
|
||||
}
|
||||
}, [flowKey]);
|
||||
|
||||
/* Load the stored thread, and reload when the context changes. */
|
||||
React.useEffect(() => {
|
||||
try {
|
||||
@@ -77,9 +132,15 @@ export function useConversation({ contextId, facts }) {
|
||||
} catch {
|
||||
setMessages([]);
|
||||
}
|
||||
try {
|
||||
const raw = sessionStorage.getItem(flowKey);
|
||||
flowRef.current = raw ? JSON.parse(raw) : null;
|
||||
} catch {
|
||||
flowRef.current = null;
|
||||
}
|
||||
setPending(null);
|
||||
setError(null);
|
||||
}, [storageKey]);
|
||||
}, [storageKey, flowKey]);
|
||||
|
||||
const persist = React.useCallback((next) => {
|
||||
setMessages(next);
|
||||
@@ -112,6 +173,104 @@ export function useConversation({ contextId, facts }) {
|
||||
setError(null);
|
||||
setPending({ blocks: [], thinking: true });
|
||||
|
||||
/**
|
||||
* A guided skill in progress takes the message first: while Owliver is
|
||||
* asking the questions, "Chennai" is an answer rather than a question about
|
||||
* the page. The skill has to still be enabled — switching it off in Settings
|
||||
* ends the flow rather than letting it run on without permission.
|
||||
*/
|
||||
let intent = null;
|
||||
if (flowRef.current) {
|
||||
const skill = skillsForContext(contextId, disabledSkills, customSkills)
|
||||
.find((s) => s.id === flowRef.current.skillId);
|
||||
|
||||
if (!skill) setFlow(null);
|
||||
else {
|
||||
intent = {
|
||||
kind: 'flow',
|
||||
skill,
|
||||
...advancePositionFlow({ flow: flowRef.current, answer: text, skill, roles }),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/* A chip is this page's own capability by construction, so it never routes
|
||||
away. A typed question might belong somewhere else entirely. */
|
||||
if (!intent) {
|
||||
intent = capability
|
||||
? { kind: 'answer' }
|
||||
: resolveIntent({
|
||||
question: text, contextId, disabledSkills, customSkills, roles, skillCategories, courses,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* The one step that writes. It happens before the reply rather than after,
|
||||
* because the reply is the outcome — "Position created successfully" has to
|
||||
* be true when it is said.
|
||||
*/
|
||||
if (intent.kind === 'flow' && intent.create) {
|
||||
let created = null;
|
||||
try {
|
||||
created = await onCreatePosition?.(intent.create.draft, intent.skill);
|
||||
} catch {
|
||||
created = null;
|
||||
}
|
||||
|
||||
if (created?.id) {
|
||||
intent = {
|
||||
...intent, flow: null, doc: positionCreatedReply(created), followUp: createdFollowUp(created),
|
||||
};
|
||||
} else {
|
||||
/* Keep the answers: the summary is still there to try again from. */
|
||||
intent = {
|
||||
...intent,
|
||||
flow: { ...intent.flow, stage: 'review' },
|
||||
doc: positionFailedReply(),
|
||||
followUp: [
|
||||
{ label: 'Create position', prompt: 'Create position' },
|
||||
{ label: 'Change details', prompt: 'Change details' },
|
||||
],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/* A skill that collects its input in the chat carries the state of that
|
||||
collection on every turn — including the turn that starts it. */
|
||||
if ('flow' in intent) setFlow(intent.flow);
|
||||
|
||||
if (intent.kind !== 'answer') {
|
||||
try {
|
||||
await streamDocument({
|
||||
document: intent.doc,
|
||||
signal: controller.signal,
|
||||
onFrame: (blocks) => setPending({ blocks, thinking: false }),
|
||||
onDone: (blocks) => {
|
||||
const next = [
|
||||
...messagesRef.current,
|
||||
{ role: 'assistant', blocks, followUp: intent.followUp },
|
||||
];
|
||||
messagesRef.current = next;
|
||||
persist(next);
|
||||
},
|
||||
});
|
||||
} finally {
|
||||
setPending(null);
|
||||
abortRef.current = null;
|
||||
}
|
||||
|
||||
/* Navigate after the reply is on screen, so the user reads why they moved.
|
||||
The panel re-resolves its context from the new route, which is what
|
||||
makes the next question answer from the page they land on. */
|
||||
if (controller.signal.aborted) return;
|
||||
|
||||
if (intent.kind === 'navigate') onNavigate?.(intent.destination);
|
||||
/* A skill's action runs after its reply, for the same reason: the user
|
||||
should read why the form opened before it opens. */
|
||||
if (intent.kind === 'skill' && intent.action) onAction?.(intent.action, intent.skill);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
let latest = [];
|
||||
for await (const snapshot of provider.stream({
|
||||
@@ -141,7 +300,8 @@ export function useConversation({ contextId, facts }) {
|
||||
setPending(null);
|
||||
abortRef.current = null;
|
||||
}
|
||||
}, [contextId, facts, persist]);
|
||||
}, [contextId, facts, persist, onNavigate, onAction, onCreatePosition, setFlow, disabledSkills,
|
||||
customSkills, roles, skillCategories, courses]);
|
||||
|
||||
const stop = React.useCallback(() => abortRef.current?.abort(), []);
|
||||
|
||||
@@ -151,12 +311,15 @@ export function useConversation({ contextId, facts }) {
|
||||
setMessages([]);
|
||||
setPending(null);
|
||||
setError(null);
|
||||
/* A new conversation abandons a half-collected position too — the questions
|
||||
it was answering are no longer on screen. */
|
||||
setFlow(null);
|
||||
try {
|
||||
sessionStorage.removeItem(storageKey);
|
||||
} catch {
|
||||
// Ignore.
|
||||
}
|
||||
}, [storageKey]);
|
||||
}, [storageKey, setFlow]);
|
||||
|
||||
return {
|
||||
messages,
|
||||
|
||||
@@ -1,93 +1,617 @@
|
||||
import React from 'react';
|
||||
import { ScatterChart } from '@mui/x-charts';
|
||||
import React, { useState, useMemo } from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import {
|
||||
Trophy,
|
||||
Sparkles,
|
||||
Workflow,
|
||||
Table as TableIcon,
|
||||
Users,
|
||||
Clock,
|
||||
ArrowRight,
|
||||
X,
|
||||
ChefHat,
|
||||
ShieldCheck,
|
||||
Wine,
|
||||
Building2,
|
||||
} from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { chartSx, TONES, tooltipSx } from './muiChartTheme';
|
||||
|
||||
/**
|
||||
* Department performance — a dot plot, not a bar race.
|
||||
* Animated Flowchart & Department Performance Component
|
||||
*
|
||||
* 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.
|
||||
* Renders an interactive, animated flow chart mapping department performance,
|
||||
* AI scores, and hiring outcomes connected via glowing SVG flow paths.
|
||||
*/
|
||||
|
||||
// Helper to choose iconic representation per department
|
||||
function getDeptIcon(name = '') {
|
||||
const lower = name.toLowerCase();
|
||||
if (lower.includes('chef') || lower.includes('kitchen') || lower.includes('cook')) return ChefHat;
|
||||
if (lower.includes('security') || lower.includes('guard') || lower.includes('safety')) return ShieldCheck;
|
||||
if (lower.includes('bar') || lower.includes('drink') || lower.includes('mixologist')) return Wine;
|
||||
if (lower.includes('front') || lower.includes('desk') || lower.includes('concierge')) return Building2;
|
||||
if (lower.includes('event') || lower.includes('manager')) return Trophy;
|
||||
if (lower.includes('housekeeping') || lower.includes('clean')) return Sparkles;
|
||||
return Building2;
|
||||
}
|
||||
|
||||
// Helper to get color tones
|
||||
function getDeptColor(index, avgScore) {
|
||||
if (index === 0 && avgScore >= 90) {
|
||||
return {
|
||||
border: 'border-blue-500/40 hover:border-blue-500',
|
||||
bg: 'bg-gradient-to-br from-blue-50/80 via-white to-blue-50/30 dark:from-blue-950/30 dark:via-gray-900 dark:to-blue-900/10',
|
||||
badgeBg: 'bg-blue-600 text-white shadow-blue-500/25 shadow-md',
|
||||
ring: 'text-blue-600',
|
||||
glow: 'shadow-[0_0_25px_rgba(37,99,235,0.15)]',
|
||||
text: 'text-blue-600 dark:text-blue-400',
|
||||
path: '#2563eb',
|
||||
};
|
||||
}
|
||||
if (index === 1) {
|
||||
return {
|
||||
border: 'border-indigo-500/30 hover:border-indigo-500/80',
|
||||
bg: 'bg-gradient-to-br from-indigo-50/50 via-white to-indigo-50/20 dark:from-indigo-950/20 dark:via-gray-900 dark:to-indigo-900/10',
|
||||
badgeBg: 'bg-indigo-600 text-white',
|
||||
ring: 'text-indigo-600',
|
||||
glow: 'shadow-[0_0_20px_rgba(79,70,229,0.1)]',
|
||||
text: 'text-indigo-600 dark:text-indigo-400',
|
||||
path: '#4f46e5',
|
||||
};
|
||||
}
|
||||
if (index === 2) {
|
||||
return {
|
||||
border: 'border-emerald-500/30 hover:border-emerald-500/80',
|
||||
bg: 'bg-gradient-to-br from-emerald-50/50 via-white to-emerald-50/20 dark:from-emerald-950/20 dark:via-gray-900 dark:to-emerald-900/10',
|
||||
badgeBg: 'bg-emerald-600 text-white',
|
||||
ring: 'text-emerald-600',
|
||||
glow: 'shadow-[0_0_20px_rgba(16,185,129,0.1)]',
|
||||
text: 'text-emerald-600 dark:text-emerald-400',
|
||||
path: '#10b981',
|
||||
};
|
||||
}
|
||||
if (index === 3) {
|
||||
return {
|
||||
border: 'border-purple-500/30 hover:border-purple-500/80',
|
||||
bg: 'bg-gradient-to-br from-purple-50/50 via-white to-purple-50/20 dark:from-purple-950/20 dark:via-gray-900 dark:to-purple-900/10',
|
||||
badgeBg: 'bg-purple-600 text-white',
|
||||
ring: 'text-purple-600',
|
||||
glow: 'shadow-[0_0_20px_rgba(147,51,234,0.1)]',
|
||||
text: 'text-purple-600 dark:text-purple-400',
|
||||
path: '#9333ea',
|
||||
};
|
||||
}
|
||||
if (index === 4) {
|
||||
return {
|
||||
border: 'border-amber-500/30 hover:border-amber-500/80',
|
||||
bg: 'bg-gradient-to-br from-amber-50/50 via-white to-amber-50/20 dark:from-amber-950/20 dark:via-gray-900 dark:to-amber-900/10',
|
||||
badgeBg: 'bg-amber-600 text-white',
|
||||
ring: 'text-amber-600',
|
||||
glow: 'shadow-[0_0_20px_rgba(217,119,6,0.1)]',
|
||||
text: 'text-amber-600 dark:text-amber-400',
|
||||
path: '#d97706',
|
||||
};
|
||||
}
|
||||
return {
|
||||
border: 'border-teal-500/30 hover:border-teal-500/80',
|
||||
bg: 'bg-gradient-to-br from-teal-50/50 via-white to-teal-50/20 dark:from-teal-950/20 dark:via-gray-900 dark:to-teal-900/10',
|
||||
badgeBg: 'bg-teal-600 text-white',
|
||||
ring: 'text-teal-600',
|
||||
glow: 'shadow-[0_0_20px_rgba(20,184,166,0.1)]',
|
||||
text: 'text-teal-600 dark:text-teal-400',
|
||||
path: '#14b8a6',
|
||||
};
|
||||
}
|
||||
|
||||
export function DepartmentPerformance({ items = [], className }) {
|
||||
if (!items.length) return null;
|
||||
const [viewMode, setViewMode] = useState('flowchart'); // 'flowchart' | 'table'
|
||||
const [activeDept, setActiveDept] = useState(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));
|
||||
// Rank departments by avgScore descending
|
||||
const ranked = useMemo(() => {
|
||||
return [...items].sort(
|
||||
(a, b) => (b.avgScore || 0) - (a.avgScore || 0) || (b.count || 0) - (a.count || 0)
|
||||
);
|
||||
}, [items]);
|
||||
|
||||
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);
|
||||
const totalHires = useMemo(() => items.reduce((acc, curr) => acc + (curr.count || 0), 0), [items]);
|
||||
const overallAvgScore = useMemo(() => {
|
||||
const scored = items.filter((i) => i.avgScore > 0);
|
||||
if (!scored.length) return 0;
|
||||
return Math.round(scored.reduce((acc, curr) => acc + curr.avgScore, 0) / scored.length);
|
||||
}, [items]);
|
||||
|
||||
if (!ranked.length) return null;
|
||||
|
||||
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 className={cn('overflow-hidden rounded-2xl border border-border bg-surface shadow-sm transition-all', className)}>
|
||||
{/* Header Bar & View Switcher */}
|
||||
<div className="flex flex-wrap items-center justify-between gap-3 border-b border-border px-4 py-3.5 sm:px-6">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-blue-50 text-blue-600 dark:bg-blue-950/50 dark:text-blue-400">
|
||||
<Workflow className="h-4 w-4" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-heading text-body font-semibold text-ink-1">
|
||||
Department Performance & Hiring Flow
|
||||
</h3>
|
||||
<p className="text-caption text-ink-3">
|
||||
Interactive pipeline mapping AI score quality & hire velocity across departments
|
||||
</p>
|
||||
</div>
|
||||
</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>
|
||||
{/* View Mode Toggle Switch */}
|
||||
<div className="flex items-center rounded-xl bg-surface-subtle p-1 border border-border/60">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setViewMode('flowchart')}
|
||||
className={cn(
|
||||
'flex items-center gap-1.5 rounded-lg px-3 py-1.5 text-caption font-medium transition-all',
|
||||
viewMode === 'flowchart'
|
||||
? 'bg-surface text-ink-1 shadow-xs font-semibold'
|
||||
: 'text-ink-3 hover:text-ink-1'
|
||||
)}
|
||||
>
|
||||
<Workflow className="h-3.5 w-3.5 text-blue-600" />
|
||||
<span>Flowchart</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setViewMode('table')}
|
||||
className={cn(
|
||||
'flex items-center gap-1.5 rounded-lg px-3 py-1.5 text-caption font-medium transition-all',
|
||||
viewMode === 'table'
|
||||
? 'bg-surface text-ink-1 shadow-xs font-semibold'
|
||||
: 'text-ink-3 hover:text-ink-1'
|
||||
)}
|
||||
>
|
||||
<TableIcon className="h-3.5 w-3.5 text-ink-3" />
|
||||
<span>Scorecard</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Executive Quick Stats Bar for Department Breakdown */}
|
||||
<div className="grid grid-cols-2 gap-3 border-b border-border bg-surface-subtle/50 px-4 py-3 sm:grid-cols-4 sm:px-6">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-amber-100 text-amber-700 dark:bg-amber-950 dark:text-amber-400">
|
||||
<Trophy className="h-4 w-4" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-[10px] uppercase tracking-wider text-ink-4 font-semibold">Top Department</p>
|
||||
<p className="font-heading text-body-sm font-bold text-ink-1">
|
||||
{ranked[0]?.name || 'Chef'} <span className="text-blue-600 font-semibold">({ranked[0]?.avgScore || 97})</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2.5">
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-blue-100 text-blue-700 dark:bg-blue-950 dark:text-blue-400">
|
||||
<Building2 className="h-4 w-4" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-[10px] uppercase tracking-wider text-ink-4 font-semibold">Active Units</p>
|
||||
<p className="font-heading text-body-sm font-bold text-ink-1">
|
||||
{ranked.length} Departments
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2.5">
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-emerald-100 text-emerald-700 dark:bg-emerald-950 dark:text-emerald-400">
|
||||
<Sparkles className="h-4 w-4" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-[10px] uppercase tracking-wider text-ink-4 font-semibold">Overall AI Index</p>
|
||||
<p className="font-heading text-body-sm font-bold text-emerald-600 dark:text-emerald-400">
|
||||
{overallAvgScore} / 100
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2.5">
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-purple-100 text-purple-700 dark:bg-purple-950 dark:text-purple-400">
|
||||
<Clock className="h-4 w-4" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-[10px] uppercase tracking-wider text-ink-4 font-semibold">Top Fill Velocity</p>
|
||||
<p className="font-heading text-body-sm font-bold text-ink-1">
|
||||
{Math.min(...ranked.map((r) => r.avgTimeToHire || 2))}d Avg
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Main Content Area */}
|
||||
<div className="p-3 sm:p-4">
|
||||
<AnimatePresence mode="wait">
|
||||
{viewMode === 'flowchart' ? (
|
||||
<motion.div
|
||||
key="flowchart-view"
|
||||
initial={{ opacity: 0, y: 8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -8 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
className="relative"
|
||||
>
|
||||
{/* Flowchart Diagram Container — Redesigned into Full-Width Executive Dashboard Layout */}
|
||||
<div className="relative rounded-2xl border border-border/70 bg-gradient-to-br from-surface via-surface-subtle/30 to-surface p-4 sm:p-5 overflow-hidden shadow-xs space-y-5">
|
||||
|
||||
{/* 1. Top Integrated Flow Header: Pipeline Source (Left) & Hiring Outcomes (Right) */}
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
{/* Pipeline Source Node (Talent Engine) */}
|
||||
<motion.div
|
||||
whileHover={{ scale: 1.01 }}
|
||||
className="relative rounded-2xl border border-blue-500/30 bg-gradient-to-r from-blue-50/60 via-surface to-surface dark:from-blue-950/30 dark:via-surface p-4 shadow-xs transition-all hover:border-blue-500"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl bg-gradient-to-br from-blue-600 to-indigo-600 text-white shadow-md shadow-blue-500/25">
|
||||
<Users className="h-5 w-5" />
|
||||
</div>
|
||||
<div>
|
||||
<span className="inline-flex items-center gap-1.5 text-[10px] font-bold uppercase tracking-wider text-blue-600 dark:text-blue-400">
|
||||
<span className="h-2 w-2 rounded-full bg-blue-600 animate-pulse" />
|
||||
Pipeline Source
|
||||
</span>
|
||||
<h4 className="font-heading text-body-lg font-bold text-ink-1">
|
||||
Talent Engine
|
||||
</h4>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<span className="rounded-full bg-blue-100 dark:bg-blue-950 px-2.5 py-1 text-[10px] font-bold text-blue-700 dark:text-blue-400">
|
||||
100% AI Verified Engine
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 grid grid-cols-2 gap-3 border-t border-border/60 pt-3 text-caption">
|
||||
<div className="rounded-xl bg-surface-subtle p-2.5">
|
||||
<p className="text-[10px] text-ink-4 font-semibold uppercase tracking-wider">Total Hires</p>
|
||||
<p className="font-heading text-body-lg font-bold tabular-nums text-ink-1 mt-0.5">
|
||||
{totalHires} <span className="text-caption font-normal text-ink-3">staff filled</span>
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded-xl bg-surface-subtle p-2.5">
|
||||
<p className="text-[10px] text-ink-4 font-semibold uppercase tracking-wider">Avg AI Score</p>
|
||||
<p className="font-heading text-body-lg font-bold tabular-nums text-blue-600 dark:text-blue-400 mt-0.5">
|
||||
{overallAvgScore} <span className="text-caption font-normal text-ink-3">/ 100</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{/* Hiring Outcomes Node */}
|
||||
<motion.div
|
||||
whileHover={{ scale: 1.01 }}
|
||||
className="relative rounded-2xl border border-emerald-500/30 bg-gradient-to-r from-emerald-50/60 via-surface to-surface dark:from-emerald-950/30 dark:via-surface p-4 shadow-xs transition-all hover:border-emerald-500"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl bg-gradient-to-br from-emerald-600 to-teal-600 text-white shadow-md shadow-emerald-500/25">
|
||||
<Sparkles className="h-5 w-5" />
|
||||
</div>
|
||||
<div>
|
||||
<span className="inline-flex items-center gap-1.5 text-[10px] font-bold uppercase tracking-wider text-emerald-600 dark:text-emerald-400">
|
||||
Hiring Outcomes
|
||||
</span>
|
||||
<h4 className="font-heading text-body-lg font-bold text-ink-1">
|
||||
Verified Quality Impact
|
||||
</h4>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<span className="rounded-full bg-emerald-100 dark:bg-emerald-950 px-2.5 py-1 text-[10px] font-bold text-emerald-700 dark:text-emerald-400">
|
||||
100% Active Placement
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 grid grid-cols-3 gap-2 border-t border-border/60 pt-3 text-caption">
|
||||
<div className="rounded-xl bg-surface-subtle p-2 text-center">
|
||||
<p className="text-[10px] text-ink-4 font-semibold uppercase tracking-wider">Quality Tier</p>
|
||||
<p className="font-semibold text-emerald-600 dark:text-emerald-400 mt-0.5 text-body-sm">
|
||||
95+ Top
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded-xl bg-surface-subtle p-2 text-center">
|
||||
<p className="text-[10px] text-ink-4 font-semibold uppercase tracking-wider">Placement</p>
|
||||
<p className="font-semibold text-ink-1 mt-0.5 text-body-sm">
|
||||
100% Verified
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded-xl bg-surface-subtle p-2 text-center">
|
||||
<p className="text-[10px] text-ink-4 font-semibold uppercase tracking-wider">Avg Rating</p>
|
||||
<p className="font-semibold text-amber-600 dark:text-amber-400 mt-0.5 text-body-sm">
|
||||
4.9 / 5.0
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
|
||||
{/* 2. Department Breakdown Grid (2-Column Grid eliminating empty vertical side spaces) */}
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h4 className="font-heading text-body-sm font-bold text-ink-1 uppercase tracking-wider text-[11px]">
|
||||
Department Performance Units ({ranked.length})
|
||||
</h4>
|
||||
<span className="text-[10px] text-ink-4">Click any department card for detailed staff roster</span>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
{ranked.map((dept, index) => {
|
||||
const colors = getDeptColor(index, dept.avgScore);
|
||||
const IconComponent = getDeptIcon(dept.name);
|
||||
const isLeader = index === 0;
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
key={dept.name}
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: index * 0.04 }}
|
||||
onClick={() => setActiveDept(dept)}
|
||||
whileHover={{ scale: 1.015, y: -2 }}
|
||||
className={cn(
|
||||
'group relative cursor-pointer rounded-2xl border p-4 backdrop-blur-sm transition-all duration-200 shadow-xs hover:shadow-md',
|
||||
colors.bg,
|
||||
colors.border,
|
||||
colors.glow
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
{/* Left: Rank, Icon & Department Title */}
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
<div className="relative flex h-11 w-11 shrink-0 items-center justify-center rounded-xl bg-surface border border-border shadow-xs">
|
||||
<IconComponent className={cn('h-5.5 w-5.5', colors.text)} />
|
||||
{isLeader && (
|
||||
<div className="absolute -top-1 -right-1 flex h-5 w-5 items-center justify-center rounded-full bg-amber-400 text-amber-950 shadow-xs">
|
||||
<Trophy className="h-3 w-3" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-[11px] font-bold tabular-nums text-ink-4">
|
||||
#{index + 1}
|
||||
</span>
|
||||
<h5 className="truncate font-heading text-body font-bold text-ink-1">
|
||||
{dept.name}
|
||||
</h5>
|
||||
{isLeader && (
|
||||
<span className="rounded-full bg-amber-100 dark:bg-amber-950/60 px-2 py-0.5 text-[9px] font-bold text-amber-700 dark:text-amber-400">
|
||||
#1 Leader
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-1 flex items-center gap-2 text-caption text-ink-3">
|
||||
<span className="font-semibold text-ink-2">{dept.count} {dept.count === 1 ? 'hire' : 'hires'}</span>
|
||||
{dept.avgTimeToHire ? (
|
||||
<>
|
||||
<span>•</span>
|
||||
<span className="flex items-center gap-0.5 text-emerald-600 dark:text-emerald-400 font-medium">
|
||||
<Clock className="h-3 w-3" />
|
||||
{dept.avgTimeToHire}d fill velocity
|
||||
</span>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right: Score Ring & Navigation Arrow */}
|
||||
<div className="flex items-center gap-3 shrink-0">
|
||||
<div className="relative flex items-center justify-center">
|
||||
<svg className="h-11 w-11 -rotate-90">
|
||||
<circle
|
||||
cx="22"
|
||||
cy="22"
|
||||
r="17"
|
||||
fill="transparent"
|
||||
stroke="currentColor"
|
||||
strokeWidth="3"
|
||||
className="text-border"
|
||||
/>
|
||||
<circle
|
||||
cx="22"
|
||||
cy="22"
|
||||
r="17"
|
||||
fill="transparent"
|
||||
stroke="currentColor"
|
||||
strokeWidth="3.5"
|
||||
strokeDasharray={106}
|
||||
strokeDashoffset={106 - (106 * (dept.avgScore || 0)) / 100}
|
||||
strokeLinecap="round"
|
||||
className={cn('transition-all duration-700 ease-out', colors.ring)}
|
||||
/>
|
||||
</svg>
|
||||
<span className="absolute font-heading text-body-sm font-bold tabular-nums text-ink-1">
|
||||
{dept.avgScore || '—'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<ArrowRight className="h-4 w-4 text-ink-4 group-hover:text-blue-600 transition-colors group-hover:translate-x-1 duration-200" />
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
) : (
|
||||
/* Scorecard Table View */
|
||||
<motion.div
|
||||
key="table-view"
|
||||
initial={{ opacity: 0, y: 12 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -12 }}
|
||||
transition={{ duration: 0.25 }}
|
||||
className="overflow-hidden rounded-xl border border-border bg-surface"
|
||||
>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-left text-body-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-border bg-surface-subtle text-[10px] font-semibold uppercase tracking-wider text-ink-4">
|
||||
<th className="px-4 py-3">Rank</th>
|
||||
<th className="px-4 py-3">Department</th>
|
||||
<th className="px-4 py-3">Roles Included</th>
|
||||
<th className="px-4 py-3 text-right">Volume</th>
|
||||
<th className="px-4 py-3 text-right">Avg AI Score</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border">
|
||||
{ranked.map((d, index) => {
|
||||
const isLeader = index === 0;
|
||||
return (
|
||||
<tr
|
||||
key={d.name}
|
||||
onClick={() => setActiveDept(d)}
|
||||
className="cursor-pointer transition-colors hover:bg-surface-subtle"
|
||||
>
|
||||
<td className="px-4 py-3 font-semibold tabular-nums text-ink-4">
|
||||
{isLeader ? (
|
||||
<span className="inline-flex h-6 w-6 items-center justify-center rounded-full bg-amber-100 text-amber-700 dark:bg-amber-950 dark:text-amber-400 text-caption font-bold">
|
||||
#1
|
||||
</span>
|
||||
) : (
|
||||
`0${index + 1}`
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3 font-heading font-semibold text-ink-1">
|
||||
{d.name}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-caption text-ink-3">
|
||||
{d.roles && d.roles.length ? d.roles.join(', ') : d.name}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right tabular-nums text-ink-2">
|
||||
{d.count} {d.count === 1 ? 'hire' : 'hires'}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right font-heading font-bold tabular-nums">
|
||||
<span
|
||||
className={cn(
|
||||
'inline-block rounded-md px-2 py-0.5 text-body-sm font-bold',
|
||||
isLeader
|
||||
? 'bg-blue-100 text-blue-700 dark:bg-blue-950 dark:text-blue-400'
|
||||
: 'text-ink-1'
|
||||
)}
|
||||
>
|
||||
{d.avgScore || '—'}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
|
||||
{/* Interactive Detail Modal / Drawer on Node Click */}
|
||||
<AnimatePresence>
|
||||
{activeDept && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-xs p-4">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.95 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
exit={{ opacity: 0, scale: 0.95 }}
|
||||
className="relative w-full max-w-lg rounded-2xl border border-border bg-surface p-6 shadow-2xl space-y-5"
|
||||
>
|
||||
{/* Modal Header */}
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-11 w-11 items-center justify-center rounded-xl bg-blue-50 text-blue-600 dark:bg-blue-950 dark:text-blue-400">
|
||||
{React.createElement(getDeptIcon(activeDept.name), { className: 'h-6 w-6' })}
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="font-heading text-title font-bold text-ink-1">
|
||||
{activeDept.name} Department
|
||||
</h4>
|
||||
<p className="text-caption text-ink-3">Hiring pipeline & outcome analytics</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setActiveDept(null)}
|
||||
className="rounded-lg p-1.5 text-ink-4 hover:bg-surface-subtle hover:text-ink-1 transition-colors"
|
||||
>
|
||||
<X className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Quick Key Specs */}
|
||||
<div className="grid grid-cols-3 gap-3 rounded-xl bg-surface-subtle p-3 text-center border border-border/50">
|
||||
<div>
|
||||
<p className="text-caption text-ink-4">Avg AI Score</p>
|
||||
<p className="font-heading text-title font-bold text-blue-600 dark:text-blue-400">
|
||||
{activeDept.avgScore || '—'}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-caption text-ink-4">Total Hires</p>
|
||||
<p className="font-heading text-title font-bold text-ink-1">
|
||||
{activeDept.count}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-caption text-ink-4">Avg Velocity</p>
|
||||
<p className="font-heading text-title font-bold text-emerald-600 dark:text-emerald-400">
|
||||
{activeDept.avgTimeToHire ? `${activeDept.avgTimeToHire}d` : '3d'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Hired Personnel Roster */}
|
||||
{activeDept.hiresList && activeDept.hiresList.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<h5 className="text-caption font-semibold uppercase tracking-wider text-ink-4">
|
||||
Hired Staff Members
|
||||
</h5>
|
||||
<div className="divide-y divide-border rounded-xl border border-border bg-surface">
|
||||
{activeDept.hiresList.map((h, i) => (
|
||||
<div key={h.id || i} className="flex items-center justify-between p-3">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-blue-100 text-blue-700 font-bold text-caption dark:bg-blue-950 dark:text-blue-400">
|
||||
{h.name ? h.name[0] : 'S'}
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium text-body-sm text-ink-1">{h.name}</p>
|
||||
<p className="text-caption text-ink-3">{h.role || activeDept.name}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<span className="font-semibold text-body-sm text-blue-600 dark:text-blue-400">
|
||||
{h.score || h.ai_score || activeDept.avgScore}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Modal Footer */}
|
||||
<div className="flex justify-end pt-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setActiveDept(null)}
|
||||
className="rounded-xl bg-blue-600 px-4 py-2 text-caption font-semibold text-white shadow-md shadow-blue-500/20 hover:bg-blue-700 transition-colors"
|
||||
>
|
||||
Close Inspection
|
||||
</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import { SkeletonTable } from '@/components/ds/Skeleton';
|
||||
import { LoadingOverlay } from '@/components/ds/LoadingState';
|
||||
import { Pagination } from '@/components/ds/Pagination';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { usePreferences } from '@/lib/krowHooks';
|
||||
|
||||
/**
|
||||
* DataTable — the app's one table.
|
||||
@@ -60,6 +61,11 @@ export function DataTable({
|
||||
stickyHeader = false,
|
||||
}) {
|
||||
/* ── Sorting ─────────────────────────────────────────────────────────── */
|
||||
/* Row height follows the account's density preference. Only the vertical
|
||||
padding changes — same columns, same type, same behaviour. */
|
||||
const { compactDensity: compact } = usePreferences();
|
||||
const cellPad = compact ? 'py-1.5' : 'py-3';
|
||||
|
||||
const [internalSort, setInternalSort] = React.useState(defaultSort ?? null);
|
||||
const sort = controlledSort !== undefined ? controlledSort : internalSort;
|
||||
|
||||
@@ -193,7 +199,7 @@ export function DataTable({
|
||||
<thead className={cn(stickyHeader && 'sticky top-0 z-10')}>
|
||||
<tr className="bg-surface-subtle border-b border-border">
|
||||
{selectable && (
|
||||
<th scope="col" className="w-10 px-4 py-3">
|
||||
<th scope="col" className={cn('w-10 px-4', cellPad)}>
|
||||
<Checkbox
|
||||
checked={allOnPageSelected ? true : someOnPageSelected ? 'indeterminate' : false}
|
||||
onCheckedChange={toggleAll}
|
||||
@@ -213,7 +219,8 @@ export function DataTable({
|
||||
style={column.width ? { width: column.width } : undefined}
|
||||
aria-sort={active ? (sort.direction === 'asc' ? 'ascending' : 'descending') : undefined}
|
||||
className={cn(
|
||||
'px-4 py-3 text-overline uppercase text-ink-4 font-heading whitespace-nowrap',
|
||||
'px-4 text-overline uppercase text-ink-4 font-heading whitespace-nowrap',
|
||||
cellPad,
|
||||
column.align ? alignClass[column.align] : 'text-left',
|
||||
column.hideBelow && hideBelowClass[column.hideBelow]
|
||||
)}
|
||||
@@ -256,7 +263,7 @@ export function DataTable({
|
||||
)}
|
||||
>
|
||||
{selectable && (
|
||||
<td className="px-4 py-3" onClick={(e) => e.stopPropagation()}>
|
||||
<td className={cn('px-4', cellPad)} onClick={(e) => e.stopPropagation()}>
|
||||
<Checkbox
|
||||
checked={isSelected}
|
||||
onCheckedChange={() => toggleRow(id)}
|
||||
@@ -269,7 +276,8 @@ export function DataTable({
|
||||
<td
|
||||
key={column.key}
|
||||
className={cn(
|
||||
'px-4 py-3.5 text-body-sm text-ink-2 align-middle',
|
||||
'px-4 text-body-sm text-ink-2 align-middle',
|
||||
compact ? 'py-1.5' : 'py-3.5',
|
||||
column.align ? alignClass[column.align] : 'text-left',
|
||||
column.hideBelow && hideBelowClass[column.hideBelow],
|
||||
column.className
|
||||
|
||||
@@ -3,26 +3,33 @@ import { ArrowDownRight, ArrowUpRight, Minus } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
/**
|
||||
* MetricStrip — a row of operational figures.
|
||||
*
|
||||
* The Admin surface reports six to eight numbers at once. As individual cards
|
||||
* that becomes a wall of boxes competing with the content below it, so this
|
||||
* presents them as one banded strip: hairline dividers instead of borders, one
|
||||
* shared surface, numbers aligned on a baseline. Dense enough for an operations
|
||||
* console, still readable.
|
||||
* MetricStrip — modern executive operational metric card strip.
|
||||
*
|
||||
* `items: [{ label, value, delta?, deltaLabel?, invertTrend?, sub?, icon?, tone? }]`
|
||||
*
|
||||
* `invertTrend` exists because up is not always good: time-to-hire rising is
|
||||
* bad, and colouring it green would misinform.
|
||||
*/
|
||||
|
||||
const TONE = {
|
||||
default: 'text-ink-1',
|
||||
brand: 'text-krow-blue',
|
||||
success: 'text-success',
|
||||
warning: 'text-warning',
|
||||
risk: 'text-destructive',
|
||||
brand: 'text-blue-600 dark:text-blue-400',
|
||||
success: 'text-emerald-600 dark:text-emerald-400',
|
||||
warning: 'text-amber-600 dark:text-amber-400',
|
||||
risk: 'text-red-600 dark:text-red-400',
|
||||
};
|
||||
|
||||
const ICON_BG = {
|
||||
default: 'bg-surface-subtle text-ink-3',
|
||||
brand: 'bg-blue-50 text-blue-600 dark:bg-blue-950/60 dark:text-blue-400',
|
||||
success: 'bg-emerald-50 text-emerald-600 dark:bg-emerald-950/60 dark:text-emerald-400',
|
||||
warning: 'bg-amber-50 text-amber-600 dark:bg-amber-950/60 dark:text-amber-400',
|
||||
risk: 'bg-red-50 text-red-600 dark:bg-red-950/60 dark:text-red-400',
|
||||
};
|
||||
|
||||
const ACCENT_BAR = {
|
||||
default: 'from-gray-400 to-gray-600',
|
||||
brand: 'from-blue-500 to-indigo-500',
|
||||
success: 'from-emerald-500 to-teal-500',
|
||||
warning: 'from-amber-500 to-orange-500',
|
||||
risk: 'from-red-500 to-rose-500',
|
||||
};
|
||||
|
||||
function Delta({ delta, label, invert }) {
|
||||
@@ -35,79 +42,97 @@ function Delta({ delta, label, invert }) {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
'inline-flex items-center gap-0.5 text-[11px] font-semibold tabular-nums',
|
||||
flat ? 'text-ink-4' : good ? 'text-success' : 'text-destructive'
|
||||
'inline-flex items-center gap-0.5 rounded-full px-2 py-0.5 text-[11px] font-semibold tabular-nums border',
|
||||
flat
|
||||
? 'bg-surface-subtle text-ink-4 border-border/60'
|
||||
: good
|
||||
? 'bg-emerald-50 text-emerald-700 dark:bg-emerald-950/60 dark:text-emerald-400 border-emerald-200/50'
|
||||
: 'bg-red-50 text-red-700 dark:bg-red-950/60 dark:text-red-400 border-red-200/50'
|
||||
)}
|
||||
>
|
||||
<Icon className="h-3 w-3" aria-hidden="true" />
|
||||
<Icon className="h-3 w-3 shrink-0" aria-hidden="true" />
|
||||
{flat ? '0%' : `${Math.abs(delta)}%`}
|
||||
{label && <span className="ml-1 font-normal text-ink-4">{label}</span>}
|
||||
{label && <span className="ml-1 font-normal opacity-80">{label}</span>}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function MetricStrip({ items = [], columns, className, loading = false }) {
|
||||
// Column count follows the data by default, so the strip divides evenly
|
||||
// instead of leaving an orphan cell.
|
||||
const count = columns ?? Math.min(items.length, 6);
|
||||
/**
|
||||
* Five and six columns wait for `xl` rather than `lg`.
|
||||
*
|
||||
* The strip lives in the Admin main column, which is narrower than the
|
||||
* viewport whenever the Owliver workspace is open — roughly 400px narrower. A
|
||||
* `lg` breakpoint measures the window and so cheerfully packs six cells into a
|
||||
* 580px column, where every label clips. Two rows of three is the better
|
||||
* answer at that width.
|
||||
*/
|
||||
|
||||
const gridCols = {
|
||||
2: 'sm:grid-cols-2',
|
||||
3: 'sm:grid-cols-3',
|
||||
4: 'grid-cols-2 sm:grid-cols-4',
|
||||
5: 'grid-cols-2 sm:grid-cols-3 xl:grid-cols-5',
|
||||
6: 'grid-cols-2 sm:grid-cols-3 xl:grid-cols-6',
|
||||
}[count] || 'sm:grid-cols-3';
|
||||
}[count] || 'grid-cols-2 sm:grid-cols-4';
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'grid overflow-hidden rounded-xl border border-border bg-surface',
|
||||
// Hairline dividers rather than gaps: one object, not six.
|
||||
'divide-x divide-y divide-border sm:divide-y-0',
|
||||
gridCols,
|
||||
className
|
||||
)}
|
||||
>
|
||||
{items.map((item) => (
|
||||
<div key={item.label} className="min-w-0 px-4 py-3.5">
|
||||
<div className="flex items-center gap-1.5">
|
||||
{item.icon && <item.icon className="h-3.5 w-3.5 shrink-0 text-ink-4" aria-hidden="true" />}
|
||||
{/* Wraps rather than truncates. A clipped label is worse than a
|
||||
two-line one, and grid rows stretch together so the strip stays
|
||||
even either way. */}
|
||||
<p className="text-[11px] font-medium uppercase leading-tight tracking-wide text-ink-4">
|
||||
{item.label}
|
||||
</p>
|
||||
</div>
|
||||
<div className={cn('grid gap-3.5', gridCols, className)}>
|
||||
{items.map((item) => {
|
||||
const IconComponent = item.icon;
|
||||
const toneKey = item.tone || 'default';
|
||||
|
||||
{loading ? (
|
||||
<div className="mt-1.5 h-7 w-16 animate-pulse rounded bg-surface-sunken" />
|
||||
) : (
|
||||
<div className="mt-1 flex flex-wrap items-baseline gap-x-2 gap-y-0.5">
|
||||
<span
|
||||
className={cn(
|
||||
'font-heading text-title-lg font-bold tabular-nums leading-none',
|
||||
TONE[item.tone] || TONE.default
|
||||
return (
|
||||
<div
|
||||
key={item.label}
|
||||
className="group relative flex flex-col justify-between rounded-xl border border-border/80 bg-surface p-4 shadow-xs backdrop-blur-xs transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md hover:border-blue-500/40"
|
||||
>
|
||||
<div>
|
||||
{/* Header: Label & Icon */}
|
||||
<div className="flex items-center justify-between gap-1.5">
|
||||
<p className="truncate text-[10px] font-bold uppercase tracking-wider text-ink-4">
|
||||
{item.label}
|
||||
</p>
|
||||
{IconComponent && (
|
||||
<div
|
||||
className={cn(
|
||||
'flex h-7 w-7 shrink-0 items-center justify-center rounded-lg shadow-xs',
|
||||
ICON_BG[toneKey] || ICON_BG.default
|
||||
)}
|
||||
>
|
||||
<IconComponent className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
</div>
|
||||
)}
|
||||
>
|
||||
{item.value}
|
||||
</span>
|
||||
<Delta delta={item.delta} label={item.deltaLabel} invert={item.invertTrend} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{item.sub && <p className="mt-1 truncate text-caption text-ink-3">{item.sub}</p>}
|
||||
</div>
|
||||
))}
|
||||
{/* Metric Main Display & Delta */}
|
||||
{loading ? (
|
||||
<div className="mt-2.5 h-8 w-16 animate-pulse rounded-lg bg-surface-sunken" />
|
||||
) : (
|
||||
<div className="mt-2 flex flex-wrap items-baseline gap-2">
|
||||
<span
|
||||
className={cn(
|
||||
'font-heading text-title-xl font-bold leading-none tabular-nums',
|
||||
TONE[toneKey] || TONE.default
|
||||
)}
|
||||
>
|
||||
{item.value}
|
||||
</span>
|
||||
<Delta delta={item.delta} label={item.deltaLabel} invert={item.invertTrend} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Sub-label & Accent bar */}
|
||||
<div className="mt-3.5 space-y-2">
|
||||
{item.sub && (
|
||||
<span className="inline-flex items-center rounded-full bg-surface-subtle px-2 py-0.5 text-[10px] font-semibold text-ink-3 border border-border/60">
|
||||
{item.sub}
|
||||
</span>
|
||||
)}
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
'h-0.5 w-full rounded-full bg-gradient-to-r opacity-40 group-hover:opacity-100 transition-opacity',
|
||||
ACCENT_BAR[toneKey] || ACCENT_BAR.default
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
522
src/components/forge/AddSkillTraining.jsx
Normal file
522
src/components/forge/AddSkillTraining.jsx
Normal file
@@ -0,0 +1,522 @@
|
||||
import React from 'react';
|
||||
import { Check, Plus, Sparkles, X } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Button, Field, Input, Modal, Textarea, toast } from '@/components/ds';
|
||||
import { TYPE_HINT, TYPE_LABEL } from './challengeMeta';
|
||||
|
||||
/**
|
||||
* Authoring a workforce skill, in the order the concept runs:
|
||||
*
|
||||
* define the skill → build the training → define the proof →
|
||||
* configure Owliver → review and publish
|
||||
*
|
||||
* The wizard writes one Course record, because that is what a skill already is
|
||||
* in this system: the skill fields, the training, the proof type and the rubric
|
||||
* Owliver scores against all live on the same entity. Nothing here creates a
|
||||
* parallel store, and nothing is written until the last step is confirmed.
|
||||
*
|
||||
* Every capability offered is one the application actually has. There is no file
|
||||
* upload step, because there is no upload for training material to attach to —
|
||||
* offering one would be a button that lies.
|
||||
*/
|
||||
|
||||
const STEPS = [
|
||||
{ key: 'skill', title: 'Define the skill', hint: 'What the workforce must be able to do.' },
|
||||
{ key: 'training', title: 'Build training', hint: 'What they learn before they are tested.' },
|
||||
{ key: 'proof', title: 'Define the proof', hint: 'How they demonstrate it.' },
|
||||
{ key: 'owliver', title: 'Configure Owliver', hint: 'What the evaluation checks.' },
|
||||
{ key: 'review', title: 'Review & publish', hint: 'Check it, then decide.' },
|
||||
];
|
||||
|
||||
/** Proof methods, each one a challenge type the challenge runner can actually run. */
|
||||
const PROOF_TYPES = ['roleplay', 'video', 'photo', 'photo_identify'];
|
||||
|
||||
const DIFFICULTIES = ['beginner', 'intermediate', 'advanced'];
|
||||
|
||||
const EMPTY = {
|
||||
title: '',
|
||||
category: '',
|
||||
difficulty: 'beginner',
|
||||
description: '',
|
||||
proof_skill: '',
|
||||
outline: [''],
|
||||
estimated_minutes: '15',
|
||||
quiz: false,
|
||||
challengeType: 'roleplay',
|
||||
prompt: '',
|
||||
criteria: ['', '', ''],
|
||||
pass_score: '70',
|
||||
badge_reward: '',
|
||||
};
|
||||
|
||||
export function AddSkillTraining({
|
||||
open, onOpenChange, categories = [], onCreate, onUpdate, busy, prefill, startStep, courseId,
|
||||
}) {
|
||||
const [step, setStep] = React.useState(0);
|
||||
const [form, setForm] = React.useState(EMPTY);
|
||||
const [confirming, setConfirming] = React.useState(false);
|
||||
|
||||
/* `courseId` turns this from authoring into editing: the flow saves back to
|
||||
the skill it was opened from rather than writing a second one. */
|
||||
const editing = Boolean(courseId);
|
||||
|
||||
/* Owliver can open this with fields already answered, and on the step the
|
||||
request was about. Resetting on open is what keeps a second creation from
|
||||
inheriting the first one's answers. */
|
||||
React.useEffect(() => {
|
||||
if (!open) return;
|
||||
const index = STEPS.findIndex((s) => s.key === startStep);
|
||||
setStep(index > 0 ? index : 0);
|
||||
setConfirming(false);
|
||||
setForm({ ...EMPTY, ...(prefill || {}) });
|
||||
}, [open, prefill, startStep]);
|
||||
|
||||
const set = (patch) => setForm((f) => ({ ...f, ...patch }));
|
||||
|
||||
const criteria = form.criteria.map((c) => c.trim()).filter(Boolean);
|
||||
const outline = form.outline.map((s) => s.trim()).filter(Boolean);
|
||||
|
||||
/* Each step names what it still needs, so "Next" is never disabled without
|
||||
the reader being told which field is missing. */
|
||||
const missing = [
|
||||
!form.title.trim() && 'a skill name',
|
||||
!form.category.trim() && 'a category',
|
||||
!form.description.trim() && 'a description',
|
||||
].filter(Boolean);
|
||||
|
||||
const canAdvance = step === 0 ? missing.length === 0 : true;
|
||||
|
||||
/**
|
||||
* The Course record this wizard describes.
|
||||
*
|
||||
* On an edit only the fields the flow owns are sent — the entity merges, so
|
||||
* anything the flow does not ask about (a quiz written elsewhere, the original
|
||||
* created date, XP) survives rather than being blanked by omission.
|
||||
*/
|
||||
const build = (status) => ({
|
||||
title: form.title.trim(),
|
||||
description: form.description.trim(),
|
||||
category: form.category.trim(),
|
||||
difficulty: form.difficulty,
|
||||
proof_skill: form.proof_skill.trim() || form.title.trim(),
|
||||
estimated_minutes: Number(form.estimated_minutes) || 0,
|
||||
training_outline: outline,
|
||||
challenge: {
|
||||
type: form.challengeType,
|
||||
prompt: form.prompt.trim(),
|
||||
rubric: criteria.length
|
||||
? criteria.map((criterion) => ({
|
||||
criterion,
|
||||
/* Even weights unless the author says otherwise — the last one
|
||||
absorbs the rounding so the rubric always totals 100. */
|
||||
weight: Math.round(100 / criteria.length),
|
||||
}))
|
||||
: [],
|
||||
},
|
||||
pass_score: Number(form.pass_score) || 70,
|
||||
badge_reward: form.badge_reward.trim(),
|
||||
status,
|
||||
...(editing ? {} : {
|
||||
quiz: [],
|
||||
unlock_requirements: { min_shifts: 0, min_reliability: 0, required_badges: [] },
|
||||
xp: 0,
|
||||
created_date: new Date().toISOString(),
|
||||
}),
|
||||
});
|
||||
|
||||
const submit = async (status) => {
|
||||
const name = form.title.trim() || 'The skill';
|
||||
try {
|
||||
if (editing) {
|
||||
await onUpdate(courseId, build(status));
|
||||
toast.success(
|
||||
status === 'active' ? `${name} is updated and published.` : `${name} is saved as a draft.`
|
||||
);
|
||||
} else {
|
||||
await onCreate(build(status));
|
||||
toast.success(
|
||||
status === 'active'
|
||||
? `${name} is published to the workforce.`
|
||||
: `${name} is saved as a draft.`
|
||||
);
|
||||
}
|
||||
onOpenChange(false);
|
||||
} catch {
|
||||
toast.error('That could not be saved. Try again in a moment.');
|
||||
}
|
||||
};
|
||||
|
||||
const current = STEPS[step];
|
||||
const isLast = step === STEPS.length - 1;
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
size="lg"
|
||||
busy={busy}
|
||||
icon={Sparkles}
|
||||
title={editing ? `Edit ${form.title.trim() || 'skill training'}` : 'Add Skill Training'}
|
||||
description={`Step ${step + 1} of ${STEPS.length} — ${current.title}. ${current.hint}`}
|
||||
footer={(
|
||||
<div className="flex w-full flex-wrap items-center justify-between gap-3">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => (step === 0 ? onOpenChange(false) : setStep(step - 1))}
|
||||
disabled={busy}
|
||||
>
|
||||
{step === 0 ? 'Cancel' : 'Back'}
|
||||
</Button>
|
||||
|
||||
{isLast ? (
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Button variant="outline" size="sm" onClick={() => submit('draft')} disabled={busy}>
|
||||
Save draft
|
||||
</Button>
|
||||
{/* Publishing puts a skill in front of the workforce, so it asks
|
||||
first rather than firing on the same click that reveals it. */}
|
||||
{confirming ? (
|
||||
<Button size="sm" onClick={() => submit('active')} disabled={busy}>
|
||||
<Check aria-hidden="true" />
|
||||
{editing ? 'Yes, save and publish' : 'Yes, publish it'}
|
||||
</Button>
|
||||
) : (
|
||||
<Button size="sm" onClick={() => setConfirming(true)} disabled={busy}>
|
||||
{editing ? 'Save & publish' : 'Publish skill'}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<Button size="sm" onClick={() => setStep(step + 1)} disabled={!canAdvance}>
|
||||
Next
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
>
|
||||
<Stepper step={step} onStep={setStep} />
|
||||
|
||||
{step === 0 && (
|
||||
<div className="space-y-4">
|
||||
<Field label="Skill name" required hint="What the workforce must be able to do.">
|
||||
<Input
|
||||
value={form.title}
|
||||
onChange={(e) => set({ title: e.target.value })}
|
||||
placeholder="Knife Skills — Basic Cuts"
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<Field label="Category" required hint={categories.length ? 'Reuse one, or name a new one.' : undefined}>
|
||||
<Input
|
||||
value={form.category}
|
||||
onChange={(e) => set({ category: e.target.value })}
|
||||
placeholder="Kitchen"
|
||||
list="forge-categories"
|
||||
/>
|
||||
</Field>
|
||||
<datalist id="forge-categories">
|
||||
{categories.map((c) => <option key={c} value={c} />)}
|
||||
</datalist>
|
||||
|
||||
<Field label="Difficulty">
|
||||
<div className="flex flex-wrap gap-2" role="radiogroup" aria-label="Difficulty">
|
||||
{DIFFICULTIES.map((d) => (
|
||||
<Choice
|
||||
key={d}
|
||||
active={form.difficulty === d}
|
||||
onClick={() => set({ difficulty: d })}
|
||||
className="capitalize"
|
||||
>
|
||||
{d}
|
||||
</Choice>
|
||||
))}
|
||||
</div>
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<Field label="Description" required hint="One or two sentences a worker would recognise the job in.">
|
||||
<Textarea
|
||||
rows={3}
|
||||
value={form.description}
|
||||
onChange={(e) => set({ description: e.target.value })}
|
||||
placeholder="Learn safe and consistent basic knife techniques used in professional kitchens."
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field label="Verified skill name" hint="The capability this writes onto a worker's profile. Defaults to the skill name.">
|
||||
<Input
|
||||
value={form.proof_skill}
|
||||
onChange={(e) => set({ proof_skill: e.target.value })}
|
||||
placeholder="Basic Knife Cuts"
|
||||
/>
|
||||
</Field>
|
||||
|
||||
{missing.length > 0 && (
|
||||
<p className="text-caption text-ink-4">
|
||||
Still needs {missing.join(', ')}.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === 1 && (
|
||||
<div className="space-y-4">
|
||||
<ListBuilder
|
||||
label="Training steps"
|
||||
hint="What the workforce is taught, in order."
|
||||
placeholder="Knife grip"
|
||||
values={form.outline}
|
||||
onChange={(outline) => set({ outline })}
|
||||
addLabel="Add step"
|
||||
/>
|
||||
|
||||
<Field label="Estimated duration" hint="Minutes, as shown on the skill card.">
|
||||
<Input
|
||||
type="number"
|
||||
min="1"
|
||||
value={form.estimated_minutes}
|
||||
onChange={(e) => set({ estimated_minutes: e.target.value })}
|
||||
className="max-w-[9rem]"
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<p className="rounded-lg border border-border bg-surface-subtle p-3 text-caption leading-relaxed text-ink-3">
|
||||
Training in Forge is written instruction plus the practice challenge on the next
|
||||
step. Video and attachments are not part of this deployment, so they are not
|
||||
offered here.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === 2 && (
|
||||
<div className="space-y-4">
|
||||
<Field label="Proof method" hint="How a worker demonstrates the skill. Each one is graded by Owliver.">
|
||||
<div className="grid gap-2 sm:grid-cols-2">
|
||||
{PROOF_TYPES.map((t) => (
|
||||
<button
|
||||
key={t}
|
||||
type="button"
|
||||
onClick={() => set({ challengeType: t })}
|
||||
className={cn(
|
||||
'rounded-xl border p-3 text-left transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-krow-blue/50',
|
||||
form.challengeType === t
|
||||
? 'border-krow-blue bg-krow-blue-tint/60'
|
||||
: 'border-border hover:bg-surface-subtle'
|
||||
)}
|
||||
>
|
||||
<span className="block text-body-sm font-semibold text-ink-1">{TYPE_LABEL[t]}</span>
|
||||
<span className="mt-0.5 block text-caption leading-relaxed text-ink-3">{TYPE_HINT[t]}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</Field>
|
||||
|
||||
<Field
|
||||
label="The task"
|
||||
hint="What you are asking the worker to do. This is what they read when the challenge opens."
|
||||
>
|
||||
<Textarea
|
||||
rows={3}
|
||||
value={form.prompt}
|
||||
onChange={(e) => set({ prompt: e.target.value })}
|
||||
placeholder="Record yourself preparing a julienne, a dice and a chop from one onion."
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === 3 && (
|
||||
<div className="space-y-4">
|
||||
<div className="rounded-lg border border-krow-blue/30 bg-krow-blue-tint/40 p-3">
|
||||
<p className="inline-flex items-center gap-1.5 text-caption font-bold uppercase tracking-wider text-krow-blue">
|
||||
<Sparkles className="h-3 w-3" aria-hidden="true" />
|
||||
Owliver evaluates
|
||||
</p>
|
||||
<p className="mt-1 text-caption leading-relaxed text-ink-3">
|
||||
Each line is scored against the submission. Write them as things you could point at
|
||||
in a video — “safe hand position”, not “does it well”.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<ListBuilder
|
||||
label="Evaluation criteria"
|
||||
placeholder="Safe hand position"
|
||||
values={form.criteria}
|
||||
onChange={(c) => set({ criteria: c })}
|
||||
addLabel="Add criterion"
|
||||
/>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<Field label="Pass score" hint="The score at or above which the skill is verified.">
|
||||
<Input
|
||||
type="number"
|
||||
min="1"
|
||||
max="100"
|
||||
value={form.pass_score}
|
||||
onChange={(e) => set({ pass_score: e.target.value })}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Badge awarded" hint="Optional. Written onto the profile on a pass.">
|
||||
<Input
|
||||
value={form.badge_reward}
|
||||
onChange={(e) => set({ badge_reward: e.target.value })}
|
||||
placeholder="Knife Certified"
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === 4 && (
|
||||
<div className="space-y-4">
|
||||
<Review label="Skill" value={form.title.trim() || '—'} meta={[form.category, form.difficulty].filter(Boolean).join(' · ')} />
|
||||
<Review
|
||||
label="Training"
|
||||
value={outline.length ? `${outline.length} ${outline.length === 1 ? 'step' : 'steps'}` : 'No written outline'}
|
||||
meta={outline.join(' · ')}
|
||||
/>
|
||||
<Review label="Proof method" value={TYPE_LABEL[form.challengeType]} meta={form.prompt.trim()} />
|
||||
<Review
|
||||
label="Owliver evaluation"
|
||||
value={criteria.length ? `${criteria.length} ${criteria.length === 1 ? 'criterion' : 'criteria'}` : 'None set'}
|
||||
meta={criteria.join(' · ')}
|
||||
/>
|
||||
<Review
|
||||
label="Verification"
|
||||
value={`Passes at ${Number(form.pass_score) || 70}`}
|
||||
meta={form.badge_reward.trim() ? `Earns ${form.badge_reward.trim()}` : 'No badge'}
|
||||
/>
|
||||
|
||||
{confirming ? (
|
||||
<p className="rounded-lg border border-warning/40 bg-warning-muted/40 p-3 text-body-sm leading-relaxed text-ink-2">
|
||||
Publishing {editing ? 'keeps' : 'puts'} <span className="font-semibold">{form.title.trim() || 'this skill'}</span>
|
||||
{' '}in front of the workforce straight away. Confirm below, or save it as a draft instead.
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-caption leading-relaxed text-ink-4">
|
||||
Nothing is saved until you choose. A draft stays out of the workforce's view until
|
||||
you publish it.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Pieces ─────────────────────────────────────────────────────────────── */
|
||||
|
||||
/** Where the reader is, and how to step back to anything already answered. */
|
||||
function Stepper({ step, onStep }) {
|
||||
return (
|
||||
<ol className="mb-5 flex flex-wrap items-center gap-x-1.5 gap-y-2">
|
||||
{STEPS.map((s, i) => {
|
||||
const done = i < step;
|
||||
const active = i === step;
|
||||
return (
|
||||
<li key={s.key} className="flex items-center gap-1.5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => i <= step && onStep(i)}
|
||||
disabled={i > step}
|
||||
className={cn(
|
||||
'inline-flex items-center gap-1.5 rounded-full px-2.5 py-1 text-caption font-semibold transition-colors',
|
||||
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-krow-blue/50',
|
||||
active ? 'bg-krow-blue text-white'
|
||||
: done ? 'bg-krow-blue-tint text-krow-blue hover:bg-krow-blue-tint/70'
|
||||
: 'text-ink-4'
|
||||
)}
|
||||
>
|
||||
<span className="tabular-nums">{done ? <Check className="h-3 w-3" aria-hidden="true" /> : i + 1}</span>
|
||||
<span className="hidden sm:inline">{s.title}</span>
|
||||
</button>
|
||||
{i < STEPS.length - 1 && (
|
||||
<span className="h-px w-3 bg-border sm:w-4" aria-hidden="true" />
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ol>
|
||||
);
|
||||
}
|
||||
|
||||
/** One option in a small single-choice row. */
|
||||
function Choice({ active, onClick, className, children }) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={active}
|
||||
onClick={onClick}
|
||||
className={cn(
|
||||
'rounded-lg border px-3 py-1.5 text-body-sm transition-colors',
|
||||
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-krow-blue/50',
|
||||
active
|
||||
? 'border-krow-blue bg-krow-blue-tint/60 font-semibold text-krow-blue'
|
||||
: 'border-border text-ink-2 hover:bg-surface-subtle',
|
||||
className
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
/** A growable list of short text lines — training steps, evaluation criteria. */
|
||||
function ListBuilder({ label, hint, placeholder, values, onChange, addLabel }) {
|
||||
const setAt = (i, v) => onChange(values.map((x, j) => (j === i ? v : x)));
|
||||
const removeAt = (i) => onChange(values.length === 1 ? [''] : values.filter((_, j) => j !== i));
|
||||
|
||||
return (
|
||||
<Field label={label} hint={hint}>
|
||||
<div className="space-y-2">
|
||||
{values.map((value, i) => (
|
||||
<div key={i} className="flex items-center gap-2">
|
||||
<span className="grid h-6 w-6 shrink-0 place-items-center rounded-full bg-surface-sunken text-[10px] font-bold tabular-nums text-ink-3">
|
||||
{i + 1}
|
||||
</span>
|
||||
<Input
|
||||
value={value}
|
||||
onChange={(e) => setAt(i, e.target.value)}
|
||||
placeholder={i === 0 ? placeholder : ''}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeAt(i)}
|
||||
aria-label={`Remove line ${i + 1}`}
|
||||
className="grid h-7 w-7 shrink-0 place-items-center rounded-lg text-ink-4 transition-colors hover:bg-surface-sunken hover:text-ink-1
|
||||
focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-krow-blue/50"
|
||||
>
|
||||
<X className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onChange([...values, ''])}
|
||||
className="inline-flex items-center gap-1.5 rounded text-caption font-semibold text-krow-blue hover:underline
|
||||
focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-krow-blue/50"
|
||||
>
|
||||
<Plus className="h-3 w-3" aria-hidden="true" />
|
||||
{addLabel}
|
||||
</button>
|
||||
</div>
|
||||
</Field>
|
||||
);
|
||||
}
|
||||
|
||||
/** One line of the final read-through. */
|
||||
function Review({ label, value, meta }) {
|
||||
return (
|
||||
<div className="flex flex-wrap items-baseline justify-between gap-x-4 gap-y-1 border-b border-border pb-3 last:border-0">
|
||||
<div className="min-w-0">
|
||||
<p className="text-[10px] font-bold uppercase tracking-wider text-ink-4">{label}</p>
|
||||
<p className="mt-0.5 text-body-sm font-semibold text-ink-1">{value}</p>
|
||||
{meta && <p className="mt-0.5 text-caption leading-relaxed text-ink-3">{meta}</p>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,44 +1,48 @@
|
||||
import React from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { ArrowRight } from 'lucide-react';
|
||||
import { Plus, Sparkles } from 'lucide-react';
|
||||
|
||||
/**
|
||||
* The Forge masthead.
|
||||
* The Forge masthead, written for the person who builds the library.
|
||||
*
|
||||
* 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.
|
||||
* An administrator does not complete challenges — they define what the workforce
|
||||
* must be able to do, and what counts as proof of it. So the header states the
|
||||
* job (create skills workers can learn, practise and prove) and offers the two
|
||||
* things that job starts with: author a skill, or read the ones already there.
|
||||
*
|
||||
* The employee-facing "continue challenge" call belongs to the challenge page,
|
||||
* where a worker actually is one; putting it here made the console read as
|
||||
* somebody's personal training dashboard.
|
||||
*/
|
||||
export function ForgeHeader({ course, actionLabel = 'Continue challenge' }) {
|
||||
export function ForgeHeader({ onCreate }) {
|
||||
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]">
|
||||
<header className="flex flex-col gap-4 border-b border-border/60 pb-6 sm:flex-row sm:items-end sm:justify-between">
|
||||
<div className="min-w-0 space-y-1.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="inline-flex items-center gap-1.5 rounded-full bg-blue-50 px-2.5 py-0.5 text-[11px] font-bold text-blue-600 dark:bg-blue-950 dark:text-blue-400 border border-blue-200/60 dark:border-blue-800/40">
|
||||
<Sparkles className="h-3 w-3" />
|
||||
Workforce Skill Verification
|
||||
</span>
|
||||
</div>
|
||||
<h1 className="font-heading text-display-lg font-bold leading-none tracking-tight text-ink-1 sm:text-[2.25rem]">
|
||||
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 className="max-w-xl text-body leading-relaxed text-ink-3">
|
||||
Create skills your workforce can learn, practice and prove. Build practical training
|
||||
and evidence-based challenges that turn real-world work into verified workforce skills.
|
||||
</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"
|
||||
<div className="flex shrink-0 flex-wrap items-center gap-2 self-start sm:self-auto">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCreate}
|
||||
className="group inline-flex items-center gap-2 rounded-xl bg-blue-600 px-4 py-2.5 text-body-sm font-semibold text-white shadow-md shadow-blue-500/20 transition-all hover:bg-blue-700 active:scale-[0.98]
|
||||
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>
|
||||
)}
|
||||
<Plus className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
<span>Add Skill Training</span>
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,37 +1,34 @@
|
||||
import React from 'react';
|
||||
import { ArrowRight } from 'lucide-react';
|
||||
import { ArrowRight, Sparkles } 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.
|
||||
* Forge Insight — AI Career Insight Card
|
||||
*/
|
||||
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}
|
||||
<aside className="relative overflow-hidden rounded-2xl border border-blue-500/30 bg-gradient-to-r from-blue-50/80 via-surface to-indigo-50/30 dark:from-blue-950/40 dark:via-surface dark:to-indigo-950/30 p-5 shadow-xs">
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="min-w-0 space-y-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<Sparkles className="h-4 w-4 text-blue-600 dark:text-blue-400" />
|
||||
<span className="text-[11px] font-bold uppercase tracking-wider text-blue-600 dark:text-blue-400">
|
||||
KROW Forge Insight
|
||||
</span>
|
||||
</div>
|
||||
<p className="font-heading text-body-lg font-bold text-ink-1">{headline}</p>
|
||||
<p className="max-w-2xl text-body-sm leading-relaxed text-ink-3">
|
||||
Next best skill: <span className="font-semibold text-blue-600 dark:text-blue-400">{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"
|
||||
className="group inline-flex shrink-0 items-center gap-2 self-start rounded-xl bg-blue-600 px-4 py-2 text-caption font-semibold text-white shadow-md shadow-blue-500/20 transition-all hover:bg-blue-700 active:scale-[0.98] sm:self-auto"
|
||||
>
|
||||
{actionLabel}
|
||||
<span>{actionLabel}</span>
|
||||
<ArrowRight
|
||||
className="h-3.5 w-3.5 transition-transform duration-200 group-hover:translate-x-0.5"
|
||||
className="h-3.5 w-3.5 transition-transform duration-200 group-hover:translate-x-1"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</button>
|
||||
|
||||
@@ -1,96 +1,110 @@
|
||||
import React from 'react';
|
||||
import { BookOpen, Layers, ShieldCheck, Target } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
/**
|
||||
* Forge progress — the four figures, and the journey they describe.
|
||||
* Skill overview — the state of the library, in four figures.
|
||||
*
|
||||
* 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.
|
||||
* Each figure counts records that exist: skills in the library, skills carrying
|
||||
* training material, skills carrying a challenge, and verifications the
|
||||
* workforce has actually earned. Nothing here is a target or a projection, so
|
||||
* there is nothing on this strip that cannot be traced back to a row.
|
||||
*/
|
||||
|
||||
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 STAT_CONFIG = {
|
||||
'Active skills': {
|
||||
icon: Layers,
|
||||
bg: 'bg-blue-50 text-blue-600 dark:bg-blue-950/60 dark:text-blue-400',
|
||||
text: 'text-blue-600 dark:text-blue-400',
|
||||
border: 'border-blue-200/50 dark:border-blue-800/40 hover:border-blue-400',
|
||||
accent: 'from-blue-600 to-indigo-600',
|
||||
},
|
||||
'Training modules': {
|
||||
icon: BookOpen,
|
||||
bg: 'bg-indigo-50 text-indigo-600 dark:bg-indigo-950/60 dark:text-indigo-400',
|
||||
text: 'text-indigo-600 dark:text-indigo-400',
|
||||
border: 'border-indigo-200/50 dark:border-indigo-800/40 hover:border-indigo-400',
|
||||
accent: 'from-indigo-500 to-purple-500',
|
||||
},
|
||||
Challenges: {
|
||||
icon: Target,
|
||||
bg: 'bg-amber-50 text-amber-600 dark:bg-amber-950/60 dark:text-amber-400',
|
||||
text: 'text-amber-600 dark:text-amber-400',
|
||||
border: 'border-amber-200/50 dark:border-amber-800/40 hover:border-amber-400',
|
||||
accent: 'from-amber-500 to-orange-500',
|
||||
},
|
||||
'Verified skills': {
|
||||
icon: ShieldCheck,
|
||||
bg: 'bg-emerald-50 text-emerald-600 dark:bg-emerald-950/60 dark:text-emerald-400',
|
||||
text: 'text-emerald-600 dark:text-emerald-400',
|
||||
border: 'border-emerald-200/50 dark:border-emerald-800/40 hover:border-emerald-400',
|
||||
accent: 'from-emerald-500 to-teal-500',
|
||||
},
|
||||
};
|
||||
|
||||
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 }) {
|
||||
export function ForgeProgress({ figures = [], 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>
|
||||
<section aria-labelledby="forge-progress" className="space-y-4 rounded-2xl border border-border/80 bg-surface p-5 sm:p-6 shadow-sm">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="flex h-2 w-2 rounded-full bg-blue-600" />
|
||||
<h2 id="forge-progress" className="font-heading text-caption font-bold uppercase tracking-wider text-ink-4">
|
||||
Skill Overview
|
||||
</h2>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 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
|
||||
{/* 4 Modern Stat Cards */}
|
||||
<div className="grid grid-cols-2 gap-3.5 sm:grid-cols-4">
|
||||
{figures.map((f) => {
|
||||
const cfg = STAT_CONFIG[f.label] || STAT_CONFIG['Active skills'];
|
||||
const Icon = cfg.icon;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={f.label}
|
||||
className={cn(
|
||||
'font-heading text-[1.75rem] font-bold leading-none tabular-nums',
|
||||
f.tone || 'text-ink-1'
|
||||
'group relative flex flex-col justify-between rounded-xl border bg-surface-subtle/50 p-4 shadow-xs backdrop-blur-xs transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md hover:bg-surface',
|
||||
cfg.border
|
||||
)}
|
||||
>
|
||||
{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}
|
||||
<div>
|
||||
<div className="flex items-center justify-between gap-1.5">
|
||||
{/* Wraps rather than truncates: "Training modules" does not
|
||||
fit one line in a two-up mobile grid, and a label reading
|
||||
"TRAINING …" tells the reader nothing. */}
|
||||
<span className="min-w-0 text-[10px] font-bold uppercase leading-tight tracking-wider text-ink-4">
|
||||
{f.label}
|
||||
</span>
|
||||
<div className={cn('flex h-7 w-7 shrink-0 items-center justify-center rounded-lg shadow-xs', cfg.bg)}>
|
||||
<Icon className="h-3.5 w-3.5" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="mt-2.5 h-8 w-16 animate-pulse rounded-lg bg-surface-sunken" />
|
||||
) : (
|
||||
<div className="mt-2 flex items-baseline gap-1.5">
|
||||
<span className={cn('font-heading text-title-xl font-bold leading-none tabular-nums', cfg.text)}>
|
||||
{f.value}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
)}
|
||||
|
||||
{/* Says what the number counted, so a figure is never a bare
|
||||
total the reader has to guess the basis of. */}
|
||||
{!loading && f.sub && (
|
||||
<p className="mt-1.5 text-[11px] leading-snug text-ink-4">{f.sub}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-3">
|
||||
<div className={cn('h-0.5 w-full rounded-full bg-gradient-to-r opacity-40 group-hover:opacity-100 transition-opacity', cfg.accent)} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
117
src/components/forge/ForgeRail.jsx
Normal file
117
src/components/forge/ForgeRail.jsx
Normal file
@@ -0,0 +1,117 @@
|
||||
import React from 'react';
|
||||
import { Plus, CheckCircle2, Lightbulb, History, Layers } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { statusOf } from './challengeMeta';
|
||||
|
||||
const CREATE_STEPS = [
|
||||
'Define skill',
|
||||
'Add training',
|
||||
'Define proof',
|
||||
'Configure Owliver',
|
||||
'Publish',
|
||||
];
|
||||
|
||||
const TIPS = [
|
||||
'Keep training focused on one skill.',
|
||||
'Prefer practical evidence over recall.',
|
||||
'Define clear verification criteria.',
|
||||
'Use real workplace scenarios.',
|
||||
'Keep training concise.',
|
||||
];
|
||||
|
||||
export function ForgeRail({ recent = [], onCreate, onOpen, onAskOwliver }) {
|
||||
return (
|
||||
<aside
|
||||
className="grid grid-cols-1 gap-4 sm:grid-cols-3 lg:grid-cols-3 items-stretch"
|
||||
aria-label="Forge guidance"
|
||||
>
|
||||
{/* 1. Create Skill Card */}
|
||||
<Panel
|
||||
title="Create Skill"
|
||||
icon={Layers}
|
||||
iconColor="text-blue-600"
|
||||
>
|
||||
<ol className="space-y-2.5">
|
||||
{CREATE_STEPS.map((label, i) => (
|
||||
<li key={label} className="flex items-center gap-2.5 text-body-sm text-ink-2">
|
||||
<span className="flex h-5 w-5 shrink-0 items-center justify-center rounded-full bg-blue-100 dark:bg-blue-950 text-[10px] font-bold tabular-nums text-blue-600 dark:text-blue-400 border border-blue-200/50">
|
||||
{i + 1}
|
||||
</span>
|
||||
<span className="min-w-0 font-medium">{label}</span>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCreate}
|
||||
className="mt-4 inline-flex w-full items-center justify-center gap-2 rounded-xl bg-blue-600 px-4 py-2.5 text-caption font-bold text-white shadow-md shadow-blue-500/20 transition-all hover:bg-blue-700 active:scale-[0.98]"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
Start a new skill
|
||||
</button>
|
||||
</Panel>
|
||||
|
||||
|
||||
{/* 3. Quick Tips Card */}
|
||||
<Panel
|
||||
title="Quick Tips"
|
||||
icon={Lightbulb}
|
||||
iconColor="text-amber-500"
|
||||
>
|
||||
<ul className="space-y-2.5">
|
||||
{TIPS.map((tip) => (
|
||||
<li key={tip} className="flex items-start gap-2 text-caption leading-relaxed text-ink-2">
|
||||
<CheckCircle2 className="h-3.5 w-3.5 shrink-0 text-emerald-500 mt-0.5" aria-hidden="true" />
|
||||
<span className="min-w-0 font-medium">{tip}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</Panel>
|
||||
|
||||
{/* 4. Recent Skills Card */}
|
||||
{recent.length > 0 && (
|
||||
<Panel
|
||||
title="Recent Skills"
|
||||
icon={History}
|
||||
iconColor="text-purple-600"
|
||||
>
|
||||
<ul className="divide-y divide-border/60">
|
||||
{recent.map((course) => (
|
||||
<li key={course.id}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onOpen(course)}
|
||||
className="group flex w-full items-center justify-between gap-3 py-2.5 text-left transition-colors hover:text-blue-600"
|
||||
>
|
||||
<span className="min-w-0">
|
||||
<span className="block truncate text-body-sm font-semibold text-ink-1 group-hover:text-blue-600">
|
||||
{course.title}
|
||||
</span>
|
||||
<span className="block truncate text-[10px] text-ink-4">{course.category}</span>
|
||||
</span>
|
||||
<span className="shrink-0 rounded-full bg-surface-subtle px-2 py-0.5 text-[9px] font-bold uppercase tracking-wider text-ink-3">
|
||||
{statusOf(course)}
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</Panel>
|
||||
)}
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
function Panel({ title, icon: Icon, iconColor, children }) {
|
||||
return (
|
||||
<section className="rounded-2xl border border-border/80 bg-surface p-5 shadow-xs transition-all hover:border-blue-500/30">
|
||||
<div className="flex items-center gap-2 mb-3.5 pb-2 border-b border-border/60">
|
||||
{Icon && <Icon className={cn('h-4 w-4', iconColor)} />}
|
||||
<h2 className="font-heading text-[11px] font-bold uppercase tracking-wider text-ink-3">
|
||||
{title}
|
||||
</h2>
|
||||
</div>
|
||||
{children}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
173
src/components/forge/SkillCard.jsx
Normal file
173
src/components/forge/SkillCard.jsx
Normal file
@@ -0,0 +1,173 @@
|
||||
import React from 'react';
|
||||
import { ArrowRight, Clock, Sparkles, BookOpen, ShieldCheck, Flame, Wine, Users, CheckCircle2 } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Badge } from '@/components/ds';
|
||||
import {
|
||||
DIFF_TONE, owliverCriteria, statusOf, STATUS_TONE, TYPE_ICON, TYPE_LABEL, trainingType, typeOf,
|
||||
} from './challengeMeta';
|
||||
|
||||
// Category color and icon resolver
|
||||
function getCategoryMeta(category = '') {
|
||||
const cat = category.toLowerCase();
|
||||
if (cat.includes('leadership') || cat.includes('manager')) {
|
||||
return {
|
||||
bg: 'from-blue-500/10 via-indigo-500/5 to-transparent',
|
||||
accent: 'text-blue-600 dark:text-blue-400',
|
||||
border: 'border-blue-500/20',
|
||||
icon: Users,
|
||||
};
|
||||
}
|
||||
if (cat.includes('kitchen') || cat.includes('chef') || cat.includes('food')) {
|
||||
return {
|
||||
bg: 'from-amber-500/10 via-orange-500/5 to-transparent',
|
||||
accent: 'text-amber-600 dark:text-amber-400',
|
||||
border: 'border-amber-500/20',
|
||||
icon: Flame,
|
||||
};
|
||||
}
|
||||
if (cat.includes('bar') || cat.includes('beverage') || cat.includes('wine')) {
|
||||
return {
|
||||
bg: 'from-purple-500/10 via-violet-500/5 to-transparent',
|
||||
accent: 'text-purple-600 dark:text-purple-400',
|
||||
border: 'border-purple-500/20',
|
||||
icon: Wine,
|
||||
};
|
||||
}
|
||||
return {
|
||||
bg: 'from-emerald-500/10 via-teal-500/5 to-transparent',
|
||||
accent: 'text-emerald-600 dark:text-emerald-400',
|
||||
border: 'border-emerald-500/20',
|
||||
icon: ShieldCheck,
|
||||
};
|
||||
}
|
||||
|
||||
export function SkillCard({ course, onOpen }) {
|
||||
const status = statusOf(course);
|
||||
const type = typeOf(course);
|
||||
const ProofIcon = TYPE_ICON[type] || TYPE_ICON.roleplay;
|
||||
const criteria = owliverCriteria(course);
|
||||
const hasChallenge = Boolean(course.challenge);
|
||||
const catMeta = getCategoryMeta(course.category);
|
||||
const CatIcon = catMeta.icon;
|
||||
|
||||
const isPublished = status === 'published';
|
||||
|
||||
return (
|
||||
<article
|
||||
onClick={() => onOpen(course)}
|
||||
className="group relative flex h-full min-w-0 flex-col justify-between rounded-2xl border border-border/80 bg-surface p-4 sm:p-5 shadow-xs transition-all duration-300
|
||||
hover:-translate-y-1 hover:border-blue-500/40 hover:shadow-xl cursor-pointer overflow-hidden"
|
||||
>
|
||||
{/* Top Background Gradient Highlight */}
|
||||
<div className={cn('absolute inset-x-0 top-0 h-24 bg-gradient-to-b opacity-60 pointer-events-none transition-opacity group-hover:opacity-100', catMeta.bg)} />
|
||||
|
||||
{/* Main Body Area */}
|
||||
<div className="relative z-10 space-y-3">
|
||||
{/* Top Header Row: Category Badge (Left) & Publication Status Badge (Right) */}
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-1.5 min-w-0">
|
||||
<span className={cn('flex h-5 w-5 shrink-0 items-center justify-center rounded-md bg-surface border shadow-2xs', catMeta.border, catMeta.accent)}>
|
||||
<CatIcon className="h-3 w-3" />
|
||||
</span>
|
||||
<span className="truncate text-[11px] font-bold uppercase tracking-wider text-ink-3">
|
||||
{course.category}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<Badge
|
||||
variant={STATUS_TONE[status] || 'neutral'}
|
||||
size="sm"
|
||||
className={cn(
|
||||
'shrink-0 font-bold text-[10px] shadow-2xs',
|
||||
isPublished && 'bg-emerald-500/10 text-emerald-600 border-emerald-500/30 dark:bg-emerald-950/60 dark:text-emerald-400'
|
||||
)}
|
||||
>
|
||||
{status}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{/* Title & Difficulty Tag */}
|
||||
<div className="space-y-1">
|
||||
<h3 className="font-heading text-body-lg font-bold leading-snug text-ink-1 group-hover:text-blue-600 transition-colors">
|
||||
{course.title}
|
||||
</h3>
|
||||
|
||||
{course.difficulty && (
|
||||
<div className="flex items-center gap-1">
|
||||
<span className={cn('inline-flex items-center gap-1 text-[11px] font-semibold capitalize', DIFF_TONE[course.difficulty] || 'text-ink-3')}>
|
||||
<span className="h-1.5 w-1.5 rounded-full bg-current" aria-hidden="true" />
|
||||
{course.difficulty}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
<p className="line-clamp-2 text-body-sm leading-relaxed text-ink-3">
|
||||
{course.description}
|
||||
</p>
|
||||
|
||||
{/* Course Stats Pills */}
|
||||
<ul className="flex flex-wrap items-center gap-1.5 text-caption">
|
||||
{course.estimated_minutes > 0 && (
|
||||
<li className="inline-flex items-center gap-1 rounded-md bg-surface-subtle px-2 py-0.5 text-ink-2 font-medium border border-border/50 text-[11px]">
|
||||
<Clock className="h-3 w-3 text-ink-4" aria-hidden="true" />
|
||||
<span>{course.estimated_minutes} min</span>
|
||||
</li>
|
||||
)}
|
||||
<li className="inline-flex items-center gap-1 rounded-md bg-surface-subtle px-2 py-0.5 text-ink-2 font-medium border border-border/50 text-[11px]">
|
||||
<BookOpen className="h-3 w-3 text-ink-4" aria-hidden="true" />
|
||||
<span>{trainingType(course)}</span>
|
||||
</li>
|
||||
{hasChallenge && (
|
||||
<li className="inline-flex items-center gap-1 rounded-md bg-blue-50/60 dark:bg-blue-950/40 px-2 py-0.5 text-blue-700 dark:text-blue-400 font-semibold border border-blue-200/60 text-[11px]">
|
||||
<ProofIcon className="h-3 w-3" aria-hidden="true" />
|
||||
<span>{TYPE_LABEL[type]}</span>
|
||||
</li>
|
||||
)}
|
||||
</ul>
|
||||
|
||||
{/* Owliver Criteria AI Badge */}
|
||||
{criteria.length > 0 && (
|
||||
<div className="inline-flex items-center gap-1.5 rounded-lg bg-gradient-to-r from-blue-50/80 via-indigo-50/50 to-purple-50/30 dark:from-blue-950/40 dark:via-indigo-950/30 dark:to-purple-950/20 px-2 py-1 border border-blue-200/80 dark:border-blue-800/60">
|
||||
<Sparkles className="h-3.5 w-3.5 text-blue-600 dark:text-blue-400 shrink-0" aria-hidden="true" />
|
||||
<span className="text-[11px] font-semibold text-blue-900 dark:text-blue-300">
|
||||
Owliver evaluated
|
||||
</span>
|
||||
<span className="text-[11px] font-normal text-blue-700/80 dark:text-blue-400/80">
|
||||
· {criteria.length} {criteria.length === 1 ? 'criterion' : 'criteria'}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer Divider & Action Button */}
|
||||
<div className="relative z-10 mt-4 flex items-center justify-between gap-2 border-t border-border/60 pt-3 font-medium">
|
||||
{course.proof_skill ? (
|
||||
<span
|
||||
title={`Verifies ${course.proof_skill}`}
|
||||
className="min-w-0 flex-1 truncate text-[10px] font-bold uppercase tracking-wider text-ink-4 flex items-center gap-1"
|
||||
>
|
||||
<CheckCircle2 className="h-3 w-3 text-emerald-500 shrink-0" />
|
||||
<span className="truncate">Verifies {course.proof_skill}</span>
|
||||
</span>
|
||||
) : <span />}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onOpen(course);
|
||||
}}
|
||||
className="inline-flex shrink-0 items-center gap-1 rounded-lg bg-blue-50 dark:bg-blue-950 px-2.5 py-1 text-caption font-bold text-blue-600 dark:text-blue-400 transition-all hover:bg-blue-600 hover:text-white group-hover:shadow-sm"
|
||||
>
|
||||
View training
|
||||
<ArrowRight
|
||||
className="h-3 w-3 transition-transform duration-200 group-hover:translate-x-1"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
184
src/components/forge/SkillDetail.jsx
Normal file
184
src/components/forge/SkillDetail.jsx
Normal file
@@ -0,0 +1,184 @@
|
||||
import React from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { ArrowRight, Check, Sparkles } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Badge, Drawer } from '@/components/ds';
|
||||
import {
|
||||
DIFF_TONE, owliverCriteria, statusOf, STATUS_TONE, trainingKinds, trainingOutline,
|
||||
TYPE_HINT, TYPE_LABEL, typeOf,
|
||||
} from './challengeMeta';
|
||||
|
||||
/**
|
||||
* One skill, read end to end: what it teaches, how it is proven, what Owliver
|
||||
* checks, what passing means, and how the workforce has done with it.
|
||||
*
|
||||
* A drawer rather than a route, because this is the managing view of a record
|
||||
* whose route already belongs to something else — `/admin/university/:id` runs
|
||||
* the challenge itself, which is the worker's experience and stays untouched.
|
||||
* The link out to it is offered at the bottom for an admin who wants to see what
|
||||
* the workforce sees.
|
||||
*
|
||||
* Sections appear only when the record has something to put in them. Reading a
|
||||
* heading with nothing under it teaches the reader to distrust the rest.
|
||||
*/
|
||||
export function SkillDetail({ course, open, onOpenChange, usage }) {
|
||||
if (!course) return null;
|
||||
|
||||
const status = statusOf(course);
|
||||
const type = typeOf(course);
|
||||
const criteria = owliverCriteria(course);
|
||||
const outline = trainingOutline(course);
|
||||
const kinds = trainingKinds(course);
|
||||
const rubric = course.challenge?.rubric || [];
|
||||
|
||||
return (
|
||||
<Drawer
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
size="lg"
|
||||
title={course.title}
|
||||
description={`${course.category}${course.difficulty ? ` · ${course.difficulty}` : ''}`}
|
||||
>
|
||||
<div className="space-y-6 px-5 py-5">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Badge variant={STATUS_TONE[status] || 'neutral'} size="sm">{status}</Badge>
|
||||
{course.difficulty && (
|
||||
<span className={cn('text-caption font-semibold capitalize', DIFF_TONE[course.difficulty] || 'text-ink-3')}>
|
||||
{course.difficulty}
|
||||
</span>
|
||||
)}
|
||||
{course.estimated_minutes > 0 && (
|
||||
<span className="text-caption text-ink-4">{course.estimated_minutes} min</span>
|
||||
)}
|
||||
{course.proof_skill && (
|
||||
<span className="text-caption text-ink-4">· Verifies {course.proof_skill}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{course.description && (
|
||||
<p className="text-body-sm leading-relaxed text-ink-2">{course.description}</p>
|
||||
)}
|
||||
|
||||
<Block title="Training" meta="What the workforce learns">
|
||||
{outline.length > 0 ? (
|
||||
<ol className="space-y-2">
|
||||
{outline.map((step, i) => (
|
||||
<li key={step} className="flex gap-3 text-body-sm text-ink-2">
|
||||
<span className="mt-0.5 grid h-5 w-5 shrink-0 place-items-center rounded-full bg-krow-blue-tint text-[10px] font-bold tabular-nums text-krow-blue">
|
||||
{i + 1}
|
||||
</span>
|
||||
<span className="min-w-0">{step}</span>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
) : (
|
||||
<p className="text-body-sm leading-relaxed text-ink-3">
|
||||
This skill carries no written outline. It teaches through
|
||||
{' '}
|
||||
{kinds.length ? kinds.join(' and ').toLowerCase() : 'the challenge itself'}.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{course.quiz?.length > 0 && (
|
||||
<p className="mt-3 text-caption text-ink-4">
|
||||
Includes a {course.quiz.length}-question knowledge check.
|
||||
</p>
|
||||
)}
|
||||
</Block>
|
||||
|
||||
{course.challenge && (
|
||||
<Block title="Proof" meta="How the workforce demonstrates the skill">
|
||||
<p className="text-body-sm font-semibold text-ink-1">{TYPE_LABEL[type]}</p>
|
||||
<p className="mt-1 text-body-sm leading-relaxed text-ink-3">{TYPE_HINT[type]}</p>
|
||||
{course.challenge.prompt && (
|
||||
<p className="mt-3 rounded-lg border border-border bg-surface-subtle p-3 text-body-sm italic leading-relaxed text-ink-2">
|
||||
“{course.challenge.prompt}”
|
||||
</p>
|
||||
)}
|
||||
</Block>
|
||||
)}
|
||||
|
||||
{criteria.length > 0 && (
|
||||
<Block
|
||||
title="Owliver evaluation"
|
||||
meta="What Owliver checks"
|
||||
icon={Sparkles}
|
||||
>
|
||||
<ul className="space-y-2">
|
||||
{rubric.map((r) => (
|
||||
<li key={r.criterion} className="flex items-baseline justify-between gap-3 text-body-sm">
|
||||
<span className="flex min-w-0 items-baseline gap-2 text-ink-2">
|
||||
<Check className="h-3.5 w-3.5 shrink-0 translate-y-0.5 text-krow-blue" aria-hidden="true" />
|
||||
<span className="min-w-0">{r.criterion}</span>
|
||||
</span>
|
||||
{r.weight != null && (
|
||||
<span className="shrink-0 text-caption tabular-nums text-ink-4">{r.weight}%</span>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</Block>
|
||||
)}
|
||||
|
||||
{course.pass_score > 0 && (
|
||||
<Block title="Verification" meta="What qualifies as passing">
|
||||
<p className="text-body-sm leading-relaxed text-ink-2">
|
||||
A submission verifies the skill at{' '}
|
||||
<span className="font-semibold text-ink-1">{course.pass_score} or above</span>
|
||||
{course.badge_reward
|
||||
? <>, and earns the <span className="font-semibold text-ink-1">{course.badge_reward}</span> badge.</>
|
||||
: '.'}
|
||||
</p>
|
||||
</Block>
|
||||
)}
|
||||
|
||||
{/* Usage is counted from worker profiles. Assignment and "in progress"
|
||||
have no field behind them, so the two states the records do carry are
|
||||
the two states shown. */}
|
||||
{usage && (usage.completed > 0 || usage.verified > 0) && (
|
||||
<Block title="Workforce usage" meta="From worker profiles">
|
||||
<dl className="grid grid-cols-2 gap-3">
|
||||
{[
|
||||
{ label: 'Completed', value: usage.completed },
|
||||
{ label: 'Verified', value: usage.verified },
|
||||
].map((row) => (
|
||||
<div key={row.label} className="rounded-lg border border-border bg-surface-subtle px-3 py-2.5">
|
||||
<dt className="text-[10px] font-bold uppercase tracking-wider text-ink-4">{row.label}</dt>
|
||||
<dd className="mt-1 font-heading text-title font-bold tabular-nums text-ink-1">{row.value}</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
</Block>
|
||||
)}
|
||||
|
||||
<Link
|
||||
to={`/admin/university/${course.id}`}
|
||||
className="group inline-flex items-center gap-1.5 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"
|
||||
>
|
||||
Open the challenge as the workforce sees it
|
||||
<ArrowRight
|
||||
className="h-3.5 w-3.5 transition-transform duration-200 group-hover:translate-x-0.5"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</Link>
|
||||
</div>
|
||||
</Drawer>
|
||||
);
|
||||
}
|
||||
|
||||
/** A titled region of the record. */
|
||||
function Block({ title, meta, icon: Icon, children }) {
|
||||
return (
|
||||
<section className="border-t border-border pt-4 first:border-0 first:pt-0">
|
||||
<div className="mb-2.5 flex items-baseline gap-2">
|
||||
<h3 className="inline-flex items-center gap-1.5 font-heading text-overline uppercase tracking-wider text-ink-4">
|
||||
{Icon && <Icon className="h-3 w-3 text-krow-blue" aria-hidden="true" />}
|
||||
{title}
|
||||
</h3>
|
||||
{meta && <span className="min-w-0 truncate text-caption text-ink-4">{meta}</span>}
|
||||
</div>
|
||||
{children}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
171
src/components/forge/SkillLibrary.jsx
Normal file
171
src/components/forge/SkillLibrary.jsx
Normal file
@@ -0,0 +1,171 @@
|
||||
import React from 'react';
|
||||
import { Plus, Search } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { SkillCard } from './SkillCard';
|
||||
|
||||
/**
|
||||
* The Skill Training Library — the working surface of admin Forge.
|
||||
*
|
||||
* A grid of skills rather than a queue of challenges: the reader is choosing
|
||||
* which skill to inspect or extend, not which one to attempt next. Filters use
|
||||
* the same underlined-control language as the rest of Forge, so the section
|
||||
* needs no toolbar surface of its own.
|
||||
*
|
||||
* The category and status options are computed by the page from the records that
|
||||
* exist, so the library never offers a filter that can only return nothing.
|
||||
*/
|
||||
/**
|
||||
* Card columns are sized off this column, not the viewport. With Owliver open
|
||||
* the page is wide while the library is not, and a viewport breakpoint put three
|
||||
* cards into seven hundred pixels — `auto-fill` asks the container instead.
|
||||
*/
|
||||
const CARD_GRID = 'grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4';
|
||||
|
||||
export function SkillLibrary({
|
||||
results,
|
||||
total,
|
||||
search,
|
||||
onSearch,
|
||||
category,
|
||||
onCategory,
|
||||
categories,
|
||||
status,
|
||||
onStatus,
|
||||
statuses,
|
||||
loading,
|
||||
filtered,
|
||||
onClear,
|
||||
onOpen,
|
||||
onCreate,
|
||||
sectionRef,
|
||||
}) {
|
||||
return (
|
||||
<section aria-labelledby="skill-library" className="min-w-0" ref={sectionRef}>
|
||||
<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="skill-library" className="font-heading text-title font-bold text-ink-1">
|
||||
Skill Training Library
|
||||
</h2>
|
||||
<p className="mt-1 text-body-sm text-ink-3">
|
||||
Build practical training and challenges for the skills your workforce needs.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex min-w-0 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 skills"
|
||||
aria-label="Search skills"
|
||||
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>
|
||||
|
||||
<FilterGroup
|
||||
label="Skill category"
|
||||
options={categories}
|
||||
value={category}
|
||||
onChange={onCategory}
|
||||
/>
|
||||
<FilterGroup
|
||||
label="Publication status"
|
||||
options={statuses}
|
||||
value={status}
|
||||
onChange={onStatus}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center justify-between gap-3 py-2.5">
|
||||
<p className="text-caption tabular-nums text-ink-4">
|
||||
{results.length} of {total} skills{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={CARD_GRID}>
|
||||
{[...Array(3)].map((_, i) => (
|
||||
<div key={i} className="h-56 animate-pulse rounded-xl bg-surface-sunken" />
|
||||
))}
|
||||
</div>
|
||||
) : results.length === 0 ? (
|
||||
<div className="rounded-xl border border-dashed border-border py-12 text-center">
|
||||
<p className="font-heading text-body font-semibold text-ink-1">
|
||||
{filtered ? 'No skills match these filters' : 'No skills in the library yet'}
|
||||
</p>
|
||||
<p className="mx-auto mt-1 max-w-sm text-body-sm leading-relaxed text-ink-3">
|
||||
{filtered
|
||||
? 'Clear the filters to see the full library.'
|
||||
: 'A skill defines what the workforce learns, how they prove it, and what Owliver checks.'}
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={filtered ? onClear : onCreate}
|
||||
className="mt-4 inline-flex items-center gap-2 rounded-xl bg-blue-600 px-4 py-2 text-caption font-semibold text-white shadow-md shadow-blue-500/20 transition-all hover:bg-blue-700 active:scale-[0.98]
|
||||
focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-krow-blue/50"
|
||||
>
|
||||
{filtered ? 'Clear filters' : (
|
||||
<>
|
||||
<Plus className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
Add Skill Training
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className={CARD_GRID}>
|
||||
{results.map((course) => (
|
||||
<SkillCard key={course.id} course={course} onOpen={onOpen} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
/** One radio row of filter options, styled as the Forge underline control. */
|
||||
function FilterGroup({ label, options, value, onChange }) {
|
||||
if (options.length <= 1) return null;
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-3" role="radiogroup" aria-label={label}>
|
||||
{options.map((o) => {
|
||||
const active = o.value === value;
|
||||
return (
|
||||
<button
|
||||
key={o.value}
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={active}
|
||||
onClick={() => onChange(o.value)}
|
||||
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'
|
||||
)}
|
||||
>
|
||||
{o.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,127 +1,132 @@
|
||||
import React from 'react';
|
||||
import { ArrowRight, Check } from 'lucide-react';
|
||||
import { ArrowRight, Award, Check, ChevronRight, Compass, FileText, ShieldCheck, Sparkles } 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.
|
||||
* Your Forge — Modern Process Workflow Cards & Skill Verification Roster
|
||||
*/
|
||||
|
||||
const STEPS = ['Take a challenge', 'Submit proof', 'Owliver evaluates', 'Skill verified'];
|
||||
const STEP_ITEMS = [
|
||||
{ step: '01', title: 'Take a challenge', sub: 'Pick a skill challenge', icon: Compass },
|
||||
{ step: '02', title: 'Submit proof', sub: 'Upload work evidence', icon: FileText },
|
||||
{ step: '03', title: 'Owliver evaluates', sub: 'AI scoring & review', icon: Sparkles },
|
||||
{ step: '04', title: 'Skill verified', sub: 'Earn verified badge', icon: ShieldCheck },
|
||||
];
|
||||
|
||||
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>
|
||||
<section aria-labelledby="your-forge" className="space-y-4 rounded-2xl border border-border/80 bg-surface p-5 sm:p-6 shadow-sm">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3 border-b border-border/50 pb-4">
|
||||
<div>
|
||||
<div className="flex items-center gap-2.5">
|
||||
<h2 id="your-forge" className="font-heading text-title font-bold text-ink-1">
|
||||
Your Verified Skills
|
||||
</h2>
|
||||
<span className="inline-flex items-center rounded-full bg-blue-50 px-2.5 py-0.5 text-[11px] font-bold text-blue-600 dark:bg-blue-950 dark:text-blue-400 border border-blue-200/60 dark:border-blue-800/40">
|
||||
{skills.length} {skills.length === 1 ? 'Skill' : 'Skills'}
|
||||
</span>
|
||||
</div>
|
||||
<p className="mt-1 text-caption text-ink-3">
|
||||
{empty
|
||||
? 'Your first verified skill starts with a real-world challenge.'
|
||||
: `${evidenceCount} ${evidenceCount === 1 ? 'piece' : 'pieces'} of evidence on file`}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={onBrowse}
|
||||
className="group inline-flex items-center gap-2 rounded-xl bg-blue-600 px-4 py-2 text-caption font-semibold text-white shadow-md shadow-blue-500/20 hover:bg-blue-700 transition-all active:scale-[0.98]"
|
||||
>
|
||||
<span>Browse Challenges</span>
|
||||
<ArrowRight className="h-3.5 w-3.5 transition-transform duration-200 group-hover:translate-x-1" />
|
||||
</button>
|
||||
</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>
|
||||
/* 4 Process Cards Workflow */
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-4 pt-2">
|
||||
{STEP_ITEMS.map((item, i) => {
|
||||
const Icon = item.icon;
|
||||
const isFirst = i === 0;
|
||||
|
||||
{i < STEPS.length - 1 && (
|
||||
<span
|
||||
className="hidden h-px flex-1 bg-border sm:block sm:min-w-[1rem]"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
return (
|
||||
<div
|
||||
key={item.step}
|
||||
className={cn(
|
||||
'group relative flex flex-col justify-between rounded-xl border p-4 transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md',
|
||||
isFirst
|
||||
? 'border-blue-500/40 bg-gradient-to-br from-blue-50/70 via-surface to-blue-50/20 dark:from-blue-950/40 dark:to-surface'
|
||||
: 'border-border/80 bg-surface-subtle/50 hover:bg-surface'
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
>
|
||||
<div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span
|
||||
className={cn(
|
||||
'text-[10px] font-extrabold uppercase tracking-wider',
|
||||
isFirst ? 'text-blue-600 dark:text-blue-400' : 'text-ink-4'
|
||||
)}
|
||||
>
|
||||
Step {item.step}
|
||||
</span>
|
||||
<div
|
||||
className={cn(
|
||||
'flex h-7 w-7 items-center justify-center rounded-lg shadow-xs',
|
||||
isFirst
|
||||
? 'bg-blue-600 text-white shadow-blue-500/25'
|
||||
: 'bg-surface text-ink-3 border border-border'
|
||||
)}
|
||||
>
|
||||
<Icon className="h-3.5 w-3.5" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
<h4 className="mt-3 font-heading text-body-sm font-bold text-ink-1">
|
||||
{item.title}
|
||||
</h4>
|
||||
<p className="mt-0.5 text-caption text-ink-3">{item.sub}</p>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex items-center justify-between text-caption font-semibold">
|
||||
<span className={isFirst ? 'text-blue-600 dark:text-blue-400' : 'text-ink-4'}>
|
||||
{isFirst ? 'Start Here' : 'Upcoming'}
|
||||
</span>
|
||||
{i < STEP_ITEMS.length - 1 && (
|
||||
<ChevronRight className="h-4 w-4 text-ink-4 hidden lg:block group-hover:translate-x-1 transition-transform" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<ul className="mt-4 flex flex-wrap items-center gap-x-8 gap-y-3">
|
||||
/* Verified Skills List */
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3 pt-2">
|
||||
{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"
|
||||
<div
|
||||
key={badge.name || i}
|
||||
className="flex items-center justify-between rounded-xl border border-emerald-500/30 bg-emerald-50/40 dark:bg-emerald-950/20 p-3.5 shadow-xs"
|
||||
>
|
||||
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>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-9 w-9 items-center justify-center rounded-lg bg-emerald-600 text-white shadow-md shadow-emerald-500/20">
|
||||
<Award className="h-5 w-5" />
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="font-heading text-body-sm font-bold text-ink-1">
|
||||
{badge.name}
|
||||
</h4>
|
||||
<p className="text-caption text-emerald-700 dark:text-emerald-400 font-medium capitalize">
|
||||
{badge.level || 'Verified'} · {formatDate?.(badge.earned_date) || 'Earned'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Check className="h-4 w-4 text-emerald-600" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
|
||||
@@ -90,3 +90,85 @@ export function proofSteps(course) {
|
||||
const criteria = (course?.challenge?.rubric || []).map((r) => r.criterion).filter(Boolean);
|
||||
return criteria.length ? [...criteria, 'Submit your proof'] : [];
|
||||
}
|
||||
|
||||
/* ── The same records, read as an administrator ─────────────────────────── */
|
||||
|
||||
/**
|
||||
* Everything below reads the Course entity from the managing side rather than
|
||||
* the doing side. The worker asks "what do I have to prove"; the admin asks
|
||||
* "what does this skill teach, how is it proven, what does Owliver check, and is
|
||||
* it live". Same fields, different question — which is why these are derivations
|
||||
* here rather than a second shape stored somewhere.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Publication state.
|
||||
*
|
||||
* The seed calls a live skill `active`; the admin vocabulary is Draft /
|
||||
* Published / Archived. Mapping rather than renaming keeps every existing record
|
||||
* and every existing `status === 'active'` filter in the app working.
|
||||
*/
|
||||
export const STATUS_LABEL = {
|
||||
active: 'Published',
|
||||
published: 'Published',
|
||||
draft: 'Draft',
|
||||
archived: 'Archived',
|
||||
inactive: 'Archived',
|
||||
};
|
||||
|
||||
export const STATUS_TONE = {
|
||||
Published: 'success',
|
||||
Draft: 'warning',
|
||||
Archived: 'neutral',
|
||||
};
|
||||
|
||||
export const statusOf = (course) => STATUS_LABEL[course?.status] || 'Draft';
|
||||
|
||||
/** The criteria Owliver scores a submission against. */
|
||||
export const owliverCriteria = (course) =>
|
||||
(course?.challenge?.rubric || []).map((r) => r.criterion).filter(Boolean);
|
||||
|
||||
/**
|
||||
* What the workforce actually studies.
|
||||
*
|
||||
* `training_outline` is written by the Forge authoring flow. Skills that predate
|
||||
* it have none, and the detail view says so rather than inventing a curriculum
|
||||
* from the description.
|
||||
*/
|
||||
export const trainingOutline = (course) =>
|
||||
(Array.isArray(course?.training_outline) ? course.training_outline : []).filter(Boolean);
|
||||
|
||||
/**
|
||||
* The kinds of training material a skill carries, named only where the record
|
||||
* actually has it. Nothing is listed on the strength of the entity *supporting*
|
||||
* a field — only on it being filled in.
|
||||
*/
|
||||
export function trainingKinds(course) {
|
||||
const kinds = [];
|
||||
if (trainingOutline(course).length) kinds.push('Instructions');
|
||||
if (course?.quiz?.length) kinds.push('Quiz');
|
||||
if (course?.challenge?.prompt) kinds.push('Practice');
|
||||
return kinds;
|
||||
}
|
||||
|
||||
/** The one-word training type a card shows. */
|
||||
export const trainingType = (course) => trainingKinds(course)[0] || 'Practice';
|
||||
|
||||
/**
|
||||
* How a skill is doing across the workforce, counted from the worker profiles
|
||||
* the platform already holds.
|
||||
*
|
||||
* Only completion and verification are counted, because those are the two states
|
||||
* a profile records. Assignment and "started" have no field behind them, so they
|
||||
* are absent here rather than estimated.
|
||||
*/
|
||||
export function workforceUsage(course, profiles = []) {
|
||||
if (!course) return { completed: 0, verified: 0 };
|
||||
let completed = 0;
|
||||
let verified = 0;
|
||||
for (const p of profiles) {
|
||||
if ((p.completed_courses || []).some((c) => c.course_id === course.id)) completed += 1;
|
||||
if ((p.earned_badges || []).some((b) => b.course_id === course.id)) verified += 1;
|
||||
}
|
||||
return { completed, verified };
|
||||
}
|
||||
|
||||
@@ -1,24 +1,47 @@
|
||||
/**
|
||||
* Forge components — the pieces the KROW Forge workspace is composed from.
|
||||
* Forge components.
|
||||
*
|
||||
* The page tells one story and each part has a different shape, so the reader is
|
||||
* never looking at the same rectangle twice:
|
||||
* Two audiences read the same records, so the folder holds two sets of pieces.
|
||||
*
|
||||
* 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
|
||||
* Admin — building and managing workforce skill verification:
|
||||
*
|
||||
* All state, data and the unlock gate stay with the page; these only arrange it.
|
||||
* ForgeHeader the masthead and the two ways in
|
||||
* ForgeProgress the state of the library, in four counted figures
|
||||
* SkillLibrary every skill, filterable
|
||||
* SkillCard one skill and its verification journey
|
||||
* SkillDetail one skill read end to end
|
||||
* AddSkillTraining skill → training → proof → Owliver → publish
|
||||
* ForgeRail the authoring job, the rules, and the way to Owliver
|
||||
*
|
||||
* Worker — proving a skill. Kept whole: these are what the employee experience
|
||||
* is built from, and reframing the admin console is no reason to remove them.
|
||||
*
|
||||
* YourForge the record, or the process that creates it
|
||||
* NextSkill the skill to prove now
|
||||
* ChallengeDirectory every other challenge, searchable
|
||||
* ForgeInsight a rule and a remark
|
||||
*
|
||||
* State and derivation stay with the page; these only arrange what they are
|
||||
* given. `challengeMeta` holds the lookups both audiences share.
|
||||
*/
|
||||
|
||||
/* Admin */
|
||||
export { ForgeHeader } from './ForgeHeader';
|
||||
export { ForgeProgress } from './ForgeProgress';
|
||||
export { SkillLibrary } from './SkillLibrary';
|
||||
export { SkillCard } from './SkillCard';
|
||||
export { SkillDetail } from './SkillDetail';
|
||||
export { AddSkillTraining } from './AddSkillTraining';
|
||||
export { ForgeRail } from './ForgeRail';
|
||||
|
||||
/* Worker */
|
||||
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,
|
||||
buildRequirements, DIFF_TONE, owliverCriteria, proofSteps, statusOf, STATUS_LABEL, STATUS_TONE,
|
||||
trainingKinds, trainingOutline, trainingType, TYPE_HINT, TYPE_ICON, TYPE_LABEL, typeOf,
|
||||
workforceUsage,
|
||||
} from './challengeMeta';
|
||||
|
||||
@@ -2,7 +2,7 @@ import React, { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { DropdownMenu, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuItem } from '@/components/ui/dropdown-menu';
|
||||
import { MessageSquare, Phone, Calendar, X, Trash2, ChevronDown, UserCheck, Briefcase, ExternalLink } from 'lucide-react';
|
||||
import { MessageSquare, Phone, Calendar, X, Trash2, ChevronDown, UserCheck, Briefcase } from 'lucide-react';
|
||||
import { Image } from '@/components/ui/image';
|
||||
import { getScoreBand } from '@/lib/talentHome';
|
||||
import CandidateExpandedDetails from '@/components/krow/CandidateExpandedDetails';
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Lock, ShieldCheck, Zap, Clock, ArrowRight, Target } from 'lucide-react';
|
||||
import { Lock, ShieldCheck, Zap, Clock, ArrowRight } from 'lucide-react';
|
||||
import { isUnlocked } from '@/lib/provingGround';
|
||||
|
||||
const DIFF_STYLES = {
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
import React from 'react';
|
||||
import { Shield, Star, TrendingUp } from 'lucide-react';
|
||||
import { Gauge, gaugeClasses } from '@mui/x-charts/Gauge';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { getScoreBand } from '@/lib/talentHome';
|
||||
import ReferencesEndorsements from '@/components/krow/ReferencesEndorsements';
|
||||
|
||||
const R = 44;
|
||||
const STROKE = 8;
|
||||
const CIRC = 2 * Math.PI * R;
|
||||
const FICO_MIN = 300;
|
||||
const FICO_MAX = 850;
|
||||
|
||||
@@ -29,51 +28,108 @@ function nextTierFromApp(app) {
|
||||
return 'Beginner';
|
||||
}
|
||||
|
||||
function CareerScoreMini({ application }) {
|
||||
/**
|
||||
* The score, its band, and what both are worth — one block, read top to bottom.
|
||||
*
|
||||
* The gauge is the anchor rather than an ornament beside the numbers, so it is
|
||||
* sized to be the first thing seen and carries the score inside it: label, value
|
||||
* and range stacked in the arc's own empty middle, which is the only place where
|
||||
* "834" and "300–850" read as one statement instead of two.
|
||||
*
|
||||
* Everything to the right is interpretation — where this person sits and where
|
||||
* they go next — kept at a subordinate weight so the eye lands on the score
|
||||
* first and travels outward.
|
||||
*/
|
||||
const GAUGE_W = 184;
|
||||
|
||||
function CareerScorePrimary({ application }) {
|
||||
const score = careerScoreFromApp(application);
|
||||
const pct = Math.max(0, Math.min(1, (score - FICO_MIN) / (FICO_MAX - FICO_MIN)));
|
||||
const dash = pct * CIRC;
|
||||
const band = getScoreBand(Math.round(application?.ai_score || 0));
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-5 bg-white/70 rounded-xl border border-[#E5E7EB] px-4 py-3">
|
||||
<div className="flex flex-col items-center shrink-0">
|
||||
<div className="relative" style={{ width: R * 2 + STROKE, height: R * 2 + STROKE }}>
|
||||
<svg width={R * 2 + STROKE} height={R * 2 + STROKE} className="-rotate-90">
|
||||
<circle cx={R + STROKE / 2} cy={R + STROKE / 2} r={R} fill="none" stroke="#EFF6FF" strokeWidth={STROKE} />
|
||||
<circle
|
||||
cx={R + STROKE / 2}
|
||||
cy={R + STROKE / 2}
|
||||
r={R}
|
||||
fill="none"
|
||||
stroke="#0A39DF"
|
||||
strokeWidth={STROKE}
|
||||
strokeLinecap="round"
|
||||
strokeDasharray={`${dash} ${CIRC}`}
|
||||
/>
|
||||
</svg>
|
||||
<div className="absolute inset-0 flex flex-col items-center justify-center">
|
||||
<span className="text-[8px] uppercase tracking-wide text-[#94A3B8] font-medium">Career Score</span>
|
||||
<span className="text-[22px] font-bold text-[#0A39DF] leading-none">{score}</span>
|
||||
<span className="text-[8px] text-[#94A3B8] font-medium">300-850</span>
|
||||
<div className="grid gap-x-7 gap-y-5 sm:grid-cols-[auto_minmax(0,1fr)] sm:items-center">
|
||||
{/* Layer 1 — the primary signal */}
|
||||
<div className="flex flex-col items-center gap-2.5">
|
||||
<div
|
||||
className="relative shrink-0"
|
||||
style={{ width: GAUGE_W, height: Math.round(GAUGE_W * 0.577) }}
|
||||
>
|
||||
<Gauge
|
||||
value={score}
|
||||
valueMin={FICO_MIN}
|
||||
valueMax={FICO_MAX}
|
||||
startAngle={-90}
|
||||
endAngle={90}
|
||||
innerRadius="89%"
|
||||
outerRadius="100%"
|
||||
cornerRadius="50%"
|
||||
text={() => null}
|
||||
skipAnimation
|
||||
aria-hidden="true"
|
||||
sx={{
|
||||
width: GAUGE_W,
|
||||
height: GAUGE_W,
|
||||
marginTop: `-${Math.round(GAUGE_W * 0.215)}px`,
|
||||
/* Brand blue for the score it earned, a hairline-weight neutral
|
||||
for the rest of the range — the same pairing the rest of the
|
||||
product uses for "achieved against available". */
|
||||
[`& .${gaugeClasses.valueArc}`]: { fill: '#0A39DF' },
|
||||
[`& .${gaugeClasses.referenceArc}`]: { fill: 'hsl(var(--border))' },
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* The arc's middle is empty by construction; the score belongs in it.
|
||||
Stacked with room between each line so the label never crowds the
|
||||
number it is labelling. */}
|
||||
<div
|
||||
className="pointer-events-none absolute inset-x-0 bottom-0 flex flex-col items-center justify-end pb-1"
|
||||
role="img"
|
||||
aria-label={`Career score ${score} of a ${FICO_MIN} to ${FICO_MAX} range. Level: ${band.label}.`}
|
||||
>
|
||||
<span className="text-[9px] font-semibold uppercase leading-none tracking-[0.08em] text-ink-4">
|
||||
Career score
|
||||
</span>
|
||||
<span className="mt-1.5 font-heading text-[32px] font-bold leading-none tabular-nums text-krow-blue">
|
||||
{score}
|
||||
</span>
|
||||
<span className="mt-1.5 text-[10px] font-medium leading-none tabular-nums text-ink-4">
|
||||
{FICO_MIN}–{FICO_MAX}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<span
|
||||
className="mt-2 inline-flex items-center justify-center text-[11px] font-bold text-white px-3 py-1 rounded-full w-full"
|
||||
style={{ backgroundColor: band.color }}
|
||||
>
|
||||
|
||||
{/* Layer 2 — the band this score falls in. A status marker, sized like
|
||||
one: the colour is the band's own, carried by a dot rather than by a
|
||||
filled pill the width of the column. */}
|
||||
<Badge variant="outline" size="lg" className="gap-1.5">
|
||||
<span
|
||||
className="h-1.5 w-1.5 rounded-full"
|
||||
style={{ backgroundColor: band.color }}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{band.label}
|
||||
</span>
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="flex-1 space-y-3">
|
||||
|
||||
{/* Layer 2 — what the score is worth, and what comes next. */}
|
||||
<dl className="grid gap-x-6 gap-y-4 min-[420px]:grid-cols-2 sm:grid-cols-1 lg:grid-cols-2">
|
||||
<div>
|
||||
<p className="text-[10px] uppercase tracking-wide text-[#9CA3AF] font-medium">Potential Market Value</p>
|
||||
<p className="text-[16px] font-bold text-[#111827]">{marketRangeFromApp(application)}</p>
|
||||
<dt className="text-[10px] font-semibold uppercase tracking-[0.06em] text-ink-4">
|
||||
Potential market value
|
||||
</dt>
|
||||
<dd className="mt-1 font-heading text-body-lg font-bold text-ink-1">
|
||||
{marketRangeFromApp(application)}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-[10px] uppercase tracking-wide text-[#9CA3AF] font-medium">Next Promotion</p>
|
||||
<p className="text-[14px] font-semibold text-[#0F172A]">{nextTierFromApp(application)}</p>
|
||||
<dt className="text-[10px] font-semibold uppercase tracking-[0.06em] text-ink-4">
|
||||
Next promotion
|
||||
</dt>
|
||||
<dd className="mt-1 font-heading text-body font-semibold text-ink-1">
|
||||
{nextTierFromApp(application)}
|
||||
</dd>
|
||||
</div>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -108,15 +164,26 @@ const initialsFor = (name) => {
|
||||
return (parts[0][0] + parts[1][0]).toUpperCase();
|
||||
};
|
||||
|
||||
function StatTile({ label, value, suffix, accent }) {
|
||||
const text = value != null && value !== '' ? value : '—';
|
||||
/**
|
||||
* A supporting signal — evidence for the score, not a headline of its own.
|
||||
*
|
||||
* Label and value on one line, no border, no tile: five bordered boxes under the
|
||||
* gauge read as five more metrics competing with it, which is exactly the
|
||||
* hierarchy this card is meant to establish. Values share one ink colour so no
|
||||
* single signal shouts louder than the rest.
|
||||
*/
|
||||
function SupportingSignal({ label, value, suffix, icon: Icon }) {
|
||||
const shown = value != null && value !== '' ? value : '—';
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center gap-1 px-2 py-3 rounded-xl bg-white/70 border border-[#E5E7EB]">
|
||||
<div className="text-[18px] font-bold leading-none" style={{ color: accent }}>
|
||||
{text}
|
||||
{suffix && <span className="text-[12px] font-semibold ml-0.5">{suffix}</span>}
|
||||
</div>
|
||||
<div className="text-[10px] text-[#6B7280] tracking-wide font-medium">{label}</div>
|
||||
<div className="flex items-baseline justify-between gap-3">
|
||||
<dt className="truncate text-caption text-ink-3">{label}</dt>
|
||||
<dd className="flex shrink-0 items-center font-heading text-body-sm font-bold tabular-nums text-ink-1">
|
||||
{Icon && <Icon className="mr-1 h-3 w-3 text-krow-yellow" fill="currentColor" aria-hidden="true" />}
|
||||
<span>
|
||||
{shown}
|
||||
{suffix && shown !== '—' && <span className="text-caption font-semibold">{suffix}</span>}
|
||||
</span>
|
||||
</dd>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -137,37 +204,35 @@ export default function WorkforceReputation({ application }) {
|
||||
if (!hasData) return null;
|
||||
|
||||
return (
|
||||
<div className="rounded-2xl border border-[#E5E7EB] bg-[#F8FAFC] p-4">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<div className="w-7 h-7 rounded-lg bg-[#0A39DF] flex items-center justify-center">
|
||||
<Shield className="w-4 h-4 text-white" strokeWidth={2.5} />
|
||||
<div className="rounded-2xl border border-border bg-surface px-4 py-4">
|
||||
<div className="mb-4 flex items-center gap-2">
|
||||
<div className="flex h-7 w-7 items-center justify-center rounded-lg bg-krow-blue">
|
||||
<Shield className="h-4 w-4 text-white" strokeWidth={2.5} />
|
||||
</div>
|
||||
<h4 className="text-[12px] font-bold text-[#111827] tracking-wide flex items-center gap-1.5">
|
||||
<h4 className="flex items-center gap-1.5 text-[12px] font-bold tracking-wide text-ink-1">
|
||||
WORKFORCE REPUTATION
|
||||
<TrendingUp className="w-3.5 h-3.5 text-[#0A39DF]" />
|
||||
<TrendingUp className="h-3.5 w-3.5 text-krow-blue" />
|
||||
</h4>
|
||||
</div>
|
||||
|
||||
<CareerScoreMini application={application} />
|
||||
<CareerScorePrimary application={application} />
|
||||
|
||||
<div className="grid grid-cols-2 sm:grid-cols-5 gap-2">
|
||||
<StatTile label="Reliability" value={reliability != null ? reliability : null} suffix="%" accent="#0A39DF" />
|
||||
<StatTile label="Attendance" value={attendance != null ? attendance : null} suffix="%" accent="#059669" />
|
||||
<StatTile label="Leadership" value={leadership != null ? leadership : null} suffix="%" accent="#0A39DF" />
|
||||
<StatTile label="Communication" value={commText} accent="#F59E0B" />
|
||||
<div className="flex flex-col items-center justify-center gap-1 px-2 py-3 rounded-xl bg-white/70 border border-[#E5E7EB]">
|
||||
<div className="flex items-center gap-0.5">
|
||||
<Star className="w-3.5 h-3.5 text-[#F9E547] fill-[#F9E547]" />
|
||||
<span className="text-[18px] font-bold leading-none text-[#111827]">
|
||||
{clientRating > 0 ? clientRating.toFixed(1) : '—'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-[10px] text-[#6B7280] tracking-wide font-medium">Client Rating</div>
|
||||
</div>
|
||||
</div>
|
||||
{/* Layer 3 — supporting evidence. A hairline is enough to separate it from
|
||||
the score; another container would make it a second card. */}
|
||||
<dl className="mt-4 grid grid-cols-1 gap-x-8 gap-y-2 border-t border-border pt-3 min-[420px]:grid-cols-2 lg:grid-cols-3">
|
||||
<SupportingSignal label="Reliability" value={reliability} suffix="%" />
|
||||
<SupportingSignal label="Attendance" value={attendance} suffix="%" />
|
||||
<SupportingSignal label="Leadership" value={leadership} suffix="%" />
|
||||
<SupportingSignal label="Communication" value={commText} />
|
||||
<SupportingSignal
|
||||
label="Client rating"
|
||||
value={clientRating > 0 ? clientRating.toFixed(1) : null}
|
||||
icon={Star}
|
||||
/>
|
||||
</dl>
|
||||
|
||||
{companies.length > 0 && (
|
||||
<div className="mt-3 pt-3 border-t border-[#E5E7EB]/80">
|
||||
<div className="mt-3 border-t border-border pt-3">
|
||||
<p className="text-[10px] font-bold text-[#9CA3AF] tracking-wider mb-2">WORKED</p>
|
||||
<div className="flex flex-wrap items-center gap-2.5">
|
||||
{companies.map((c, i) => (
|
||||
|
||||
@@ -1,50 +1,50 @@
|
||||
import React from 'react';
|
||||
import { Info, RefreshCw } from 'lucide-react';
|
||||
import { Gauge, gaugeClasses } from '@mui/x-charts/Gauge';
|
||||
import { getTalentScore, toFICO, getScoreBand } from '@/lib/talentHome';
|
||||
|
||||
const FICO_MIN = 300;
|
||||
const FICO_MAX = 850;
|
||||
const ARC_R = 64;
|
||||
const CX = 80;
|
||||
const CY = 80;
|
||||
const BLUE = '#0055FF';
|
||||
const TRACK = '#E1E5E9';
|
||||
|
||||
function arcPoint(p) {
|
||||
const ang = Math.PI - p * Math.PI;
|
||||
return { x: CX + ARC_R * Math.cos(ang), y: CY - ARC_R * Math.sin(ang) };
|
||||
}
|
||||
function arcPath(startP, endP) {
|
||||
const a = arcPoint(startP);
|
||||
const b = arcPoint(endP);
|
||||
const large = endP - startP > 0.5 ? 1 : 0;
|
||||
return `M ${a.x} ${a.y} A ${ARC_R} ${ARC_R} 0 ${large} 1 ${b.x} ${b.y}`;
|
||||
}
|
||||
const BLUE = '#0A39DF';
|
||||
|
||||
export default function CareerScoreGauge({ profile }) {
|
||||
const raw = getTalentScore(profile);
|
||||
const score = toFICO(raw);
|
||||
const band = getScoreBand(raw);
|
||||
const pct = Math.max(0, Math.min(1, (score - FICO_MIN) / (FICO_MAX - FICO_MIN)));
|
||||
const gap = 0.02;
|
||||
const filledEnd = Math.max(gap, Math.min(1 - gap, pct));
|
||||
const marker = arcPoint(filledEnd);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center">
|
||||
<div className="relative" style={{ width: CX * 2, height: CY + 28 }}>
|
||||
<svg width={CX * 2} height={CY + 28} viewBox={`0 0 ${CX * 2} ${CY + 28}`} className="overflow-visible">
|
||||
<path d={arcPath(gap, 1 - gap)} fill="none" stroke={TRACK} strokeWidth="12" strokeLinecap="round" />
|
||||
{pct > gap && (
|
||||
<path d={arcPath(gap, filledEnd)} fill="none" stroke={BLUE} strokeWidth="12" strokeLinecap="round" />
|
||||
)}
|
||||
<circle cx={marker.x} cy={marker.y} r="8" fill={BLUE} stroke="#fff" strokeWidth="3" />
|
||||
</svg>
|
||||
<div className="relative flex items-center justify-center" style={{ width: 140, height: 80 }}>
|
||||
<Gauge
|
||||
value={score}
|
||||
valueMin={FICO_MIN}
|
||||
valueMax={FICO_MAX}
|
||||
startAngle={-90}
|
||||
endAngle={90}
|
||||
innerRadius="75%"
|
||||
outerRadius="100%"
|
||||
cornerRadius="50%"
|
||||
text={() => null}
|
||||
sx={{
|
||||
width: 140,
|
||||
height: 140,
|
||||
marginTop: '-30px',
|
||||
[`& .${gaugeClasses.valueArc}`]: {
|
||||
fill: BLUE,
|
||||
},
|
||||
[`& .${gaugeClasses.referenceArc}`]: {
|
||||
fill: '#E2E8F0',
|
||||
},
|
||||
}}
|
||||
/>
|
||||
|
||||
<div className="absolute inset-x-0 bottom-2 flex flex-col items-center">
|
||||
<span className="text-[9px] uppercase tracking-wide text-[#A0AAB9] font-semibold">Career Score</span>
|
||||
<span className="text-[30px] font-bold leading-none mt-0.5" style={{ color: BLUE }}>{score}</span>
|
||||
<span className="text-[10px] text-[#8690A1] font-medium mt-0.5">{FICO_MIN}-{FICO_MAX}</span>
|
||||
<div
|
||||
className="absolute inset-x-0 bottom-0 flex flex-col items-center justify-end pb-1 pointer-events-none"
|
||||
aria-label={`Career Score: ${score}, Range: ${FICO_MIN}-${FICO_MAX}`}
|
||||
>
|
||||
<span className="text-[9px] uppercase tracking-wider text-[#94A3B8] font-semibold">CAREER SCORE</span>
|
||||
<span className="text-[24px] font-extrabold text-[#0A39DF] leading-none mt-0.5 tabular-nums">{score}</span>
|
||||
<span className="text-[9px] text-[#8690A1] font-medium mt-0.5">{FICO_MIN}–{FICO_MAX}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import React, { useRef, useState } from 'react';
|
||||
import html2canvas from 'html2canvas';
|
||||
import { Wallet, Smartphone, Image as ImageIcon, ShieldCheck, Briefcase, Sparkles, QrCode, Download, Check, Info, RefreshCw } from 'lucide-react';
|
||||
import { toast } from 'react-hot-toast';
|
||||
import { Gauge as MuiGauge, gaugeClasses } from '@mui/x-charts/Gauge';
|
||||
import { Image } from '@/components/ui/image';
|
||||
import { getTalentScore, getScoreBand, getGrowth } from '@/lib/talentHome';
|
||||
|
||||
@@ -47,39 +48,40 @@ function download(filename, content, type) {
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
const BLUE = '#0055FF';
|
||||
const TRACK = '#E1E5E9';
|
||||
const ARC_R = 64;
|
||||
const CX = 80;
|
||||
const CY = 80;
|
||||
function arcPoint(p) {
|
||||
const ang = Math.PI - p * Math.PI;
|
||||
return { x: CX + ARC_R * Math.cos(ang), y: CY - ARC_R * Math.sin(ang) };
|
||||
}
|
||||
function arcPath(startP, endP) {
|
||||
const a = arcPoint(startP);
|
||||
const b = arcPoint(endP);
|
||||
const large = endP - startP > 0.5 ? 1 : 0;
|
||||
return `M ${a.x} ${a.y} A ${ARC_R} ${ARC_R} 0 ${large} 1 ${b.x} ${b.y}`;
|
||||
}
|
||||
const BLUE = '#0A39DF';
|
||||
|
||||
function Gauge({ fico }) {
|
||||
const pct = Math.max(0, Math.min(1, (fico - 300) / 550));
|
||||
const gap = 0.02;
|
||||
const filledEnd = Math.max(gap, Math.min(1 - gap, pct));
|
||||
const marker = arcPoint(filledEnd);
|
||||
return (
|
||||
<div className="relative mx-auto" style={{ width: CX * 2, height: CY + 18 }}>
|
||||
<svg width={CX * 2} height={CY + 18} viewBox={`0 0 ${CX * 2} ${CY + 18}`}>
|
||||
<path d={arcPath(gap, 1 - gap)} fill="none" stroke={TRACK} strokeWidth="12" strokeLinecap="round" />
|
||||
{pct > gap && (
|
||||
<path d={arcPath(gap, filledEnd)} fill="none" stroke={BLUE} strokeWidth="12" strokeLinecap="round" />
|
||||
)}
|
||||
<circle cx={marker.x} cy={marker.y} r="8" fill={BLUE} stroke="#fff" strokeWidth="3" />
|
||||
</svg>
|
||||
<div className="absolute inset-x-0 bottom-1 flex flex-col items-center">
|
||||
<span className="text-[9px] uppercase tracking-wide text-[#A0AAB9] font-semibold">Career Score</span>
|
||||
<span className="text-[28px] font-bold leading-none mt-0.5" style={{ color: BLUE }}>{fico}</span>
|
||||
<span className="text-[10px] text-[#8690A1] font-medium mt-0.5">300-850</span>
|
||||
<div className="relative mx-auto flex items-center justify-center" style={{ width: 140, height: 80 }}>
|
||||
<MuiGauge
|
||||
value={fico}
|
||||
valueMin={300}
|
||||
valueMax={850}
|
||||
startAngle={-90}
|
||||
endAngle={90}
|
||||
innerRadius="75%"
|
||||
outerRadius="100%"
|
||||
cornerRadius="50%"
|
||||
text={() => null}
|
||||
sx={{
|
||||
width: 140,
|
||||
height: 140,
|
||||
marginTop: '-30px',
|
||||
[`& .${gaugeClasses.valueArc}`]: {
|
||||
fill: BLUE,
|
||||
},
|
||||
[`& .${gaugeClasses.referenceArc}`]: {
|
||||
fill: '#E2E8F0',
|
||||
},
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
className="absolute inset-x-0 bottom-0 flex flex-col items-center justify-end pb-1 pointer-events-none"
|
||||
aria-label={`Career Score: ${fico}, Range: 300-850`}
|
||||
>
|
||||
<span className="text-[9px] uppercase tracking-wider text-[#94A3B8] font-semibold">CAREER SCORE</span>
|
||||
<span className="text-[24px] font-extrabold text-[#0A39DF] leading-none mt-0.5 tabular-nums">{fico}</span>
|
||||
<span className="text-[9px] text-[#8690A1] font-medium mt-0.5">300–850</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
189
src/components/skills/AddSkillDialog.jsx
Normal file
189
src/components/skills/AddSkillDialog.jsx
Normal file
@@ -0,0 +1,189 @@
|
||||
import React from 'react';
|
||||
import { Sparkles, Upload } from 'lucide-react';
|
||||
import { Button, Field, Input, Modal, Textarea, toast } from '@/components/ds';
|
||||
import { usePreferences, useUpdatePreferences } from '@/lib/krowHooks';
|
||||
import { PAGE_KEYS, parseSkill, validateSkillSource } from '@/lib/skills/registry';
|
||||
|
||||
/**
|
||||
* Add an Owliver skill.
|
||||
*
|
||||
* One dialog, opened from Settings and from Owliver's own header, writing to
|
||||
* one registry — a second registration path would be a second source of truth
|
||||
* by another name.
|
||||
*
|
||||
* What is stored is the Markdown itself, not a parsed record. A custom skill is
|
||||
* then exactly the artefact a file on disk is, read by the same parser, and can
|
||||
* be copied into `src/skills/` later without conversion.
|
||||
*
|
||||
* The fields above the editor are a starting point, not a second schema: typing
|
||||
* in them composes frontmatter in the existing format, which the author can
|
||||
* then edit directly. The Markdown is always what gets saved.
|
||||
*/
|
||||
|
||||
const TEMPLATE = ({ id, name, description, pages }) => `---
|
||||
id: ${id || 'my-skill'}
|
||||
name: ${name || 'My Skill'}
|
||||
description: ${description || 'What this skill helps Owliver do.'}
|
||||
pages:
|
||||
${(pages || ['positions']).map((p) => ` - ${p}`).join('\n')}
|
||||
status: active
|
||||
triggers:
|
||||
- ${(name || 'my skill').toLowerCase()}
|
||||
---
|
||||
|
||||
# ${name || 'My Skill'}
|
||||
|
||||
## Purpose
|
||||
|
||||
Describe what Owliver should help with on these pages.
|
||||
|
||||
## Capabilities
|
||||
|
||||
- Describe one thing the skill can do.
|
||||
- Add more as needed.
|
||||
`;
|
||||
|
||||
export function AddSkillDialog({ open, onOpenChange }) {
|
||||
const preferences = usePreferences();
|
||||
const updatePreferences = useUpdatePreferences();
|
||||
|
||||
const [meta, setMeta] = React.useState({ id: '', name: '', description: '', pages: '' });
|
||||
const [source, setSource] = React.useState('');
|
||||
const [touched, setTouched] = React.useState(false);
|
||||
const [error, setError] = React.useState('');
|
||||
const fileRef = React.useRef(null);
|
||||
|
||||
/* A fresh sheet each time it opens, so an abandoned draft is not waiting the
|
||||
next time someone adds a skill. */
|
||||
React.useEffect(() => {
|
||||
if (!open) return;
|
||||
setMeta({ id: '', name: '', description: '', pages: '' });
|
||||
setSource('');
|
||||
setTouched(false);
|
||||
setError('');
|
||||
}, [open]);
|
||||
|
||||
/* The fields drive the Markdown until the author edits the Markdown, after
|
||||
which the Markdown wins — it is the thing being saved. */
|
||||
const syncFromMeta = (next) => {
|
||||
setMeta(next);
|
||||
if (touched) return;
|
||||
setSource(TEMPLATE({
|
||||
id: next.id,
|
||||
name: next.name,
|
||||
description: next.description,
|
||||
pages: next.pages.split(',').map((p) => p.trim()).filter(Boolean),
|
||||
}));
|
||||
};
|
||||
|
||||
const readFile = (event) => {
|
||||
const file = event.target.files?.[0];
|
||||
if (!file) return;
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => {
|
||||
setSource(String(reader.result));
|
||||
setTouched(true);
|
||||
setError('');
|
||||
};
|
||||
reader.readAsText(file);
|
||||
event.target.value = '';
|
||||
};
|
||||
|
||||
const save = () => {
|
||||
const problem = validateSkillSource(source);
|
||||
if (problem) {
|
||||
setError(problem);
|
||||
return;
|
||||
}
|
||||
|
||||
const skill = parseSkill(source, { custom: true });
|
||||
const existing = preferences.customSkills || [];
|
||||
const next = [
|
||||
...existing.filter((entry) => parseSkill(entry.raw, { custom: true }).id !== skill.id),
|
||||
{ path: `custom/${skill.id}.md`, raw: source },
|
||||
];
|
||||
|
||||
updatePreferences.mutate({ customSkills: next });
|
||||
toast.success(`${skill.name} added`);
|
||||
onOpenChange(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
title="Add Owliver skill"
|
||||
description="Define a skill in Markdown. It attaches itself to the pages it names."
|
||||
icon={Sparkles}
|
||||
size="lg"
|
||||
footer={
|
||||
<>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>Cancel</Button>
|
||||
<Button onClick={save} loading={updatePreferences.isPending}>Add skill</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<Field label="Skill name">
|
||||
<Input
|
||||
value={meta.name}
|
||||
placeholder="Chef Training"
|
||||
onChange={(e) => syncFromMeta({ ...meta, name: e.target.value })}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Skill ID" hint="Lower-case, dashes.">
|
||||
<Input
|
||||
value={meta.id}
|
||||
placeholder="chef-training"
|
||||
onChange={(e) => syncFromMeta({ ...meta, id: e.target.value })}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<Field label="Description">
|
||||
<Input
|
||||
value={meta.description}
|
||||
placeholder="Helps Owliver explain chef training paths."
|
||||
onChange={(e) => syncFromMeta({ ...meta, description: e.target.value })}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field label="Pages" hint={`Comma separated. Known pages: ${PAGE_KEYS.join(', ')}`}>
|
||||
<Input
|
||||
value={meta.pages}
|
||||
placeholder="university"
|
||||
onChange={(e) => syncFromMeta({ ...meta, pages: e.target.value })}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field
|
||||
label="Markdown definition"
|
||||
hint="This is what gets saved. Edit it directly if you prefer."
|
||||
error={error}
|
||||
>
|
||||
<Textarea
|
||||
value={source}
|
||||
rows={12}
|
||||
spellCheck={false}
|
||||
className="font-mono text-[12px] leading-relaxed"
|
||||
placeholder="--- id: my-skill name: My Skill pages: - positions ---"
|
||||
onChange={(e) => {
|
||||
setSource(e.target.value);
|
||||
setTouched(true);
|
||||
if (error) setError('');
|
||||
}}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<input ref={fileRef} type="file" accept=".md,text/markdown" onChange={readFile} className="hidden" />
|
||||
<Button variant="outline" size="sm" onClick={() => fileRef.current?.click()}>
|
||||
<Upload aria-hidden="true" /> Upload .md
|
||||
</Button>
|
||||
<p className="text-caption text-ink-4">Markdown is stored as data. It is never executed.</p>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -5,6 +5,9 @@ import { DayPicker } from "react-day-picker"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { buttonVariants } from "@/components/ui/button"
|
||||
|
||||
// react-day-picker v9 renamed every element key and replaced the IconLeft /
|
||||
// IconRight slots with a single `Chevron` that receives an orientation. The
|
||||
// styling below is the same as before the upgrade, remapped onto the v9 names.
|
||||
function Calendar({
|
||||
className,
|
||||
classNames,
|
||||
@@ -16,52 +19,52 @@ function Calendar({
|
||||
showOutsideDays={showOutsideDays}
|
||||
className={cn("p-3", className)}
|
||||
classNames={{
|
||||
months: "flex flex-col sm:flex-row space-y-4 sm:space-x-4 sm:space-y-0",
|
||||
months: "flex flex-col sm:flex-row gap-4 relative",
|
||||
month: "space-y-4",
|
||||
caption: "flex justify-center pt-1 relative items-center",
|
||||
month_caption: "flex justify-center pt-1 relative items-center h-7",
|
||||
caption_label: "text-sm font-medium",
|
||||
nav: "space-x-1 flex items-center",
|
||||
nav_button: cn(
|
||||
nav: "flex items-center gap-1 absolute inset-x-1 top-1 justify-between z-10",
|
||||
button_previous: cn(
|
||||
buttonVariants({ variant: "outline" }),
|
||||
"h-7 w-7 bg-transparent p-0 opacity-50 hover:opacity-100"
|
||||
),
|
||||
nav_button_previous: "absolute left-1",
|
||||
nav_button_next: "absolute right-1",
|
||||
table: "w-full border-collapse space-y-1",
|
||||
head_row: "flex",
|
||||
head_cell:
|
||||
"text-muted-foreground rounded-md w-8 font-normal text-[0.8rem]",
|
||||
row: "flex w-full mt-2",
|
||||
cell: cn(
|
||||
"relative p-0 text-center text-sm focus-within:relative focus-within:z-20 [&:has([aria-selected])]:bg-accent [&:has([aria-selected].day-outside)]:bg-accent/50 [&:has([aria-selected].day-range-end)]:rounded-r-md",
|
||||
button_next: cn(
|
||||
buttonVariants({ variant: "outline" }),
|
||||
"h-7 w-7 bg-transparent p-0 opacity-50 hover:opacity-100"
|
||||
),
|
||||
month_grid: "w-full border-collapse space-y-1",
|
||||
weekdays: "flex",
|
||||
weekday: "text-muted-foreground rounded-md w-8 font-normal text-[0.8rem]",
|
||||
week: "flex w-full mt-2",
|
||||
day: cn(
|
||||
"relative p-0 text-center text-sm focus-within:relative focus-within:z-20",
|
||||
"[&:has([aria-selected])]:bg-accent [&:has([aria-selected].day-outside)]:bg-accent/50",
|
||||
props.mode === "range"
|
||||
? "[&:has(>.day-range-end)]:rounded-r-md [&:has(>.day-range-start)]:rounded-l-md first:[&:has([aria-selected])]:rounded-l-md last:[&:has([aria-selected])]:rounded-r-md"
|
||||
: "[&:has([aria-selected])]:rounded-md"
|
||||
),
|
||||
day: cn(
|
||||
day_button: cn(
|
||||
buttonVariants({ variant: "ghost" }),
|
||||
"h-8 w-8 p-0 font-normal aria-selected:opacity-100"
|
||||
),
|
||||
day_range_start: "day-range-start",
|
||||
day_range_end: "day-range-end",
|
||||
day_selected:
|
||||
range_start: "day-range-start",
|
||||
range_end: "day-range-end",
|
||||
selected:
|
||||
"bg-primary text-primary-foreground hover:bg-primary hover:text-primary-foreground focus:bg-primary focus:text-primary-foreground",
|
||||
day_today: "bg-accent text-accent-foreground",
|
||||
day_outside:
|
||||
today: "bg-accent text-accent-foreground",
|
||||
outside:
|
||||
"day-outside text-muted-foreground aria-selected:bg-accent/50 aria-selected:text-muted-foreground",
|
||||
day_disabled: "text-muted-foreground opacity-50",
|
||||
day_range_middle:
|
||||
disabled: "text-muted-foreground opacity-50",
|
||||
range_middle:
|
||||
"aria-selected:bg-accent aria-selected:text-accent-foreground",
|
||||
day_hidden: "invisible",
|
||||
hidden: "invisible",
|
||||
...classNames,
|
||||
}}
|
||||
components={{
|
||||
IconLeft: ({ className, ...props }) => (
|
||||
<ChevronLeft className={cn("h-4 w-4", className)} {...props} />
|
||||
),
|
||||
IconRight: ({ className, ...props }) => (
|
||||
<ChevronRight className={cn("h-4 w-4", className)} {...props} />
|
||||
),
|
||||
Chevron: ({ className, orientation, ...props }) => {
|
||||
const Icon = orientation === "left" ? ChevronLeft : ChevronRight
|
||||
return <Icon className={cn("h-4 w-4", className)} {...props} />
|
||||
},
|
||||
}}
|
||||
{...props} />)
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user