import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useLocation, useNavigate } from 'react-router-dom'; import { Loader2 } from 'lucide-react'; import { useAssistantPanel } from '@/components/ai-assistant'; import { useCourses, useCreateCourse, useUpdateCourse, useWorkerProfiles } from '@/lib/krowHooks'; import { AddSkillTraining, ForgeHeader, ForgeProgress, ForgeRail, SkillDetail, SkillLibrary, SkillProgressCard, SkillProgressDetail, statusOf, workforceUsage, } from '@/components/forge'; import { SectionTitle } from '@/pages/admin/_shell'; import { FilterSelect } from '@/pages/admin/Positions'; import { completedCourseIds, skillStates } from '@/lib/skillGraph'; import { SkillSurface } from '@/components/skills/SkillSurface'; /** * KROW Forge — workforce skill verification, from the managing side. * * 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, 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 * * 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. * * 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`. */ /** Publication states, in the order an administrator thinks about them. */ const STATUS_ORDER = ['Published', 'Draft', 'Archived']; export default function University() { 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 [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 [openSkill, setOpenSkill] = useState(null); const [personId, setPersonId] = useState(''); const libraryRef = useRef(null); /** * 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, }); setCreating(true); navigate(location.pathname, { replace: true, state: null }); }, [location.state, location.pathname, navigate]); /* 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]); const statuses = useMemo(() => { const found = new Set(courses.map(statusOf)); return [ { value: 'all', label: 'All' }, ...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]); /** * Whose progression this page is showing. * * Forge is one page read by two people. An administrator asks what the ladders * require and how the workforce is moving up them; a worker asks where they * are and what is next. Both questions are answered by the same ladder against * one person, so the page picks a person rather than splitting in two. * * The default is the worker closest to their next level — the one whose * progression is worth looking at, rather than whoever sorts first. */ const person = useMemo(() => { if (personId) return profiles.find((p) => p.id === personId) || null; const ranked = profiles .map((p) => { const rows = skillStates(courses, p); return { p, /* Closest to a level: skills one module short, then how far up the next rung they already are. */ nearly: rows.filter((s) => s.nearlyThere).length, progress: rows.reduce((n, s) => n + (s.nextRequirement ? s.progressToNext : 0), 0), done: rows.reduce((n, s) => n + s.totalCompleted, 0), }; }) .filter((row) => row.done > 0) .sort((a, b) => (b.nearly - a.nearly) || (b.progress - a.progress) || (b.done - a.done)); return ranked[0]?.p || profiles[0] || null; }, [personId, profiles, courses]); const states = useMemo(() => (person ? skillStates(courses, person) : []), [courses, person]); /** What this person has already completed — drives every module's state. */ const completed = useMemo(() => completedCourseIds(person), [person]); /** * The five figures. * * Progression, not inventory: how many skills exist to climb, how many this * person is moving on, how many they hold, how many are one module from the * next level, and how many modules are queued up next. Each is a count of * records or derived states — nothing here is a target or an estimate. */ const figures = useMemo(() => { const verified = states.filter((s) => s.verifiedLevel).length; const inProgress = states.filter((s) => s.status === 'in_progress' || s.status === 'advancing').length; const ready = states.filter((s) => s.nearlyThere).length; const recommended = states.filter((s) => s.nextRequirement).length; return [ { label: 'Active skills', value: states.length, sub: `${courses.filter((c) => statusOf(c) === 'Published').length} published modules` }, { label: 'Skills in progress', value: inProgress, sub: inProgress ? 'training under way' : 'nothing started' }, { label: 'Skills verified', value: verified, sub: verified ? 'levels backed by evidence' : 'none earned yet' }, { label: 'Next level ready', value: ready, sub: ready ? 'one module from the next level' : 'none within one module' }, { label: 'Recommended training', value: recommended, sub: recommended ? 'modules queued next' : 'nothing outstanding' }, ]; }, [states, courses]); /* 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 (
); } return ( /* 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. */
{/* Skill progression — the primary experience. The library below is the authoring surface; this is the part that answers "where am I and what is next", which is the question both audiences arrive with. */}
s.verifiedLevel).length} of ${states.length} skills verified` : undefined} /> {profiles.length > 1 && (
Progress for ({ value: p.id, label: p.full_name }))} className="min-w-[11rem]" />
)}
{states.map((state) => ( ))}
{/* 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. */}
(completed.has(course.id) ? 'completed' : states.find((s) => s.skillId === course.skill_id)?.nextRequirement?.id === course.id ? 'available' : course.skill_id ? 'locked' : undefined) } />
{/* Starting a module hands off to the challenge page that already exists, carrying who it is for — so the evidence, Owliver's verdict and the level it updates all land on the right record. */} !next && setOpenSkill(null)} onStartTraining={(module) => navigate(`/admin/university/${module.id}${person ? `?as=${person.id}` : ''}`) } /> !next && setSelected(null)} usage={usage} /> 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 })} />
); }