186 lines
7.6 KiB
JavaScript
186 lines
7.6 KiB
JavaScript
import React from 'react';
|
|
import { Link } from 'react-router-dom';
|
|
import { ArrowRight, Check, Sparkles } from 'lucide-react';
|
|
import { cn } from '@/lib/utils';
|
|
import { Badge, Drawer } from '@/components/ds';
|
|
import {
|
|
DIFF_TONE, owliverCriteria, statusOf, STATUS_TONE, trainingKinds, trainingOutline,
|
|
TYPE_HINT, TYPE_LABEL, typeOf,
|
|
} from './challengeMeta';
|
|
|
|
/**
|
|
* One skill, read end to end: what it teaches, how it is proven, what Owliver
|
|
* checks, what passing means, and how the workforce has done with it.
|
|
*
|
|
* A drawer rather than a route, because this is the managing view of a record
|
|
* whose route already belongs to something else — `/admin/university/:id` runs
|
|
* the challenge itself, which is the worker's experience and stays untouched.
|
|
* The link out to it is offered at the bottom for an admin who wants to see what
|
|
* the workforce sees.
|
|
*
|
|
* Sections appear only when the record has something to put in them. Reading a
|
|
* heading with nothing under it teaches the reader to distrust the rest.
|
|
*/
|
|
export function SkillDetail({ course, open, onOpenChange, usage }) {
|
|
if (!course) return null;
|
|
|
|
const status = statusOf(course);
|
|
const type = typeOf(course);
|
|
const criteria = owliverCriteria(course);
|
|
const outline = trainingOutline(course);
|
|
const kinds = trainingKinds(course);
|
|
const rubric = course.challenge?.rubric || [];
|
|
|
|
return (
|
|
<Drawer
|
|
open={open}
|
|
onOpenChange={onOpenChange}
|
|
size="lg"
|
|
title={course.title}
|
|
description={`${course.category}${course.difficulty ? ` · ${course.difficulty}` : ''}`}
|
|
>
|
|
<div className="space-y-6 px-5 py-5">
|
|
<div className="flex flex-wrap items-center gap-2">
|
|
<Badge variant={STATUS_TONE[status] || 'neutral'} size="sm">{status}</Badge>
|
|
{course.difficulty && (
|
|
<span className={cn('text-caption font-semibold capitalize', DIFF_TONE[course.difficulty] || 'text-ink-3')}>
|
|
{course.difficulty}
|
|
</span>
|
|
)}
|
|
{course.estimated_minutes > 0 && (
|
|
<span className="text-caption text-ink-4">{course.estimated_minutes} min</span>
|
|
)}
|
|
{course.proof_skill && (
|
|
<span className="text-caption text-ink-4">· Verifies {course.proof_skill}</span>
|
|
)}
|
|
</div>
|
|
|
|
{course.description && (
|
|
<p className="text-body-sm leading-relaxed text-ink-2">{course.description}</p>
|
|
)}
|
|
|
|
<Block title="Training" meta="What the workforce learns">
|
|
{outline.length > 0 ? (
|
|
<ol className="space-y-2">
|
|
{outline.map((step, i) => (
|
|
<li key={step} className="flex gap-3 text-body-sm text-ink-2">
|
|
<span className="mt-0.5 grid h-5 w-5 shrink-0 place-items-center rounded-full bg-krow-blue-tint text-[10px] font-bold tabular-nums text-krow-blue">
|
|
{i + 1}
|
|
</span>
|
|
<span className="min-w-0">{step}</span>
|
|
</li>
|
|
))}
|
|
</ol>
|
|
) : (
|
|
<p className="text-body-sm leading-relaxed text-ink-3">
|
|
This skill carries no written outline. It teaches through
|
|
{' '}
|
|
{kinds.length ? kinds.join(' and ').toLowerCase() : 'the challenge itself'}.
|
|
</p>
|
|
)}
|
|
|
|
{course.quiz?.length > 0 && (
|
|
<p className="mt-3 text-caption text-ink-4">
|
|
Includes a {course.quiz.length}-question knowledge check.
|
|
</p>
|
|
)}
|
|
</Block>
|
|
|
|
{course.challenge && (
|
|
<Block title="Proof" meta="How the workforce demonstrates the skill">
|
|
<p className="text-body-sm font-semibold text-ink-1">{TYPE_LABEL[type]}</p>
|
|
<p className="mt-1 text-body-sm leading-relaxed text-ink-3">{TYPE_HINT[type]}</p>
|
|
{course.challenge.prompt && (
|
|
<p className="mt-3 rounded-lg border border-border bg-surface-subtle p-3 text-body-sm italic leading-relaxed text-ink-2">
|
|
“{course.challenge.prompt}”
|
|
</p>
|
|
)}
|
|
</Block>
|
|
)}
|
|
|
|
{criteria.length > 0 && (
|
|
<Block
|
|
title="Owliver evaluation"
|
|
meta="What Owliver checks"
|
|
icon={Sparkles}
|
|
>
|
|
<ul className="space-y-2">
|
|
{rubric.map((r) => (
|
|
<li key={r.criterion} className="flex items-baseline justify-between gap-3 text-body-sm">
|
|
<span className="flex min-w-0 items-baseline gap-2 text-ink-2">
|
|
<Check className="h-3.5 w-3.5 shrink-0 translate-y-0.5 text-krow-blue" aria-hidden="true" />
|
|
<span className="min-w-0">{r.criterion}</span>
|
|
</span>
|
|
{r.weight != null && (
|
|
<span className="shrink-0 text-caption tabular-nums text-ink-4">{r.weight}%</span>
|
|
)}
|
|
</li>
|
|
))}
|
|
</ul>
|
|
</Block>
|
|
)}
|
|
|
|
{course.pass_score > 0 && (
|
|
<Block title="Verification" meta="What qualifies as passing">
|
|
<p className="text-body-sm leading-relaxed text-ink-2">
|
|
A submission verifies the skill at{' '}
|
|
<span className="font-semibold text-ink-1">{course.pass_score} or above</span>
|
|
{course.badge_reward
|
|
? <>, and earns the <span className="font-semibold text-ink-1">{course.badge_reward}</span> badge.</>
|
|
: '.'}
|
|
</p>
|
|
</Block>
|
|
)}
|
|
|
|
{/* Usage is counted from worker profiles. Assignment and "in progress"
|
|
have no field behind them, so the two states the records do carry are
|
|
the two states shown. */}
|
|
{usage && (usage.completed > 0 || usage.verified > 0) && (
|
|
<Block title="Workforce usage" meta="From worker profiles">
|
|
<dl className="grid grid-cols-2 gap-3">
|
|
{[
|
|
{ label: 'Completed', value: usage.completed },
|
|
{ label: 'Verified', value: usage.verified },
|
|
].map((row) => (
|
|
<div key={row.label} className="rounded-lg border border-border bg-surface-subtle px-3 py-2.5">
|
|
<dt className="text-[10px] font-bold uppercase tracking-wider text-ink-4">{row.label}</dt>
|
|
<dd className="mt-1 font-heading text-title font-bold tabular-nums text-ink-1">{row.value}</dd>
|
|
</div>
|
|
))}
|
|
</dl>
|
|
</Block>
|
|
)}
|
|
|
|
<Link
|
|
to={`/admin/university/${course.id}`}
|
|
className="group inline-flex items-center gap-1.5 rounded text-body-sm font-semibold text-krow-blue hover:underline
|
|
focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-krow-blue/50"
|
|
>
|
|
Open the challenge as the workforce sees it
|
|
<ArrowRight
|
|
className="h-3.5 w-3.5 transition-transform duration-200 group-hover:translate-x-0.5"
|
|
aria-hidden="true"
|
|
/>
|
|
</Link>
|
|
</div>
|
|
</Drawer>
|
|
);
|
|
}
|
|
|
|
/** A titled region of the record. */
|
|
/** @param {any} props */
|
|
function Block({ title, meta, icon: Icon, children }) {
|
|
return (
|
|
<section className="border-t border-border pt-4 first:border-0 first:pt-0">
|
|
<div className="mb-2.5 flex items-baseline gap-2">
|
|
<h3 className="inline-flex items-center gap-1.5 font-heading text-overline uppercase tracking-wider text-ink-4">
|
|
{Icon && <Icon className="h-3 w-3 text-krow-blue" aria-hidden="true" />}
|
|
{title}
|
|
</h3>
|
|
{meta && <span className="min-w-0 truncate text-caption text-ink-4">{meta}</span>}
|
|
</div>
|
|
{children}
|
|
</section>
|
|
);
|
|
}
|