313 lines
13 KiB
JavaScript
313 lines
13 KiB
JavaScript
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 (
|
|
<div className="flex min-h-[400px] items-center justify-center py-20">
|
|
<Loader2 className="h-7 w-7 animate-spin text-krow-blue" />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
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. */
|
|
<div className="space-y-7 pb-12">
|
|
<ForgeHeader onCreate={openCreate} onBrowse={scrollToLibrary} />
|
|
|
|
<SkillSurface page="krow-forge" placement="after-header" />
|
|
|
|
<ForgeProgress figures={figures} loading={isLoading} />
|
|
|
|
{/* 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. */}
|
|
<section className="space-y-4">
|
|
<SectionTitle
|
|
title="Skill progression"
|
|
meta={person ? `${person.full_name} · ${states.filter((s) => s.verifiedLevel).length} of ${states.length} skills verified` : undefined}
|
|
/>
|
|
|
|
{profiles.length > 1 && (
|
|
<div className="flex flex-wrap items-center gap-2">
|
|
<span className="text-caption text-ink-4">Progress for</span>
|
|
<FilterSelect
|
|
value={person?.id || ''}
|
|
onChange={setPersonId}
|
|
label="Worker"
|
|
options={profiles.map((p) => ({ value: p.id, label: p.full_name }))}
|
|
className="min-w-[11rem]"
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
|
|
{states.map((state) => (
|
|
<SkillProgressCard key={state.skillId} state={state} onOpen={setOpenSkill} />
|
|
))}
|
|
</div>
|
|
</section>
|
|
|
|
{/* 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}
|
|
category={category}
|
|
onCategory={setCategory}
|
|
categories={categories}
|
|
status={status}
|
|
onStatus={setStatus}
|
|
statuses={statuses}
|
|
loading={isLoading}
|
|
filtered={isFiltered}
|
|
onClear={clearFilters}
|
|
onOpen={setSelected}
|
|
onCreate={openCreate}
|
|
moduleStateFor={(course) =>
|
|
(completed.has(course.id) ? 'completed'
|
|
: states.find((s) => s.skillId === course.skill_id)?.nextRequirement?.id === course.id ? 'available'
|
|
: course.skill_id ? 'locked' : undefined)
|
|
}
|
|
/>
|
|
|
|
<ForgeRail
|
|
recent={recent}
|
|
onCreate={openCreate}
|
|
onOpen={setSelected}
|
|
onAskOwliver={panel.open}
|
|
/>
|
|
</div>
|
|
|
|
{/* 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. */}
|
|
<SkillProgressDetail
|
|
state={openSkill}
|
|
open={Boolean(openSkill)}
|
|
onOpenChange={(next) => !next && setOpenSkill(null)}
|
|
onStartTraining={(module) =>
|
|
navigate(`/admin/university/${module.id}${person ? `?as=${person.id}` : ''}`)
|
|
}
|
|
/>
|
|
|
|
<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 })}
|
|
/>
|
|
|
|
<SkillSurface page="krow-forge" placement="before-footer" />
|
|
</div>
|
|
);
|
|
}
|