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, statusOf, workforceUsage, } from '@/components/forge'; /** * 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 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 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 (