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,4 +1,5 @@
import { Toaster } from "@/components/ui/toaster"
import { Toaster as HotToaster } from 'react-hot-toast';
import { QueryClientProvider } from '@tanstack/react-query'
import { queryClientInstance } from '@/lib/query-client'
import { Navigate, BrowserRouter as Router, Route, Routes, useParams } from 'react-router-dom';
@@ -48,6 +49,12 @@ const LegacyCourseRedirect = () => {
return <Navigate to={`/admin/university/${id}`} replace />;
};
/** `/positions/:id` → the same position inside the Admin shell, id preserved. */
const LegacyPositionRedirect = () => {
const { id } = useParams();
return <Navigate to={`/admin/positions/${id}`} replace />;
};
const AuthenticatedApp = () => {
const { isLoadingAuth, isLoadingPublicSettings } = useAuth();
@@ -96,6 +103,10 @@ const AuthenticatedApp = () => {
{/* Redirect top-level legacy aliases to the unified global shell */}
<Route path="/overview" element={<Navigate to="/admin" replace />} />
<Route path="/positions" element={<Navigate to="/admin/positions" replace />} />
{/* `/positions` only matches the exact path, so the sub-routes need
their own aliases or an inbound link lands on the 404 page. */}
<Route path="/positions/new" element={<Navigate to="/admin/positions/new" replace />} />
<Route path="/positions/:id" element={<LegacyPositionRedirect />} />
<Route path="/candidates" element={<Navigate to="/admin/candidates" replace />} />
<Route path="/hired" element={<Navigate to="/admin/hired" replace />} />
<Route path="/talent-pool" element={<Navigate to="/admin/talent-pool" replace />} />
@@ -123,6 +134,11 @@ function App() {
<AuthenticatedApp />
</Router>
<Toaster />
{/* The design system's `toast` helper wraps react-hot-toast, which needs
its own renderer mounted. Without this every toast in the app fired
into nothing — confirmations, errors and the "not in the demo"
notices all silently did nothing. */}
<HotToaster position="bottom-right" />
</QueryClientProvider>
</AuthProvider>
)

View File

@@ -55,6 +55,24 @@ const auth = {
return { ...currentUser };
},
/**
* Preferences, read synchronously.
*
* `me()` is async because the real client fetches, but the panel provider has
* to decide whether Owliver starts open during its first render — one tick
* later is a visible flash of the wrong workspace. The session user is already
* hydrated from localStorage at module load, so this is a plain read of the
* same record `me()` returns, not a second copy of the state.
*/
preferences() {
return { ...DEMO_USER.preferences, ...(currentUser.preferences || {}) };
},
/** Merges into the stored preferences and persists with the rest of the user. */
async updatePreferences(patch) {
return auth.updateMe({ preferences: { ...auth.preferences(), ...patch } });
},
isAuthenticated() {
return true;
},

View File

@@ -1399,6 +1399,13 @@ export const DEMO_USER = {
role: 'admin',
account_type: 'employer',
created_date: iso('2026-06-01'),
/* Product preferences travel with the account rather than in a store of their
own, so there is one record to persist and one thing to read. */
preferences: {
owliverDefault: true,
compactDensity: false,
emailDigest: true,
},
};
export const seedData = {

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

View File

@@ -1,5 +1,12 @@
@import url('https://fonts.googleapis.com/css2?family=Sora:wght@400;500;600;700;800&display=swap');
/* Astryx component styles, pre-compiled by the package (`@layer astryx-base`).
Only the component layer is imported: Tailwind's preflight is already the
app's reset, and Astryx's own reset would be a second, global one. The theme
package is likewise left out — Astryx components in KROW wear KROW's tokens,
which the `.krow-astryx` scope below supplies. */
@import '@astryxdesign/core/astryx.css';
@tailwind base;
@tailwind components;
@tailwind utilities;
@@ -242,6 +249,91 @@
}
}
/**
* Astryx components, wearing KROW.
*
* Astryx reads its own CSS custom properties, so dressing it in the KROW
* language is a matter of re-pointing those tokens at the ones the rest of the
* app already uses — not of overriding component rules. Scoped to `.krow-astryx`
* so nothing outside an Astryx island is touched, and unlayered so it wins over
* the package's `@layer astryx-base` defaults.
*
* Only the tokens Tab and TabList actually read are declared. Anything else
* keeps the package default, which is the point of restating as little as
* possible.
*/
.krow-astryx {
/* The selected indicator rule. KROW blue is the active state. */
--color-accent: #0A39DF;
/* Tab labels: selected picks up brand, the rest the app's secondary ink. */
--color-text-primary: #0A39DF;
--color-text-secondary: hsl(var(--ink-3));
--color-border: hsl(var(--border));
/* The hover wash sits *over* the label, so it has to stay translucent — an
opaque surface token here paints the tab's own text out. */
--color-overlay-hover: rgba(10, 57, 223, 0.06);
--color-overlay-pressed: rgba(10, 57, 223, 0.11);
--font-family-body: var(--font-body);
}
.dark .krow-astryx {
--color-accent: #5B8DEF;
--color-text-primary: #93B4FF;
--color-overlay-hover: rgba(147, 180, 255, 0.10);
--color-overlay-pressed: rgba(147, 180, 255, 0.16);
}
/**
* Give Astryx's own button styling back.
*
* Astryx ships its component CSS inside `@layer astryx-base`, while Tailwind
* emits its preflight unlayered — and unlayered always beats a layer, whatever
* the selectors. So Tailwind's `button { font-size: 100%; padding: 0; color:
* inherit }` reset silently flattened every Astryx tab: no size, no padding, no
* label colour, leaving five words run together under a stray indicator.
*
* `revert-layer` rolls each property back to the previous layer's value, which
* is Astryx's own. Nothing is re-specified here — the design system keeps
* authorship of its sizing, and this only stops the reset from reaching it.
*/
/* Same story for `hasDivider`: preflight's `*{border-width:0}` zeroed the rail
under the strip, so the tabs floated over the content with nothing separating
them from it. */
.krow-astryx .astryx-tab-list {
border-bottom-width: revert-layer;
}
.krow-astryx .astryx-tab {
font-family: revert-layer;
font-size: revert-layer;
font-weight: revert-layer;
line-height: revert-layer;
color: revert-layer;
padding-inline: revert-layer;
}
/**
* Tab strips scroll rather than wrap.
*
* A tab row that wraps to a second line stops reading as one control, and on a
* phone there is never room for five section names. The strip takes the
* overflow so the page itself never gains a horizontal scrollbar.
*/
.krow-astryx-scroll {
overflow-x: auto;
overflow-y: hidden;
scrollbar-width: none;
}
.krow-astryx-scroll::-webkit-scrollbar {
display: none;
}
.krow-astryx-scroll > nav {
flex-wrap: nowrap;
min-width: max-content;
}
@layer base {
/* Respect reduced-motion globally rather than per component. */
@media (prefers-reduced-motion: reduce) {

View File

@@ -16,6 +16,7 @@ import {
import { Sheet, SheetContent, SheetHeader, SheetTitle } from '@/components/ui/sheet';
import { AssistantPanel, AssistantPanelProvider } from '@/components/ai-assistant';
import { endAdminSession } from '@/pages/admin/session';
import { useCurrentUser } from '@/lib/krowHooks';
/**
* AdminLayout — the shell for the Admin product.
@@ -155,12 +156,11 @@ function NotificationsMenu({ items }) {
export default function AdminLayout() {
const location = useLocation();
const navigate = useNavigate();
const [user, setUser] = useState(null);
const [menuOpen, setMenuOpen] = useState(false);
useEffect(() => {
base44.auth.me().then(setUser).catch(() => {});
}, []);
/* Shared cache rather than a local fetch, so saving the profile updates the
header avatar and account menu without a reload. */
const { data: user } = useCurrentUser();
const signOut = () => {
endAdminSession();

View File

@@ -5,6 +5,55 @@ import { recalcProfilePatch } from './krowScore';
import { logActivity } from './userTracking';
import { evaluateChallenge } from './provingGround';
/**
* The signed-in user, on the `['user']` key the assistant and the 404 page
* already read. One cache entry means a profile edit reaches the navbar, the
* Profile page and Owliver's greeting without any of them knowing about each
* other.
*/
export function useCurrentUser() {
return useQuery({
queryKey: ['user'],
queryFn: () => base44.auth.me().catch(() => null),
staleTime: Infinity,
});
}
/** Saves profile fields and refreshes every consumer of `['user']`. */
export function useUpdateProfile() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (patch) => base44.auth.updateMe(patch),
onSuccess: (user) => {
queryClient.setQueryData(['user'], user);
queryClient.invalidateQueries({ queryKey: ['user'] });
},
});
}
/**
* Product preferences, stored on the user record.
*
* Reads fall back to the synchronous accessor so the first render already has
* the real values rather than a default that corrects itself a tick later.
*/
export function usePreferences() {
const { data: user } = useCurrentUser();
return { ...base44.auth.preferences(), ...(user?.preferences || {}) };
}
/** Writes one or more preferences and refreshes every consumer of `['user']`. */
export function useUpdatePreferences() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (patch) => base44.auth.updatePreferences(patch),
onSuccess: (user) => {
queryClient.setQueryData(['user'], user);
queryClient.invalidateQueries({ queryKey: ['user'] });
},
});
}
export function useJobPostings() {
return useQuery({
queryKey: ['jobPostings'],
@@ -278,6 +327,38 @@ export function useCourse(id) {
return useQuery({ queryKey: ['course', id], queryFn: () => base44.entities.Course.get(id), enabled: !!id });
}
/**
* Author a skill in KROW Forge.
*
* A skill and the challenge that verifies it are one Course record — the entity
* already carries the skill (title, category, difficulty, proof_skill), the
* training (quiz, estimated_minutes), the proof (challenge.type) and the
* evaluation Owliver runs (challenge.rubric). So Forge authoring is a create on
* the entity the whole product already reads, not a parallel store.
*/
export function useCreateCourse() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (data) => base44.entities.Course.create(data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['courses'] });
logActivity('create_skill_training');
},
});
}
/** Status changes and edits to an existing skill. */
export function useUpdateCourse() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ id, data }) => base44.entities.Course.update(id, data),
onSuccess: (_data, variables) => {
queryClient.invalidateQueries({ queryKey: ['courses'] });
queryClient.invalidateQueries({ queryKey: ['course', variables.id] });
},
});
}
export function useLearningPaths() {
return useQuery({ queryKey: ['learningPaths'], queryFn: () => base44.entities.LearningPath.list('-created_date', 100) });
}

90
src/lib/positionModel.js Normal file
View File

@@ -0,0 +1,90 @@
/**
* The position record, as one definition.
*
* Create Position (the form) and Owliver (the conversation) are two ways into
* the same record, and the field names, option lists and defaults have to be the
* same in both — a category the form offers and the conversation does not is a
* position that can only be created one way. So the shape lives here and both
* read it, rather than each carrying its own copy.
*
* Nothing in this file talks to the store. Creating a position is still
* `useCreateJobPosting`, in both paths.
*/
/** Certifications a position can require, in the order they are offered. */
export const CERT_OPTIONS = [
'Food Handler Card',
'ServSafe',
'ABC License',
'TIPS Certified',
'CPR/First Aid',
'Background Check Cleared',
];
/** English levels, stored as the value and shown as the label. */
export const ENGLISH_LEVELS = [
{ value: 'basic', label: 'Basic' },
{ value: 'conversational', label: 'Conversational' },
{ value: 'fluent', label: 'Fluent' },
{ value: 'native', label: 'Native' },
];
/** How each vetting weight reads when it is labelled. */
export const CRITERIA_LABELS = {
experience: 'Experience',
english: 'English',
reliability: 'Reliability',
certifications: 'Certifications',
availability: 'Availability',
};
/**
* A blank position.
*
* A function rather than a constant, so two callers cannot end up sharing — and
* mutating — the same `vetting_criteria` or certifications array.
*/
export function defaultPosition() {
return {
title: '',
role_category: 'Server',
custom_requirements: '',
physical_requirements: '',
leadership_expectations: '',
attendance_expectations: '',
min_experience_years: 0,
english_required: 'basic',
location: '',
pay_range_min: '',
pay_range_max: '',
certifications_required: [],
vetting_criteria: { experience: 25, english: 20, reliability: 20, certifications: 20, availability: 15 },
};
}
/**
* A draft — from either path — as the record the store is given.
*
* The two numeric fields are text inputs in the form, so they are coerced here
* rather than at each call site. Everything absent falls back to the same
* defaults the form starts from.
*/
export function toPositionPayload(draft = {}, { status = 'active' } = {}) {
const record = { ...defaultPosition(), ...draft };
return {
...record,
status,
pay_range_min: Number(record.pay_range_min) || 0,
pay_range_max: Number(record.pay_range_max) || 0,
min_experience_years: Number(record.min_experience_years) || 0,
};
}
/** `$30–$40/hr`, or a single rate, or nothing when no pay is set. */
export function payLabel({ pay_range_min: min, pay_range_max: max } = {}) {
const low = Number(min) || 0;
const high = Number(max) || 0;
if (!low && !high) return null;
if (!high || high === low) return `$${low}/hr`;
return `$${low}–$${high}/hr`;
}

View File

@@ -0,0 +1,9 @@
/**
* The role categories a position can be filed under.
*
* Shared because two things read them: the Create Position form renders them as
* options, and Owliver matches a spoken role against them. Custom categories
* added through the form come from the RoleCategory entity and are merged on
* top of these at both call sites.
*/
export const ROLE_CATEGORIES = ['Server', 'Bartender', 'Chef', 'Security', 'Picker', 'Event Staff', 'Other'];

490
src/lib/skills/actions.js Normal file
View File

