update ui design for all pages

This commit is contained in:
2026-08-12 18:56:56 +05:30
parent 25c4ee4a6a
commit 3beb66059b
58 changed files with 7892 additions and 1891 deletions

View File

@@ -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';

View File

@@ -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 {

View File

@@ -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"
/>
)}

View File

@@ -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 },
];

View File

@@ -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,
},

View File

@@ -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' }

View File

@@ -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,

View File

@@ -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];

View 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) };
}

View File

@@ -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,

View File

@@ -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>
);
}

View File

@@ -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

View File

@@ -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>
);
}

View 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&apos;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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>

View File

@@ -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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View File

@@ -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>
);

View File

@@ -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 };
}

View File

@@ -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';

View File

@@ -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';

View File

@@ -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 = {

View File

@@ -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) => (

View File

@@ -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>

View File

@@ -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>
);

View 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="---&#10;id: my-skill&#10;name: My Skill&#10;pages:&#10; - positions&#10;---"
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>
);
}

View File

@@ -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} />)
);