update ui design for all pages
This commit is contained in:
@@ -1,233 +1,239 @@
|
||||
import React, { useState, useMemo, useRef } from 'react';
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useLocation, useNavigate } from 'react-router-dom';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
import { useCourses, useWorkerProfile, useEvidenceList } from '@/lib/krowHooks';
|
||||
import { isUnlocked } from '@/lib/provingGround';
|
||||
import { useAssistantPanel } from '@/components/ai-assistant';
|
||||
import { useCourses, useCreateCourse, useUpdateCourse, useWorkerProfiles } from '@/lib/krowHooks';
|
||||
import {
|
||||
buildRequirements, ChallengeDirectory, ForgeHeader, ForgeInsight, ForgeProgress, NextSkill,
|
||||
YourForge,
|
||||
AddSkillTraining, ForgeHeader, ForgeProgress, ForgeRail, SkillDetail, SkillLibrary,
|
||||
statusOf, workforceUsage,
|
||||
} from '@/components/forge';
|
||||
|
||||
/**
|
||||
* KROW Forge — the skill verification workspace.
|
||||
* KROW Forge — workforce skill verification, from the managing side.
|
||||
*
|
||||
* The page is a progression rather than a dashboard, and it reads in one
|
||||
* direction:
|
||||
* 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
|
||||
* progress the four figures, and the journey they describe
|
||||
* your Forge what is verified — or the process that verifies the first one
|
||||
* next skill the one skill to prove now: what it tests, how proof is
|
||||
* submitted, who grades it, what is blocking it ← the focus
|
||||
* directory every other challenge, searchable
|
||||
* insight what the record means for the work they can get
|
||||
* 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
|
||||
*
|
||||
* Owliver is the evaluator throughout — named on the proof step here, and
|
||||
* running the challenge itself on the challenge page. Nothing on this page
|
||||
* duplicates that component.
|
||||
* 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.
|
||||
*
|
||||
* Every figure, state and requirement is derived here from existing data and the
|
||||
* existing unlock gate; the components only arrange what they are given.
|
||||
* 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`.
|
||||
*/
|
||||
|
||||
const DIFFS = [
|
||||
{ v: 'all', label: 'All' },
|
||||
{ v: 'beginner', label: 'Beginner' },
|
||||
{ v: 'intermediate', label: 'Intermediate' },
|
||||
{ v: 'advanced', label: 'Advanced' },
|
||||
];
|
||||
|
||||
/** Badges carry a full date; the record only needs the month. */
|
||||
const earnedOn = (value) => {
|
||||
if (!value) return 'Jul 2026';
|
||||
const d = new Date(value);
|
||||
return Number.isNaN(d.getTime())
|
||||
? value
|
||||
: d.toLocaleDateString(undefined, { month: 'short', year: 'numeric' });
|
||||
};
|
||||
/** Publication states, in the order an administrator thinks about them. */
|
||||
const STATUS_ORDER = ['Published', 'Draft', 'Archived'];
|
||||
|
||||
export default function University() {
|
||||
const { data: profile } = useWorkerProfile();
|
||||
const { data: courses = [], isLoading: loadingCourses } = useCourses();
|
||||
const { data: evidence = [] } = useEvidenceList(profile?.email);
|
||||
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 [diffFilter, setDiffFilter] = useState('all');
|
||||
const challengesRef = useRef(null);
|
||||
const nextSkillRef = useRef(null);
|
||||
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);
|
||||
|
||||
const completedIds = useMemo(
|
||||
() => new Set((profile?.completed_courses || []).map((c) => c.course_id)),
|
||||
[profile]
|
||||
);
|
||||
const activeCourses = useMemo(
|
||||
() => courses.filter((c) => c.status === 'active'),
|
||||
[courses]
|
||||
);
|
||||
|
||||
const verifiedSkills = useMemo(() => {
|
||||
return profile?.earned_badges || [];
|
||||
}, [profile]);
|
||||
|
||||
const recommendedCourses = useMemo(() => {
|
||||
return activeCourses
|
||||
.filter((c) => !completedIds.has(c.id))
|
||||
.slice(0, 3);
|
||||
}, [activeCourses, completedIds]);
|
||||
|
||||
const scrollToChallenges = () => {
|
||||
challengesRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||
};
|
||||
|
||||
const scrollToNext = () => {
|
||||
nextSkillRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||
};
|
||||
|
||||
const verifiedCount = verifiedSkills.length;
|
||||
const inProgressCount = (profile?.completed_courses || []).filter(c => c.status === 'in_progress').length || 2;
|
||||
const nextUnlockCount = recommendedCourses.length || 4;
|
||||
const firstActiveCourse = recommendedCourses[0] || activeCourses[0];
|
||||
|
||||
/* XP still on the table — what the unfinished challenges are collectively worth. */
|
||||
const xpAvailable = useMemo(
|
||||
() => activeCourses.filter((c) => !completedIds.has(c.id)).reduce((sum, c) => sum + (c.xp || 0), 0),
|
||||
[activeCourses, completedIds]
|
||||
);
|
||||
|
||||
const lockedCourses = useMemo(
|
||||
() => activeCourses.filter((c) => !isUnlocked(c, profile).unlocked && !completedIds.has(c.id)),
|
||||
[activeCourses, profile, completedIds]
|
||||
);
|
||||
|
||||
/* The featured skill, its gate, and the action that matches its state — the
|
||||
same gate and the same route the directory rows use. */
|
||||
const lead = firstActiveCourse;
|
||||
const leadLocked = lead ? !isUnlocked(lead, profile).unlocked : false;
|
||||
const leadInProgress = (profile?.completed_courses || []).some(
|
||||
(c) => c.course_id === lead?.id && c.status === 'in_progress'
|
||||
);
|
||||
const leadRequirements = useMemo(
|
||||
() => (lead ? buildRequirements(lead, profile || {}) : []),
|
||||
[lead, profile]
|
||||
);
|
||||
const leadAction = leadLocked
|
||||
? 'View details'
|
||||
: leadInProgress
|
||||
? 'Continue challenge'
|
||||
: 'Start challenge';
|
||||
|
||||
/* The directory holds everything the featured skill does not. */
|
||||
const otherCourses = useMemo(
|
||||
() => activeCourses.filter((c) => c.id !== lead?.id),
|
||||
[activeCourses, lead]
|
||||
);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
return otherCourses.filter((c) => {
|
||||
const ms = !search || c.title?.toLowerCase().includes(search.toLowerCase()) || c.category?.toLowerCase().includes(search.toLowerCase()) || c.proof_skill?.toLowerCase().includes(search.toLowerCase());
|
||||
const md = diffFilter === 'all' || c.difficulty === diffFilter;
|
||||
return ms && md;
|
||||
/**
|
||||
* 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,
|
||||
});
|
||||
}, [otherCourses, search, diffFilter]);
|
||||
setCreating(true);
|
||||
navigate(location.pathname, { replace: true, state: null });
|
||||
}, [location.state, location.pathname, navigate]);
|
||||
|
||||
const isFiltered = Boolean(search) || diffFilter !== 'all';
|
||||
const clearFilters = () => { setSearch(''); setDiffFilter('all'); };
|
||||
/* 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]);
|
||||
|
||||
if (!profile && loadingCourses) {
|
||||
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 justify-center items-center py-20 min-h-[400px]">
|
||||
<Loader2 className="w-7 h-7 animate-spin text-[#0A39DF]" />
|
||||
<div className="flex min-h-[400px] items-center justify-center py-20">
|
||||
<Loader2 className="h-7 w-7 animate-spin text-krow-blue" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const figures = [
|
||||
{ label: 'Verified skills', value: verifiedCount, tone: verifiedCount ? 'text-success' : 'text-ink-1' },
|
||||
{ label: 'In progress', value: inProgressCount },
|
||||
{ label: 'Next unlock', value: nextUnlockCount, tone: 'text-krow-blue' },
|
||||
{ label: 'XP available', value: xpAvailable },
|
||||
];
|
||||
|
||||
/* The journey, read off the same counts as the figures above it. */
|
||||
const stages = [
|
||||
{
|
||||
label: 'Started',
|
||||
caption: `${activeCourses.length} challenges open`,
|
||||
state: 'done',
|
||||
},
|
||||
{
|
||||
label: 'Proving',
|
||||
caption: `${inProgressCount} active`,
|
||||
state: inProgressCount > 0 ? 'current' : 'upcoming',
|
||||
},
|
||||
{
|
||||
label: 'Next skill',
|
||||
caption: lead?.title,
|
||||
state: verifiedCount > 0 ? 'done' : inProgressCount > 0 ? 'upcoming' : 'current',
|
||||
},
|
||||
{
|
||||
label: 'Verified',
|
||||
caption: verifiedCount > 0
|
||||
? `${verifiedCount} verified`
|
||||
: `${lockedCourses.length} still locked`,
|
||||
state: verifiedCount > 0 ? 'done' : lockedCourses.length > 0 ? 'locked' : 'upcoming',
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
/* No max-width here: the Admin shell already supplies the gutters
|
||||
(px-4 lg:px-6 on a flex-1 main), which is how every other Admin page is
|
||||
sized. This div only owns vertical rhythm. */
|
||||
/* 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">
|
||||
{/* 1 ── What this is. */}
|
||||
<ForgeHeader course={firstActiveCourse} actionLabel="Continue challenge" />
|
||||
<ForgeHeader onCreate={openCreate} onBrowse={scrollToLibrary} />
|
||||
|
||||
{/* 2 ── Where the worker stands, and where it leads. */}
|
||||
<ForgeProgress figures={figures} stages={stages} loading={loadingCourses} />
|
||||
<ForgeProgress figures={figures} loading={isLoading} />
|
||||
|
||||
{/* 3 ── The record, or the process that creates it. */}
|
||||
<YourForge
|
||||
skills={verifiedSkills}
|
||||
evidenceCount={evidence.length}
|
||||
onBrowse={scrollToChallenges}
|
||||
formatDate={earnedOn}
|
||||
/>
|
||||
|
||||
{/* 4 ── The skill to prove now. The page's focus. */}
|
||||
<div ref={nextSkillRef}>
|
||||
<NextSkill
|
||||
course={lead}
|
||||
locked={leadLocked}
|
||||
requirements={leadRequirements}
|
||||
actionLabel={leadAction}
|
||||
index={verifiedCount + 1}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 5 ── Everything else. */}
|
||||
<div ref={challengesRef}>
|
||||
<ChallengeDirectory
|
||||
courses={otherCourses}
|
||||
results={filtered}
|
||||
{/* 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}
|
||||
difficulty={diffFilter}
|
||||
onDifficulty={setDiffFilter}
|
||||
difficulties={DIFFS}
|
||||
loading={loadingCourses}
|
||||
category={category}
|
||||
onCategory={setCategory}
|
||||
categories={categories}
|
||||
status={status}
|
||||
onStatus={setStatus}
|
||||
statuses={statuses}
|
||||
loading={isLoading}
|
||||
filtered={isFiltered}
|
||||
onClear={clearFilters}
|
||||
completedIds={completedIds}
|
||||
profile={profile}
|
||||
onOpen={setSelected}
|
||||
onCreate={openCreate}
|
||||
/>
|
||||
|
||||
<ForgeRail
|
||||
recent={recent}
|
||||
onCreate={openCreate}
|
||||
onOpen={setSelected}
|
||||
onAskOwliver={panel.open}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 6 ── The remark. */}
|
||||
<ForgeInsight
|
||||
headline="Your strongest verified area is Service & Operations."
|
||||
highlight="Team Leadership"
|
||||
detail="Completing this challenge could strengthen your workforce profile for senior event roles."
|
||||
actionLabel="Explore recommended skill"
|
||||
onAction={scrollToNext}
|
||||
<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>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user