Files
krow_talent_app/src/pages/University.jsx

241 lines
8.7 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,
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 (
<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} />
<ForgeProgress figures={figures} loading={isLoading} />
{/* 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}
/>
<ForgeRail
recent={recent}
onCreate={openCreate}
onOpen={setSelected}
onAskOwliver={panel.open}
/>
</div>
<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>
);
}