update position page design
This commit is contained in:
@@ -5,8 +5,11 @@ import { useAssistantPanel } from '@/components/ai-assistant';
|
||||
import { useCourses, useCreateCourse, useUpdateCourse, useWorkerProfiles } from '@/lib/krowHooks';
|
||||
import {
|
||||
AddSkillTraining, ForgeHeader, ForgeProgress, ForgeRail, SkillDetail, SkillLibrary,
|
||||
statusOf, workforceUsage,
|
||||
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';
|
||||
|
||||
/**
|
||||
* KROW Forge — workforce skill verification, from the managing side.
|
||||
@@ -50,6 +53,8 @@ export default function University() {
|
||||
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);
|
||||
|
||||
/**
|
||||
@@ -103,46 +108,62 @@ export default function University() {
|
||||
}, [courses, search, category, status]);
|
||||
|
||||
/**
|
||||
* The four figures.
|
||||
* Whose progression this page is showing.
|
||||
*
|
||||
* 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.
|
||||
* 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 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
|
||||
);
|
||||
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: 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',
|
||||
},
|
||||
{ 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' },
|
||||
];
|
||||
}, [courses, profiles]);
|
||||
}, [states, courses]);
|
||||
|
||||
/* The rail's recent list is the library's own newest records. */
|
||||
const recent = useMemo(() => courses.slice(0, 4), [courses]);
|
||||
@@ -185,6 +206,35 @@ export default function University() {
|
||||
|
||||
<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,
|
||||
@@ -207,6 +257,11 @@ export default function University() {
|
||||
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
|
||||
@@ -217,6 +272,18 @@ export default function University() {
|
||||
/>
|
||||
</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)}
|
||||
|
||||
Reference in New Issue
Block a user