position ui and owliver update
This commit is contained in:
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