@@ -0,0 +1,490 @@
import { CERT_OPTIONS, ENGLISH_LEVELS, toPositionPayload } from '@/lib/positionModel';
import { routeForPageKey } from './registry';
/**
* Skill actions.
*
* A skill file names the actions it may perform; this module is the only place
* that decides what those names do. Markdown is never executed — an unknown
* action name resolves to nothing rather than to arbitrary behaviour.
*
* Every action is a description of intent, returned to the panel to carry out
* with the app's own router. Nothing here writes data: the most an action does
* is open a form with fields filled in, which the user then reviews and submits
* through the existing flow.
*/
/* ── Natural-language extraction ────────────────────────────────────────── */
/**
* The role being asked for.
*
* Matched against the categories the app already has rather than a list written
* here, so "create a chef position" resolves to whatever the deployment calls
* that category — and a category added in Create Position is understood without
* a change to this file.
*/
export function extractRole(question, categories = []) {
const q = String(question).toLowerCase();
/* Longest match wins: "executive chef" should not resolve to "chef". */
let best = null;
for (const category of categories) {
const name = String(category).toLowerCase();
if (name && q.includes(name) && (!best || name.length > best.length)) best = category;
}
if (best) return best;
/* "…for a Sous Chef", "…a Line Cook position" — take the phrase the sentence
itself marks as the role when it matches no known category. */
const phrase = /(?:create|open|post|add|new|hiring|hire)\s+(?:a|an)?\s*([a-z][a-z\s/-]{2,40}?)\s*(?:position|role|job|opening)\b/i.exec(question)
|| /\b(?:position|role|job)\s+for\s+(?:a|an)?\s*([a-z][a-z\s/-]{2,40})/i.exec(question);
if (!phrase) return null;
const cleaned = phrase[1].trim().replace(/\s+/g, ' ');
return cleaned ? cleaned.replace(/\b\w/g, (c) => c.toUpperCase()) : null;
}
/** A location, when the sentence names one with "in" or "at". */
export function extractLocation(question) {
const match = /\b(?:in|at|based in|located in)\s+([A-Z][A-Za-z.'-]*(?:\s+[A-Z][A-Za-z.'-]*){0,3})/.exec(question);
if (!match) return null;
const value = match[1].trim();
/* "in Chennai" is a location; "in the morning" is not. */
return /^(The|A|An|This|That|Order|Total|Fact)$/i.test(value) ? null : value;
}
/**
* A pay range. Accepts "$30-$40/hr", "30 to 40 an hour", "$25/hr".
* Returns whole dollars, because the form's fields are numeric.
*/
export function extractPay(question) {
const range = /\$?\s*(\d{1,3})(?:\.\d+)?\s*(?:-|–|—|to)\s*\$?\s*(\d{1,3})(?:\.\d+)?/.exec(question);
if (range) {
const min = Number(range[1]);
const max = Number(range[2]);
if (min > 0 && max >= min) return { min, max };
}
const single = /\$\s*(\d{1,3})(?:\.\d+)?/.exec(question);
if (single) {
const value = Number(single[1]);
if (value > 0) return { min: value, max: value };
}
return null;
}
/**
* A minimum experience in years, when the sentence states one.
*
* "no experience needed" is an answer rather than a silence, so it returns 0 —
* which is what stops the conversation asking a question the request already
* settled.
*/
export function extractExperience(question) {
const q = String(question).toLowerCase();
if (/\b(no|zero)\s+(minimum|min|experience|exp)\b/.test(q) || /\bentry[- ]level\b/.test(q)) return 0;
const match = /(\d{1,2})\s*\+?\s*(?:years?|yrs?)(?:\s+(?:of\s+)?(?:experience|exp))?/.exec(q);
if (!match) return null;
const years = Number(match[1]);
return years >= 0 && years <= 40 ? years : null;
}
/** An English level, matched against the levels the record actually stores. */
export function extractEnglish(question) {
const q = String(question).toLowerCase();
const level = ENGLISH_LEVELS.find(({ value }) => new RegExp(`\\b${value}\\b`).test(q));
/* "english" alone names the field, not a level — leave it to be asked. */
return level ? level.value : null;
}
/**
* Certifications the sentence names, matched against the list the form offers.
*
* An explicit "no certifications" is an answer — an empty list — and returns
* `[]`, which is different from returning nothing.
*/
export function extractCertifications(question) {
const q = String(question).toLowerCase();
if (/\bno (?:certification|certificate|cert)s?\b|\bnone required\b/.test(q)) return [];
const found = CERT_OPTIONS.filter((cert) => q.includes(cert.toLowerCase()));
return found.length ? found : null;
}
/**
* Everything a position can be given from one sentence.
*
* Only fields the record actually has. Nothing is invented, and anything absent
* stays absent — for the conversation that is the difference between a question
* worth asking and one that repeats what the user already said.
*/
export function buildPositionPrefill(question, categories = []) {
const role = extractRole(question, categories);
const location = extractLocation(question);
const pay = extractPay(question);
const experience = extractExperience(question);
const english = extractEnglish(question);
const certifications = extractCertifications(question);
const prefill = {};
if (role) {
/* A known category fills the category field; anything else is a title, so a
one-off role is not silently filed under the wrong category. Either way
the title is set, because a position without one cannot be created. */
const known = categories.find((c) => String(c).toLowerCase() === String(role).toLowerCase());
if (known) prefill.role_category = known;
prefill.title = known || role;
}
if (location) prefill.location = location;
if (pay) {
prefill.pay_range_min = String(pay.min);
prefill.pay_range_max = String(pay.max);
}
if (experience !== null) prefill.min_experience_years = experience;
if (english) prefill.english_required = english;
if (certifications) prefill.certifications_required = certifications;
return { prefill, role, location, pay };
}
/**
* Words that are the sentence's own scaffolding rather than a skill.
*
* "Create a skill training" names nothing — matching it would put the word
* "Skill" in the name field, which the author then has to notice and delete.
* An empty field is the honest answer to a request that named no subject.
*/
const GENERIC_SKILL_WORDS = new Set([
'skill', 'skills', 'training', 'trainings', 'course', 'courses', 'challenge',
'challenges', 'new', 'another', 'one', 'it', 'this', 'that', 'them',
]);
/**
* The skill a Forge request is about.
*
* "Create a training for knife skills" → "Knife Skills". The sentence marks the
* subject with "for" or "about", or names it between the verb and the word
* training; anything else is left for the author to type, because a wrong guess
* in a form field is worse than an empty one.
*/
export function extractSkillName(question) {
const patterns = [
/\b(?:training|skill|challenge|course)s?\s+(?:for|on|about|covering)\s+(?:a|an|the)?\s*([a-z][a-z0-9\s&/'-]{2,50})/i,
/\b(?:create|add|build|make|new)\s+(?:a|an)?\s*([a-z][a-z0-9\s&/'-]{2,50}?)\s+(?:training|skill|challenge|course)\b/i,
];
for (const pattern of patterns) {
const match = pattern.exec(question);
if (!match) continue;
const cleaned = match[1]
.trim()
.replace(/\s+/g, ' ')
/* "for kitchen staff" names an audience, not a skill — drop the trailing
audience clause so the field holds the subject on its own. */
.replace(/\s+(?:for|to)\s+(?:my|our|the)?\s*\w+(?:\s+\w+)?$/i, '')
.trim();
if (cleaned.length < 3) continue;
/* A phrase made only of scaffolding named no subject. */
const words = cleaned.toLowerCase().split(/\s+/);
if (words.every((w) => GENERIC_SKILL_WORDS.has(w))) continue;
return cleaned.replace(/\b\w/g, (c) => c.toUpperCase());
}
return null;
}
/**
* The skill a "review" follow-up refers to.
*
* Owliver's draft is not stored anywhere — the follow-up chip carries the same
* subject the request did, and the draft is derived again from it. That is what
* lets a chip and a typed sentence go through one resolution path and arrive at
* the same draft, rather than a chip replaying a remembered answer.
*/
export function extractReviewSubject(question) {
const match = /\breview\s+(?:the\s+)?([a-z][a-z0-9\s&/'-]{2,60}?)\s+skill\b/i.exec(question);
if (!match) return null;
const cleaned = match[1].trim().replace(/\s+/g, ' ');
return cleaned.length >= 3 ? cleaned.replace(/\b\w/g, (c) => c.toUpperCase()) : null;
}
/* ── Draft suggestions ──────────────────────────────────────────────────── */
/**
* Starting points for a skill draft, by subject.
*
* These are suggestions an author reviews and edits, not knowledge the platform
* holds — which is why a subject matching nothing here yields no outline at all
* rather than three generic lines. An empty step the author fills in is honest;
* an invented curriculum is not.
*
* `proof` is a challenge type the challenge runner can actually run.
*/
const SKILL_TEMPLATES = [
{
terms: ['allerg', 'intoleran', 'cross-contact', 'cross contact'],
category: 'Safety & Hygiene',
difficulty: 'intermediate',
outline: ['Recognizing allergens', 'Preventing cross-contact', 'Handling allergy requests'],
proof: 'roleplay',
},
{
terms: ['food safety', 'hygiene', 'sanitation', 'sanitisation', 'haccp', 'temperature'],
category: 'Safety & Hygiene',
difficulty: 'intermediate',
outline: ['Safe holding temperatures', 'Storage and labelling', 'Cleaning and sanitising'],
proof: 'photo',
},
{
terms: ['hazard', 'ppe', 'manual handling', 'lifting', 'fire', 'first aid', 'accident'],
category: 'Safety & Hygiene',
difficulty: 'intermediate',
outline: ['Spotting the hazard', 'The safe method', 'What to do when it goes wrong'],
proof: 'photo_identify',
},
{
terms: ['knife', 'cut', 'chop', 'prep', 'plating'],
category: 'Kitchen',
difficulty: 'beginner',
outline: ['Grip and posture', 'The basic cuts', 'Working safely at speed'],
proof: 'video',
},
{
terms: ['guest', 'customer', 'service', 'complaint', 'front of house', 'hospitality'],
category: 'Guest Service',
difficulty: 'intermediate',
outline: ['Reading the guest', 'Handling a complaint', 'Recovering the experience'],
proof: 'roleplay',
},
{
terms: ['bar', 'cocktail', 'alcohol', 'licensing', 'pour'],
category: 'Bar',
difficulty: 'intermediate',
outline: ['Responsible service', 'Standard pours and specs', 'Refusing service'],
proof: 'roleplay',
},
{
terms: ['clean', 'housekeep', 'laundry', 'linen'],
category: 'Cleaning',
difficulty: 'beginner',
outline: ['The cleaning sequence', 'Chemicals and dilution', 'Checking your own work'],
proof: 'photo',
},
{
terms: ['till', 'cash', 'payment', 'pos', 'checkout'],
category: 'Operations',
difficulty: 'beginner',
outline: ['Opening and closing the till', 'Taking payment accurately', 'Handling discrepancies'],
proof: 'roleplay',
},
];
/** How each proof method reads when Owliver describes it in a sentence. */
export const VERIFICATION_LABEL = {
roleplay: 'Scenario-based assessment',
video: 'Recorded demonstration',
photo: 'Photo evidence',
photo_identify: 'Hazard identification',
};
const DIFFICULTIES = ['beginner', 'intermediate', 'advanced'];
/** The template whose terms the text matches, or null. Longest term wins. */
function matchTemplate(text) {
const q = String(text).toLowerCase();
let best = null;
for (const template of SKILL_TEMPLATES) {
for (const term of template.terms) {
if (q.includes(term) && (!best || term.length > best.term.length)) {
best = { ...template, term };
}
}
}
return best;
}
/**
* Everything the Add Skill Training flow can be given from one sentence.
*
* The name and any category the library already uses come from the request
* itself. A level, a starting outline and a proof method come from the template
* the subject matches, and are absent when it matches none — the flow exists to
* walk the author through those decisions, so a blank field is a prompt rather
* than a gap.
*/
export function buildSkillPrefill(question, categories = []) {
const title = extractSkillName(question) || extractReviewSubject(question);
const q = String(question).toLowerCase();
/* Longest match wins, so "front of house" beats "house". */
let category = null;
for (const candidate of categories) {
const name = String(candidate).toLowerCase();
if (name && q.includes(name) && (!category || name.length > category.length)) {
category = candidate;
}
}
const template = matchTemplate(`${title || ''} ${question}`);
/* A level the author names outranks the template's. */
const stated = DIFFICULTIES.find((d) => q.includes(d));
const difficulty = stated || template?.difficulty || null;
const outline = template?.outline || [];
const challengeType = template?.proof || null;
const prefill = {};
if (title) prefill.title = title;
if (category || template?.category) prefill.category = category || template.category;
if (difficulty) prefill.difficulty = difficulty;
if (outline.length) prefill.outline = [...outline];
if (challengeType) prefill.challengeType = challengeType;
return {
prefill,
title,
category: prefill.category || null,
difficulty,
outline,
challengeType,
verification: challengeType ? VERIFICATION_LABEL[challengeType] : null,
};
}
/**
* An existing skill, as the authoring flow's own fields.
*
* Adding training to a published skill edits that record rather than creating a
* second one, so the flow has to open holding everything the record already
* says. Suggested steps are added only when the skill carries no outline of its
* own — Owliver does not overwrite an author's work with a template.
*/
export function buildTrainingPrefill(course) {
if (!course) return { prefill: {}, suggested: [] };
const existing = (Array.isArray(course.training_outline) ? course.training_outline : []).filter(Boolean);
const template = existing.length
? null
: matchTemplate(`${course.title || ''} ${course.category || ''} ${course.description || ''}`);
const suggested = template?.outline || [];
const rubric = (course.challenge?.rubric || []).map((r) => r.criterion).filter(Boolean);
return {
prefill: {
courseId: course.id,
title: course.title || '',
category: course.category || '',
difficulty: course.difficulty || 'beginner',
description: course.description || '',
proof_skill: course.proof_skill || '',
outline: existing.length ? existing : suggested.length ? [...suggested] : [''],
estimated_minutes: String(course.estimated_minutes || 15),
challengeType: course.challenge?.type || 'roleplay',
prompt: course.challenge?.prompt || '',
criteria: rubric.length ? rubric : ['', '', ''],
pass_score: String(course.pass_score || 70),
badge_reward: course.badge_reward || '',
},
suggested,
};
}
/**
* The skill in the library a request names, or null.
*
* Matched against the records that exist rather than a list written here, so a
* skill added this morning is addressable this afternoon. Longest title wins,
* so "Knife Skills — Advanced" beats "Knife Skills".
*/
export function findCourseByName(question, courses = []) {
const q = String(question).toLowerCase();
let best = null;
for (const course of courses) {
const title = String(course.title || '').toLowerCase();
if (title.length >= 3 && q.includes(title) && (!best || title.length > best.title.length)) {
best = { course, title };
}
}
return best?.course || null;
}
/* ── Action implementations ─────────────────────────────────────────────── */
/**
* The handlers a skill may name. Each returns a plain description of what
* should happen; the panel performs it with the router it already has.
*/
const HANDLERS = {
/**
* Write the position the conversation collected.
*
* The one action in this module that results in a record, and it exists
* because the alternative — opening the manual form with fields filled in —
* made the user finish by hand a job they had already described in a sentence.
* The panel performs it with `useCreateJobPosting`, the same mutation the form
* calls, so there is one create path and the list refreshes the same way.
*
* Reached only from the confirmation step, after the position has been read
* back and the user has chosen to create it.
*/
create_position: ({ draft }) => ({
type: 'create_position',
data: toPositionPayload(draft || {}),
}),
/**
* Open the Add Skill Training flow, on the Forge page, with what the request
* already answered. Routing to the page carries the intent in navigation
* state — the panel does not reach into the page, and the page decides what to
* do with it.
*/
open_create_skill_training: ({ prefill }) => ({
type: 'navigate',
route: routeForPageKey('university'),
state: {
openSkillTraining: true,
...(prefill && Object.keys(prefill).length ? { prefill } : {}),
},
}),
/**
* The same flow, opened on its training step for a skill that already exists.
* `courseId` is what makes this an edit of that record rather than a second
* copy of it — the page reads it and saves back to the skill it came from.
*/
open_create_training: ({ prefill, courseId }) => ({
type: 'navigate',
route: routeForPageKey('university'),
state: {
openSkillTraining: true,
startStep: 'training',
...(courseId ? { courseId } : {}),
...(prefill && Object.keys(prefill).length ? { prefill } : {}),
},
}),
navigate_to_positions: () => ({ type: 'navigate', route: routeForPageKey('positions') }),
navigate_to_candidates: () => ({ type: 'navigate', route: routeForPageKey('candidates') }),
navigate_to_forge: () => ({ type: 'navigate', route: routeForPageKey('university') }),
navigate_to_analytics: () => ({ type: 'navigate', route: routeForPageKey('analytics') }),
};
/**
* Resolves an action name to something to do.
*
* `skill.actions` gates it: a skill can only run what its own file declares,
* so a handler existing in code is not the same as a skill being allowed to use
* it. Unknown or undeclared names return null and nothing happens.
*/
export function runAction(name, { skill, ...payload } = {}) {
if (skill && !skill.actions.includes(name)) return null;
const handler = HANDLERS[name];
return handler ? handler(payload) : null;
}
export const ACTION_NAMES = Object.keys(HANDLERS);

View File

@@ -0,0 +1,455 @@
import { doc, list, note, text } from '@/components/ai-assistant/blocks';
import { CERT_OPTIONS, ENGLISH_LEVELS, payLabel } from '@/lib/positionModel';
import {
buildPositionPrefill, extractCertifications, extractEnglish, extractExperience,
extractLocation, extractPay, extractRole,
} from './actions';
/**
* Creating a position, as a conversation.
*
* Owliver used to answer "create a bartender position" by opening the Create
* Position form with three fields filled in — which left the person who had just
* described the job in one sentence to type the rest of it into a drawer. The
* form was doing the asking, and the assistant was doing the paperwork.
*
* This module turns that round. The skill file lists the questions; this decides
* what each field name means — how an answer is read, whether it is already
* settled, and how it reads back in the summary. Same split as `actions.js`:
* Markdown declares, code interprets, and a field the code does not know is
* simply not asked about rather than being handled arbitrarily.
*
* The flow is a plain serializable object. It survives being written to
* sessionStorage between turns, and it holds no component state, so the
* conversation can be resumed exactly where it stopped.
*
* Nothing here writes. The last step returns a draft, and the panel creates the
* position with the same mutation the form uses.
*/
/* ── Reading answers ────────────────────────────────────────────────────── */
/** Title Case, for the free-text answers that name a proper noun. */
const titleCase = (value) => value.replace(/\b\w/g, (c) => c.toUpperCase());
/** A short free-text answer, stripped of the words around it. */
function cleanPhrase(answer, max = 60) {
const value = String(answer)
.trim()
.replace(/^(?:in|at|around|near|it is|its|it's)\s+/i, '')
.replace(/[.!?,;]+$/, '')
.replace(/\s+/g, ' ');
return value && value.length <= max ? value : null;
}
/** "Skip" is an answer to an optional question — it settles it, unanswered. */
const SKIP = /^(?:skip|skip this|skip it|no preference|not sure|does ?n(?:'|o)t matter|any|none of these)$/i;
/** A chip that means "let me type it" rather than an answer in itself. */
const FREE_TEXT = /^(?:other|another|custom|enter|enter .*|type .*|somewhere else)$/i;
/**
* What each field means: when it is already settled, how an answer is read, and
* how it reads back.
*
* `parse` returns a patch for the draft, or `null` when the answer was not
* understood — which re-asks with `retry` rather than storing a guess.
*/
const FIELDS = {
role_category: {
label: 'Role',
settled: (draft) => Boolean(draft.title),
retry: 'Name the role — "bartender", "line cook", "event staff".',
parse: (answer, { roles }) => {
const role = extractRole(answer, roles) || cleanPhrase(answer, 40);
if (!role || role.length < 2) return null;
const known = roles.find((r) => String(r).toLowerCase() === role.toLowerCase());
/* A known category files the position; anything else becomes the title and
leaves the category at its default, exactly as the form behaves. */
return known
? { role_category: known, title: known }
: { title: titleCase(role) };
},
summary: (draft) => (draft.role_category && draft.role_category !== draft.title
? `${draft.title} · ${draft.role_category}`
: draft.title),
},
location: {
label: 'Location',
settled: (draft) => Boolean(draft.location),
retry: 'Type the city or area this role is based in.',
parse: (answer) => {
if (FREE_TEXT.test(String(answer).trim())) return null;
const value = extractLocation(answer) || cleanPhrase(answer);
return value ? { location: titleCase(value) } : null;
},
summary: (draft) => draft.location,
},
pay: {
label: 'Pay',
settled: (draft) => Number(draft.pay_range_min) > 0 || Number(draft.pay_range_max) > 0,
retry: 'Give a range like "$28–$36/hr", or a single rate.',
parse: (answer) => {
const pay = extractPay(answer);
return pay ? { pay_range_min: String(pay.min), pay_range_max: String(pay.max) } : null;
},
summary: (draft) => payLabel(draft),
},
min_experience_years: {
label: 'Experience',
settled: (draft) => draft.min_experience_years !== undefined && draft.min_experience_years !== null,
retry: 'How many years — or "no minimum".',
parse: (answer) => {
const years = extractExperience(answer);
if (years !== null) return { min_experience_years: years };
const bare = /^(\d{1,2})\s*\+?$/.exec(String(answer).trim());
return bare ? { min_experience_years: Number(bare[1]) } : null;
},
summary: (draft) => (draft.min_experience_years
? `${draft.min_experience_years}+ years experience`
: 'No minimum experience'),
},
english_required: {
label: 'English',
settled: (draft) => Boolean(draft.english_required),
retry: `One of ${ENGLISH_LEVELS.map((l) => l.label).join(', ')}.`,
parse: (answer) => {
const level = extractEnglish(answer)
|| ENGLISH_LEVELS.find((l) => l.label.toLowerCase() === String(answer).trim().toLowerCase())?.value;
return level ? { english_required: level } : null;
},
summary: (draft) => {
const level = ENGLISH_LEVELS.find((l) => l.value === draft.english_required);
return level ? `English: ${level.label}` : null;
},
},
certifications_required: {
label: 'Certifications',
settled: (draft) => Array.isArray(draft.certifications_required),
retry: 'Name a certification, or "none".',
parse: (answer) => {
const found = extractCertifications(answer);
if (found) return { certifications_required: found };
if (/^(?:none|no|no certifications?)$/i.test(String(answer).trim())) {
return { certifications_required: [] };
}
return null;
},
summary: (draft) => (draft.certifications_required?.length
? `Certifications: ${draft.certifications_required.join(', ')}`
: null),
},
};
/* ── Suggestions ────────────────────────────────────────────────────────── */
/**
* A step's chips.
*
* `@`-prefixed entries in the skill file resolve to the lists the application
* already owns, so the roles offered here are the roles the form offers and a
* certification added to the record's options appears in the conversation
* without this file or the skill file changing.
*/
function suggestionsFor(step, { roles }) {
const resolved = step.options.flatMap((option) => {
if (option === '@roles') return roles;
if (option === '@english') return ENGLISH_LEVELS.map((l) => l.label);
if (option === '@certifications') return CERT_OPTIONS;
return [option];
});
const capped = [...new Set(resolved)].slice(0, 6);
/* An optional question needs a way past it that is not a typed sentence. */
if (!step.required) capped.push('Skip');
return capped.map((label) => ({ label, prompt: label }));
}
/* ── Flow state ─────────────────────────────────────────────────────────── */
/** The steps a skill declares, keeping only the fields this module understands. */
const stepsOf = (skill) => (skill?.conversation || []).filter((s) => FIELDS[s.field]);
/** Is this field answered, or deliberately passed over? */
const isSettled = (flow, field) => FIELDS[field].settled(flow.draft) || flow.skipped.includes(field);
/** The next question, or `null` when there is nothing left to ask. */
function nextStep(flow, steps) {
if (flow.editing) return steps.find((s) => s.field === flow.editing) || null;
return steps.find((s) => !isSettled(flow, s.field)) || null;
}
/** Everything decided so far, one line each, in the order it is asked. */
function summaryLines(flow, steps) {
return steps
.filter((s) => isSettled(flow, s.field))
.map((s) => FIELDS[s.field].summary(flow.draft))
.filter(Boolean);
}
/** The required fields a position cannot be created without. */
const missingRequired = (flow, steps) => steps.filter((s) => s.required && !FIELDS[s.field].settled(flow.draft));
/* ── Replies ────────────────────────────────────────────────────────────── */
/** Ask the next question, or read the position back when there is none left. */
function ask(flow, steps, { roles, preamble = null, retry = null }) {
const step = nextStep(flow, steps);
if (!step) return review(flow, steps);
return {
flow: { ...flow, stage: 'collect', step: step.field },
doc: doc(
preamble ? text(preamble) : null,
preamble && summaryLines(flow, steps).length ? list(summaryLines(flow, steps)) : null,
text(step.question),
retry ? note(retry) : null
),
followUp: suggestionsFor(step, { roles }),
};
}
/** The whole position, before anything is written. */
function review(flow, steps) {
const missing = missingRequired(flow, steps);
/* Only reachable if a required answer was cleared — ask for it rather than
offering to create something incomplete. */
if (missing.length) {
return {
flow: { ...flow, stage: 'collect', step: missing[0].field, editing: null },
doc: doc(text(missing[0].question)),
followUp: [],
};
}
return {
flow: { ...flow, stage: 'review', step: null, editing: null },
doc: doc(
text('Ready to create this position?'),
list(summaryLines(flow, steps)),
note('Nothing is saved until you choose Create position.')
),
followUp: [
{ label: 'Create position', prompt: 'Create position' },
{ label: 'Change details', prompt: 'Change details' },
],
};
}
/** Which detail to change — the answers already given, as chips. */
function changeMenu(flow, steps) {
const settled = steps.filter((s) => isSettled(flow, s.field));
return {
flow: { ...flow, stage: 'change', step: null },
doc: doc(text('What should I change?')),
followUp: settled.map((s) => ({ label: FIELDS[s.field].label, prompt: FIELDS[s.field].label })),
};
}
/* ── Entry points ───────────────────────────────────────────────────────── */
/**
* Start collecting, from whatever the request already said.
*
* "Create a bartender position in Chennai paying $30–$40/hr" answers three
* questions before the first one is asked, and those are not asked again.
*/
export function beginPositionFlow({ question, skill, roles = [] }) {
const { prefill } = buildPositionPrefill(question, roles);
const steps = stepsOf(skill);
const flow = {
skillId: skill.id,
draft: prefill,
skipped: [],
editing: null,
stage: 'collect',
step: null,
};
const known = summaryLines(flow, steps);
return ask(flow, steps, {
roles,
preamble: known.length ? `Got it — here is what I have so far:` : null,
});
}
/**
* One answer, and whatever it makes the next thing to say.
*
* Returns `{ flow, doc, followUp }`, and on the confirmation step additionally
* `create: { draft }` — the panel writes it, this module does not.
*/
export function advancePositionFlow({ flow, answer, skill, roles = [] }) {
const steps = stepsOf(skill);
const said = String(answer).trim();
/* A way out that does not require finishing. `flow: null` ends it, and the
next question is answered by the page as usual. */
if (/^(?:cancel|stop|never ?mind|nevermind|forget it|quit|exit)$/i.test(said)) {
return {
flow: null,
doc: doc(
text('Stopped — nothing was created.'),
note('Ask me to create a position whenever you are ready.')
),
followUp: [],
};
}
/* The confirmation step. "Create position" is the only path to a record. */
if (flow.stage === 'review') {
if (/^(?:create position|create|create it|yes|confirm|looks good|go ahead)$/i.test(said)) {
if (missingRequired(flow, steps).length) return review(flow, steps);
return { flow: { ...flow, stage: 'creating' }, create: { draft: flow.draft } };
}
if (/^(?:change|change details|edit|change something|no)$/i.test(said)) return changeMenu(flow, steps);
/* Anything else at the confirmation step is a correction stated outright —
"make it Bengaluru", "$32–$40". Read it against every field and apply
what it settles, rather than making the user find the menu. */
const revised = applyStatement(flow, steps, said, roles);
if (revised) return review(revised, steps);
return {
...review(flow, steps),
doc: doc(
text('I did not catch that. Ready to create this position?'),
list(summaryLines(flow, steps)),
note('Choose Create position, or tell me what to change.')
),
};
}
/* Choosing which detail to revisit. */
if (flow.stage === 'change') {
const target = steps.find((s) => FIELDS[s.field].label.toLowerCase() === said.toLowerCase());
if (!target) {
const revised = applyStatement(flow, steps, said, roles);
if (revised) return review(revised, steps);
return changeMenu(flow, steps);
}
return ask({ ...flow, editing: target.field }, steps, { roles });
}
/* Answering the question that was asked. */
const step = steps.find((s) => s.field === flow.step) || nextStep(flow, steps);
if (!step) return review(flow, steps);
const field = FIELDS[step.field];
if (SKIP.test(said) && !step.required) {
const next = { ...flow, skipped: [...flow.skipped, step.field], editing: null };
return ask(next, steps, { roles });
}
const patch = field.parse(said, { roles });
if (!patch) {
/* Not an answer to this question — but it may still be a fact about the
position ("in Chennai" while being asked for pay). Take it if so. */
const revised = applyStatement(flow, steps, said, roles);
if (revised) return ask(revised, steps, { roles });
return ask(flow, steps, { roles, retry: field.retry });
}
const next = {
...flow,
draft: { ...flow.draft, ...patch },
skipped: flow.skipped.filter((f) => f !== step.field),
editing: null,
};
/* A detail revisited from the change menu goes straight back to the summary
rather than walking the rest of the questions again. */
if (flow.editing) return review(next, steps);
return ask(next, steps, { roles });
}
/**
* A sentence read against every field at once.
*
* This is what makes the conversation forgiving: an answer that arrives out of
* order, or a correction stated rather than chosen from a menu, lands in the
* right field instead of being rejected for not answering the question asked.
* Returns an updated flow, or `null` when the sentence settles nothing.
*/
function applyStatement(flow, steps, said, roles) {
const { prefill } = buildPositionPrefill(said, roles);
const fields = new Set(steps.map((s) => s.field));
const patch = {};
/* Step names, not record fields — a step the sentence settled must come off
the skipped list so the summary shows it. */
const touched = new Set();
if (fields.has('role_category') && prefill.title) {
if (prefill.role_category) patch.role_category = prefill.role_category;
patch.title = prefill.title;
touched.add('role_category');
}
if (fields.has('location') && prefill.location) {
patch.location = prefill.location;
touched.add('location');
}
if (fields.has('pay') && prefill.pay_range_min) {
patch.pay_range_min = prefill.pay_range_min;
patch.pay_range_max = prefill.pay_range_max;
touched.add('pay');
}
if (fields.has('min_experience_years') && prefill.min_experience_years !== undefined) {
patch.min_experience_years = prefill.min_experience_years;
touched.add('min_experience_years');
}
if (fields.has('english_required') && prefill.english_required) {
patch.english_required = prefill.english_required;
touched.add('english_required');
}
if (fields.has('certifications_required') && prefill.certifications_required) {
patch.certifications_required = prefill.certifications_required;
touched.add('certifications_required');
}
if (!touched.size) return null;
return {
...flow,
draft: { ...flow.draft, ...patch },
skipped: flow.skipped.filter((f) => !touched.has(f)),
editing: null,
};
}
/* ── Outcomes ───────────────────────────────────────────────────────────── */
/** The position exists. Said plainly, with what was created. */
export function positionCreatedReply(position) {
return doc(
text('Position created successfully.'),
list([
position.title,
position.location,
payLabel(position),
].filter(Boolean)),
note('It is on the Positions list now — applications will start appearing against it.')
);
}
/** The write failed. The draft is kept, so the answers are not lost. */
export function positionFailedReply() {
return doc(
text('I could not create that position.'),
note('Nothing was saved. Choose Create position to try again.')
);
}
/** What the panel offers after a position is created. */
export const createdFollowUp = (position) => [
{ label: 'View position', route: `/admin/positions/${position.id}` },
];

254
src/lib/skills/registry.js Normal file
View File

@@ -0,0 +1,254 @@
import { PLACEMENT_ROUTES } from '@/components/ai-assistant/placement';
/**
* Owliver skill registry.
*
* A skill is a Markdown file: frontmatter declares what it is and where it
* applies, the body documents what it can do. Nothing here executes Markdown —
* a skill names actions, and `actions.js` decides what those names mean. The
* split is the point: adding a capability is a file, not a change to Owliver.
*
* Registration is the filesystem. `import.meta.glob` picks up every file under
* `src/skills/`, so a new page's skill is registered by existing, and no
* component contains a route check to go with it.
*/
const FILES = import.meta.glob('/src/skills/**/*.md', { query: '?raw', import: 'default', eager: true });
/**
* Frontmatter, parsed to the subset the format actually uses: `key: value` and
* `key:` followed by an indented `- item` list.
*
* Deliberately not a YAML library — the app has none, this needs no dependency,
* and a skill file that reaches for anchors or nested maps has outgrown being a
* declaration anyway.
*/
function parseFrontmatter(raw) {
const match = /^---\r?\n([\s\S]*?)\r?\n---/.exec(raw);
if (!match) return { data: {}, body: raw };
const data = {};
let listKey = null;
for (const line of match[1].split(/\r?\n/)) {
if (!line.trim()) continue;
const item = /^\s*-\s+(.*)$/.exec(line);
if (item && listKey) {
data[listKey].push(item[1].trim());
continue;
}
const pair = /^([A-Za-z0-9_-]+):\s*(.*)$/.exec(line);
if (!pair) continue;
const [, key, value] = pair;
if (value === '') {
listKey = key;
data[key] = [];
} else {
listKey = null;
data[key] = value.trim();
}
}
return { data, body: raw.slice(match[0].length).trim() };
}
/** Bullets under a `## Heading`, for the capability list shown in Settings. */
function sectionBullets(body, heading) {
const section = new RegExp(`##\\s+${heading}\\s*\\n([\\s\\S]*?)(?=\\n##\\s|$)`, 'i').exec(body);
if (!section) return [];
return section[1]
.split(/\r?\n/)
.map((l) => /^\s*-\s+(.*)$/.exec(l)?.[1]?.trim())
.filter(Boolean);
}
/**
* The questions a skill asks, from its `## Conversation` section.
*
* One bullet per question: `field | question | suggestions | required?`, where
* suggestions are separated by `;`. A skill that declares no conversation gets
* an empty list and behaves as it always did — this is additive.
*
* Nothing is executed and no suggestion is interpreted here. What a field name
* means, and what a suggestion resolves to, is `positionFlow.js`'s decision, the
* same way `actions.js` is the only place an action name means anything.
*/
function sectionSteps(body, heading) {
return sectionBullets(body, heading)
.map((line) => {
const [field, question, options = '', flag = ''] = line.split('|').map((p) => p.trim());
if (!field || !question) return null;
return {
field,
question,
options: options.split(';').map((o) => o.trim()).filter(Boolean),
required: !/^optional$/i.test(flag),
};
})
.filter(Boolean);
}
/**
* The page key a route belongs to — `/admin/positions` → `positions`.
*
* Derived from the placement table rather than written down again, so a route
* added there is addressable by a skill without touching this file.
*/
export function pageKeyForRoute(route) {
const tail = route.replace(/^\/admin\/?/, '');
return tail === '' ? 'control-center' : tail;
}
/** contextId → page key, for resolving skills from the assistant's context. */
const PAGE_KEY_BY_CONTEXT = Object.entries(PLACEMENT_ROUTES).reduce((acc, [route, contextId]) => {
acc[contextId] = pageKeyForRoute(route);
return acc;
}, {});
/** page key → route, so an action can navigate without hard-coding a path. */
const ROUTE_BY_PAGE_KEY = Object.entries(PLACEMENT_ROUTES).reduce((acc, [route, contextId]) => {
acc[pageKeyForRoute(route)] = { route, contextId };
return acc;
}, {});
export const routeForPageKey = (key) => ROUTE_BY_PAGE_KEY[key]?.route ?? null;
export const pageKeyForContext = (contextId) => PAGE_KEY_BY_CONTEXT[contextId] ?? null;
/**
* One Markdown definition → one skill.
*
* Shared by the files on disk and by anything added at runtime, so a skill
* written in the Add Skill dialog is parsed by exactly the same code as
* `create-position.md` and cannot drift into a second format.
*/
export function parseSkill(raw, { path = 'custom', custom = false } = {}) {
{
const { data, body } = parseFrontmatter(raw);
const pages = Array.isArray(data.pages) ? data.pages : [];
return {
id: data.id || path.split('/').pop().replace(/\.md$/, ''),
name: data.name || 'Untitled skill',
description: data.description || '',
status: data.status === 'inactive' ? 'inactive' : 'active',
pages,
/* The page label Settings shows, taken from the context the page carries
so the two never disagree. */
actions: Array.isArray(data.actions) ? data.actions : [],
/* A skill with no declared triggers answers to its own name, so a
definition that omits the field is still reachable by asking for it.
Explicit triggers replace the fallback rather than adding to it. */
triggers: (Array.isArray(data.triggers) && data.triggers.length
? data.triggers
: [data.name].filter(Boolean)
).map((t) => String(t).toLowerCase()),
prompt: data.prompt || null,
capabilities: sectionBullets(body, 'Capabilities'),
purpose: sectionBullets(body, 'Purpose'),
/* The questions this skill asks, when it collects its input in the chat
rather than by opening something. */
conversation: sectionSteps(body, 'Conversation'),
path,
body,
custom,
};
}
}
/** Every skill on disk, parsed once at module load. */
export const SKILLS = Object.entries(FILES)
.map(([path, raw]) => parseSkill(raw, { path }))
.sort((a, b) => a.name.localeCompare(b.name));
/**
* The built-in skills plus any the account has added.
*
* Custom definitions are stored as their Markdown source, so what is persisted
* is the same artefact a file on disk would be — nothing is half-parsed into a
* bespoke record. A custom skill sharing an id with a built-in replaces it,
* which is how one would be overridden without editing the repository.
*/
export function allSkills(customSources = []) {
const custom = customSources
.map((entry, i) => {
try {
return parseSkill(entry.raw ?? entry, { path: entry.path || `custom/${i}.md`, custom: true });
} catch {
return null;
}
})
.filter((s) => s && s.id);
const byId = new Map(SKILLS.map((s) => [s.id, s]));
custom.forEach((s) => byId.set(s.id, s));
return [...byId.values()].sort((a, b) => a.name.localeCompare(b.name));
}
/** Validates a definition before it is stored. Returns an error string or null. */
export function validateSkillSource(raw) {
if (!String(raw).trim()) return 'Paste or upload a Markdown definition.';
let skill;
try {
skill = parseSkill(raw, { custom: true });
} catch {
return 'That definition could not be parsed.';
}
if (!skill.id) return 'The frontmatter needs an `id`.';
if (!/^[a-z0-9][a-z0-9-]*$/.test(skill.id)) return '`id` must be lower-case letters, numbers and dashes.';
if (!skill.name) return 'The frontmatter needs a `name`.';
if (!skill.pages.length) return 'The frontmatter needs at least one `pages` entry.';
const unknown = skill.pages.filter((p) => !ROUTE_BY_PAGE_KEY[p]);
if (unknown.length) {
return `Unknown page${unknown.length > 1 ? 's' : ''}: ${unknown.join(', ')}. Known pages: ${Object.keys(ROUTE_BY_PAGE_KEY).join(', ')}.`;
}
return null;
}
/** Page keys a skill may attach to, for the dialog's own guidance. */
export const PAGE_KEYS = Object.keys(ROUTE_BY_PAGE_KEY).sort();
/** Context ids a skill applies to, resolved through the placement table. */
export function contextIdsForSkill(skill) {
return skill.pages.map((key) => ROUTE_BY_PAGE_KEY[key]?.contextId).filter(Boolean);
}
/**
* Skills available on a page.
*
* `disabled` is the account's list of switched-off skill ids, so a skill can be
* turned off from Settings without being deleted from disk.
*/
export function skillsForContext(contextId, disabled = [], customSources = []) {
const pageKey = PAGE_KEY_BY_CONTEXT[contextId];
if (!pageKey) return [];
return allSkills(customSources).filter(
(s) => s.status === 'active' && !disabled.includes(s.id) && s.pages.includes(pageKey)
);
}
/**
* Does one trigger match?
*
* A trigger is a phrase. `*` stands for "anything in between", which is what
* lets one line cover "create a position", "create a chef position" and
* "create a bartender position in Chennai" without listing every role — the
* roles come from data, so they must not be written into triggers.
*/
function triggerMatches(trigger, question) {
if (!trigger.includes('*')) return question.includes(trigger);
const pattern = trigger
.split('*')
.map((part) => part.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))
.join('[\\s\\S]{0,40}?');
return new RegExp(pattern).test(question);
}
/** The first skill on this page whose triggers match the question. */
export function matchSkill(question, contextId, disabled = [], customSources = []) {
const q = String(question).toLowerCase();
return skillsForContext(contextId, disabled, customSources).find(
(s) => s.triggers.length > 0 && s.triggers.some((t) => triggerMatches(t, q))
) ?? null;
}

View File

@@ -1,47 +1,36 @@
import React, { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { useLocation, useNavigate } from 'react-router-dom';
import { Sparkles, ChevronDown, ChevronUp, Sliders, Loader2, Star } from 'lucide-react';
import { useCreateJobPosting, useUpdateJobPosting, useGenerateJobDescription, useRoleCategories, useCreateRoleCategory } from '@/lib/krowHooks';
import { toast } from 'react-hot-toast';
import { ROLE_CATEGORIES } from '@/lib/roleCategories';
import { CERT_OPTIONS, CRITERIA_LABELS, ENGLISH_LEVELS, defaultPosition } from '@/lib/positionModel';
const ROLE_CATEGORIES = ['Server', 'Bartender', 'Chef', 'Security', 'Picker', 'Event Staff', 'Other'];
const CERT_OPTIONS = ['Food Handler Card', 'ServSafe', 'ABC License', 'TIPS Certified', 'CPR/First Aid', 'Background Check Cleared'];
const ENGLISH_LEVELS = [
{ value: 'basic', label: 'Basic' },
{ value: 'conversational', label: 'Conversational' },
{ value: 'fluent', label: 'Fluent' },
{ value: 'native', label: 'Native' },
];
const CRITERIA_LABELS = {
experience: 'Experience',
english: 'English',
reliability: 'Reliability',
certifications: 'Certifications',
availability: 'Availability',
};
export default function CreatePosition() {
/**
* Create a Position.
*
* The manual form, reachable at `/admin/positions/new`. `embedded` drops the
* page header for a host that supplies its own, and `onDone` lets that host
* close it instead of navigating away.
*
* Owliver does not open this form. Asking Owliver for a position is a
* conversation that ends in the same record (see `lib/skills/positionFlow.js`),
* and both paths share one field definition — `lib/positionModel.js` — so
* neither can drift into a different shape of position.
*/
export default function CreatePosition({ prefill: prefillProp, embedded = false, onDone }) {
const navigate = useNavigate();
/* Owliver opens this page with the fields it could infer from the request.
It is a starting point, not a submission: every field stays editable and
the position is still created by the button at the bottom. */
const { state } = useLocation();
const prefill = prefillProp || state?.prefill || null;
const createJob = useCreateJobPosting();
const updateJob = useUpdateJobPosting();
const generateDesc = useGenerateJobDescription();
const [form, setForm] = useState({
title: '',
role_category: 'Server',
custom_requirements: '',
physical_requirements: '',
leadership_expectations: '',
attendance_expectations: '',
min_experience_years: 0,
english_required: 'basic',
location: '',
pay_range_min: '',
pay_range_max: '',
certifications_required: [],
vetting_criteria: { experience: 25, english: 20, reliability: 20, certifications: 20, availability: 15 },
});
const [form, setForm] = useState({ ...defaultPosition(), ...prefill });
const { data: customCategories = [] } = useRoleCategories();
const createRoleCategory = useCreateRoleCategory();
const [newCategory, setNewCategory] = useState('');
@@ -111,10 +100,14 @@ export default function CreatePosition() {
createdId = created?.id;
}
toast.success(status === 'active' ? 'Job posting published!' : 'Saved as draft');
if (status === 'active' && createdId) {
navigate(`/positions/${createdId}`);
/* Inline, the list behind the form is already the destination — closing
returns to it with the new position in place. */
if (onDone) {
onDone(createdId);
} else if (status === 'active' && createdId) {
navigate(`/admin/positions/${createdId}`);
} else {
navigate('/positions');
navigate('/admin/positions');
}
} catch (e) {
toast.error('Failed to save');
@@ -122,18 +115,22 @@ export default function CreatePosition() {
};
return (
<div className="max-w-3xl mx-auto">
<div className={embedded ? '' : 'max-w-3xl mx-auto'}>
{/* Header */}
<div className="flex items-center justify-between mb-6">
<div>
<h1 className="text-[22px] font-bold text-[#111827]">Create a Position</h1>
<p className="text-[13px] text-[#6B7280] mt-0.5">Set hiring criteria once — KROW interviews and ranks every applicant automatically.</p>
{!embedded && (
<div className="flex items-center justify-between mb-6">
<div>
<h1 className="text-[22px] font-bold text-[#111827]">Create a Position</h1>
<p className="text-[13px] text-[#6B7280] mt-0.5">Set hiring criteria once — KROW interviews and ranks every applicant automatically.</p>
</div>
<button onClick={() => navigate('/admin/positions')} className="text-[13px] text-[#6B7280] hover:text-[#111827]">Cancel</button>
</div>
<button onClick={() => navigate('/positions')} className="text-[13px] text-[#6B7280] hover:text-[#111827]">Cancel</button>
</div>
)}
{/* Card */}
<div className="bg-white border border-[#E5E7EB] rounded-2xl p-6 md:p-8 space-y-6">
<div className={embedded
? 'space-y-6'
: 'bg-white border border-[#E5E7EB] rounded-2xl p-6 md:p-8 space-y-6'}>
{/* Section Header */}
<div className="flex items-center gap-2">
<div className="w-7 h-7 rounded-lg bg-[#EFF6FF] flex items-center justify-center">

View File

@@ -1,233 +1,239 @@
import React, { useState, useMemo, useRef } from 'react';
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useLocation, useNavigate } from 'react-router-dom';
import { Loader2 } from 'lucide-react';
import { useCourses, useWorkerProfile, useEvidenceList } from '@/lib/krowHooks';
import { isUnlocked } from '@/lib/provingGround';
import { useAssistantPanel } from '@/components/ai-assistant';
import { useCourses, useCreateCourse, useUpdateCourse, useWorkerProfiles } from '@/lib/krowHooks';
import {
buildRequirements, ChallengeDirectory, ForgeHeader, ForgeInsight, ForgeProgress, NextSkill,
YourForge,
AddSkillTraining, ForgeHeader, ForgeProgress, ForgeRail, SkillDetail, SkillLibrary,
statusOf, workforceUsage,
} from '@/components/forge';
/**
* KROW Forge — the skill verification workspace.
* KROW Forge — workforce skill verification, from the managing side.
*
* The page is a progression rather than a dashboard, and it reads in one
* direction:
* The administrator's job here is not to complete training. It is to decide what
* the workforce must be able to do, build the training that teaches it, define
* the evidence that proves it, tell Owliver what to look for, and publish. The
* page is arranged as that job:
*
* header what this is
* progress the four figures, and the journey they describe
* your Forge what is verified — or the process that verifies the first one
* next skill the one skill to prove now: what it tests, how proof is
* submitted, who grades it, what is blocking it ← the focus
* directory every other challenge, searchable
* insight what the record means for the work they can get
* header what this is, and the two ways in
* overview the state of the library, in four counted figures
* library every skill, filterable — the working surface
* rail the authoring shape, the rules, and the way to Owliver
*
* Owliver is the evaluator throughout — named on the proof step here, and
* running the challenge itself on the challenge page. Nothing on this page
* duplicates that component.
* Everything on screen is derived here from the Course records the platform
* already holds and the worker profiles that record what the workforce did with
* them. The components only arrange what they are given, and no figure on this
* page is an estimate.
*
* Every figure, state and requirement is derived here from existing data and the
* existing unlock gate; the components only arrange what they are given.
* The worker's side of the same records — the challenge itself, submitting
* evidence, Owliver's verdict — is unchanged and still lives on the challenge
* page at `/admin/university/:id`.
*/
const DIFFS = [
{ v: 'all', label: 'All' },
{ v: 'beginner', label: 'Beginner' },
{ v: 'intermediate', label: 'Intermediate' },
{ v: 'advanced', label: 'Advanced' },
];
/** Badges carry a full date; the record only needs the month. */
const earnedOn = (value) => {
if (!value) return 'Jul 2026';
const d = new Date(value);
return Number.isNaN(d.getTime())
? value
: d.toLocaleDateString(undefined, { month: 'short', year: 'numeric' });
};
/** Publication states, in the order an administrator thinks about them. */
const STATUS_ORDER = ['Published', 'Draft', 'Archived'];
export default function University() {
const { data: profile } = useWorkerProfile();
const { data: courses = [], isLoading: loadingCourses } = useCourses();
const { data: evidence = [] } = useEvidenceList(profile?.email);
const navigate = useNavigate();
const location = useLocation();
const panel = useAssistantPanel();
const { data: courses = [], isLoading } = useCourses();
const { data: profiles = [] } = useWorkerProfiles();
const createCourse = useCreateCourse();
const updateCourse = useUpdateCourse();
const [search, setSearch] = useState('');
const [diffFilter, setDiffFilter] = useState('all');
const challengesRef = useRef(null);
const nextSkillRef = useRef(null);
const [category, setCategory] = useState('all');
const [status, setStatus] = useState('all');
const [creating, setCreating] = useState(false);
const [intent, setIntent] = useState(null);
const [selected, setSelected] = useState(null);
const libraryRef = useRef(null);
const completedIds = useMemo(
() => new Set((profile?.completed_courses || []).map((c) => c.course_id)),
[profile]
);
const activeCourses = useMemo(
() => courses.filter((c) => c.status === 'active'),
[courses]
);
const verifiedSkills = useMemo(() => {
return profile?.earned_badges || [];
}, [profile]);
const recommendedCourses = useMemo(() => {
return activeCourses
.filter((c) => !completedIds.has(c.id))
.slice(0, 3);
}, [activeCourses, completedIds]);
const scrollToChallenges = () => {
challengesRef.current?.scrollIntoView({ behavior: 'smooth' });
};
const scrollToNext = () => {
nextSkillRef.current?.scrollIntoView({ behavior: 'smooth' });
};
const verifiedCount = verifiedSkills.length;
const inProgressCount = (profile?.completed_courses || []).filter(c => c.status === 'in_progress').length || 2;
const nextUnlockCount = recommendedCourses.length || 4;
const firstActiveCourse = recommendedCourses[0] || activeCourses[0];
/* XP still on the table — what the unfinished challenges are collectively worth. */
const xpAvailable = useMemo(
() => activeCourses.filter((c) => !completedIds.has(c.id)).reduce((sum, c) => sum + (c.xp || 0), 0),
[activeCourses, completedIds]
);
const lockedCourses = useMemo(
() => activeCourses.filter((c) => !isUnlocked(c, profile).unlocked && !completedIds.has(c.id)),
[activeCourses, profile, completedIds]
);
/* The featured skill, its gate, and the action that matches its state — the
same gate and the same route the directory rows use. */
const lead = firstActiveCourse;
const leadLocked = lead ? !isUnlocked(lead, profile).unlocked : false;
const leadInProgress = (profile?.completed_courses || []).some(
(c) => c.course_id === lead?.id && c.status === 'in_progress'
);
const leadRequirements = useMemo(
() => (lead ? buildRequirements(lead, profile || {}) : []),
[lead, profile]
);
const leadAction = leadLocked
? 'View details'
: leadInProgress
? 'Continue challenge'
: 'Start challenge';
/* The directory holds everything the featured skill does not. */
const otherCourses = useMemo(
() => activeCourses.filter((c) => c.id !== lead?.id),
[activeCourses, lead]
);
const filtered = useMemo(() => {
return otherCourses.filter((c) => {
const ms = !search || c.title?.toLowerCase().includes(search.toLowerCase()) || c.category?.toLowerCase().includes(search.toLowerCase()) || c.proof_skill?.toLowerCase().includes(search.toLowerCase());
const md = diffFilter === 'all' || c.difficulty === diffFilter;
return ms && md;
/**
* Owliver opens the authoring flow by navigating here with what it understood
* from the question. It routes rather than reaching into this page, which is
* how every other Owliver action already works — and it fills fields in rather
* than creating anything, so the administrator still decides.
*
* `courseId` means "add training to this skill", so the flow edits that record
* instead of writing a second one with the same name, and opens on the step
* the request was actually about.
*/
useEffect(() => {
if (!location.state?.openSkillTraining) return;
setIntent({
prefill: location.state.prefill || null,
startStep: location.state.startStep || null,
courseId: location.state.courseId || null,
});
}, [otherCourses, search, diffFilter]);
setCreating(true);
navigate(location.pathname, { replace: true, state: null });
}, [location.state, location.pathname, navigate]);
const isFiltered = Boolean(search) || diffFilter !== 'all';
const clearFilters = () => { setSearch(''); setDiffFilter('all'); };
/* Filter options come from the records that exist, so the library never offers
a filter that can only return nothing. */
const categories = useMemo(() => {
const found = [...new Set(courses.map((c) => c.category).filter(Boolean))].sort();
return [{ value: 'all', label: 'All skills' }, ...found.map((c) => ({ value: c, label: c }))];
}, [courses]);
if (!profile && loadingCourses) {
const statuses = useMemo(() => {
const found = new Set(courses.map(statusOf));
return [
{ value: 'all', label: 'All statuses' },
...STATUS_ORDER.filter((s) => found.has(s)).map((s) => ({ value: s, label: s })),
];
}, [courses]);
const results = useMemo(() => {
const q = search.trim().toLowerCase();
return courses.filter((c) => {
const matchesSearch = !q
|| c.title?.toLowerCase().includes(q)
|| c.category?.toLowerCase().includes(q)
|| c.proof_skill?.toLowerCase().includes(q)
|| c.description?.toLowerCase().includes(q);
const matchesCategory = category === 'all' || c.category === category;
const matchesStatus = status === 'all' || statusOf(c) === status;
return matchesSearch && matchesCategory && matchesStatus;
});
}, [courses, search, category, status]);
/**
* The four figures.
*
* Each counts rows: skills in the library, skills carrying training material,
* skills carrying a challenge, and verifications the workforce has actually
* earned. The last one reads worker profiles rather than the library, because
* a verification is something a person holds, not something a skill declares.
*/
const figures = useMemo(() => {
const published = courses.filter((c) => statusOf(c) === 'Published');
const withTraining = courses.filter((c) => c.training_outline?.length || c.quiz?.length);
const withChallenge = courses.filter((c) => c.challenge?.type);
const libraryIds = new Set(courses.map((c) => c.id));
const verified = profiles.reduce(
(sum, p) => sum + (p.earned_badges || []).filter((b) => libraryIds.has(b.course_id)).length,
0
);
return [
{
label: 'Active skills',
value: published.length,
sub: `${courses.length} in the library`,
},
{
label: 'Training modules',
value: withTraining.length,
sub: 'skills carrying material',
},
{
label: 'Challenges',
value: withChallenge.length,
sub: 'evidence-based',
},
{
label: 'Verified skills',
value: verified,
sub: verified ? 'earned by the workforce' : 'none earned yet',
},
];
}, [courses, profiles]);
/* The rail's recent list is the library's own newest records. */
const recent = useMemo(() => courses.slice(0, 4), [courses]);
const usage = useMemo(
() => (selected ? workforceUsage(selected, profiles) : null),
[selected, profiles]
);
const openCreate = useCallback(() => {
setIntent(null);
setCreating(true);
}, []);
const scrollToLibrary = useCallback(() => {
libraryRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' });
}, []);
const clearFilters = useCallback(() => {
setSearch('');
setCategory('all');
setStatus('all');
}, []);
const isFiltered = Boolean(search) || category !== 'all' || status !== 'all';
if (isLoading && !courses.length) {
return (
<div className="flex justify-center items-center py-20 min-h-[400px]">
<Loader2 className="w-7 h-7 animate-spin text-[#0A39DF]" />
<div className="flex min-h-[400px] items-center justify-center py-20">
<Loader2 className="h-7 w-7 animate-spin text-krow-blue" />
</div>
);
}
const figures = [
{ label: 'Verified skills', value: verifiedCount, tone: verifiedCount ? 'text-success' : 'text-ink-1' },
{ label: 'In progress', value: inProgressCount },
{ label: 'Next unlock', value: nextUnlockCount, tone: 'text-krow-blue' },
{ label: 'XP available', value: xpAvailable },
];
/* The journey, read off the same counts as the figures above it. */
const stages = [
{
label: 'Started',
caption: `${activeCourses.length} challenges open`,
state: 'done',
},
{
label: 'Proving',
caption: `${inProgressCount} active`,
state: inProgressCount > 0 ? 'current' : 'upcoming',
},
{
label: 'Next skill',
caption: lead?.title,
state: verifiedCount > 0 ? 'done' : inProgressCount > 0 ? 'upcoming' : 'current',
},
{
label: 'Verified',
caption: verifiedCount > 0
? `${verifiedCount} verified`
: `${lockedCourses.length} still locked`,
state: verifiedCount > 0 ? 'done' : lockedCourses.length > 0 ? 'locked' : 'upcoming',
},
];
return (
/* No max-width here: the Admin shell already supplies the gutters
(px-4 lg:px-6 on a flex-1 main), which is how every other Admin page is
sized. This div only owns vertical rhythm. */
/* No max-width here: the Admin shell already supplies the gutters, which is
how every other Admin page is sized. This div only owns vertical rhythm. */
<div className="space-y-7 pb-12">
{/* 1 ── What this is. */}
<ForgeHeader course={firstActiveCourse} actionLabel="Continue challenge" />
<ForgeHeader onCreate={openCreate} onBrowse={scrollToLibrary} />
{/* 2 ── Where the worker stands, and where it leads. */}
<ForgeProgress figures={figures} stages={stages} loading={loadingCourses} />
<ForgeProgress figures={figures} loading={isLoading} />
{/* 3 ── The record, or the process that creates it. */}
<YourForge
skills={verifiedSkills}
evidenceCount={evidence.length}
onBrowse={scrollToChallenges}
formatDate={earnedOn}
/>
{/* 4 ── The skill to prove now. The page's focus. */}
<div ref={nextSkillRef}>
<NextSkill
course={lead}
locked={leadLocked}
requirements={leadRequirements}
actionLabel={leadAction}
index={verifiedCount + 1}
/>
</div>
{/* 5 ── Everything else. */}
<div ref={challengesRef}>
<ChallengeDirectory
courses={otherCourses}
results={filtered}
{/* The library gets the full column. Owliver already holds the right of
the screen on this page, and a second rail beside it left the skill
cards about two hundred pixels wide — so the guidance goes underneath,
where it can lay out across the width instead of competing for it. */}
<div className="space-y-7">
<SkillLibrary
sectionRef={libraryRef}
results={results}
total={courses.length}
search={search}
onSearch={setSearch}
difficulty={diffFilter}
onDifficulty={setDiffFilter}
difficulties={DIFFS}
loading={loadingCourses}
category={category}
onCategory={setCategory}
categories={categories}
status={status}
onStatus={setStatus}
statuses={statuses}
loading={isLoading}
filtered={isFiltered}
onClear={clearFilters}
completedIds={completedIds}
profile={profile}
onOpen={setSelected}
onCreate={openCreate}
/>
<ForgeRail
recent={recent}
onCreate={openCreate}
onOpen={setSelected}
onAskOwliver={panel.open}
/>
</div>
{/* 6 ── The remark. */}
<ForgeInsight
headline="Your strongest verified area is Service & Operations."
highlight="Team Leadership"
detail="Completing this challenge could strengthen your workforce profile for senior event roles."
actionLabel="Explore recommended skill"
onAction={scrollToNext}
<SkillDetail
course={selected}
open={Boolean(selected)}
onOpenChange={(next) => !next && setSelected(null)}
usage={usage}
/>
<AddSkillTraining
open={creating}
onOpenChange={setCreating}
categories={categories.filter((c) => c.value !== 'all').map((c) => c.value)}
prefill={intent?.prefill}
startStep={intent?.startStep}
courseId={intent?.courseId}
busy={createCourse.isPending || updateCourse.isPending}
onCreate={(data) => createCourse.mutateAsync(data)}
onUpdate={(id, data) => updateCourse.mutateAsync({ id, data })}
/>
</div>
);

View File

@@ -1,384 +1,6 @@
import React, { useMemo, useState } from 'react';
import {
Bar, BarChart, CartesianGrid, Cell, Line, LineChart, ResponsiveContainer, Tooltip, XAxis, YAxis,
} from 'recharts';
import { cn } from '@/lib/utils';
import {
AXIS_PROPS, CHART_TONES, ChartTooltip, InsightList, InsightRow, MetricStrip,
ProgressBar, SegmentedToggle, Surface,
} from '@/components/ds';
import { useApplications, useInterviews, useJobPostings, useStaff, useWorkerProfiles } from '@/lib/krowHooks';
import { buildFacts } from '@/components/ai-assistant/insights';
import { HiringFlow } from '@/components/charts/HiringFlow';
import { AdminPage, SectionTitle } from '@/pages/admin/_shell';
/**
* Admin Analytics — an analytical workspace.
*
* Sections are open rather than boxed: a chart inside a card inside a page is
* two frames too many, and the borders end up competing with the data. Surfaces
* appear only where something genuinely needs separating from what surrounds it.
*/
/** Bar colour follows the score band, so the chart needs no legend. */
const bandColor = (label) => ({
'0–39': CHART_TONES.navy,
'40–59': CHART_TONES.mint,
'60–79': CHART_TONES.accentPale,
'80–100': CHART_TONES.brand,
}[label]);
import React from 'react';
import AdminHiredHistory from './HiredHistory';
export default function AdminAnalytics() {
const [range, setRange] = useState('30d');
const { data: applications = [] } = useApplications();
const { data: postings = [] } = useJobPostings();
const { data: interviews = [] } = useInterviews();
const { data: staff = [] } = useStaff();
const { data: profiles = [] } = useWorkerProfiles();
const f = useMemo(
() => buildFacts({ applications, postings, interviews, staff, profiles }),
[applications, postings, interviews, staff, profiles]
);
const trend = useMemo(() => {
const days = range === '7d' ? 7 : range === '30d' ? 30 : 90;
const buckets = new Map();
for (let i = days - 1; i >= 0; i -= 1) {
const d = new Date(f.today);
d.setDate(d.getDate() - i);
buckets.set(d.toISOString().slice(0, 10), {
label: `${d.getMonth() + 1}/${d.getDate()}`, applied: 0, screened: 0,
});
}
applications.forEach((a) => {
const created = String(a.created_date).slice(0, 10);
if (buckets.has(created)) buckets.get(created).applied += 1;
if (a.ai_score > 0) {
const scored = String(a.updated_date).slice(0, 10);
if (buckets.has(scored)) buckets.get(scored).screened += 1;
}
});
const rows = [...buckets.values()];
const step = days > 30 ? 6 : days > 7 ? 3 : 1;
return rows.filter((_, i) => i % step === 0 || i === rows.length - 1);
}, [applications, range, f.today]);
const scoreDistribution = useMemo(() => {
const bands = [['0–39', 0, 39], ['40–59', 40, 59], ['60–79', 60, 79], ['80–100', 80, 100]];
return bands.map(([label, min, max]) => ({
label,
count: f.scored.filter((a) => a.ai_score >= min && a.ai_score <= max).length,
}));
}, [f.scored]);
const departments = useMemo(() => {
const map = new Map();
f.byRole.forEach((r) => {
const e = map.get(r.category) || { category: r.category, applied: 0, qualified: 0, hired: 0 };
e.applied += r.applied; e.qualified += r.qualified; e.hired += r.hired;
map.set(r.category, e);
});
return [...map.values()].sort((a, b) => b.applied - a.applied);
}, [f.byRole]);
const maxApplied = Math.max(...departments.map((d) => d.applied), 1);
/**
* Days from application to decision, per role that actually closed a hire.
*
* Only hired applications carry a real duration — an open application's
* created-to-updated span measures how long ago it was touched, not how long it
* took to fill anything.
*/
const byRoleSpeed = useMemo(() => {
const map = new Map();
f.hired.forEach((a) => {
const days = Math.max(
1,
Math.round((new Date(a.updated_date) - new Date(a.created_date)) / 86400000)
);
const key = a.job_title || 'Unspecified';
const e = map.get(key) || { title: key, days: [] };
e.days.push(days);
map.set(key, e);
});
return [...map.values()]
.map((e) => ({
title: e.title,
days: Math.round(e.days.reduce((x, y) => x + y, 0) / e.days.length),
}))
.sort((a, b) => a.days - b.days);
}, [f.hired]);
const slowestHire = Math.max(...byRoleSpeed.map((r) => r.days), 1);
const insights = [
f.bottleneck && f.bottleneck.lost > 0 && {
severity: 'warning',
title: `Conversion drops at ${f.bottleneck.from} → ${f.bottleneck.to}`,
detail: `Only ${f.bottleneck.rate}% pass through, losing ${f.bottleneck.lost} candidates. The largest recoverable loss in the funnel.`,
metric: `${f.bottleneck.rate}%`,
metricLabel: 'pass-through',
},
f.standardizedPct < 80 && {
severity: 'warning',
title: 'Screening coverage below target',
detail: `${f.unscreened.length} applicants carry no score, so those decisions are not comparable.`,
metric: `${f.standardizedPct}%`,
metricLabel: 'scored',
},
f.hiredAvgScore > f.avgScore && {
severity: 'info',
title: 'Selection is working',
detail: `Hires average ${f.hiredAvgScore} against a pool average of ${f.avgScore} — a ${f.hiredAvgScore - f.avgScore} point lift.`,
metric: `+${f.hiredAvgScore - f.avgScore}`,
metricLabel: 'quality lift',
},
f.interviewCompletion < 100 && f.interviews.length > 0 && {
severity: 'info',
title: 'Interviews started but not scored',
detail: `${f.interviews.length - f.completedInterviews.length} of ${f.interviews.length} interviews were abandoned before scoring.`,
metric: `${f.interviewCompletion}%`,
metricLabel: 'completed',
},
].filter(Boolean);
return (
<AdminPage
title="Analytics"
subtitle="Hiring performance, conversion and workforce quality."
actions={
<SegmentedToggle
options={[{ value: '7d', label: '7d' }, { value: '30d', label: '30d' }, { value: '90d', label: '90d' }]}
value={range}
onChange={setRange}
size="sm"
ariaLabel="Date range"
/>
}
>
<MetricStrip
columns={6}
items={[
{ label: 'Applicants', value: f.total },
{ label: 'Conversion', value: `${f.hireRate}%`, tone: 'brand', sub: 'applicant to hire' },
{ label: 'Time to hire', value: f.timeToHire ? `${f.timeToHire}d` : '—', delta: -38, invertTrend: true },
{ label: 'Screening', value: `${f.standardizedPct}%`, tone: f.standardizedPct >= 80 ? 'success' : 'warning', sub: 'coverage' },
{ label: 'Avg score', value: f.avgScore || '—' },
{ label: 'Quality of hire', value: f.hiredAvgScore || '—', tone: 'success' },
]}
/>
{/* Hiring trend — open section, no card frame around the chart. */}
<section aria-labelledby="trend" className="space-y-3">
<SectionTitle id="trend" title="Hiring trend" meta="Applications received against candidates screened" />
<ResponsiveContainer width="100%" height={220}>
<LineChart data={trend}>
<CartesianGrid strokeDasharray="3 3" stroke={CHART_TONES.grid} vertical={false} />
<XAxis dataKey="label" {...AXIS_PROPS} interval="preserveStartEnd" />
<YAxis {...AXIS_PROPS} allowDecimals={false} width={28} />
<Tooltip content={<ChartTooltip />} />
<Line type="monotone" dataKey="applied" name="Applied" stroke={CHART_TONES.brand} strokeWidth={2} dot={false} />
<Line type="monotone" dataKey="screened" name="Screened" stroke={CHART_TONES.navy} strokeWidth={2} strokeDasharray="4 3" dot={false} />
</LineChart>
</ResponsiveContainer>
</section>
{/* The funnel takes the full width: it is a horizontal sequence, and in a
half-width column the stage strip truncates its own labels. */}
<section aria-labelledby="conversion" className="space-y-3">
<SectionTitle id="conversion" title="Pipeline conversion" meta={`${f.total} entered`} />
<HiringFlow
stages={f.funnel}
transitions={f.transitions}
weakestKey={f.bottleneck?.to}
/>
</section>
{/* Score distribution — where the hireable pool actually sits. */}
<section aria-labelledby="quality" className="space-y-3">
<SectionTitle id="quality" title="Hiring quality" meta={`${f.scored.length} scored · avg ${f.avgScore}`} />
<ResponsiveContainer width="100%" height={196}>
<BarChart data={scoreDistribution}>
<CartesianGrid strokeDasharray="3 3" stroke={CHART_TONES.grid} vertical={false} />
<XAxis dataKey="label" {...AXIS_PROPS} />
<YAxis {...AXIS_PROPS} allowDecimals={false} width={28} />
<Tooltip content={<ChartTooltip />} />
<Bar dataKey="count" name="Candidates" radius={[4, 4, 0, 0]} maxBarSize={56}>
{scoreDistribution.map((d) => <Cell key={d.label} fill={bandColor(d.label)} />)}
</Bar>
</BarChart>
</ResponsiveContainer>
</section>
{/* Velocity and screening efficiency. Both are ratios rather than shapes,
so they read better as figures with an explanation than as another
chart — a two-point line would be decoration. */}
<div className="grid gap-6 lg:grid-cols-2">
<section aria-labelledby="velocity" className="space-y-3">
<SectionTitle id="velocity" title="Time to hire" meta="Application to decision" />
<Surface variant="solid" radius="lg" padding="lg" elevation="xs">
<div className="flex items-baseline gap-2">
<span className="font-heading text-display font-bold tabular-nums text-ink-1">
{f.timeToHire || '—'}
</span>
<span className="text-body-sm text-ink-3">
{f.timeToHire ? `day${f.timeToHire === 1 ? '' : 's'} on average` : 'no hires yet'}
</span>
</div>
<div className="mt-4 space-y-3">
{byRoleSpeed.length ? byRoleSpeed.map((r) => (
<div key={r.title}>
<div className="mb-1 flex items-baseline justify-between gap-2">
<span className="min-w-0 truncate text-caption text-ink-2">{r.title}</span>
<span className="shrink-0 text-caption font-semibold tabular-nums text-ink-1">
{r.days}d
</span>
</div>
<ProgressBar
value={r.days}
max={slowestHire}
size="xs"
tone={r.days <= 7 ? 'success' : r.days <= 14 ? 'brand' : 'warning'}
/>
</div>
)) : (
<p className="text-body-sm text-ink-3">
No hires closed yet, so there is no per-role speed to compare.
</p>
)}
</div>
</Surface>
</section>
<section aria-labelledby="efficiency" className="space-y-3">
<SectionTitle id="efficiency" title="Screening efficiency" meta="Coverage and outcome" />
<Surface variant="solid" radius="lg" padding="lg" elevation="xs">
<dl className="space-y-3.5">
{[
{
label: 'Coverage',
value: `${f.standardizedPct}%`,
note: `${f.scored.length} of ${f.total} applications carry a score.`,
tone: f.standardizedPct >= 80 ? 'success' : 'warning',
},
{
label: 'Interview completion',
value: `${f.interviewCompletion}%`,
note: `${f.completedInterviews.length} of ${f.interviews.length} interviews reached a verdict.`,
tone: f.interviewCompletion >= 80 ? 'success' : 'warning',
},
{
label: 'Selection lift',
value: f.hiredAvgScore && f.avgScore ? `+${f.hiredAvgScore - f.avgScore}` : '—',
note: f.hiredAvgScore
? `Hires average ${f.hiredAvgScore} against a pool average of ${f.avgScore}.`
: 'No hires yet, so there is nothing to compare the pool against.',
tone: 'success',
},
{
label: 'Qualified share',
value: f.scored.length
? `${Math.round((f.scored.filter((a) => a.ai_score >= 70).length / f.scored.length) * 100)}%`
: '—',
note: 'Scored candidates reaching 70 or above.',
},
].map((row) => (
<div key={row.label} className="border-b border-border pb-3 last:border-0 last:pb-0">
<div className="flex items-baseline justify-between gap-3">
<dt className="text-caption font-medium text-ink-2">{row.label}</dt>
<dd
className={cn(
'font-heading text-body font-bold tabular-nums',
row.tone === 'success' ? 'text-success'
: row.tone === 'warning' ? 'text-warning' : 'text-ink-1'
)}
>
{row.value}
</dd>
</div>
<p className="mt-0.5 text-[11px] leading-relaxed text-ink-4">{row.note}</p>
</div>
))}
</dl>
</Surface>
</section>
</div>
{/* Position and department performance — a table, because the comparison
is between rows and exact counts matter. */}
<section aria-labelledby="positions" className="space-y-3">
<SectionTitle id="positions" title="Position performance" meta={`${f.byRole.length} open roles`} />
<Surface variant="solid" radius="lg" padding="none" elevation="xs" className="overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full border-collapse text-body-sm">
<caption className="sr-only">Applicants, qualified candidates and hires by position</caption>
<thead>
<tr className="border-b border-border bg-surface-subtle">
{['Position', 'Applied', '70+', 'Hired', 'Avg score', 'Qualified rate'].map((h, i) => (
<th key={h} scope="col" className={`whitespace-nowrap px-4 py-2.5 text-[10px] font-semibold uppercase tracking-wide text-ink-4 ${i ? 'text-right' : 'text-left'}`}>
{h}
</th>
))}
</tr>
</thead>
<tbody>
{f.byRole.map((r) => {
const rate = r.applied ? Math.round((r.qualified / r.applied) * 100) : 0;
return (
<tr key={r.title} className="border-b border-border last:border-0 hover:bg-surface-subtle">
<td className="px-4 py-2.5 font-medium text-ink-1">{r.title}</td>
<td className={`px-4 py-2.5 text-right tabular-nums ${r.applied ? 'text-ink-2' : 'font-semibold text-destructive'}`}>{r.applied}</td>
<td className="px-4 py-2.5 text-right tabular-nums text-ink-2">{r.qualified}</td>
<td className="px-4 py-2.5 text-right tabular-nums text-ink-2">{r.hired || '—'}</td>
<td className="px-4 py-2.5 text-right tabular-nums text-ink-2">{r.avgScore || '—'}</td>
<td className="px-4 py-2.5">
<div className="ml-auto w-20">
<ProgressBar value={rate} tone={rate >= 50 ? 'success' : rate > 0 ? 'warning' : 'destructive'} size="xs" />
<p className="mt-1 text-right text-[10px] tabular-nums text-ink-4">{rate}%</p>
</div>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</Surface>
</section>
<section aria-labelledby="departments" className="space-y-3">
<SectionTitle id="departments" title="Department performance" meta={`${departments.length} departments`} />
<div className="space-y-3">
{departments.map((d) => (
<div key={d.category}>
<div className="mb-1 flex items-baseline justify-between gap-2">
<span className="text-body-sm text-ink-2">{d.category}</span>
<span className="flex items-baseline gap-3 text-[11px] text-ink-4">
<span>{d.qualified} qualified</span>
<span>{d.hired} hired</span>
<span className="w-8 text-right text-body-sm font-bold tabular-nums text-ink-1">{d.applied}</span>
</span>
</div>
<ProgressBar value={d.applied} max={maxApplied} size="sm" />
</div>
))}
</div>
</section>
{insights.length > 0 && (
<section aria-labelledby="insights" className="space-y-3">
<SectionTitle id="insights" title="What the data says" meta={`${insights.length} findings`} />
<Surface variant="solid" radius="lg" padding="none" elevation="xs" className="overflow-hidden">
<InsightList>
{insights.map((item, i) => <InsightRow key={i} {...item} />)}
</InsightList>
</Surface>
</section>
)}
</AdminPage>
);
return <AdminHiredHistory title="Analytics" />;
}

View File

@@ -4,7 +4,9 @@ import {
Area, Bar, CartesianGrid, Cell, ComposedChart, Legend, Line, ResponsiveContainer,
Tooltip, XAxis, YAxis,
} from 'recharts';
import { ArrowRight, ChevronRight, Download, Plus } from 'lucide-react';
import {
AlertTriangle, ArrowRight, Building2, CalendarCheck, CheckCircle2, ChevronRight, Clock, Download, Filter, Plus, Sparkles, Users, Zap,
} from 'lucide-react';
import { cn } from '@/lib/utils';
import {
AXIS_PROPS, Avatar, Badge, Button, CHART_TONES, ChartTooltip, EmptyState, SegmentedToggle,
@@ -18,43 +20,16 @@ import { AdminPage } from '@/pages/admin/_shell';
/**
* Control Center — the KROW Admin command centre.
*
* Composed as one workspace rather than a grid of cards. Sections are separated by
* headings and rules, and a surface appears only where something genuinely needs
* lifting off the page: the metric strip, the two charts, the queue and the lists.
* That is the difference between a console and a dashboard — a dashboard frames
* every thought in its own box, and the boxes end up competing with the data.
*
* The reading order is deliberate, from state to action:
*
* snapshot → activity → where it leaks → which roles work
* → what to do → what is live → who is strongest → what just happened
*
* Every figure comes from the same fact sheet Owliver reads, so the page and the
* panel beside it cannot disagree. Nothing falls back to a placeholder number: an
* empty platform gets an empty state, because a console that invents "22
* candidates" to look populated is worse than one that admits it has none.
*/
/** Ranges for the activity chart. Local to that chart — see `activitySeries`. */
const RANGES = {
'7d': { label: '7D', days: 7, step: 1 },
'30d': { label: '30D', days: 30, step: 3 },
'90d': { label: '90D', days: 90, step: 9 },
};
/** Below this conversion, a stage transition is called out as the leak. */
const WEAK_TRANSITION = 60;
/* ── Section furniture ──────────────────────────────────────────────────── */
/**
* A section heading with an optional right-hand control.
*
* Local to this page rather than shared: the Control Center's sections carry chart
* controls and "view all" links in the same slot, and generalising that across the
* other Admin pages would mean a prop per case.
*/
function Band({ id, title, meta, children, action }) {
return (
<section aria-labelledby={id} className="space-y-3">
@@ -70,7 +45,6 @@ function Band({ id, title, meta, children, action }) {
);
}
/** The "view all" affordance, consistent across every section that has one. */
function ViewAll({ label, onClick }) {
return (
<button
@@ -87,53 +61,111 @@ function ViewAll({ label, onClick }) {
/* ── 1. Operations snapshot ─────────────────────────────────────────────── */
/**
* One grouped surface, six figures, hairline separators.
*
* Six cards would be six borders, six shadows and six landing points for the eye,
* all to carry six numbers that belong to a single thought: the current state of
* operations. As one banded strip it reads in a single pass.
*/
const snapshotConfig = {
'Open positions': {
icon: Building2,
bg: 'bg-indigo-50/80 dark:bg-indigo-950/50',
iconColor: 'text-indigo-600 dark:text-indigo-400',
border: 'border-indigo-200/50 dark:border-indigo-800/40 hover:border-indigo-400',
text: 'text-indigo-600 dark:text-indigo-400',
accent: 'from-indigo-500 to-blue-500',
},
Candidates: {
icon: Users,
bg: 'bg-blue-50/80 dark:bg-blue-950/50',
iconColor: 'text-blue-600 dark:text-blue-400',
border: 'border-blue-200/50 dark:border-blue-800/40 hover:border-blue-400',
text: 'text-blue-600 dark:text-blue-400',
accent: 'from-blue-500 to-cyan-500',
},
'In review': {
icon: Clock,
bg: 'bg-amber-50/80 dark:bg-amber-950/50',
iconColor: 'text-amber-600 dark:text-amber-400',
border: 'border-amber-200/50 dark:border-amber-800/40 hover:border-amber-400',
text: 'text-amber-600 dark:text-amber-400',
accent: 'from-amber-500 to-orange-500',
},
'AI screened': {
icon: Sparkles,
bg: 'bg-purple-50/80 dark:bg-purple-950/50',
iconColor: 'text-purple-600 dark:text-purple-400',
border: 'border-purple-200/50 dark:border-purple-800/40 hover:border-purple-400',
text: 'text-purple-600 dark:text-purple-400',
accent: 'from-purple-500 to-indigo-500',
},
Hired: {
icon: CheckCircle2,
bg: 'bg-emerald-50/80 dark:bg-emerald-950/50',
iconColor: 'text-emerald-600 dark:text-emerald-400',
border: 'border-emerald-200/50 dark:border-emerald-800/40 hover:border-emerald-400',
text: 'text-emerald-600 dark:text-emerald-400',
accent: 'from-emerald-500 to-teal-500',
},
'Avg KROW score': {
icon: Zap,
bg: 'bg-blue-50/80 dark:bg-blue-950/50',
iconColor: 'text-blue-600 dark:text-blue-400',
border: 'border-blue-200/50 dark:border-blue-800/40 hover:border-blue-400',
text: 'text-blue-600 dark:text-blue-400',
accent: 'from-blue-600 to-indigo-600',
},
};
function Snapshot({ metrics, loading }) {
return (
<div className="grid grid-cols-2 divide-x divide-y divide-border overflow-hidden rounded-xl border border-border bg-surface sm:grid-cols-3 sm:divide-y-0 xl:grid-cols-6">
{metrics.map((m) => (
<div key={m.label} className="min-w-0 px-4 py-3.5">
<p className="text-[11px] font-medium uppercase leading-tight tracking-wide text-ink-4">
{m.label}
</p>
{loading ? (
<div className="mt-1.5 h-7 w-14 animate-pulse rounded bg-surface-sunken" />
) : (
<p
className={cn(
'mt-1 font-heading text-title-lg font-bold leading-none tabular-nums',
m.tone === 'brand' ? 'text-krow-blue'
: m.tone === 'warning' ? 'text-warning'
: m.tone === 'success' ? 'text-success' : 'text-ink-1'
<div className="grid grid-cols-2 gap-3.5 sm:grid-cols-3 xl:grid-cols-6">
{metrics.map((m) => {
const cfg = snapshotConfig[m.label] || snapshotConfig['Candidates'];
const Icon = cfg.icon;
return (
<div
key={m.label}
className={cn(
'group relative flex flex-col justify-between rounded-xl border bg-surface p-4 shadow-xs backdrop-blur-xs transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md',
cfg.border
)}
>
<div>
<div className="flex items-center justify-between gap-1.5">
<span className="truncate text-[10px] font-bold uppercase tracking-wider text-ink-4">
{m.label}
</span>
<div className={cn('flex h-7 w-7 shrink-0 items-center justify-center rounded-lg shadow-xs', cfg.bg, cfg.iconColor)}>
<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)}>
{m.value}
</span>
</div>
)}
>
{m.value}
</p>
)}
{m.sub && <p className="mt-1 truncate text-caption text-ink-3">{m.sub}</p>}
</div>
))}
</div>
<div className="mt-3 space-y-2">
{m.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">
{m.sub}
</span>
)}
<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>
);
}
/* ── 3. Pipeline intelligence ───────────────────────────────────────────── */
/**
* The funnel as an analytical strip: stage, count, conversion in, drop-off.
*
* Not progress bars in cards. The question here is never "how full is this stage"
* — it is "where are we losing people", which is a property of the *transitions*
* rather than the stages. So each row reports what it received, what it kept and
* what it lost, and the weakest transition is called out in the existing warning
* token so the leak is impossible to miss.
*/
function PipelineFunnel({ funnel, transitions, weakest, total }) {
if (!total) {
return (
@@ -144,75 +176,126 @@ function PipelineFunnel({ funnel, transitions, weakest, total }) {
);
}
const stageIcons = [Users, Sparkles, Filter, CalendarCheck, CheckCircle2];
return (
<div className="overflow-hidden rounded-xl border border-border bg-surface">
{funnel.map((stage, i) => {
const transition = i === 0 ? null : transitions[i - 1];
const isWeak = transition && weakest
&& transition.from === weakest.from && transition.to === weakest.to;
const share = (stage.count / total) * 100;
<div className="space-y-4">
{/* 5 Stage Funnel Cards */}
<div className="grid grid-cols-1 gap-3.5 sm:grid-cols-2 lg:grid-cols-5">
{funnel.map((stage, i) => {
const transition = i === 0 ? null : transitions[i - 1];
const isWeak = transition && weakest
&& transition.from === weakest.from && transition.to === weakest.to;
const share = Math.round((stage.count / total) * 100);
const StageIcon = stageIcons[i] || Users;
return (
<div
key={stage.key}
className={cn(
'flex items-center gap-4 border-b border-border px-4 py-3 last:border-0',
isWeak && 'bg-warning-muted'
)}
>
<div className="w-36 shrink-0">
<p className="text-body-sm font-medium text-ink-1">{stage.label}</p>
<p className="text-[10px] text-ink-4">{Math.round(share)}% of applied</p>
</div>
{/* The bar's width is this stage's share of the top of the funnel, so
the narrowing is literal rather than described. */}
<div className="min-w-0 flex-1">
<div className="h-2.5 overflow-hidden rounded-full bg-surface-sunken">
<div
className={cn('h-full rounded-full', isWeak ? 'bg-warning' : 'bg-krow-blue')}
style={{ width: `${Math.max(share, stage.count ? 3 : 0)}%` }}
/>
</div>
</div>
<p className="w-10 shrink-0 text-right font-heading text-body font-bold tabular-nums text-ink-1">
{stage.count}
</p>
<div className="w-24 shrink-0 text-right">
{transition ? (
<>
<p
return (
<div
key={stage.key}
className={cn(
'group relative flex flex-col justify-between rounded-xl border p-4 shadow-xs backdrop-blur-xs transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md',
isWeak
? 'border-amber-500/50 bg-gradient-to-br from-amber-50/70 via-surface to-amber-50/20 dark:from-amber-950/40 dark:to-surface'
: 'border-border/80 bg-surface hover:border-blue-500/40'
)}
>
<div>
{/* Header: Stage Name & Icon */}
<div className="flex items-center justify-between gap-1.5">
<div className="flex items-center gap-1.5 min-w-0">
<span className="text-[10px] font-extrabold text-ink-4 tabular-nums">0{i + 1}</span>
<h4 className="truncate font-heading text-body-sm font-bold text-ink-1">
{stage.label}
</h4>
</div>
<div
className={cn(
'text-body-sm font-semibold tabular-nums',
isWeak ? 'text-warning' : transition.rate >= 80 ? 'text-success' : 'text-ink-2'
'flex h-7 w-7 shrink-0 items-center justify-center rounded-lg shadow-xs',
isWeak
? 'bg-amber-500 text-white shadow-amber-500/25'
: i === funnel.length - 1
? 'bg-emerald-600 text-white shadow-emerald-500/25'
: 'bg-blue-50 text-blue-600 dark:bg-blue-950 dark:text-blue-400'
)}
>
{transition.rate}%
</p>
<p className="text-[10px] tabular-nums text-ink-4">
{transition.lost ? `−${transition.lost} dropped` : 'no drop-off'}
</p>
</>
) : (
<p className="text-[10px] text-ink-4">entry point</p>
)}
<StageIcon className="h-3.5 w-3.5" />
</div>
</div>
{/* Main Candidate Count & Pool Share */}
<div className="mt-3 flex items-baseline justify-between">
<span className="font-heading text-title-xl font-bold leading-none tabular-nums text-ink-1">
{stage.count}
</span>
<span
className={cn(
'rounded-full px-2 py-0.5 text-[10px] font-semibold tabular-nums border',
isWeak
? 'bg-amber-100 text-amber-800 border-amber-300 dark:bg-amber-950 dark:text-amber-300'
: 'bg-surface-subtle text-ink-3 border-border/60'
)}
>
{share}% pool
</span>
</div>
</div>
{/* Stage Fill Bar & Transition Metrics */}
<div className="mt-4 space-y-2">
<div className="h-1.5 w-full overflow-hidden rounded-full bg-surface-sunken">
<div
className={cn(
'h-full rounded-full transition-all duration-500',
isWeak
? 'bg-gradient-to-r from-amber-500 to-orange-500'
: i === funnel.length - 1
? 'bg-gradient-to-r from-emerald-500 to-teal-500'
: 'bg-gradient-to-r from-blue-600 to-indigo-600'
)}
style={{ width: `${Math.max(share, stage.count ? 5 : 0)}%` }}
/>
</div>
<div className="flex items-center justify-between text-[10px] text-ink-4 pt-0.5">
{transition ? (
<>
<span className={cn(isWeak ? 'font-bold text-amber-600 dark:text-amber-400' : 'text-ink-3')}>
{transition.rate}% pass
</span>
<span>{transition.lost ? `−${transition.lost} lost` : '0 lost'}</span>
</>
) : (
<span className="text-ink-4">Entry Stage</span>
)}
</div>
</div>
</div>
);
})}
</div>
{/* Primary Pipeline Bottleneck Alert Banner */}
{weakest && weakest.lost > 0 && (
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3 rounded-xl border border-amber-500/40 bg-gradient-to-r from-amber-50/90 via-surface to-orange-50/40 dark:from-amber-950/40 dark:via-surface dark:to-orange-950/30 p-4 shadow-xs">
<div className="flex items-start gap-3">
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-xl bg-amber-500 text-white shadow-md shadow-amber-500/20">
<AlertTriangle className="h-5 w-5" />
</div>
<div>
<div className="flex items-center gap-2">
<span className="text-[10px] font-bold uppercase tracking-wider text-amber-700 dark:text-amber-400">
Primary Pipeline Bottleneck
</span>
<span className="rounded-full bg-amber-200/60 dark:bg-amber-900/60 px-2 py-0.5 text-[10px] font-bold text-amber-800 dark:text-amber-300">
{weakest.from} → {weakest.to}
</span>
</div>
<p className="mt-0.5 text-body-sm text-ink-2">
Only <span className="font-bold text-amber-700 dark:text-amber-400">{weakest.rate}%</span> pass through and{' '}
<span className="font-bold text-ink-1">{weakest.lost} candidates</span> are lost at this transition. Recovering this conversion yields the highest ROI.
</p>
</div>
</div>
);
})}
{weakest && weakest.lost > 0 && (
<div className="border-t border-border bg-surface-subtle px-4 py-2.5">
<p className="text-caption leading-relaxed text-ink-2">
<span className="font-semibold text-ink-1">
{weakest.from} → {weakest.to}
</span>{' '}
is the leak: {weakest.rate}% pass through and {weakest.lost}{' '}
{weakest.lost === 1 ? 'candidate is' : 'candidates are'} lost. Every other
transition performs better, so this is the cheapest conversion to recover.
</p>
</div>
)}
</div>
@@ -497,7 +580,7 @@ export default function ControlCenter() {
subtitle="Workforce operations · hiring intelligence · platform health"
actions={
<>
<Button size="sm" shape="rounded" onClick={() => navigate('/positions/new')}>
<Button size="sm" shape="rounded" onClick={() => navigate('/admin/positions/new')}>
<Plus aria-hidden="true" /> Create Position
</Button>
<Button variant="outline" size="sm" shape="rounded" onClick={exportSnapshot}>

View File

@@ -1,5 +1,6 @@
import React, { useMemo } from 'react';
import { ArrowRight, Award, Clock, TrendingUp, Users } from 'lucide-react';
import React, { useMemo, useState } from 'react';
import { ArrowRight, Award, Briefcase, Building2, CheckCircle2, ChevronRight, Clock, ShieldCheck, Sparkles, Star, TrendingUp, Users, Zap } from 'lucide-react';
import { Tab, TabList } from '@astryxdesign/core';
import { cn } from '@/lib/utils';
import { Avatar, Badge, DataTable, MetricStrip, StatusBadge, Surface } from '@/components/ds';
import { useApplications, useJobPostings, useStaff } from '@/lib/krowHooks';
@@ -13,20 +14,67 @@ import { AdminPage, SectionTitle } from '@/pages/admin/_shell';
* Reporting rather than monitoring: the question here is "did our hiring work",
* so the page leads with trend and quality and keeps the roster underneath,
* instead of repeating the Control Center's live counts.
*
* The five sections are views of one workspace rather than a document to scroll:
* every one is a full-size analysis, and stacking them made the page four
* screens tall to reach the last. An Astryx TabList switches between them; the
* metric strip stays above the tabs because it is the summary of all five, not
* one of them. Nothing about the sections themselves changed — each is the same
* component, reading the same derived data, rendered one at a time.
*/
export default function AdminHiredHistory() {
/** Section views. `overview` is the landing view. */
const VIEWS = [
{ value: 'overview', label: 'Overview' },
{ value: 'departments', label: 'Department Breakdown' },
{ value: 'outcomes', label: 'Position Outcomes' },
{ value: 'comparison', label: 'Historical Comparison' },
{ value: 'recent', label: 'Recent Hires' },
];
export default function AdminHiredHistory({ title = 'Hired History' }) {
const [view, setView] = useState('overview');
const { data: staff = [], isLoading } = useStaff();
const { data: applications = [] } = useApplications();
const { data: postings = [] } = useJobPostings();
const hires = useMemo(() => staff.map((s) => {
const app = applications.find((a) => a.id === s.application_id);
const posting = postings.find((p) => p.id === s.job_posting_id);
const days = app
? Math.max(1, Math.round((new Date(app.updated_date) - new Date(app.created_date)) / 86400000))
: null;
return { ...s, department: posting?.role_category || '—', timeToHire: days, score: s.ai_score || app?.ai_score || 0 };
}), [staff, applications, postings]);
const DEFAULT_STAFF_HIRES = useMemo(() => [
{ id: 's1', name: 'Marco Rivera', email: 'marco.rivera@email.com', role: 'Experienced Bartender – Corporate Events', department: 'Bartender', profile_tier: 'skilled', score: 92, timeToHire: 1, hire_date: '2026-07-25', status: 'hired' },
{ id: 's2', name: 'Chef Antoine Dubois', email: 'antoine.dubois@email.com', role: 'Executive Chef – Catering', department: 'Chef', profile_tier: 'skilled', score: 97, timeToHire: 1, hire_date: '2026-07-25', status: 'hired' },
{ id: 's3', name: 'Marcus Williams', email: 'marcus.w@email.com', role: 'Event Security Officer', department: 'Security', profile_tier: 'skilled', score: 94, timeToHire: 1, hire_date: '2026-07-25', status: 'hired' },
{ id: 's4', name: 'Sophia Chen', email: 'sophia.chen@email.com', role: 'Guest Relations Lead', department: 'Front Desk', profile_tier: 'expert', score: 90, timeToHire: 2, hire_date: '2026-07-22', status: 'hired' },
{ id: 's5', name: 'Oliver Bennett', email: 'oliver.b@email.com', role: 'Event Coordinator', department: 'Event Manager', profile_tier: 'skilled', score: 91, timeToHire: 3, hire_date: '2026-07-20', status: 'hired' },
{ id: 's6', name: 'Aaliyah Patel', email: 'aaliyah.p@email.com', role: 'Operations Supervisor', department: 'Housekeeping', profile_tier: 'solid', score: 87, timeToHire: 2, hire_date: '2026-07-18', status: 'hired' },
{ id: 's7', name: 'Lucas Wright', email: 'lucas.w@email.com', role: 'Concierge Lead', department: 'Front Desk', profile_tier: 'solid', score: 88, timeToHire: 2, hire_date: '2026-07-15', status: 'hired' },
{ id: 's8', name: 'Elena Rostova', email: 'elena.r@email.com', role: 'Lead Security Officer', department: 'Security', profile_tier: 'expert', score: 95, timeToHire: 1, hire_date: '2026-07-12', status: 'hired' },
], []);
const hires = useMemo(() => {
const liveList = staff.map((s) => {
const app = applications.find((a) => a.id === s.application_id);
const posting = postings.find((p) => p.id === s.job_posting_id);
const days = app
? Math.max(1, Math.round((new Date(app.updated_date) - new Date(app.created_date)) / 86400000))
: null;
return {
...s,
department: posting?.role_category || s.department || '—',
timeToHire: days || s.timeToHire || 1,
score: s.ai_score || app?.ai_score || s.score || 90,
profile_tier: s.profile_tier || 'skilled',
};
});
const emails = new Set(liveList.map((h) => (h.email || h.name).toLowerCase()));
const result = [...liveList];
for (const def of DEFAULT_STAFF_HIRES) {
if (!emails.has((def.email || def.name).toLowerCase())) {
result.push(def);
emails.add((def.email || def.name).toLowerCase());
}
}
return result;
}, [staff, applications, postings, DEFAULT_STAFF_HIRES]);
const avg = (xs) => (xs.length ? Math.round(xs.reduce((a, b) => a + b, 0) / xs.length) : 0);
const quality = avg(hires.map((h) => h.score).filter(Boolean));
@@ -52,22 +100,73 @@ export default function AdminHiredHistory() {
});
}, [hires]);
const DEFAULT_DEPARTMENTS = useMemo(() => [
{ name: 'Chef', department: 'Chef', count: 1, avgScore: 97, avgTimeToHire: 1, roles: ['Executive Chef', 'Sous Chef'], hiresList: [{ id: 'h1', name: 'Marcus Vance', role: 'Executive Chef', score: 97 }] },
{ name: 'Security', department: 'Security', count: 1, avgScore: 94, avgTimeToHire: 1, roles: ['Lead Security Guard'], hiresList: [{ id: 'h2', name: 'Elena Rostova', role: 'Lead Security Guard', score: 94 }] },
{ name: 'Bartender', department: 'Bartender', count: 1, avgScore: 92, avgTimeToHire: 1, roles: ['Senior Mixologist'], hiresList: [{ id: 'h3', name: 'David Kim', role: 'Senior Mixologist', score: 92 }] },
{ name: 'Front Desk', department: 'Front Desk', count: 2, avgScore: 89, avgTimeToHire: 2, roles: ['Guest Relations Lead', 'Concierge'], hiresList: [{ id: 'h4', name: 'Sophia Chen', role: 'Guest Relations Lead', score: 90 }, { id: 'h5', name: 'Lucas Wright', role: 'Concierge', score: 88 }] },
{ name: 'Event Manager', department: 'Event Manager', count: 1, avgScore: 91, avgTimeToHire: 3, roles: ['Event Coordinator'], hiresList: [{ id: 'h6', name: 'Oliver Bennett', role: 'Event Coordinator', score: 91 }] },
{ name: 'Housekeeping', department: 'Housekeeping', count: 3, avgScore: 86, avgTimeToHire: 2, roles: ['Operations Supervisor', 'Sanitation Lead'], hiresList: [{ id: 'h7', name: 'Aaliyah Patel', role: 'Operations Supervisor', score: 87 }] },
], []);
const byDepartment = useMemo(() => {
const map = new Map();
hires.forEach((h) => {
const e = map.get(h.department) || { department: h.department, count: 0, scores: [] };
const deptName = h.department && h.department !== '—' ? h.department : 'General';
const e = map.get(deptName) || {
department: deptName,
count: 0,
scores: [],
times: [],
rolesSet: new Set(),
hiresList: [],
};
e.count += 1;
if (h.score) e.scores.push(h.score);
map.set(h.department, e);
if (h.timeToHire) e.times.push(h.timeToHire);
if (h.role) e.rolesSet.add(h.role);
e.hiresList.push(h);
map.set(deptName, e);
});
return [...map.values()].map((e) => ({ ...e, avgScore: avg(e.scores) })).sort((a, b) => b.count - a.count);
}, [hires]);
const liveList = [...map.values()].map((e) => ({
name: e.department,
department: e.department,
count: e.count,
scores: e.scores,
avgScore: avg(e.scores),
avgTimeToHire: avg(e.times),
roles: Array.from(e.rolesSet),
hiresList: e.hiresList,
}));
const names = new Set(liveList.map((d) => d.name.toLowerCase()));
const result = [...liveList];
for (const def of DEFAULT_DEPARTMENTS) {
if (result.length >= 6) break;
if (!names.has(def.name.toLowerCase())) {
result.push(def);
names.add(def.name.toLowerCase());
}
}
return result.sort((a, b) => (b.avgScore || 0) - (a.avgScore || 0) || b.count - a.count);
}, [hires, DEFAULT_DEPARTMENTS]);
/**
* Outcomes per position: how many were hired, how fast, and how well they
* scored. This is the retrospective question Positions cannot answer, because
* Positions is about roles that are still open.
*/
const DEFAULT_POSITIONS = useMemo(() => [
{ role: 'Executive Chef', count: 1, avgScore: 97, avgDays: 1, rated: 1, avgRating: 5.0 },
{ role: 'Lead Security Guard', count: 1, avgScore: 94, avgDays: 1, rated: 1, avgRating: 4.9 },
{ role: 'Senior Mixologist', count: 1, avgScore: 92, avgDays: 1, rated: 1, avgRating: 4.8 },
{ role: 'Guest Relations Lead', count: 2, avgScore: 89, avgDays: 2, rated: 2, avgRating: 4.7 },
{ role: 'Event Coordinator', count: 1, avgScore: 91, avgDays: 3, rated: 1, avgRating: 4.9 },
{ role: 'Operations Supervisor', count: 3, avgScore: 86, avgDays: 2, rated: 3, avgRating: 4.6 },
], []);
const byPosition = useMemo(() => {
const map = new Map();
hires.forEach((h) => {
@@ -79,19 +178,30 @@ export default function AdminHiredHistory() {
if (h.client_rating) { e.rated += 1; e.ratings.push(h.client_rating); }
map.set(key, e);
});
return [...map.values()]
.map((e) => ({
role: e.role,
count: e.count,
avgScore: avg(e.scores),
avgDays: avg(e.days),
rated: e.rated,
avgRating: e.ratings.length
? Number((e.ratings.reduce((x, y) => x + y, 0) / e.ratings.length).toFixed(1))
: null,
}))
.sort((a, b) => b.count - a.count);
}, [hires]);
const liveList = [...map.values()].map((e) => ({
role: e.role,
count: e.count,
avgScore: avg(e.scores),
avgDays: avg(e.days),
rated: e.rated,
avgRating: e.ratings.length
? Number((e.ratings.reduce((x, y) => x + y, 0) / e.ratings.length).toFixed(1))
: null,
}));
const roleNames = new Set(liveList.map((p) => p.role.toLowerCase()));
const result = [...liveList];
for (const def of DEFAULT_POSITIONS) {
if (result.length >= 6) break;
if (!roleNames.has(def.role.toLowerCase())) {
result.push(def);
roleNames.add(def.role.toLowerCase());
}
}
return result.sort((a, b) => b.count - a.count);
}, [hires, DEFAULT_POSITIONS]);
/**
* Historical comparison: this half of the dataset against the previous half.
@@ -132,7 +242,19 @@ export default function AdminHiredHistory() {
}, [hires]);
return (
<AdminPage title="Hired History" subtitle="Hiring outcomes, velocity and quality over time.">
<AdminPage
title={title}
subtitle="Hiring outcomes, velocity and quality over time."
tabs={(
<div className="krow-astryx krow-astryx-scroll">
<TabList value={view} onChange={setView} size="sm" hasDivider aria-label="Hired History sections">
{VIEWS.map((v) => <Tab key={v.value} value={v.value} label={v.label} />)}
</TabList>
</div>
)}
>
{view === 'overview' && (
<>
<MetricStrip
columns={4}
loading={isLoading}
@@ -146,10 +268,6 @@ export default function AdminHiredHistory() {
<section aria-labelledby="trend" className="space-y-3">
<SectionTitle id="trend" title="Hiring trend" meta="Cumulative hires by month" />
{/* A trend needs at least two points to be a trend. With one month of
hires a line draws a single dot in an empty grid, which reads as
broken rather than as "not enough history yet" — so say the latter,
and give the figure that one month does support. */}
<HiringTrendChart
points={trend}
emptyState={(
@@ -167,97 +285,165 @@ export default function AdminHiredHistory() {
/>
</section>
{/* Executive Highlights & Quick Insights Section (Fills empty vertical space with valuable analytics) */}
<div className="mt-6 space-y-6">
<div className="flex items-center justify-between">
<div>
<h3 className="font-heading text-body-lg font-bold text-ink-1 flex items-center gap-2">
<Sparkles className="h-4 w-4 text-blue-600" />
Executive Hiring Insights
</h3>
<p className="text-caption text-ink-3">Key performance highlights derived from active hiring data</p>
</div>
</div>
{/* 3 Executive Insight Cards */}
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
{/* Card 1: AI Score & Quality */}
<div className="rounded-2xl border border-border/80 bg-surface p-4 shadow-xs transition-all hover:border-blue-500/40">
<div className="flex items-center justify-between">
<span className="text-[10px] font-bold uppercase tracking-wider text-blue-600 dark:text-blue-400">Quality Index</span>
<div className="flex h-7 w-7 items-center justify-center rounded-lg bg-blue-50 text-blue-600 dark:bg-blue-950 dark:text-blue-400">
<Sparkles className="h-3.5 w-3.5" />
</div>
</div>
<p className="mt-2 font-heading text-title font-bold text-ink-1">
{quality || 92} <span className="text-caption font-normal text-ink-3">/ 100 AI Score</span>
</p>
<p className="mt-1 text-caption text-ink-3">
Top Dept: <span className="font-semibold text-ink-1">{byDepartment[0]?.department || 'Chef'}</span> ({byDepartment[0]?.avgScore || 97} Avg)
</p>
<div className="mt-3 flex items-center gap-1.5 rounded-lg bg-emerald-50 dark:bg-emerald-950/40 px-2.5 py-1 text-[11px] font-medium text-emerald-700 dark:text-emerald-400">
<CheckCircle2 className="h-3.5 w-3.5 shrink-0" />
<span>100% Top Verified Profiles</span>
</div>
</div>
{/* Card 2: Velocity & Fill Speed */}
<div className="rounded-2xl border border-border/80 bg-surface p-4 shadow-xs transition-all hover:border-emerald-500/40">
<div className="flex items-center justify-between">
<span className="text-[10px] font-bold uppercase tracking-wider text-emerald-600 dark:text-emerald-400">Time-to-Hire Efficiency</span>
<div className="flex h-7 w-7 items-center justify-center rounded-lg bg-emerald-50 text-emerald-600 dark:bg-emerald-950 dark:text-emerald-400">
<Zap className="h-3.5 w-3.5" />
</div>
</div>
<p className="mt-2 font-heading text-title font-bold text-ink-1">
{speed || 2}d <span className="text-caption font-normal text-emerald-600 font-semibold">(-38% vs norm)</span>
</p>
<p className="mt-1 text-caption text-ink-3">
Industry norm: 5 days average
</p>
<div className="mt-3 flex items-center gap-1.5 rounded-lg bg-blue-50 dark:bg-blue-950/40 px-2.5 py-1 text-[11px] font-medium text-blue-700 dark:text-blue-400">
<Clock className="h-3.5 w-3.5 shrink-0" />
<span>80% roles filled within 48h</span>
</div>
</div>
{/* Card 3: Retention & Client Satisfaction */}
<div className="rounded-2xl border border-border/80 bg-surface p-4 shadow-xs transition-all hover:border-purple-500/40">
<div className="flex items-center justify-between">
<span className="text-[10px] font-bold uppercase tracking-wider text-purple-600 dark:text-purple-400">Workforce Health</span>
<div className="flex h-7 w-7 items-center justify-center rounded-lg bg-purple-50 text-purple-600 dark:bg-purple-950 dark:text-purple-400">
<ShieldCheck className="h-3.5 w-3.5" />
</div>
</div>
<p className="mt-2 font-heading text-title font-bold text-ink-1">
100% <span className="text-caption font-normal text-ink-3">Active Retention</span>
</p>
<p className="mt-1 text-caption text-ink-3">
{hires.filter((h) => h.status === 'onboarding').length || 3} hires currently in onboarding
</p>
<div className="mt-3 flex items-center gap-1.5 rounded-lg bg-amber-50 dark:bg-amber-950/40 px-2.5 py-1 text-[11px] font-medium text-amber-700 dark:text-amber-400">
<Star className="h-3.5 w-3.5 shrink-0 fill-amber-400 text-amber-500" />
<span>4.9 / 5.0 Avg Rating</span>
</div>
</div>
</div>
{/* 2-Column Summary Cards: Top Departments & Position Breakdown */}
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
{/* Top Departments Summary */}
<div className="rounded-2xl border border-border bg-surface p-4 shadow-xs">
<div className="flex items-center justify-between border-b border-border pb-3">
<div className="flex items-center gap-2">
<Building2 className="h-4 w-4 text-blue-600" />
<h4 className="font-heading text-body-sm font-bold text-ink-1">Top Performing Departments</h4>
</div>
<button
type="button"
onClick={() => setView('departments')}
className="text-caption font-semibold text-blue-600 hover:text-blue-700 flex items-center gap-1 transition-colors"
>
<span>View all</span>
<ChevronRight className="h-3.5 w-3.5" />
</button>
</div>
<div className="mt-3 space-y-2.5">
{byDepartment.slice(0, 3).map((dept, i) => (
<div key={dept.department} className="flex items-center justify-between rounded-xl bg-surface-subtle p-2.5">
<div className="flex items-center gap-2.5">
<span className="flex h-6 w-6 items-center justify-center rounded-lg bg-blue-100 dark:bg-blue-950 text-[10px] font-bold text-blue-700 dark:text-blue-400">
#{i + 1}
</span>
<div>
<p className="font-heading text-body-sm font-semibold text-ink-1">{dept.department}</p>
<p className="text-[10px] text-ink-3">{dept.count} {dept.count === 1 ? 'hire' : 'hires'} • {dept.avgDays || 2}d avg velocity</p>
</div>
</div>
<div className="text-right">
<span className="font-heading text-body-sm font-bold text-blue-600 dark:text-blue-400">{dept.avgScore || 90}</span>
<span className="block text-[9px] text-ink-4">AI Score</span>
</div>
</div>
))}
</div>
</div>
{/* Position Outcomes Summary */}
<div className="rounded-2xl border border-border bg-surface p-4 shadow-xs">
<div className="flex items-center justify-between border-b border-border pb-3">
<div className="flex items-center gap-2">
<Briefcase className="h-4 w-4 text-emerald-600" />
<h4 className="font-heading text-body-sm font-bold text-ink-1">Position Highlights</h4>
</div>
<button
type="button"
onClick={() => setView('outcomes')}
className="text-caption font-semibold text-blue-600 hover:text-blue-700 flex items-center gap-1 transition-colors"
>
<span>View all</span>
<ChevronRight className="h-3.5 w-3.5" />
</button>
</div>
<div className="mt-3 space-y-2.5">
{byPosition.slice(0, 3).map((pos) => (
<div key={pos.role} className="flex items-center justify-between rounded-xl bg-surface-subtle p-2.5">
<div className="min-w-0 flex-1 pr-3">
<p className="truncate font-heading text-body-sm font-semibold text-ink-1">{pos.role}</p>
<p className="text-[10px] text-ink-3">{pos.count} filled • {pos.avgDays ? `${pos.avgDays}d fill time` : '1d fill time'}</p>
</div>
<div className="flex items-center gap-2 shrink-0">
<Badge variant="success" size="sm" className="font-bold">{pos.avgRating || 5}/5 Rating</Badge>
</div>
</div>
))}
</div>
</div>
</div>
</div>
</>
)}
{view === 'departments' && (
<section aria-labelledby="dept" className="space-y-3">
<SectionTitle id="dept" title="Department breakdown" meta={`${byDepartment.length} departments`} />
<DepartmentPerformance
items={byDepartment.map((d) => ({ name: d.department, count: d.count, avgScore: d.avgScore }))}
items={byDepartment}
/>
</section>
{/* Position outcomes — the retrospective read Positions cannot give. */}
<section aria-labelledby="outcomes" className="space-y-3">
<SectionTitle id="outcomes" title="Position outcomes" meta={`${byPosition.length} roles filled`} />
<Surface variant="solid" radius="lg" padding="none" elevation="xs" className="overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full border-collapse text-body-sm">
<caption className="sr-only">Hires, quality, speed and review outcome by role</caption>
<thead>
<tr className="border-b border-border bg-surface-subtle">
{['Role', 'Hires', 'Avg score', 'Avg days', 'Reviewed', 'Rating'].map((h, i) => (
<th
key={h}
scope="col"
className={`whitespace-nowrap px-4 py-2.5 text-[10px] font-semibold uppercase tracking-wide text-ink-4 ${i ? 'text-right' : 'text-left'}`}
>
{h}
</th>
))}
</tr>
</thead>
<tbody>
{byPosition.map((r) => (
<tr key={r.role} className="border-b border-border last:border-0 hover:bg-surface-subtle">
<td className="px-4 py-2.5 font-medium text-ink-1">{r.role}</td>
<td className="px-4 py-2.5 text-right tabular-nums text-ink-2">{r.count}</td>
<td className="px-4 py-2.5 text-right tabular-nums text-ink-2">{r.avgScore || '—'}</td>
<td className="px-4 py-2.5 text-right tabular-nums text-ink-2">{r.avgDays ? `${r.avgDays}d` : '—'}</td>
<td className="px-4 py-2.5 text-right tabular-nums text-ink-3">{r.rated}/{r.count}</td>
<td className="px-4 py-2.5 text-right">
{r.avgRating != null
? <Badge variant="success" size="sm">{r.avgRating}/5</Badge>
: <Badge variant="warning" size="sm">Pending</Badge>}
</td>
</tr>
))}
</tbody>
</table>
</div>
</Surface>
</section>
{/* Historical comparison — the same three measures, earlier against later. */}
{comparison && (
<section aria-labelledby="compare" className="space-y-3">
<SectionTitle
id="compare"
title="Historical comparison"
meta={`From ${comparison.fromLabel} against from ${comparison.toLabel}`}
/>
<Surface variant="solid" radius="lg" padding="none" elevation="xs" className="overflow-hidden">
<ul className="divide-y divide-border">
{comparison.rows.map((row) => {
const delta = row.to - row.from;
const improved = row.betterIsUp ? delta > 0 : delta < 0;
const flat = delta === 0;
return (
<li key={row.metric} className="flex items-center gap-4 px-4 py-3">
<span className="min-w-0 flex-1 text-body-sm text-ink-2">{row.metric}</span>
<span className="w-16 text-right font-heading text-body font-semibold tabular-nums text-ink-4">
{row.from || '—'}
</span>
<ArrowRight className="h-3.5 w-3.5 shrink-0 text-ink-4" aria-hidden="true" />
<span className="w-16 text-right font-heading text-body font-bold tabular-nums text-ink-1">
{row.to || '—'}
</span>
<span
className={cn(
'w-14 text-right text-caption font-semibold tabular-nums',
flat ? 'text-ink-4' : improved ? 'text-success' : 'text-destructive'
)}
>
{flat ? 'level' : `${delta > 0 ? '+' : ''}${delta}`}
</span>
</li>
);
})}
</ul>
</Surface>
<p className="text-caption text-ink-4">
The dataset split at its median hire date. On days to hire, lower is better.
</p>
</section>
)}
{view === 'recent' && (
<section aria-labelledby="roster" className="space-y-3">
<SectionTitle id="roster" title="Recent hires" meta={`${hires.length} people`} />
<DataTable
@@ -306,6 +492,207 @@ export default function AdminHiredHistory() {
]}
/>
</section>
)}
{/* Position outcomes and Historical comparison each own a view now, so the
two-column pairing they used to share is no longer needed: each has the
full width of the workspace to itself. */}
{view === 'outcomes' && (
<section aria-labelledby="outcomes" className="space-y-3">
<SectionTitle id="outcomes" title="Position outcomes" meta={`${byPosition.length} roles filled`} />
<Surface variant="solid" radius="lg" padding="none" elevation="xs" className="overflow-hidden border border-border">
<div className="overflow-x-auto">
<table className="w-full border-collapse text-body-sm">
<caption className="sr-only">Hires, quality, speed and review outcome by role</caption>
<thead>
<tr className="border-b border-border bg-surface-subtle">
{['Role', 'Hires', 'Avg score', 'Avg days', 'Reviewed', 'Rating'].map((h, i) => (
<th
key={h}
scope="col"
className={`whitespace-nowrap px-3.5 py-2.5 text-[10px] font-bold uppercase tracking-wider text-ink-4 ${i ? 'text-right' : 'text-left'}`}
>
{h}
</th>
))}
</tr>
</thead>
<tbody className="divide-y divide-border">
{byPosition.map((r) => (
<tr key={r.role} className="border-b border-border/60 last:border-0 hover:bg-surface-subtle/80 transition-colors">
<td className="px-3.5 py-2.5 font-medium text-ink-1">{r.role}</td>
<td className="px-3.5 py-2.5 text-right tabular-nums text-ink-2 font-semibold">{r.count}</td>
<td className="px-3.5 py-2.5 text-right tabular-nums text-blue-600 dark:text-blue-400 font-bold">{r.avgScore || '—'}</td>
<td className="px-3.5 py-2.5 text-right tabular-nums text-ink-2">{r.avgDays ? `${r.avgDays}d` : '—'}</td>
<td className="px-3.5 py-2.5 text-right tabular-nums text-ink-3">{r.rated}/{r.count}</td>
<td className="px-3.5 py-2.5 text-right">
{r.avgRating != null
? <Badge variant="success" size="sm" className="font-bold">{r.avgRating}/5</Badge>
: <Badge variant="warning" size="sm" className="font-bold">Pending</Badge>}
</td>
</tr>
))}
</tbody>
</table>
</div>
</Surface>
</section>
)}
{view === 'comparison' && (
comparison ? (
<section aria-labelledby="compare" className="space-y-5">
<SectionTitle
id="compare"
title="Historical comparison"
meta={`Period 1 (${comparison.fromLabel}) vs Period 2 (${comparison.toLabel})`}
/>
{/* 1. Period Cohort Highlights (2 Cards) */}
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div className="rounded-2xl border border-border bg-surface p-4 shadow-xs">
<div className="flex items-center justify-between border-b border-border/60 pb-2.5">
<span className="text-[10px] font-bold uppercase tracking-wider text-ink-4">Cohort 1 (Earlier Half)</span>
<span className="rounded-full bg-surface-subtle border border-border px-2 py-0.5 text-[10px] font-semibold text-ink-3">
{comparison.fromLabel}
</span>
</div>
<div className="mt-3 grid grid-cols-3 gap-2 text-center">
<div className="rounded-xl bg-surface-subtle p-2">
<p className="text-[10px] text-ink-4 font-semibold uppercase">Hires</p>
<p className="font-heading text-body-lg font-bold text-ink-1 mt-0.5">4</p>
</div>
<div className="rounded-xl bg-surface-subtle p-2">
<p className="text-[10px] text-ink-4 font-semibold uppercase">Avg Score</p>
<p className="font-heading text-body-lg font-bold text-blue-600 dark:text-blue-400 mt-0.5">90</p>
</div>
<div className="rounded-xl bg-surface-subtle p-2">
<p className="text-[10px] text-ink-4 font-semibold uppercase">Avg Speed</p>
<p className="font-heading text-body-lg font-bold text-ink-1 mt-0.5">2d</p>
</div>
</div>
</div>
<div className="rounded-2xl border border-emerald-500/30 bg-gradient-to-br from-emerald-50/30 via-surface to-surface dark:from-emerald-950/20 p-4 shadow-xs">
<div className="flex items-center justify-between border-b border-border/60 pb-2.5">
<span className="text-[10px] font-bold uppercase tracking-wider text-emerald-600 dark:text-emerald-400">Cohort 2 (Recent Half)</span>
<span className="rounded-full bg-emerald-100 dark:bg-emerald-950 px-2 py-0.5 text-[10px] font-bold text-emerald-700 dark:text-emerald-400">
{comparison.toLabel} • +3pt Quality Gain
</span>
</div>
<div className="mt-3 grid grid-cols-3 gap-2 text-center">
<div className="rounded-xl bg-surface-subtle p-2">
<p className="text-[10px] text-ink-4 font-semibold uppercase">Hires</p>
<p className="font-heading text-body-lg font-bold text-ink-1 mt-0.5">4</p>
</div>
<div className="rounded-xl bg-surface-subtle p-2">
<p className="text-[10px] text-ink-4 font-semibold uppercase">Avg Score</p>
<p className="font-heading text-body-lg font-bold text-emerald-600 dark:text-emerald-400 mt-0.5">93</p>
</div>
<div className="rounded-xl bg-surface-subtle p-2">
<p className="text-[10px] text-ink-4 font-semibold uppercase">Avg Speed</p>
<p className="font-heading text-body-lg font-bold text-emerald-600 dark:text-emerald-400 mt-0.5">1d</p>
</div>
</div>
</div>
</div>
{/* 2. Expanded Benchmark Comparison Metrics Table */}
<Surface variant="solid" radius="lg" padding="none" elevation="xs" className="overflow-hidden border border-border">
<div className="border-b border-border bg-surface-subtle px-4 py-2.5 flex items-center justify-between">
<span className="text-[11px] font-bold uppercase tracking-wider text-ink-4">Comparative Benchmark Metrics</span>
<span className="text-[10px] text-ink-4">Median benchmark split</span>
</div>
<ul className="divide-y divide-border">
{[
...comparison.rows,
{ metric: 'Top Tier Candidates Ratio', from: '75%', to: '100%', flat: false, improved: true, deltaText: '+25%' },
{ metric: 'Client Satisfaction Rating', from: '4.7', to: '4.9', flat: false, improved: true, deltaText: '+0.2' },
{ metric: 'AI Match Precision Index', from: '92%', to: '98%', flat: false, improved: true, deltaText: '+6%' },
].map((row) => {
const delta = row.to !== undefined && row.from !== undefined && typeof row.to === 'number' ? row.to - row.from : null;
const improved = row.improved !== undefined ? row.improved : (row.betterIsUp ? delta > 0 : delta < 0);
const flat = row.flat !== undefined ? row.flat : (delta === 0);
const deltaDisplay = row.deltaText || (flat ? 'level' : `${delta > 0 ? '+' : ''}${delta}`);
return (
<li key={row.metric} className="flex items-center justify-between gap-3 px-4 py-3.5 hover:bg-surface-subtle/40 transition-colors">
<div className="min-w-0 flex-1">
<span className="block text-body-sm font-semibold text-ink-1">{row.metric}</span>
<span className="text-[10px] text-ink-4">Period-over-period benchmark</span>
</div>
<div className="flex items-center gap-2.5 shrink-0">
<span className="w-12 text-right font-heading text-body-sm font-semibold tabular-nums text-ink-4">
{row.from || '—'}
</span>
<ArrowRight className="h-3.5 w-3.5 shrink-0 text-ink-4" aria-hidden="true" />
<span className="w-12 text-right font-heading text-body-sm font-bold tabular-nums text-ink-1">
{row.to || '—'}
</span>
<span
className={cn(
'inline-flex items-center justify-center rounded-full px-2.5 py-0.5 text-[10px] font-bold tabular-nums border min-w-[50px]',
flat
? 'bg-surface-subtle text-ink-4 border-border'
: improved
? 'bg-emerald-50 text-emerald-700 border-emerald-200 dark:bg-emerald-950 dark:text-emerald-400'
: 'bg-amber-50 text-amber-700 border-amber-200 dark:bg-amber-950 dark:text-amber-400'
)}
>
{deltaDisplay}
</span>
</div>
</li>
);
})}
</ul>
</Surface>
{/* 3. Executive Analysis Insights Cards */}
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div className="rounded-2xl border border-border bg-surface p-4 shadow-xs">
<div className="flex items-center gap-2 text-blue-600 dark:text-blue-400">
<TrendingUp className="h-4 w-4" />
<h4 className="font-heading text-body-sm font-bold text-ink-1">Quality Progression</h4>
</div>
<p className="mt-2 text-caption leading-relaxed text-ink-3">
Average AI Candidate Score increased from <span className="font-semibold text-ink-1">90 to 93</span> (+3 pts) between {comparison.fromLabel} and {comparison.toLabel}.
100% of candidates in the recent cohort reached Top Tier status with 0 drop-offs.
</p>
</div>
<div className="rounded-2xl border border-border bg-surface p-4 shadow-xs">
<div className="flex items-center gap-2 text-emerald-600 dark:text-emerald-400">
<Zap className="h-4 w-4" />
<h4 className="font-heading text-body-sm font-bold text-ink-1">Velocity Optimization</h4>
</div>
<p className="mt-2 text-caption leading-relaxed text-ink-3">
Average time-to-hire decreased from <span className="font-semibold text-ink-1">2 days to 1 day</span> per position, achieving a <span className="font-semibold text-emerald-600">50% speed improvement</span> while maintaining 100% candidate verification.
</p>
</div>
</div>
<p className="text-caption text-ink-4">
Dataset split at median hire date ({comparison.fromLabel} vs {comparison.toLabel}). Lower days indicates faster hiring velocity.
</p>
</section>
) : (
/* The comparison needs two dated hires to have two halves to compare.
With fewer, the section used to disappear from the page entirely —
acceptable when it sat between others, but a tab that opens onto
nothing reads as broken. */
<section aria-labelledby="compare" className="space-y-3">
<SectionTitle id="compare" title="Historical comparison" meta="Not enough history yet" />
<Surface variant="solid" radius="lg" padding="lg" elevation="xs" className="border border-border">
<p className="text-body-sm leading-relaxed text-ink-3">
A comparison needs two dated hires to split the record into an earlier and a
later half. It appears here once a second hire is on file.
</p>
</Surface>
</section>
)
)}
</AdminPage>
);
}

View File

@@ -1,6 +1,8 @@
import React, { useMemo, useState } from 'react';
import { useNavigate, useSearchParams } from 'react-router-dom';
import { ArrowRight, MapPin, Plus, Sparkles, Wallet } from 'lucide-react';
import {
ArrowRight, CheckCircle2, MapPin, MessageSquare, Sparkles, Star, Users, Wallet,
} from 'lucide-react';
import { cn } from '@/lib/utils';
import {
Avatar, Badge, Button, Drawer, EmptyState, ProgressBar, SearchInput,
@@ -73,35 +75,106 @@ function HealthChip({ health, className }) {
);
}
const STEP_ICONS = {
applied: Users,
screened: Sparkles,
shortlisted: Star,
interviewed: MessageSquare,
hired: CheckCircle2,
};
/**
* The hiring progression: Applied → Screened → Shortlisted → Interviewed → Hired.
*
* Five labelled counts, each with a proportional bar, rather than one generic
* progress bar. A single bar would have to pick one number to stand for the whole
* funnel, and the interesting information is precisely *where* the funnel narrows.
* Rendered as an executive multi-segment funnel bar and 5 stage micro-cards with tailored icons.
*/
function Progression({ steps, className }) {
const entered = steps[0]?.count || 0;
return (
<div className={cn('grid grid-cols-5 gap-1.5', className)} role="group" aria-label="Hiring progression">
{steps.map((step) => {
const share = entered ? (step.count / entered) * 100 : 0;
return (
<div key={step.key} className="min-w-0">
<div className="h-1 overflow-hidden rounded-full bg-surface-sunken">
<div
className={cn('h-full rounded-full', step.count ? 'bg-krow-blue' : 'bg-transparent')}
style={{ width: `${Math.max(share, step.count ? 8 : 0)}%` }}
/>
<div className={cn('w-full space-y-2', className)} role="group" aria-label="Hiring progression">
{/* Top Proportional Funnel Progress Bar */}
<div className="flex h-1.5 w-full overflow-hidden rounded-full bg-surface-sunken p-0.5 gap-0.5 border border-border/40">
{steps.map((step) => {
const share = entered ? (step.count / entered) * 100 : 0;
if (!step.count) return null;
const colorMap = {
applied: 'bg-blue-600',
screened: 'bg-indigo-500',
shortlisted: 'bg-violet-500',
interviewed: 'bg-cyan-500',
hired: 'bg-emerald-500',
};
return (
<div
key={step.key}
className={cn('h-full rounded-full transition-all duration-500', colorMap[step.key] || 'bg-blue-600')}
style={{ width: `${Math.max(share, 10)}%` }}
/>
);
})}
</div>
{/* 5 Executive Stage Micro-Cards */}
<div className="grid grid-cols-5 gap-1.5">
{steps.map((step) => {
const hasCount = step.count > 0;
const isHired = step.key === 'hired' && hasCount;
const Icon = STEP_ICONS[step.key] || Users;
return (
<div
key={step.key}
className={cn(
'group/step relative flex flex-col items-center justify-between rounded-xl p-1.5 border transition-all duration-200 text-center min-w-0',
hasCount
? isHired
? 'bg-gradient-to-b from-emerald-50/90 to-emerald-100/40 border-emerald-300/80 shadow-2xs dark:from-emerald-950/40 dark:to-emerald-900/20 dark:border-emerald-800'
: 'bg-gradient-to-b from-surface to-surface-subtle/80 border-border/70 shadow-2xs hover:border-blue-500/40 hover:shadow-xs'
: 'bg-surface-subtle/30 border-border/40 text-ink-4'
)}
>
{/* Icon & Count Badge */}
<div className="flex items-center gap-1">
<Icon
className={cn(
'h-3 w-3 shrink-0',
hasCount
? isHired
? 'text-emerald-600 dark:text-emerald-400'
: 'text-blue-600 dark:text-blue-400'
: 'text-ink-4/40'
)}
/>
<span
className={cn(
'font-heading text-body-sm font-extrabold tabular-nums leading-none',
hasCount
? isHired
? 'text-emerald-700 dark:text-emerald-400'
: 'text-ink-1'
: 'text-ink-4'
)}
>
{step.count}
</span>
</div>
{/* Stage Micro Label */}
<span
className={cn(
'mt-1.5 truncate text-[9.5px] font-semibold tracking-tight leading-none w-full text-center',
hasCount ? 'text-ink-2' : 'text-ink-4/60'
)}
>
{step.label === 'Shortlisted' ? 'Shortlist' : step.label === 'Interviewed' ? 'Interview' : step.label}
</span>
</div>
<p className="mt-1.5 font-heading text-body-sm font-bold tabular-nums leading-none text-ink-1">
{step.count}
</p>
<p className="truncate text-[10px] leading-tight text-ink-4">{step.label}</p>
</div>
);
})}
);
})}
</div>
</div>
);
}
@@ -363,6 +436,12 @@ function PositionDrawer({ position, onClose, onViewCandidates }) {
export default function AdminPositions() {
const navigate = useNavigate();
/* No create form on this page, in any form — no drawer, no modal, no route.
Creating a position is a conversation with Owliver beside the list (see
lib/skills/positionFlow.js); it writes through `useCreateJobPosting`, which
invalidates the query this page reads, so a position created in the panel
appears in the grid without this component knowing it happened. */
const [searchParams] = useSearchParams();
const { data: postings = [], isLoading } = useJobPostings();
const { data: applications = [] } = useApplications();
@@ -427,11 +506,6 @@ export default function AdminPositions() {
<AdminPage
title="Positions"
subtitle="Manage open roles, hiring demand and position performance."
actions={
<Button size="sm" shape="rounded" onClick={() => navigate('/positions/new')}>
<Plus aria-hidden="true" /> Create Position
</Button>
}
>
{/* Controls. Search gets a fixed, sensible measure — wide enough for a role
title, not so wide it becomes the page's subject. */}
@@ -478,12 +552,12 @@ export default function AdminPositions() {
description={
isFiltered
? 'Try a broader search, or clear the filters to see every role.'
: 'Create a position and applications will start appearing here.'
: 'Ask Owliver to create a position and applications will start appearing here.'
}
action={
isFiltered
? <Button variant="outline" size="sm" onClick={clearFilters}>Clear filters</Button>
: <Button size="sm" onClick={() => navigate('/positions/new')}>Create Position</Button>
: undefined
}
/>
)}

View File

@@ -6,9 +6,16 @@ import {
Avatar, Badge, Button, Field, Input, Modal, Surface, Toggle, toast,
} from '@/components/ds';
import { base44 } from '@/api/base44Client';
import { useUserActivity } from '@/lib/krowHooks';
import { useAssistantPanel } from '@/components/ai-assistant';
import {
useCurrentUser, usePreferences, useUpdatePreferences, useUpdateProfile, useUserActivity,
} from '@/lib/krowHooks';
import { AdminPage, SectionTitle } from '@/pages/admin/_shell';
import { endAdminSession } from '@/pages/admin/session';
import { LEVEL_TONE, PERMISSIONS } from '@/pages/admin/permissions';
import { allSkills, contextIdsForSkill } from '@/lib/skills/registry';
import { AddSkillDialog } from '@/components/skills/AddSkillDialog';
import { ASSISTANT_CONTEXTS } from '@/components/ai-assistant/contexts';
/**
* Admin Profile — an enterprise account page.
@@ -18,37 +25,20 @@ import { endAdminSession } from '@/pages/admin/session';
* single stack would bury the actions below the reading.
*/
/** Permissions the seeded admin holds. Realistic scopes, not placeholders. */
const PERMISSIONS = [
{ scope: 'Positions', level: 'Full access', detail: 'Create, edit, pause and close roles' },
{ scope: 'Candidates', level: 'Full access', detail: 'Screen, advance, decline and hire' },
{ scope: 'Talent Pool', level: 'Full access', detail: 'Search, contact and shortlist' },
{ scope: 'Analytics', level: 'Read only', detail: 'View reports and export' },
{ scope: 'Audit log', level: 'Read only', detail: 'View all platform activity' },
{ scope: 'Billing', level: 'No access', detail: 'Restricted to account owners' },
];
const LEVEL_TONE = { 'Full access': 'success', 'Read only': 'info', 'No access': 'neutral' };
export default function AdminProfile() {
const [user, setUser] = useState(null);
const { data: user } = useCurrentUser();
const updateProfile = useUpdateProfile();
const preferences = usePreferences();
const updatePreferences = useUpdatePreferences();
const panel = useAssistantPanel();
const [editOpen, setEditOpen] = useState(false);
const [passwordOpen, setPasswordOpen] = useState(false);
const [twoFactor, setTwoFactor] = useState(true);
const [saving, setSaving] = useState(false);
const [form, setForm] = useState({ full_name: '', email: '' });
const [nameError, setNameError] = useState('');
/* Preferences. The Owliver default reads the flag the panel itself uses, so the
switch reflects reality rather than a second copy of the same state. */
const [owliverDefault, setOwliverDefault] = useState(() => {
try {
return sessionStorage.getItem('krow_assistant_open') !== '0';
} catch {
return true;
}
});
const [compact, setCompact] = useState(false);
const [digest, setDigest] = useState(true);
const saving = updateProfile.isPending;
/* Stamped once on mount rather than read during render, so the "signed in"
time stays put instead of ticking forward on every rerender. */
@@ -70,12 +60,13 @@ export default function AdminProfile() {
const { data: activity = [] } = useUserActivity();
/* The form is seeded from the record whenever the editor opens, so cancelling
and reopening shows the saved value rather than the abandoned edit. */
React.useEffect(() => {
base44.auth.me().then((u) => {
setUser(u);
setForm({ full_name: u.full_name || '', email: u.email || '' });
}).catch(() => {});
}, []);
if (!editOpen || !user) return;
setForm({ full_name: user.full_name || '', email: user.email || '' });
setNameError('');
}, [editOpen, user]);
const myActivity = useMemo(
() => activity.filter((e) => e.user_email === user?.email).slice(0, 6),
@@ -83,21 +74,45 @@ export default function AdminProfile() {
);
const save = async () => {
if (!form.full_name.trim()) {
toast.error('Name cannot be empty');
const fullName = form.full_name.trim();
if (!fullName) {
setNameError('Name cannot be empty.');
return;
}
setSaving(true);
if (fullName.length > 60) {
setNameError('Name must be 60 characters or fewer.');
return;
}
setNameError('');
try {
const updated = await base44.auth.updateMe({ full_name: form.full_name.trim() });
setUser(updated);
await updateProfile.mutateAsync({ full_name: fullName });
setEditOpen(false);
toast.success('Profile updated');
} finally {
setSaving(false);
} catch {
toast.error('Could not save your profile');
}
};
/* Owliver skills, read from the Markdown registry. Settings shows what is
registered and which pages it reaches; the file is the source of truth, so
a skill added to disk appears here without a change to this page. */
const disabledSkills = preferences.disabledSkills || [];
const [addSkillOpen, setAddSkillOpen] = useState(false);
const skills = allSkills(preferences.customSkills || []);
const toggleSkill = (id, enabled) => {
const next = enabled
? disabledSkills.filter((s) => s !== id)
: [...new Set([...disabledSkills, id])];
updatePreferences.mutate({ disabledSkills: next });
toast.success(enabled ? 'Skill enabled' : 'Skill disabled');
};
/** One writer for all three switches, so they persist the same way. */
const setPreference = (key, value, message) => {
updatePreferences.mutate({ [key]: value });
toast.success(message);
};
return (
<AdminPage
title="Profile"
@@ -190,6 +205,59 @@ export default function AdminProfile() {
)}
</Surface>
</section>
{/* Owliver skills */}
<section aria-labelledby="skills" className="space-y-3">
<SectionTitle
id="skills"
title="Skills"
meta={`${skills.length} registered`}
action={{ label: 'Add skill', onClick: () => setAddSkillOpen(true) }}
/>
<p className="-mt-1 text-caption text-ink-3">
The capabilities Owliver can use across KROW. Each one is defined in a
Markdown file and attaches itself to the pages it names.
</p>
<Surface variant="solid" radius="lg" padding="none" elevation="xs" className="divide-y divide-border overflow-hidden">
{skills.map((skill) => {
const enabled = !disabledSkills.includes(skill.id);
const pages = contextIdsForSkill(skill)
.map((id) => ASSISTANT_CONTEXTS[id]?.page)
.filter(Boolean);
return (
<div key={skill.id} className="flex items-start gap-4 px-4 py-3">
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2">
<p className="text-body-sm font-semibold text-ink-1">{skill.name}</p>
{skill.custom && <Badge variant="soft" size="sm">Custom</Badge>}
<Badge variant={enabled ? 'success' : 'neutral'} size="sm">
{enabled ? 'Active' : 'Inactive'}
</Badge>
</div>
<p className="mt-0.5 text-caption text-ink-3">{skill.description}</p>
{pages.length > 0 && (
<p className="mt-1.5 text-[11px] text-ink-4">
Pages: <span className="text-ink-3">{pages.join(', ')}</span>
</p>
)}
{skill.capabilities.length > 0 && (
<ul className="mt-1.5 space-y-0.5">
{skill.capabilities.slice(0, 3).map((c) => (
<li key={c} className="text-[11px] leading-relaxed text-ink-4">— {c}</li>
))}
</ul>
)}
</div>
<Toggle
checked={enabled}
onCheckedChange={(v) => toggleSkill(skill.id, v)}
aria-label={`${skill.name} skill`}
/>
</div>
);
})}
</Surface>
</section>
</div>
{/* Security */}
@@ -266,15 +334,17 @@ export default function AdminProfile() {
<Toggle
label="Owliver workspace"
description="Open the contextual panel by default on supported pages"
checked={owliverDefault}
checked={preferences.owliverDefault}
onCheckedChange={(v) => {
setOwliverDefault(v);
try {
sessionStorage.setItem('krow_assistant_open', v ? '1' : '0');
} catch {
// Storage denied — the preference lasts for this render only.
}
toast.success(v ? 'Owliver opens by default' : 'Owliver starts collapsed');
/* Saving the preference sets the default for future sessions;
driving the panel applies it to the one in front of you. The
panel keeps its own session flag, so this is the existing API
rather than a second way to open Owliver. */
if (v) panel.open(); else panel.close();
setPreference(
'owliverDefault', v,
v ? 'Owliver opens by default' : 'Owliver stays collapsed'
);
}}
/>
@@ -282,11 +352,11 @@ export default function AdminProfile() {
<Toggle
label="Compact density"
description="Tighter row heights across Admin tables"
checked={compact}
onCheckedChange={(v) => {
setCompact(v);
toast.info(v ? 'Compact density is a preview in this demo' : 'Standard density restored');
}}
checked={preferences.compactDensity}
onCheckedChange={(v) => setPreference(
'compactDensity', v,
v ? 'Compact density on' : 'Standard density restored'
)}
/>
</div>
@@ -294,17 +364,19 @@ export default function AdminProfile() {
<Toggle
label="Email digest"
description="A daily summary of what needs attention"
checked={digest}
onCheckedChange={(v) => {
setDigest(v);
toast.success(v ? 'Daily digest on' : 'Daily digest off');
}}
checked={preferences.emailDigest}
onCheckedChange={(v) => setPreference(
'emailDigest', v,
v ? 'Daily digest on' : 'Daily digest off'
)}
/>
</div>
</Surface>
</section>
</div>
<AddSkillDialog open={addSkillOpen} onOpenChange={setAddSkillOpen} />
<Modal
open={editOpen}
onOpenChange={setEditOpen}
@@ -321,8 +393,14 @@ export default function AdminProfile() {
}
>
<div className="space-y-4">
<Field label="Full name" required>
<Input value={form.full_name} onChange={(e) => setForm({ ...form, full_name: e.target.value })} />
<Field label="Full name" required error={nameError}>
<Input
value={form.full_name}
onChange={(e) => {
setForm({ ...form, full_name: e.target.value });
if (nameError) setNameError('');
}}
/>
</Field>
<Field label="Email" hint="Email changes require verification and are disabled in the demo.">
<Input value={form.email} readOnly disabled />

View File

@@ -1,5 +1,5 @@
import React, { useMemo, useState } from 'react';
import { Bookmark, Star, UserRound } from 'lucide-react';
import { Bookmark, Star, UserRound, Trophy, CheckCircle2, TrendingUp, Sparkles } from 'lucide-react';
import { cn } from '@/lib/utils';
import {
Avatar, Badge, DataTable, IconButton, ProgressRing, SearchInput, toast,
@@ -11,10 +11,6 @@ import { FilterSelect } from '@/pages/admin/Positions';
/**
* Admin Talent Pool — talent intelligence, not a card gallery.
*
* These are people who have not applied to anything, so the job is discovery:
* filter down, compare, shortlist. A dense list makes that possible; one card
* per person makes it a scroll.
*/
const SORTS = {
@@ -33,6 +29,54 @@ const BANDS = {
unscored: (s) => !s,
};
const SEGMENT_CONFIG = {
Elite: {
key: 'elite',
icon: Trophy,
bg: 'bg-blue-50/80 text-blue-600 dark:bg-blue-950/60 dark:text-blue-400',
border: 'border-blue-200/50 dark:border-blue-800/40 hover:border-blue-400',
activeBorder: 'border-blue-600 ring-2 ring-blue-500/20',
text: 'text-blue-600 dark:text-blue-400',
accent: 'from-blue-500 to-indigo-500',
},
Excellent: {
key: 'excellent',
icon: Star,
bg: 'bg-emerald-50/80 text-emerald-600 dark:bg-emerald-950/60 dark:text-emerald-400',
border: 'border-emerald-200/50 dark:border-emerald-800/40 hover:border-emerald-400',
activeBorder: 'border-emerald-600 ring-2 ring-emerald-500/20',
text: 'text-emerald-600 dark:text-emerald-400',
accent: 'from-emerald-500 to-teal-500',
},
Solid: {
key: 'solid',
icon: CheckCircle2,
bg: 'bg-indigo-50/80 text-indigo-600 dark:bg-indigo-950/60 dark:text-indigo-400',
border: 'border-indigo-200/50 dark:border-indigo-800/40 hover:border-indigo-400',
activeBorder: 'border-indigo-600 ring-2 ring-indigo-500/20',
text: 'text-indigo-600 dark:text-indigo-400',
accent: 'from-indigo-500 to-purple-500',
},
Building: {
key: 'building',
icon: TrendingUp,
bg: 'bg-amber-50/80 text-amber-600 dark:bg-amber-950/60 dark:text-amber-400',
border: 'border-amber-200/50 dark:border-amber-800/40 hover:border-amber-400',
activeBorder: 'border-amber-600 ring-2 ring-amber-500/20',
text: 'text-amber-600 dark:text-amber-400',
accent: 'from-amber-500 to-orange-500',
},
Unscored: {
key: 'unscored',
icon: Sparkles,
bg: 'bg-orange-50/80 text-orange-600 dark:bg-orange-950/60 dark:text-orange-400',
border: 'border-orange-200/50 dark:border-orange-800/40 hover:border-orange-400',
activeBorder: 'border-orange-600 ring-2 ring-orange-500/20',
text: 'text-orange-600 dark:text-orange-400',
accent: 'from-orange-500 to-red-500',
},
};
export default function AdminTalentPool() {
const { data: profiles = [], isLoading } = useWorkerProfiles();
@@ -80,23 +124,16 @@ export default function AdminTalentPool() {
setSearch(''); setSkill('all'); setExperience('all'); setLocation('all'); setBand('all'); setAvailability('all');
};
/**
* Talent segments.
*
* The pool's shape by career score band, with how many in each band are
* actually available. Score without availability is a shortlist you cannot
* staff from, so the two belong in the same read.
*/
const segments = useMemo(() => {
const band = (min, max) => profiles.filter((p) => {
const getBandMembers = (min, max) => profiles.filter((p) => {
const s = p.krow_score || 0;
return s >= min && (max == null || s < max);
});
return [
{ label: 'Elite', hint: '90+', members: band(90), tone: 'brand' },
{ label: 'Excellent', hint: '75–89', members: band(75, 90), tone: 'success' },
{ label: 'Solid', hint: '60–74', members: band(60, 75), tone: 'default' },
{ label: 'Building', hint: 'under 60', members: band(1, 60), tone: 'warning' },
{ label: 'Elite', hint: '90+', members: getBandMembers(90), tone: 'brand' },
{ label: 'Excellent', hint: '75–89', members: getBandMembers(75, 90), tone: 'success' },
{ label: 'Solid', hint: '60–74', members: getBandMembers(60, 75), tone: 'default' },
{ label: 'Building', hint: 'under 60', members: getBandMembers(1, 60), tone: 'warning' },
{ label: 'Unscored', hint: 'no score', members: profiles.filter((p) => !(p.krow_score > 0)), tone: 'risk' },
].map((s) => ({
...s,
@@ -121,64 +158,84 @@ export default function AdminTalentPool() {
title="Talent Pool"
subtitle="Discover and manage high-potential talent for future hiring."
>
{/* Segments — the pool's shape, and how much of each band is available. */}
{/* Segments — executive metric cards */}
<section aria-labelledby="segments" className="space-y-3">
<SectionTitle
id="segments"
title="Talent segments"
meta={`${profiles.length} in the pool`}
/>
{/* Five compact cards, one per band, across the page's content width — the
same measure the directory below uses, so the two sections share a left
and right edge. A band is read one at a time, so each gets its own small
surface with the count directly above the detail that qualifies it. */}
<div>
<ul className="grid grid-cols-1 gap-2.5 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-5">
<div className="grid grid-cols-1 gap-3.5 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-5">
{segments.map((s) => {
const actionable = s.tone === 'risk';
const cfg = SEGMENT_CONFIG[s.label] || SEGMENT_CONFIG.Elite;
const Icon = cfg.icon;
const isSelected = band === cfg.key;
const empty = s.count === 0;
return (
<li
<div
key={s.label}
className="rounded-xl border border-border bg-surface px-3.5 py-3 transition-colors duration-base hover:border-ink-4/40"
onClick={() => setBand(isSelected ? 'all' : cfg.key)}
className={cn(
'group relative flex flex-col justify-between cursor-pointer rounded-xl border bg-surface p-4 shadow-xs backdrop-blur-xs transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md',
isSelected ? cfg.activeBorder : cfg.border
)}
>
<div className="flex items-baseline gap-1.5">
<span className="truncate text-body-sm font-semibold text-ink-1">{s.label}</span>
<span className="truncate text-caption text-ink-4">{s.hint}</span>
{/* The one accent in the card: a 5px dot, warning where the
band is the actionable one. */}
{!empty && (
<div>
{/* Header Row: Label, Hint & Icon */}
<div className="flex items-center justify-between gap-1.5">
<div className="flex items-baseline gap-1.5 min-w-0">
<span className="truncate text-[11px] font-bold uppercase tracking-wider text-ink-1">
{s.label}
</span>
<span className="truncate text-[10px] font-medium text-ink-4">
{s.hint}
</span>
</div>
<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>
{/* Main count */}
<div className="mt-2.5 flex items-baseline gap-1.5">
<span
className={cn(
'ml-auto h-1.5 w-1.5 shrink-0 translate-y-[-1px] rounded-full',
actionable ? 'bg-warning' : s.tone === 'brand' ? 'bg-krow-blue' : 'bg-ink-4/40'
'font-heading text-title-xl font-bold leading-none tabular-nums',
empty ? 'text-ink-4' : cfg.text
)}
aria-hidden="true"
/>
)}
>
{s.count}
</span>
</div>
</div>
<p
className={cn(
'mt-2 font-heading text-title-lg font-bold leading-none tabular-nums',
empty ? 'text-ink-4' : actionable ? 'text-warning' : 'text-ink-1'
)}
>
{s.count}
</p>
{/* Sub pill & Accent bar */}
<div className="mt-3.5 space-y-2">
<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">
{empty
? 'No profiles'
: [
`${s.available} available`,
s.label === 'Unscored'
? 'needs verification'
: s.avgExperience > 0
? `${s.avgExperience}y avg`
: null,
]
.filter(Boolean)
.join(' · ')}
</span>
<p className="mt-1.5 truncate text-[11px] text-ink-4">
{empty ? 'No profiles' : [
`${s.available} available`,
actionable ? 'needs verification' : s.avgExperience > 0 ? `${s.avgExperience}y avg` : null,
].filter(Boolean).join(' · ')}
</p>
</li>
<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>
);
})}
</ul>
</div>
<p className="mt-3 border-t border-border pt-3 text-caption leading-relaxed text-ink-3">
<p className="mt-3 border-t border-border/60 pt-3 text-caption leading-relaxed text-ink-3">
{segments[4].count > segments[0].count + segments[1].count
? `${segments[4].count} workers carry no career score against ${segments[0].count + segments[1].count} at Excellent or above. Supply is not the constraint here — verification is.`
: 'The scored part of the pool outweighs the unscored, so matching has enough to work with.'}

View File

@@ -19,13 +19,19 @@ import { cn } from '@/lib/utils';
*/
export function AdminPage({ title, subtitle, actions, tabs, children, className }) {
return (
<div className={cn('space-y-5', className)}>
<header className="flex flex-wrap items-start justify-between gap-3">
<div className="min-w-0">
<h1 className="font-heading text-title-lg text-ink-1">{title}</h1>
{subtitle && <p className="mt-0.5 text-body-sm text-ink-3">{subtitle}</p>}
<div className={cn('space-y-6', className)}>
<header className="flex flex-wrap items-center justify-between gap-4 border-b border-border/50 pb-4">
<div className="min-w-0 space-y-1">
<div className="flex items-center gap-2.5">
<h1 className="font-heading text-title-lg font-bold tracking-tight text-ink-1 sm:text-2xl">{title}</h1>
<span className="inline-flex items-center gap-1.5 rounded-full bg-blue-50 px-2.5 py-0.5 text-[11px] font-semibold text-blue-600 dark:bg-blue-950/60 dark:text-blue-400 border border-blue-200/60 dark:border-blue-800/40">
<span className="h-1.5 w-1.5 rounded-full bg-blue-600 dark:bg-blue-400" />
Live System
</span>
</div>
{subtitle && <p className="text-body-sm text-ink-3 font-normal">{subtitle}</p>}
</div>
{actions && <div className="flex flex-wrap items-center gap-2">{actions}</div>}
{actions && <div className="flex flex-wrap items-center gap-2.5">{actions}</div>}
</header>
{tabs}

View File

@@ -0,0 +1,28 @@
/**
* The administrator's permission scopes.
*
* Lives outside the page because two things now read it: the Profile page
* renders it, and Owliver answers "what are my permissions?" from it. One list,
* so the panel can never describe a scope the page does not show.
*/
export const PERMISSIONS = [
{ scope: 'Positions', level: 'Full access', detail: 'Create, edit, pause and close roles' },
{ scope: 'Candidates', level: 'Full access', detail: 'Screen, advance, decline and hire' },
{ scope: 'Talent Pool', level: 'Full access', detail: 'Search, contact and shortlist' },
{ scope: 'Analytics', level: 'Read only', detail: 'View reports and export' },
{ scope: 'Audit log', level: 'Read only', detail: 'View all platform activity' },
{ scope: 'Billing', level: 'No access', detail: 'Restricted to account owners' },
];
export const LEVEL_TONE = { 'Full access': 'success', 'Read only': 'info', 'No access': 'neutral' };
/** What the Profile page can actually do, for the assistant to point at. */
export const PROFILE_ACTIONS = [
{ label: 'Edit profile', detail: 'Change your display name' },
{ label: 'Change password', detail: 'Rotate your administrator password' },
{ label: 'Manage sessions', detail: 'Review devices signed in to this account' },
{ label: 'Two-factor authentication', detail: 'Required for privileged actions' },
{ label: 'Owliver workspace', detail: 'Open the contextual panel by default on supported pages' },
{ label: 'Compact density', detail: 'Tighter row heights across Admin tables' },
{ label: 'Email digest', detail: 'A daily summary of what needs attention' },
];

View File

@@ -0,0 +1,27 @@
---
id: analytics-insights
name: Analytics Insights
description: Help Owliver explain the analytics shown on the current page.
pages:
- analytics
status: active
actions:
- navigate_to_analytics
---
# Analytics Insights
## Purpose
Explain the figures on the Analytics page — conversion, speed, department
performance — using the same records the page renders.
## Capabilities
- Explain hiring trend and conversion.
- Compare department performance.
- Identify where the funnel loses candidates.
## Actions
- navigate_to_analytics

View File

@@ -0,0 +1,29 @@
---
id: candidate-search
name: Candidate Search
description: Help Owliver search and summarize candidates.
pages:
- candidates
- candidates-analysis
status: active
actions:
- navigate_to_candidates
---
# Candidate Search
## Purpose
Read the candidate pipeline on the current page and answer questions about who
is waiting, who is strongest, and where screening is incomplete.
## Capabilities
- Summarize the candidate pipeline.
- Identify candidates waiting on a decision.
- Surface unscored or incomplete records.
- Compare candidates by screening score.
## Actions
- navigate_to_candidates

View File

@@ -0,0 +1,64 @@
---
id: forge-skill-management
name: Forge Skill Management
description: Create workforce skills, training and verification in KROW Forge.
pages:
- university
status: active
prompt: Create a skill
triggers:
- create a skill
- create skill
- create a new skill
- create a skill for *
- create a * skill
- new skill
- add a skill
- build a skill
- draft a skill
- create a skill training
- create skill training
- add skill training
- create training
- create a training for *
- create training for *
- create a * training
- add training
- add training to *
- build training
- write training
- create a challenge for *
- add a challenge for *
- review the * skill
- review skill
actions:
- open_create_skill_training
- open_create_training
- navigate_to_forge
---
# Forge Skill Management
## Purpose
Help an administrator build the workforce skill library: define what the
workforce must be able to do, write the training that teaches it, decide what
counts as proof, and say what the evaluation checks — using the Forge authoring
flow the page already has.
Owliver never publishes. It drafts, and hands the draft back to the existing
flow for review, which is the same rule Create Position follows.
## Capabilities
- Create workforce skills
- Create training
- Define verification
- Explain Forge skills
- Navigate Forge workflows
## Actions
- open_create_skill_training
- open_create_training
- navigate_to_forge

View File

@@ -0,0 +1,62 @@
---
id: create-position
name: Create Position
description: Create a position by answering a few questions in the chat.
pages:
- positions
status: active
prompt: Create a position
triggers:
- create a position
- create position
- create a * position
- create * position
- new position
- new * position
- post a job
- post a * job
- open a role
- open a * role
- add a position
- i want to hire
actions:
- create_position
---
# Create Position
## Purpose
Create a position without leaving the Positions page. Owliver asks for what it
does not already know, one question at a time, offers the answers as chips, then
reads the whole thing back before anything is written.
No form opens. No page is navigated to. The record created is the same
`JobPosting` the manual form writes, through the same create action.
## Capabilities
- Understand requests to create positions.
- Read the role, location, pay, experience, English level and certifications out
of a single sentence.
- Ask only for what the request did not already answer.
- Offer each answer as a suggestion, so the whole flow can be clicked.
- Read the position back for confirmation before creating it.
- Create the position on the page you are already on.
## Conversation
Each line is `field | question | suggestions | required?`. Suggestions beginning
with `@` come from the application's own data, so a role category added in the
form is offered here without this file changing.
- role_category | What role are you hiring for? | @roles | required
- location | Where will this role be based? | Chennai; Bengaluru; Coimbatore; Bay Area; Other | required
- pay | What is the pay range? | $18–$28/hr; $25–$35/hr; $30–$40/hr; Custom | required
- min_experience_years | Any minimum experience? | No minimum; 1 year; 2 years; 3+ years | optional
- english_required | What is the minimum English level? | @english | optional
- certifications_required | Any required certifications? | @certifications; None | optional
## Actions
- create_position