update position page design

This commit is contained in:
2026-08-13 21:41:14 +05:30
parent 6340962907
commit 1c653264b5
124 changed files with 5746 additions and 596 deletions

View File

@@ -8,7 +8,9 @@ import { cn } from '@/lib/utils';
import {
Avatar, Badge, Button, EmptyState, ProgressBar, StatusBadge, toast,
} from '@/components/ds';
import { useApplications, useInterviews, useJobPostings } from '@/lib/krowHooks';
import { useApplications, useCourses, useInterviews, useJobPostings, useWorkerProfiles } from '@/lib/krowHooks';
import { VerifiedSkills } from '@/components/krow/VerifiedSkills';
import { usePageSkills } from '@/lib/skills/usePageSkills';
import {
ALL_DIMENSIONS, SCORE_GROUPS, activityTimeline, careerScore, careerTier,
communicationLabel, endorsements, marketValue, nextPromotion, recommendationVerdict,
@@ -685,8 +687,12 @@ function Endorsements({ candidate }) {
}
/** Experience: history, availability, credentials and the applied position. */
function Experience({ candidate, job, interview }) {
function Experience({ candidate, job, interview, workerProfiles = [], courses = [], positions = [] }) {
const c = candidate;
/* What Forge has attached to the Candidates page. Nothing here names a skill —
a path authored tomorrow with `pages: candidates` appears without this
component changing. */
const pageSkills = usePageSkills('candidates');
return (
<div className="space-y-8">
@@ -738,6 +744,27 @@ function Experience({ candidate, job, interview }) {
)}
</section>
{/* Verified capability first, self-reported skills after it. Both belong
on the page — but one is backed by evidence Owliver graded, and the
order should say which.
Which skills appear is the Forge definitions' decision: only paths
attached to `candidates` are surfaced here, and when none are, the
heading goes with them rather than sitting over an empty panel. */}
{pageSkills.skills.length > 0 && (
<section>
<SectionLabel meta={`${pageSkills.skills.length} attached`}>Verified skills</SectionLabel>
<VerifiedSkills
pageId="candidates"
email={c.email}
profiles={workerProfiles}
courses={courses}
positions={positions}
customSources={pageSkills.customSources}
/>
</section>
)}
<SkillsAndCerts candidate={c} />
<section>
@@ -835,6 +862,11 @@ export default function CandidateProfile() {
const { data: applications = [], isLoading } = useApplications();
const { data: postings = [] } = useJobPostings();
const { data: interviews = [] } = useInterviews();
/* The workforce and the training library, for the verified-skill panel: a
candidate's real capability lives on the worker profile behind the
application, not on the application itself. */
const { data: workerProfiles = [] } = useWorkerProfiles();
const { data: courses = [] } = useCourses();
const candidate = useMemo(() => applications.find((a) => a.id === id), [applications, id]);
const job = useMemo(
@@ -946,7 +978,14 @@ export default function CandidateProfile() {
{tab === 'reputation' && <Reputation candidate={candidate} />}
{tab === 'experience' && (
<Experience candidate={candidate} job={job} interview={interview} />
<Experience
candidate={candidate}
job={job}
interview={interview}
workerProfiles={workerProfiles}
courses={courses}
positions={postings}
/>
)}
{tab === 'endorsements' && <Endorsements candidate={candidate} />}
{tab === 'activity' && <Activity candidate={candidate} interview={interview} />}

View File

@@ -1,9 +1,14 @@
import React, { useMemo, useState } from 'react';
import { Button, SearchInput } from '@/components/ds';
import { useApplications, useInterviews, useJobPostings } from '@/lib/krowHooks';
import { useApplications, useInterviews, useJobPostings, useUpdateApplication, useHireCandidate } from '@/lib/krowHooks';
import { base44 } from '@/api/base44Client';
import { toast } from 'react-hot-toast';
import { AdminPage, Toolbar } from '@/pages/admin/_shell';
import { FilterSelect } from '@/pages/admin/Positions';
import CandidateCard from '@/components/krow/CandidateCard';
import AIInterviewModal from '@/components/krow/AIInterviewModal';
import ScheduleInterviewModal from '@/components/krow/ScheduleInterviewModal';
import MessageCandidateModal from '@/components/krow/MessageCandidateModal';
const SCORE_BANDS = {
all: () => true,
@@ -24,6 +29,8 @@ export default function AdminCandidates() {
const { data: applications = [], isLoading } = useApplications();
const { data: postings = [] } = useJobPostings();
const { data: interviews = [] } = useInterviews();
const updateApp = useUpdateApplication();
const hireCandidate = useHireCandidate();
const [search, setSearch] = useState('');
const [position, setPosition] = useState('all');
@@ -31,6 +38,46 @@ export default function AdminCandidates() {
const [band, setBand] = useState('all');
const [sort, setSort] = useState('score');
const [interviewApp, setInterviewApp] = useState(null);
const [scheduleApp, setScheduleApp] = useState(null);
const [messageApp, setMessageApp] = useState(null);
const handleDecline = async (app) => {
try {
await updateApp.mutateAsync({ id: app.id, data: { status: 'rejected' } });
toast.success(`${app.applicant_name} declined`);
} catch (e) {
toast.error('Failed to decline candidate');
}
};
const handleDelete = async (app) => {
try {
await base44.entities.JobApplication.delete(app.id);
toast.success(`${app.applicant_name} deleted`);
} catch (e) {
toast.error('Failed to delete candidate');
}
};
const handleHire = async (app) => {
try {
const job = postings.find(p => p.id === app.job_posting_id);
await hireCandidate.mutateAsync({ application: app, job });
toast.success(`${app.applicant_name} hired!`);
} catch (e) {
toast.error('Failed to hire candidate');
}
};
const handleAction = (app) => {
if (app.status === 'interview') {
setInterviewApp(app);
} else {
setScheduleApp(app);
}
};
const positions = useMemo(
() => [...new Set(applications.map((a) => a.job_title).filter(Boolean))].sort(),
[applications]
@@ -106,11 +153,28 @@ export default function AdminCandidates() {
application={app}
jobTitle={resolveTitle(app)}
rank={idx + 1}
onAction={handleAction}
onMessage={setMessageApp}
onCall={setMessageApp}
onSchedule={setScheduleApp}
onDecline={handleDecline}
onDelete={handleDelete}
onHire={handleHire}
/>
))}
</div>
)}
{/* Modals */}
{interviewApp && (
<AIInterviewModal open={true} onClose={() => setInterviewApp(null)} application={interviewApp} job={postings.find(p => p.id === interviewApp.job_posting_id)} />
)}
{scheduleApp && (
<ScheduleInterviewModal open={true} onClose={() => setScheduleApp(null)} application={scheduleApp} />
)}
{messageApp && (
<MessageCandidateModal open={true} onClose={() => setMessageApp(null)} application={messageApp} />
)}
</AdminPage>
);
}

View File

@@ -63,7 +63,7 @@ export default function AdminLogin() {
{/* Left Visual Section — Clean White & KROW Yellow Theme */}
<div className="relative hidden md:flex flex-col justify-between p-8 sm:p-10 bg-[#FAFAFA] overflow-hidden border-r border-[#E2E8F0]">
{/* Subtle Decorative Accents */}
<div className="absolute -top-20 -left-20 w-64 h-64 rounded-full bg-[#0A39DF]/5 blur-3xl pointer-events-none" />
<div className="absolute -top-20 -left-20 w-64 h-64 rounded-full bg-[#0838E0]/5 blur-3xl pointer-events-none" />
<div className="absolute -bottom-20 -right-20 w-80 h-80 rounded-full bg-[#F9E547]/10 blur-3xl pointer-events-none" />
{/* Top Brand & Badge */}
@@ -89,8 +89,8 @@ export default function AdminLogin() {
{/* Bottom Hero Tagline */}
<div className="relative z-10 space-y-2">
<div className="flex items-center gap-1.5 text-xs font-bold text-[#0A39DF] uppercase tracking-wider">
<Sparkles className="w-3.5 h-3.5 text-[#0A39DF]" />
<div className="flex items-center gap-1.5 text-xs font-bold text-[#0838E0] uppercase tracking-wider">
<Sparkles className="w-3.5 h-3.5 text-[#0838E0]" />
Recruiting Intelligence Platform
</div>
<h2 className="text-xl font-bold text-[#0F172A] tracking-tight leading-snug">

View File

@@ -1,14 +1,17 @@
import React, { useMemo, useState } from 'react';
import { useNavigate, useSearchParams } from 'react-router-dom';
import {
ArrowRight, CheckCircle2, MapPin, MessageSquare, Sparkles, Star, Users, Wallet,
ArrowRight, CheckCircle2, LayoutGrid, List, MapPin, MessageSquare, Sparkles, Star, Users, Wallet,
} from 'lucide-react';
import { cn } from '@/lib/utils';
import {
Avatar, Badge, Button, Drawer, EmptyState, ProgressBar, SearchInput,
Avatar, Badge, Button, Drawer, EmptyState, ProgressBar, SearchInput, SegmentedToggle,
Select, SelectContent, SelectItem, SelectTrigger, SelectValue, Skeleton, StatusBadge, toast,
} from '@/components/ds';
import { useApplications, useJobPostings } from '@/lib/krowHooks';
import {
useApplications, useAssignments, useCourses, useJobPostings, useStaff, useWorkerProfiles,
} from '@/lib/krowHooks';
import { workforceStatusFor } from '@/lib/workforce';
import { AdminPage, SectionTitle } from '@/pages/admin/_shell';
import { RoleGlyph } from '@/pages/admin/RoleGlyph';
import { buildPosition, payLabel } from '@/pages/admin/positionInsights';
@@ -179,6 +182,223 @@ function Progression({ steps, className }) {
);
}
/**
* The workforce line — one compact row under the hiring pipeline.
*
* Deliberately a single line of text rather than a panel. The pipeline above it
* is what this card has always been about; workforce demand is a second, quieter
* fact, and giving it equal weight turned the card into a dashboard.
*
* Every clause appears only if the data behind it exists. A position that
* declares no headcount has no coverage to report, and one with no strong
* matches simply omits that clause instead of announcing an absence. If nothing
* is known, the row does not render at all.
*/
function WorkforceRow({ workforce }) {
if (!workforce) return null;
const { demand, strong, newToday } = workforce;
const parts = [
demand.declared && (
<span key="demand" className="tabular-nums">
<span className="font-semibold text-ink-1">{demand.required}</span> required
<span className="text-ink-4"> · </span>
<span className="font-semibold text-ink-1">{demand.assigned}</span> assigned
<span className="text-ink-4"> · </span>
<span className={cn('font-semibold', demand.remaining ? 'text-warning' : 'text-success')}>
{demand.remaining}
</span> remaining
</span>
),
strong.length > 0 && (
<span key="strong" className="tabular-nums">
<span className="font-semibold text-ink-1">{strong.length}</span> strong
{strong.length === 1 ? ' match' : ' matches'}
</span>
),
newToday.length > 0 && (
<span key="new" className="tabular-nums font-semibold text-krow-blue">
{newToday.length} new today
</span>
),
].filter(Boolean);
if (!parts.length) return null;
return (
<div className="mt-2.5 flex flex-wrap items-center gap-x-2 gap-y-0.5 rounded-lg bg-surface-subtle px-2.5 py-1.5 text-caption text-ink-3">
<Users className="h-3 w-3 shrink-0 text-ink-4" aria-hidden="true" />
{parts.map((part, i) => (
<React.Fragment key={part.key}>
{i > 0 && <span className="text-ink-4/60">|</span>}
{part}
</React.Fragment>
))}
</div>
);
}
/**
* The chosen presentation, remembered.
*
* localStorage rather than the account preferences the rest of the app uses:
* this is how one person likes to look at one page on one machine, not a
* setting worth a write to the user record.
*/
const VIEW_KEY = 'krow:positions:view';
function useViewMode() {
const [view, setView] = useState(() => {
try {
return localStorage.getItem(VIEW_KEY) === 'list' ? 'list' : 'grid';
} catch {
return 'grid';
}
});
return [view, (next) => {
setView(next);
try {
localStorage.setItem(VIEW_KEY, next);
} catch {
/* Preference is not persisted; the page still works. */
}
}];
}
/**
* The same positions, as a table.
*
* Takes the rows the page already built, filtered and sorted — the same objects
* the cards render, carrying both the pipeline and the workforce reading. It
* derives nothing, so a number here cannot differ from the same number there.
*
* Columns follow the data: the workforce columns appear only if some position on
* screen actually declares a headcount, rather than filling a column with dashes.
*/
function PositionsTable({ rows, onOpen }) {
const showDemand = rows.some((p) => p.workforce.demand.declared);
const columns = [
{ key: 'position', label: 'Position' },
{ key: 'applied', label: 'Applied', align: 'right' },
{ key: 'screened', label: 'Screened', align: 'right' },
{ key: 'shortlisted', label: 'Shortlist', align: 'right' },
{ key: 'interviews', label: 'Interview', align: 'right' },
{ key: 'hired', label: 'Hired', align: 'right' },
showDemand && { key: 'required', label: 'Required', align: 'right' },
showDemand && { key: 'assigned', label: 'Assigned', align: 'right' },
showDemand && { key: 'remaining', label: 'Remaining', align: 'right' },
{ key: 'strong', label: 'Strong', align: 'right' },
{ key: 'health', label: 'Status' },
{ key: 'action', label: '' },
].filter(Boolean);
return (
<div className="overflow-hidden rounded-xl border border-border bg-surface shadow-xs">
<table className="hidden w-full border-collapse text-body-sm lg:table">
<caption className="sr-only">Open positions, their hiring pipeline and workforce demand</caption>
<thead>
<tr className="border-b border-border bg-surface-subtle">
{columns.map((c) => (
<th
key={c.key}
scope="col"
className={cn(
'whitespace-nowrap px-3 py-2.5 text-[10px] font-bold uppercase tracking-wide text-ink-4',
c.align === 'right' ? 'text-right' : 'text-left'
)}
>
{c.label}
</th>
))}
</tr>
</thead>
<tbody>
{rows.map((p) => {
const { demand, strong } = p.workforce;
return (
<tr
key={p.id}
onClick={() => onOpen(p)}
className="cursor-pointer border-b border-border last:border-0 transition-colors hover:bg-surface-subtle"
>
<td className="px-3 py-2.5">
<p className="font-medium text-ink-1">{p.title}</p>
<p className="text-caption text-ink-4">
{p.role_category}
{p.location ? ` · ${p.location}` : ''}
</p>
</td>
<td className="px-3 py-2.5 text-right font-semibold tabular-nums text-ink-1">{p.stats.applied}</td>
<td className="px-3 py-2.5 text-right tabular-nums text-ink-2">{p.stats.screened}</td>
<td className="px-3 py-2.5 text-right tabular-nums text-ink-2">{p.stats.shortlisted}</td>
<td className="px-3 py-2.5 text-right tabular-nums text-ink-2">{p.stats.interviews}</td>
<td className="px-3 py-2.5 text-right tabular-nums text-ink-2">{p.stats.hired}</td>
{showDemand && (
<>
<td className="px-3 py-2.5 text-right tabular-nums text-ink-2">
{demand.declared ? demand.required : <span className="text-ink-4">—</span>}
</td>
<td className="px-3 py-2.5 text-right tabular-nums text-ink-2">
{demand.declared ? demand.assigned : <span className="text-ink-4">—</span>}
</td>
<td className={cn('px-3 py-2.5 text-right font-semibold tabular-nums', demand.declared && demand.remaining ? 'text-warning' : 'text-ink-2')}>
{demand.declared ? demand.remaining : <span className="font-normal text-ink-4">—</span>}
</td>
</>
)}
<td className="px-3 py-2.5 text-right tabular-nums text-ink-2">{strong.length}</td>
<td className="px-3 py-2.5"><HealthChip health={p.health} /></td>
<td className="px-3 py-2.5 text-right">
<span className="inline-flex items-center gap-1 text-caption font-semibold text-krow-blue">
View <ArrowRight className="h-3 w-3" aria-hidden="true" />
</span>
</td>
</tr>
);
})}
</tbody>
</table>
{/* Below `lg` the same rows stack: twelve columns cannot be read on a
phone, and a table that scrolls the page sideways is worse than one
that changes shape. */}
<ul className="divide-y divide-border lg:hidden">
{rows.map((p) => (
<li key={p.id}>
<button
type="button"
onClick={() => onOpen(p)}
className="flex w-full flex-col gap-2 px-4 py-3 text-left transition-colors hover:bg-surface-subtle
focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-krow-blue/50"
>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<p className="truncate font-medium text-ink-1">{p.title}</p>
<p className="truncate text-caption text-ink-4">
{p.role_category}
{p.location ? ` · ${p.location}` : ''}
</p>
</div>
<HealthChip health={p.health} />
</div>
<p className="flex flex-wrap items-center gap-x-2 text-caption tabular-nums text-ink-3">
<span><span className="font-semibold text-ink-1">{p.stats.applied}</span> applied</span>
<span className="text-ink-4">·</span>
<span><span className="font-semibold text-ink-1">{p.stats.interviews}</span> interview</span>
<span className="text-ink-4">·</span>
<span><span className="font-semibold text-ink-1">{p.stats.hired}</span> hired</span>
</p>
<p className="text-caption text-ink-2">{p.insight}</p>
</button>
</li>
))}
</ul>
</div>
);
}
/** One position. The whole card is the target, so there is no hunting for a link. */
function PositionCard({ position, onOpen }) {
const p = position;
@@ -232,6 +452,8 @@ function PositionCard({ position, onOpen }) {
<Progression steps={p.progression} />
<WorkforceRow workforce={p.workforce} />
{/* Health, and the one thing worth knowing. `mb-3.5` rather than a margin on
the footer, so the footer is free to take `mt-auto`. */}
<div className="mb-3.5 mt-3.5 flex items-start gap-2">
@@ -445,6 +667,12 @@ export default function AdminPositions() {
const [searchParams] = useSearchParams();
const { data: postings = [], isLoading } = useJobPostings();
const { data: applications = [] } = useApplications();
/* The workforce side of the record. Read alongside the pipeline, never in
place of it — the two describe different things about the same position. */
const { data: assignments = [] } = useAssignments();
const { data: profiles = [] } = useWorkerProfiles();
const { data: courses = [] } = useCourses();
const { data: staff = [] } = useStaff();
const [search, setSearch] = useState(searchParams.get('search') || '');
const [status, setStatus] = useState(searchParams.get('status') || 'all');
@@ -452,12 +680,29 @@ export default function AdminPositions() {
const [location, setLocation] = useState('all');
const [sort, setSort] = useState('attention');
const [selected, setSelected] = useState(null);
const [view, setView] = useViewMode();
/**
* Derived once per position rather than per cell, so the grid does not run the
* same filter five times per card.
*
* `buildPosition` owns the hiring pipeline — applied, screened, shortlisted,
* interviewed, hired — exactly as it always has. `workforce` is attached
* beside it, not merged into it: a position can have four applicants moving
* through screening and no strong workforce match at the same time, and both
* of those are true. Collapsing them would make one of the two a lie.
*/
const workforceContext = useMemo(
() => ({ profiles, applications, assignments, courses, staff }),
[profiles, applications, assignments, courses, staff]
);
/* Derived once per position rather than per cell, so the grid does not run the
same filter five times per card. */
const rows = useMemo(
() => postings.map((p) => buildPosition(p, applications)),
[postings, applications]
() => postings.map((p) => ({
...buildPosition(p, applications),
workforce: workforceStatusFor(p, workforceContext),
})),
[postings, applications, workforceContext]
);
const departments = useMemo(
@@ -494,12 +739,33 @@ export default function AdminPositions() {
const open = rows.filter((p) => p.status === 'active').length;
const sum = (key) => rows.reduce((total, p) => total + p.stats[key], 0);
const applied = sum('applied');
return [
/* The pipeline sentence the page has always shown, unchanged. */
const parts = [
`${open} open position${open === 1 ? '' : 's'}`,
`${applied} active candidate${applied === 1 ? '' : 's'}`,
`${sum('shortlisted')} shortlisted`,
`${sum('hired')} hired`,
].join(' · ');
];
/* Workforce demand joins it only where positions actually declare one.
Summing a schema default across every row would produce a total nobody
could reconcile against the cards. */
const declared = rows.filter((p) => p.status === 'active' && p.workforce.demand.declared);
if (declared.length) {
const required = declared.reduce((n, p) => n + p.workforce.demand.required, 0);
const assignedTotal = declared.reduce((n, p) => n + p.workforce.demand.assigned, 0);
parts.push(
`${required} required`,
`${assignedTotal} assigned`,
`${Math.max(0, required - assignedTotal)} remaining`
);
}
const newToday = rows.reduce((n, p) => n + p.workforce.newToday.length, 0);
if (newToday) parts.push(`${newToday} new today`);
return parts.join(' · ');
}, [rows]);
return (
@@ -527,6 +793,21 @@ export default function AdminPositions() {
<FilterSelect value={sort} onChange={setSort} label="Sort"
options={Object.entries(SORTS).map(([value, s]) => ({ value, label: s.label }))} />
</div>
{/* Presentation, at the end of the same toolbar. It changes how the rows
are drawn, never which rows or what they say. */}
<div className="lg:ml-auto">
<SegmentedToggle
variant="surface"
ariaLabel="View mode"
value={view}
onChange={setView}
options={[
{ value: 'grid', icon: LayoutGrid, ariaLabel: 'Grid view' },
{ value: 'list', icon: List, ariaLabel: 'List view' },
]}
/>
</div>
</div>
<p className="text-caption text-ink-4">
@@ -541,11 +822,16 @@ export default function AdminPositions() {
))}
</div>
) : filtered.length ? (
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-3">
{filtered.map((p) => (
<PositionCard key={p.id} position={p} onOpen={setSelected} />
))}
</div>
/* One filtered array, two drawings of it. */
view === 'grid' ? (
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-3">
{filtered.map((p) => (
<PositionCard key={p.id} position={p} onOpen={setSelected} />
))}
</div>
) : (
<PositionsTable rows={filtered} onOpen={setSelected} />
)
) : (
<EmptyState
title={isFiltered ? 'No positions match these filters' : 'No positions yet'}

View File

@@ -1,4 +1,5 @@
import React, { useMemo, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import {
Building2, Check, KeyRound, LogOut, Mail, Pencil, Shield, ShieldCheck, Smartphone,
} from 'lucide-react';
@@ -8,8 +9,12 @@ import {
import { base44 } from '@/api/base44Client';
import { useAssistantPanel } from '@/components/ai-assistant';
import {
useCurrentUser, usePreferences, useUpdatePreferences, useUpdateProfile, useUserActivity,
useCourses, useCurrentUser, usePreferences, useUpdatePreferences,
useUpdateProfile, useUserActivity, useWorkerProfiles,
} from '@/lib/krowHooks';
import { usePageSkills } from '@/lib/skills/usePageSkills';
import { profileForEmail } from '@/lib/skillGraph';
import { SkillProgressCard, SkillProgressDetail } from '@/components/forge';
import { AdminPage, SectionTitle } from '@/pages/admin/_shell';
import { endAdminSession } from '@/pages/admin/session';
import { LEVEL_TONE, PERMISSIONS } from '@/pages/admin/permissions';
@@ -26,6 +31,7 @@ import { ASSISTANT_CONTEXTS } from '@/components/ai-assistant/contexts';
*/
export default function AdminProfile() {
const navigate = useNavigate();
const { data: user } = useCurrentUser();
const updateProfile = useUpdateProfile();
const preferences = usePreferences();
@@ -93,6 +99,28 @@ export default function AdminProfile() {
}
};
/**
* Skill development — the workforce paths Forge has attached to this page.
*
* The worker record is looked up by email rather than fetched with
* `useWorkerProfile()`, which creates one on demand: an account opening its
* own settings should not be written into the workforce as a side effect. With
* no record the ladders still render, at "Not started" — which is the true
* state of someone who has completed no training, not a placeholder.
*/
const { data: courses = [] } = useCourses();
const { data: workerProfiles = [] } = useWorkerProfiles();
const profileSkills = usePageSkills('profile');
const myWorkerProfile = useMemo(
() => profileForEmail(workerProfiles, user?.email),
[workerProfiles, user?.email]
);
const skillStates = useMemo(
() => profileSkills.statesFor(courses, myWorkerProfile),
[profileSkills, courses, myWorkerProfile]
);
const [openSkill, setOpenSkill] = useState(null);
/* Owliver skills, read from the Markdown registry. Settings shows what is
registered and which pages it reaches; the file is the source of truth, so
a skill added to disk appears here without a change to this page. */
@@ -205,6 +233,35 @@ export default function AdminProfile() {
)}
</Surface>
</section>
{/* Skill development.
Rendered only when Forge has attached a workforce path to this
page, and not rendered at all otherwise — an empty skills panel
teaches the reader the feature is broken. Nothing here names a
skill: the list is whatever carries `pages: profile`. */}
{skillStates.length > 0 && (
<section aria-labelledby="development" className="space-y-3">
<SectionTitle
id="development"
title="Skill development"
meta={`${skillStates.length} ${skillStates.length === 1 ? 'path' : 'paths'}`}
/>
<p className="-mt-1 text-caption text-ink-3">
Where you stand on each path, and the training that moves you to the next level.
Levels are earned by completing the modules and passing Owliver&apos;s verification —
they are never set by hand.
</p>
<div className="grid gap-4 [grid-template-columns:repeat(auto-fill,minmax(min(100%,16rem),1fr))]">
{skillStates.map(({ definition, state }) => (
<SkillProgressCard
key={definition.id}
state={state}
onOpen={() => setOpenSkill({ definition, state })}
/>
))}
</div>
</section>
)}
{/* Owliver skills */}
<section aria-labelledby="skills" className="space-y-3">
<SectionTitle
@@ -235,9 +292,21 @@ export default function AdminProfile() {
</Badge>
</div>
<p className="mt-0.5 text-caption text-ink-3">{skill.description}</p>
{/* What the attachment does, not just what it says. A
workforce path names the capability it governs and the
rungs it defines, because on this list those are the
things that change what the other pages render. */}
{skill.kind === 'workforce' && (
<p className="mt-1 text-[11px] text-ink-4">
Workforce path · <span className="text-ink-3">{skill.skillId}</span>
{skill.levels.length > 0 && (
<> · {skill.levels.map((l) => l.label).join(' → ')}</>
)}
</p>
)}
{pages.length > 0 && (
<p className="mt-1.5 text-[11px] text-ink-4">
Pages: <span className="text-ink-3">{pages.join(', ')}</span>
Surfaces on: <span className="text-ink-3">{pages.join(', ')}</span>
</p>
)}
{skill.capabilities.length > 0 && (
@@ -377,6 +446,19 @@ export default function AdminProfile() {
<AddSkillDialog open={addSkillOpen} onOpenChange={setAddSkillOpen} />
{/* The whole ladder for one path. The same drawer Forge opens, so the
employee reads their progression in the surface that authored it
rather than in a second copy of it. */}
<SkillProgressDetail
state={openSkill?.state}
open={Boolean(openSkill)}
onOpenChange={(next) => !next && setOpenSkill(null)}
onStartTraining={(module) => {
setOpenSkill(null);
navigate(`/admin/university/${module.id}`);
}}
/>
<Modal
open={editOpen}
onOpenChange={setEditOpen}