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