position ui and owliver update
This commit is contained in:
16
src/App.jsx
16
src/App.jsx
@@ -40,6 +40,11 @@ import AdminTalentPool from '@/pages/admin/TalentPool';
|
||||
import AdminAnalytics from '@/pages/admin/Analytics';
|
||||
import AdminActivity from '@/pages/admin/Activity';
|
||||
import AdminProfile from '@/pages/admin/Profile';
|
||||
import AdminSettings from '@/pages/admin/Settings';
|
||||
import AdminWorkspace from '@/pages/admin/Workspace';
|
||||
import AdminWorkspaceSkills from '@/pages/admin/WorkspaceSkills';
|
||||
import AdminSkillEditor from '@/pages/admin/SkillEditor';
|
||||
import AdminSkillDevelopment from '@/pages/admin/SkillDevelopment';
|
||||
import AdminLogin from '@/pages/admin/Login';
|
||||
import AdminRoute from '@/pages/admin/AdminRoute';
|
||||
|
||||
@@ -97,6 +102,17 @@ const AuthenticatedApp = () => {
|
||||
<Route path="analytics" element={<AdminAnalytics />} />
|
||||
<Route path="activity" element={<AdminActivity />} />
|
||||
<Route path="profile" element={<AdminProfile />} />
|
||||
{/* Two centres, deliberately separate. Settings configures the
|
||||
account and the system; Workspace & Skills configures the
|
||||
workforce — Owliver's capabilities, training and progression —
|
||||
each with its own pages underneath rather than one page holding
|
||||
both subjects. */}
|
||||
<Route path="settings" element={<AdminSettings />} />
|
||||
<Route path="workspace" element={<AdminWorkspace />} />
|
||||
<Route path="workspace/skills" element={<AdminWorkspaceSkills />} />
|
||||
<Route path="workspace/skills/new" element={<AdminSkillEditor />} />
|
||||
<Route path="workspace/skills/:id" element={<AdminSkillEditor />} />
|
||||
<Route path="workspace/skill-development" element={<AdminSkillDevelopment />} />
|
||||
</Route>
|
||||
</Route>
|
||||
|
||||
|
||||
@@ -35,6 +35,10 @@ const CERTIFICATIONS = [
|
||||
const JOB_POSTINGS = [
|
||||
{
|
||||
id: 'job_senior_server',
|
||||
company: 'Fairmont San Jose',
|
||||
custom_requirements: 'Fine dining or banquet service at senior level — recover a table before the client notices there was anything to recover.',
|
||||
leadership_expectations: 'Set the pace for the servers on your floor',
|
||||
attendance_expectations: 'Available evenings and weekends',
|
||||
title: 'Senior Server – Fine Dining',
|
||||
role_category: 'Server',
|
||||
description:
|
||||
@@ -75,6 +79,9 @@ const JOB_POSTINGS = [
|
||||
},
|
||||
{
|
||||
id: 'job_bartender',
|
||||
company: 'Bay Event Staffing',
|
||||
custom_requirements: 'Own your bar from setup to breakdown at high-volume private events.',
|
||||
physical_requirements: 'Comfortable standing for a full shift',
|
||||
title: 'Bartender',
|
||||
role_category: 'Bartender',
|
||||
skill_requirements: [
|
||||
@@ -109,6 +116,9 @@ const JOB_POSTINGS = [
|
||||
},
|
||||
{
|
||||
id: 'job_server_fine',
|
||||
company: 'Valley Catering Co.',
|
||||
custom_requirements: 'Tray service and synchronized coursing experience for seated events.',
|
||||
attendance_expectations: 'Available evenings and weekends',
|
||||
skill_requirements: [
|
||||
{ skill_id: 'server', level: 'advanced', weight: 45 },
|
||||
{ skill_id: 'customer_service', level: 'intermediate', weight: 30 },
|
||||
@@ -144,6 +154,9 @@ const JOB_POSTINGS = [
|
||||
},
|
||||
{
|
||||
id: 'job_security',
|
||||
company: 'Legendary Event Staff',
|
||||
custom_requirements: 'Valid California Guard Card on file before the first shift.',
|
||||
physical_requirements: 'Able to stand and patrol for extended periods',
|
||||
title: 'Event Security Officer',
|
||||
role_category: 'Security',
|
||||
description:
|
||||
@@ -174,6 +187,9 @@ const JOB_POSTINGS = [
|
||||
},
|
||||
{
|
||||
id: 'job_chef',
|
||||
company: 'Valley Catering Co.',
|
||||
custom_requirements: 'Offsite and high-volume catering experience, with food cost ownership.',
|
||||
leadership_expectations: 'Lead and schedule a rotating kitchen brigade',
|
||||
skill_requirements: [
|
||||
{ skill_id: 'food_safety', level: 'advanced', weight: 40 },
|
||||
{ skill_id: 'leadership', level: 'advanced', weight: 35 },
|
||||
@@ -209,6 +225,9 @@ const JOB_POSTINGS = [
|
||||
},
|
||||
{
|
||||
id: 'job_bartender_corp',
|
||||
company: 'Bay Event Staffing',
|
||||
custom_requirements: 'Corporate or executive event experience, with batched cocktail programs.',
|
||||
leadership_expectations: 'Run a bar independently at corporate functions',
|
||||
skill_requirements: [
|
||||
{ skill_id: 'bartending', level: 'advanced', weight: 50 },
|
||||
{ skill_id: 'customer_service', level: 'intermediate', weight: 30 },
|
||||
@@ -244,6 +263,10 @@ const JOB_POSTINGS = [
|
||||
},
|
||||
{
|
||||
id: 'job_picker',
|
||||
company: 'Oakland Event Logistics',
|
||||
custom_requirements: 'Accuracy first — every mispick becomes a missing item at an event.',
|
||||
physical_requirements: 'Able to lift 50 lbs repeatedly',
|
||||
attendance_expectations: 'Reliable attendance for early shifts',
|
||||
title: 'Picker',
|
||||
role_category: 'Picker',
|
||||
description:
|
||||
@@ -274,6 +297,10 @@ const JOB_POSTINGS = [
|
||||
},
|
||||
{
|
||||
id: 'job_banquet',
|
||||
company: 'Fairmont San Jose',
|
||||
custom_requirements: 'Hotel banquet background and BEO fluency.',
|
||||
leadership_expectations: 'Direct a floor team you have often just met that afternoon',
|
||||
attendance_expectations: 'Available for early mornings and late nights',
|
||||
skill_requirements: [
|
||||
{ skill_id: 'server', level: 'advanced', weight: 35 },
|
||||
{ skill_id: 'leadership', level: 'intermediate', weight: 35 },
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
*/
|
||||
|
||||
const STORAGE_KEY = 'krow_demo_db';
|
||||
const STORAGE_VERSION = 6;
|
||||
const STORAGE_VERSION = 8;
|
||||
|
||||
/** Simulated network latency, in ms, so loading states are real. */
|
||||
const LATENCY = { read: 140, write: 220 };
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import * as React from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { ArrowDownRight, ArrowUpRight, Check, ChevronRight, TriangleAlert } from 'lucide-react';
|
||||
import {
|
||||
ArrowDownRight, ArrowRight, ArrowUpRight, Check, ChevronRight, TriangleAlert,
|
||||
} from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { ProgressBar, toneForScore } from '@/components/ds/Progress';
|
||||
@@ -280,11 +282,17 @@ ListBlock.displayName = 'ListBlock';
|
||||
* position they were working on. This is the default for a
|
||||
* recommended person: inspecting a candidate is part of the
|
||||
* conversation, not a trip to another page.
|
||||
* `to` leaves for a route the app already has. Reserved for the explicit
|
||||
* "open the full record" case.
|
||||
* `to` leaves for a route the app already has, with the whole card as the
|
||||
* target. Reserved for items whose only meaning is "open this".
|
||||
* `action` a named link inside the card — `{ label, to }`. For an item that
|
||||
* has *both* a conversation and a record: the card keeps asking
|
||||
* Owliver, and the link is the one explicit way to leave. Reading a
|
||||
* card can then never navigate by accident, which matters most where
|
||||
* the body is an explanation worth reading.
|
||||
*
|
||||
* `hint` is for an item that can do neither — it says why, instead of looking
|
||||
* clickable and doing nothing.
|
||||
* clickable and doing nothing. An item may carry a hint *and* an action: the
|
||||
* hint explains the record, the action opens it.
|
||||
*/
|
||||
const InsightsBlock = React.memo(({ block, onPrompt }) => (
|
||||
<div className="space-y-2">
|
||||
@@ -298,7 +306,8 @@ const InsightsBlock = React.memo(({ block, onPrompt }) => (
|
||||
'transition-colors hover:border-krow-blue/50',
|
||||
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-krow-blue/50'
|
||||
);
|
||||
const actionable = Boolean(item.prompt && onPrompt) || Boolean(item.to);
|
||||
const asksOwliver = Boolean(item.prompt && onPrompt);
|
||||
const actionable = asksOwliver || Boolean(item.to);
|
||||
|
||||
const body = (
|
||||
<>
|
||||
@@ -315,7 +324,37 @@ const InsightsBlock = React.memo(({ block, onPrompt }) => (
|
||||
</>
|
||||
);
|
||||
|
||||
if (item.prompt && onPrompt) {
|
||||
/* A named link cannot live inside a button, so when an item has both, the
|
||||
card is a plain container: the readable area is its own button and the
|
||||
action sits beside it. One click target each, never nested. */
|
||||
if (item.action?.to) {
|
||||
return (
|
||||
<div key={i} className={surface}>
|
||||
{asksOwliver ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onPrompt(item.prompt)}
|
||||
className="block w-full rounded-lg text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-krow-blue/50"
|
||||
>
|
||||
{body}
|
||||
</button>
|
||||
) : body}
|
||||
|
||||
<div className="mt-2 flex justify-end">
|
||||
<Link
|
||||
to={item.action.to}
|
||||
className="inline-flex items-center gap-0.5 rounded text-caption font-semibold text-krow-blue transition-colors hover:underline
|
||||
focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-krow-blue/50"
|
||||
>
|
||||
{item.action.label}
|
||||
<ArrowRight className="h-3 w-3" aria-hidden="true" />
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (asksOwliver) {
|
||||
return (
|
||||
<button key={i} type="button" onClick={() => onPrompt(item.prompt)} className={interactive}>
|
||||
{body}
|
||||
|
||||
@@ -2,11 +2,22 @@ import * as React from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from '@/components/ui/sheet';
|
||||
|
||||
/**
|
||||
* Panel widths.
|
||||
*
|
||||
* Each entry states its `max-w` as well as its `w`. The underlying Sheet caps a
|
||||
* side panel at `sm:max-w-sm` (24rem), so a width alone was silently ignored on
|
||||
* desktop and every "large" drawer in the app was drawn at phone width — the
|
||||
* reason detail panels wrapped and scrolled far more than their content needed.
|
||||
*/
|
||||
const WIDTHS = {
|
||||
sm: 'w-full sm:w-80',
|
||||
default: 'w-full sm:w-[28rem]',
|
||||
lg: 'w-full sm:w-[36rem]',
|
||||
xl: 'w-full sm:w-[44rem]',
|
||||
sm: 'w-full sm:w-80 sm:max-w-[20rem]',
|
||||
default: 'w-full sm:w-[28rem] sm:max-w-[28rem]',
|
||||
lg: 'w-full sm:w-[36rem] sm:max-w-[36rem]',
|
||||
/* A record with a two-column body: 704px on desktop, 768px from `2xl`
|
||||
(1536px and up), and never the whole screen — the page behind it stays
|
||||
visible, which is what keeps the drawer a drawer. */
|
||||
xl: 'w-full sm:w-[44rem] sm:max-w-[44rem] 2xl:w-[48rem] 2xl:max-w-[48rem]',
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -43,7 +54,7 @@ export function Drawer({
|
||||
)}
|
||||
>
|
||||
{!bare && (
|
||||
<SheetHeader className="px-5 pt-5 pb-4 border-b border-border shrink-0 space-y-0 text-left">
|
||||
<SheetHeader className="px-5 sm:px-6 pt-5 pb-4 border-b border-border shrink-0 space-y-0 text-left">
|
||||
<div className="flex items-start gap-3">
|
||||
{Icon && (
|
||||
<span className="grid place-items-center w-9 h-9 rounded-xl bg-krow-blue-tint text-krow-blue shrink-0">
|
||||
@@ -62,10 +73,10 @@ export function Drawer({
|
||||
</SheetHeader>
|
||||
)}
|
||||
|
||||
<div className="flex-1 overflow-y-auto px-5 py-5">{children}</div>
|
||||
<div className="flex-1 overflow-y-auto px-5 py-5 sm:px-6">{children}</div>
|
||||
|
||||
{footer && (
|
||||
<div className="px-5 py-4 border-t border-border bg-surface-subtle shrink-0 flex items-center justify-end gap-2">
|
||||
<div className="px-5 sm:px-6 py-4 border-t border-border bg-surface-subtle shrink-0 flex items-center justify-end gap-2">
|
||||
{footer}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -2,7 +2,7 @@ import React, { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { DropdownMenu, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuItem } from '@/components/ui/dropdown-menu';
|
||||
import { MessageSquare, Phone, Calendar, X, Trash2, ChevronDown, UserCheck, Briefcase } from 'lucide-react';
|
||||
import { MessageSquare, Phone, Calendar, X, Trash2, ChevronDown, UserCheck, Briefcase, ArrowRight } from 'lucide-react';
|
||||
import { Image } from '@/components/ui/image';
|
||||
import { getScoreBand } from '@/lib/talentHome';
|
||||
import CandidateExpandedDetails from '@/components/krow/CandidateExpandedDetails';
|
||||
@@ -205,6 +205,22 @@ export default function CandidateCard({ application, jobTitle, rank, onAction, o
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center gap-2 shrink-0" onClick={(e) => e.stopPropagation()}>
|
||||
{/* The full record, at the route that already serves it.
|
||||
Clicking the row expands a summary in place, which is the right
|
||||
default while comparing people — but the complete profile had no
|
||||
way in from this list at all, so the same candidate opened from a
|
||||
recommendation and from here led to two different depths of the
|
||||
same person. One link, one route, one profile. */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => navigate(`/admin/candidates/${application.id}`)}
|
||||
className="hidden sm:inline-flex items-center gap-1 rounded-lg px-2.5 py-1.5 text-[12px] font-semibold text-[#0838E0]
|
||||
transition-colors hover:bg-[#EEF3FE] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#0838E0]/40"
|
||||
>
|
||||
View profile
|
||||
<ArrowRight className="w-3 h-3" aria-hidden="true" />
|
||||
</button>
|
||||
|
||||
{onAction && canTakeAction && (
|
||||
<button
|
||||
onClick={() => onAction(application)}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { payLabel } from '@/lib/positionModel';
|
||||
|
||||
const STATUS_STYLES = {
|
||||
active: 'bg-[#0838E0] text-white',
|
||||
@@ -35,6 +36,11 @@ export default function KROWJobPostingCard({ posting, stats }) {
|
||||
{getCategoryAbbr(posting.role_category)}
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
{/* The client this role is being staffed for, when the record says
|
||||
so. Above the title, never in place of it. */}
|
||||
{posting.company && (
|
||||
<p className="text-[10px] font-semibold uppercase tracking-wide text-[#0838E0] truncate">{posting.company}</p>
|
||||
)}
|
||||
<h3 className="font-semibold text-[#111827] text-[15px] leading-tight truncate">{posting.title}</h3>
|
||||
<div className="text-[12px] text-[#6B7280] capitalize">{posting.role_category}</div>
|
||||
</div>
|
||||
@@ -49,8 +55,8 @@ export default function KROWJobPostingCard({ posting, stats }) {
|
||||
{posting.location && (
|
||||
<span>{posting.location}</span>
|
||||
)}
|
||||
{posting.pay_range_min != null && posting.pay_range_max != null && (
|
||||
<span className="font-medium text-[#111827]">${posting.pay_range_min}–${posting.pay_range_max}/hr</span>
|
||||
{payLabel(posting) && (
|
||||
<span className="font-medium text-[#111827]">{payLabel(posting)}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -2,8 +2,10 @@ import React, { useMemo, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { ArrowRight, Check, GraduationCap, Minus, Sparkles, X } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { LEVEL_LABEL, matchPosition, rankWorkforce, skillName } from '@/lib/skillGraph';
|
||||
import { LEVEL_LABEL, matchPosition, skillName } from '@/lib/skillGraph';
|
||||
import { poolFor } from '@/lib/workforce';
|
||||
import { usePageSkills } from '@/lib/skills/usePageSkills';
|
||||
import { useApplications, useAssignments } from '@/lib/krowHooks';
|
||||
|
||||
/**
|
||||
* Who is qualified for this position, and who is one module away.
|
||||
@@ -51,8 +53,11 @@ function RequirementRow({ line }) {
|
||||
|
||||
function CandidateRow({ row, job, onView, onInterview, busy, attaches }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const { profile, match, application } = row;
|
||||
const tone = TONE[match.tone] || TONE.warning;
|
||||
/* Identity is the candidate record; the worker profile is enrichment that
|
||||
most candidates do not have. Nothing here reads a profile field without
|
||||
checking, and nothing substitutes for one when it is absent. */
|
||||
const { candidate, profile, match, application } = row;
|
||||
const tone = (match && TONE[match.tone]) || TONE.warning;
|
||||
|
||||
/* The training prompt is shown only when Forge attached that path to
|
||||
Positions. The score, the requirement rows and the gaps are unaffected —
|
||||
@@ -66,34 +71,51 @@ function CandidateRow({ row, job, onView, onInterview, busy, attaches }) {
|
||||
? new Date(application.created_date).toDateString() === new Date().toDateString()
|
||||
? 'Applied today'
|
||||
: `Applied ${new Date(application.created_date).toLocaleDateString(undefined, { month: 'short', day: 'numeric' })}`
|
||||
: 'Not applied — eligible from verified skills';
|
||||
: candidate.job_title
|
||||
? `Applied to ${candidate.job_title}`
|
||||
: 'On file as a candidate';
|
||||
|
||||
return (
|
||||
<div className="rounded-xl border border-[#E5E7EB] p-4 transition-colors hover:border-[#0838E0]/40">
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div className="flex min-w-0 items-center gap-3">
|
||||
{profile.selfie_url
|
||||
? <img src={profile.selfie_url} alt="" className="h-10 w-10 shrink-0 rounded-full object-cover" />
|
||||
: <div className="grid h-10 w-10 shrink-0 place-items-center rounded-full bg-[#EEF3FE] text-[13px] font-semibold text-[#0838E0]">{profile.full_name?.[0]}</div>}
|
||||
{candidate.selfie_url
|
||||
? <img src={candidate.selfie_url} alt="" className="h-10 w-10 shrink-0 rounded-full object-cover" />
|
||||
: <div className="grid h-10 w-10 shrink-0 place-items-center rounded-full bg-[#EEF3FE] text-[13px] font-semibold text-[#0838E0]">{candidate.applicant_name?.[0]}</div>}
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-[14px] font-semibold text-[#111827]">{profile.full_name}</p>
|
||||
<p className="truncate text-[14px] font-semibold text-[#111827]">{candidate.applicant_name}</p>
|
||||
<p className="truncate text-[12px] text-[#6B7280]">
|
||||
{profile.current_position || profile.desired_position || 'Worker'} · {applied}
|
||||
{profile?.current_position || profile?.desired_position || candidate.job_title || 'Candidate'} · {applied}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
{/* The score, and what it rests on. A requirement fit read from an
|
||||
application is not the same claim as a verified skill match, so the
|
||||
chip says which rather than letting the weaker one pass as the
|
||||
stronger. */}
|
||||
<div className="flex shrink-0 flex-col items-end gap-1">
|
||||
<span className={cn('rounded-full border px-2.5 py-1 text-[12px] font-semibold', tone.chip)}>
|
||||
{match.score}% · {match.band}
|
||||
{match ? `${match.score}% · ${match.band}` : 'Not scored'}
|
||||
</span>
|
||||
<span className="text-[10px] uppercase tracking-wide text-[#9CA3AF]">
|
||||
{row.basis === 'verified' ? 'Verified skills'
|
||||
: row.basis === 'stated' ? 'From application'
|
||||
: 'Insufficient data'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* The score, as the requirements that produced it. */}
|
||||
<ul className="mt-3 space-y-1.5">
|
||||
{match.lines.map((line) => <RequirementRow key={line.skillId} line={line} />)}
|
||||
</ul>
|
||||
{match ? (
|
||||
<ul className="mt-3 space-y-1.5">
|
||||
{match.lines.map((line) => <RequirementRow key={line.skillId} line={line} />)}
|
||||
</ul>
|
||||
) : (
|
||||
<p className="mt-3 text-[12px] text-[#6B7280]">
|
||||
This position states no requirements that can be checked against this candidate's record.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* The gap and the training that closes it — the Forge loop, stated on the
|
||||
recruiter's page so "not ready" comes with what to do about it. */}
|
||||
@@ -102,7 +124,7 @@ function CandidateRow({ row, job, onView, onInterview, busy, attaches }) {
|
||||
<GraduationCap className="h-3.5 w-3.5 shrink-0 text-[#0838E0]" aria-hidden="true" />
|
||||
<p className="min-w-0 flex-1 text-[12px] text-[#1E3A8A]">
|
||||
<span className="font-semibold">{recommendation.module.title}</span> would take{' '}
|
||||
{profile.full_name.split(' ')[0]} to {recommendation.unlocks} — match{' '}
|
||||
{candidate.applicant_name.split(' ')[0]} to {recommendation.unlocks} — match{' '}
|
||||
<span className="font-semibold tabular-nums">{recommendation.currentScore}% → {recommendation.projectedScore}%</span>
|
||||
</p>
|
||||
</div>
|
||||
@@ -144,13 +166,13 @@ function CandidateRow({ row, job, onView, onInterview, busy, attaches }) {
|
||||
{job.title} — weighted requirements
|
||||
</p>
|
||||
<ul className="space-y-1 text-[12px] text-[#374151]">
|
||||
{match.lines.map((line) => (
|
||||
{(match?.lines || []).map((line) => (
|
||||
<li key={line.skillId} className="flex items-baseline justify-between gap-3">
|
||||
<span className="min-w-0 truncate">
|
||||
{line.name} — {LEVEL_LABEL[line.required]} required, holds {line.heldLabel}
|
||||
{line.name} — {LEVEL_LABEL[line.required] || line.requiredLabel} required, holds {line.heldLabel}
|
||||
</span>
|
||||
<span className="shrink-0 tabular-nums font-medium">
|
||||
{line.earned} of {line.weight}
|
||||
{line.weight != null ? `${line.earned} of ${line.weight}` : line.met ? 'Met' : 'Not met'}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
@@ -174,39 +196,52 @@ export function MatchedCandidates({ job, profiles = [], courses = [], applicatio
|
||||
const navigate = useNavigate();
|
||||
const [showAll, setShowAll] = useState(false);
|
||||
const positionSkills = usePageSkills('positions');
|
||||
/* The Candidates dataset in full. The `applications` prop is scoped to this
|
||||
position, which is the right input for its pipeline counts and the wrong
|
||||
one for asking "is this person a candidate at all" — somebody who applied
|
||||
to a different role is still on the Candidates page. Same hook that page
|
||||
uses, so there is one dataset behind both answers. */
|
||||
const { data: everyApplication = [] } = useApplications();
|
||||
/* Availability needs the roster; without it the pool would report people as
|
||||
free who are already committed. */
|
||||
const { data: assignments = [] } = useAssignments();
|
||||
|
||||
/**
|
||||
* The candidates for this position — the same pool Owliver ranks.
|
||||
*
|
||||
* `poolFor` starts from the Candidates dataset and enriches each person with
|
||||
* a worker profile where one exists, so this list and the assistant's list
|
||||
* are the same people with the same scores. Reading the workforce directly
|
||||
* here would put a second, disagreeing population on the same page.
|
||||
*/
|
||||
const ranked = useMemo(
|
||||
() => rankWorkforce(job, profiles, courses, applications),
|
||||
[job, profiles, courses, applications]
|
||||
() => poolFor(job, { profiles, applications: everyApplication, assignments, courses }),
|
||||
[job, profiles, everyApplication, assignments, courses]
|
||||
);
|
||||
|
||||
/* Position-level counts, from the same scores the rows show. Internal: these
|
||||
are hiring metrics, and they stay on this page. */
|
||||
const counts = useMemo(() => {
|
||||
const jobApps = applications.filter((a) => a.job_posting_id === job?.id);
|
||||
const scoredApplicants = ranked.filter((r) => r.application);
|
||||
/* "Strong" means verified: an application stating it holds a certification
|
||||
is useful, and it is not the same evidence as completed training. The two
|
||||
are counted separately rather than added together. */
|
||||
const verified = ranked.filter((r) => r.basis === 'verified');
|
||||
return {
|
||||
candidates: ranked.length,
|
||||
verified: verified.length,
|
||||
applicants: jobApps.length,
|
||||
strong: scoredApplicants.filter((r) => r.match.score >= 75).length,
|
||||
potential: scoredApplicants.filter((r) => r.match.score >= 50 && r.match.score < 75).length,
|
||||
strong: verified.filter((r) => r.match.score >= 75).length,
|
||||
potential: verified.filter((r) => r.match.score >= 50 && r.match.score < 75).length,
|
||||
interview: jobApps.filter((a) => a.status === 'interview').length,
|
||||
hired: jobApps.filter((a) => a.status === 'hired').length,
|
||||
eligible: ranked.filter((r) => r.match.score >= 75).length,
|
||||
};
|
||||
}, [ranked, applications, job]);
|
||||
|
||||
if (!job?.skill_requirements?.length) {
|
||||
return (
|
||||
<div className="rounded-2xl border border-dashed border-[#E5E7EB] bg-white p-6">
|
||||
<h3 className="text-[15px] font-semibold text-[#111827]">Skill matching not configured</h3>
|
||||
<p className="mt-1 max-w-lg text-[13px] text-[#6B7280]">
|
||||
This position has no required skills, so candidates cannot be scored against verified
|
||||
training. Add them by editing the position — every requirement is a skill, a level and a
|
||||
weight.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
/* A position with no skill requirements can still be matched — on the
|
||||
requirements it does state — so this no longer replaces the whole section.
|
||||
It is said once, above the list, and the list still renders. */
|
||||
const hasSkillRequirements = Boolean(job?.skill_requirements?.length);
|
||||
|
||||
const shown = showAll ? ranked : ranked.slice(0, 5);
|
||||
|
||||
@@ -216,14 +251,17 @@ export function MatchedCandidates({ job, profiles = [], courses = [], applicatio
|
||||
<div className="min-w-0">
|
||||
<h3 className="text-[15px] font-semibold text-[#111827]">Matched candidates</h3>
|
||||
<p className="mt-0.5 text-[13px] text-[#6B7280]">
|
||||
{counts.eligible} {counts.eligible === 1 ? 'person meets' : 'people meet'} this position at
|
||||
75% or better, scored against verified skills. Best match first.
|
||||
{counts.candidates} {counts.candidates === 1 ? 'candidate' : 'candidates'} on file
|
||||
{counts.verified > 0
|
||||
? `, ${counts.verified} scored against verified skills`
|
||||
: ', scored against the requirements this position states'}. Best match first.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<ul className="flex flex-wrap items-center gap-x-4 gap-y-1 text-[12px] text-[#6B7280]">
|
||||
{[
|
||||
['Applicants', counts.applicants],
|
||||
['Verified', counts.verified],
|
||||
['Strong matches', counts.strong],
|
||||
['Potential', counts.potential],
|
||||
['Interview', counts.interview],
|
||||
@@ -237,6 +275,14 @@ export function MatchedCandidates({ job, profiles = [], courses = [], applicatio
|
||||
</div>
|
||||
|
||||
{/* What the position asks for, so the scores below have a stated basis. */}
|
||||
{!hasSkillRequirements && (
|
||||
<p className="mt-4 rounded-lg border border-dashed border-[#E5E7EB] px-3 py-2 text-[12px] text-[#6B7280]">
|
||||
This position defines no verified skill requirements, so candidates are scored against the
|
||||
experience, English level and certifications it states. Add skill requirements by editing
|
||||
the position to score against verified training.
|
||||
</p>
|
||||
)}
|
||||
{hasSkillRequirements && (
|
||||
<div className="mt-4 flex flex-wrap items-center gap-2 rounded-lg bg-[#F9FAFB] px-3 py-2">
|
||||
<Sparkles className="h-3.5 w-3.5 shrink-0 text-[#0838E0]" aria-hidden="true" />
|
||||
<span className="text-[12px] text-[#6B7280]">Requires</span>
|
||||
@@ -247,17 +293,18 @@ export function MatchedCandidates({ job, profiles = [], courses = [], applicatio
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-4 space-y-3">
|
||||
{shown.map((row) => (
|
||||
<CandidateRow
|
||||
key={row.profile.id}
|
||||
key={row.candidateId}
|
||||
row={row}
|
||||
job={job}
|
||||
busy={busy}
|
||||
onView={(r) => navigate(
|
||||
r.application ? `/admin/candidates/${r.application.id}` : `/admin/talent-pool?search=${encodeURIComponent(r.profile.full_name)}`
|
||||
)}
|
||||
/* Every row here now carries a candidate record, so this always
|
||||
opens the existing candidate profile by id. */
|
||||
onView={(r) => navigate(`/admin/candidates/${r.candidate.id}`)}
|
||||
onInterview={onInterview}
|
||||
attaches={positionSkills.attaches}
|
||||
/>
|
||||
|
||||
156
src/components/krow/PositionDetails.jsx
Normal file
156
src/components/krow/PositionDetails.jsx
Normal file
@@ -0,0 +1,156 @@
|
||||
import React from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import {
|
||||
customRequirementsText, englishLabel, experienceLabel, rateLabel, statedRequirements,
|
||||
} from '@/lib/positionModel';
|
||||
|
||||
/**
|
||||
* What a position actually says about itself.
|
||||
*
|
||||
* The Create Position form asks twelve questions. This is where all twelve
|
||||
* answers are read back — every one of them, on both detail surfaces, in the
|
||||
* order the form asks them. A field an employer filled in and cannot find
|
||||
* afterwards may as well not have been asked for.
|
||||
*
|
||||
* Three rules hold the section together:
|
||||
*
|
||||
* - **Universal fields always appear.** Client, title, category, location, pay,
|
||||
* experience and English are questions every position answers, so a blank one
|
||||
* reads "Not specified" — the reader can tell "left out" from "not shown".
|
||||
* Nothing renders `undefined`, `null`, `N/A` or `NaN`.
|
||||
* - **Role-specific requirements appear only when stated.** What a role demands
|
||||
* physically, of a leader, or of attendance is not a universal question; a
|
||||
* server role that never asked for lifting should not be shown carrying a
|
||||
* lifting requirement, empty or otherwise. The section collapses to what the
|
||||
* position actually says, and disappears entirely when it says nothing.
|
||||
* - **Pay is two fields, because the form asks for two.** The compact
|
||||
* `$24–$34/hr` range still leads the header, but Pay Min and Pay Max are
|
||||
* stated separately here, as they were entered.
|
||||
*
|
||||
* Everything is read from the position record passed in — nothing is defaulted,
|
||||
* inferred or invented. The drawer and the full page render these same
|
||||
* components, so the two cannot drift apart.
|
||||
*/
|
||||
|
||||
/** How an unanswered field reads. Subtle, and never a stand-in for real data. */
|
||||
const NOT_SPECIFIED = 'Not specified';
|
||||
|
||||
/** One label/value pair. A `value` of `null` means the record states nothing. */
|
||||
function InfoCell({ label, value }) {
|
||||
const specified = Boolean(value);
|
||||
return (
|
||||
<div className="min-w-0">
|
||||
<dt className="text-[10px] font-semibold uppercase tracking-wide text-ink-4">{label}</dt>
|
||||
<dd
|
||||
className={cn(
|
||||
'mt-0.5 break-words text-body-sm',
|
||||
specified ? 'font-medium text-ink-1' : 'italic text-ink-4'
|
||||
)}
|
||||
>
|
||||
{specified ? value : NOT_SPECIFIED}
|
||||
</dd>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Role information — the eight single-value fields, as a grid.
|
||||
*
|
||||
* Two columns from `sm` and three from `lg`, which is what the widened drawer
|
||||
* was widened for: eight short values in one column is a column of whitespace
|
||||
* beside a very long scroll.
|
||||
*/
|
||||
export function PositionOverview({ position, className = '' }) {
|
||||
/* Ordered by what each field answers, three to a row on desktop so the tiers
|
||||
read as rows: who and what the role is, then where and for how much, then
|
||||
what is asked of the person. */
|
||||
const rows = [
|
||||
{ label: 'Client / Company', value: position.company || null },
|
||||
{ label: 'Job Title', value: position.title || null },
|
||||
{ label: 'Role Category', value: position.role_category || null },
|
||||
{ label: 'Location', value: position.location || null },
|
||||
{ label: 'Pay Min', value: rateLabel(position.pay_range_min) },
|
||||
{ label: 'Pay Max', value: rateLabel(position.pay_range_max) },
|
||||
{ label: 'Min. Experience', value: experienceLabel(position) },
|
||||
{ label: 'English Level', value: englishLabel(position.english_required) },
|
||||
];
|
||||
|
||||
return (
|
||||
<dl
|
||||
className={cn(
|
||||
'grid grid-cols-1 gap-x-6 gap-y-4 rounded-xl border border-border bg-surface-subtle p-4 sm:grid-cols-2 lg:grid-cols-3',
|
||||
className
|
||||
)}
|
||||
>
|
||||
{rows.map((row) => <InfoCell key={row.label} {...row} />)}
|
||||
</dl>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* What this particular role asks of a person — and only that.
|
||||
*
|
||||
* The grid is filled by whatever the position states, so a security role shows
|
||||
* a physical requirement, a chef role shows a leadership one, and neither is
|
||||
* padded out to match the other. Returns `null` when the position states none,
|
||||
* which is what lets a caller drop the heading with it.
|
||||
*
|
||||
* Two columns on desktop, one on mobile; the cards reflow, so two requirements
|
||||
* sit side by side rather than beside a gap where a third would have gone.
|
||||
*/
|
||||
export function PositionRequirements({ position, className = '' }) {
|
||||
const fields = statedRequirements(position);
|
||||
const certifications = position.certifications_required || [];
|
||||
|
||||
if (!fields.length && !certifications.length) return null;
|
||||
|
||||
return (
|
||||
<div className={cn('grid grid-cols-1 gap-3 sm:grid-cols-2', className)}>
|
||||
{fields.map((field) => (
|
||||
<div key={field.key} className="rounded-xl border border-border bg-surface p-3.5">
|
||||
<p className="text-[10px] font-semibold uppercase tracking-wide text-ink-4">{field.label}</p>
|
||||
<p className="mt-1 whitespace-pre-wrap text-body-sm leading-relaxed text-ink-1">{field.value}</p>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Certifications are a requirement too, and they are chips rather than
|
||||
prose — so they sit with the expectations rather than in the grid of
|
||||
single values above. Shown only when the position asks for any. */}
|
||||
{certifications.length > 0 && (
|
||||
<div className="rounded-xl border border-border bg-surface p-3.5 sm:col-span-2">
|
||||
<p className="text-[10px] font-semibold uppercase tracking-wide text-ink-4">Required Certifications</p>
|
||||
<div className="mt-1.5 flex flex-wrap gap-1.5">
|
||||
{certifications.map((cert) => (
|
||||
<span
|
||||
key={cert}
|
||||
className="rounded-full border border-krow-blue/20 bg-krow-blue-tint px-2.5 py-0.5 text-[11px] font-medium text-krow-blue"
|
||||
>
|
||||
{cert}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Custom requirements — the one field that is a paragraph.
|
||||
*
|
||||
* Full width, wrapping naturally, never truncated and never replaced with a
|
||||
* pointer to somewhere else: this is the field an employer uses to say the
|
||||
* thing no other field covers, and it is usually the most specific sentence on
|
||||
* the record. Absent when the employer wrote nothing, like every other
|
||||
* role-specific field.
|
||||
*/
|
||||
export function PositionCustomRequirements({ position, className = '' }) {
|
||||
const text = customRequirementsText(position);
|
||||
if (!text) return null;
|
||||
|
||||
return (
|
||||
<div className={cn('rounded-xl border border-border bg-surface p-4', className)}>
|
||||
<p className="whitespace-pre-wrap text-body-sm leading-relaxed text-ink-1">{text}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -2,7 +2,8 @@ import React from 'react';
|
||||
import { Sparkles, Upload } from 'lucide-react';
|
||||
import { Button, Field, Input, Modal, Textarea, toast } from '@/components/ds';
|
||||
import { usePreferences, useUpdatePreferences } from '@/lib/krowHooks';
|
||||
import { PAGE_KEYS, parseSkill, validateSkillSource } from '@/lib/skills/registry';
|
||||
import { PAGE_KEYS, validateSkillSource } from '@/lib/skills/registry';
|
||||
import { skillTemplate, upsertCustomSkill } from '@/lib/skills/customSkills';
|
||||
|
||||
/**
|
||||
* Add an Owliver skill.
|
||||
@@ -20,29 +21,6 @@ import { PAGE_KEYS, parseSkill, validateSkillSource } from '@/lib/skills/registr
|
||||
* then edit directly. The Markdown is always what gets saved.
|
||||
*/
|
||||
|
||||
const TEMPLATE = ({ id, name, description, pages }) => `---
|
||||
id: ${id || 'my-skill'}
|
||||
name: ${name || 'My Skill'}
|
||||
description: ${description || 'What this skill helps Owliver do.'}
|
||||
pages:
|
||||
${(pages || ['positions']).map((p) => ` - ${p}`).join('\n')}
|
||||
status: active
|
||||
triggers:
|
||||
- ${(name || 'my skill').toLowerCase()}
|
||||
---
|
||||
|
||||
# ${name || 'My Skill'}
|
||||
|
||||
## Purpose
|
||||
|
||||
Describe what Owliver should help with on these pages.
|
||||
|
||||
## Capabilities
|
||||
|
||||
- Describe one thing the skill can do.
|
||||
- Add more as needed.
|
||||
`;
|
||||
|
||||
export function AddSkillDialog({ open, onOpenChange }) {
|
||||
const preferences = usePreferences();
|
||||
const updatePreferences = useUpdatePreferences();
|
||||
@@ -68,7 +46,7 @@ export function AddSkillDialog({ open, onOpenChange }) {
|
||||
const syncFromMeta = (next) => {
|
||||
setMeta(next);
|
||||
if (touched) return;
|
||||
setSource(TEMPLATE({
|
||||
setSource(skillTemplate({
|
||||
id: next.id,
|
||||
name: next.name,
|
||||
description: next.description,
|
||||
@@ -96,12 +74,7 @@ export function AddSkillDialog({ open, onOpenChange }) {
|
||||
return;
|
||||
}
|
||||
|
||||
const skill = parseSkill(source, { custom: true });
|
||||
const existing = preferences.customSkills || [];
|
||||
const next = [
|
||||
...existing.filter((entry) => parseSkill(entry.raw, { custom: true }).id !== skill.id),
|
||||
{ path: `custom/${skill.id}.md`, raw: source },
|
||||
];
|
||||
const { skill, next } = upsertCustomSkill(preferences.customSkills || [], source);
|
||||
|
||||
updatePreferences.mutate({ customSkills: next });
|
||||
toast.success(`${skill.name} added`);
|
||||
|
||||
@@ -2,7 +2,7 @@ import React, { useEffect, useRef, useState } from 'react';
|
||||
import { Link, Outlet, useLocation, useNavigate } from 'react-router-dom';
|
||||
import { motion } from 'framer-motion';
|
||||
import {
|
||||
Bell, ChevronDown, LogOut, Menu, Search, Shield, User as UserIcon, Users,
|
||||
Bell, ChevronDown, LayoutGrid, LogOut, Menu, Search, Settings, Shield,
|
||||
} from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { base44 } from '@/api/base44Client';
|
||||
@@ -248,12 +248,19 @@ export default function AdminLayout() {
|
||||
<Badge variant="soft" size="sm" className="mt-1.5">Platform Administrator</Badge>
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={() => navigate('/admin/profile')} className="cursor-pointer">
|
||||
<UserIcon className="mr-2 h-4 w-4" /> Profile
|
||||
{/* Account configuration, then the one thing people come to
|
||||
the account menu for by name, then the workforce workspace.
|
||||
Profile is not dropped — it is the first entry inside
|
||||
Settings, where account information belongs. */}
|
||||
<DropdownMenuItem onClick={() => navigate('/admin/settings')} className="cursor-pointer">
|
||||
<Settings className="mr-2 h-4 w-4" /> Settings
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => navigate('/admin/profile#security')} className="cursor-pointer">
|
||||
<Shield className="mr-2 h-4 w-4" /> Security
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => navigate('/admin/workspace')} className="cursor-pointer">
|
||||
<LayoutGrid className="mr-2 h-4 w-4" /> Workspace & Skills
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
onClick={signOut}
|
||||
@@ -298,13 +305,20 @@ export default function AdminLayout() {
|
||||
</Link>
|
||||
))}
|
||||
<div className="my-2 h-px bg-border" />
|
||||
<Link
|
||||
to="/admin/profile"
|
||||
onClick={() => setMenuOpen(false)}
|
||||
className="rounded-lg px-3 py-2.5 text-body-sm font-medium text-ink-2 hover:bg-surface-sunken"
|
||||
>
|
||||
Profile
|
||||
</Link>
|
||||
{[
|
||||
{ label: 'Settings', path: '/admin/settings' },
|
||||
{ label: 'Security', path: '/admin/profile#security' },
|
||||
{ label: 'Workspace & Skills', path: '/admin/workspace' },
|
||||
].map((item) => (
|
||||
<Link
|
||||
key={item.label}
|
||||
to={item.path}
|
||||
onClick={() => setMenuOpen(false)}
|
||||
className="rounded-lg px-3 py-2.5 text-body-sm font-medium text-ink-2 hover:bg-surface-sunken"
|
||||
>
|
||||
{item.label}
|
||||
</Link>
|
||||
))}
|
||||
</nav>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
|
||||
@@ -173,3 +173,92 @@ export function payLabel({ pay_range_min: min, pay_range_max: max } = {}) {
|
||||
if (!high || high === low) return `$${low}/hr`;
|
||||
return `$${low}–$${high}/hr`;
|
||||
}
|
||||
|
||||
/** `fluent` → `Fluent`. Returns null for a level the record does not state. */
|
||||
export function englishLabel(value) {
|
||||
if (!value) return null;
|
||||
return ENGLISH_LEVELS.find((l) => l.value === value)?.label
|
||||
?? String(value).replace(/\b\w/, (c) => c.toUpperCase());
|
||||
}
|
||||
|
||||
/**
|
||||
* "0 years", "1 year", "3 years" — the number the employer actually entered.
|
||||
*
|
||||
* Zero is an answer, not an absence: a position open to people with no
|
||||
* experience says so, and reading it back as "No minimum" would be this code
|
||||
* rewording the employer rather than reporting them. `null` is returned only
|
||||
* when the field carries nothing at all.
|
||||
*/
|
||||
export function experienceLabel(position = {}) {
|
||||
const years = position.min_experience_years;
|
||||
if (years === undefined || years === null || years === '') return null;
|
||||
const n = Number(years);
|
||||
if (!Number.isFinite(n)) return null;
|
||||
return `${n} ${n === 1 ? 'year' : 'years'}`;
|
||||
}
|
||||
|
||||
/** A single rate as its own value: `$24/hr`, or null when it was not set. */
|
||||
export function rateLabel(value) {
|
||||
if (value === undefined || value === null || value === '') return null;
|
||||
const n = Number(value);
|
||||
if (!Number.isFinite(n) || n <= 0) return null;
|
||||
return `$${n}/hr`;
|
||||
}
|
||||
|
||||
/**
|
||||
* The prose expectations the employer wrote, in the order the form asks for them.
|
||||
*
|
||||
* `custom_requirements` is deliberately not in this list: it is a paragraph
|
||||
* rather than a value, and it gets a block of its own — see
|
||||
* `PositionCustomRequirements`.
|
||||
*/
|
||||
export const REQUIREMENT_FIELDS = [
|
||||
{ key: 'physical_requirements', label: 'Physical Requirements' },
|
||||
{ key: 'leadership_expectations', label: 'Leadership Expectations' },
|
||||
{ key: 'attendance_expectations', label: 'Attendance Expectations' },
|
||||
];
|
||||
|
||||
/**
|
||||
* The requirements this position actually states.
|
||||
*
|
||||
* Only the ones with something in them. A requirement is role-specific by
|
||||
* nature — a warehouse role states what it needs lifted, a server role does not
|
||||
* — so a field left blank is not an omission to be reported, it is that role
|
||||
* saying the question does not apply to it. Rendering an empty "Physical
|
||||
* requirements" card on every position turns a blank answer into a demand, and
|
||||
* makes every role look like the same template.
|
||||
*
|
||||
* The universal fields (title, category, location, pay, experience, English)
|
||||
* behave the opposite way and are handled separately: those are questions every
|
||||
* position answers, so an unanswered one is worth showing as unanswered.
|
||||
*/
|
||||
export function statedRequirements(position = {}) {
|
||||
return REQUIREMENT_FIELDS
|
||||
.map((field) => ({ ...field, value: String(position[field.key] ?? '').trim() }))
|
||||
.filter((field) => field.value);
|
||||
}
|
||||
|
||||
/** The custom requirement text, or `''` when none was written. */
|
||||
export const customRequirementsText = (position = {}) =>
|
||||
String(position.custom_requirements ?? '').trim();
|
||||
|
||||
/**
|
||||
* Does this position state anything under Requirements at all?
|
||||
*
|
||||
* The gate a surface uses before drawing the heading — a section title standing
|
||||
* over nothing is worse than no section.
|
||||
*/
|
||||
export const hasRequirements = (position = {}) =>
|
||||
statedRequirements(position).length > 0 || (position.certifications_required || []).length > 0;
|
||||
|
||||
/**
|
||||
* "Server · San Jose, CA · $24–$34/hr" — the terms of the role in one line.
|
||||
*
|
||||
* Company is deliberately absent: it belongs above the title, not beside the
|
||||
* category. Parts the record does not carry are omitted rather than padded.
|
||||
*/
|
||||
export function roleMetaLine(position = {}) {
|
||||
return [position.role_category, position.location, payLabel(position)]
|
||||
.filter(Boolean)
|
||||
.join(' · ');
|
||||
}
|
||||
|
||||
78
src/lib/skills/customSkills.js
Normal file
78
src/lib/skills/customSkills.js
Normal file
@@ -0,0 +1,78 @@
|
||||
import { parseSkill } from './registry';
|
||||
|
||||
/**
|
||||
* Account-authored skills, as stored.
|
||||
*
|
||||
* A custom skill is its Markdown source and nothing else — the same artefact a
|
||||
* file in `src/skills/` is, read back by the same parser. These helpers exist so
|
||||
* the Add Skill dialog and the Skills page write that list identically; two
|
||||
* writers with two shapes would be a second skill system by accident.
|
||||
*/
|
||||
|
||||
/** The starting definition offered to an author, in the existing format. */
|
||||
export const skillTemplate = ({ id = '', name = '', description = '', pages = [] } = {}) => `---
|
||||
id: ${id || 'my-skill'}
|
||||
name: ${name || 'My Skill'}
|
||||
description: ${description || 'What this skill helps Owliver do.'}
|
||||
pages:
|
||||
${(pages?.length ? pages : ['positions']).map((p) => ` - ${p}`).join('\n')}
|
||||
status: active
|
||||
triggers:
|
||||
- ${(name || 'my skill').toLowerCase()}
|
||||
---
|
||||
|
||||
# ${name || 'My Skill'}
|
||||
|
||||
## Purpose
|
||||
|
||||
Describe what Owliver should help with on these pages.
|
||||
|
||||
## Capabilities
|
||||
|
||||
- Describe one thing the skill can do.
|
||||
- Add more as needed.
|
||||
`;
|
||||
|
||||
/** Parses a stored entry, tolerating the bare-string form. */
|
||||
const sourceOf = (entry) => (typeof entry === 'string' ? entry : entry?.raw ?? '');
|
||||
|
||||
/**
|
||||
* The stored list with `source` added or replaced.
|
||||
*
|
||||
* Matching is by skill id, so editing a skill overwrites its own entry rather
|
||||
* than adding a near-duplicate beside it.
|
||||
*/
|
||||
export function upsertCustomSkill(existing = [], source) {
|
||||
const skill = parseSkill(source, { custom: true });
|
||||
const rest = existing.filter((entry) => {
|
||||
try {
|
||||
return parseSkill(sourceOf(entry), { custom: true }).id !== skill.id;
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
});
|
||||
return { skill, next: [...rest, { path: `custom/${skill.id}.md`, raw: source }] };
|
||||
}
|
||||
|
||||
/** The stored list without the skill of this id. */
|
||||
export function removeCustomSkill(existing = [], id) {
|
||||
return existing.filter((entry) => {
|
||||
try {
|
||||
return parseSkill(sourceOf(entry), { custom: true }).id !== id;
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** The stored Markdown for one custom skill, or null if the account has none. */
|
||||
export function customSkillSource(existing = [], id) {
|
||||
for (const entry of existing) {
|
||||
try {
|
||||
if (parseSkill(sourceOf(entry), { custom: true }).id === id) return sourceOf(entry);
|
||||
} catch {
|
||||
/* An unparseable entry cannot be the one being edited. */
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -73,6 +73,32 @@ export function skillStatesForPage(pageId, courses = [], profile = null, options
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
/**
|
||||
* Every training path the platform has, paired with one person's state in it.
|
||||
*
|
||||
* Page attachment is deliberately not consulted here. `pages:` says which
|
||||
* *product surfaces* may talk about a path; a workforce management view is
|
||||
* asking a different question — what training exists at all — and filtering it
|
||||
* by attachment would quietly hide a path from the one screen whose job is to
|
||||
* account for every one of them.
|
||||
*
|
||||
* Same definitions, same graph, same derivation as the page-scoped version
|
||||
* above: this is a second question asked of one dataset, not a second dataset.
|
||||
*/
|
||||
export function workforceSkillStates(courses = [], profile = null, options = {}) {
|
||||
const { customSources = [], disabled = [] } = options;
|
||||
return allSkills(customSources)
|
||||
.filter((s) => s.kind === 'workforce' && s.status === 'active' && !disabled.includes(s.id))
|
||||
.map((definition) => {
|
||||
if (!definition.skillId) return null;
|
||||
const state = skillStateFor(definition.skillId, courses, profile);
|
||||
/* A definition bound to a capability the graph does not carry is an
|
||||
authoring error; showing it as "Not started" would conceal that. */
|
||||
return state.ladder?.length ? { definition, state } : null;
|
||||
})
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
/**
|
||||
* The definition governing one capability, or null.
|
||||
*
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { useMemo } from 'react';
|
||||
import { usePreferences } from '@/lib/krowHooks';
|
||||
import {
|
||||
isSkillOnPage, pageDefinitionForSkillId, skillStatesForPage, workforceSkillsForPage,
|
||||
isSkillOnPage, pageDefinitionForSkillId, skillStatesForPage, workforceSkillStates,
|
||||
workforceSkillsForPage,
|
||||
} from './pageSkills';
|
||||
|
||||
/**
|
||||
@@ -25,6 +26,33 @@ import {
|
||||
* already exists — a training prompt beside a position requirement, say — where
|
||||
* the page is not listing skills but is about to mention one.
|
||||
*/
|
||||
/**
|
||||
* Every training path, for the views that manage training rather than surface it.
|
||||
*
|
||||
* The same preferences plumbing as `usePageSkills`, asking `pageSkills.js` the
|
||||
* other question it answers — so Workspace & Skills and Skill Development count
|
||||
* paths from the registry rather than keeping a list of their own.
|
||||
*/
|
||||
export function useWorkforcePaths() {
|
||||
const preferences = usePreferences();
|
||||
|
||||
const customKey = JSON.stringify(preferences.customSkills || []);
|
||||
const disabledKey = JSON.stringify(preferences.disabledSkills || []);
|
||||
|
||||
return useMemo(() => {
|
||||
const options = {
|
||||
customSources: JSON.parse(customKey),
|
||||
disabled: JSON.parse(disabledKey),
|
||||
};
|
||||
return {
|
||||
/** The training paths themselves, before anyone's progress is applied. */
|
||||
paths: workforceSkillStates([], null, options).map((entry) => entry.definition),
|
||||
/** Each path paired with one person's state in it. */
|
||||
statesFor: (courses, profile) => workforceSkillStates(courses, profile, options),
|
||||
};
|
||||
}, [customKey, disabledKey]);
|
||||
}
|
||||
|
||||
export function usePageSkills(pageId) {
|
||||
const preferences = usePreferences();
|
||||
|
||||
|
||||
@@ -140,13 +140,24 @@ export function resolveCandidate(question, pool = []) {
|
||||
function reasonsFor(row) {
|
||||
const out = [];
|
||||
|
||||
if (!row.match) {
|
||||
return ['This position states no requirements this candidate can be scored against'];
|
||||
}
|
||||
|
||||
for (const line of row.match.met) {
|
||||
out.push(`✓ ${line.name} — ${line.heldLabel}`);
|
||||
}
|
||||
for (const line of row.match.gaps) {
|
||||
out.push(`⚠ ${line.name} — ${line.heldLabel}, needs ${line.requiredLabel}`);
|
||||
}
|
||||
out.push(row.availability.available ? '✓ Available when this starts' : `✕ ${row.availability.reason}`);
|
||||
|
||||
/* Availability is only known for people the workforce system holds a record
|
||||
for. For everyone else it is reported as unknown rather than as free. */
|
||||
if (row.availability.known === false) {
|
||||
out.push('Availability not on file');
|
||||
} else {
|
||||
out.push(row.availability.available ? '✓ Available when this starts' : `✕ ${row.availability.reason}`);
|
||||
}
|
||||
if (row.duration.note) out.push(`⚠ ${row.duration.note}`);
|
||||
|
||||
return out;
|
||||
@@ -155,31 +166,23 @@ function reasonsFor(row) {
|
||||
/**
|
||||
* The record to open for a recommended person.
|
||||
*
|
||||
* Matching reads worker profiles, because that is where verified skill levels
|
||||
* live. The candidate profile is addressed by *application* id, because that is
|
||||
* what the page was built to show. So the link is only real when this person has
|
||||
* an application on file — and when they do not, the card says so rather than
|
||||
* pointing somewhere that is not their record.
|
||||
* One id, decided in one place. `poolFor` resolved this row against the
|
||||
* Candidates dataset before it was ever ranked, so `candidateId` is the id of
|
||||
* an application that genuinely exists — never a name, a rank, or a record
|
||||
* invented to make the link work. A row without one never reaches here, because
|
||||
* a person with no candidate record is not returned as a match at all.
|
||||
*
|
||||
* The position travels with the link so the profile knows what the person was
|
||||
* being considered for.
|
||||
* The route is the existing candidate route, opening the existing full profile.
|
||||
* The position travels with it so the profile knows what the person was being
|
||||
* considered for.
|
||||
*/
|
||||
export function candidateRoute(row, applications = [], position = null) {
|
||||
const email = String(row.email || '').toLowerCase();
|
||||
if (!email) return null;
|
||||
|
||||
const mine = applications.filter((a) => String(a.email || '').toLowerCase() === email);
|
||||
if (!mine.length) return null;
|
||||
|
||||
/* An application for *this* role is the right record to open; failing that,
|
||||
their most recent one, which is still their profile. */
|
||||
const forThis = position && mine.find((a) => a.job_posting_id === position.id);
|
||||
const chosen = forThis
|
||||
|| [...mine].sort((a, b) => new Date(b.created_date) - new Date(a.created_date))[0];
|
||||
const id = row?.candidateId;
|
||||
if (!id) return null;
|
||||
|
||||
return position
|
||||
? `/admin/candidates/${chosen.id}?for=${encodeURIComponent(position.id)}`
|
||||
: `/admin/candidates/${chosen.id}`;
|
||||
? `/admin/candidates/${id}?for=${encodeURIComponent(position.id)}`
|
||||
: `/admin/candidates/${id}`;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -189,15 +192,53 @@ export function candidateRoute(row, applications = [], position = null) {
|
||||
* — met requirements, gaps, and the availability verdict — so a card can never
|
||||
* read as a stronger endorsement than the match actually is.
|
||||
*/
|
||||
const candidateBlock = (row, index, { position = null, inspectable = true } = {}) => insights([{
|
||||
tone: row.strong ? 'success' : row.availability.available ? 'info' : 'warning',
|
||||
title: `${index}. ${row.name} — ${row.score}% match`,
|
||||
body: reasonsFor(row).join(' · '),
|
||||
/* Clicking inspects the person *here*. Opening their full record is offered
|
||||
separately, from the detail view, because leaving the panel loses the
|
||||
position the admin is working on. */
|
||||
prompt: inspectable && position ? `Show ${row.name} for ${position.title}` : null,
|
||||
}]);
|
||||
const candidateBlock = (row, index, { position = null, applications = [], inspectable = true } = {}) => {
|
||||
/**
|
||||
* Two different things a reader wants from a match, kept apart.
|
||||
*
|
||||
* **View Profile** opens that person's record on the Candidates page. It is an
|
||||
* explicit control rather than the whole card, so reading the match reasons
|
||||
* cannot navigate away by accident — and it resolves for everyone, applied or
|
||||
* not, because the profile page reads both kinds of record.
|
||||
*
|
||||
* **The card itself** still asks Owliver about this person for this role, which
|
||||
* is the existing inspect-and-assign conversation, unchanged.
|
||||
*
|
||||
* The hint stays informational: it explains what opening the profile will and
|
||||
* will not show, and is no longer the only way in.
|
||||
*/
|
||||
const to = candidateRoute(row, applications, position);
|
||||
|
||||
/**
|
||||
* The headline says what kind of evidence the number rests on.
|
||||
*
|
||||
* A skill match is scored on training this person completed; a requirement
|
||||
* match is scored on what their application states. Printing both as "match"
|
||||
* would make the weaker claim borrow the authority of the stronger one, and a
|
||||
* candidate with nothing to score says so rather than showing a 0.
|
||||
*/
|
||||
const headline = !row.scored
|
||||
? 'not scored'
|
||||
: row.basis === 'verified'
|
||||
? `${row.score}% match`
|
||||
: `${row.score}% requirement fit`;
|
||||
|
||||
return insights([{
|
||||
tone: row.strong ? 'success' : row.scored ? 'info' : 'neutral',
|
||||
title: `${index}. ${row.name} — ${headline}`,
|
||||
body: reasonsFor(row).join(' · '),
|
||||
prompt: !inspectable || !position ? null : `Show ${row.name} for ${position.title}`,
|
||||
action: to ? { label: 'View Profile', to } : null,
|
||||
/* Two informational states, never a control: what the score rests on, and
|
||||
whether they have applied for this role. The profile opens either way. */
|
||||
hint: [
|
||||
row.basis === 'stated'
|
||||
? 'Scored from their application — no verified training on file.'
|
||||
: !row.scored ? 'Insufficient profile data to score against this role.' : null,
|
||||
row.applied ? null : 'No application for this role yet — assigning them creates one.',
|
||||
].filter(Boolean).join(' ') || null,
|
||||
}]);
|
||||
};
|
||||
|
||||
/**
|
||||
* The people who could do this work, ranked.
|
||||
@@ -213,23 +254,31 @@ export function candidateMatches(position, context) {
|
||||
if (!pool.length) {
|
||||
return {
|
||||
doc: doc(
|
||||
text(`No worker profiles can be scored against **${position.title}** — it defines no skill requirements, or no profiles carry the skills it asks for.`),
|
||||
note('Matching reads verified skill levels from worker profiles. Applicants without a profile appear in the pipeline but cannot be scored.')
|
||||
text(`There are no candidates on file to score against **${position.title}**.`),
|
||||
note('Candidates come from the Candidates page. Once somebody applies — to this role or any other — they can be scored against this position.')
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
/* Committed elsewhere is a workforce fact, so it can only exclude somebody the
|
||||
workforce system holds a record for. A candidate with no such record is
|
||||
ranked below the verified ones, never filtered out by an availability
|
||||
nobody has recorded. */
|
||||
const eligible = pool.filter((r) => r.availability.available);
|
||||
const blocked = pool.filter((r) => !r.availability.available);
|
||||
const shown = eligible.slice(0, 5);
|
||||
const verified = pool.filter((r) => r.basis === 'verified').length;
|
||||
|
||||
return {
|
||||
doc: doc(
|
||||
heading(`Best matches for ${position.title}`,
|
||||
`${status.strong.length} strong · ${eligible.length} available · ${pool.length} scored`),
|
||||
`${pool.length} candidate${pool.length === 1 ? '' : 's'} · ${verified} with verified skills · ${status.strong.length} strong`),
|
||||
...(shown.length
|
||||
? shown.map((row, i) => candidateBlock(row, i + 1, { applications: context.applications, position }))
|
||||
: [text('Nobody is currently free for this role.')]),
|
||||
pool.length > shown.length + blocked.length
|
||||
? note(`${pool.length - shown.length - blocked.length} more candidate${pool.length - shown.length - blocked.length === 1 ? '' : 's'} on file — ask for the full list or open Candidates to see them all.`)
|
||||
: null,
|
||||
blocked.length
|
||||
? note(`${blocked.length} other ${blocked.length === 1 ? 'person is' : 'people are'} qualified but committed elsewhere — ask who is available next to see when they free up.`)
|
||||
: null,
|
||||
@@ -250,7 +299,7 @@ export function candidateMatches(position, context) {
|
||||
export function availability(position, context) {
|
||||
const pool = poolFor(position, context);
|
||||
if (!pool.length) {
|
||||
return { doc: doc(text(`No worker profiles can be assessed for **${position.title}**.`)) };
|
||||
return { doc: doc(text(`There are no candidates on file to assess for **${position.title}**.`)) };
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
@@ -185,78 +185,218 @@ export function durationFitOf(profile, position, { today = new Date() } = {}) {
|
||||
/* ── Candidate and workforce pool ──────────────────────────────────────── */
|
||||
|
||||
/**
|
||||
* Everyone who could do this work, ranked, with the reasoning attached.
|
||||
* How well a candidate meets the requirements this position actually states.
|
||||
*
|
||||
* Skill match is the base score; timing adjusts it. A perfect skill match who
|
||||
* cannot start until after the work begins is not a better answer than a
|
||||
* slightly weaker one who can, and the ranking says so — but the person is
|
||||
* still returned, with the reason, because "nobody is free" is something the
|
||||
* admin needs to see rather than have filtered away.
|
||||
* The fallback for somebody with no worker profile — which is most candidates.
|
||||
* It compares fields both records genuinely carry: the minimum experience,
|
||||
* English level and certifications the position asks for, against the ones the
|
||||
* application states. Nothing is inferred and nothing is invented; a
|
||||
* requirement the position does not state is not scored, and an answer the
|
||||
* application does not give counts as unmet rather than assumed.
|
||||
*
|
||||
* Returns `null` when the position states none of these, because then there is
|
||||
* nothing to measure and a number would be decoration.
|
||||
*/
|
||||
const ENGLISH_ORDER = ['basic', 'conversational', 'fluent', 'native'];
|
||||
|
||||
function statedRequirementMatch(position, application) {
|
||||
const lines = [];
|
||||
|
||||
const required = Number(position?.min_experience_years) || 0;
|
||||
if (required > 0) {
|
||||
const held = Number(application?.years_experience) || 0;
|
||||
lines.push({
|
||||
skillId: 'experience',
|
||||
name: 'Experience',
|
||||
met: held >= required,
|
||||
heldLabel: `${held} ${held === 1 ? 'year' : 'years'}`,
|
||||
requiredLabel: `${required} ${required === 1 ? 'year' : 'years'}`,
|
||||
});
|
||||
}
|
||||
|
||||
const englishNeeded = ENGLISH_ORDER.indexOf(String(position?.english_required || '').toLowerCase());
|
||||
if (englishNeeded > 0) {
|
||||
const heldIndex = ENGLISH_ORDER.indexOf(String(application?.english_level || '').toLowerCase());
|
||||
const label = (i) => (i < 0 ? 'Not stated' : ENGLISH_ORDER[i][0].toUpperCase() + ENGLISH_ORDER[i].slice(1));
|
||||
lines.push({
|
||||
skillId: 'english',
|
||||
name: 'English',
|
||||
met: heldIndex >= englishNeeded,
|
||||
heldLabel: label(heldIndex),
|
||||
requiredLabel: label(englishNeeded),
|
||||
});
|
||||
}
|
||||
|
||||
const certsNeeded = position?.certifications_required || [];
|
||||
if (certsNeeded.length) {
|
||||
const held = new Set((application?.certifications || []).map((c) => String(c).toLowerCase()));
|
||||
const missing = certsNeeded.filter((c) => !held.has(String(c).toLowerCase()));
|
||||
const heldList = certsNeeded.filter((c) => held.has(String(c).toLowerCase()));
|
||||
lines.push({
|
||||
skillId: 'certifications',
|
||||
name: 'Certifications',
|
||||
met: missing.length === 0,
|
||||
/* What they hold, not what they are missing — the requirement half of the
|
||||
line already names what is needed, and saying it twice reads as two
|
||||
different facts. */
|
||||
heldLabel: missing.length === 0
|
||||
? 'All on file'
|
||||
: heldList.length ? `Holds ${heldList.join(', ')}` : 'None on file',
|
||||
requiredLabel: certsNeeded.join(', '),
|
||||
});
|
||||
}
|
||||
|
||||
if (!lines.length) return null;
|
||||
|
||||
const met = lines.filter((l) => l.met);
|
||||
return {
|
||||
score: Math.round((met.length / lines.length) * 100),
|
||||
lines,
|
||||
met,
|
||||
gaps: lines.filter((l) => !l.met),
|
||||
band: met.length === lines.length ? 'Meets stated requirements' : 'Partial requirement match',
|
||||
tone: met.length === lines.length ? 'success' : met.length ? 'warning' : 'risk',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The candidates for this position, ranked, with the reasoning attached.
|
||||
*
|
||||
* ── Where the pool comes from ────────────────────────────────────────────
|
||||
*
|
||||
* It starts from the **candidate records** — the applications the Candidates
|
||||
* page lists — and enriches each one with a worker profile *if that person has
|
||||
* one*. Never the other way round. Three concepts stay separate:
|
||||
*
|
||||
* candidate a person on the Candidates page. The identity, and the id
|
||||
* every link uses.
|
||||
* application that person's application to a role. Present or not.
|
||||
* worker profile verified skill levels, availability, training. Optional
|
||||
* supporting intelligence, held by 9 of 24 people here.
|
||||
*
|
||||
* Building the pool from worker profiles inverted that: it recommended people
|
||||
* the Candidates page had never heard of, and — worse — dropped real candidates
|
||||
* for the crime of not having a training record. A candidate is a candidate
|
||||
* whether or not the workforce system knows anything else about them.
|
||||
*
|
||||
* ── How a row is scored ──────────────────────────────────────────────────
|
||||
*
|
||||
* Two bases, and a row says which one it used, because they are not the same
|
||||
* claim:
|
||||
*
|
||||
* `verified` the skill graph, scored on training this person actually
|
||||
* completed. Timing adjusts it, since somebody who cannot start
|
||||
* is not the better answer.
|
||||
* `stated` the position's own stated requirements against what the
|
||||
* application states. Real fields, weaker evidence.
|
||||
*
|
||||
* A candidate the position gives nothing to measure against is still returned,
|
||||
* marked unscored, rather than being dropped or given an invented number.
|
||||
*/
|
||||
export function poolFor(position, {
|
||||
profiles = [], applications = [], assignments = [], courses = [], staff = [], today = new Date(),
|
||||
} = {}) {
|
||||
const appliedBy = new Map(
|
||||
applications
|
||||
.filter((a) => a.job_posting_id === position?.id)
|
||||
.map((a) => [String(a.email || '').toLowerCase(), a])
|
||||
const profileByEmail = new Map(
|
||||
profiles.map((p) => [String(p.email || '').toLowerCase(), p])
|
||||
);
|
||||
|
||||
return profiles
|
||||
.map((profile) => {
|
||||
const held = verifiedLevels(courses, profile);
|
||||
const match = matchPosition(position, held);
|
||||
if (!match) return null;
|
||||
/* One row per person, not per application: somebody who applied to three
|
||||
roles is one candidate. Their record for this position is the application
|
||||
to it when there is one, otherwise their most recent. */
|
||||
const candidates = new Map();
|
||||
for (const application of applications) {
|
||||
const key = String(application.email || '').toLowerCase() || application.id;
|
||||
const held = candidates.get(key);
|
||||
const isForThisRole = application.job_posting_id === position?.id;
|
||||
const heldIsForThisRole = held?.job_posting_id === position?.id;
|
||||
|
||||
const availability = availabilityOf(profile, position, { assignments, today });
|
||||
const duration = durationFitOf(profile, position, { today });
|
||||
const application = appliedBy.get(String(profile.email || '').toLowerCase()) || null;
|
||||
if (!held
|
||||
|| (isForThisRole && !heldIsForThisRole)
|
||||
|| (isForThisRole === heldIsForThisRole
|
||||
&& new Date(application.created_date) > new Date(held.created_date))) {
|
||||
candidates.set(key, application);
|
||||
}
|
||||
}
|
||||
|
||||
/* Timing multiplies the skill score rather than replacing it: the skill
|
||||
match is still the thing being measured, and the reader can see both
|
||||
numbers. A person who cannot start loses most of the score but keeps a
|
||||
visible one, so "strong but unavailable" stays legible. */
|
||||
const timingFactor = (availability.available ? 1 : 0.25) * (0.6 + 0.4 * duration.ratio);
|
||||
const score = Math.round(match.score * timingFactor);
|
||||
return [...candidates.values()]
|
||||
.map((candidate) => {
|
||||
const email = String(candidate.email || '').toLowerCase();
|
||||
const profile = profileByEmail.get(email) || null;
|
||||
const application = candidate.job_posting_id === position?.id ? candidate : null;
|
||||
|
||||
/* Verified skills where the workforce system holds them; the position's
|
||||
stated requirements where it does not. */
|
||||
const verified = profile ? matchPosition(position, verifiedLevels(courses, profile)) : null;
|
||||
const match = verified || statedRequirementMatch(position, candidate);
|
||||
const basis = verified ? 'verified' : match ? 'stated' : 'none';
|
||||
|
||||
/* Availability is a workforce fact. Without a profile it is not known,
|
||||
and "not known" is said rather than assumed either way. */
|
||||
const availability = profile
|
||||
? availabilityOf(profile, position, { assignments, today })
|
||||
: { available: true, known: false, reason: 'Availability not on file' };
|
||||
const duration = profile
|
||||
? durationFitOf(profile, position, { today })
|
||||
: { covers: true, ratio: 1, note: null };
|
||||
|
||||
/* Timing multiplies the skill score rather than replacing it — but only
|
||||
where timing is actually known. A stated-requirement score is not
|
||||
adjusted by an availability nobody has recorded. */
|
||||
const timingFactor = profile
|
||||
? (availability.available ? 1 : 0.25) * (0.6 + 0.4 * duration.ratio)
|
||||
: 1;
|
||||
const score = match ? Math.round(match.score * timingFactor) : null;
|
||||
|
||||
const reasons = [
|
||||
`${match.score}% skill match against this role's requirements`,
|
||||
availability.reason,
|
||||
match
|
||||
? basis === 'verified'
|
||||
? `${match.score}% skill match against this role's requirements`
|
||||
: `${match.score}% of this role's stated requirements met`
|
||||
: 'No requirements on this position to score against',
|
||||
profile ? availability.reason : null,
|
||||
duration.note,
|
||||
application ? `Applied ${new Date(application.created_date).toLocaleDateString()}` : null,
|
||||
].filter(Boolean);
|
||||
|
||||
return {
|
||||
/* Identity: always the candidate record. */
|
||||
candidate,
|
||||
candidateId: candidate.id,
|
||||
name: candidate.applicant_name,
|
||||
email: candidate.email,
|
||||
/* Enrichment: present only when this person has it. */
|
||||
profile,
|
||||
email: profile.email,
|
||||
name: profile.full_name,
|
||||
basis,
|
||||
scored: Boolean(match),
|
||||
match,
|
||||
skillScore: match.score,
|
||||
skillScore: match ? match.score : null,
|
||||
score,
|
||||
availability,
|
||||
duration,
|
||||
application,
|
||||
applied: Boolean(application),
|
||||
/* Ready to be put forward: qualified enough, and actually free. */
|
||||
strong: match.score >= 75 && availability.available,
|
||||
/* Only verified evidence earns "strong". A stated-requirement match is
|
||||
real information, but it is not proof the person can do the work. */
|
||||
strong: basis === 'verified' && match.score >= 75 && availability.available,
|
||||
reasons,
|
||||
recommendation: recommendTraining(position, profile, courses),
|
||||
recommendation: profile ? recommendTraining(position, profile, courses) : null,
|
||||
};
|
||||
})
|
||||
.filter(Boolean)
|
||||
/**
|
||||
* Equally qualified people are common and the score says so honestly —
|
||||
* everyone who meets every requirement scores the same. Rather than
|
||||
* manufacture variance to break the tie, the order falls through to things
|
||||
* that are also true and also matter: who can cover more of the run, who
|
||||
* has done this longer, and who is more reliable on the record.
|
||||
* Verified matches first, then stated ones, then candidates with nothing to
|
||||
* score — so the strongest evidence leads and no real candidate is buried
|
||||
* under an ordering rule they had no way to satisfy. Ties fall through to
|
||||
* things that are also true: who can cover more of the run, and who has
|
||||
* done this longer.
|
||||
*/
|
||||
.sort((a, b) =>
|
||||
b.score - a.score
|
||||
|| b.duration.ratio - a.duration.ratio
|
||||
|| (b.profile.experience_years || 0) - (a.profile.experience_years || 0)
|
||||
|| (b.profile.reliability_score || 0) - (a.profile.reliability_score || 0)
|
||||
|| String(a.name).localeCompare(String(b.name)));
|
||||
.sort((a, b) => {
|
||||
const rank = (row) => (row.basis === 'verified' ? 0 : row.basis === 'stated' ? 1 : 2);
|
||||
return rank(a) - rank(b)
|
||||
|| (b.score ?? -1) - (a.score ?? -1)
|
||||
|| b.duration.ratio - a.duration.ratio
|
||||
|| (b.candidate.years_experience || 0) - (a.candidate.years_experience || 0)
|
||||
|| String(a.name).localeCompare(String(b.name));
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -269,18 +409,11 @@ export function workforceStatusFor(position, context = {}) {
|
||||
const today = context.today || new Date();
|
||||
|
||||
/**
|
||||
* Applicants are counted from the application records, not from the matched
|
||||
* pool.
|
||||
* Applicants are counted from the application records for *this* position;
|
||||
* the pool is every candidate, whichever role they applied to.
|
||||
*
|
||||
* These are two different populations and conflating them under-reports the
|
||||
* real one. Matching needs a worker profile — that is where verified skill
|
||||
* levels live — but most applicants are external people who have applied
|
||||
* without one. Counting "applied" off the pool would report a position with
|
||||
* four applications as having none, because none of the four happened to
|
||||
* carry a profile.
|
||||
*
|
||||
* So: applications answer "who applied", the pool answers "who can do the
|
||||
* work", and each row is paired with its pool entry when one exists.
|
||||
* Two different questions — "who applied here" and "who could do this work" —
|
||||
* so each row is paired with its pool entry rather than the two being merged.
|
||||
*/
|
||||
const byEmail = new Map(pool.map((row) => [String(row.email || '').toLowerCase(), row]));
|
||||
|
||||
@@ -303,8 +436,15 @@ export function workforceStatusFor(position, context = {}) {
|
||||
position,
|
||||
demand,
|
||||
pool,
|
||||
/* Existing workforce free for this work and not already on it. */
|
||||
existingAvailable: pool.filter((p) => p.availability.available && !p.applied),
|
||||
/* Free for this work and not already on it. Availability is a workforce
|
||||
fact, so this counts only people it is actually known for — a candidate
|
||||
with no workforce record is not reported as free. */
|
||||
existingAvailable: pool.filter(
|
||||
(p) => p.availability.known !== false && p.availability.available && !p.applied
|
||||
),
|
||||
/* Candidates whose score rests on completed training rather than on what
|
||||
their application states. Reported so a surface can say which. */
|
||||
verified: pool.filter((p) => p.basis === 'verified'),
|
||||
applicants,
|
||||
newToday,
|
||||
strong,
|
||||
|
||||
@@ -89,6 +89,7 @@ export default function CreatePosition({ prefill: prefillProp, embedded = false,
|
||||
const payload = {
|
||||
...form,
|
||||
status,
|
||||
company: String(form.company || '').trim(),
|
||||
pay_range_min: Number(form.pay_range_min) || 0,
|
||||
pay_range_max: Number(form.pay_range_max) || 0,
|
||||
...(aiResult ? { description: aiResult.description, responsibilities: aiResult.responsibilities, qualifications: aiResult.qualifications, nice_to_haves: aiResult.nice_to_haves, ai_generated: true } : {}),
|
||||
@@ -154,6 +155,20 @@ export default function CreatePosition({ prefill: prefillProp, embedded = false,
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* The client this role is being staffed for. Stored on the position
|
||||
record (`company`), which is what every surface reads to show who the
|
||||
hire is actually for — the field existed in the model and had no way
|
||||
in from this form until now. */}
|
||||
<div>
|
||||
<label className="text-[13px] font-medium text-[#374151]">Client / Company</label>
|
||||
<input
|
||||
value={form.company}
|
||||
onChange={(e) => update('company', e.target.value)}
|
||||
placeholder="e.g. Fairmont San Jose"
|
||||
className="w-full mt-1.5 h-10 rounded-lg border border-[#E5E7EB] bg-white px-3 text-[13px] focus:border-[#0838E0] focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Add new category */}
|
||||
<div className="flex gap-2">
|
||||
<input value={newCategory} onChange={(e) => setNewCategory(e.target.value)} placeholder="Create a new role category (saved for reuse)..." className="flex-1 h-10 rounded-lg border border-[#E5E7EB] px-3 text-[13px] focus:border-[#0838E0] focus:outline-none" />
|
||||
@@ -180,18 +195,31 @@ export default function CreatePosition({ prefill: prefillProp, embedded = false,
|
||||
<textarea value={form.custom_requirements} onChange={(e) => update('custom_requirements', e.target.value)} placeholder="e.g. Must have fine dining experience..." className="w-full mt-1.5 min-h-[70px] p-3 text-[13px] rounded-lg border border-[#E5E7EB] focus:border-[#0838E0] focus:outline-none resize-none" />
|
||||
</div>
|
||||
|
||||
{/* Physical / Leadership / Attendance Expectations */}
|
||||
{/* Physical / Leadership / Attendance Expectations.
|
||||
All three are role-specific and optional, and the labels say so: a
|
||||
blank one is not shown on the position at all. The placeholder used
|
||||
to read "Lift 50 lbs, stand 8 hrs", which invited every role to be
|
||||
given a warehouse requirement whether or not the work has one. */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div>
|
||||
<label className="text-[13px] font-medium text-[#374151]">Physical Requirements</label>
|
||||
<input value={form.physical_requirements} onChange={(e) => update('physical_requirements', e.target.value)} placeholder="e.g. Lift 50 lbs, stand 8 hrs" className="w-full mt-1.5 h-10 rounded-lg border border-[#E5E7EB] bg-white px-3 text-[13px] focus:border-[#0838E0] focus:outline-none" />
|
||||
<label className="text-[13px] font-medium text-[#374151]">
|
||||
Physical Requirements <span className="font-normal text-[#9CA3AF]">— optional</span>
|
||||
</label>
|
||||
<p className="mt-0.5 text-[11px] text-[#9CA3AF]">Only if this role genuinely has them.</p>
|
||||
<input value={form.physical_requirements} onChange={(e) => update('physical_requirements', e.target.value)} placeholder="e.g. Extended standing, lifting or climbing" className="w-full mt-1.5 h-10 rounded-lg border border-[#E5E7EB] bg-white px-3 text-[13px] focus:border-[#0838E0] focus:outline-none" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-[13px] font-medium text-[#374151]">Leadership Expectations</label>
|
||||
<label className="text-[13px] font-medium text-[#374151]">
|
||||
Leadership Expectations <span className="font-normal text-[#9CA3AF]">— optional</span>
|
||||
</label>
|
||||
<p className="mt-0.5 text-[11px] text-[#9CA3AF]">Only if this role leads people.</p>
|
||||
<input value={form.leadership_expectations} onChange={(e) => update('leadership_expectations', e.target.value)} placeholder="e.g. Lead a team of 5" className="w-full mt-1.5 h-10 rounded-lg border border-[#E5E7EB] bg-white px-3 text-[13px] focus:border-[#0838E0] focus:outline-none" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-[13px] font-medium text-[#374151]">Attendance Expectations</label>
|
||||
<label className="text-[13px] font-medium text-[#374151]">
|
||||
Attendance Expectations <span className="font-normal text-[#9CA3AF]">— optional</span>
|
||||
</label>
|
||||
<p className="mt-0.5 text-[11px] text-[#9CA3AF]">Shift pattern or availability, if it matters.</p>
|
||||
<input value={form.attendance_expectations} onChange={(e) => update('attendance_expectations', e.target.value)} placeholder="e.g. On-time, no weekends off" className="w-full mt-1.5 h-10 rounded-lg border border-[#E5E7EB] bg-white px-3 text-[13px] focus:border-[#0838E0] focus:outline-none" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import { useJobPosting, useApplications, useScreenAllCandidates, useUpdateJobPosting, useHireCandidate, useUpdateApplication, useCreateApplication, useWorkerProfiles, useCourses, useAssignments, useStaff } from '@/lib/krowHooks';
|
||||
import { demandLabel } from '@/lib/positionModel';
|
||||
import {
|
||||
customRequirementsText, demandLabel, hasRequirements, payLabel,
|
||||
} from '@/lib/positionModel';
|
||||
import {
|
||||
PositionCustomRequirements, PositionOverview, PositionRequirements,
|
||||
} from '@/components/krow/PositionDetails';
|
||||
import { workforceStatusFor } from '@/lib/workforce';
|
||||
import { MatchedCandidates } from '@/components/krow/MatchedCandidates';
|
||||
import { base44 } from '@/api/base44Client';
|
||||
@@ -283,12 +288,15 @@ export default function PositionDetail() {
|
||||
<div className="bg-white border border-[#E5E7EB] rounded-2xl p-6">
|
||||
<div className="flex items-start justify-between gap-4 flex-wrap">
|
||||
<div className="flex-1 min-w-0">
|
||||
{/* Company leads: on a workforce page the client asking for the
|
||||
people is as much the identity of the role as its title — above
|
||||
it rather than fused into it, so the stored job title is always
|
||||
read as the title. */}
|
||||
{job.company && (
|
||||
<p className="text-[11px] font-semibold uppercase tracking-wide text-[#0838E0]">{job.company}</p>
|
||||
)}
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
{/* Company leads: on a workforce page the client asking for the
|
||||
people is as much the identity of the role as its title. */}
|
||||
<h1 className="text-[22px] font-bold text-[#111827]">
|
||||
{job.company ? `${job.company} — ${job.title}` : job.title}
|
||||
</h1>
|
||||
<h1 className="text-[22px] font-bold text-[#111827]">{job.title}</h1>
|
||||
{job.priority && job.priority !== 'normal' && (
|
||||
<span className={cn(
|
||||
'px-2 py-0.5 rounded-full text-[11px] font-bold capitalize',
|
||||
@@ -308,7 +316,7 @@ export default function PositionDetail() {
|
||||
<div className="flex items-center gap-4 text-[13px] text-[#6B7280] flex-wrap">
|
||||
<span className="capitalize">{job.role_category}</span>
|
||||
{job.location && <span>{job.location}</span>}
|
||||
{job.pay_range_min != null && <span className="font-medium text-[#111827]">${job.pay_range_min}–{job.pay_range_max}/hr</span>}
|
||||
{payLabel(job) && <span className="font-medium text-[#111827]">{payLabel(job)}</span>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -378,6 +386,30 @@ export default function PositionDetail() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Everything Create Position collected about the role itself. The same
|
||||
two components the Positions drawer renders, so the page and the panel
|
||||
state the record identically. */}
|
||||
<div className="bg-white border border-[#E5E7EB] rounded-2xl p-6 space-y-5">
|
||||
<div>
|
||||
<h2 className="text-[15px] font-bold text-[#111827]">Position details</h2>
|
||||
<p className="text-[12px] text-[#6B7280] mt-0.5">The role as it was defined when this position was created.</p>
|
||||
</div>
|
||||
<PositionOverview position={job} />
|
||||
{/* Role-specific, so present only when this role states them. */}
|
||||
{hasRequirements(job) && (
|
||||
<div>
|
||||
<h3 className="text-[11px] font-bold uppercase tracking-wide text-[#9CA3AF] mb-2">Requirements</h3>
|
||||
<PositionRequirements position={job} />
|
||||
</div>
|
||||
)}
|
||||
{customRequirementsText(job) && (
|
||||
<div>
|
||||
<h3 className="text-[11px] font-bold uppercase tracking-wide text-[#9CA3AF] mb-2">Custom requirements</h3>
|
||||
<PositionCustomRequirements position={job} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<WorkforceStatus workforce={workforce} job={job} />
|
||||
|
||||
{/* Which people can actually do this work, scored against the position's
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
72
src/pages/admin/NavRow.jsx
Normal file
72
src/pages/admin/NavRow.jsx
Normal file
@@ -0,0 +1,72 @@
|
||||
import React from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { ChevronRight } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Surface } from '@/components/ds';
|
||||
import { SectionTitle } from '@/pages/admin/_shell';
|
||||
|
||||
/**
|
||||
* The entry-point row, and the group it lives in.
|
||||
*
|
||||
* Settings and Workspace & Skills are two different subjects, but they are the
|
||||
* same *kind* of page: a short list of destinations, each saying what it manages
|
||||
* and how much of it there is. Sharing the furniture is what stops the two from
|
||||
* drifting into different-looking navigation for the same job — and it is the
|
||||
* reason a row added to either one needs no styling decisions.
|
||||
*
|
||||
* `meta` is always a real figure counted from real records. A row with nothing
|
||||
* to count simply omits it rather than showing a zero that means "not measured".
|
||||
*/
|
||||
|
||||
/** One destination. The whole row is the target, so there is no small link. */
|
||||
export function NavRow({ icon: Icon, title, description, meta = null, to, action = null }) {
|
||||
return (
|
||||
<Link
|
||||
to={to}
|
||||
className="group flex items-center gap-3.5 px-4 py-3.5 transition-colors hover:bg-surface-subtle
|
||||
focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-krow-blue/50"
|
||||
>
|
||||
<span className="grid h-9 w-9 shrink-0 place-items-center rounded-xl bg-krow-blue-tint text-krow-blue">
|
||||
<Icon className="h-4 w-4" aria-hidden="true" />
|
||||
</span>
|
||||
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block text-body-sm font-semibold text-ink-1">{title}</span>
|
||||
<span className="block text-caption leading-relaxed text-ink-3">{description}</span>
|
||||
{/* Below `sm` the count sits under the description rather than being
|
||||
dropped: on a phone it is the same fact, just in the only place
|
||||
there is room for it. */}
|
||||
{meta && <span className="mt-0.5 block text-caption tabular-nums text-ink-4 sm:hidden">{meta}</span>}
|
||||
</span>
|
||||
|
||||
{meta && <span className="hidden shrink-0 text-caption tabular-nums text-ink-4 sm:block">{meta}</span>}
|
||||
|
||||
{action && (
|
||||
<span className="hidden shrink-0 text-caption font-semibold text-krow-blue md:block">{action}</span>
|
||||
)}
|
||||
|
||||
<ChevronRight
|
||||
className="h-4 w-4 shrink-0 text-ink-4 transition-transform duration-base group-hover:translate-x-0.5"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
/** One group of rows. Same container and spacing in every group, on both pages. */
|
||||
export function NavGroup({ id, title, children, className }) {
|
||||
return (
|
||||
<section aria-labelledby={id} className={cn('space-y-3', className)}>
|
||||
<SectionTitle id={id} title={title} />
|
||||
<Surface
|
||||
variant="solid"
|
||||
radius="lg"
|
||||
padding="none"
|
||||
elevation="xs"
|
||||
className="divide-y divide-border overflow-hidden"
|
||||
>
|
||||
{children}
|
||||
</Surface>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -12,6 +12,12 @@ import {
|
||||
useApplications, useAssignments, useCourses, useJobPostings, useStaff, useWorkerProfiles,
|
||||
} from '@/lib/krowHooks';
|
||||
import { workforceStatusFor } from '@/lib/workforce';
|
||||
import {
|
||||
customRequirementsText, englishLabel, hasRequirements, roleMetaLine,
|
||||
} from '@/lib/positionModel';
|
||||
import {
|
||||
PositionCustomRequirements, PositionOverview, PositionRequirements,
|
||||
} from '@/components/krow/PositionDetails';
|
||||
import { AdminPage, SectionTitle } from '@/pages/admin/_shell';
|
||||
import { RoleGlyph } from '@/pages/admin/RoleGlyph';
|
||||
import { buildPosition, payLabel } from '@/pages/admin/positionInsights';
|
||||
@@ -324,6 +330,9 @@ function PositionsTable({ rows, onOpen }) {
|
||||
className="cursor-pointer border-b border-border last:border-0 transition-colors hover:bg-surface-subtle"
|
||||
>
|
||||
<td className="px-3 py-2.5">
|
||||
{p.company && (
|
||||
<p className="text-[10px] font-semibold uppercase tracking-wide text-krow-blue">{p.company}</p>
|
||||
)}
|
||||
<p className="font-medium text-ink-1">{p.title}</p>
|
||||
<p className="text-caption text-ink-4">
|
||||
{p.role_category}
|
||||
@@ -375,6 +384,9 @@ function PositionsTable({ rows, onOpen }) {
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
{p.company && (
|
||||
<p className="truncate text-[10px] font-semibold uppercase tracking-wide text-krow-blue">{p.company}</p>
|
||||
)}
|
||||
<p className="truncate font-medium text-ink-1">{p.title}</p>
|
||||
<p className="truncate text-caption text-ink-4">
|
||||
{p.role_category}
|
||||
@@ -399,6 +411,23 @@ function PositionsTable({ rows, onOpen }) {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* "4 yrs experience · Fluent English" — the two candidate criteria, or nothing.
|
||||
*
|
||||
* Abbreviated deliberately: this is a supporting line under the terms, not a
|
||||
* field list. A position that states neither returns null and the line is not
|
||||
* drawn, rather than a card carrying a label with no answer.
|
||||
*/
|
||||
function candidateCriteria(position) {
|
||||
const years = Number(position.min_experience_years);
|
||||
const english = englishLabel(position.english_required);
|
||||
|
||||
return [
|
||||
Number.isFinite(years) && years > 0 ? `${years} yrs experience` : null,
|
||||
english ? `${english} English` : null,
|
||||
].filter(Boolean).join(' · ') || null;
|
||||
}
|
||||
|
||||
/** One position. The whole card is the target, so there is no hunting for a link. */
|
||||
function PositionCard({ position, onOpen }) {
|
||||
const p = position;
|
||||
@@ -424,7 +453,16 @@ function PositionCard({ position, onOpen }) {
|
||||
<div className="flex items-start gap-3">
|
||||
<RoleGlyph category={p.role_category} title={p.title} aiGenerated={p.ai_generated} />
|
||||
|
||||
{/* Client above title: on a staffing console the company a role is being
|
||||
filled for is part of its identity, and it is read from the position
|
||||
record — a position with no client stated simply leads with its
|
||||
title, as it always did. */}
|
||||
<div className="min-w-0 flex-1">
|
||||
{p.company && (
|
||||
<p className="truncate text-[11px] font-semibold uppercase tracking-wide text-krow-blue">
|
||||
{p.company}
|
||||
</p>
|
||||
)}
|
||||
<h3 className="truncate font-heading text-body font-semibold leading-snug text-ink-1">
|
||||
{p.title}
|
||||
</h3>
|
||||
@@ -448,6 +486,14 @@ function PositionCard({ position, onOpen }) {
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
{/* One quiet line of candidate criteria — the two that help decide whether
|
||||
a role is worth opening. Everything else the position states belongs in
|
||||
the detail view; a card that lists requirements stops being scannable,
|
||||
which is the only thing a card is for. */}
|
||||
{candidateCriteria(p) && (
|
||||
<p className="mt-1.5 truncate text-[11px] text-ink-4">{candidateCriteria(p)}</p>
|
||||
)}
|
||||
|
||||
<div className="my-3 h-px bg-border" />
|
||||
|
||||
<Progression steps={p.progression} />
|
||||
@@ -480,7 +526,7 @@ function PositionCard({ position, onOpen }) {
|
||||
}
|
||||
|
||||
/** The full record, over the grid rather than instead of it. */
|
||||
function PositionDrawer({ position, onClose, onViewCandidates }) {
|
||||
function PositionDrawer({ position, onClose, onViewCandidates, onOpenPosition }) {
|
||||
if (!position) return null;
|
||||
const p = position;
|
||||
const s = p.stats;
|
||||
@@ -511,12 +557,22 @@ function PositionDrawer({ position, onClose, onViewCandidates }) {
|
||||
<Drawer
|
||||
open={Boolean(p)}
|
||||
onOpenChange={(open) => !open && onClose()}
|
||||
title={p.title}
|
||||
description={`${p.role_category || 'Uncategorized'} · ${p.location || 'Location not set'} · ${payLabel(p)}`}
|
||||
size="lg"
|
||||
/* Client → job title → terms. The company leads because it is who the
|
||||
hire is for; it never replaces the stored job title. */
|
||||
title={p.company ? (
|
||||
<span className="block">
|
||||
<span className="block text-[11px] font-semibold uppercase tracking-wide text-krow-blue">{p.company}</span>
|
||||
{p.title}
|
||||
</span>
|
||||
) : p.title}
|
||||
description={roleMetaLine(p) || 'No terms set on this position yet'}
|
||||
size="xl"
|
||||
footer={
|
||||
<>
|
||||
<Button variant="outline" size="sm" onClick={onClose}>Close</Button>
|
||||
{/* The drawer holds the record; the page holds the record plus every
|
||||
applicant on it. This is the way through to the second. */}
|
||||
<Button variant="outline" size="sm" onClick={() => onOpenPosition(p)}>Open position</Button>
|
||||
<Button size="sm" onClick={() => onViewCandidates(p)}>
|
||||
View {s.applied} candidate{s.applied === 1 ? '' : 's'}
|
||||
</Button>
|
||||
@@ -539,6 +595,31 @@ function PositionDrawer({ position, onClose, onViewCandidates }) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* The position as it was specified. Every field Create Position asks
|
||||
for is read back here — the three sections below mirror the three
|
||||
groups of that form, in the same order. */}
|
||||
<section>
|
||||
<SectionTitle title="Position details" />
|
||||
<PositionOverview position={p} className="mt-2" />
|
||||
</section>
|
||||
|
||||
{/* Only what this role actually asks for. A position that states no
|
||||
requirements has no Requirements section — an empty heading is a
|
||||
demand nobody made. */}
|
||||
{hasRequirements(p) && (
|
||||
<section>
|
||||
<SectionTitle title="Requirements" />
|
||||
<PositionRequirements position={p} className="mt-2" />
|
||||
</section>
|
||||
)}
|
||||
|
||||
{customRequirementsText(p) && (
|
||||
<section>
|
||||
<SectionTitle title="Custom requirements" />
|
||||
<PositionCustomRequirements position={p} className="mt-2" />
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Hiring status */}
|
||||
<section>
|
||||
<SectionTitle title="Hiring status" />
|
||||
@@ -579,6 +660,9 @@ function PositionDrawer({ position, onClose, onViewCandidates }) {
|
||||
<Progression steps={p.progression} />
|
||||
</section>
|
||||
|
||||
{/* Two short lists side by side. The extra drawer width is spent on
|
||||
information density rather than on longer lines of the same stack. */}
|
||||
<div className="grid gap-5 lg:grid-cols-2">
|
||||
{/* Top candidates */}
|
||||
<section>
|
||||
<SectionTitle
|
||||
@@ -626,6 +710,7 @@ function PositionDrawer({ position, onClose, onViewCandidates }) {
|
||||
))}
|
||||
</ol>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<section>
|
||||
<SectionTitle title="Recent activity" />
|
||||
@@ -718,7 +803,7 @@ export default function AdminPositions() {
|
||||
const q = search.trim().toLowerCase();
|
||||
return rows
|
||||
.filter((p) => {
|
||||
if (q && !`${p.title} ${p.role_category} ${p.location}`.toLowerCase().includes(q)) return false;
|
||||
if (q && !`${p.title} ${p.company || ''} ${p.role_category} ${p.location}`.toLowerCase().includes(q)) return false;
|
||||
if (status !== 'all' && p.status !== status) return false;
|
||||
if (department !== 'all' && p.role_category !== department) return false;
|
||||
if (location !== 'all' && p.location !== location) return false;
|
||||
@@ -851,6 +936,7 @@ export default function AdminPositions() {
|
||||
<PositionDrawer
|
||||
position={selected}
|
||||
onClose={() => setSelected(null)}
|
||||
onOpenPosition={(p) => navigate(`/admin/positions/${p.id}`)}
|
||||
onViewCandidates={(p) => {
|
||||
setSelected(null);
|
||||
if (p.stats.applied) navigate(`/admin/candidates?search=${encodeURIComponent(p.title)}`);
|
||||
|
||||
@@ -1,42 +1,26 @@
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
Building2, Check, KeyRound, LogOut, Mail, Pencil, Shield, ShieldCheck, Smartphone,
|
||||
Building2, Check, KeyRound, LogOut, Pencil, Settings as SettingsIcon, Shield,
|
||||
ShieldCheck, Smartphone, CheckCircle2, Lock, Eye, Sparkles, Clock, Laptop,
|
||||
Activity, Zap, AlertCircle, Key
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
Avatar, Badge, Button, Field, Input, Modal, Surface, Toggle, toast,
|
||||
import { Button, Field, Input, Modal, Toggle, toast,
|
||||
} from '@/components/ds';
|
||||
import { base44 } from '@/api/base44Client';
|
||||
import { useAssistantPanel } from '@/components/ai-assistant';
|
||||
import {
|
||||
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 { useCurrentUser, useUpdateProfile, useUserActivity } from '@/lib/krowHooks';
|
||||
import { AdminPage } from '@/pages/admin/_shell';
|
||||
import { endAdminSession } from '@/pages/admin/session';
|
||||
import { LEVEL_TONE, PERMISSIONS } from '@/pages/admin/permissions';
|
||||
import { allSkills, contextIdsForSkill } from '@/lib/skills/registry';
|
||||
import { AddSkillDialog } from '@/components/skills/AddSkillDialog';
|
||||
import { ASSISTANT_CONTEXTS } from '@/components/ai-assistant/contexts';
|
||||
import { PERMISSIONS } from '@/pages/admin/permissions';
|
||||
|
||||
/**
|
||||
* Admin Profile — an enterprise account page.
|
||||
*
|
||||
* Two columns rather than one long form: identity and permissions are reference
|
||||
* information you read, while security and activity are things you act on. A
|
||||
* single stack would bury the actions below the reading.
|
||||
* Admin Profile — Premium enterprise account & security control center.
|
||||
*/
|
||||
|
||||
export default function AdminProfile() {
|
||||
const navigate = useNavigate();
|
||||
const { data: user } = useCurrentUser();
|
||||
const updateProfile = useUpdateProfile();
|
||||
const preferences = usePreferences();
|
||||
const updatePreferences = useUpdatePreferences();
|
||||
const panel = useAssistantPanel();
|
||||
|
||||
const [editOpen, setEditOpen] = useState(false);
|
||||
const [passwordOpen, setPasswordOpen] = useState(false);
|
||||
@@ -46,15 +30,14 @@ export default function AdminProfile() {
|
||||
|
||||
const saving = updateProfile.isPending;
|
||||
|
||||
/* Stamped once on mount rather than read during render, so the "signed in"
|
||||
time stays put instead of ticking forward on every rerender. */
|
||||
/* Stamped once on mount */
|
||||
const [sessionStarted] = useState(() =>
|
||||
new Date().toLocaleString(undefined, {
|
||||
weekday: 'short', month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit',
|
||||
})
|
||||
);
|
||||
|
||||
/** Enough to recognise the device without fingerprinting it. */
|
||||
/** Recognise device without fingerprinting */
|
||||
const deviceLabel = useMemo(() => {
|
||||
const ua = navigator.userAgent;
|
||||
const os = /Mac/i.test(ua) ? 'macOS' : /Windows/i.test(ua) ? 'Windows'
|
||||
@@ -66,8 +49,6 @@ export default function AdminProfile() {
|
||||
|
||||
const { data: activity = [] } = useUserActivity();
|
||||
|
||||
/* The form is seeded from the record whenever the editor opens, so cancelling
|
||||
and reopening shows the saved value rather than the abandoned edit. */
|
||||
React.useEffect(() => {
|
||||
if (!editOpen || !user) return;
|
||||
setForm({ full_name: user.full_name || '', email: user.email || '' });
|
||||
@@ -99,421 +80,413 @@ 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. */
|
||||
const disabledSkills = preferences.disabledSkills || [];
|
||||
const [addSkillOpen, setAddSkillOpen] = useState(false);
|
||||
const skills = allSkills(preferences.customSkills || []);
|
||||
const toggleSkill = (id, enabled) => {
|
||||
const next = enabled
|
||||
? disabledSkills.filter((s) => s !== id)
|
||||
: [...new Set([...disabledSkills, id])];
|
||||
updatePreferences.mutate({ disabledSkills: next });
|
||||
toast.success(enabled ? 'Skill enabled' : 'Skill disabled');
|
||||
};
|
||||
|
||||
/** One writer for all three switches, so they persist the same way. */
|
||||
const setPreference = (key, value, message) => {
|
||||
updatePreferences.mutate({ [key]: value });
|
||||
toast.success(message);
|
||||
};
|
||||
const fullName = user?.full_name || 'Admin User';
|
||||
const email = user?.email || 'admin@krow.app';
|
||||
const memberSince = user
|
||||
? new Date(user.created_date).toLocaleDateString(undefined, { month: 'long', year: 'numeric' })
|
||||
: 'June 2026';
|
||||
|
||||
return (
|
||||
<AdminPage
|
||||
title="Profile"
|
||||
subtitle="Account, permissions and security."
|
||||
title="Admin Profile"
|
||||
subtitle="Manage your platform credentials, system permissions and security governance."
|
||||
actions={
|
||||
<>
|
||||
<Button variant="outline" size="sm" shape="rounded" onClick={() => setEditOpen(true)}>
|
||||
<Pencil aria-hidden="true" /> Edit profile
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive-ghost"
|
||||
size="sm"
|
||||
shape="rounded"
|
||||
onClick={() => { endAdminSession(); base44.auth.logout('/admin/login'); }}
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => navigate('/admin/settings')}
|
||||
className="inline-flex items-center gap-1.5 px-3.5 py-2 rounded-xl bg-white border border-[#E0E4EA] text-[#101820] text-[13px] font-semibold hover:bg-[#F8F8F8] transition-all shadow-xs"
|
||||
>
|
||||
<LogOut aria-hidden="true" /> Sign out
|
||||
</Button>
|
||||
</>
|
||||
<SettingsIcon className="w-4 h-4 text-[#687386]" />
|
||||
Settings
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setEditOpen(true)}
|
||||
className="inline-flex items-center gap-1.5 px-4 py-2 rounded-xl bg-[#0838E0] hover:bg-[#062BAF] text-white text-[13px] font-semibold transition-all shadow-md shadow-blue-500/20 hover:scale-[1.02]"
|
||||
>
|
||||
<Pencil className="w-4 h-4" />
|
||||
Edit Profile
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { endAdminSession(); base44.auth.logout('/admin/login'); }}
|
||||
className="inline-flex items-center gap-1.5 px-3.5 py-2 rounded-xl bg-red-50 hover:bg-red-100 text-red-600 border border-red-200/60 text-[13px] font-semibold transition-all"
|
||||
>
|
||||
<LogOut className="w-4 h-4" />
|
||||
Sign Out
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className="grid gap-5 lg:grid-cols-3">
|
||||
<div className="space-y-5 lg:col-span-2">
|
||||
{/* Identity */}
|
||||
<Surface variant="solid" radius="lg" padding="lg" elevation="xs">
|
||||
<div className="flex flex-wrap items-start gap-4">
|
||||
<Avatar name={user?.full_name || 'Admin'} size="2xl" shape="rounded" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<h2 className="font-heading text-title-lg text-ink-1">{user?.full_name || '—'}</h2>
|
||||
<p className="mt-0.5 text-body-sm text-ink-3">{user?.email}</p>
|
||||
<div className="mt-2.5 flex flex-wrap gap-1.5">
|
||||
<Badge variant="default" size="lg">Platform Administrator</Badge>
|
||||
<Badge variant="success" size="lg">
|
||||
<Check className="h-3 w-3" aria-hidden="true" /> Active
|
||||
</Badge>
|
||||
{twoFactor && <Badge variant="soft" size="lg">2FA enabled</Badge>}
|
||||
<div className="space-y-6">
|
||||
{/* ── 1. Hero Identity Banner ────────────────────────────────────────────── */}
|
||||
<div className="relative overflow-hidden rounded-3xl bg-gradient-to-r from-[#0838E0] via-[#1E40AF] to-[#0284C7] p-6 sm:p-8 text-white shadow-xl">
|
||||
{/* Subtle background ambient mesh */}
|
||||
<div className="absolute inset-0 bg-[radial-gradient(ellipse_at_top_right,_var(--tw-gradient-stops))] from-white/15 via-transparent to-transparent pointer-events-none" />
|
||||
<div className="absolute -right-16 -bottom-16 w-64 h-64 rounded-full bg-white/5 blur-3xl pointer-events-none" />
|
||||
|
||||
<div className="relative z-10 flex flex-col md:flex-row md:items-center justify-between gap-6">
|
||||
<div className="flex items-center gap-5">
|
||||
{/* Avatar with status ring */}
|
||||
<div className="relative shrink-0">
|
||||
<div className="w-20 h-20 sm:w-24 sm:h-24 rounded-2xl bg-white/20 backdrop-blur-md border-2 border-white/40 shadow-2xl flex items-center justify-center text-white text-3xl font-extrabold overflow-hidden">
|
||||
{user?.avatar_url ? (
|
||||
<img src={user.avatar_url} alt={fullName} className="w-full h-full object-cover" />
|
||||
) : (
|
||||
fullName.split(' ').map(n => n[0]).join('').slice(0, 2).toUpperCase()
|
||||
)}
|
||||
</div>
|
||||
<span className="absolute -bottom-1 -right-1 flex h-6 w-6 items-center justify-center rounded-full bg-[#16A765] text-white ring-4 ring-[#0838E0]">
|
||||
<Check className="w-3.5 h-3.5 stroke-[3]" />
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<dl className="mt-5 grid gap-x-6 gap-y-3 border-t border-border pt-4 sm:grid-cols-2">
|
||||
{[
|
||||
{ label: 'Role', value: 'Platform Administrator', icon: Shield },
|
||||
{ label: 'Department', value: 'Workforce Operations', icon: Building2 },
|
||||
{ label: 'Account status', value: 'Active · verified', icon: ShieldCheck },
|
||||
{ label: 'Member since', value: user ? new Date(user.created_date).toLocaleDateString(undefined, { month: 'long', year: 'numeric' }) : '—', icon: Mail },
|
||||
].map((row) => (
|
||||
<div key={row.label} className="flex items-start gap-2.5">
|
||||
<row.icon className="mt-0.5 h-3.5 w-3.5 shrink-0 text-ink-4" aria-hidden="true" />
|
||||
<div className="min-w-0">
|
||||
<dt className="text-[11px] font-medium uppercase tracking-wide text-ink-4">{row.label}</dt>
|
||||
<dd className="text-body-sm text-ink-1">{row.value}</dd>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
</Surface>
|
||||
|
||||
{/* Permissions */}
|
||||
<section aria-labelledby="perms" className="space-y-3">
|
||||
<SectionTitle id="perms" title="Permissions" meta={`${PERMISSIONS.length} scopes`} />
|
||||
<Surface variant="solid" radius="lg" padding="none" elevation="xs" className="divide-y divide-border overflow-hidden">
|
||||
{PERMISSIONS.map((p) => (
|
||||
<div key={p.scope} className="flex items-center gap-3 px-4 py-2.5">
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-body-sm font-medium text-ink-1">{p.scope}</p>
|
||||
<p className="truncate text-caption text-ink-3">{p.detail}</p>
|
||||
</div>
|
||||
<Badge variant={LEVEL_TONE[p.level]} size="sm">{p.level}</Badge>
|
||||
</div>
|
||||
))}
|
||||
</Surface>
|
||||
</section>
|
||||
|
||||
{/* Recent activity */}
|
||||
<section aria-labelledby="activity" className="space-y-3">
|
||||
<SectionTitle id="activity" title="Your recent activity" meta={`${myActivity.length} events`} />
|
||||
<Surface variant="solid" radius="lg" padding="none" elevation="xs" className="divide-y divide-border overflow-hidden">
|
||||
{myActivity.length ? myActivity.map((e) => (
|
||||
<div key={e.id} className="flex items-baseline justify-between gap-3 px-4 py-2.5">
|
||||
<div className="min-w-0">
|
||||
<p className="text-body-sm text-ink-1">{e.event_type.replace(/_/g, ' ')}</p>
|
||||
{e.details && <p className="truncate text-caption text-ink-3">{e.details}</p>}
|
||||
</div>
|
||||
<span className="shrink-0 text-caption text-ink-4">
|
||||
{new Date(e.created_date).toLocaleDateString(undefined, { month: 'short', day: 'numeric' })}
|
||||
{/* User details */}
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<h1 className="text-2xl sm:text-3xl font-bold tracking-tight text-white">{fullName}</h1>
|
||||
<span className="inline-flex items-center gap-1 px-2.5 py-0.5 rounded-full bg-white/20 backdrop-blur-md text-white text-[11px] font-semibold border border-white/30">
|
||||
<ShieldCheck className="w-3.5 h-3.5 text-sky-300" />
|
||||
Verified Admin
|
||||
</span>
|
||||
</div>
|
||||
)) : (
|
||||
<p className="px-4 py-8 text-center text-body-sm text-ink-3">No activity recorded for this account.</p>
|
||||
)}
|
||||
</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'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 })}
|
||||
/>
|
||||
))}
|
||||
<p className="text-[14px] text-blue-100 font-normal">{email}</p>
|
||||
|
||||
<div className="flex items-center gap-2 pt-1 flex-wrap">
|
||||
<span className="inline-flex items-center gap-1.5 px-3 py-1 rounded-lg bg-white/10 backdrop-blur-md text-white text-[12px] font-medium border border-white/20">
|
||||
<Shield className="w-3.5 h-3.5 text-yellow-300" />
|
||||
Platform Administrator
|
||||
</span>
|
||||
<span className="inline-flex items-center gap-1.5 px-3 py-1 rounded-lg bg-[#16A765]/20 backdrop-blur-md text-emerald-200 text-[12px] font-semibold border border-[#16A765]/40">
|
||||
<span className="w-2 h-2 rounded-full bg-[#16A765] animate-ping shrink-0" />
|
||||
Active · Verified
|
||||
</span>
|
||||
{twoFactor && (
|
||||
<span className="inline-flex items-center gap-1.5 px-3 py-1 rounded-lg bg-purple-500/20 backdrop-blur-md text-purple-200 text-[12px] font-medium border border-purple-400/30">
|
||||
<Zap className="w-3.5 h-3.5 text-purple-300" />
|
||||
2FA Protected
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Owliver skills */}
|
||||
<section aria-labelledby="skills" className="space-y-3">
|
||||
<SectionTitle
|
||||
id="skills"
|
||||
title="Skills"
|
||||
meta={`${skills.length} registered`}
|
||||
action={{ label: 'Add skill', onClick: () => setAddSkillOpen(true) }}
|
||||
/>
|
||||
<p className="-mt-1 text-caption text-ink-3">
|
||||
The capabilities Owliver can use across KROW. Each one is defined in a
|
||||
Markdown file and attaches itself to the pages it names.
|
||||
</p>
|
||||
<Surface variant="solid" radius="lg" padding="none" elevation="xs" className="divide-y divide-border overflow-hidden">
|
||||
{skills.map((skill) => {
|
||||
const enabled = !disabledSkills.includes(skill.id);
|
||||
const pages = contextIdsForSkill(skill)
|
||||
.map((id) => ASSISTANT_CONTEXTS[id]?.page)
|
||||
.filter(Boolean);
|
||||
|
||||
return (
|
||||
<div key={skill.id} className="flex items-start gap-4 px-4 py-3">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<p className="text-body-sm font-semibold text-ink-1">{skill.name}</p>
|
||||
{skill.custom && <Badge variant="soft" size="sm">Custom</Badge>}
|
||||
<Badge variant={enabled ? 'success' : 'neutral'} size="sm">
|
||||
{enabled ? 'Active' : 'Inactive'}
|
||||
</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">
|
||||
Surfaces on: <span className="text-ink-3">{pages.join(', ')}</span>
|
||||
</p>
|
||||
)}
|
||||
{skill.capabilities.length > 0 && (
|
||||
<ul className="mt-1.5 space-y-0.5">
|
||||
{skill.capabilities.slice(0, 3).map((c) => (
|
||||
<li key={c} className="text-[11px] leading-relaxed text-ink-4">— {c}</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
<Toggle
|
||||
checked={enabled}
|
||||
onCheckedChange={(v) => toggleSkill(skill.id, v)}
|
||||
aria-label={`${skill.name} skill`}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</Surface>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
{/* Security */}
|
||||
<section id="security" aria-labelledby="sec" className="space-y-3">
|
||||
<SectionTitle id="sec" title="Security" />
|
||||
<Surface variant="solid" radius="lg" padding="lg" elevation="xs" className="space-y-4">
|
||||
<div>
|
||||
<p className="text-[11px] font-medium uppercase tracking-wide text-ink-4">Last sign-in</p>
|
||||
<p className="text-body-sm text-ink-1">
|
||||
{new Date().toLocaleString(undefined, { month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' })}
|
||||
</p>
|
||||
<p className="text-caption text-ink-3">This device · 10.24.11.86</p>
|
||||
</div>
|
||||
|
||||
<div className="border-t border-border pt-4">
|
||||
<Toggle
|
||||
label="Two-factor authentication"
|
||||
description="Required for privileged actions"
|
||||
checked={twoFactor}
|
||||
onCheckedChange={(v) => {
|
||||
setTwoFactor(v);
|
||||
toast[v ? 'success' : 'error'](v ? '2FA enabled' : '2FA disabled');
|
||||
{/* Quick stats in Hero */}
|
||||
<div className="flex items-center gap-3 border-t md:border-t-0 md:border-l border-white/20 pt-4 md:pt-0 md:pl-6">
|
||||
<div className="bg-white/10 backdrop-blur-md rounded-2xl p-4 border border-white/15 min-w-[130px] text-center">
|
||||
<p className="text-[11px] uppercase tracking-wider text-blue-200 font-semibold">Scopes</p>
|
||||
<p className="text-2xl font-bold text-white mt-0.5">{PERMISSIONS.length}</p>
|
||||
<span className="text-[10px] text-blue-200">Full Control</span>
|
||||
</div>
|
||||
<div className="bg-white/10 backdrop-blur-md rounded-2xl p-4 border border-white/15 min-w-[130px] text-center">
|
||||
<p className="text-[11px] uppercase tracking-wider text-blue-200 font-semibold">Security Level</p>
|
||||
<p className="text-2xl font-bold text-emerald-300 mt-0.5">Tier 1</p>
|
||||
<span className="text-[10px] text-blue-200">Max Privileges</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── 2. Information Cards Grid ────────────────────────────────────────── */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<div className="bg-white rounded-2xl border border-[#E0E4EA] p-4 shadow-xs hover:shadow-md transition-all group">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-[11px] font-bold uppercase tracking-wider text-[#687386]">System Role</span>
|
||||
<div className="w-8 h-8 rounded-xl bg-blue-50 text-[#0838E0] flex items-center justify-center group-hover:scale-110 transition-transform">
|
||||
<Shield className="w-4 h-4" />
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-[15px] font-bold text-[#101820]">Platform Administrator</p>
|
||||
<p className="text-[12px] text-[#687386] mt-0.5">Full System Governance</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-2xl border border-[#E0E4EA] p-4 shadow-xs hover:shadow-md transition-all group">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-[11px] font-bold uppercase tracking-wider text-[#687386]">Department</span>
|
||||
<div className="w-8 h-8 rounded-xl bg-indigo-50 text-indigo-600 flex items-center justify-center group-hover:scale-110 transition-transform">
|
||||
<Building2 className="w-4 h-4" />
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-[15px] font-bold text-[#101820]">Workforce Operations</p>
|
||||
<p className="text-[12px] text-[#687386] mt-0.5">Enterprise Management</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-2xl border border-[#E0E4EA] p-4 shadow-xs hover:shadow-md transition-all group">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-[11px] font-bold uppercase tracking-wider text-[#687386]">Account Status</span>
|
||||
<div className="w-8 h-8 rounded-xl bg-emerald-50 text-[#16A765] flex items-center justify-center group-hover:scale-110 transition-transform">
|
||||
<ShieldCheck className="w-4 h-4" />
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-[15px] font-bold text-[#101820]">Active · Verified</p>
|
||||
<p className="text-[12px] text-[#16A765] font-medium mt-0.5">All services active</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-2xl border border-[#E0E4EA] p-4 shadow-xs hover:shadow-md transition-all group">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-[11px] font-bold uppercase tracking-wider text-[#687386]">Member Since</span>
|
||||
<div className="w-8 h-8 rounded-xl bg-violet-50 text-violet-600 flex items-center justify-center group-hover:scale-110 transition-transform">
|
||||
<Clock className="w-4 h-4" />
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-[15px] font-bold text-[#101820]">{memberSince}</p>
|
||||
<p className="text-[12px] text-[#687386] mt-0.5">Authenticated Account</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── 3. Main Split View: Left (Permissions & Activity) | Right (Security & Session) */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
{/* Left Column (2 Cols) */}
|
||||
<div className="lg:col-span-2 space-y-6">
|
||||
{/* Permissions Matrix. `id` is the anchor Settings → Users &
|
||||
permissions links to, so that row lands on this card. */}
|
||||
<div id="perms" className="bg-white rounded-2xl border border-[#E0E4EA] p-6 shadow-xs">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div>
|
||||
<h2 className="text-[16px] font-bold text-[#101820] flex items-center gap-2">
|
||||
<Key className="w-4 h-4 text-[#0838E0]" />
|
||||
Access & Scope Permissions
|
||||
</h2>
|
||||
<p className="text-[13px] text-[#687386]">Operational scopes granted to your administrative account.</p>
|
||||
</div>
|
||||
<span className="px-3 py-1 rounded-full bg-blue-50 text-[#0838E0] text-[12px] font-semibold border border-blue-200/60">
|
||||
{PERMISSIONS.length} Active Scopes
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="divide-y divide-[#E0E4EA]">
|
||||
{PERMISSIONS.map((p) => {
|
||||
const isFull = p.level === 'Full access';
|
||||
return (
|
||||
<div key={p.scope} className="py-3.5 first:pt-0 last:pb-0 flex items-center justify-between gap-4 hover:bg-[#F8F8F8] px-3 -mx-3 rounded-xl transition-colors">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="text-[14px] font-semibold text-[#101820]">{p.scope}</p>
|
||||
</div>
|
||||
<p className="text-[12px] text-[#687386] mt-0.5">{p.detail}</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3 shrink-0">
|
||||
{/* Status badge */}
|
||||
{isFull ? (
|
||||
<span className="inline-flex items-center gap-1.5 px-3 py-1 rounded-full bg-[#EAF8F0] text-[#16A765] text-[12px] font-bold border border-[#16A765]/30">
|
||||
<CheckCircle2 className="w-3.5 h-3.5" />
|
||||
Full Access
|
||||
</span>
|
||||
) : (
|
||||
<span className="inline-flex items-center gap-1.5 px-3 py-1 rounded-full bg-[#E8F0F8] text-[#0838E0] text-[12px] font-bold border border-[#0838E0]/20">
|
||||
<Eye className="w-3.5 h-3.5" />
|
||||
Read Only
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Recent Activity */}
|
||||
<div className="bg-white rounded-2xl border border-[#E0E4EA] p-6 shadow-xs">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div>
|
||||
<h2 className="text-[16px] font-bold text-[#101820] flex items-center gap-2">
|
||||
<Activity className="w-4 h-4 text-[#0838E0]" />
|
||||
Recent Account Activity
|
||||
</h2>
|
||||
<p className="text-[13px] text-[#687386]">Audit log of administrative actions performed on your profile.</p>
|
||||
</div>
|
||||
<span className="px-3 py-1 rounded-full bg-[#F8F8F8] text-[#687386] text-[12px] font-medium border border-[#E0E4EA]">
|
||||
{myActivity.length} Events Logged
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{myActivity.length ? (
|
||||
<div className="space-y-3">
|
||||
{myActivity.map((e) => (
|
||||
<div key={e.id} className="flex items-start gap-3 p-3 rounded-xl border border-[#E0E4EA] bg-[#F8F8F8]/50 hover:bg-white hover:shadow-xs transition-all">
|
||||
<div className="w-8 h-8 rounded-lg bg-blue-50 text-[#0838E0] flex items-center justify-center shrink-0 mt-0.5">
|
||||
<Sparkles className="w-4 h-4" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-[13px] font-semibold text-[#101820] capitalize">
|
||||
{e.event_type.replace(/_/g, ' ')}
|
||||
</p>
|
||||
{e.details && <p className="text-[12px] text-[#687386] truncate mt-0.5">{e.details}</p>}
|
||||
</div>
|
||||
<span className="text-[11px] font-medium text-[#8B94A5] shrink-0">
|
||||
{new Date(e.created_date).toLocaleDateString(undefined, { month: 'short', day: 'numeric' })}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-8 bg-[#F8F8F8] rounded-xl border border-dashed border-[#E0E4EA]">
|
||||
<Activity className="w-8 h-8 text-[#8B94A5] mx-auto mb-2" />
|
||||
<p className="text-[13px] text-[#687386]">No recent activity recorded for this account.</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right Column (1 Col) - Security & Session */}
|
||||
<div className="space-y-6">
|
||||
{/* Security Control Card. `id` is the anchor the account menu's
|
||||
Security item and Settings → Security both point at. */}
|
||||
<div id="security" className="bg-white rounded-2xl border border-[#E0E4EA] p-6 shadow-xs space-y-5">
|
||||
<div className="flex items-center gap-2 border-b border-[#E0E4EA] pb-3">
|
||||
<ShieldCheck className="w-5 h-5 text-[#0838E0]" />
|
||||
<h2 className="text-[16px] font-bold text-[#101820]">Security & 2FA</h2>
|
||||
</div>
|
||||
|
||||
{/* Last Sign-In */}
|
||||
<div className="p-3.5 rounded-xl bg-[#F8F8F8] border border-[#E0E4EA]">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-[11px] font-bold uppercase tracking-wider text-[#687386]">Last Sign-in</span>
|
||||
<span className="inline-flex items-center gap-1 text-[11px] font-medium text-[#16A765] bg-[#EAF8F0] px-2 py-0.5 rounded-full">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-[#16A765]" />
|
||||
Active Device
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-[14px] font-bold text-[#101820] mt-1">
|
||||
{new Date().toLocaleString(undefined, { month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' })}
|
||||
</p>
|
||||
<p className="text-[12px] text-[#687386] mt-0.5">IP: 10.24.11.86 · {deviceLabel}</p>
|
||||
</div>
|
||||
|
||||
{/* 2FA Toggle */}
|
||||
<div className="p-3.5 rounded-xl border border-[#E0E4EA] bg-white">
|
||||
<Toggle
|
||||
label="Two-Factor Authentication"
|
||||
description="Require code verification for all admin logins"
|
||||
checked={twoFactor}
|
||||
onCheckedChange={(v) => {
|
||||
setTwoFactor(v);
|
||||
toast[v ? 'success' : 'error'](v ? '2FA enabled' : '2FA disabled');
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="space-y-2.5">
|
||||
<button
|
||||
onClick={() => setPasswordOpen(true)}
|
||||
className="w-full flex items-center justify-center gap-2 px-4 py-2.5 rounded-xl border border-[#E0E4EA] bg-white hover:bg-[#F8F8F8] text-[#101820] text-[13px] font-semibold transition-all shadow-xs"
|
||||
>
|
||||
<KeyRound className="w-4 h-4 text-[#0838E0]" />
|
||||
Change Password
|
||||
</button>
|
||||
<button
|
||||
onClick={() => toast.info('Session management is active for this device.')}
|
||||
className="w-full flex items-center justify-center gap-2 px-4 py-2.5 rounded-xl border border-[#E0E4EA] bg-white hover:bg-[#F8F8F8] text-[#101820] text-[13px] font-semibold transition-all shadow-xs"
|
||||
>
|
||||
<Smartphone className="w-4 h-4 text-[#687386]" />
|
||||
Manage Active Sessions
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Password expiry note */}
|
||||
<div className="flex items-start gap-2.5 p-3 rounded-xl bg-amber-50/80 border border-amber-200/60 text-amber-900 text-[12px] leading-relaxed">
|
||||
<AlertCircle className="w-4 h-4 text-amber-600 shrink-0 mt-0.5" />
|
||||
<p>
|
||||
Password last changed <strong>42 days ago</strong>. Administrator credentials expire every 90 days.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Current Session Card */}
|
||||
<div className="bg-white rounded-2xl border border-[#E0E4EA] p-6 shadow-xs space-y-4">
|
||||
<div className="flex items-center gap-2 border-b border-[#E0E4EA] pb-3">
|
||||
<Laptop className="w-5 h-5 text-[#0838E0]" />
|
||||
<h2 className="text-[16px] font-bold text-[#101820]">Current Active Session</h2>
|
||||
</div>
|
||||
|
||||
<dl className="space-y-3 text-[13px]">
|
||||
<div className="flex justify-between items-center border-b border-[#F1F4F7] pb-2.5">
|
||||
<dt className="text-[#687386] font-medium">Session Started</dt>
|
||||
<dd className="font-semibold text-[#101820]">{sessionStarted}</dd>
|
||||
</div>
|
||||
<div className="flex justify-between items-center border-b border-[#F1F4F7] pb-2.5">
|
||||
<dt className="text-[#687386] font-medium">Session Type</dt>
|
||||
<dd className="font-semibold text-[#101820]">Browser Tab</dd>
|
||||
</div>
|
||||
<div className="flex justify-between items-center border-b border-[#F1F4F7] pb-2.5">
|
||||
<dt className="text-[#687386] font-medium">Platform / OS</dt>
|
||||
<dd className="font-semibold text-[#101820]">{deviceLabel}</dd>
|
||||
</div>
|
||||
<div className="flex justify-between items-center">
|
||||
<dt className="text-[#687386] font-medium">Token Security</dt>
|
||||
<dd className="font-semibold text-[#16A765] inline-flex items-center gap-1">
|
||||
<Lock className="w-3.5 h-3.5" /> Encrypted
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
<div className="pt-2">
|
||||
<p className="text-[11px] text-[#8B94A5] leading-relaxed bg-[#F8F8F8] p-3 rounded-xl border border-[#E0E4EA]">
|
||||
Administrator sessions expire automatically on browser close to enforce zero-trust security.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Edit Profile Modal */}
|
||||
<Modal
|
||||
open={editOpen}
|
||||
onOpenChange={setEditOpen}
|
||||
title="Edit Admin Profile"
|
||||
description="Update your display name and administrative credentials."
|
||||
icon={Pencil}
|
||||
size="sm"
|
||||
busy={saving}
|
||||
footer={
|
||||
<>
|
||||
<Button variant="outline" onClick={() => setEditOpen(false)} disabled={saving}>Cancel</Button>
|
||||
<Button onClick={save} loading={saving}>Save Changes</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<Field label="Full Name" required error={nameError}>
|
||||
<Input
|
||||
value={form.full_name}
|
||||
onChange={(e) => {
|
||||
setForm({ ...form, full_name: e.target.value });
|
||||
if (nameError) setNameError('');
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</Field>
|
||||
<Field label="Email Address" hint="Email changes require system verification.">
|
||||
<Input value={form.email} readOnly disabled />
|
||||
</Field>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
<div className="space-y-2 border-t border-border pt-4">
|
||||
<Button variant="outline" size="sm" shape="rounded" block onClick={() => setPasswordOpen(true)}>
|
||||
<KeyRound aria-hidden="true" /> Change password
|
||||
{/* Change Password Modal */}
|
||||
<Modal
|
||||
open={passwordOpen}
|
||||
onOpenChange={setPasswordOpen}
|
||||
title="Change Password"
|
||||
description="Update your administrator login password."
|
||||
icon={KeyRound}
|
||||
size="sm"
|
||||
footer={
|
||||
<>
|
||||
<Button variant="outline" onClick={() => setPasswordOpen(false)}>Cancel</Button>
|
||||
<Button onClick={() => { setPasswordOpen(false); toast.success('Password updated'); }}>
|
||||
Update Password
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
shape="rounded"
|
||||
block
|
||||
onClick={() => toast.info('Session management is disabled in the demo')}
|
||||
>
|
||||
<Smartphone aria-hidden="true" /> Manage sessions
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<p className="border-t border-border pt-4 text-caption leading-relaxed text-ink-3">
|
||||
Password last changed 42 days ago. Administrator passwords expire every 90 days.
|
||||
</p>
|
||||
</Surface>
|
||||
|
||||
{/* Session information. Separate from Security because these are facts
|
||||
about the session you are in right now, not settings you change. */}
|
||||
<SectionTitle title="This session" />
|
||||
<Surface variant="solid" radius="lg" padding="lg" elevation="xs">
|
||||
<dl className="space-y-3">
|
||||
{[
|
||||
{ label: 'Signed in', value: sessionStarted },
|
||||
{ label: 'Session type', value: 'Browser tab · ends on close' },
|
||||
{ label: 'Device', value: deviceLabel },
|
||||
{ label: 'Role in session', value: 'Platform Administrator' },
|
||||
].map((row) => (
|
||||
<div key={row.label} className="border-b border-border pb-3 last:border-0 last:pb-0">
|
||||
<dt className="text-[10px] font-medium uppercase tracking-wide text-ink-4">{row.label}</dt>
|
||||
<dd className="mt-0.5 text-body-sm text-ink-1">{row.value}</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
<p className="mt-4 border-t border-border pt-3 text-caption leading-relaxed text-ink-3">
|
||||
Administrator sessions are not persisted across browser restarts, so
|
||||
closing this tab signs you out.
|
||||
</p>
|
||||
</Surface>
|
||||
|
||||
{/* Product preferences. Only settings that change something real — a
|
||||
preferences panel of inert switches is worse than no panel. */}
|
||||
<SectionTitle title="Preferences" />
|
||||
<Surface variant="solid" radius="lg" padding="lg" elevation="xs" className="space-y-4">
|
||||
<Toggle
|
||||
label="Owliver workspace"
|
||||
description="Open the contextual panel by default on supported pages"
|
||||
checked={preferences.owliverDefault}
|
||||
onCheckedChange={(v) => {
|
||||
/* Saving the preference sets the default for future sessions;
|
||||
driving the panel applies it to the one in front of you. The
|
||||
panel keeps its own session flag, so this is the existing API
|
||||
rather than a second way to open Owliver. */
|
||||
if (v) panel.open(); else panel.close();
|
||||
setPreference(
|
||||
'owliverDefault', v,
|
||||
v ? 'Owliver opens by default' : 'Owliver stays collapsed'
|
||||
);
|
||||
}}
|
||||
/>
|
||||
|
||||
<div className="border-t border-border pt-4">
|
||||
<Toggle
|
||||
label="Compact density"
|
||||
description="Tighter row heights across Admin tables"
|
||||
checked={preferences.compactDensity}
|
||||
onCheckedChange={(v) => setPreference(
|
||||
'compactDensity', v,
|
||||
v ? 'Compact density on' : 'Standard density restored'
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="border-t border-border pt-4">
|
||||
<Toggle
|
||||
label="Email digest"
|
||||
description="A daily summary of what needs attention"
|
||||
checked={preferences.emailDigest}
|
||||
onCheckedChange={(v) => setPreference(
|
||||
'emailDigest', v,
|
||||
v ? 'Daily digest on' : 'Daily digest off'
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</Surface>
|
||||
</section>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<Field label="Current Password"><Input type="password" autoComplete="current-password" /></Field>
|
||||
<Field label="New Password" hint="At least 12 characters, with numbers and symbols.">
|
||||
<Input type="password" autoComplete="new-password" />
|
||||
</Field>
|
||||
<Field label="Confirm New Password"><Input type="password" autoComplete="new-password" /></Field>
|
||||
</div>
|
||||
</Modal>
|
||||
</div>
|
||||
|
||||
<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}
|
||||
title="Edit profile"
|
||||
description="Update your account details."
|
||||
icon={Pencil}
|
||||
size="sm"
|
||||
busy={saving}
|
||||
footer={
|
||||
<>
|
||||
<Button variant="outline" onClick={() => setEditOpen(false)} disabled={saving}>Cancel</Button>
|
||||
<Button onClick={save} loading={saving}>Save changes</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<Field label="Full name" required error={nameError}>
|
||||
<Input
|
||||
value={form.full_name}
|
||||
onChange={(e) => {
|
||||
setForm({ ...form, full_name: e.target.value });
|
||||
if (nameError) setNameError('');
|
||||
}}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Email" hint="Email changes require verification and are disabled in the demo.">
|
||||
<Input value={form.email} readOnly disabled />
|
||||
</Field>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
open={passwordOpen}
|
||||
onOpenChange={setPasswordOpen}
|
||||
title="Change password"
|
||||
description="Choose a password you do not use anywhere else."
|
||||
icon={KeyRound}
|
||||
size="sm"
|
||||
footer={
|
||||
<>
|
||||
<Button variant="outline" onClick={() => setPasswordOpen(false)}>Cancel</Button>
|
||||
<Button onClick={() => { setPasswordOpen(false); toast.success('Password updated'); }}>
|
||||
Update password
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<Field label="Current password"><Input type="password" autoComplete="current-password" /></Field>
|
||||
<Field label="New password" hint="At least 12 characters, with a number and a symbol.">
|
||||
<Input type="password" autoComplete="new-password" />
|
||||
</Field>
|
||||
<Field label="Confirm new password"><Input type="password" autoComplete="new-password" /></Field>
|
||||
</div>
|
||||
</Modal>
|
||||
</AdminPage>
|
||||
);
|
||||
}
|
||||
|
||||
306
src/pages/admin/Settings.jsx
Normal file
306
src/pages/admin/Settings.jsx
Normal file
@@ -0,0 +1,306 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import { useNavigate, Link } from 'react-router-dom';
|
||||
import {
|
||||
Activity, Bell, Building2, KeyRound, Shield, User as UserIcon, Users,
|
||||
ChevronRight, Sliders,
|
||||
ShieldCheck, Server
|
||||
} from 'lucide-react';
|
||||
import { Toggle, toast } from '@/components/ds';
|
||||
import { useAssistantPanel } from '@/components/ai-assistant';
|
||||
import {
|
||||
useJobPostings, usePreferences, useStaff, useUpdatePreferences,
|
||||
} from '@/lib/krowHooks';
|
||||
import { AdminPage } from '@/pages/admin/_shell';
|
||||
|
||||
/**
|
||||
* Admin Settings — Full-width platform configuration control center.
|
||||
*/
|
||||
|
||||
export default function AdminSettings() {
|
||||
const navigate = useNavigate();
|
||||
const preferences = usePreferences();
|
||||
const updatePreferences = useUpdatePreferences();
|
||||
const panel = useAssistantPanel();
|
||||
|
||||
const { data: postings = [] } = useJobPostings();
|
||||
const { data: staff = [] } = useStaff();
|
||||
|
||||
const clientCount = useMemo(
|
||||
() => new Set(postings.map((p) => p.company).filter(Boolean)).size,
|
||||
[postings]
|
||||
);
|
||||
|
||||
const setPreference = (key, value, message) => {
|
||||
updatePreferences.mutate({ [key]: value });
|
||||
toast.success(message);
|
||||
};
|
||||
|
||||
return (
|
||||
<AdminPage
|
||||
title="Settings"
|
||||
subtitle="Manage your account, organization and system configuration."
|
||||
>
|
||||
<div className="space-y-6 w-full">
|
||||
{/* ── 1. Hero Banner (Full Width) ───────────────────────────────── */}
|
||||
<div className="relative overflow-hidden rounded-3xl bg-gradient-to-r from-[#0838E0] via-[#1E40AF] to-[#0284C7] p-6 sm:p-8 text-white shadow-xl w-full">
|
||||
<div className="absolute inset-0 bg-[radial-gradient(ellipse_at_top_right,_var(--tw-gradient-stops))] from-white/15 via-transparent to-transparent pointer-events-none" />
|
||||
<div className="relative z-10 flex flex-col md:flex-row md:items-center justify-between gap-6">
|
||||
<div className="space-y-2">
|
||||
<div className="inline-flex items-center gap-2 px-3 py-1 rounded-full bg-white/20 backdrop-blur-md text-white text-[12px] font-semibold border border-white/30">
|
||||
<Sliders className="w-3.5 h-3.5 text-sky-300" />
|
||||
Workspace Control Center
|
||||
</div>
|
||||
<h1 className="text-2xl sm:text-3xl font-bold tracking-tight text-white">System Settings & Governance</h1>
|
||||
<p className="text-[14px] text-blue-100 font-normal max-w-2xl">
|
||||
Configure account credentials, access security policies, organizational workforce units, and system automation behavior.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3 shrink-0">
|
||||
<div className="bg-white/10 backdrop-blur-md rounded-2xl p-4 border border-white/15 min-w-[130px] text-center">
|
||||
<p className="text-[11px] uppercase tracking-wider text-blue-200 font-semibold">Active Staff</p>
|
||||
<p className="text-2xl font-bold text-white mt-0.5">{staff.length}</p>
|
||||
<span className="text-[10px] text-blue-200">On Platform</span>
|
||||
</div>
|
||||
<div className="bg-white/10 backdrop-blur-md rounded-2xl p-4 border border-white/15 min-w-[130px] text-center">
|
||||
<p className="text-[11px] uppercase tracking-wider text-blue-200 font-semibold">Positions</p>
|
||||
<p className="text-2xl font-bold text-emerald-300 mt-0.5">{postings.length}</p>
|
||||
<span className="text-[10px] text-blue-200">Active Roles</span>
|
||||
</div>
|
||||
<div className="bg-white/10 backdrop-blur-md rounded-2xl p-4 border border-white/15 min-w-[130px] text-center">
|
||||
<p className="text-[11px] uppercase tracking-wider text-blue-200 font-semibold">Clients</p>
|
||||
<p className="text-2xl font-bold text-sky-300 mt-0.5">{clientCount}</p>
|
||||
<span className="text-[10px] text-blue-200">Organizations</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── 2. Two-Column Full Width Grid ─────────────────────────────── */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6 w-full">
|
||||
{/* ── Left Column: Account & Organization ─────────────────────── */}
|
||||
<div className="space-y-6">
|
||||
{/* Account & Security Card */}
|
||||
<div className="bg-white rounded-2xl border border-[#E0E4EA] p-6 shadow-xs space-y-4">
|
||||
<div className="flex items-center justify-between border-b border-[#E0E4EA] pb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<UserIcon className="w-5 h-5 text-[#0838E0]" />
|
||||
<h2 className="text-[16px] font-bold text-[#101820]">Account & Security</h2>
|
||||
</div>
|
||||
<span className="text-[12px] font-semibold text-[#0838E0] bg-blue-50 px-2.5 py-0.5 rounded-full border border-blue-200/60">
|
||||
Personal Credentials
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<Link
|
||||
to="/admin/profile"
|
||||
className="group p-4 rounded-xl border border-[#E0E4EA] bg-white hover:bg-[#F8F8F8] hover:border-[#0838E0]/40 hover:shadow-xs transition-all flex items-start gap-3.5"
|
||||
>
|
||||
<div className="w-10 h-10 rounded-xl bg-blue-50 text-[#0838E0] flex items-center justify-center shrink-0 group-hover:scale-105 transition-transform">
|
||||
<UserIcon className="w-5 h-5" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-[14px] font-bold text-[#101820] group-hover:text-[#0838E0] transition-colors">Profile Details</p>
|
||||
<ChevronRight className="w-4 h-4 text-[#8B94A5] group-hover:text-[#0838E0] group-hover:translate-x-0.5 transition-all" />
|
||||
</div>
|
||||
<p className="text-[12px] text-[#687386] mt-1 leading-relaxed">Your name, email address, and platform identity information.</p>
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
<Link
|
||||
to="/admin/profile#security"
|
||||
className="group p-4 rounded-xl border border-[#E0E4EA] bg-white hover:bg-[#F8F8F8] hover:border-purple-300 hover:shadow-xs transition-all flex items-start gap-3.5"
|
||||
>
|
||||
<div className="w-10 h-10 rounded-xl bg-purple-50 text-purple-600 flex items-center justify-center shrink-0 group-hover:scale-105 transition-transform">
|
||||
<KeyRound className="w-5 h-5" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-[14px] font-bold text-[#101820] group-hover:text-purple-600 transition-colors">Security & 2FA</p>
|
||||
<ChevronRight className="w-4 h-4 text-[#8B94A5] group-hover:text-purple-600 group-hover:translate-x-0.5 transition-all" />
|
||||
</div>
|
||||
<p className="text-[12px] text-[#687386] mt-1 leading-relaxed">Password management, two-factor authentication and active sessions.</p>
|
||||
</div>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Organization Management Card */}
|
||||
<div className="bg-white rounded-2xl border border-[#E0E4EA] p-6 shadow-xs space-y-4">
|
||||
<div className="flex items-center justify-between border-b border-[#E0E4EA] pb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Building2 className="w-5 h-5 text-indigo-600" />
|
||||
<h2 className="text-[16px] font-bold text-[#101820]">Organization Structure</h2>
|
||||
</div>
|
||||
<span className="text-[12px] font-semibold text-indigo-600 bg-indigo-50 px-2.5 py-0.5 rounded-full border border-indigo-200/60">
|
||||
Workforce Operations
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<Link
|
||||
to="/admin/positions"
|
||||
className="group p-4 rounded-xl border border-[#E0E4EA] bg-white hover:bg-[#F8F8F8] hover:border-indigo-300 hover:shadow-xs transition-all flex items-start gap-3.5"
|
||||
>
|
||||
<div className="w-10 h-10 rounded-xl bg-indigo-50 text-indigo-600 flex items-center justify-center shrink-0 group-hover:scale-105 transition-transform">
|
||||
<Building2 className="w-5 h-5" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-[14px] font-bold text-[#101820] group-hover:text-indigo-600 transition-colors">Clients & Positions</p>
|
||||
<ChevronRight className="w-4 h-4 text-[#8B94A5] group-hover:text-indigo-600 group-hover:translate-x-0.5 transition-all" />
|
||||
</div>
|
||||
<p className="text-[12px] text-[#687386] mt-1 leading-relaxed">Companies and active positions currently being staffed.</p>
|
||||
<div className="mt-2.5">
|
||||
<span className="inline-flex items-center gap-1 text-[11px] font-semibold text-indigo-700 bg-indigo-50 px-2.5 py-0.5 rounded-md">
|
||||
{clientCount} clients · {postings.length} positions
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
<Link
|
||||
to="/admin/profile#perms"
|
||||
className="group p-4 rounded-xl border border-[#E0E4EA] bg-white hover:bg-[#F8F8F8] hover:border-emerald-300 hover:shadow-xs transition-all flex items-start gap-3.5"
|
||||
>
|
||||
<div className="w-10 h-10 rounded-xl bg-emerald-50 text-[#16A765] flex items-center justify-center shrink-0 group-hover:scale-105 transition-transform">
|
||||
<Users className="w-5 h-5" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-[14px] font-bold text-[#101820] group-hover:text-[#16A765] transition-colors">Users & Permissions</p>
|
||||
<ChevronRight className="w-4 h-4 text-[#8B94A5] group-hover:text-[#16A765] group-hover:translate-x-0.5 transition-all" />
|
||||
</div>
|
||||
<p className="text-[12px] text-[#687386] mt-1 leading-relaxed">Platform user roles and administrative permission scopes.</p>
|
||||
<div className="mt-2.5">
|
||||
<span className="inline-flex items-center gap-1 text-[11px] font-semibold text-emerald-700 bg-emerald-50 px-2.5 py-0.5 rounded-md">
|
||||
{staff.length} on staff
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Audit Log Banner */}
|
||||
<Link
|
||||
to="/admin/activity"
|
||||
className="group p-5 rounded-2xl border border-[#E0E4EA] bg-white hover:bg-[#F8F8F8] hover:border-amber-300 hover:shadow-xs transition-all flex items-center justify-between gap-4"
|
||||
>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="w-12 h-12 rounded-xl bg-amber-50 text-amber-600 flex items-center justify-center shrink-0 group-hover:scale-105 transition-transform">
|
||||
<Activity className="w-6 h-6" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-[15px] font-bold text-[#101820] group-hover:text-amber-600 transition-colors">Audit & System Activity Log</p>
|
||||
<p className="text-[12px] text-[#687386] mt-0.5">Review all platform administrative actions, login events and security logs.</p>
|
||||
</div>
|
||||
</div>
|
||||
<ChevronRight className="w-5 h-5 text-[#8B94A5] group-hover:text-amber-600 group-hover:translate-x-0.5 transition-all" />
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* ── Right Column: System Preferences & Governance ──────────────── */}
|
||||
<div className="space-y-6">
|
||||
{/* System Preferences Card */}
|
||||
<div className="bg-white rounded-2xl border border-[#E0E4EA] p-6 shadow-xs space-y-5">
|
||||
<div className="flex items-center justify-between border-b border-[#E0E4EA] pb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Sliders className="w-5 h-5 text-amber-600" />
|
||||
<h2 className="text-[16px] font-bold text-[#101820]">Workspace Preferences</h2>
|
||||
</div>
|
||||
<span className="text-[12px] font-semibold text-amber-700 bg-amber-50 px-2.5 py-0.5 rounded-full border border-amber-200/60">
|
||||
Automation Controls
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="p-5 rounded-xl border border-[#E0E4EA] bg-[#F8F8F8]/50 space-y-4">
|
||||
<div className="flex items-center gap-2 pb-1">
|
||||
<Bell className="w-4 h-4 text-[#0838E0]" />
|
||||
<p className="text-[13px] font-bold text-[#101820]">Account Behaviors & Notifications</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4 divide-y divide-[#E0E4EA] pt-1">
|
||||
<div className="pt-3 first:pt-0">
|
||||
<Toggle
|
||||
label="Owliver Workspace Assistant"
|
||||
description="Open the AI contextual panel by default on supported admin pages"
|
||||
checked={preferences.owliverDefault}
|
||||
onCheckedChange={(v) => {
|
||||
if (v) panel.open(); else panel.close();
|
||||
setPreference(
|
||||
'owliverDefault', v,
|
||||
v ? 'Owliver opens by default' : 'Owliver stays collapsed'
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="pt-3">
|
||||
<Toggle
|
||||
label="Compact UI Density"
|
||||
description="Tighter table row spacing for high-density monitoring"
|
||||
checked={preferences.compactDensity}
|
||||
onCheckedChange={(v) => setPreference(
|
||||
'compactDensity', v,
|
||||
v ? 'Compact density on' : 'Standard density restored'
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="pt-3">
|
||||
<Toggle
|
||||
label="Daily Executive Email Digest"
|
||||
description="Automated daily summary of platform applications and actions"
|
||||
checked={preferences.emailDigest}
|
||||
onCheckedChange={(v) => setPreference(
|
||||
'emailDigest', v,
|
||||
v ? 'Daily digest on' : 'Daily digest off'
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Governance & Environment Note Card */}
|
||||
<div className="bg-white rounded-2xl border border-[#E0E4EA] p-6 shadow-xs space-y-4">
|
||||
<div className="flex items-center gap-2 border-b border-[#E0E4EA] pb-3">
|
||||
<Server className="w-5 h-5 text-[#0838E0]" />
|
||||
<h2 className="text-[16px] font-bold text-[#101820]">Environment & Compliance</h2>
|
||||
</div>
|
||||
|
||||
<dl className="space-y-3 text-[13px]">
|
||||
<div className="flex justify-between items-center border-b border-[#F1F4F7] pb-2.5">
|
||||
<dt className="text-[#687386] font-medium">Environment</dt>
|
||||
<dd className="font-semibold text-[#101820] flex items-center gap-1.5">
|
||||
<span className="w-2 h-2 rounded-full bg-[#16A765]" /> Production (Live)
|
||||
</dd>
|
||||
</div>
|
||||
<div className="flex justify-between items-center border-b border-[#F1F4F7] pb-2.5">
|
||||
<dt className="text-[#687386] font-medium">Workspace Isolation</dt>
|
||||
<dd className="font-semibold text-[#101820]">Enterprise Tenant #1042</dd>
|
||||
</div>
|
||||
<div className="flex justify-between items-center">
|
||||
<dt className="text-[#687386] font-medium">Security Standard</dt>
|
||||
<dd className="font-semibold text-[#16A765] inline-flex items-center gap-1">
|
||||
<ShieldCheck className="w-3.5 h-3.5" /> SOC-2 Type II
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
<div className="pt-1">
|
||||
<div className="flex items-center gap-2 p-3.5 rounded-xl bg-blue-50/60 border border-blue-200/50 text-[#0838E0] text-[12px] font-medium leading-relaxed">
|
||||
<Shield className="w-4 h-4 shrink-0 text-[#0838E0]" />
|
||||
<span>Administrator configurations apply across this entire workspace environment.</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</AdminPage>
|
||||
);
|
||||
}
|
||||
262
src/pages/admin/SkillDevelopment.jsx
Normal file
262
src/pages/admin/SkillDevelopment.jsx
Normal file
@@ -0,0 +1,262 @@
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { ArrowRight, ChevronLeft, GraduationCap } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Badge, Button, ProgressBar, Surface } from '@/components/ds';
|
||||
import { useCourses, useCurrentUser, useWorkerProfiles } from '@/lib/krowHooks';
|
||||
import { useWorkforcePaths } from '@/lib/skills/usePageSkills';
|
||||
import { profileForEmail } from '@/lib/skillGraph';
|
||||
import { SkillProgressDetail } from '@/components/forge';
|
||||
import { AdminPage } from '@/pages/admin/_shell';
|
||||
|
||||
/**
|
||||
* Skill development — every training path, as a management list.
|
||||
*
|
||||
* This replaced a grid of tall cards. Each card carried a four-node timeline, a
|
||||
* progress block and a footer, which is a good shape for *one* skill and a poor
|
||||
* one for five: the page became three columns of mostly-identical furniture,
|
||||
* and answering "which path is anyone actually on" meant reading five separate
|
||||
* dashboards.
|
||||
*
|
||||
* A row states the same facts in the order they are asked for — what the path
|
||||
* is, where the workforce stands on it, how far through, and what is next — so
|
||||
* the five can be compared by scanning down a column rather than by reading
|
||||
* each one whole. The ladder survives as a single compact line, because the
|
||||
* distance to the top of it is worth seeing; it is just no longer a diagram.
|
||||
*
|
||||
* Nothing here derives progress. `lib/skillGraph.js` computes each state and
|
||||
* this arranges it, which is what keeps this page, the detail drawer and Forge
|
||||
* from ever disagreeing about a level.
|
||||
*/
|
||||
|
||||
/** The three states a path can be in, in the platform's own status language. */
|
||||
const STATUS = {
|
||||
completed: { label: 'Completed', variant: 'success' },
|
||||
in_progress: { label: 'In progress', variant: 'soft' },
|
||||
not_started: { label: 'Not started', variant: 'neutral' },
|
||||
};
|
||||
|
||||
/**
|
||||
* One path's headline state, from the module counts that produced it.
|
||||
*
|
||||
* Deliberately coarser than `state.status`, which distinguishes "verified" from
|
||||
* "advancing" — useful on a personal card, more than a management list needs.
|
||||
*/
|
||||
function summaryStatus(state) {
|
||||
if (state.totalModules > 0 && state.totalCompleted === state.totalModules) return 'completed';
|
||||
if (state.totalCompleted > 0) return 'in_progress';
|
||||
return 'not_started';
|
||||
}
|
||||
|
||||
/**
|
||||
* The ladder as one line of text.
|
||||
*
|
||||
* Earned rungs read as earned, the rung being worked towards is the only thing
|
||||
* highlighted, and everything above it recedes. No circles, no connectors, no
|
||||
* repetition of the same diagram five times down the page.
|
||||
*/
|
||||
function LevelTrail({ state }) {
|
||||
return (
|
||||
<p className="flex flex-wrap items-center gap-x-1 gap-y-0.5" aria-label={`${state.name} levels`}>
|
||||
{state.levels.map((rung, i) => {
|
||||
const current = !rung.earned && state.nextLevel === rung.level;
|
||||
return (
|
||||
<React.Fragment key={rung.level}>
|
||||
{i > 0 && <span className="text-[10px] text-ink-4/70" aria-hidden="true">→</span>}
|
||||
<span
|
||||
className={cn(
|
||||
'text-[10px] font-semibold uppercase tracking-wide',
|
||||
rung.earned ? 'text-success' : current ? 'text-krow-blue' : 'text-ink-4/70'
|
||||
)}
|
||||
>
|
||||
{rung.label}
|
||||
</span>
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
/** One training path. Everything a manager asks of it, on one row. */
|
||||
function PathRow({ definition, state, onOpen }) {
|
||||
const status = STATUS[summaryStatus(state)];
|
||||
const percent = state.totalModules
|
||||
? Math.round((state.totalCompleted / state.totalModules) * 100)
|
||||
: 0;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="grid gap-3 px-4 py-5 transition-colors hover:bg-surface-subtle
|
||||
md:grid-cols-[minmax(0,1.5fr)_minmax(0,1fr)_minmax(0,1fr)_auto] md:items-center md:gap-5"
|
||||
>
|
||||
{/* Identity — name, what the path is for, and the ladder it defines. */}
|
||||
<div className="flex min-w-0 items-start gap-3">
|
||||
<span className="mt-0.5 grid h-8 w-8 shrink-0 place-items-center rounded-lg bg-krow-blue-tint text-krow-blue">
|
||||
<GraduationCap className="h-4 w-4" aria-hidden="true" />
|
||||
</span>
|
||||
<div className="min-w-0">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<p className="text-body-sm font-semibold text-ink-1">{state.name}</p>
|
||||
<Badge variant={status.variant} size="sm">{status.label}</Badge>
|
||||
</div>
|
||||
{definition.description && (
|
||||
<p className="mt-0.5 line-clamp-2 text-caption leading-relaxed text-ink-3">
|
||||
{definition.description}
|
||||
</p>
|
||||
)}
|
||||
<LevelTrail state={state} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Where the workforce stands. The bar is secondary to the count beside
|
||||
it — a percentage alone does not say how much work that is. */}
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-baseline justify-between gap-2">
|
||||
<span className="text-[10px] font-semibold uppercase tracking-wide text-ink-4">Progress</span>
|
||||
<span className="text-caption font-semibold tabular-nums text-ink-1">{percent}%</span>
|
||||
</div>
|
||||
<ProgressBar
|
||||
value={percent}
|
||||
tone={percent === 100 ? 'success' : 'brand'}
|
||||
size="sm"
|
||||
className="mt-1"
|
||||
/>
|
||||
<p className="mt-1 text-caption tabular-nums text-ink-3">
|
||||
{state.totalCompleted} / {state.totalModules} modules
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Current level and the one thing to do next. */}
|
||||
<div className="min-w-0">
|
||||
<p className="text-[10px] font-semibold uppercase tracking-wide text-ink-4">Current level</p>
|
||||
<p className="text-body-sm font-medium text-ink-1">{state.verifiedLabel}</p>
|
||||
<p className="mt-1 truncate text-caption text-ink-3">
|
||||
<span className="text-ink-4">Next: </span>
|
||||
{state.nextRequirement
|
||||
? state.nextRequirement.title
|
||||
: state.nextLevel ? 'No training authored yet' : 'Nothing outstanding'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="md:justify-self-end">
|
||||
<Button variant="outline" size="sm" shape="rounded" onClick={() => onOpen({ definition, state })}>
|
||||
View <ArrowRight aria-hidden="true" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** One figure in the summary strip. */
|
||||
function SummaryStat({ label, value }) {
|
||||
return (
|
||||
<div className="min-w-0 flex-1 px-4 py-3">
|
||||
<p className="font-heading text-title font-bold tabular-nums text-ink-1">{value}</p>
|
||||
<p className="text-caption text-ink-3">{label}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function AdminSkillDevelopment() {
|
||||
const navigate = useNavigate();
|
||||
const { data: user } = useCurrentUser();
|
||||
const { data: courses = [] } = useCourses();
|
||||
const { data: workerProfiles = [] } = useWorkerProfiles();
|
||||
const { statesFor } = useWorkforcePaths();
|
||||
|
||||
/* The worker record is looked up by email rather than fetched with
|
||||
`useWorkerProfile()`, which creates one on demand: opening this page should
|
||||
not write an account into the workforce as a side effect. With no record
|
||||
every path reads "Not started", which is the true state of someone who has
|
||||
completed no training. */
|
||||
const profile = useMemo(
|
||||
() => profileForEmail(workerProfiles, user?.email),
|
||||
[workerProfiles, user?.email]
|
||||
);
|
||||
|
||||
const rows = useMemo(
|
||||
() => statesFor(courses, profile),
|
||||
[statesFor, courses, profile]
|
||||
);
|
||||
|
||||
/* Counted from the same rows the list renders, so the strip and the list can
|
||||
never report different things. */
|
||||
const summary = useMemo(() => {
|
||||
const tally = { completed: 0, in_progress: 0, not_started: 0 };
|
||||
rows.forEach(({ state }) => { tally[summaryStatus(state)] += 1; });
|
||||
return tally;
|
||||
}, [rows]);
|
||||
|
||||
const [openPath, setOpenPath] = useState(null);
|
||||
|
||||
return (
|
||||
<AdminPage
|
||||
title="Skill development"
|
||||
meta={`${rows.length} path${rows.length === 1 ? '' : 's'}`}
|
||||
subtitle="Track workforce progress across every training path. Levels are earned by completing modules and passing Owliver verification."
|
||||
actions={
|
||||
<>
|
||||
<Button variant="outline" size="sm" shape="rounded" onClick={() => navigate('/admin/workspace')}>
|
||||
<ChevronLeft aria-hidden="true" /> Workspace
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" shape="rounded" onClick={() => navigate('/admin/university')}>
|
||||
Manage training
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
{rows.length ? (
|
||||
<>
|
||||
<Surface
|
||||
variant="solid"
|
||||
radius="lg"
|
||||
padding="none"
|
||||
elevation="xs"
|
||||
className="flex divide-x divide-border overflow-hidden"
|
||||
>
|
||||
<SummaryStat label={rows.length === 1 ? 'Path' : 'Paths'} value={rows.length} />
|
||||
<SummaryStat label="In progress" value={summary.in_progress} />
|
||||
<SummaryStat label="Completed" value={summary.completed} />
|
||||
<SummaryStat label="Not started" value={summary.not_started} />
|
||||
</Surface>
|
||||
|
||||
<Surface
|
||||
variant="solid"
|
||||
radius="lg"
|
||||
padding="none"
|
||||
elevation="xs"
|
||||
className="divide-y divide-border overflow-hidden"
|
||||
>
|
||||
{rows.map(({ definition, state }) => (
|
||||
<PathRow
|
||||
key={definition.id}
|
||||
definition={definition}
|
||||
state={state}
|
||||
onOpen={setOpenPath}
|
||||
/>
|
||||
))}
|
||||
</Surface>
|
||||
</>
|
||||
) : (
|
||||
<p className="text-body-sm text-ink-3">
|
||||
No training paths are registered yet. A path is a Markdown definition that
|
||||
declares a ladder — add one from Skills and it appears here.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* The whole ladder for one path — the same drawer Forge and the account
|
||||
page open, so progression is read in the surface that authored it
|
||||
rather than in a second copy of it. */}
|
||||
<SkillProgressDetail
|
||||
state={openPath?.state}
|
||||
open={Boolean(openPath)}
|
||||
onOpenChange={(next) => !next && setOpenPath(null)}
|
||||
onStartTraining={(module) => {
|
||||
setOpenPath(null);
|
||||
navigate(`/admin/university/${module.id}`);
|
||||
}}
|
||||
/>
|
||||
</AdminPage>
|
||||
);
|
||||
}
|
||||
320
src/pages/admin/SkillEditor.jsx
Normal file
320
src/pages/admin/SkillEditor.jsx
Normal file
@@ -0,0 +1,320 @@
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
import { ChevronLeft, Upload } from 'lucide-react';
|
||||
import {
|
||||
Badge, Button, Field, Input, Surface, Switch, Textarea, toast,
|
||||
} from '@/components/ds';
|
||||
import { usePreferences, useUpdatePreferences } from '@/lib/krowHooks';
|
||||
import { PAGE_KEYS, allSkills, parseSkill, validateSkillSource } from '@/lib/skills/registry';
|
||||
import { customSkillSource, skillTemplate, upsertCustomSkill } from '@/lib/skills/customSkills';
|
||||
import { AdminPage, SectionTitle } from '@/pages/admin/_shell';
|
||||
|
||||
/**
|
||||
* Add or edit an Owliver skill, as a page.
|
||||
*
|
||||
* The dialog this replaces still exists — Owliver's own header opens it, where a
|
||||
* modal is the right shape for "add one while you are mid-conversation". This is
|
||||
* the same act with room to do it in: the fields, the surfaces and the Markdown
|
||||
* side by side rather than stacked in a 640px box.
|
||||
*
|
||||
* Both write through `upsertCustomSkill`, and what is stored is the Markdown
|
||||
* itself. There is one registry, one parser and one format; this page adds a
|
||||
* door, not a second system.
|
||||
*/
|
||||
|
||||
/** The frontmatter fields, kept in step with the Markdown until it is edited. */
|
||||
const emptyMeta = { id: '', name: '', description: '', pages: [] };
|
||||
|
||||
/** Reads the meta back out of a definition, for editing an existing skill. */
|
||||
function metaFromSource(source) {
|
||||
try {
|
||||
const skill = parseSkill(source, { custom: true });
|
||||
return {
|
||||
id: skill.id, name: skill.name, description: skill.description, pages: skill.pages,
|
||||
};
|
||||
} catch {
|
||||
return emptyMeta;
|
||||
}
|
||||
}
|
||||
|
||||
export default function SkillEditor() {
|
||||
const navigate = useNavigate();
|
||||
/* `new` is the add route; anything else is a skill id being edited. */
|
||||
const { id: routeId } = useParams();
|
||||
const editingId = routeId && routeId !== 'new' ? routeId : null;
|
||||
|
||||
const preferences = usePreferences();
|
||||
const updatePreferences = useUpdatePreferences();
|
||||
const customSkills = preferences.customSkills || [];
|
||||
|
||||
/**
|
||||
* The definition being worked on.
|
||||
*
|
||||
* Editing an account skill loads its stored Markdown. Editing one that ships
|
||||
* with KROW loads that file's Markdown as a starting point — saving writes an
|
||||
* account definition with the same id, which the registry treats as an
|
||||
* override rather than a duplicate.
|
||||
*/
|
||||
const initial = useMemo(() => {
|
||||
if (!editingId) return skillTemplate({});
|
||||
const stored = customSkillSource(customSkills, editingId);
|
||||
if (stored) return stored;
|
||||
const shipped = allSkills(customSkills).find((s) => s.id === editingId);
|
||||
return shipped?.markdown || skillTemplate({});
|
||||
/* Loaded once per skill: retyping in the editor must not be overwritten by
|
||||
a preferences write elsewhere in the app. */
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [editingId]);
|
||||
|
||||
const [source, setSource] = useState(initial);
|
||||
const [meta, setMeta] = useState(() => (editingId ? metaFromSource(initial) : emptyMeta));
|
||||
const [pagesText, setPagesText] = useState(() => (editingId ? metaFromSource(initial).pages.join(', ') : ''));
|
||||
const [touched, setTouched] = useState(Boolean(editingId));
|
||||
const [error, setError] = useState('');
|
||||
const fileRef = React.useRef(null);
|
||||
|
||||
/* The parsed view of what is in the editor right now — what actually gets
|
||||
registered, so the preview cannot flatter the definition. */
|
||||
const preview = useMemo(() => {
|
||||
try {
|
||||
return parseSkill(source, { custom: true });
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}, [source]);
|
||||
|
||||
/**
|
||||
* Active, as the rest of the app decides it.
|
||||
*
|
||||
* Two things can switch a skill off: the definition declaring
|
||||
* `status: inactive`, and the account switching it off on the Skills list.
|
||||
* One switch here reads both and, on save, writes both — otherwise this page
|
||||
* and that list would show contradictory states for the same skill.
|
||||
*/
|
||||
const disabledSkills = preferences.disabledSkills || [];
|
||||
const declaredActive = preview?.status !== 'inactive';
|
||||
const active = declaredActive && !(preview?.id && disabledSkills.includes(preview.id));
|
||||
|
||||
/* Typing in the fields composes frontmatter until the Markdown is edited by
|
||||
hand, after which the Markdown wins — it is the thing being saved. */
|
||||
const syncFromMeta = (next, nextPagesText = pagesText) => {
|
||||
setMeta(next);
|
||||
if (touched) return;
|
||||
setSource(skillTemplate({
|
||||
...next,
|
||||
pages: nextPagesText.split(',').map((p) => p.trim()).filter(Boolean),
|
||||
}));
|
||||
};
|
||||
|
||||
const setStatus = (nextActive) => {
|
||||
/* Status lives in the frontmatter, so the switch edits the definition
|
||||
rather than keeping a second copy of the state beside it. */
|
||||
const next = /^status:\s*\w+$/m.test(source)
|
||||
? source.replace(/^status:\s*\w+$/m, `status: ${nextActive ? 'active' : 'inactive'}`)
|
||||
: source.replace(/^---\n/, `---\nstatus: ${nextActive ? 'active' : 'inactive'}\n`);
|
||||
setSource(next);
|
||||
setTouched(true);
|
||||
};
|
||||
|
||||
const readFile = (event) => {
|
||||
const file = event.target.files?.[0];
|
||||
if (!file) return;
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => {
|
||||
setSource(String(reader.result));
|
||||
setTouched(true);
|
||||
setError('');
|
||||
};
|
||||
reader.readAsText(file);
|
||||
event.target.value = '';
|
||||
};
|
||||
|
||||
const save = () => {
|
||||
const problem = validateSkillSource(source);
|
||||
if (problem) {
|
||||
setError(problem);
|
||||
return;
|
||||
}
|
||||
const { skill, next } = upsertCustomSkill(customSkills, source);
|
||||
/* The definition and the account switch agree after a save: a skill saved
|
||||
as active is not left switched off by an earlier toggle. */
|
||||
const nextDisabled = skill.status === 'inactive'
|
||||
? [...new Set([...disabledSkills, skill.id])]
|
||||
: disabledSkills.filter((id) => id !== skill.id);
|
||||
updatePreferences.mutate({ customSkills: next, disabledSkills: nextDisabled });
|
||||
toast.success(editingId ? `${skill.name} updated` : `${skill.name} added`);
|
||||
navigate('/admin/workspace/skills');
|
||||
};
|
||||
|
||||
return (
|
||||
<AdminPage
|
||||
title={editingId ? 'Edit skill' : 'Add skill'}
|
||||
subtitle="Define what Owliver can do, and the pages it can do it on."
|
||||
actions={
|
||||
<>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
shape="rounded"
|
||||
onClick={() => navigate('/admin/workspace/skills')}
|
||||
>
|
||||
<ChevronLeft aria-hidden="true" /> Skills
|
||||
</Button>
|
||||
<Button size="sm" shape="rounded" onClick={save} loading={updatePreferences.isPending}>
|
||||
{editingId ? 'Save skill' : 'Add skill'}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="grid gap-5 lg:grid-cols-[minmax(0,1fr)_20rem] lg:items-start">
|
||||
<div className="space-y-5">
|
||||
<section className="space-y-3">
|
||||
<SectionTitle title="Skill" />
|
||||
<Surface variant="solid" radius="lg" padding="lg" elevation="xs" className="space-y-4">
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<Field label="Skill name" required>
|
||||
<Input
|
||||
value={meta.name}
|
||||
placeholder="Chef Training"
|
||||
onChange={(e) => syncFromMeta({ ...meta, name: e.target.value })}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Skill ID" required hint="Lower-case letters, numbers and dashes.">
|
||||
<Input
|
||||
value={meta.id}
|
||||
placeholder="chef-training"
|
||||
onChange={(e) => syncFromMeta({ ...meta, id: e.target.value })}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<Field label="Description">
|
||||
<Input
|
||||
value={meta.description}
|
||||
placeholder="Helps Owliver explain chef training paths."
|
||||
onChange={(e) => syncFromMeta({ ...meta, description: e.target.value })}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field
|
||||
label="Surfaces"
|
||||
required
|
||||
hint={`The pages this skill attaches to. Comma separated. Known pages: ${PAGE_KEYS.join(', ')}`}
|
||||
>
|
||||
<Input
|
||||
value={pagesText}
|
||||
placeholder="positions, candidates"
|
||||
onChange={(e) => {
|
||||
setPagesText(e.target.value);
|
||||
syncFromMeta(meta, e.target.value);
|
||||
}}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<div className="flex items-center justify-between gap-3 border-t border-border pt-4">
|
||||
<div className="min-w-0">
|
||||
<p className="text-body-sm font-medium text-ink-2">Active</p>
|
||||
<p className="text-caption text-ink-3">
|
||||
An inactive skill stays registered but Owliver will not offer it.
|
||||
</p>
|
||||
</div>
|
||||
<Switch checked={active} onCheckedChange={setStatus} aria-label="Skill active" />
|
||||
</div>
|
||||
</Surface>
|
||||
</section>
|
||||
|
||||
<section className="space-y-3">
|
||||
<SectionTitle title="Instructions" meta="Markdown" />
|
||||
<Surface variant="solid" radius="lg" padding="lg" elevation="xs" className="space-y-3">
|
||||
<Field
|
||||
label="Skill definition"
|
||||
hint="This is what gets saved. Frontmatter declares the skill; the body documents what it can do."
|
||||
error={error}
|
||||
>
|
||||
<Textarea
|
||||
value={source}
|
||||
rows={22}
|
||||
spellCheck={false}
|
||||
className="font-mono text-[12px] leading-relaxed"
|
||||
onChange={(e) => {
|
||||
setSource(e.target.value);
|
||||
setTouched(true);
|
||||
if (error) setError('');
|
||||
}}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<input
|
||||
ref={fileRef}
|
||||
type="file"
|
||||
accept=".md,text/markdown"
|
||||
onChange={readFile}
|
||||
className="hidden"
|
||||
/>
|
||||
<Button variant="outline" size="sm" onClick={() => fileRef.current?.click()}>
|
||||
<Upload aria-hidden="true" /> Upload .md
|
||||
</Button>
|
||||
<p className="text-caption text-ink-4">Markdown is stored as data. It is never executed.</p>
|
||||
</div>
|
||||
</Surface>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
{/* What the registry will make of this definition, as it stands. */}
|
||||
<aside className="space-y-3">
|
||||
<SectionTitle title="Preview" />
|
||||
<Surface variant="solid" radius="lg" padding="lg" elevation="xs" className="space-y-3">
|
||||
{preview ? (
|
||||
<>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<p className="text-body-sm font-semibold text-ink-1">{preview.name}</p>
|
||||
<Badge variant={active ? 'success' : 'neutral'} size="sm">
|
||||
{active ? 'Active' : 'Inactive'}
|
||||
</Badge>
|
||||
</div>
|
||||
{preview.description && (
|
||||
<p className="text-caption leading-relaxed text-ink-3">{preview.description}</p>
|
||||
)}
|
||||
<dl className="space-y-2 border-t border-border pt-3">
|
||||
<div>
|
||||
<dt className="text-[10px] font-semibold uppercase tracking-wide text-ink-4">Skill ID</dt>
|
||||
<dd className="font-mono text-caption text-ink-2">{preview.id}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-[10px] font-semibold uppercase tracking-wide text-ink-4">Surfaces</dt>
|
||||
<dd className="text-caption text-ink-2">
|
||||
{preview.pages.length ? preview.pages.join(', ') : 'None declared yet'}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-[10px] font-semibold uppercase tracking-wide text-ink-4">Type</dt>
|
||||
<dd className="text-caption text-ink-2">
|
||||
{preview.kind === 'workforce' ? 'Workforce path' : 'Assistant'}
|
||||
</dd>
|
||||
</div>
|
||||
{preview.capabilities.length > 0 && (
|
||||
<div>
|
||||
<dt className="text-[10px] font-semibold uppercase tracking-wide text-ink-4">Capabilities</dt>
|
||||
<dd>
|
||||
<ul className="mt-0.5 space-y-0.5">
|
||||
{preview.capabilities.slice(0, 4).map((c) => (
|
||||
<li key={c} className="text-[11px] leading-relaxed text-ink-3">— {c}</li>
|
||||
))}
|
||||
</ul>
|
||||
</dd>
|
||||
</div>
|
||||
)}
|
||||
</dl>
|
||||
</>
|
||||
) : (
|
||||
<p className="text-caption text-ink-3">
|
||||
The definition could not be parsed yet. Keep the frontmatter block at the top.
|
||||
</p>
|
||||
)}
|
||||
</Surface>
|
||||
</aside>
|
||||
</div>
|
||||
</AdminPage>
|
||||
);
|
||||
}
|
||||
211
src/pages/admin/Workspace.jsx
Normal file
211
src/pages/admin/Workspace.jsx
Normal file
@@ -0,0 +1,211 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import { Link, useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
GraduationCap, Sparkles, TrendingUp,
|
||||
BookOpen, ArrowRight
|
||||
} from 'lucide-react';
|
||||
import { usePreferences } from '@/lib/krowHooks';
|
||||
import { allSkills } from '@/lib/skills/registry';
|
||||
import { useWorkforcePaths } from '@/lib/skills/usePageSkills';
|
||||
import { AdminPage } from '@/pages/admin/_shell';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
/**
|
||||
* Workspace & Skills — Full-width workforce capabilities & AI governance hub.
|
||||
*/
|
||||
|
||||
export default function AdminWorkspace() {
|
||||
const navigate = useNavigate();
|
||||
const preferences = usePreferences();
|
||||
|
||||
/* Owliver's skills from Markdown registry */
|
||||
const skills = useMemo(
|
||||
() => allSkills(preferences.customSkills || []),
|
||||
[preferences.customSkills]
|
||||
);
|
||||
const disabledSkills = preferences.disabledSkills || [];
|
||||
const activeSkills = skills.filter((s) => !disabledSkills.includes(s.id)).length;
|
||||
|
||||
/* Workforce training paths from registry */
|
||||
const { paths } = useWorkforcePaths();
|
||||
|
||||
return (
|
||||
<AdminPage
|
||||
title="Workspace & Skills"
|
||||
subtitle="Manage workforce capabilities, Owliver skills and training."
|
||||
>
|
||||
<div className="space-y-6 w-full">
|
||||
{/* ── 1. Hero Banner (Full Width) ───────────────────────────────── */}
|
||||
<div className="relative overflow-hidden rounded-3xl bg-gradient-to-r from-[#0838E0] via-[#1E40AF] to-[#0284C7] p-6 sm:p-8 text-white shadow-xl w-full">
|
||||
<div className="absolute inset-0 bg-[radial-gradient(ellipse_at_top_right,_var(--tw-gradient-stops))] from-white/15 via-transparent to-transparent pointer-events-none" />
|
||||
<div className="relative z-10 flex flex-col md:flex-row md:items-center justify-between gap-6">
|
||||
<div className="space-y-2">
|
||||
<div className="inline-flex items-center gap-2 px-3 py-1 rounded-full bg-white/20 backdrop-blur-md text-white text-[12px] font-semibold border border-white/30">
|
||||
<Sparkles className="w-3.5 h-3.5 text-sky-300" />
|
||||
Workforce Intelligence Hub
|
||||
</div>
|
||||
<h1 className="text-2xl sm:text-3xl font-bold tracking-tight text-white">Capabilities & Skill Governance</h1>
|
||||
<p className="text-[14px] text-blue-100 font-normal max-w-2xl">
|
||||
Govern Owliver AI agent capabilities, configure workforce training paths, and track talent progression across your organization.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3 shrink-0">
|
||||
<div className="bg-white/10 backdrop-blur-md rounded-2xl p-4 border border-white/15 min-w-[130px] text-center">
|
||||
<p className="text-[11px] uppercase tracking-wider text-blue-200 font-semibold">Registered Skills</p>
|
||||
<p className="text-2xl font-bold text-white mt-0.5">{skills.length}</p>
|
||||
<span className="text-[10px] text-emerald-300 font-medium">{activeSkills} Active</span>
|
||||
</div>
|
||||
<div className="bg-white/10 backdrop-blur-md rounded-2xl p-4 border border-white/15 min-w-[130px] text-center">
|
||||
<p className="text-[11px] uppercase tracking-wider text-blue-200 font-semibold">Training Paths</p>
|
||||
<p className="text-2xl font-bold text-sky-300 mt-0.5">{paths.length}</p>
|
||||
<span className="text-[10px] text-blue-200">Verified Ladders</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── 2. Full Width 2-Column Grid ───────────────────────────────── */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6 w-full">
|
||||
{/* ── Left Column: Owliver AI Capabilities ───────────────────── */}
|
||||
<div className="space-y-6">
|
||||
<div className="bg-white rounded-2xl border border-[#E0E4EA] p-6 shadow-xs space-y-5">
|
||||
<div className="flex items-center justify-between border-b border-[#E0E4EA] pb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Sparkles className="w-5 h-5 text-[#0838E0]" />
|
||||
<h2 className="text-[16px] font-bold text-[#101820]">Owliver AI Capabilities</h2>
|
||||
</div>
|
||||
<span className="text-[12px] font-semibold text-[#0838E0] bg-blue-50 px-3 py-1 rounded-full border border-blue-200/60">
|
||||
{skills.length} Registered · {activeSkills} Active
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<p className="text-[13px] text-[#687386]">
|
||||
Owliver capabilities define what the AI assistant can execute autonomously during candidate screening, background matching, and contextual analysis.
|
||||
</p>
|
||||
|
||||
{/* Skills Sample List */}
|
||||
<div className="space-y-3">
|
||||
{skills.slice(0, 4).map((s) => {
|
||||
const isActive = !disabledSkills.includes(s.id);
|
||||
return (
|
||||
<div key={s.id} className="p-3.5 rounded-xl border border-[#E0E4EA] bg-[#F8F8F8]/50 flex items-center justify-between gap-3">
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-[13px] font-bold text-[#101820]">{s.name || s.id}</p>
|
||||
{s.description && <p className="text-[12px] text-[#687386] truncate mt-0.5">{s.description}</p>}
|
||||
</div>
|
||||
<span className={cn(
|
||||
'px-2.5 py-0.5 rounded-full text-[11px] font-semibold shrink-0',
|
||||
isActive ? 'bg-emerald-50 text-[#16A765] border border-emerald-200' : 'bg-gray-100 text-gray-500'
|
||||
)}>
|
||||
{isActive ? 'Active' : 'Disabled'}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* CTA Button */}
|
||||
<div className="pt-2">
|
||||
<Link
|
||||
to="/admin/workspace/skills"
|
||||
className="w-full inline-flex items-center justify-center gap-2 px-5 py-3 rounded-xl bg-[#0838E0] hover:bg-[#062BAF] text-white text-[13px] font-semibold transition-all shadow-md shadow-blue-500/20 hover:scale-[1.01]"
|
||||
>
|
||||
<Sparkles className="w-4 h-4" />
|
||||
Manage Owliver Skills & Agent Capabilities
|
||||
<ArrowRight className="w-4 h-4" />
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Right Column: Workforce Training & Skill Development ──────── */}
|
||||
<div className="space-y-6">
|
||||
{/* Workforce Training */}
|
||||
<div className="bg-white rounded-2xl border border-[#E0E4EA] p-6 shadow-xs space-y-5">
|
||||
<div className="flex items-center justify-between border-b border-[#E0E4EA] pb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<GraduationCap className="w-5 h-5 text-indigo-600" />
|
||||
<h2 className="text-[16px] font-bold text-[#101820]">Workforce Training Paths</h2>
|
||||
</div>
|
||||
<span className="text-[12px] font-semibold text-indigo-600 bg-indigo-50 px-3 py-1 rounded-full border border-indigo-200/60">
|
||||
{paths.length} Active Paths
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<p className="text-[13px] text-[#687386]">
|
||||
Structured learning challenges and skill verification ladders where workforce candidates prove their real-world capabilities.
|
||||
</p>
|
||||
|
||||
{/* Paths Sample List */}
|
||||
<div className="space-y-3">
|
||||
{paths.slice(0, 3).map((p) => (
|
||||
<div key={p.id} className="p-3.5 rounded-xl border border-[#E0E4EA] bg-[#F8F8F8]/50 flex items-center justify-between gap-3">
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-[13px] font-bold text-[#101820]">{p.name || p.id}</p>
|
||||
{p.category && <p className="text-[12px] text-[#687386] capitalize mt-0.5">{p.category} Path</p>}
|
||||
</div>
|
||||
<span className="px-2.5 py-0.5 rounded-full bg-indigo-50 text-indigo-700 text-[11px] font-semibold border border-indigo-200">
|
||||
Verified Path
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* CTA Button */}
|
||||
<div className="pt-2">
|
||||
<Link
|
||||
to="/admin/university"
|
||||
className="w-full inline-flex items-center justify-center gap-2 px-5 py-3 rounded-xl border border-[#E0E4EA] bg-white hover:bg-[#F8F8F8] text-[#101820] text-[13px] font-semibold transition-all shadow-xs"
|
||||
>
|
||||
<GraduationCap className="w-4 h-4 text-indigo-600" />
|
||||
Manage Training Paths & Proving Challenges
|
||||
<ArrowRight className="w-4 h-4" />
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Skill Development */}
|
||||
<div className="bg-white rounded-2xl border border-[#E0E4EA] p-6 shadow-xs space-y-4">
|
||||
<div className="flex items-center justify-between border-b border-[#E0E4EA] pb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<TrendingUp className="w-5 h-5 text-[#16A765]" />
|
||||
<h2 className="text-[16px] font-bold text-[#101820]">Skill Progression Tracking</h2>
|
||||
</div>
|
||||
<span className="text-[12px] font-semibold text-[#16A765] bg-emerald-50 px-3 py-1 rounded-full border border-emerald-200/60">
|
||||
Live Analytics
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<p className="text-[13px] text-[#687386]">
|
||||
Track talent advancement across training paths, verified levels, and candidate endorsement histories.
|
||||
</p>
|
||||
|
||||
<div className="pt-2">
|
||||
<Link
|
||||
to="/admin/workspace/skill-development"
|
||||
className="w-full inline-flex items-center justify-center gap-2 px-5 py-3 rounded-xl border border-[#E0E4EA] bg-white hover:bg-[#F8F8F8] text-[#101820] text-[13px] font-semibold transition-all shadow-xs"
|
||||
>
|
||||
<TrendingUp className="w-4 h-4 text-[#16A765]" />
|
||||
View Skill Progression & Talent Growth
|
||||
<ArrowRight className="w-4 h-4" />
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── 3. Unified Registry Architecture Banner ───────────────────── */}
|
||||
<div className="flex items-start gap-3.5 p-5 rounded-2xl bg-blue-50/60 border border-blue-200/50 text-[#0838E0] text-[13px] leading-relaxed">
|
||||
<BookOpen className="w-5 h-5 shrink-0 text-[#0838E0] mt-0.5" />
|
||||
<div>
|
||||
<p className="font-bold text-[#101820]">Unified Markdown Registry Architecture</p>
|
||||
<p className="text-[#687386] mt-0.5">
|
||||
Skills define what Owliver can execute autonomously. Training paths define what the human workforce can be verified in. Both are defined as structured Markdown in one central registry, keeping candidate evaluation and skill progression seamlessly synchronized.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</AdminPage>
|
||||
);
|
||||
}
|
||||
217
src/pages/admin/WorkspaceSkills.jsx
Normal file
217
src/pages/admin/WorkspaceSkills.jsx
Normal file
@@ -0,0 +1,217 @@
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { ChevronLeft, MoreHorizontal, Pencil, Plus, Trash2 } from 'lucide-react';
|
||||
import {
|
||||
Badge, Button, ConfirmModal, DropdownMenu, DropdownMenuContent, DropdownMenuItem,
|
||||
DropdownMenuTrigger, EmptyState, SearchInput, Surface, Switch, toast,
|
||||
} from '@/components/ds';
|
||||
import { usePreferences, useUpdatePreferences } from '@/lib/krowHooks';
|
||||
import { allSkills, contextIdsForSkill } from '@/lib/skills/registry';
|
||||
import { removeCustomSkill } from '@/lib/skills/customSkills';
|
||||
import { ASSISTANT_CONTEXTS } from '@/components/ai-assistant/contexts';
|
||||
import { AdminPage } from '@/pages/admin/_shell';
|
||||
|
||||
/**
|
||||
* Skills — the management page.
|
||||
*
|
||||
* Skills used to be a section at the bottom of the account page, which made
|
||||
* "add a skill" mean "append another row to a page about something else". They
|
||||
* are a registry with their own lifecycle: authored, enabled, edited, removed.
|
||||
* A registry deserves an address, so this is one — reachable from Workspace &
|
||||
* Skills, where Owliver's capabilities belong, and linkable on its own.
|
||||
*
|
||||
* Nothing here is a second skill system. The rows are `allSkills()` — the
|
||||
* Markdown files on disk plus whatever the account has added — and enabling or
|
||||
* disabling one writes the same `disabledSkills` preference it always did.
|
||||
*/
|
||||
|
||||
const KIND_LABEL = {
|
||||
assistant: 'Assistant',
|
||||
workforce: 'Workforce path',
|
||||
};
|
||||
|
||||
/** One skill, as a row in the management list. */
|
||||
function SkillRow({ skill, enabled, onToggle, onEdit, onDelete }) {
|
||||
const pages = contextIdsForSkill(skill)
|
||||
.map((id) => ASSISTANT_CONTEXTS[id]?.page)
|
||||
.filter(Boolean);
|
||||
|
||||
return (
|
||||
<div className="grid gap-3 px-4 py-3.5 sm:grid-cols-[minmax(0,1fr)_auto] sm:items-start">
|
||||
<div className="min-w-0">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<p className="text-body-sm font-semibold text-ink-1">{skill.name}</p>
|
||||
<Badge variant={enabled ? 'success' : 'neutral'} size="sm">
|
||||
{enabled ? 'Active' : 'Inactive'}
|
||||
</Badge>
|
||||
<Badge variant="soft" size="sm">{KIND_LABEL[skill.kind] || 'Assistant'}</Badge>
|
||||
{skill.custom && <Badge variant="soft" size="sm">Custom</Badge>}
|
||||
</div>
|
||||
|
||||
{skill.description && (
|
||||
<p className="mt-1 text-caption leading-relaxed text-ink-3">{skill.description}</p>
|
||||
)}
|
||||
|
||||
{/* A workforce path names the capability it governs and the rungs it
|
||||
defines: on this list those are what change other pages. */}
|
||||
{skill.kind === 'workforce' && (
|
||||
<p className="mt-1.5 text-[11px] text-ink-4">
|
||||
Capability: <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">
|
||||
Surfaces: <span className="text-ink-3">{pages.join(', ')}</span>
|
||||
</p>
|
||||
)}
|
||||
|
||||
<p className="mt-1.5 text-[11px] text-ink-4">
|
||||
{skill.source === 'account' ? 'Added on this account' : 'Shipped with KROW'}
|
||||
{' · '}
|
||||
<span className="font-mono">{skill.path}</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1.5 sm:justify-end">
|
||||
<Switch
|
||||
checked={enabled}
|
||||
onCheckedChange={(v) => onToggle(skill.id, v)}
|
||||
aria-label={`${skill.name} — ${enabled ? 'active' : 'inactive'}`}
|
||||
/>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`Actions for ${skill.name}`}
|
||||
className="grid h-8 w-8 place-items-center rounded-lg text-ink-3 transition-colors hover:bg-surface-sunken hover:text-ink-1
|
||||
focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-krow-blue/50"
|
||||
>
|
||||
<MoreHorizontal className="h-4 w-4" aria-hidden="true" />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-48">
|
||||
<DropdownMenuItem onClick={() => onEdit(skill)} className="cursor-pointer">
|
||||
<Pencil className="mr-2 h-4 w-4" />
|
||||
{skill.custom ? 'Edit skill' : 'Duplicate & edit'}
|
||||
</DropdownMenuItem>
|
||||
{skill.custom && (
|
||||
<DropdownMenuItem
|
||||
onClick={() => onDelete(skill)}
|
||||
className="cursor-pointer text-destructive focus:text-destructive"
|
||||
>
|
||||
<Trash2 className="mr-2 h-4 w-4" /> Remove skill
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function WorkspaceSkills() {
|
||||
const navigate = useNavigate();
|
||||
const preferences = usePreferences();
|
||||
const updatePreferences = useUpdatePreferences();
|
||||
|
||||
const [search, setSearch] = useState('');
|
||||
const [pendingDelete, setPendingDelete] = useState(null);
|
||||
|
||||
const disabledSkills = preferences.disabledSkills || [];
|
||||
const customSkills = preferences.customSkills || [];
|
||||
const skills = useMemo(() => allSkills(customSkills), [customSkills]);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const q = search.trim().toLowerCase();
|
||||
if (!q) return skills;
|
||||
return skills.filter((s) => `${s.name} ${s.description} ${s.pages.join(' ')}`.toLowerCase().includes(q));
|
||||
}, [skills, search]);
|
||||
|
||||
const toggleSkill = (id, enabled) => {
|
||||
const next = enabled
|
||||
? disabledSkills.filter((s) => s !== id)
|
||||
: [...new Set([...disabledSkills, id])];
|
||||
updatePreferences.mutate({ disabledSkills: next });
|
||||
toast.success(enabled ? 'Skill enabled' : 'Skill disabled');
|
||||
};
|
||||
|
||||
const removeSkill = (skill) => {
|
||||
updatePreferences.mutate({ customSkills: removeCustomSkill(customSkills, skill.id) });
|
||||
setPendingDelete(null);
|
||||
toast.success(`${skill.name} removed`);
|
||||
};
|
||||
|
||||
const activeCount = skills.filter((s) => !disabledSkills.includes(s.id)).length;
|
||||
|
||||
return (
|
||||
<AdminPage
|
||||
title="Skills"
|
||||
subtitle="Manage the capabilities Owliver can use across KROW."
|
||||
actions={
|
||||
<>
|
||||
<Button variant="outline" size="sm" shape="rounded" onClick={() => navigate('/admin/workspace')}>
|
||||
<ChevronLeft aria-hidden="true" /> Workspace
|
||||
</Button>
|
||||
<Button size="sm" shape="rounded" onClick={() => navigate('/admin/workspace/skills/new')}>
|
||||
<Plus aria-hidden="true" /> Add skill
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="flex flex-col gap-2 lg:flex-row lg:items-center">
|
||||
<div className="w-full lg:w-80">
|
||||
<SearchInput value={search} onChange={setSearch} placeholder="Search skills" size="sm" />
|
||||
</div>
|
||||
<p className="text-caption text-ink-4 lg:ml-auto">
|
||||
{skills.length} registered · {activeCount} active
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{filtered.length ? (
|
||||
<Surface
|
||||
variant="solid"
|
||||
radius="lg"
|
||||
padding="none"
|
||||
elevation="xs"
|
||||
className="divide-y divide-border overflow-hidden"
|
||||
>
|
||||
{filtered.map((skill) => (
|
||||
<SkillRow
|
||||
key={skill.id}
|
||||
skill={skill}
|
||||
enabled={!disabledSkills.includes(skill.id)}
|
||||
onToggle={toggleSkill}
|
||||
onEdit={(s) => navigate(`/admin/workspace/skills/${s.id}`)}
|
||||
onDelete={setPendingDelete}
|
||||
/>
|
||||
))}
|
||||
</Surface>
|
||||
) : (
|
||||
<EmptyState
|
||||
title="No skills match that search"
|
||||
description="Try a shorter term, or clear the search to see every registered skill."
|
||||
action={<Button variant="outline" size="sm" onClick={() => setSearch('')}>Clear search</Button>}
|
||||
/>
|
||||
)}
|
||||
|
||||
<p className="text-caption leading-relaxed text-ink-4">
|
||||
Each skill is a Markdown definition that attaches itself to the pages it names.
|
||||
Definitions shipped with KROW live in the repository and can be switched off here;
|
||||
skills added on this account are stored with your preferences. Markdown is stored as
|
||||
data — it is never executed.
|
||||
</p>
|
||||
|
||||
<ConfirmModal
|
||||
open={Boolean(pendingDelete)}
|
||||
onOpenChange={(open) => !open && setPendingDelete(null)}
|
||||
title={`Remove ${pendingDelete?.name || 'skill'}?`}
|
||||
description="This removes the definition from this account. Skills shipped with KROW are unaffected."
|
||||
confirmLabel="Remove skill"
|
||||
tone="destructive"
|
||||
onConfirm={() => pendingDelete && removeSkill(pendingDelete)}
|
||||
/>
|
||||
</AdminPage>
|
||||
);
|
||||
}
|
||||
@@ -17,13 +17,16 @@ import { cn } from '@/lib/utils';
|
||||
* Controls live in the page rather than in the global chrome, which is why the
|
||||
* Admin header only needs one 56px bar.
|
||||
*/
|
||||
export function AdminPage({ title, subtitle, actions, tabs, children, className }) {
|
||||
export function AdminPage({ title, subtitle, meta, actions, tabs, children, className }) {
|
||||
return (
|
||||
<div className={cn('space-y-6', className)}>
|
||||
<header className="flex flex-wrap items-center justify-between gap-4 border-b border-border/50 pb-4">
|
||||
<div className="min-w-0 space-y-1">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<h1 className="font-heading text-title-lg font-bold tracking-tight text-ink-1 sm:text-2xl">{title}</h1>
|
||||
{/* A count of whatever the page is a list of, stated beside its
|
||||
name rather than only inside the list. */}
|
||||
{meta && <span className="text-body-sm tabular-nums text-ink-4">{meta}</span>}
|
||||
<span className="inline-flex items-center gap-1.5 rounded-full bg-blue-50 px-2.5 py-0.5 text-[11px] font-semibold text-blue-600 dark:bg-blue-950/60 dark:text-blue-400 border border-blue-200/60 dark:border-blue-800/40">
|
||||
<span className="h-1.5 w-1.5 rounded-full bg-blue-600 dark:bg-blue-400" />
|
||||
Live System
|
||||
|
||||
Reference in New Issue
Block a user