fix error line
This commit is contained in:
@@ -121,7 +121,7 @@ export default function AdminActivity() {
|
||||
const recent = useMemo(
|
||||
() => events
|
||||
.slice()
|
||||
.sort((a, b) => new Date(b.created_date) - new Date(a.created_date))
|
||||
.sort((a, b) => new Date(b.created_date).getTime() - new Date(a.created_date).getTime())
|
||||
.slice(0, TIMELINE_LIMIT),
|
||||
[events]
|
||||
);
|
||||
|
||||
@@ -85,7 +85,8 @@ const PRESSABLE = 'transition-transform duration-150 hover:-translate-y-px activ
|
||||
/* ── Small shared pieces ────────────────────────────────────────────────── */
|
||||
|
||||
/** A label inside a section — a rung below the section headings the rail draws. */
|
||||
function SubLabel({ children, className }) {
|
||||
/** @param {any} props */
|
||||
function SubLabel({ children, className = '' }) {
|
||||
return (
|
||||
<p className={cn('mb-2 text-[10px] font-semibold uppercase tracking-[0.08em] text-ink-4', className)}>
|
||||
{children}
|
||||
@@ -115,7 +116,8 @@ function Signal({ label, value }) {
|
||||
}
|
||||
|
||||
/** A reputation figure. Value large, label quiet, no card around it. */
|
||||
function Metric({ label, value, suffix, tone }) {
|
||||
/** @param {any} props */
|
||||
function Metric({ label, value, suffix = '', tone = '' }) {
|
||||
if (value == null || value === '') return null;
|
||||
return (
|
||||
<div className="min-w-0 px-4 py-3">
|
||||
@@ -163,7 +165,8 @@ function Fact({ label, children }) {
|
||||
* Staggered by index, once. The delay is capped so a candidate with twenty
|
||||
* skills does not make the reader watch a queue form.
|
||||
*/
|
||||
function Pill({ index = 0, className, children }) {
|
||||
/** @param {any} props */
|
||||
function Pill({ index = 0, className = '', children }) {
|
||||
const reduced = useReducedMotion();
|
||||
return (
|
||||
<motion.span
|
||||
|
||||
@@ -21,7 +21,7 @@ const SCORE_BANDS = {
|
||||
|
||||
const SORTS = {
|
||||
score: { label: 'Highest score', compare: (a, b) => (b.ai_score || 0) - (a.ai_score || 0) },
|
||||
recent: { label: 'Recent activity', compare: (a, b) => new Date(b.updated_date) - new Date(a.updated_date) },
|
||||
recent: { label: 'Recent activity', compare: (a, b) => new Date(b.updated_date).getTime() - new Date(a.updated_date).getTime() },
|
||||
name: { label: 'Name A–Z', compare: (a, b) => a.applicant_name.localeCompare(b.applicant_name) },
|
||||
experience: { label: 'Most experience', compare: (a, b) => (b.years_experience || 0) - (a.years_experience || 0) },
|
||||
};
|
||||
|
||||
@@ -31,7 +31,8 @@ const RANGES = {
|
||||
|
||||
const WEAK_TRANSITION = 60;
|
||||
|
||||
function Band({ id, title, meta, children, action }) {
|
||||
/** @param {any} props */
|
||||
function Band({ id, title, meta = '', children, action = null }) {
|
||||
return (
|
||||
<section aria-labelledby={id} className="space-y-3">
|
||||
<div className="flex flex-wrap items-baseline justify-between gap-x-3 gap-y-2">
|
||||
|
||||
@@ -166,7 +166,7 @@ export default function AdminHiredHistory() {
|
||||
if (cutoff && new Date(h.hire_date || 0).getTime() < cutoff) return false;
|
||||
return true;
|
||||
})
|
||||
.sort((a, b) => new Date(b.hire_date || 0) - new Date(a.hire_date || 0));
|
||||
.sort((a, b) => new Date(b.hire_date || 0).getTime() - new Date(a.hire_date || 0).getTime());
|
||||
}, [hires, search, filters]);
|
||||
|
||||
const summary = useMemo(() => summarise(filtered), [filtered]);
|
||||
|
||||
@@ -20,7 +20,7 @@ export default function AdminLogin() {
|
||||
|
||||
const [form, setForm] = useState({ email: DEMO_EMAIL, password: '', remember: true });
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [errors, setErrors] = useState({});
|
||||
const [errors, setErrors] = useState(/** @type {{ email?: string, password?: string }} */ ({}));
|
||||
const [authError, setAuthError] = useState('');
|
||||
const [status, setStatus] = useState('idle');
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ import {
|
||||
} from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import {
|
||||
Avatar, Badge, Button, Drawer, EmptyState, ProgressBar, SearchInput, SegmentedToggle,
|
||||
Avatar, Badge, Button, Drawer, EmptyState, SearchInput, SegmentedToggle,
|
||||
Select, SelectContent, SelectItem, SelectTrigger, SelectValue, Skeleton, StatusBadge, toast,
|
||||
} from '@/components/ds';
|
||||
import {
|
||||
@@ -59,7 +59,7 @@ const SORTS = {
|
||||
starved: { label: 'Fewest applicants', compare: (a, b) => a.stats.applied - b.stats.applied },
|
||||
updated: {
|
||||
label: 'Recently updated',
|
||||
compare: (a, b) => new Date(b.updated_date || b.created_date) - new Date(a.updated_date || a.created_date),
|
||||
compare: (a, b) => new Date(b.updated_date || b.created_date).getTime() - new Date(a.updated_date || a.created_date).getTime(),
|
||||
},
|
||||
title: { label: 'Title A–Z', compare: (a, b) => a.title.localeCompare(b.title) },
|
||||
};
|
||||
@@ -72,7 +72,8 @@ const HEALTH_TONE = {
|
||||
};
|
||||
|
||||
/** Hiring health as a small filled chip — it is a state, so it reads as one. */
|
||||
function HealthChip({ health, className }) {
|
||||
/** @param {any} props */
|
||||
function HealthChip({ health, className = '' }) {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
@@ -740,43 +741,48 @@ function PositionDrawer({ position, onClose, onViewCandidates, onOpenPosition })
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Hiring status */}
|
||||
<section>
|
||||
<SectionTitle title="Hiring status" />
|
||||
<dl className="grid grid-cols-2 gap-x-4 gap-y-3 sm:grid-cols-3">
|
||||
{[
|
||||
{ label: 'Applicants', value: s.applied },
|
||||
{ label: 'Shortlisted', value: s.shortlisted },
|
||||
{ label: 'Interviews', value: s.interviews },
|
||||
{ label: 'Hired', value: s.hired },
|
||||
{ label: 'Qualified (70+)', value: s.qualified },
|
||||
{ label: 'Avg score', value: s.avgScore || '—' },
|
||||
].map((m) => (
|
||||
<div key={m.label}>
|
||||
<dt className="text-[10px] font-medium uppercase tracking-wide text-ink-4">{m.label}</dt>
|
||||
<dd className="font-heading text-title font-bold tabular-nums text-ink-1">{m.value}</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
|
||||
<div className="mt-4">
|
||||
<div className="mb-1.5 flex items-baseline justify-between">
|
||||
<span className="text-caption text-ink-2">Screening progress</span>
|
||||
<span className="text-caption font-semibold tabular-nums text-ink-1">{s.coverage}%</span>
|
||||
</div>
|
||||
<ProgressBar
|
||||
value={s.coverage}
|
||||
tone={s.coverage === 100 ? 'success' : s.coverage >= 60 ? 'brand' : 'warning'}
|
||||
size="sm"
|
||||
/>
|
||||
<p className="mt-1 text-[11px] text-ink-4">
|
||||
{s.applied - s.unscreened} of {s.applied} applicants scored
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Hiring progression — the single source of truth for the pipeline.
|
||||
A separate "Hiring status" block used to sit above this one repeating
|
||||
Shortlisted, Interviews and Hired as figures, which is exactly what the
|
||||
progression already draws. Two readings of the same funnel invite the
|
||||
reader to check whether they agree, so the duplicated stage counts are
|
||||
gone and the three figures the progression does *not* carry — supply,
|
||||
quality and the screening average — lead it as one line. */}
|
||||
<section>
|
||||
<SectionTitle title="Hiring progression" />
|
||||
|
||||
<dl className="mb-3 flex flex-wrap items-baseline gap-x-2 gap-y-1 text-body-sm">
|
||||
<div className="flex items-baseline gap-1.5">
|
||||
<dt className="sr-only">Total applicants</dt>
|
||||
<dd className="font-semibold tabular-nums text-ink-1">{s.applied}</dd>
|
||||
<span className="text-ink-3">Applicant{s.applied === 1 ? '' : 's'}</span>
|
||||
</div>
|
||||
<span className="text-ink-4" aria-hidden="true">·</span>
|
||||
<div className="flex items-baseline gap-1.5">
|
||||
<dt className="sr-only">Qualified, scoring 70 or above</dt>
|
||||
<dd className="font-semibold tabular-nums text-ink-1">{s.qualified}</dd>
|
||||
<span className="text-ink-3">Qualified (70+)</span>
|
||||
</div>
|
||||
<span className="text-ink-4" aria-hidden="true">·</span>
|
||||
<div className="flex items-baseline gap-1.5">
|
||||
<dt className="text-ink-3">Avg Score</dt>
|
||||
<dd className="font-semibold tabular-nums text-ink-1">{s.avgScore || '—'}</dd>
|
||||
</div>
|
||||
{/* Screening coverage was the one figure in the old block that the
|
||||
progression cannot show — a stage count says how many reached
|
||||
Screened, not what share of the intake that is. */}
|
||||
{s.applied > 0 && (
|
||||
<>
|
||||
<span className="text-ink-4" aria-hidden="true">·</span>
|
||||
<div className="flex items-baseline gap-1.5">
|
||||
<dt className="sr-only">Screening coverage</dt>
|
||||
<dd className="font-semibold tabular-nums text-ink-1">{s.coverage}%</dd>
|
||||
<span className="text-ink-3">screened</span>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</dl>
|
||||
|
||||
<Progression steps={p.progression} />
|
||||
</section>
|
||||
|
||||
@@ -811,22 +817,45 @@ function PositionDrawer({ position, onClose, onViewCandidates, onOpenPosition })
|
||||
))}
|
||||
</ul>
|
||||
) : (
|
||||
<p className="text-body-sm text-ink-3">
|
||||
Nobody has been screened for this role yet, so there is no ranking to show.
|
||||
</p>
|
||||
/* Two short lines, not a paragraph. The reason nobody is ranked is
|
||||
always the same — screening has not run — so it needs stating once,
|
||||
plainly, rather than explained. */
|
||||
<div className="rounded-lg border border-dashed border-border px-3 py-4">
|
||||
<p className="text-body-sm font-medium text-ink-2">No screened candidates yet</p>
|
||||
<p className="mt-0.5 text-caption leading-relaxed text-ink-3">
|
||||
Candidates will appear here once screening is completed.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* The timeline sits beside Top candidates, so it earns a rail and nodes:
|
||||
two plain label/date lists side by side read as one table split in
|
||||
half. The dots say "this one is chronological" without a heavier
|
||||
treatment. */}
|
||||
<section>
|
||||
<SectionTitle title="Hiring timeline" />
|
||||
<ol className="space-y-2">
|
||||
<ol className="space-y-0">
|
||||
{timeline.map((e, i) => (
|
||||
<li key={i} className="flex items-baseline justify-between gap-3 text-body-sm">
|
||||
<span className="text-ink-2">{e.label}</span>
|
||||
<span className="shrink-0 text-caption text-ink-4">
|
||||
{e.date
|
||||
? new Date(e.date).toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' })
|
||||
: '—'}
|
||||
<li key={i} className="flex gap-2.5 pb-3 last:pb-0">
|
||||
<span className="flex flex-col items-center" aria-hidden="true">
|
||||
<span
|
||||
className={cn(
|
||||
'mt-1.5 h-1.5 w-1.5 shrink-0 rounded-full',
|
||||
i === timeline.length - 1 ? 'bg-krow-blue' : 'bg-ink-4'
|
||||
)}
|
||||
/>
|
||||
{i < timeline.length - 1 && <span className="mt-1 w-px flex-1 bg-border" />}
|
||||
</span>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block text-body-sm leading-snug text-ink-2">{e.label}</span>
|
||||
<span className="block text-caption text-ink-4">
|
||||
{e.date
|
||||
? new Date(e.date).toLocaleDateString(undefined, {
|
||||
month: 'short', day: 'numeric', year: 'numeric',
|
||||
})
|
||||
: '—'}
|
||||
</span>
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
@@ -841,7 +870,7 @@ function PositionDrawer({ position, onClose, onViewCandidates, onOpenPosition })
|
||||
{p.applications.length ? (
|
||||
<ol className="space-y-2">
|
||||
{[...p.applications]
|
||||
.sort((a, b) => new Date(b.updated_date) - new Date(a.updated_date))
|
||||
.sort((a, b) => new Date(b.updated_date).getTime() - new Date(a.updated_date).getTime())
|
||||
.slice(0, 5)
|
||||
.map((a) => (
|
||||
<li key={a.id} className="flex items-baseline justify-between gap-3 text-body-sm">
|
||||
@@ -1161,7 +1190,8 @@ export default function AdminPositions() {
|
||||
}
|
||||
|
||||
/** A compact labelled select pill — consistent on every Admin management page. */
|
||||
export function FilterSelect({ value, onChange, label, options, className }) {
|
||||
/** @param {any} props */
|
||||
export function FilterSelect({ value, onChange, label, options, className = '' }) {
|
||||
return (
|
||||
<Select value={value} onValueChange={onChange}>
|
||||
<SelectTrigger
|
||||
|
||||
@@ -19,6 +19,7 @@ import { cn } from '@/lib/utils';
|
||||
*/
|
||||
|
||||
/** Category to icon. Matched loosely, so a new "Head Chef" still gets the hat. */
|
||||
/** @type {[RegExp, React.ComponentType<any>][]} */
|
||||
const GLYPHS = [
|
||||
[/chef|cook|kitchen/i, ChefHat],
|
||||
[/bartend|bar|mixolog/i, Wine],
|
||||
@@ -41,7 +42,8 @@ const SIZES = {
|
||||
lg: { box: 'h-14 w-14 rounded-xl', icon: 'h-6 w-6' },
|
||||
};
|
||||
|
||||
export function RoleGlyph({ category, title, size = 'md', aiGenerated = false, className }) {
|
||||
/** @param {any} props */
|
||||
export function RoleGlyph({ category, title, size = 'md', aiGenerated = false, className = '' }) {
|
||||
const Icon = iconForRole(category, title);
|
||||
const s = SIZES[size] || SIZES.md;
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ const SORTS = {
|
||||
score: { label: 'Highest score', compare: (a, b) => (b.krow_score || 0) - (a.krow_score || 0) },
|
||||
experience: { label: 'Most experience', compare: (a, b) => (b.experience_years || 0) - (a.experience_years || 0) },
|
||||
name: { label: 'Name A–Z', compare: (a, b) => a.full_name.localeCompare(b.full_name) },
|
||||
recent: { label: 'Recently added', compare: (a, b) => new Date(b.created_date) - new Date(a.created_date) },
|
||||
recent: { label: 'Recently added', compare: (a, b) => new Date(b.created_date).getTime() - new Date(a.created_date).getTime() },
|
||||
};
|
||||
|
||||
const BANDS = {
|
||||
|
||||
@@ -1,38 +1,62 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import { Link, useNavigate } from 'react-router-dom';
|
||||
import { Link } from 'react-router-dom';
|
||||
import {
|
||||
GraduationCap, Sparkles, TrendingUp,
|
||||
LayoutTemplate, Sparkles,
|
||||
BookOpen, ArrowRight
|
||||
} from 'lucide-react';
|
||||
import { usePreferences } from '@/lib/krowHooks';
|
||||
import { allSkills } from '@/lib/skills/registry';
|
||||
import { useWorkforcePaths } from '@/lib/skills/usePageSkills';
|
||||
import { aiAgentSkills, allSkills, skillsWithFacet } from '@/lib/skills/registry';
|
||||
import { AdminPage } from '@/pages/admin/_shell';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
/**
|
||||
* Workspace & Skills — Full-width workforce capabilities & AI governance hub.
|
||||
* Workspace & Skills — AI agent capability governance.
|
||||
*
|
||||
* This page governs what *Owliver* can do. It used to govern workforce training
|
||||
* as well — Training Paths and Skill Progression sat in the right-hand column —
|
||||
* and that conflated two unrelated things under one word. A "skill" here is a
|
||||
* capability the assistant gains; a "skill" in KROW Forge is something a person
|
||||
* learns, proves and is verified in. They have different owners, different
|
||||
* lifecycles and different audiences, and putting them side by side made the page
|
||||
* read as though enabling a Bartending path taught Owliver to tend bar.
|
||||
*
|
||||
* Workforce training was not removed, only returned to where it belongs: the
|
||||
* definitions, the routes and the pages are untouched, and KROW Forge
|
||||
* (`/admin/university`) and Skill Development still read them. See
|
||||
* `workforceTrainingSkills` in the registry for the other half of the split.
|
||||
*/
|
||||
|
||||
export default function AdminWorkspace() {
|
||||
const navigate = useNavigate();
|
||||
const preferences = usePreferences();
|
||||
|
||||
/* Owliver's skills from Markdown registry */
|
||||
/**
|
||||
* AI agent skills only.
|
||||
*
|
||||
* Deliberately `aiAgentSkills(...)` rather than `allSkills(...)`. The registry
|
||||
* holds both kinds, so the unfiltered read counted five workforce training
|
||||
* paths among Owliver's capabilities — the hero said ten registered skills when
|
||||
* the assistant had five. Naming the kind is also what stops a training path
|
||||
* added later from reappearing here on its own.
|
||||
*/
|
||||
const skills = useMemo(
|
||||
() => allSkills(preferences.customSkills || []),
|
||||
() => aiAgentSkills(allSkills(preferences.customSkills || [])),
|
||||
[preferences.customSkills]
|
||||
);
|
||||
const disabledSkills = preferences.disabledSkills || [];
|
||||
const activeSkills = skills.filter((s) => !disabledSkills.includes(s.id)).length;
|
||||
|
||||
/* Workforce training paths from registry */
|
||||
const { paths } = useWorkforcePaths();
|
||||
/* The two kinds of agent skill — the same two lists the management page is
|
||||
split into, read here from the same filtered registry so the landing page and
|
||||
the manager can never disagree about what is registered. */
|
||||
const owliverSkills = useMemo(() => skillsWithFacet(skills, 'owliver'), [skills]);
|
||||
const boardSkills = useMemo(() => skillsWithFacet(skills, 'ui'), [skills]);
|
||||
const owliverCount = owliverSkills.length;
|
||||
const boardCount = boardSkills.length;
|
||||
|
||||
return (
|
||||
<AdminPage
|
||||
title="Workspace & Skills"
|
||||
subtitle="Manage workforce capabilities, Owliver skills and training."
|
||||
subtitle="Govern the AI agent capabilities that extend Owliver and KROW pages."
|
||||
>
|
||||
<div className="space-y-6 w-full">
|
||||
{/* ── 1. Hero Banner (Full Width) ───────────────────────────────── */}
|
||||
@@ -42,11 +66,12 @@ export default function AdminWorkspace() {
|
||||
<div className="space-y-2">
|
||||
<div className="inline-flex items-center gap-2 px-3 py-1 rounded-full bg-white/20 backdrop-blur-md text-white text-[12px] font-semibold border border-white/30">
|
||||
<Sparkles className="w-3.5 h-3.5 text-sky-300" />
|
||||
Workforce Intelligence Hub
|
||||
AI Capability Governance
|
||||
</div>
|
||||
<h1 className="text-2xl sm:text-3xl font-bold tracking-tight text-white">Capabilities & Skill Governance</h1>
|
||||
<h1 className="text-2xl sm:text-3xl font-bold tracking-tight text-white">AI Agent Capabilities</h1>
|
||||
<p className="text-[14px] text-blue-100 font-normal max-w-2xl">
|
||||
Govern Owliver AI agent capabilities, configure workforce training paths, and track talent progression across your organization.
|
||||
Govern what Owliver can understand, analyze and execute. Workforce training
|
||||
paths and talent progression are managed in KROW Forge.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -56,10 +81,13 @@ export default function AdminWorkspace() {
|
||||
<p className="text-2xl font-bold text-white mt-0.5">{skills.length}</p>
|
||||
<span className="text-[10px] text-emerald-300 font-medium">{activeSkills} Active</span>
|
||||
</div>
|
||||
{/* Was "Training Paths". Replaced rather than dropped: the slot is
|
||||
useful, and the split of agent skills across the two lists is
|
||||
what this page actually governs. */}
|
||||
<div className="bg-white/10 backdrop-blur-md rounded-2xl p-4 border border-white/15 min-w-[130px] text-center">
|
||||
<p className="text-[11px] uppercase tracking-wider text-blue-200 font-semibold">Training Paths</p>
|
||||
<p className="text-2xl font-bold text-sky-300 mt-0.5">{paths.length}</p>
|
||||
<span className="text-[10px] text-blue-200">Verified Ladders</span>
|
||||
<p className="text-[11px] uppercase tracking-wider text-blue-200 font-semibold">Skill Kinds</p>
|
||||
<p className="text-2xl font-bold text-sky-300 mt-0.5">{owliverCount + boardCount}</p>
|
||||
<span className="text-[10px] text-blue-200">{owliverCount} Owliver · {boardCount} Board</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -73,20 +101,22 @@ export default function AdminWorkspace() {
|
||||
<div className="flex items-center justify-between border-b border-[#E0E4EA] pb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Sparkles className="w-5 h-5 text-[#0838E0]" />
|
||||
<h2 className="text-[16px] font-bold text-[#101820]">Owliver AI Capabilities</h2>
|
||||
<h2 className="text-[16px] font-bold text-[#101820]">Owliver Skills</h2>
|
||||
</div>
|
||||
<span className="text-[12px] font-semibold text-[#0838E0] bg-blue-50 px-3 py-1 rounded-full border border-blue-200/60">
|
||||
{skills.length} Registered · {activeSkills} Active
|
||||
{owliverCount} Registered
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<p className="text-[13px] text-[#687386]">
|
||||
Owliver capabilities define what the AI assistant can execute autonomously during candidate screening, background matching, and contextual analysis.
|
||||
Owliver skills teach the assistant what it can be asked on a page and how to answer —
|
||||
screening, matching and contextual analysis. They define what the AI understands, not
|
||||
what a candidate learns.
|
||||
</p>
|
||||
|
||||
{/* Skills Sample List */}
|
||||
<div className="space-y-3">
|
||||
{skills.slice(0, 4).map((s) => {
|
||||
{owliverSkills.slice(0, 4).map((s) => {
|
||||
const isActive = !disabledSkills.includes(s.id);
|
||||
return (
|
||||
<div key={s.id} className="p-3.5 rounded-xl border border-[#E0E4EA] bg-[#F8F8F8]/50 flex items-center justify-between gap-3">
|
||||
@@ -108,86 +138,78 @@ export default function AdminWorkspace() {
|
||||
{/* CTA Button */}
|
||||
<div className="pt-2">
|
||||
<Link
|
||||
to="/admin/workspace/skills"
|
||||
to="/admin/workspace/skills?tab=owliver"
|
||||
className="w-full inline-flex items-center justify-center gap-2 px-5 py-3 rounded-xl bg-[#0838E0] hover:bg-[#062BAF] text-white text-[13px] font-semibold transition-all shadow-md shadow-blue-500/20 hover:scale-[1.01]"
|
||||
>
|
||||
<Sparkles className="w-4 h-4" />
|
||||
Manage Owliver Skills & Agent Capabilities
|
||||
Manage Owliver Skills
|
||||
<ArrowRight className="w-4 h-4" />
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Right Column: Workforce Training & Skill Development ──────── */}
|
||||
{/* ── Right Column: Board Skills ─────────────────────────────────
|
||||
The other kind of AI agent skill. This column previously held
|
||||
Workforce Training Paths and Skill Progression Tracking, both of
|
||||
which are KROW Forge concerns — see the note at the top of the file.
|
||||
Board skills belong here because, like Owliver skills, they are
|
||||
capabilities the product gains rather than something a person
|
||||
learns. */}
|
||||
<div className="space-y-6">
|
||||
{/* Workforce Training */}
|
||||
<div className="bg-white rounded-2xl border border-[#E0E4EA] p-6 shadow-xs space-y-5">
|
||||
<div className="flex items-center justify-between border-b border-[#E0E4EA] pb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<GraduationCap className="w-5 h-5 text-indigo-600" />
|
||||
<h2 className="text-[16px] font-bold text-[#101820]">Workforce Training Paths</h2>
|
||||
<LayoutTemplate className="w-5 h-5 text-[#0838E0]" />
|
||||
<h2 className="text-[16px] font-bold text-[#101820]">Board Skills</h2>
|
||||
</div>
|
||||
<span className="text-[12px] font-semibold text-indigo-600 bg-indigo-50 px-3 py-1 rounded-full border border-indigo-200/60">
|
||||
{paths.length} Active Paths
|
||||
<span className="text-[12px] font-semibold text-[#0838E0] bg-blue-50 px-3 py-1 rounded-full border border-blue-200/60">
|
||||
{boardCount} Registered
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<p className="text-[13px] text-[#687386]">
|
||||
Structured learning challenges and skill verification ladders where workforce candidates prove their real-world capabilities.
|
||||
Board skills add dynamic sections to KROW pages, read from real page data. They
|
||||
extend what a page shows, not what a candidate learns.
|
||||
</p>
|
||||
|
||||
{/* Paths Sample List */}
|
||||
{/* Sample list, from the same filtered registry read as the left
|
||||
column — so the two columns can never disagree about what is
|
||||
registered. */}
|
||||
<div className="space-y-3">
|
||||
{paths.slice(0, 3).map((p) => (
|
||||
<div key={p.id} className="p-3.5 rounded-xl border border-[#E0E4EA] bg-[#F8F8F8]/50 flex items-center justify-between gap-3">
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-[13px] font-bold text-[#101820]">{p.name || p.id}</p>
|
||||
{p.category && <p className="text-[12px] text-[#687386] capitalize mt-0.5">{p.category} Path</p>}
|
||||
{boardSkills.slice(0, 4).map((s) => {
|
||||
const isActive = !disabledSkills.includes(s.id);
|
||||
return (
|
||||
<div key={s.id} className="p-3.5 rounded-xl border border-[#E0E4EA] bg-[#F8F8F8]/50 flex items-center justify-between gap-3">
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-[13px] font-bold text-[#101820]">{s.name || s.id}</p>
|
||||
{s.description && <p className="text-[12px] text-[#687386] truncate mt-0.5">{s.description}</p>}
|
||||
</div>
|
||||
<span className={cn(
|
||||
'px-2.5 py-0.5 rounded-full text-[11px] font-semibold shrink-0',
|
||||
isActive ? 'bg-emerald-50 text-[#16A765] border border-emerald-200' : 'bg-gray-100 text-gray-500'
|
||||
)}>
|
||||
{isActive ? 'Active' : 'Disabled'}
|
||||
</span>
|
||||
</div>
|
||||
<span className="px-2.5 py-0.5 rounded-full bg-indigo-50 text-indigo-700 text-[11px] font-semibold border border-indigo-200">
|
||||
Verified Path
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* CTA Button */}
|
||||
<div className="pt-2">
|
||||
<Link
|
||||
to="/admin/university"
|
||||
className="w-full inline-flex items-center justify-center gap-2 px-5 py-3 rounded-xl border border-[#E0E4EA] bg-white hover:bg-[#F8F8F8] text-[#101820] text-[13px] font-semibold transition-all shadow-xs"
|
||||
>
|
||||
<GraduationCap className="w-4 h-4 text-indigo-600" />
|
||||
Manage Training Paths & Proving Challenges
|
||||
<ArrowRight className="w-4 h-4" />
|
||||
</Link>
|
||||
{boardSkills.length === 0 && (
|
||||
<p className="text-[13px] text-[#687386] py-2">
|
||||
No Board skills registered yet. A Board skill declares a section, where it
|
||||
sits and what it reads.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Skill Development */}
|
||||
<div className="bg-white rounded-2xl border border-[#E0E4EA] p-6 shadow-xs space-y-4">
|
||||
<div className="flex items-center justify-between border-b border-[#E0E4EA] pb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<TrendingUp className="w-5 h-5 text-[#16A765]" />
|
||||
<h2 className="text-[16px] font-bold text-[#101820]">Skill Progression Tracking</h2>
|
||||
</div>
|
||||
<span className="text-[12px] font-semibold text-[#16A765] bg-emerald-50 px-3 py-1 rounded-full border border-emerald-200/60">
|
||||
Live Analytics
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<p className="text-[13px] text-[#687386]">
|
||||
Track talent advancement across training paths, verified levels, and candidate endorsement histories.
|
||||
</p>
|
||||
|
||||
<div className="pt-2">
|
||||
<Link
|
||||
to="/admin/workspace/skill-development"
|
||||
to="/admin/workspace/skills?tab=ui"
|
||||
className="w-full inline-flex items-center justify-center gap-2 px-5 py-3 rounded-xl border border-[#E0E4EA] bg-white hover:bg-[#F8F8F8] text-[#101820] text-[13px] font-semibold transition-all shadow-xs"
|
||||
>
|
||||
<TrendingUp className="w-4 h-4 text-[#16A765]" />
|
||||
View Skill Progression & Talent Growth
|
||||
<LayoutTemplate className="w-4 h-4 text-[#0838E0]" />
|
||||
Manage Board Skills
|
||||
<ArrowRight className="w-4 h-4" />
|
||||
</Link>
|
||||
</div>
|
||||
@@ -199,9 +221,15 @@ export default function AdminWorkspace() {
|
||||
<div className="flex items-start gap-3.5 p-5 rounded-2xl bg-blue-50/60 border border-blue-200/50 text-[#0838E0] text-[13px] leading-relaxed">
|
||||
<BookOpen className="w-5 h-5 shrink-0 text-[#0838E0] mt-0.5" />
|
||||
<div>
|
||||
<p className="font-bold text-[#101820]">Unified Markdown Registry Architecture</p>
|
||||
<p className="font-bold text-[#101820]">AI skills and workforce training are different things</p>
|
||||
<p className="text-[#687386] mt-0.5">
|
||||
Skills define what Owliver can execute autonomously. Training paths define what the human workforce can be verified in. Both are defined as structured Markdown in one central registry, keeping candidate evaluation and skill progression seamlessly synchronized.
|
||||
This page governs <span className="font-semibold">AI agent skills</span> — what Owliver
|
||||
understands and what a Board skill draws on a page. <span className="font-semibold">Workforce
|
||||
training</span> — training paths, proving challenges, skill verification and talent
|
||||
progression — is what a person learns, and it is managed in{' '}
|
||||
<Link to="/admin/university" className="font-semibold underline hover:no-underline">KROW Forge</Link>.
|
||||
Both are Markdown in one registry, read through separate queries so neither appears in the
|
||||
other’s surface.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -4,13 +4,13 @@ import {
|
||||
ChevronDown, ChevronLeft, LayoutTemplate, MoreHorizontal, Pencil, Plus, Sparkles, Trash2,
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
Badge, Button, ConfirmModal, DropdownMenu, DropdownMenuContent, DropdownMenuItem,
|
||||
Alert, Badge, Button, ConfirmModal, DropdownMenu, DropdownMenuContent, DropdownMenuItem,
|
||||
DropdownMenuTrigger, EmptyState, SearchInput, Select, SelectContent, SelectItem,
|
||||
SelectTrigger, SelectValue, Surface, Switch, Tabs, toast,
|
||||
} from '@/components/ds';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { usePreferences, useUpdatePreferences } from '@/lib/krowHooks';
|
||||
import { PAGE_KEYS, allSkills, skillsWithFacet } from '@/lib/skills/registry';
|
||||
import { PAGE_KEYS, readSkillRegistry, skillsWithFacet } from '@/lib/skills/registry';
|
||||
import { removeCustomSkill, upsertCustomSkill } from '@/lib/skills/customSkills';
|
||||
import { countSections } from '@/lib/skills/uiConfig';
|
||||
import {
|
||||
@@ -211,7 +211,14 @@ export default function WorkspaceSkills() {
|
||||
*/
|
||||
const removedSkills = preferences.removedSkills || [];
|
||||
const customSkills = preferences.customSkills || [];
|
||||
const skills = useMemo(() => allSkills(customSkills), [customSkills]);
|
||||
/* The registry and everything that went wrong assembling it, from one read.
|
||||
A definition that failed to parse, lost a capability, or was overridden by
|
||||
another used to disappear without a word — the skill simply stopped
|
||||
answering and this page still looked healthy. */
|
||||
const { skills, diagnostics } = useMemo(
|
||||
() => readSkillRegistry(customSkills),
|
||||
[customSkills]
|
||||
);
|
||||
|
||||
/**
|
||||
* Whether a skill is actually offered.
|
||||
@@ -237,7 +244,6 @@ export default function WorkspaceSkills() {
|
||||
const present = useMemo(
|
||||
() => skills.filter((s) => !removedSkills.includes(s.id)),
|
||||
/* Keyed on the content: `preferences` rebuilds its arrays every read. */
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[skills, removedSkills.join('|')]
|
||||
);
|
||||
|
||||
@@ -247,7 +253,6 @@ export default function WorkspaceSkills() {
|
||||
/** The removed ones, for the drawer that lets them be put back. */
|
||||
const removed = useMemo(
|
||||
() => skills.filter((s) => removedSkills.includes(s.id)),
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[skills, removedSkills.join('|')]
|
||||
);
|
||||
|
||||
@@ -387,6 +392,29 @@ export default function WorkspaceSkills() {
|
||||
|
||||
<p className="text-caption leading-relaxed text-ink-3">{current.blurb}</p>
|
||||
|
||||
{/* What the registry could not read, or read only in part.
|
||||
A skill that half-loads used to be indistinguishable from a skill that
|
||||
works: the capabilities that resolved answered, the ones that did not
|
||||
were dropped without a word, and the definition still appeared here as
|
||||
Active. Saying so is the whole fix — the parser's leniency is correct,
|
||||
its silence was not. */}
|
||||
{diagnostics.length > 0 && (
|
||||
<Alert
|
||||
tone={diagnostics.some((d) => d.level === 'error') ? 'destructive' : 'warning'}
|
||||
title={`${diagnostics.length} skill ${diagnostics.length === 1 ? 'problem' : 'problems'} in this workspace`}
|
||||
>
|
||||
<ul className="space-y-1">
|
||||
{diagnostics.map((d, i) => (
|
||||
<li key={`${d.kind}:${d.skillId ?? d.path}:${i}`} className="text-caption leading-relaxed">
|
||||
<span className="font-mono text-[11px]">{d.skillId || d.path}</span>
|
||||
{' — '}
|
||||
{d.message}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col gap-2 lg:flex-row lg:items-center">
|
||||
<div className="w-full lg:w-72">
|
||||
<SearchInput value={search} onChange={setSearch} placeholder={`Search ${current.label.toLowerCase()}`} size="sm" />
|
||||
|
||||
@@ -17,6 +17,7 @@ import { cn } from '@/lib/utils';
|
||||
* Controls live in the page rather than in the global chrome, which is why the
|
||||
* Admin header only needs one 56px bar.
|
||||
*/
|
||||
/** @param {any} props */
|
||||
export function AdminPage({ title, subtitle, meta, actions, tabs, children, className }) {
|
||||
return (
|
||||
<div className={cn('space-y-6', className)}>
|
||||
@@ -50,6 +51,7 @@ export function AdminPage({ title, subtitle, meta, actions, tabs, children, clas
|
||||
* Sentence-case and small rather than a heavy all-caps overline: on a page with
|
||||
* four or five sections, loud labels compete with the data they introduce.
|
||||
*/
|
||||
/** @param {any} props */
|
||||
export function SectionTitle({ id, title, meta, action, className }) {
|
||||
return (
|
||||
<div className={cn('flex flex-wrap items-baseline justify-between gap-2', className)}>
|
||||
@@ -80,7 +82,8 @@ export function SectionTitle({ id, title, meta, action, className }) {
|
||||
* primary action is pushed to the end, so every management page has the same
|
||||
* control geometry.
|
||||
*/
|
||||
export function Toolbar({ search, filters, actions, meta, className }) {
|
||||
/** @param {any} props */
|
||||
export function Toolbar({ search = null, filters = null, actions = null, meta = null, className = '' }) {
|
||||
return (
|
||||
<div className={cn('space-y-2', className)}>
|
||||
<div className="flex flex-col gap-2 lg:flex-row lg:items-center">
|
||||
|
||||
Reference in New Issue
Block a user