update owliver skill

This commit is contained in:
2026-08-14 17:33:16 +05:30
parent cccada9bd2
commit 4fdfb90326
58 changed files with 8015 additions and 1339 deletions

View File

@@ -8,6 +8,7 @@ import {
import { useUserActivity } from '@/lib/krowHooks';
import { AdminPage, SectionTitle, Toolbar } from '@/pages/admin/_shell';
import { FilterSelect } from '@/pages/admin/Positions';
import { SkillSurface } from '@/components/skills/SkillSurface';
/**
* Admin Activity — an audit centre.
@@ -144,6 +145,7 @@ export default function AdminActivity() {
title="Activity"
subtitle="Monitor workforce operations, user actions and system events."
>
<SkillSurface page="activity" placement="after-header" />
<MetricStrip
columns={4}
loading={isLoading}
@@ -293,6 +295,7 @@ export default function AdminActivity() {
]}
/>
</section>
<SkillSurface page="activity" placement="before-footer" />
</AdminPage>
);
}

View File

@@ -1,6 +1,364 @@
import React from 'react';
import AdminHiredHistory from './HiredHistory';
import React, { useMemo } from 'react';
import {
Activity, Award, Clock, Gauge, Lightbulb, Target, TrendingUp, TriangleAlert,
} from 'lucide-react';
import { cn } from '@/lib/utils';
import { Badge, MetricStrip, Surface } from '@/components/ds';
import { useApplications, useJobPostings, useStaff } from '@/lib/krowHooks';
import { DepartmentPerformance } from '@/components/charts/DepartmentPerformance';
import { HiringFlow } from '@/components/charts/HiringFlow';
import { HiringTrendChart } from '@/components/charts/HiringTrendChart';
import { useSize } from '@/hooks/use-size';
import { AdminPage, SectionTitle } from '@/pages/admin/_shell';
import { SkillSurface } from '@/components/skills/SkillSurface';
import {
buildEfficiency, buildFunnel, buildInsights, buildTrend, byDepartment, byPosition,
hiresWithFill, summarise,
} from '@/lib/hiringRecords';
/**
* Admin Analytics — how hiring is performing.
*
* This page and Hired History used to be the same component rendered twice with
* a different title, which is why they read as duplicates: identical sections,
* identical tabs, identical figures. They are not the same question.
*
* **Analytics asks "how is our hiring performing?"** — it is metrics, trends,
* comparison and what to do about them. Every section here is an aggregate: a
* rate, a distribution, a shape over time. No individual hire is named anywhere
* on this page, because a name is a record and records are Hired History's job.
*
* It is also structurally different, not just differently coloured. Analytics is
* one scrolling dashboard — the seven readings are meant to be taken together,
* and a comparison you have to remember across a tab switch is not a comparison.
* Hired History is a filtered record you search, so it keeps its controls and its
* table. Same design system, same blue, same cards; different jobs.
*
* Both read `lib/hiringRecords.js`, so a total here and the same total there
* cannot disagree.
*/
const INSIGHT_TONE = {
success: { ring: 'border-emerald-500/30 bg-emerald-50/40 dark:bg-emerald-950/20', dot: 'text-emerald-600 dark:text-emerald-400', Icon: TrendingUp },
warning: { ring: 'border-amber-500/30 bg-amber-50/40 dark:bg-amber-950/20', dot: 'text-amber-600 dark:text-amber-400', Icon: TriangleAlert },
risk: { ring: 'border-red-500/30 bg-red-50/40 dark:bg-red-950/20', dot: 'text-red-600 dark:text-red-400', Icon: TriangleAlert },
info: { ring: 'border-border bg-surface', dot: 'text-krow-blue', Icon: Lightbulb },
};
/** One finding, with the evidence underneath it. */
function Insight({ item }) {
const tone = INSIGHT_TONE[item.tone] || INSIGHT_TONE.info;
const { Icon } = tone;
return (
<li className={cn('flex items-start gap-2.5 rounded-xl border p-3.5', tone.ring)}>
<Icon className={cn('mt-0.5 h-4 w-4 shrink-0', tone.dot)} aria-hidden="true" />
<div className="min-w-0">
<p className="font-heading text-body-sm font-semibold text-ink-1">{item.title}</p>
<p className="mt-0.5 text-caption leading-relaxed text-ink-3">{item.body}</p>
</div>
</li>
);
}
/**
* The funnel, drawn once its container can be measured.
*
* `HiringFlow` renders an MUI bar chart with no explicit width, which means the
* chart measures its parent on mount. On this page that first measure lands
* before the layout has resolved a width — the shell's `main` is a `flex-1`
* column beside the Owliver panel — and the chart warns that it has nothing to
* size itself against. Gating on the measured width means the chart mounts once,
* already knowing how wide it is, instead of mounting into nothing and
* recovering.
*
* The reserved height keeps the section from collapsing and reflowing the page
* on the frame between measure and draw.
*/
function MeasuredFunnel({ funnel }) {
const ref = React.useRef(null);
const size = useSize(ref);
return (
<div ref={ref} className="w-full">
{size?.width ? (
<HiringFlow
stages={funnel.stages}
transitions={funnel.transitions}
weakestKey={funnel.weakestKey}
/>
) : (
<div className="h-[280px] rounded-xl border border-border bg-surface" aria-hidden="true" />
)}
</div>
);
}
/** A ranked comparison row — used for both fastest and slowest to fill. */
function VelocityList({ items, median, tone }) {
if (!items.length) {
return (
<p className="rounded-lg border border-dashed border-border px-3 py-3 text-body-sm text-ink-3">
No role has enough dated hires to measure velocity yet.
</p>
);
}
return (
<ul className="space-y-2">
{items.map((role) => {
const delta = median ? role.avgDays - median : 0;
return (
<li key={role.role} className="flex items-center justify-between gap-3 rounded-xl bg-surface-subtle px-3 py-2.5">
<div className="min-w-0">
<p className="truncate text-body-sm font-medium text-ink-1">{role.role}</p>
<p className="text-[10px] text-ink-4">
{role.count} hire{role.count === 1 ? '' : 's'}
</p>
</div>
<div className="flex shrink-0 items-center gap-2">
<span className="font-heading text-body-sm font-bold tabular-nums text-ink-1">
{role.avgDays}d
</span>
{median > 0 && delta !== 0 && (
<Badge variant={tone === 'fast' ? 'success' : 'warning'} size="sm" className="tabular-nums">
{delta > 0 ? '+' : ''}{delta}d
</Badge>
)}
</div>
</li>
);
})}
</ul>
);
}
export default function AdminAnalytics() {
return <AdminHiredHistory title="Analytics" />;
const { data: staff = [], isLoading } = useStaff();
const { data: applications = [] } = useApplications();
const { data: postings = [] } = useJobPostings();
const hires = useMemo(
() => hiresWithFill({ staff, applications, postings }),
[staff, applications, postings]
);
const summary = useMemo(() => summarise(hires), [hires]);
const funnel = useMemo(() => buildFunnel(applications), [applications]);
const trend = useMemo(() => buildTrend(hires), [hires]);
const departments = useMemo(() => byDepartment(hires), [hires]);
const positions = useMemo(() => byPosition(hires), [hires]);
const efficiency = useMemo(() => buildEfficiency(hires), [hires]);
const insights = useMemo(
() => buildInsights({ hires, departments, positions, funnel, efficiency }),
[hires, departments, positions, funnel, efficiency]
);
return (
<AdminPage
title="Analytics"
subtitle="How hiring is performing — rates, trends, comparison and what to act on."
>
<SkillSurface page="analytics" placement="after-header" />
{/* 1. Hiring performance — the four figures the rest of the page explains. */}
<section aria-labelledby="performance" className="space-y-3">
<SectionTitle id="performance" title="Hiring performance" meta="Across the workspace" />
<MetricStrip
columns={4}
loading={isLoading}
items={[
{ label: 'Total hires', value: summary.total, icon: Award, tone: 'brand' },
{
label: 'Avg time-to-hire',
value: summary.speed ? `${summary.speed}d` : '—',
icon: Clock,
sub: efficiency.median ? `${efficiency.median}d median` : undefined,
},
{
label: 'Quality of hire',
value: summary.quality || '—',
icon: TrendingUp,
tone: summary.quality >= 80 ? 'success' : 'default',
sub: 'avg AI score',
},
{
label: 'Conversion rate',
value: `${funnel.conversion}%`,
icon: Target,
sub: `${funnel.stages[4].count} of ${funnel.stages[0].count} applicants`,
},
]}
/>
</section>
{/* 2. The funnel — where candidates are lost, which is the finding this
page exists to surface. */}
<section aria-labelledby="funnel" className="space-y-3">
<SectionTitle
id="funnel"
title="Hiring funnel"
meta="Applied → Screened → Shortlisted → Interview → Hired"
/>
{funnel.stages[0].count ? (
<MeasuredFunnel funnel={funnel} />
) : (
<Surface variant="solid" radius="lg" padding="lg" elevation="xs" className="border border-border">
<p className="text-body-sm leading-relaxed text-ink-3">
No applications on file yet. The funnel appears once the first candidate applies.
</p>
</Surface>
)}
</section>
{/* 3. Trend over time. */}
<section aria-labelledby="trend" className="space-y-3">
<SectionTitle id="trend" title="Hiring trend" meta="Cumulative hires by month" />
<HiringTrendChart
points={trend}
emptyState={(
<div className="px-4 py-8 text-center">
<p className="font-heading text-body font-semibold text-ink-1">
Not enough history for a trend
</p>
<p className="mx-auto mt-1 max-w-md text-body-sm leading-relaxed text-ink-3">
{hires.length
? `All ${hires.length} hire${hires.length === 1 ? '' : 's'} closed in ${trend[0]?.label || 'a single month'}. A month-on-month line appears once hiring spans a second month.`
: 'No hires recorded yet. Hiring volume over time appears here once the first role closes.'}
</p>
</div>
)}
/>
</section>
{/* 4. Department comparison. */}
<section aria-labelledby="departments" className="space-y-3">
<SectionTitle
id="departments"
title="Department performance"
meta={`${departments.length} department${departments.length === 1 ? '' : 's'} compared`}
/>
<DepartmentPerformance items={departments} />
</section>
{/* 5. Position comparison — the same question one level down. */}
<section aria-labelledby="positions" className="space-y-3">
<SectionTitle
id="positions"
title="Position performance"
meta={`${positions.length} role${positions.length === 1 ? '' : 's'} filled`}
/>
<Surface variant="solid" radius="lg" padding="none" elevation="xs" className="overflow-hidden border border-border">
<div className="overflow-x-auto">
<table className="w-full border-collapse text-body-sm">
<caption className="sr-only">Hires, quality, speed and review outcome by role</caption>
<thead>
<tr className="border-b border-border bg-surface-subtle">
{['Role', 'Hires', 'Avg score', 'Avg days', 'Reviewed', 'Rating'].map((h, i) => (
<th
key={h}
scope="col"
className={cn(
'whitespace-nowrap px-3.5 py-2.5 text-[10px] font-bold uppercase tracking-wider text-ink-4',
i ? 'text-right' : 'text-left'
)}
>
{h}
</th>
))}
</tr>
</thead>
<tbody className="divide-y divide-border">
{positions.map((r) => (
<tr key={r.role} className="border-b border-border/60 last:border-0 transition-colors hover:bg-surface-subtle/80">
<td className="px-3.5 py-2.5 font-medium text-ink-1">{r.role}</td>
<td className="px-3.5 py-2.5 text-right font-semibold tabular-nums text-ink-2">{r.count}</td>
<td className="px-3.5 py-2.5 text-right font-bold tabular-nums text-blue-600 dark:text-blue-400">{r.avgScore || '—'}</td>
<td className="px-3.5 py-2.5 text-right tabular-nums text-ink-2">{r.avgDays ? `${r.avgDays}d` : '—'}</td>
<td className="px-3.5 py-2.5 text-right tabular-nums text-ink-3">{r.rated}/{r.count}</td>
<td className="px-3.5 py-2.5 text-right">
{r.avgRating != null
? <Badge variant="success" size="sm" className="font-bold">{r.avgRating}/5</Badge>
: <Badge variant="warning" size="sm" className="font-bold">Pending</Badge>}
</td>
</tr>
))}
</tbody>
</table>
</div>
</Surface>
</section>
{/* 6. Efficiency — velocity against this workspace's own median, so the
comparison is one an operator can check. */}
<section aria-labelledby="efficiency" className="space-y-3">
<SectionTitle
id="efficiency"
title="Hiring efficiency"
meta={efficiency.median ? `${efficiency.median}d median time-to-hire` : 'Not enough dated hires'}
/>
<div className="grid grid-cols-1 gap-4 lg:grid-cols-3">
<Surface variant="solid" radius="lg" padding="lg" elevation="xs" className="border border-border">
<div className="flex items-center gap-2 text-krow-blue">
<Gauge className="h-4 w-4" aria-hidden="true" />
<h3 className="font-heading text-body-sm font-bold text-ink-1">Velocity</h3>
</div>
<p className="mt-2 font-heading text-title font-bold tabular-nums text-ink-1">
{efficiency.median ? `${efficiency.median}d` : '—'}
<span className="ml-1.5 text-caption font-normal text-ink-3">median</span>
</p>
<p className="mt-1 text-caption leading-relaxed text-ink-3">
{efficiency.within48h
? `${efficiency.within48h}% of roles close within 48 hours.`
: 'Velocity appears once hires carry an application date.'}
</p>
</Surface>
<Surface variant="solid" radius="lg" padding="lg" elevation="xs" className="border border-border">
<div className="flex items-center gap-2 text-emerald-600 dark:text-emerald-400">
<Activity className="h-4 w-4" aria-hidden="true" />
<h3 className="font-heading text-body-sm font-bold text-ink-1">Fastest to fill</h3>
</div>
<div className="mt-3">
<VelocityList items={efficiency.fastest} median={efficiency.median} tone="fast" />
</div>
</Surface>
<Surface variant="solid" radius="lg" padding="lg" elevation="xs" className="border border-border">
<div className="flex items-center gap-2 text-amber-600 dark:text-amber-400">
<Clock className="h-4 w-4" aria-hidden="true" />
<h3 className="font-heading text-body-sm font-bold text-ink-1">Slowest to fill</h3>
</div>
<div className="mt-3">
<VelocityList items={efficiency.slowest} median={efficiency.median} tone="slow" />
</div>
</Surface>
</div>
</section>
{/* 7. What the numbers mean. Only findings the data actually supports —
an insight list that is always the same length is decoration. */}
<section aria-labelledby="insights" className="space-y-3">
<SectionTitle
id="insights"
title="AI hiring insights"
meta={insights.length ? `${insights.length} finding${insights.length === 1 ? '' : 's'}` : undefined}
/>
{insights.length ? (
<ul className="grid grid-cols-1 gap-3 lg:grid-cols-2">
{insights.map((item) => <Insight key={item.title} item={item} />)}
</ul>
) : (
<Surface variant="solid" radius="lg" padding="lg" elevation="xs" className="border border-border">
<p className="text-body-sm leading-relaxed text-ink-3">
There is not enough hiring on file to draw a finding from yet. Insights appear as
applications, hires and reviews accumulate.
</p>
</Surface>
)}
</section>
<SkillSurface page="analytics" placement="before-footer" />
</AdminPage>
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -6,6 +6,7 @@ import { toast } from 'react-hot-toast';
import { AdminPage, Toolbar } from '@/pages/admin/_shell';
import { FilterSelect } from '@/pages/admin/Positions';
import CandidateCard from '@/components/krow/CandidateCard';
import { SkillSurface } from '@/components/skills/SkillSurface';
import AIInterviewModal from '@/components/krow/AIInterviewModal';
import ScheduleInterviewModal from '@/components/krow/ScheduleInterviewModal';
import MessageCandidateModal from '@/components/krow/MessageCandidateModal';
@@ -107,6 +108,8 @@ export default function AdminCandidates() {
title="Candidates"
subtitle="Review, compare and manage the candidate pipeline."
>
<SkillSurface page="candidates" placement="after-header" />
<Toolbar
search={<SearchInput value={search} onChange={setSearch} placeholder="Search candidates..." size="sm" />}
filters={
@@ -165,6 +168,8 @@ export default function AdminCandidates() {
</div>
)}
<SkillSurface page="candidates" placement="before-footer" />
{/* Modals */}
{interviewApp && (
<AIInterviewModal open={true} onClose={() => setInterviewApp(null)} application={interviewApp} job={postings.find(p => p.id === interviewApp.job_posting_id)} />

View File

@@ -11,6 +11,7 @@ import {
import { useApplications, useInterviews, useJobPostings, useStaff, useWorkerProfiles } from '@/lib/krowHooks';
import { buildFacts } from '@/components/ai-assistant/insights';
import { AdminPage, SectionTitle } from '@/pages/admin/_shell';
import { SkillSurface } from '@/components/skills/SkillSurface';
/**
* Admin Candidates Analysis — the analytical counterpart to Candidates.
@@ -165,6 +166,7 @@ export default function AdminCandidatesAnalysis() {
title="Candidates Analysis"
subtitle="Talent supply, quality distribution and pipeline risk."
>
<SkillSurface page="candidates-analysis" placement="after-header" />
<MetricStrip
columns={5}
items={[
@@ -188,6 +190,7 @@ export default function AdminCandidatesAnalysis() {
</PieChart>
</ResponsiveContainer>
<div className="space-y-1.5">
{bands.map((b) => (
<div key={b.name} className="flex items-center gap-2 text-caption">
<span className="h-2 w-2 shrink-0 rounded-sm" style={{ background: b.color }} aria-hidden="true" />
@@ -413,6 +416,7 @@ export default function AdminCandidatesAnalysis() {
Full analytics <ArrowRight aria-hidden="true" />
</Button>
</section>
<SkillSurface page="candidates-analysis" placement="before-footer" />
</AdminPage>
);
}

View File

@@ -17,6 +17,7 @@ import {
} from '@/lib/krowHooks';
import { buildFacts } from '@/components/ai-assistant/insights';
import { AdminPage } from '@/pages/admin/_shell';
import { SkillSurface } from '@/components/skills/SkillSurface';
/**
* Control Center — the KROW Admin command centre.
@@ -180,6 +181,7 @@ function PipelineFunnel({ funnel, transitions, weakest, total }) {
return (
<div className="space-y-4">
{/* 5 Stage Funnel Cards */}
<div className="grid grid-cols-1 gap-3.5 sm:grid-cols-2 lg:grid-cols-5">
{funnel.map((stage, i) => {
@@ -584,6 +586,7 @@ export default function ControlCenter() {
</Button>
)}
>
<SkillSurface page="control-center" placement="after-header" />
{/* 1 ── Operations snapshot */}
<Snapshot metrics={metrics} loading={isLoading} />
@@ -973,6 +976,7 @@ export default function ControlCenter() {
</div>
</div>
</Band>
<SkillSurface page="control-center" placement="before-footer" />
</AdminPage>
);
}

View File

@@ -1,459 +1,293 @@
import React, { useMemo, useState } from 'react';
import { ArrowRight, Award, Briefcase, Building2, CheckCircle2, ChevronRight, Clock, ShieldCheck, Sparkles, Star, TrendingUp, Users, Zap } from 'lucide-react';
import { Tab, TabList } from '@astryxdesign/core';
import { Award, Building2, CalendarDays, Clock, Mail, Phone, UserCheck } from 'lucide-react';
import { cn } from '@/lib/utils';
import { Avatar, Badge, DataTable, MetricStrip, StatusBadge, Surface } from '@/components/ds';
import {
Avatar, Badge, Button, DataTable, Drawer, EmptyState, FilterBar, MetricStrip, StatusBadge,
Surface, Timeline,
} from '@/components/ds';
import { useApplications, useJobPostings, useStaff } from '@/lib/krowHooks';
import { DepartmentPerformance } from '@/components/charts/DepartmentPerformance';
import { HiringTrendChart } from '@/components/charts/HiringTrendChart';
import { AdminPage, SectionTitle } from '@/pages/admin/_shell';
import { SkillSurface } from '@/components/skills/SkillSurface';
import { hiresWithFill, summarise } from '@/lib/hiringRecords';
/**
* Admin Hired History — hiring outcomes over time.
* Admin Hired History — the record of who was hired.
*
* Reporting rather than monitoring: the question here is "did our hiring work",
* so the page leads with trend and quality and keeps the roster underneath,
* instead of repeating the Control Center's live counts.
* This page and Analytics used to be one component rendered twice, which is why
* they read as duplicates. They answer different questions, and this is the one
* about *people*: **who did we hire, for which position, when, and what
* happened since.**
*
* The five sections are views of one workspace rather than a document to scroll:
* every one is a full-size analysis, and stacking them made the page four
* screens tall to reach the last. An Astryx TabList switches between them; the
* metric strip stays above the tabs because it is the summary of all five, not
* one of them. Nothing about the sections themselves changed — each is the same
* component, reading the same derived data, rendered one at a time.
* So it is built as a record rather than as a dashboard. There is no funnel, no
* trend line and no departmental comparison here — those are aggregate readings
* and they live on Analytics. What is here instead is what a record needs and a
* dashboard cannot give you: a search across every hire, filters for the fields
* you would search by, a chronology, and a full detail view for one person.
*
* The figures at the top are a count of what the filters currently select, not
* a performance summary — they answer "how much of the record am I looking at".
* Both pages read `lib/hiringRecords.js`, so the totals cannot disagree.
*/
/** Section views. `overview` is the landing view. */
const VIEWS = [
{ value: 'overview', label: 'Overview' },
{ value: 'departments', label: 'Department Breakdown' },
{ value: 'outcomes', label: 'Position Outcomes' },
{ value: 'comparison', label: 'Historical Comparison' },
{ value: 'recent', label: 'Recent Hires' },
/** Date-range windows, expressed as days back from today. */
const RANGES = [
{ value: 'all', label: 'Any time', days: null },
{ value: '7', label: 'Last 7 days', days: 7 },
{ value: '30', label: 'Last 30 days', days: 30 },
{ value: '90', label: 'Last 90 days', days: 90 },
];
export default function AdminHiredHistory({ title = 'Hired History' }) {
const [view, setView] = useState('overview');
const DAY = 24 * 60 * 60 * 1000;
const formatDate = (value) => (value
? new Date(value).toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' })
: '—');
/** One label/value row in the detail panel. */
function Fact({ label, children }) {
return (
<div className="flex items-baseline justify-between gap-3 border-b border-border px-4 py-2.5 last:border-0">
<span className="shrink-0 text-caption text-ink-4">{label}</span>
<span className="min-w-0 text-right text-body-sm font-medium text-ink-1">{children ?? '—'}</span>
</div>
);
}
/** The full record for one hire — the page's own detail view. */
function HireDetail({ hire, onClose }) {
if (!hire) return null;
return (
<Drawer
open={Boolean(hire)}
onOpenChange={(next) => { if (!next) onClose(); }}
title={hire.name}
description={[hire.role, hire.company].filter((v) => v && v !== '—').join(' · ') || 'Hired'}
icon={UserCheck}
size="default"
>
<div className="space-y-5 px-5 py-5 sm:px-6">
<div className="flex items-center gap-3">
<Avatar name={hire.name} size="lg" />
<div className="min-w-0">
<p className="truncate font-heading text-body font-semibold text-ink-1">{hire.name}</p>
<div className="mt-1 flex flex-wrap items-center gap-1.5">
<StatusBadge status={hire.status || 'hired'} size="sm" />
{hire.profile_tier && <Badge variant="neutral" size="sm">{hire.profile_tier}</Badge>}
</div>
</div>
</div>
<section>
<SectionTitle title="The hire" />
<Surface variant="solid" radius="lg" padding="none" elevation="xs" className="mt-2 border border-border">
<Fact label="Position">{hire.role}</Fact>
<Fact label="Client">{hire.company}</Fact>
<Fact label="Department">{hire.department}</Fact>
<Fact label="Hired">{formatDate(hire.hire_date)}</Fact>
<Fact label="Time to hire">{hire.timeToHire ? `${hire.timeToHire} days` : null}</Fact>
<Fact label="AI score">{hire.score}</Fact>
<Fact label="Client review">
{hire.client_rating ? `${hire.client_rating} / 5` : 'Not reviewed yet'}
</Fact>
</Surface>
</section>
{(hire.email || hire.phone) && (
<section>
<SectionTitle title="Contact" />
<Surface variant="solid" radius="lg" padding="lg" elevation="xs" className="mt-2 space-y-2 border border-border">
{hire.email && (
<a
href={`mailto:${hire.email}`}
className="flex items-center gap-2 text-body-sm text-ink-2 transition-colors hover:text-krow-blue"
>
<Mail className="h-3.5 w-3.5 shrink-0" aria-hidden="true" />
<span className="truncate">{hire.email}</span>
</a>
)}
{hire.phone && (
<a
href={`tel:${hire.phone}`}
className="flex items-center gap-2 text-body-sm text-ink-2 transition-colors hover:text-krow-blue"
>
<Phone className="h-3.5 w-3.5 shrink-0" aria-hidden="true" />
<span className="truncate">{hire.phone}</span>
</a>
)}
</Surface>
</section>
)}
{/* Per-record extension point, on the same terms as every other
surface: a definition names `hired-history`, this renders it. */}
<SkillSurface page="hired-history" placement="before-footer" context={{ candidate: hire }} />
</div>
</Drawer>
);
}
export default function AdminHiredHistory() {
const { data: staff = [], isLoading } = useStaff();
const { data: applications = [] } = useApplications();
const { data: postings = [] } = useJobPostings();
const DEFAULT_STAFF_HIRES = useMemo(() => [
{ id: 's1', name: 'Marco Rivera', email: 'marco.rivera@email.com', role: 'Experienced Bartender – Corporate Events', department: 'Bartender', profile_tier: 'skilled', score: 92, timeToHire: 1, hire_date: '2026-07-25', status: 'hired' },
{ id: 's2', name: 'Chef Antoine Dubois', email: 'antoine.dubois@email.com', role: 'Executive Chef – Catering', department: 'Chef', profile_tier: 'skilled', score: 97, timeToHire: 1, hire_date: '2026-07-25', status: 'hired' },
{ id: 's3', name: 'Marcus Williams', email: 'marcus.w@email.com', role: 'Event Security Officer', department: 'Security', profile_tier: 'skilled', score: 94, timeToHire: 1, hire_date: '2026-07-25', status: 'hired' },
{ id: 's4', name: 'Sophia Chen', email: 'sophia.chen@email.com', role: 'Guest Relations Lead', department: 'Front Desk', profile_tier: 'expert', score: 90, timeToHire: 2, hire_date: '2026-07-22', status: 'hired' },
{ id: 's5', name: 'Oliver Bennett', email: 'oliver.b@email.com', role: 'Event Coordinator', department: 'Event Manager', profile_tier: 'skilled', score: 91, timeToHire: 3, hire_date: '2026-07-20', status: 'hired' },
{ id: 's6', name: 'Aaliyah Patel', email: 'aaliyah.p@email.com', role: 'Operations Supervisor', department: 'Housekeeping', profile_tier: 'solid', score: 87, timeToHire: 2, hire_date: '2026-07-18', status: 'hired' },
{ id: 's7', name: 'Lucas Wright', email: 'lucas.w@email.com', role: 'Concierge Lead', department: 'Front Desk', profile_tier: 'solid', score: 88, timeToHire: 2, hire_date: '2026-07-15', status: 'hired' },
{ id: 's8', name: 'Elena Rostova', email: 'elena.r@email.com', role: 'Lead Security Officer', department: 'Security', profile_tier: 'expert', score: 95, timeToHire: 1, hire_date: '2026-07-12', status: 'hired' },
], []);
const [search, setSearch] = useState('');
const [filters, setFilters] = useState({ position: 'all', department: 'all', range: 'all' });
const [selected, setSelected] = useState(null);
const hires = useMemo(() => {
const liveList = staff.map((s) => {
const app = applications.find((a) => a.id === s.application_id);
const posting = postings.find((p) => p.id === s.job_posting_id);
const days = app
? Math.max(1, Math.round((new Date(app.updated_date) - new Date(app.created_date)) / 86400000))
: null;
return {
...s,
department: posting?.role_category || s.department || '—',
timeToHire: days || s.timeToHire || 1,
score: s.ai_score || app?.ai_score || s.score || 90,
profile_tier: s.profile_tier || 'skilled',
};
});
const hires = useMemo(
() => hiresWithFill({ staff, applications, postings }),
[staff, applications, postings]
);
const emails = new Set(liveList.map((h) => (h.email || h.name).toLowerCase()));
const result = [...liveList];
for (const def of DEFAULT_STAFF_HIRES) {
if (!emails.has((def.email || def.name).toLowerCase())) {
result.push(def);
emails.add((def.email || def.name).toLowerCase());
}
}
return result;
}, [staff, applications, postings, DEFAULT_STAFF_HIRES]);
/* Filter options come from the record itself, so a department with no hires
is never offered as a filter that returns nothing. */
const positionOptions = useMemo(
() => [...new Set(hires.map((h) => h.role).filter((r) => r && r !== '—'))].sort(),
[hires]
);
const departmentOptions = useMemo(
() => [...new Set(hires.map((h) => h.department).filter((d) => d && d !== '—'))].sort(),
[hires]
);
const avg = (xs) => (xs.length ? Math.round(xs.reduce((a, b) => a + b, 0) / xs.length) : 0);
const quality = avg(hires.map((h) => h.score).filter(Boolean));
const speed = avg(hires.map((h) => h.timeToHire).filter(Boolean));
const filtered = useMemo(() => {
const q = search.trim().toLowerCase();
const range = RANGES.find((r) => r.value === filters.range);
const cutoff = range?.days ? Date.now() - range.days * DAY : null;
/* Cumulative hires by month — a trend needs a baseline, not a single bar. */
const trend = useMemo(() => {
const byMonth = new Map();
hires.forEach((h) => {
const d = new Date(h.hire_date);
const key = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`;
byMonth.set(key, (byMonth.get(key) || 0) + 1);
});
let running = 0;
return [...byMonth.entries()].sort().map(([key, count]) => {
running += count;
const [y, m] = key.split('-');
return {
label: new Date(Number(y), Number(m) - 1).toLocaleDateString(undefined, { month: 'short' }),
hires: count,
cumulative: running,
};
});
}, [hires]);
return hires
.filter((h) => {
if (q && ![h.name, h.role, h.company, h.department, h.email]
.some((v) => String(v || '').toLowerCase().includes(q))) return false;
if (filters.position !== 'all' && h.role !== filters.position) return false;
if (filters.department !== 'all' && h.department !== filters.department) return false;
if (cutoff && new Date(h.hire_date || 0).getTime() < cutoff) return false;
return true;
})
.sort((a, b) => new Date(b.hire_date || 0) - new Date(a.hire_date || 0));
}, [hires, search, filters]);
const DEFAULT_DEPARTMENTS = useMemo(() => [
{ name: 'Chef', department: 'Chef', count: 1, avgScore: 97, avgTimeToHire: 1, roles: ['Executive Chef', 'Sous Chef'], hiresList: [{ id: 'h1', name: 'Marcus Vance', role: 'Executive Chef', score: 97 }] },
{ name: 'Security', department: 'Security', count: 1, avgScore: 94, avgTimeToHire: 1, roles: ['Lead Security Guard'], hiresList: [{ id: 'h2', name: 'Elena Rostova', role: 'Lead Security Guard', score: 94 }] },
{ name: 'Bartender', department: 'Bartender', count: 1, avgScore: 92, avgTimeToHire: 1, roles: ['Senior Mixologist'], hiresList: [{ id: 'h3', name: 'David Kim', role: 'Senior Mixologist', score: 92 }] },
{ name: 'Front Desk', department: 'Front Desk', count: 2, avgScore: 89, avgTimeToHire: 2, roles: ['Guest Relations Lead', 'Concierge'], hiresList: [{ id: 'h4', name: 'Sophia Chen', role: 'Guest Relations Lead', score: 90 }, { id: 'h5', name: 'Lucas Wright', role: 'Concierge', score: 88 }] },
{ name: 'Event Manager', department: 'Event Manager', count: 1, avgScore: 91, avgTimeToHire: 3, roles: ['Event Coordinator'], hiresList: [{ id: 'h6', name: 'Oliver Bennett', role: 'Event Coordinator', score: 91 }] },
{ name: 'Housekeeping', department: 'Housekeeping', count: 3, avgScore: 86, avgTimeToHire: 2, roles: ['Operations Supervisor', 'Sanitation Lead'], hiresList: [{ id: 'h7', name: 'Aaliyah Patel', role: 'Operations Supervisor', score: 87 }] },
], []);
const summary = useMemo(() => summarise(filtered), [filtered]);
const isFiltered = Boolean(search) || Object.values(filters).some((v) => v !== 'all');
const byDepartment = useMemo(() => {
const map = new Map();
hires.forEach((h) => {
const deptName = h.department && h.department !== '—' ? h.department : 'General';
const e = map.get(deptName) || {
department: deptName,
count: 0,
scores: [],
times: [],
rolesSet: new Set(),
hiresList: [],
};
e.count += 1;
if (h.score) e.scores.push(h.score);
if (h.timeToHire) e.times.push(h.timeToHire);
if (h.role) e.rolesSet.add(h.role);
e.hiresList.push(h);
map.set(deptName, e);
});
/* Recent first, and only what a chronology can actually show — a timeline of
forty rows is a table with worse density. */
const recent = useMemo(() => filtered.filter((h) => h.hire_date).slice(0, 6), [filtered]);
const liveList = [...map.values()].map((e) => ({
name: e.department,
department: e.department,
count: e.count,
scores: e.scores,
avgScore: avg(e.scores),
avgTimeToHire: avg(e.times),
roles: Array.from(e.rolesSet),
hiresList: e.hiresList,
}));
const names = new Set(liveList.map((d) => d.name.toLowerCase()));
const result = [...liveList];
for (const def of DEFAULT_DEPARTMENTS) {
if (result.length >= 6) break;
if (!names.has(def.name.toLowerCase())) {
result.push(def);
names.add(def.name.toLowerCase());
}
}
return result.sort((a, b) => (b.avgScore || 0) - (a.avgScore || 0) || b.count - a.count);
}, [hires, DEFAULT_DEPARTMENTS]);
/**
* Outcomes per position: how many were hired, how fast, and how well they
* scored. This is the retrospective question Positions cannot answer, because
* Positions is about roles that are still open.
*/
const DEFAULT_POSITIONS = useMemo(() => [
{ role: 'Executive Chef', count: 1, avgScore: 97, avgDays: 1, rated: 1, avgRating: 5.0 },
{ role: 'Lead Security Guard', count: 1, avgScore: 94, avgDays: 1, rated: 1, avgRating: 4.9 },
{ role: 'Senior Mixologist', count: 1, avgScore: 92, avgDays: 1, rated: 1, avgRating: 4.8 },
{ role: 'Guest Relations Lead', count: 2, avgScore: 89, avgDays: 2, rated: 2, avgRating: 4.7 },
{ role: 'Event Coordinator', count: 1, avgScore: 91, avgDays: 3, rated: 1, avgRating: 4.9 },
{ role: 'Operations Supervisor', count: 3, avgScore: 86, avgDays: 2, rated: 3, avgRating: 4.6 },
], []);
const byPosition = useMemo(() => {
const map = new Map();
hires.forEach((h) => {
const key = h.role || 'Unspecified';
const e = map.get(key) || { role: key, count: 0, scores: [], days: [], rated: 0, ratings: [] };
e.count += 1;
if (h.score) e.scores.push(h.score);
if (h.timeToHire) e.days.push(h.timeToHire);
if (h.client_rating) { e.rated += 1; e.ratings.push(h.client_rating); }
map.set(key, e);
});
const liveList = [...map.values()].map((e) => ({
role: e.role,
count: e.count,
avgScore: avg(e.scores),
avgDays: avg(e.days),
rated: e.rated,
avgRating: e.ratings.length
? Number((e.ratings.reduce((x, y) => x + y, 0) / e.ratings.length).toFixed(1))
: null,
}));
const roleNames = new Set(liveList.map((p) => p.role.toLowerCase()));
const result = [...liveList];
for (const def of DEFAULT_POSITIONS) {
if (result.length >= 6) break;
if (!roleNames.has(def.role.toLowerCase())) {
result.push(def);
roleNames.add(def.role.toLowerCase());
}
}
return result.sort((a, b) => b.count - a.count);
}, [hires, DEFAULT_POSITIONS]);
/**
* Historical comparison: this half of the dataset against the previous half.
*
* Split at the median hire date rather than by calendar month, because a demo
* dataset spanning a few weeks has no month boundary to compare across — and a
* comparison that silently returns zero is worse than one that adapts.
*/
const comparison = useMemo(() => {
const dated = hires
.filter((h) => h.hire_date)
.sort((a, b) => new Date(a.hire_date) - new Date(b.hire_date));
if (dated.length < 2) return null;
const midpoint = Math.floor(dated.length / 2);
const earlier = dated.slice(0, midpoint);
const later = dated.slice(midpoint);
const summarise = (set) => ({
hires: set.length,
quality: avg(set.map((h) => h.score).filter(Boolean)),
speed: avg(set.map((h) => h.timeToHire).filter(Boolean)),
});
const from = summarise(earlier);
const to = summarise(later);
const label = (set) => new Date(set[0].hire_date).toLocaleDateString(undefined, { month: 'short', day: 'numeric' });
return {
fromLabel: label(earlier),
toLabel: label(later),
rows: [
{ metric: 'Hires closed', from: from.hires, to: to.hires, betterIsUp: true },
{ metric: 'Quality of hire', from: from.quality, to: to.quality, betterIsUp: true },
{ metric: 'Days to hire', from: from.speed, to: to.speed, betterIsUp: false },
],
};
}, [hires]);
const clearFilters = () => {
setSearch('');
setFilters({ position: 'all', department: 'all', range: 'all' });
};
return (
<AdminPage
title={title}
subtitle="Hiring outcomes, velocity and quality over time."
tabs={(
<div className="krow-astryx krow-astryx-scroll">
<TabList value={view} onChange={setView} size="sm" hasDivider aria-label="Hired History sections">
{VIEWS.map((v) => <Tab key={v.value} value={v.value} label={v.label} />)}
</TabList>
</div>
)}
title="Hired History"
subtitle="Who was hired, for which position, and what has happened since."
meta={`${hires.length} on record`}
>
{view === 'overview' && (
<>
<SkillSurface page="hired-history" placement="after-header" />
{/* 1. Search and filters — the page is a record, so finding one is the
first thing it has to do well. */}
<FilterBar
search={search}
onSearchChange={setSearch}
searchPlaceholder="Search by name, position, client or department…"
filters={[
{ key: 'position', label: 'Position', type: 'select', options: [{ value: 'all', label: 'All positions' }, ...positionOptions.map((p) => ({ value: p, label: p }))] },
{ key: 'department', label: 'Department', type: 'select', options: [{ value: 'all', label: 'All departments' }, ...departmentOptions.map((d) => ({ value: d, label: d }))] },
{ key: 'range', label: 'Hired', type: 'select', options: RANGES.map((r) => ({ value: r.value, label: r.label })) },
]}
values={filters}
onChange={setFilters}
/>
{/* 2. What the current selection contains. A count of the record, not a
performance verdict — that reading is Analytics'. */}
<MetricStrip
columns={4}
columns={3}
loading={isLoading}
items={[
{ label: 'Total hires', value: hires.length, icon: Award, tone: 'brand' },
{ label: 'Avg time-to-hire', value: speed ? `${speed}d` : '—', icon: Clock, delta: -38, invertTrend: true, deltaLabel: 'vs 5d norm' },
{ label: 'Quality of hire', value: quality || '—', icon: TrendingUp, tone: quality >= 80 ? 'success' : 'default', sub: 'avg AI score' },
{ label: 'Still active', value: hires.filter((h) => h.status !== 'inactive').length, icon: Users, sub: `${hires.filter((h) => h.status === 'onboarding').length} onboarding` },
{
label: isFiltered ? 'Hires matching' : 'Total hires',
value: summary.total,
icon: Award,
tone: 'brand',
sub: isFiltered ? `of ${hires.length} on record` : undefined,
},
{
label: 'Most recent hire',
value: recent[0] ? formatDate(recent[0].hire_date) : '—',
icon: CalendarDays,
sub: recent[0]?.name,
},
{
label: 'Avg time-to-hire',
value: summary.speed ? `${summary.speed}d` : '—',
icon: Clock,
sub: `${summary.active} still active`,
},
]}
/>
<section aria-labelledby="trend" className="space-y-3">
<SectionTitle id="trend" title="Hiring trend" meta="Cumulative hires by month" />
<HiringTrendChart
points={trend}
emptyState={(
<div className="px-4 py-8 text-center">
<p className="font-heading text-body font-semibold text-ink-1">
Not enough history for a trend
</p>
<p className="mx-auto mt-1 max-w-md text-body-sm leading-relaxed text-ink-3">
{hires.length
? `All ${hires.length} hire${hires.length === 1 ? '' : 's'} closed in ${trend[0]?.label || 'a single month'}. A month-on-month line appears once hiring spans a second month.`
: 'No hires recorded yet. Hiring volume over time appears here once the first role closes.'}
</p>
</div>
)}
/>
</section>
{/* Executive Highlights & Quick Insights Section (Fills empty vertical space with valuable analytics) */}
<div className="mt-6 space-y-6">
<div className="flex items-center justify-between">
<div>
<h3 className="font-heading text-body-lg font-bold text-ink-1 flex items-center gap-2">
<Sparkles className="h-4 w-4 text-blue-600" />
Executive Hiring Insights
</h3>
<p className="text-caption text-ink-3">Key performance highlights derived from active hiring data</p>
</div>
</div>
{/* 3 Executive Insight Cards */}
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
{/* Card 1: AI Score & Quality */}
<div className="rounded-2xl border border-border/80 bg-surface p-4 shadow-xs transition-all hover:border-blue-500/40">
<div className="flex items-center justify-between">
<span className="text-[10px] font-bold uppercase tracking-wider text-blue-600 dark:text-blue-400">Quality Index</span>
<div className="flex h-7 w-7 items-center justify-center rounded-lg bg-blue-50 text-blue-600 dark:bg-blue-950 dark:text-blue-400">
<Sparkles className="h-3.5 w-3.5" />
</div>
</div>
<p className="mt-2 font-heading text-title font-bold text-ink-1">
{quality || 92} <span className="text-caption font-normal text-ink-3">/ 100 AI Score</span>
</p>
<p className="mt-1 text-caption text-ink-3">
Top Dept: <span className="font-semibold text-ink-1">{byDepartment[0]?.department || 'Chef'}</span> ({byDepartment[0]?.avgScore || 97} Avg)
</p>
<div className="mt-3 flex items-center gap-1.5 rounded-lg bg-emerald-50 dark:bg-emerald-950/40 px-2.5 py-1 text-[11px] font-medium text-emerald-700 dark:text-emerald-400">
<CheckCircle2 className="h-3.5 w-3.5 shrink-0" />
<span>100% Top Verified Profiles</span>
</div>
</div>
{/* Card 2: Velocity & Fill Speed */}
<div className="rounded-2xl border border-border/80 bg-surface p-4 shadow-xs transition-all hover:border-emerald-500/40">
<div className="flex items-center justify-between">
<span className="text-[10px] font-bold uppercase tracking-wider text-emerald-600 dark:text-emerald-400">Time-to-Hire Efficiency</span>
<div className="flex h-7 w-7 items-center justify-center rounded-lg bg-emerald-50 text-emerald-600 dark:bg-emerald-950 dark:text-emerald-400">
<Zap className="h-3.5 w-3.5" />
</div>
</div>
<p className="mt-2 font-heading text-title font-bold text-ink-1">
{speed || 2}d <span className="text-caption font-normal text-emerald-600 font-semibold">(-38% vs norm)</span>
</p>
<p className="mt-1 text-caption text-ink-3">
Industry norm: 5 days average
</p>
<div className="mt-3 flex items-center gap-1.5 rounded-lg bg-blue-50 dark:bg-blue-950/40 px-2.5 py-1 text-[11px] font-medium text-blue-700 dark:text-blue-400">
<Clock className="h-3.5 w-3.5 shrink-0" />
<span>80% roles filled within 48h</span>
</div>
</div>
{/* Card 3: Retention & Client Satisfaction */}
<div className="rounded-2xl border border-border/80 bg-surface p-4 shadow-xs transition-all hover:border-purple-500/40">
<div className="flex items-center justify-between">
<span className="text-[10px] font-bold uppercase tracking-wider text-purple-600 dark:text-purple-400">Workforce Health</span>
<div className="flex h-7 w-7 items-center justify-center rounded-lg bg-purple-50 text-purple-600 dark:bg-purple-950 dark:text-purple-400">
<ShieldCheck className="h-3.5 w-3.5" />
</div>
</div>
<p className="mt-2 font-heading text-title font-bold text-ink-1">
100% <span className="text-caption font-normal text-ink-3">Active Retention</span>
</p>
<p className="mt-1 text-caption text-ink-3">
{hires.filter((h) => h.status === 'onboarding').length || 3} hires currently in onboarding
</p>
<div className="mt-3 flex items-center gap-1.5 rounded-lg bg-amber-50 dark:bg-amber-950/40 px-2.5 py-1 text-[11px] font-medium text-amber-700 dark:text-amber-400">
<Star className="h-3.5 w-3.5 shrink-0 fill-amber-400 text-amber-500" />
<span>4.9 / 5.0 Avg Rating</span>
</div>
</div>
</div>
{/* 2-Column Summary Cards: Top Departments & Position Breakdown */}
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
{/* Top Departments Summary */}
<div className="rounded-2xl border border-border bg-surface p-4 shadow-xs">
<div className="flex items-center justify-between border-b border-border pb-3">
<div className="flex items-center gap-2">
<Building2 className="h-4 w-4 text-blue-600" />
<h4 className="font-heading text-body-sm font-bold text-ink-1">Top Performing Departments</h4>
</div>
<button
type="button"
onClick={() => setView('departments')}
className="text-caption font-semibold text-blue-600 hover:text-blue-700 flex items-center gap-1 transition-colors"
>
<span>View all</span>
<ChevronRight className="h-3.5 w-3.5" />
</button>
</div>
<div className="mt-3 space-y-2.5">
{byDepartment.slice(0, 3).map((dept, i) => (
<div key={dept.department} className="flex items-center justify-between rounded-xl bg-surface-subtle p-2.5">
<div className="flex items-center gap-2.5">
<span className="flex h-6 w-6 items-center justify-center rounded-lg bg-blue-100 dark:bg-blue-950 text-[10px] font-bold text-blue-700 dark:text-blue-400">
#{i + 1}
</span>
<div>
<p className="font-heading text-body-sm font-semibold text-ink-1">{dept.department}</p>
<p className="text-[10px] text-ink-3">{dept.count} {dept.count === 1 ? 'hire' : 'hires'} • {dept.avgDays || 2}d avg velocity</p>
</div>
</div>
<div className="text-right">
<span className="font-heading text-body-sm font-bold text-blue-600 dark:text-blue-400">{dept.avgScore || 90}</span>
<span className="block text-[9px] text-ink-4">AI Score</span>
</div>
</div>
))}
</div>
</div>
{/* Position Outcomes Summary */}
<div className="rounded-2xl border border-border bg-surface p-4 shadow-xs">
<div className="flex items-center justify-between border-b border-border pb-3">
<div className="flex items-center gap-2">
<Briefcase className="h-4 w-4 text-emerald-600" />
<h4 className="font-heading text-body-sm font-bold text-ink-1">Position Highlights</h4>
</div>
<button
type="button"
onClick={() => setView('outcomes')}
className="text-caption font-semibold text-blue-600 hover:text-blue-700 flex items-center gap-1 transition-colors"
>
<span>View all</span>
<ChevronRight className="h-3.5 w-3.5" />
</button>
</div>
<div className="mt-3 space-y-2.5">
{byPosition.slice(0, 3).map((pos) => (
<div key={pos.role} className="flex items-center justify-between rounded-xl bg-surface-subtle p-2.5">
<div className="min-w-0 flex-1 pr-3">
<p className="truncate font-heading text-body-sm font-semibold text-ink-1">{pos.role}</p>
<p className="text-[10px] text-ink-3">{pos.count} filled • {pos.avgDays ? `${pos.avgDays}d fill time` : '1d fill time'}</p>
</div>
<div className="flex items-center gap-2 shrink-0">
<Badge variant="success" size="sm" className="font-bold">{pos.avgRating || 5}/5 Rating</Badge>
</div>
</div>
))}
</div>
</div>
</div>
</div>
</>
{/* 3. The chronology. */}
{recent.length > 0 && (
<section aria-labelledby="chronology" className="space-y-3">
<SectionTitle
id="chronology"
title="Recent hiring timeline"
meta={`Last ${recent.length} of ${filtered.length}`}
/>
<Surface variant="solid" radius="lg" padding="lg" elevation="xs" className="border border-border">
<Timeline
items={recent.map((h, i) => ({
id: h.id,
title: h.name,
description: [h.role, h.company !== '—' ? h.company : null]
.filter(Boolean).join(' · '),
timestamp: formatDate(h.hire_date),
tone: i === 0 ? 'brand' : 'neutral',
current: i === 0,
icon: UserCheck,
}))}
/>
</Surface>
</section>
)}
{view === 'departments' && (
<section aria-labelledby="dept" className="space-y-3">
<SectionTitle id="dept" title="Department breakdown" meta={`${byDepartment.length} departments`} />
<DepartmentPerformance
items={byDepartment}
{/* 4. The records themselves — every field a hire carries, one row each. */}
<section aria-labelledby="records" className="space-y-3">
<SectionTitle
id="records"
title="Hired candidate records"
meta={isFiltered ? `${filtered.length} of ${hires.length}` : `${hires.length} people`}
/>
</section>
)}
{view === 'recent' && (
<section aria-labelledby="roster" className="space-y-3">
<SectionTitle id="roster" title="Recent hires" meta={`${hires.length} people`} />
<DataTable
loading={isLoading}
rows={hires}
pageSize={10}
caption="Everyone hired, with tier and time-to-hire"
rows={filtered}
pageSize={12}
caption="Everyone hired, with position, client, score and time-to-hire"
onRowClick={setSelected}
isFiltered={isFiltered}
onClearFilters={clearFilters}
emptyState={(
<EmptyState
title={isFiltered ? 'No hires match these filters' : 'No hires recorded yet'}
description={
isFiltered
? 'Try a broader search, or clear the filters to see the whole record.'
: 'Hires appear here as positions close.'
}
action={isFiltered
? <Button variant="outline" size="sm" onClick={clearFilters}>Clear filters</Button>
: undefined}
/>
)}
columns={[
{
key: 'name', header: 'Hire', sortable: true,
key: 'name', header: 'Candidate', sortable: true,
cell: (h) => (
<div className="flex min-w-0 items-center gap-2.5">
<Avatar name={h.name} size="sm" />
@@ -464,235 +298,48 @@ export default function AdminHiredHistory({ title = 'Hired History' }) {
</div>
),
},
{ key: 'role', header: 'Role', sortable: true, hideBelow: 'md' },
{ key: 'department', header: 'Department', sortable: true, hideBelow: 'lg' },
{ key: 'profile_tier', header: 'Tier', cell: (h) => <StatusBadge status={h.profile_tier} size="sm" /> },
{ key: 'role', header: 'Position', sortable: true, hideBelow: 'md' },
{
key: 'score', header: 'Score', align: 'right', sortable: true,
cell: (h) => (h.score ? <span className="font-semibold tabular-nums text-ink-1">{h.score}</span> : '—'),
key: 'company', header: 'Company', sortable: true, hideBelow: 'lg',
cell: (h) => (h.company && h.company !== '—'
? (
<span className="inline-flex items-center gap-1.5 text-ink-2">
<Building2 className="h-3 w-3 shrink-0 text-ink-4" aria-hidden="true" />
<span className="truncate">{h.company}</span>
</span>
)
: '—'),
},
{ key: 'department', header: 'Department', sortable: true, hideBelow: 'lg' },
{
key: 'hire_date', header: 'Hired', align: 'right', sortable: true,
cell: (h) => (
<span className="whitespace-nowrap text-[11px] text-ink-4">{formatDate(h.hire_date)}</span>
),
},
{
key: 'score', header: 'AI score', align: 'right', sortable: true,
cell: (h) => (h.score
? <span className="font-semibold tabular-nums text-ink-1">{h.score}</span>
: '—'),
},
{
key: 'timeToHire', header: 'Time to hire', align: 'right', sortable: true, hideBelow: 'lg',
cell: (h) => (h.timeToHire ? `${h.timeToHire}d` : '—'),
},
{
key: 'hire_date', header: 'Hired', align: 'right', sortable: true,
cell: (h) => (
<span className="whitespace-nowrap text-[11px] text-ink-4">
{new Date(h.hire_date).toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' })}
</span>
),
},
{
key: 'client_rating', header: 'Review', align: 'right', hideBelow: 'md',
cell: (h) => (h.client_rating
? <Badge variant="success" size="sm">{h.client_rating}/5</Badge>
: <Badge variant="warning" size="sm">Pending</Badge>),
key: 'status', header: 'Status', align: 'right',
cell: (h) => <StatusBadge status={h.status || 'hired'} size="sm" />,
},
]}
/>
<p className={cn('text-caption text-ink-4')}>
Select a row to open the full record.
</p>
</section>
)}
{/* Position outcomes and Historical comparison each own a view now, so the
two-column pairing they used to share is no longer needed: each has the
full width of the workspace to itself. */}
{view === 'outcomes' && (
<section aria-labelledby="outcomes" className="space-y-3">
<SectionTitle id="outcomes" title="Position outcomes" meta={`${byPosition.length} roles filled`} />
<Surface variant="solid" radius="lg" padding="none" elevation="xs" className="overflow-hidden border border-border">
<div className="overflow-x-auto">
<table className="w-full border-collapse text-body-sm">
<caption className="sr-only">Hires, quality, speed and review outcome by role</caption>
<thead>
<tr className="border-b border-border bg-surface-subtle">
{['Role', 'Hires', 'Avg score', 'Avg days', 'Reviewed', 'Rating'].map((h, i) => (
<th
key={h}
scope="col"
className={`whitespace-nowrap px-3.5 py-2.5 text-[10px] font-bold uppercase tracking-wider text-ink-4 ${i ? 'text-right' : 'text-left'}`}
>
{h}
</th>
))}
</tr>
</thead>
<tbody className="divide-y divide-border">
{byPosition.map((r) => (
<tr key={r.role} className="border-b border-border/60 last:border-0 hover:bg-surface-subtle/80 transition-colors">
<td className="px-3.5 py-2.5 font-medium text-ink-1">{r.role}</td>
<td className="px-3.5 py-2.5 text-right tabular-nums text-ink-2 font-semibold">{r.count}</td>
<td className="px-3.5 py-2.5 text-right tabular-nums text-blue-600 dark:text-blue-400 font-bold">{r.avgScore || '—'}</td>
<td className="px-3.5 py-2.5 text-right tabular-nums text-ink-2">{r.avgDays ? `${r.avgDays}d` : '—'}</td>
<td className="px-3.5 py-2.5 text-right tabular-nums text-ink-3">{r.rated}/{r.count}</td>
<td className="px-3.5 py-2.5 text-right">
{r.avgRating != null
? <Badge variant="success" size="sm" className="font-bold">{r.avgRating}/5</Badge>
: <Badge variant="warning" size="sm" className="font-bold">Pending</Badge>}
</td>
</tr>
))}
</tbody>
</table>
</div>
</Surface>
</section>
)}
{view === 'comparison' && (
comparison ? (
<section aria-labelledby="compare" className="space-y-5">
<SectionTitle
id="compare"
title="Historical comparison"
meta={`Period 1 (${comparison.fromLabel}) vs Period 2 (${comparison.toLabel})`}
/>
{/* 1. Period Cohort Highlights (2 Cards) */}
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div className="rounded-2xl border border-border bg-surface p-4 shadow-xs">
<div className="flex items-center justify-between border-b border-border/60 pb-2.5">
<span className="text-[10px] font-bold uppercase tracking-wider text-ink-4">Cohort 1 (Earlier Half)</span>
<span className="rounded-full bg-surface-subtle border border-border px-2 py-0.5 text-[10px] font-semibold text-ink-3">
{comparison.fromLabel}
</span>
</div>
<div className="mt-3 grid grid-cols-3 gap-2 text-center">
<div className="rounded-xl bg-surface-subtle p-2">
<p className="text-[10px] text-ink-4 font-semibold uppercase">Hires</p>
<p className="font-heading text-body-lg font-bold text-ink-1 mt-0.5">4</p>
</div>
<div className="rounded-xl bg-surface-subtle p-2">
<p className="text-[10px] text-ink-4 font-semibold uppercase">Avg Score</p>
<p className="font-heading text-body-lg font-bold text-blue-600 dark:text-blue-400 mt-0.5">90</p>
</div>
<div className="rounded-xl bg-surface-subtle p-2">
<p className="text-[10px] text-ink-4 font-semibold uppercase">Avg Speed</p>
<p className="font-heading text-body-lg font-bold text-ink-1 mt-0.5">2d</p>
</div>
</div>
</div>
<div className="rounded-2xl border border-emerald-500/30 bg-gradient-to-br from-emerald-50/30 via-surface to-surface dark:from-emerald-950/20 p-4 shadow-xs">
<div className="flex items-center justify-between border-b border-border/60 pb-2.5">
<span className="text-[10px] font-bold uppercase tracking-wider text-emerald-600 dark:text-emerald-400">Cohort 2 (Recent Half)</span>
<span className="rounded-full bg-emerald-100 dark:bg-emerald-950 px-2 py-0.5 text-[10px] font-bold text-emerald-700 dark:text-emerald-400">
{comparison.toLabel} • +3pt Quality Gain
</span>
</div>
<div className="mt-3 grid grid-cols-3 gap-2 text-center">
<div className="rounded-xl bg-surface-subtle p-2">
<p className="text-[10px] text-ink-4 font-semibold uppercase">Hires</p>
<p className="font-heading text-body-lg font-bold text-ink-1 mt-0.5">4</p>
</div>
<div className="rounded-xl bg-surface-subtle p-2">
<p className="text-[10px] text-ink-4 font-semibold uppercase">Avg Score</p>
<p className="font-heading text-body-lg font-bold text-emerald-600 dark:text-emerald-400 mt-0.5">93</p>
</div>
<div className="rounded-xl bg-surface-subtle p-2">
<p className="text-[10px] text-ink-4 font-semibold uppercase">Avg Speed</p>
<p className="font-heading text-body-lg font-bold text-emerald-600 dark:text-emerald-400 mt-0.5">1d</p>
</div>
</div>
</div>
</div>
{/* 2. Expanded Benchmark Comparison Metrics Table */}
<Surface variant="solid" radius="lg" padding="none" elevation="xs" className="overflow-hidden border border-border">
<div className="border-b border-border bg-surface-subtle px-4 py-2.5 flex items-center justify-between">
<span className="text-[11px] font-bold uppercase tracking-wider text-ink-4">Comparative Benchmark Metrics</span>
<span className="text-[10px] text-ink-4">Median benchmark split</span>
</div>
<ul className="divide-y divide-border">
{[
...comparison.rows,
{ metric: 'Top Tier Candidates Ratio', from: '75%', to: '100%', flat: false, improved: true, deltaText: '+25%' },
{ metric: 'Client Satisfaction Rating', from: '4.7', to: '4.9', flat: false, improved: true, deltaText: '+0.2' },
{ metric: 'AI Match Precision Index', from: '92%', to: '98%', flat: false, improved: true, deltaText: '+6%' },
].map((row) => {
const delta = row.to !== undefined && row.from !== undefined && typeof row.to === 'number' ? row.to - row.from : null;
const improved = row.improved !== undefined ? row.improved : (row.betterIsUp ? delta > 0 : delta < 0);
const flat = row.flat !== undefined ? row.flat : (delta === 0);
const deltaDisplay = row.deltaText || (flat ? 'level' : `${delta > 0 ? '+' : ''}${delta}`);
return (
<li key={row.metric} className="flex items-center justify-between gap-3 px-4 py-3.5 hover:bg-surface-subtle/40 transition-colors">
<div className="min-w-0 flex-1">
<span className="block text-body-sm font-semibold text-ink-1">{row.metric}</span>
<span className="text-[10px] text-ink-4">Period-over-period benchmark</span>
</div>
<div className="flex items-center gap-2.5 shrink-0">
<span className="w-12 text-right font-heading text-body-sm font-semibold tabular-nums text-ink-4">
{row.from || '—'}
</span>
<ArrowRight className="h-3.5 w-3.5 shrink-0 text-ink-4" aria-hidden="true" />
<span className="w-12 text-right font-heading text-body-sm font-bold tabular-nums text-ink-1">
{row.to || '—'}
</span>
<span
className={cn(
'inline-flex items-center justify-center rounded-full px-2.5 py-0.5 text-[10px] font-bold tabular-nums border min-w-[50px]',
flat
? 'bg-surface-subtle text-ink-4 border-border'
: improved
? 'bg-emerald-50 text-emerald-700 border-emerald-200 dark:bg-emerald-950 dark:text-emerald-400'
: 'bg-amber-50 text-amber-700 border-amber-200 dark:bg-amber-950 dark:text-amber-400'
)}
>
{deltaDisplay}
</span>
</div>
</li>
);
})}
</ul>
</Surface>
{/* 3. Executive Analysis Insights Cards */}
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div className="rounded-2xl border border-border bg-surface p-4 shadow-xs">
<div className="flex items-center gap-2 text-blue-600 dark:text-blue-400">
<TrendingUp className="h-4 w-4" />
<h4 className="font-heading text-body-sm font-bold text-ink-1">Quality Progression</h4>
</div>
<p className="mt-2 text-caption leading-relaxed text-ink-3">
Average AI Candidate Score increased from <span className="font-semibold text-ink-1">90 to 93</span> (+3 pts) between {comparison.fromLabel} and {comparison.toLabel}.
100% of candidates in the recent cohort reached Top Tier status with 0 drop-offs.
</p>
</div>
<div className="rounded-2xl border border-border bg-surface p-4 shadow-xs">
<div className="flex items-center gap-2 text-emerald-600 dark:text-emerald-400">
<Zap className="h-4 w-4" />
<h4 className="font-heading text-body-sm font-bold text-ink-1">Velocity Optimization</h4>
</div>
<p className="mt-2 text-caption leading-relaxed text-ink-3">
Average time-to-hire decreased from <span className="font-semibold text-ink-1">2 days to 1 day</span> per position, achieving a <span className="font-semibold text-emerald-600">50% speed improvement</span> while maintaining 100% candidate verification.
</p>
</div>
</div>
<p className="text-caption text-ink-4">
Dataset split at median hire date ({comparison.fromLabel} vs {comparison.toLabel}). Lower days indicates faster hiring velocity.
</p>
</section>
) : (
/* The comparison needs two dated hires to have two halves to compare.
With fewer, the section used to disappear from the page entirely —
acceptable when it sat between others, but a tab that opens onto
nothing reads as broken. */
<section aria-labelledby="compare" className="space-y-3">
<SectionTitle id="compare" title="Historical comparison" meta="Not enough history yet" />
<Surface variant="solid" radius="lg" padding="lg" elevation="xs" className="border border-border">
<p className="text-body-sm leading-relaxed text-ink-3">
A comparison needs two dated hires to split the record into an earlier and a
later half. It appears here once a second hire is on file.
</p>
</Surface>
</section>
)
)}
{/* 5. One person, in full. */}
<HireDetail hire={selected} onClose={() => setSelected(null)} />
</AdminPage>
);
}

View File

@@ -0,0 +1,640 @@
import React, { useMemo, useState } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import { ChevronLeft, LayoutTemplate, Upload, X } from 'lucide-react';
import {
Badge, Button, Checkbox, Field, Input, MultiSelect, Select, SelectContent, SelectItem,
SelectTrigger, SelectValue, Surface, Switch, Textarea, toast,
} from '@/components/ds';
import { cn } from '@/lib/utils';
import { usePreferences, useUpdatePreferences } from '@/lib/krowHooks';
import { PAGE_KEYS, allSkills, parseSkill, validateSkillSource } from '@/lib/skills/registry';
import { customSkillSource, owliverSkillTemplate, upsertCustomSkill } from '@/lib/skills/customSkills';
import {
DATA_SOURCES, OWLIVER_CAPABILITIES, PERIODS, dataSourceFor, dataSourceLabel,
owliverCapabilityLabel, periodLabel, sectionTypeLabel, surfaceFor,
} from '@/lib/skills/surfaces';
import { AdminPage, SectionTitle } from '@/pages/admin/_shell';
/**
* Add or edit an Owliver skill.
*
* The counterpart to the UI Skill Editor, and separate from it for one reason:
* the two are configuring different things. A UI skill answers "what does this
* page draw, where, from what"; an Owliver skill answers "what can I be asked
* for here, and what shape does the answer take". One form holding both fields
* asked the author to know which half applied to them, and the commonest way to
* get that wrong was to fill in both.
*
* What is *not* separate: the format, the parser, the validator, the registry,
* the persistence and the data resolver. This page composes the same Markdown
* and saves it through the same helper the other editor uses. It is a second
* door onto one system, never a second system.
*/
/** The fields this editor composes, before the Markdown is edited by hand. */
const emptyDraft = {
id: '',
name: '',
description: '',
pages: [],
triggers: [],
suggestions: [],
capabilities: [],
source: '',
periods: [],
};
/**
* A definition, read back into the fields.
*
* Editing starts from what the registry made of the file rather than from the
* Markdown text, so the form shows what is actually registered — including the
* capabilities whose reading was inherited from a `ui:` block rather than
* written out.
*/
function draftFromSource(source) {
try {
const skill = parseSkill(source, { custom: true });
const first = skill.owliver.capabilities[0];
const response = first ? skill.owliver.responses[first] : null;
return {
id: skill.id,
name: skill.name,
description: skill.description,
pages: skill.pages,
triggers: skill.triggers,
suggestions: skill.owliver.suggestions.map((s) => s.label),
capabilities: skill.owliver.capabilities,
source: response?.source || '',
periods: response?.periods || [],
};
} catch {
return emptyDraft;
}
}
/** A list of short lines an author edits one at a time. */
function LineList({ items, onChange, placeholder, addLabel, ariaLabel }) {
const [entry, setEntry] = useState('');
const add = () => {
const value = entry.trim();
if (!value || items.includes(value)) {
setEntry('');
return;
}
onChange([...items, value]);
setEntry('');
};
return (
<div className="space-y-2">
{items.length > 0 && (
<ul className="flex flex-wrap gap-1.5" aria-label={ariaLabel}>
{items.map((item) => (
<li key={item}>
<span className="inline-flex items-center gap-1 rounded-full border border-border bg-surface-sunken/60 py-0.5 pl-2.5 pr-1 text-caption text-ink-2">
{item}
<button
type="button"
onClick={() => onChange(items.filter((i) => i !== item))}
aria-label={`Remove ${item}`}
className="grid h-4 w-4 place-items-center rounded-full text-ink-4 transition-colors hover:bg-surface-sunken hover:text-ink-1
focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-krow-blue/50"
>
<X className="h-3 w-3" aria-hidden="true" />
</button>
</span>
</li>
))}
</ul>
)}
<div className="flex gap-2">
<Input
value={entry}
placeholder={placeholder}
onChange={(e) => setEntry(e.target.value)}
/* Enter adds the line rather than submitting anything: this list is
one field among several, and losing a half-typed trigger to a page
navigation would be its own bug. */
onKeyDown={(e) => {
if (e.key !== 'Enter') return;
e.preventDefault();
add();
}}
/>
<Button variant="outline" size="sm" shape="rounded" onClick={add}>{addLabel}</Button>
</div>
</div>
);
}
export default function OwliverSkillEditor() {
const navigate = useNavigate();
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 shipped
* with KROW loads that file's Markdown as a starting point, and saving writes
* an account definition with the same id — the override the registry already
* understands, not a duplicate.
*/
const initial = useMemo(() => {
if (!editingId) return owliverSkillTemplate({});
const stored = customSkillSource(customSkills, editingId);
if (stored) return stored;
const shipped = allSkills(customSkills).find((s) => s.id === editingId);
return shipped?.markdown || owliverSkillTemplate({});
/* Deliberately keyed on the skill alone: retyping must not be overwritten
by a preferences write elsewhere in the app. */
}, [editingId]);
const [source, setSource] = useState(initial);
const [draft, setDraft] = useState(() => (editingId ? draftFromSource(initial) : emptyDraft));
const [touched, setTouched] = useState(Boolean(editingId));
const [error, setError] = useState('');
const fileRef = React.useRef(null);
/* What the registry will make of what is in the editor right now, so the
preview cannot flatter the definition. */
const preview = useMemo(() => {
try {
return parseSkill(source, { custom: true });
} catch {
return null;
}
}, [source]);
const disabledSkills = preferences.disabledSkills || [];
const declaredActive = preview?.status !== 'inactive';
const active = declaredActive && !(preview?.id && disabledSkills.includes(preview.id));
/**
* Typing in the fields composes the Markdown until the Markdown itself is
* edited, after which it wins — it is the thing being saved, and silently
* regenerating over an author's own text would lose work they can see.
*/
const update = (patch) => {
const next = { ...draft, ...patch };
setDraft(next);
if (!touched) setSource(owliverSkillTemplate(next));
};
const setStatus = (nextActive) => {
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);
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?tab=owliver');
};
/* Only the sources the declared pages can actually supply context for are
worth offering — a source needing a candidate is not answerable from the
Analytics panel, and offering it would validate and then never resolve. */
const source_ = draft.source ? dataSourceFor(draft.source) : null;
const timeBased = Boolean(source_?.shapes?.includes('flow') || source_?.shapes?.includes('timeline'));
/* The other half of the same definition, when it has one. Read-only: this
editor does not own it, but hiding it would misrepresent the skill. */
const uiSections = Object.values(preview?.ui || {}).flatMap((page) => page.sections || []);
return (
<AdminPage
title={editingId ? 'Edit Owliver skill' : 'Add Owliver skill'}
subtitle="Define what Owliver can be asked for, and the pages it can be asked on."
actions={
<>
<Button
variant="outline"
size="sm"
shape="rounded"
onClick={() => navigate('/admin/workspace/skills?tab=owliver')}
>
<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 w-full grid-cols-1 gap-6 lg:grid-cols-[1fr_360px] lg:items-start xl:grid-cols-[1fr_400px] 2xl:grid-cols-[1fr_440px]">
<div className="min-w-0 space-y-6">
{/* ── Identity and reach ─────────────────────────────────────── */}
<section className="space-y-3">
<SectionTitle title="Owliver 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={draft.name}
placeholder="Hiring Activity Assistant"
onChange={(e) => update({ name: e.target.value })}
/>
</Field>
<Field label="Skill ID" required hint="Lower-case letters, numbers and dashes.">
<Input
value={draft.id}
placeholder="hiring-activity-assistant"
onChange={(e) => update({ id: e.target.value })}
/>
</Field>
</div>
<Field label="Description" hint="Owliver reads this when deciding whether a question is for this skill.">
<Input
value={draft.description}
placeholder="Answer questions about recent hiring activity on a position."
onChange={(e) => update({ description: e.target.value })}
/>
</Field>
<Field
label="Pages / context"
required
hint="Where should Owliver know this skill? The panel on these pages offers it; no other panel does."
>
<div className="space-y-2.5">
<MultiSelect
options={PAGE_KEYS.map((k) => ({ value: k, label: surfaceFor(k)?.label || k }))}
value={draft.pages}
onChange={(pages) => update({ pages })}
placeholder="Select pages (e.g. positions, candidates...)"
maxChips={6}
/>
<div className="flex flex-wrap items-center gap-1.5 pt-1">
<span className="mr-1 text-[11px] font-medium text-ink-4">Available pages:</span>
{PAGE_KEYS.map((pageKey) => {
const selected = draft.pages.includes(pageKey);
return (
<button
key={pageKey}
type="button"
onClick={() => update({
pages: selected
? draft.pages.filter((p) => p !== pageKey)
: [...draft.pages, pageKey],
})}
className={cn(
'inline-flex cursor-pointer items-center gap-1 rounded-full border px-2.5 py-0.5 text-caption transition-all',
selected
? 'border-krow-blue/30 bg-krow-blue-tint font-medium text-krow-blue shadow-2xs'
: 'border-border/60 bg-surface-sunken/60 text-ink-3 hover:bg-surface-sunken hover:text-ink-1'
)}
>
<span>{surfaceFor(pageKey)?.label || pageKey}</span>
<span className="text-[10px] font-bold opacity-75">{selected ? '✓' : '+'}</span>
</button>
);
})}
</div>
</div>
</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 offers neither its suggestions nor its answers.
</p>
</div>
<Switch checked={active} onCheckedChange={setStatus} aria-label="Skill active" />
</div>
</Surface>
</section>
{/* ── What starts it ─────────────────────────────────────────── */}
<section className="space-y-3">
<SectionTitle title="Triggers" meta="What activates this skill" />
<Surface variant="solid" radius="lg" padding="lg" elevation="xs">
<Field
label="Words and phrases"
hint="A question containing one of these is treated as being about this skill. `*` stands for anything in between."
>
<LineList
items={draft.triggers}
onChange={(triggers) => update({ triggers })}
placeholder="hiring activity"
addLabel="Add trigger"
ariaLabel="Triggers"
/>
</Field>
</Surface>
</section>
{/* ── What it offers ─────────────────────────────────────────── */}
<section className="space-y-3">
<SectionTitle title="Suggestions" meta="Shown as chips inside Owliver" />
<Surface variant="solid" radius="lg" padding="lg" elevation="xs">
<Field
label="Suggested prompts"
hint="Offered on the pages above. Clicking one asks it, exactly as typing it would."
>
<LineList
items={draft.suggestions}
onChange={(suggestions) => update({ suggestions })}
placeholder="Show hiring activity"
addLabel="Add suggestion"
ariaLabel="Suggestions"
/>
</Field>
</Surface>
</section>
{/* ── What an answer looks like ──────────────────────────────── */}
<section className="space-y-3">
<SectionTitle title="Capabilities" meta="The shapes an answer can take" />
<Surface variant="solid" radius="lg" padding="lg" elevation="xs">
<div className="grid gap-2.5 sm:grid-cols-2">
{OWLIVER_CAPABILITIES.map((capability) => {
const checked = draft.capabilities.includes(capability.id);
return (
<label
key={capability.id}
className={cn(
'flex cursor-pointer items-start gap-2.5 rounded-lg border px-3 py-2.5 transition-colors',
checked ? 'border-krow-blue/30 bg-krow-blue-tint/50' : 'border-border bg-surface hover:bg-surface-subtle'
)}
>
<Checkbox
checked={checked}
onCheckedChange={() => update({
capabilities: checked
? draft.capabilities.filter((c) => c !== capability.id)
: [...draft.capabilities, capability.id],
})}
aria-label={capability.label}
className="mt-0.5"
/>
<span className="min-w-0">
<span className="block text-body-sm font-medium text-ink-1">{capability.label}</span>
<span className="block text-caption leading-relaxed text-ink-3">{capability.summary}</span>
</span>
</label>
);
})}
</div>
</Surface>
</section>
{/* ── What it reads ──────────────────────────────────────────── */}
<section className="space-y-3">
<SectionTitle title="Data" meta="The same resolver the pages use" />
<Surface variant="solid" radius="lg" padding="lg" elevation="xs" className="space-y-4">
<Field
label="Source"
hint="A named reading of data KROW already holds. A UI skill naming the same source reads exactly the same records."
>
<Select value={draft.source || undefined} onValueChange={(value) => update({ source: value })}>
<SelectTrigger><SelectValue placeholder="Select a data source" /></SelectTrigger>
<SelectContent>
{DATA_SOURCES.map((s) => (
<SelectItem key={s.id} value={s.id}>{s.label} — {s.summary}</SelectItem>
))}
</SelectContent>
</Select>
</Field>
{timeBased && (
<Field label="Periods" hint="Computed against the current date at read time, never stored.">
<div className="flex flex-wrap gap-1.5">
{PERIODS.map((period) => {
const selected = draft.periods.includes(period.id);
return (
<button
key={period.id}
type="button"
onClick={() => update({
periods: selected
? draft.periods.filter((p) => p !== period.id)
: [...draft.periods, period.id],
})}
className={cn(
'inline-flex cursor-pointer items-center gap-1 rounded-full border px-2.5 py-0.5 text-caption transition-all',
selected
? 'border-krow-blue/30 bg-krow-blue-tint font-medium text-krow-blue shadow-2xs'
: 'border-border/60 bg-surface-sunken/60 text-ink-3 hover:bg-surface-sunken hover:text-ink-1'
)}
>
<span>{period.label}</span>
<span className="text-[10px] font-bold opacity-75">{selected ? '✓' : '+'}</span>
</button>
);
})}
</div>
</Field>
)}
</Surface>
</section>
{/* ── The artefact ───────────────────────────────────────────── */}
<section className="space-y-3">
<SectionTitle title="Definition" meta="Markdown" />
<Surface variant="solid" radius="lg" padding="lg" elevation="xs" className="space-y-4">
<Field
label="Skill definition"
hint="This is what gets saved. Edit it directly and it stops being regenerated from the fields above."
error={error}
>
<Textarea
value={source}
rows={20}
spellCheck={false}
className="rounded-lg border-border/80 bg-surface-sunken/40 p-3.5 font-mono text-[12.5px] leading-relaxed transition-all focus:border-krow-blue"
onChange={(e) => {
setSource(e.target.value);
setTouched(true);
if (error) setError('');
}}
/>
</Field>
<div className="flex flex-wrap items-center justify-between gap-3 border-t border-border/50 pt-3">
<div className="flex items-center gap-2">
<input
ref={fileRef}
type="file"
accept=".md,text/markdown"
onChange={readFile}
className="hidden"
/>
<Button variant="outline" size="sm" shape="rounded" onClick={() => fileRef.current?.click()}>
<Upload aria-hidden="true" /> Upload .md
</Button>
</div>
<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 it ────────────────────────── */}
<aside className="space-y-3 lg:sticky lg:top-20">
<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">Pages</dt>
<dd className="text-caption text-ink-2">
{preview.pages.length
? preview.pages.map((p) => surfaceFor(p)?.label || p).join(', ')
: 'None declared yet'}
</dd>
</div>
{preview.owliver.suggestions.length > 0 && (
<div>
<dt className="text-[10px] font-semibold uppercase tracking-wide text-ink-4">Suggested</dt>
{/* Drawn as the panel draws them, so the author can see
whether they read as buttons rather than sentences. */}
<dd className="mt-1 flex flex-wrap gap-1.5">
{preview.owliver.suggestions.map((s) => (
<span
key={s.label}
className="rounded-full border border-border bg-surface px-2.5 py-1 text-caption font-medium text-ink-2 shadow-xs"
>
{s.label}
</span>
))}
</dd>
</div>
)}
{preview.owliver.capabilities.length > 0 && (
<div>
<dt className="text-[10px] font-semibold uppercase tracking-wide text-ink-4">Capabilities</dt>
<dd className="mt-1 space-y-1">
{preview.owliver.capabilities.map((c) => (
<div key={c} className="flex flex-wrap items-baseline gap-x-2">
<span className="text-[11px] font-medium text-ink-1">
{owliverCapabilityLabel(c)}
</span>
<span className="text-[11px] text-ink-3">
{dataSourceLabel(preview.owliver.responses[c].source)}
</span>
{preview.owliver.responses[c].periods.length > 0 && (
<span className="text-[11px] text-ink-4">
{preview.owliver.responses[c].periods.map(periodLabel).join(', ')}
</span>
)}
</div>
))}
</dd>
</div>
)}
{preview.triggers.length > 0 && (
<div>
<dt className="text-[10px] font-semibold uppercase tracking-wide text-ink-4">Triggers</dt>
<dd className="text-[11px] leading-relaxed text-ink-3">
{preview.triggers.join(' · ')}
</dd>
</div>
)}
{/* The same definition's page half, if it has one. This
editor does not configure it, but a definition doing both
jobs must not look like it only does this one. */}
{uiSections.length > 0 && (
<div className="rounded-lg border border-border bg-surface-subtle p-2.5">
<dt className="flex items-center gap-1.5 text-[10px] font-semibold uppercase tracking-wide text-ink-4">
<LayoutTemplate className="h-3 w-3" aria-hidden="true" />
Also a UI skill
</dt>
<dd className="mt-1 space-y-1">
<p className="text-[11px] leading-relaxed text-ink-3">
This definition also draws{' '}
{uiSections.map((s) => sectionTypeLabel(s.type).toLowerCase()).join(', ')}
{' '}on its pages. Saving here keeps that exactly as written.
</p>
<button
type="button"
onClick={() => navigate(`/admin/workspace/skills/${preview.id}`)}
className="text-[11px] font-semibold text-krow-blue hover:underline"
>
Edit its page section →
</button>
</dd>
</div>
)}
{preview.owliverErrors?.length > 0 && (
<div>
<dt className="text-[10px] font-semibold uppercase tracking-wide text-destructive">
Owliver configuration
</dt>
<dd className="mt-1 space-y-1">
{preview.owliverErrors.map((message) => (
<p key={message} className="text-[11px] leading-relaxed text-destructive">
{message}
</p>
))}
</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>
);
}

View File

@@ -1,4 +1,4 @@
import React, { useMemo, useState } from 'react';
import React, { useEffect, useMemo, useState } from 'react';
import { useNavigate, useSearchParams } from 'react-router-dom';
import {
ArrowRight, CheckCircle2, LayoutGrid, List, MapPin, MessageSquare, Sparkles, Star, Users, Wallet,
@@ -18,6 +18,8 @@ import {
import {
PositionCustomRequirements, PositionOverview, PositionRequirements,
} from '@/components/krow/PositionDetails';
import { SkillSurface } from '@/components/skills/SkillSurface';
import { usePublishPageContext } from '@/components/ai-assistant';
import { AdminPage, SectionTitle } from '@/pages/admin/_shell';
import { RoleGlyph } from '@/pages/admin/RoleGlyph';
import { buildPosition, payLabel } from '@/pages/admin/positionInsights';
@@ -272,6 +274,78 @@ function useViewMode() {
}];
}
/* ── Just saved ────────────────────────────────────────────────────────────
*
* A position saved in the last two minutes is held a shade closer to the brand
* on its own card, so an admin returning to a list of thirty roles can find the
* one they were just working on without a toast telling them where to look.
*
* This is emphasis only, and it is the *only* part of the card that expires.
* What the card says about the position — Draft, Hiring, Paused — is the
* position's own status and is never on a timer: a draft stays a draft until
* the publish action makes it something else.
*
* It is derived from `updated_date`, which the store stamps on every write, so
* the mark follows the last save rather than the first. Because the source is
* the record itself, it survives a refresh, a navigation away and back, and any
* filter or sort the page is in. Each position expires on its own clock; there
* is no page-level "something happened" flag.
*/
const RECENT_WINDOW_MS = 2 * 60 * 1000;
const savedAt = (posting) => Date.parse(posting.updated_date || posting.created_date);
/**
* The ids currently inside their window, and a single timer that re-reads the
* clock when the soonest of them expires. Nothing recent on screen means no
* timer at all.
*/
function useRecentlySavedIds(postings) {
const [now, setNow] = useState(() => Date.now());
/* The creation times themselves, as a value rather than an array identity.
The page re-reads `postings` from the query on every render, so depending
on the array would re-run the effects below on renders where nothing about
the data changed. */
const stamps = useMemo(
() => postings.map((p) => `${p.id}@${p.updated_date || p.created_date}`).join('|'),
[postings]
);
/* The clock is re-read whenever a position is added or saved, not only when a
timer fires. A position written while this page is already open arrives
through a query invalidation, and against a clock last read at mount it
would look like it had not happened yet. */
useEffect(() => { setNow(Date.now()); }, [stamps]);
const recentIds = useMemo(() => {
const ids = new Set();
for (const p of postings) {
const age = now - savedAt(p);
if (Number.isFinite(age) && age >= 0 && age < RECENT_WINDOW_MS) ids.add(p.id);
}
return ids;
/* `stamps` stands in for `postings` here: the same information, as a value
that only changes when the set of positions does. */
}, [stamps, now]);
useEffect(() => {
if (!recentIds.size) return undefined;
/* Wake once, at the first expiry rather than on an interval. Whatever is
still recent after that wake re-arms the next one. */
const soonest = Math.min(
...postings
.filter((p) => recentIds.has(p.id))
.map((p) => savedAt(p) + RECENT_WINDOW_MS)
);
const timer = setTimeout(() => setNow(Date.now()), Math.max(0, soonest - Date.now()) + 50);
return () => clearTimeout(timer);
}, [recentIds]);
return recentIds;
}
/**
* The same positions, as a table.
*
@@ -428,9 +502,17 @@ function candidateCriteria(position) {
].filter(Boolean).join(' · ') || null;
}
/** One position. The whole card is the target, so there is no hunting for a link. */
function PositionCard({ position, onOpen }) {
/**
* One position. The whole card is the target, so there is no hunting for a link.
*
* A draft opens where a draft belongs — back in the form that was writing it —
* while everything else opens the drawer it always did. That is the only
* difference between the two states on this card: same layout, same figures,
* and the status pill the card has always drawn now reads Draft.
*/
function PositionCard({ position, onOpen, isRecent = false }) {
const p = position;
const isDraft = p.status === 'draft';
return (
<article
@@ -443,11 +525,17 @@ function PositionCard({ position, onOpen }) {
onOpen(p);
}
}}
aria-label={`${p.title}. ${p.health.label}. ${p.stats.applied} applicants. ${p.insight}`}
className="group flex cursor-pointer flex-col rounded-xl border border-border bg-surface p-4 text-left shadow-xs
transition-[border-color,box-shadow] duration-base ease-out
hover:border-krow-blue/40 hover:shadow-md
focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-krow-blue/50"
aria-label={`${p.title}. ${p.health.label}. ${p.stats.applied} applicants. ${p.insight}${isDraft ? '. Draft — not published yet.' : ''}`}
className={cn(
`group flex cursor-pointer flex-col rounded-xl border border-border bg-surface p-4 text-left shadow-xs
transition-[border-color,box-shadow] duration-base ease-out
hover:border-krow-blue/40 hover:shadow-md
focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-krow-blue/50`,
/* Just saved: the same card, held a shade closer to the brand for two
minutes. Border, ring and surface only — the layout does not move,
and the entrance settles once rather than pulsing. */
isRecent && 'border-krow-blue/50 bg-krow-blue-tint/40 ring-1 ring-krow-blue/20 motion-safe:animate-scale-in'
)}
>
{/* Identity */}
<div className="flex items-start gap-3">
@@ -469,6 +557,9 @@ function PositionCard({ position, onOpen }) {
<p className="truncate text-caption text-ink-3">{p.role_category || 'Uncategorized'}</p>
</div>
{/* The position's own status, in the pill this card has always drawn.
A draft says Draft here — nothing else is added, and nothing about
this is on a timer. */}
<StatusBadge status={p.status} size="sm" />
</div>
@@ -516,11 +607,31 @@ function PositionCard({ position, onOpen }) {
? <><Sparkles className="h-3 w-3" aria-hidden="true" /> AI Built</>
: 'Manual'}
</span>
{/* A draft is unfinished work, so the card says what happens next on it:
it goes back to the form, not to a hiring drawer for a role nobody
can apply to yet. */}
<span className="inline-flex items-center gap-1 text-[11px] font-semibold text-krow-blue">
View position
{isDraft ? 'Continue' : 'View position'}
<ArrowRight className="h-3 w-3 transition-transform duration-base group-hover:translate-x-0.5" aria-hidden="true" />
</span>
</div>
{/* The card's own extension point. A definition can target the list and
the drawer separately, or both — `after-position-card` renders per
card, with that card's position as context. Clicks inside a section
are its own; they do not open the drawer behind it. */}
<div
onClick={(e) => e.stopPropagation()}
onKeyDown={(e) => e.stopPropagation()}
role="presentation"
>
<SkillSurface
page="positions"
placement="after-position-card"
context={{ position: p }}
className="mt-3"
/>
</div>
</article>
);
}
@@ -595,6 +706,12 @@ function PositionDrawer({ position, onClose, onViewCandidates, onOpenPosition })
</div>
</div>
{/* Extension points. A skill definition naming `positions` and one of
these placements renders here — this drawer is what "View position"
opens, so it is where a positions skill has to appear. The page
knows no skill; the definition names the slot. */}
<SkillSurface page="positions" placement="after-header" context={{ position: p }} />
{/* 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. */}
@@ -603,6 +720,8 @@ function PositionDrawer({ position, onClose, onViewCandidates, onOpenPosition })
<PositionOverview position={p} className="mt-2" />
</section>
<SkillSurface page="positions" placement="after-position-summary" context={{ position: p }} />
{/* 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. */}
@@ -660,6 +779,8 @@ function PositionDrawer({ position, onClose, onViewCandidates, onOpenPosition })
<Progression steps={p.progression} />
</section>
<SkillSurface page="positions" placement="before-candidates" context={{ position: p }} />
{/* 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">
@@ -712,6 +833,8 @@ function PositionDrawer({ position, onClose, onViewCandidates, onOpenPosition })
</section>
</div>
<SkillSurface page="positions" placement="after-candidates" context={{ position: p }} />
<section>
<SectionTitle title="Recent activity" />
{p.applications.length ? (
@@ -736,6 +859,8 @@ function PositionDrawer({ position, onClose, onViewCandidates, onOpenPosition })
<p className="text-body-sm text-ink-3">No activity on this position yet.</p>
)}
</section>
<SkillSurface page="positions" placement="before-footer" context={{ position: p }} />
</div>
</Drawer>
);
@@ -767,6 +892,44 @@ export default function AdminPositions() {
const [selected, setSelected] = useState(null);
const [view, setView] = useViewMode();
/* Read off the postings, not the filtered rows: which positions were saved
recently does not depend on what is currently on screen, so searching or
sorting cannot shift the window. */
const recentIds = useRecentlySavedIds(postings);
/**
* Continuing a draft.
*
* The same authoring form, on the same route, told which record it is
* finishing. Not a second editor and not a copy of the values: the form loads
* the draft and saves back to it, so publishing from there is the one publish
* action there has ever been.
*/
const openPosition = (p) => (p.status === 'draft'
? navigate(`/admin/positions/new?draft=${encodeURIComponent(p.id)}`)
: setSelected(p));
/**
* The position the reader has open, published for Owliver.
*
* The panel beside this page can read every collection already; what it
* cannot know is which record is in front of you. Publishing the raw posting
* — not the derived row — means a skill's declared source resolves against
* the same record the drawer is showing, and reading a different position
* changes the answer without either side knowing about the other.
*
* A position that has just been published counts as "in front of you" too: the
* form leaves `?published=<id>` behind it, which is how the panel knows which
* role to offer candidates for without this page telling it anything about
* candidates. The drawer still wins while one is open.
*/
const publishedId = searchParams.get('published');
usePublishPageContext(useMemo(() => {
const current = selected?.id || publishedId;
if (!current) return null;
return { position: postings.find((p) => p.id === current) || null };
}, [selected, publishedId, postings]));
/**
* Derived once per position rather than per cell, so the grid does not run the
* same filter five times per card.
@@ -858,30 +1021,37 @@ export default function AdminPositions() {
title="Positions"
subtitle="Manage open roles, hiring demand and position performance."
>
{/* Controls. Search gets a fixed, sensible measure — wide enough for a role
title, not so wide it becomes the page's subject. */}
<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 positions" size="sm" />
{/* Controls toolbar — unified flex row with clean alignment across viewports */}
<div className="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
<div className="flex flex-wrap items-center gap-2 flex-1 min-w-0">
<div className="w-full sm:w-60 md:w-64 xl:w-72 shrink-0">
<SearchInput
value={search}
onChange={setSearch}
placeholder="Search positions"
size="sm"
shape="pill"
className="bg-white border-border/80 hover:border-border transition-colors"
/>
</div>
<div className="flex flex-wrap items-center gap-2 min-w-0">
<FilterSelect value={status} onChange={setStatus} label="Status" options={[
{ value: 'all', label: 'All' }, { value: 'active', label: 'Hiring' },
{ value: 'paused', label: 'Paused' }, { value: 'draft', label: 'Draft' },
{ value: 'closed', label: 'Closed' },
]} />
<FilterSelect value={department} onChange={setDepartment} label="Department"
options={[{ value: 'all', label: 'All departments' }, ...departments.map((d) => ({ value: d, label: d }))]} />
<FilterSelect value={location} onChange={setLocation} label="Location"
options={[{ value: 'all', label: 'All locations' }, ...locations.map((l) => ({ value: l, label: l }))]} />
<FilterSelect value={sort} onChange={setSort} label="Sort"
options={Object.entries(SORTS).map(([value, s]) => ({ value, label: s.label }))} />
</div>
</div>
<div className="flex flex-wrap items-center gap-2">
<FilterSelect value={status} onChange={setStatus} label="Status" options={[
{ value: 'all', label: 'All statuses' }, { value: 'active', label: 'Hiring' },
{ value: 'paused', label: 'Paused' }, { value: 'draft', label: 'Draft' },
{ value: 'closed', label: 'Closed' },
]} />
<FilterSelect value={department} onChange={setDepartment} label="Department"
options={[{ value: 'all', label: 'All departments' }, ...departments.map((d) => ({ value: d, label: d }))]} />
<FilterSelect value={location} onChange={setLocation} label="Location"
options={[{ value: 'all', label: 'All locations' }, ...locations.map((l) => ({ value: l, label: l }))]} />
<FilterSelect value={sort} onChange={setSort} label="Sort"
options={Object.entries(SORTS).map(([value, s]) => ({ value, label: s.label }))} />
</div>
{/* Presentation, at the end of the same toolbar. It changes how the rows
are drawn, never which rows or what they say. */}
<div className="lg:ml-auto">
{/* View mode presentation toggle on far right */}
<div className="shrink-0 self-end lg:self-center">
<SegmentedToggle
variant="surface"
ariaLabel="View mode"
@@ -895,10 +1065,16 @@ export default function AdminPositions() {
</div>
</div>
<p className="text-caption text-ink-4">
<p className="text-body-sm text-ink-3 font-normal mt-1 mb-4 flex items-center gap-1.5 flex-wrap">
{isFiltered ? `${filtered.length} of ${rows.length} positions · filtered` : summary}
</p>
{/* The list's own extension points. These render once for the page — no
position in context — which is what a definition reporting across
every role needs. The per-card and per-drawer placements are separate
slots, so one skill can address the board and another one role. */}
<SkillSurface page="positions" placement="after-position-list-summary" className="mb-4" />
{/* Three columns on desktop, two on tablet, one on mobile. */}
{isLoading ? (
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-3">
@@ -911,11 +1087,16 @@ export default function AdminPositions() {
view === 'grid' ? (
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-3">
{filtered.map((p) => (
<PositionCard key={p.id} position={p} onOpen={setSelected} />
<PositionCard
key={p.id}
position={p}
onOpen={openPosition}
isRecent={recentIds.has(p.id)}
/>
))}
</div>
) : (
<PositionsTable rows={filtered} onOpen={setSelected} />
<PositionsTable rows={filtered} onOpen={openPosition} />
)
) : (
<EmptyState
@@ -933,6 +1114,8 @@ export default function AdminPositions() {
/>
)}
<SkillSurface page="positions" placement="after-position-list" className="mt-6" />
<PositionDrawer
position={selected}
onClose={() => setSelected(null)}
@@ -947,11 +1130,17 @@ export default function AdminPositions() {
);
}
/** A compact labelled select — the same control on every Admin management page. */
/** A compact labelled select pill — consistent on every Admin management page. */
export function FilterSelect({ value, onChange, label, options, className }) {
return (
<Select value={value} onValueChange={onChange}>
<SelectTrigger aria-label={label} className={cn('h-8 w-auto min-w-[8.5rem] text-body-sm', className)}>
<SelectTrigger
aria-label={label}
className={cn(
'h-8 w-auto min-w-[7.5rem] rounded-full border border-border/80 bg-white px-3 text-[13px] font-normal text-ink-1 shadow-2xs hover:bg-surface-hover hover:border-border transition-colors outline-none focus:ring-2 focus:ring-krow-blue/20',
className
)}
>
<SelectValue />
</SelectTrigger>
<SelectContent>

View File

@@ -12,6 +12,7 @@ import { useCurrentUser, useUpdateProfile, useUserActivity } from '@/lib/krowHoo
import { AdminPage } from '@/pages/admin/_shell';
import { endAdminSession } from '@/pages/admin/session';
import { PERMISSIONS } from '@/pages/admin/permissions';
import { SkillSurface } from '@/components/skills/SkillSurface';
/**
* Admin Profile — Premium enterprise account & security control center.
@@ -96,6 +97,7 @@ export default function AdminProfile() {
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"
>
<SkillSurface page="profile" placement="after-header" />
<SettingsIcon className="w-4 h-4 text-[#687386]" />
Settings
</button>
@@ -117,6 +119,7 @@ export default function AdminProfile() {
}
>
<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 */}
@@ -487,6 +490,7 @@ export default function AdminProfile() {
</div>
</Modal>
</div>
<SkillSurface page="profile" placement="before-footer" />
</AdminPage>
);
}

View File

@@ -50,7 +50,7 @@ export default function AdminSettings() {
<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>
<h1 className="text-2xl sm:text-3xl font-bold tracking-tight text-white">Settings </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>

View File

@@ -1,16 +1,33 @@
import React, { useMemo, useState } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import { ChevronLeft, Upload } from 'lucide-react';
import { ChevronLeft, Sparkles, Upload } from 'lucide-react';
import {
Badge, Button, Field, Input, Surface, Switch, Textarea, toast,
Badge, Button, Field, Input, MultiSelect, Select, SelectContent, SelectItem,
SelectTrigger, SelectValue, Surface, Switch, Textarea, toast,
} from '@/components/ds';
import { cn } from '@/lib/utils';
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 { customSkillSource, uiSkillTemplate, upsertCustomSkill } from '@/lib/skills/customSkills';
import {
DATA_SOURCES, PERIODS, SECTION_TYPES, dataSourceLabel, owliverCapabilityLabel, periodLabel,
placementLabel, sectionTypeLabel, surfaceFor,
} from '@/lib/skills/surfaces';
import { AdminPage, SectionTitle } from '@/pages/admin/_shell';
/**
* Add or edit an Owliver skill, as a page.
* Add or edit a UI skill, as a page.
*
* One of two editors. This one configures what a definition draws on a KROW
* page — the section, where it sits, what it reads. What the same definition
* lets Owliver be *asked* for is configured in the Owliver Skill Editor, and
* the two were one form until an author had to know which half of it applied
* to them.
*
* The split is in the fields only. Both editors compose the same Markdown,
* validate it with the same validator and store it through the same helper, and
* a definition that does both jobs is saved whole by either — which is why the
* preview here still reports the Owliver half rather than hiding it.
*
* 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
@@ -22,15 +39,32 @@ import { AdminPage, SectionTitle } from '@/pages/admin/_shell';
* door, not a second system.
*/
/** The frontmatter fields, kept in step with the Markdown until it is edited. */
const emptyMeta = { id: '', name: '', description: '', pages: [] };
/** The fields this editor composes, kept in step with the Markdown until it is edited. */
const emptyMeta = {
id: '', name: '', description: '', pages: [],
type: 'flow', placement: '', source: 'position.activity', periods: [],
};
/** Reads the meta back out of a definition, for editing an existing skill. */
/**
* Reads the fields back out of a definition, for editing an existing skill.
*
* The section is read from what the registry made of the file rather than from
* the Markdown text, so the form shows what is actually registered — including
* a placement the author left out and the vocabulary filled in.
*/
function metaFromSource(source) {
try {
const skill = parseSkill(source, { custom: true });
const section = Object.values(skill.ui || {}).flatMap((p) => p.sections || [])[0];
return {
id: skill.id, name: skill.name, description: skill.description, pages: skill.pages,
id: skill.id,
name: skill.name,
description: skill.description,
pages: skill.pages,
type: section?.type || emptyMeta.type,
placement: section?.placement || '',
source: section?.source || emptyMeta.source,
periods: section?.periods || [],
};
} catch {
return emptyMeta;
@@ -56,14 +90,13 @@ export default function SkillEditor() {
* override rather than a duplicate.
*/
const initial = useMemo(() => {
if (!editingId) return skillTemplate({});
if (!editingId) return uiSkillTemplate({});
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
return shipped?.markdown || uiSkillTemplate({});
/* Deliberately keyed on the skill alone: retyping in the editor must not be
overwritten by a preferences write elsewhere in the app. */
}, [editingId]);
const [source, setSource] = useState(initial);
@@ -73,6 +106,19 @@ export default function SkillEditor() {
const [error, setError] = useState('');
const fileRef = React.useRef(null);
const selectedPages = useMemo(
() => pagesText.split(',').map((p) => p.trim()).filter(Boolean),
[pagesText]
);
/* The placements the chosen surfaces actually mount. A page can only be
extended where it offers a slot, so this list is the surfaces' own — never
a fixed set of names typed here. */
const placements = useMemo(
() => [...new Set(selectedPages.flatMap((p) => surfaceFor(p)?.placements || []))],
[selectedPages]
);
/* 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(() => {
@@ -95,12 +141,16 @@ export default function SkillEditor() {
const declaredActive = preview?.status !== 'inactive';
const active = declaredActive && !(preview?.id && disabledSkills.includes(preview.id));
/* Every section this definition registers, flattened — the preview reads it
for both the type line and the configuration block. */
const uiSections = Object.values(preview?.ui || {}).flatMap((page) => page.sections || []);
/* 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({
setSource(uiSkillTemplate({
...next,
pages: nextPagesText.split(',').map((p) => p.trim()).filter(Boolean),
}));
@@ -166,8 +216,8 @@ export default function SkillEditor() {
</>
}
>
<div className="grid gap-5 lg:grid-cols-[minmax(0,1fr)_20rem] lg:items-start">
<div className="space-y-5">
<div className="grid grid-cols-1 gap-6 lg:grid-cols-[1fr_360px] xl:grid-cols-[1fr_400px] 2xl:grid-cols-[1fr_440px] lg:items-start w-full">
<div className="space-y-6 min-w-0">
<section className="space-y-3">
<SectionTitle title="Skill" />
<Surface variant="solid" radius="lg" padding="lg" elevation="xs" className="space-y-4">
@@ -199,16 +249,52 @@ export default function SkillEditor() {
<Field
label="Surfaces"
required
hint={`The pages this skill attaches to. Comma separated. Known pages: ${PAGE_KEYS.join(', ')}`}
hint="Select the pages this skill attaches to from the options below or dropdown list."
>
<Input
value={pagesText}
placeholder="positions, candidates"
onChange={(e) => {
setPagesText(e.target.value);
syncFromMeta(meta, e.target.value);
}}
/>
<div className="space-y-2.5">
<MultiSelect
options={PAGE_KEYS.map((k) => ({ value: k, label: k }))}
value={selectedPages}
onChange={(next) => {
const nextStr = next.join(', ');
setPagesText(nextStr);
syncFromMeta(meta, nextStr);
}}
placeholder="Select surfaces (e.g. positions, candidates...)"
maxChips={6}
/>
{/* Interactive Option List for 1-click toggling */}
<div className="flex flex-wrap items-center gap-1.5 pt-1">
<span className="text-[11px] font-medium text-ink-4 mr-1">Available surfaces:</span>
{PAGE_KEYS.map((pageKey) => {
const isSelected = selectedPages.includes(pageKey);
return (
<button
key={pageKey}
type="button"
onClick={() => {
const next = isSelected
? selectedPages.filter((p) => p !== pageKey)
: [...selectedPages, pageKey];
const nextStr = next.join(', ');
setPagesText(nextStr);
syncFromMeta(meta, nextStr);
}}
className={cn(
'inline-flex items-center gap-1 px-2.5 py-0.5 rounded-full text-caption transition-all cursor-pointer border',
isSelected
? 'bg-krow-blue-tint text-krow-blue border-krow-blue/30 font-medium shadow-2xs'
: 'bg-surface-sunken/60 text-ink-3 border-border/60 hover:bg-surface-sunken hover:text-ink-1'
)}
>
<span>{pageKey}</span>
<span className="text-[10px] font-bold opacity-75">{isSelected ? '✓' : '+'}</span>
</button>
);
})}
</div>
</div>
</Field>
<div className="flex items-center justify-between gap-3 border-t border-border pt-4">
@@ -223,9 +309,102 @@ export default function SkillEditor() {
</Surface>
</section>
{/* ── What it draws ──────────────────────────────────────────
The section itself: its shape, where it sits and what it reads.
Every option comes from the closed vocabulary in `surfaces.js`, so
this form cannot offer a component the product does not ship or a
placement no page mounts. */}
<section className="space-y-3">
<SectionTitle title="UI configuration" meta="What this skill draws" />
<Surface variant="solid" radius="lg" padding="lg" elevation="xs" className="space-y-4">
<div className="grid gap-4 sm:grid-cols-2">
<Field label="Component" hint="The shape the section takes.">
<Select value={meta.type} onValueChange={(type) => syncFromMeta({ ...meta, type })}>
<SelectTrigger><SelectValue placeholder="Select a component" /></SelectTrigger>
<SelectContent>
{SECTION_TYPES.map((t) => (
<SelectItem key={t.id} value={t.id}>{t.label} — {t.summary}</SelectItem>
))}
</SelectContent>
</Select>
</Field>
<Field
label="Placement"
hint={placements.length
? 'Where on the page the section sits.'
: 'Select a surface above and its placements appear here.'}
>
<Select
value={meta.placement || undefined}
onValueChange={(placement) => syncFromMeta({ ...meta, placement })}
disabled={!placements.length}
>
<SelectTrigger><SelectValue placeholder="First placement on the surface" /></SelectTrigger>
<SelectContent>
{placements.map((placement) => (
<SelectItem key={placement} value={placement}>{placementLabel(placement)}</SelectItem>
))}
</SelectContent>
</Select>
</Field>
</div>
<Field
label="Data source"
hint="A named reading of data KROW already holds. An Owliver skill naming the same source reads exactly the same records."
>
<Select value={meta.source} onValueChange={(source) => syncFromMeta({ ...meta, source })}>
<SelectTrigger><SelectValue placeholder="Select a data source" /></SelectTrigger>
<SelectContent>
{DATA_SOURCES.map((src) => (
<SelectItem key={src.id} value={src.id}>{src.label} — {src.summary}</SelectItem>
))}
</SelectContent>
</Select>
</Field>
<Field label="Periods" hint="Computed against the current date at read time, never stored.">
<div className="flex flex-wrap gap-1.5">
{PERIODS.map((period) => {
const selected = meta.periods.includes(period.id);
return (
<button
key={period.id}
type="button"
onClick={() => syncFromMeta({
...meta,
periods: selected
? meta.periods.filter((x) => x !== period.id)
: [...meta.periods, period.id],
})}
className={cn(
'inline-flex cursor-pointer items-center gap-1 rounded-full border px-2.5 py-0.5 text-caption transition-all',
selected
? 'border-krow-blue/30 bg-krow-blue-tint font-medium text-krow-blue shadow-2xs'
: 'border-border/60 bg-surface-sunken/60 text-ink-3 hover:bg-surface-sunken hover:text-ink-1'
)}
>
<span>{period.label}</span>
<span className="text-[10px] font-bold opacity-75">{selected ? '✓' : '+'}</span>
</button>
);
})}
</div>
</Field>
{touched && (
<p className="border-t border-border/50 pt-3 text-caption leading-relaxed text-ink-4">
The definition below has been edited by hand, so it is what gets saved — these
fields no longer compose it.
</p>
)}
</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">
<Surface variant="solid" radius="lg" padding="lg" elevation="xs" className="space-y-4">
<Field
label="Skill definition"
hint="This is what gets saved. Frontmatter declares the skill; the body documents what it can do."
@@ -235,7 +414,7 @@ export default function SkillEditor() {
value={source}
rows={22}
spellCheck={false}
className="font-mono text-[12px] leading-relaxed"
className="font-mono text-[12.5px] leading-relaxed bg-surface-sunken/40 border-border/80 focus:border-krow-blue rounded-lg p-3.5 transition-all"
onChange={(e) => {
setSource(e.target.value);
setTouched(true);
@@ -244,17 +423,19 @@ export default function SkillEditor() {
/>
</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>
<div className="flex flex-wrap items-center justify-between gap-3 border-t border-border/50 pt-3">
<div className="flex items-center gap-2">
<input
ref={fileRef}
type="file"
accept=".md,text/markdown"
onChange={readFile}
className="hidden"
/>
<Button variant="outline" size="sm" shape="rounded" onClick={() => fileRef.current?.click()}>
<Upload aria-hidden="true" /> Upload .md
</Button>
</div>
<p className="text-caption text-ink-4">Markdown is stored as data. It is never executed.</p>
</div>
</Surface>
@@ -262,7 +443,7 @@ export default function SkillEditor() {
</div>
{/* What the registry will make of this definition, as it stands. */}
<aside className="space-y-3">
<aside className="space-y-3 lg:sticky lg:top-20">
<SectionTitle title="Preview" />
<Surface variant="solid" radius="lg" padding="lg" elevation="xs" className="space-y-3">
{preview ? (
@@ -290,9 +471,108 @@ export default function SkillEditor() {
<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'}
{/* A definition that configures UI is read by what it
draws; one that does not is read by what it is. */}
{uiSections.length
? uiSections.map((s) => sectionTypeLabel(s.type)).join(', ')
: preview.kind === 'workforce' ? 'Workforce path' : 'Assistant'}
</dd>
</div>
{/* What the declarative `ui:` block will add to the product.
A sketch of the section, not a live one: the shape,
placement and source are real, the figures come from the
page at render time rather than from this editor. */}
{uiSections.length > 0 && (
<div>
<dt className="text-[10px] font-semibold uppercase tracking-wide text-ink-4">
UI configuration
</dt>
<dd className="mt-1 space-y-2">
{Object.entries(preview.ui).map(([page, config]) => (
<div key={page} className="rounded-lg border border-border bg-surface-subtle p-2.5">
<p className="text-[10px] font-semibold uppercase tracking-wide text-krow-blue">
{surfaceFor(page)?.label || page}
</p>
{config.sections.map((section) => (
<dl key={section.id} className="mt-1.5 space-y-1">
{section.title && (
<p className="text-caption font-medium text-ink-1">{section.title}</p>
)}
<div className="flex gap-2">
<dt className="w-20 shrink-0 text-[11px] text-ink-4">Placement</dt>
<dd className="text-[11px] text-ink-2">{placementLabel(section.placement)}</dd>
</div>
<div className="flex gap-2">
<dt className="w-20 shrink-0 text-[11px] text-ink-4">Source</dt>
<dd className="text-[11px] text-ink-2">{dataSourceLabel(section.source)}</dd>
</div>
{section.periods.length > 0 && (
<div className="flex gap-2">
<dt className="w-20 shrink-0 text-[11px] text-ink-4">Periods</dt>
<dd className="flex flex-wrap gap-1">
{section.periods.map((period) => (
<span
key={period}
className="rounded border border-border bg-surface px-1.5 py-0.5 text-[10px] text-ink-3"
>
{periodLabel(period)}
</span>
))}
</dd>
</div>
)}
</dl>
))}
</div>
))}
</dd>
</div>
)}
{/* The panel half of the same definition, when it has one.
Not configurable here — that is the other editor's job —
but a definition that also answers questions must not read
on this page as if it only drew a card. */}
{preview.owliver?.enabled && preview.owliver.capabilities.length > 0 && (
<div className="rounded-lg border border-border bg-surface-subtle p-2.5">
<dt className="flex items-center gap-1.5 text-[10px] font-semibold uppercase tracking-wide text-ink-4">
<Sparkles className="h-3 w-3" aria-hidden="true" />
Also an Owliver skill
</dt>
<dd className="mt-1 space-y-1">
<p className="text-[11px] leading-relaxed text-ink-3">
Owliver can be asked for{' '}
{preview.owliver.capabilities
.map((c) => owliverCapabilityLabel(c).toLowerCase())
.join(' and ')}
{' '}on these pages. Saving here keeps that exactly as written.
</p>
<button
type="button"
onClick={() => navigate(`/admin/workspace/skills/owliver/${preview.id}`)}
className="text-[11px] font-semibold text-krow-blue hover:underline"
>
Edit its Owliver behaviour →
</button>
</dd>
</div>
)}
{preview.uiErrors?.length > 0 && (
<div>
<dt className="text-[10px] font-semibold uppercase tracking-wide text-destructive">
UI configuration
</dt>
<dd className="mt-1 space-y-1">
{preview.uiErrors.map((message) => (
<p key={message} className="text-[11px] leading-relaxed text-destructive">
{message}
</p>
))}
</dd>
</div>
)}
{preview.capabilities.length > 0 && (
<div>
<dt className="text-[10px] font-semibold uppercase tracking-wide text-ink-4">Capabilities</dt>

View File

@@ -8,6 +8,7 @@ import { useWorkerProfiles } from '@/lib/krowHooks';
import { getScoreBand, toFICO } from '@/lib/talentHome';
import { AdminPage, SectionTitle, Toolbar } from '@/pages/admin/_shell';
import { FilterSelect } from '@/pages/admin/Positions';
import { SkillSurface } from '@/components/skills/SkillSurface';
/**
* Admin Talent Pool — talent intelligence, not a card gallery.
@@ -158,6 +159,7 @@ export default function AdminTalentPool() {
title="Talent Pool"
subtitle="Discover and manage high-potential talent for future hiring."
>
<SkillSurface page="talent-pool" placement="after-header" />
{/* Segments — executive metric cards */}
<section aria-labelledby="segments" className="space-y-3">
<SectionTitle
@@ -359,6 +361,7 @@ export default function AdminTalentPool() {
},
]}
/>
<SkillSurface page="talent-pool" placement="before-footer" />
</AdminPage>
);
}

View File

@@ -1,14 +1,21 @@
import React, { useMemo, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { ChevronLeft, MoreHorizontal, Pencil, Plus, Trash2 } from 'lucide-react';
import { useNavigate, useSearchParams } from 'react-router-dom';
import {
ChevronDown, ChevronLeft, LayoutTemplate, MoreHorizontal, Pencil, Plus, Sparkles, Trash2,
} from 'lucide-react';
import {
Badge, Button, ConfirmModal, DropdownMenu, DropdownMenuContent, DropdownMenuItem,
DropdownMenuTrigger, EmptyState, SearchInput, Surface, Switch, toast,
DropdownMenuTrigger, EmptyState, SearchInput, Select, SelectContent, SelectItem,
SelectTrigger, SelectValue, Surface, Switch, Tabs, toast,
} from '@/components/ds';
import { cn } from '@/lib/utils';
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 { PAGE_KEYS, allSkills, skillsWithFacet } from '@/lib/skills/registry';
import { removeCustomSkill, upsertCustomSkill } from '@/lib/skills/customSkills';
import { countSections } from '@/lib/skills/uiConfig';
import {
owliverCapabilityLabel, placementLabel, sectionTypeLabel, surfaceFor,
} from '@/lib/skills/surfaces';
import { AdminPage } from '@/pages/admin/_shell';
/**
@@ -20,21 +27,85 @@ import { AdminPage } from '@/pages/admin/_shell';
* 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.
* Two lists, because there are two jobs. A **UI skill** extends a KROW page: it
* declares a section, a placement and a reading, and the page draws it. An
* **Owliver skill** extends the assistant: it declares triggers, suggestions
* and the shapes an answer can take. Reading one list and finding both mixed
* together was the problem this page had — "pages" meant "where it draws" in
* one row and "where it listens" in the next, and nothing on screen said which.
*
* What is *not* split is as important. Both lists are readings of `allSkills()`
* — one registry, one parser, one validator, one persistence, one data
* resolver. A definition's facets are derived from what it declares, so this
* page classifies rather than configures, and nothing stored had to be
* migrated for the split to exist.
*/
const KIND_LABEL = {
assistant: 'Assistant',
workforce: 'Workforce path',
};
/** The two lists, and the words each one uses for itself. */
const FACETS = [
{
id: 'ui',
label: 'Board Skills',
icon: LayoutTemplate,
add: 'Add Board Skill',
route: '/admin/workspace/skills/new',
blurb: 'Definitions that add dynamic sections to KROW pages, read from real page data.',
emptyTitle: 'No Board skills yet',
emptyBody: 'A Board skill declares a section, where it sits and what it reads. Add one and the page it names renders it.',
},
{
id: 'owliver',
label: 'Owliver Skills',
icon: Sparkles,
add: 'Add Owliver Skill',
route: '/admin/workspace/skills/owliver/new',
blurb: 'Definitions that teach Owliver what it can be asked for on a page, and how to answer.',
emptyTitle: 'No Owliver skills yet',
emptyBody: 'An Owliver skill declares triggers, suggestions and the shapes its answers take. Add one and the panel on that page offers it.',
},
];
const facetFor = (id) => FACETS.find((f) => f.id === id) || FACETS[0];
/** The page labels a definition attaches to, in the product's own words. */
const surfaceLabels = (skill) =>
skill.pages.map((p) => surfaceFor(p)?.label || p).filter(Boolean);
/**
* What this definition contributes, in the terms of the list it is on.
*
* The same skill answers the question differently depending on which job is
* being managed: on the UI list it is a shape and a placement, on the Owliver
* list it is the capabilities it can be asked for. Derived from the definition
* both times — nothing here is a stored label.
*/
function contribution(skill, facet) {
if (facet === 'ui') {
const sections = Object.values(skill.ui || {}).flatMap((page) => page.sections || []);
return {
type: [...new Set(sections.map((s) => sectionTypeLabel(s.type)))].join(' · '),
detail: [...new Set(sections.map((s) => placementLabel(s.placement)))].join(', '),
};
}
const capabilities = skill.owliver?.capabilities || [];
return {
type: capabilities.map(owliverCapabilityLabel).join(' · '),
/* A definition written before the split has no capability list — what it
offers Owliver is its triggers, so say that rather than nothing. */
detail: capabilities.length
? `${skill.owliver.suggestions.length || 'No'} suggestion${skill.owliver.suggestions.length === 1 ? '' : 's'}`
: `${skill.triggers.length} trigger${skill.triggers.length === 1 ? '' : 's'}`,
};
}
/** 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);
function SkillRow({ skill, facet, enabled, onToggle, onEdit, onDelete }) {
const pages = surfaceLabels(skill);
const { type, detail } = contribution(skill, facet);
/* A definition doing both jobs appears on both lists. Saying so on the row is
what stops it reading as a duplicate. */
const both = skill.facets.length > 1;
return (
<div className="grid gap-3 px-4 py-3.5 sm:grid-cols-[minmax(0,1fr)_auto] sm:items-start">
@@ -44,30 +115,24 @@ function SkillRow({ skill, enabled, onToggle, onEdit, onDelete }) {
<Badge variant={enabled ? 'success' : 'neutral'} size="sm">
{enabled ? 'Active' : 'Inactive'}
</Badge>
<Badge variant="soft" size="sm">{KIND_LABEL[skill.kind] || 'Assistant'}</Badge>
{type && <Badge variant="soft" size="sm">{type}</Badge>}
{skill.custom && <Badge variant="soft" size="sm">Custom</Badge>}
{both && <Badge variant="neutral" size="sm">Also on the other list</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">
{pages.length > 0 && (
<>Pages: <span className="text-ink-3">{pages.join(', ')}</span></>
)}
{pages.length > 0 && detail && ' · '}
{detail && <span className="text-ink-3">{detail}</span>}
</p>
<p className="mt-1 text-[11px] text-ink-4">
{skill.source === 'account' ? 'Added on this account' : 'Shipped with KROW'}
{' · '}
<span className="font-mono">{skill.path}</span>
@@ -96,14 +161,16 @@ function SkillRow({ skill, enabled, onToggle, onEdit, onDelete }) {
<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>
)}
{/* Offered for every definition. What it does differs — an account
definition is deleted, a shipped one is taken out of this
workspace — and the dialog says which. */}
<DropdownMenuItem
onClick={() => onDelete(skill)}
className="cursor-pointer text-destructive focus:text-destructive"
>
<Trash2 className="mr-2 h-4 w-4" />
{skill.custom ? 'Remove skill' : 'Remove from workspace'}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
@@ -116,56 +183,240 @@ export default function WorkspaceSkills() {
const preferences = usePreferences();
const updatePreferences = useUpdatePreferences();
/* Which list is open lives in the URL, so a link can open one and the back
button steps between them rather than out of the page. */
const [params, setParams] = useSearchParams();
const facet = FACETS.some((f) => f.id === params.get('tab')) ? params.get('tab') : 'ui';
const setFacet = (next) => setParams(next === 'ui' ? {} : { tab: next }, { replace: true });
const [search, setSearch] = useState('');
const [status, setStatus] = useState('all');
const [page, setPage] = useState('all');
const [pendingDelete, setPendingDelete] = useState(null);
const [showRemoved, setShowRemoved] = useState(false);
const disabledSkills = preferences.disabledSkills || [];
/**
* Definitions this account has removed.
*
* A shipped definition lives in the repository, so "remove" cannot mean
* deleting it — the file would come back on the next deployment and the
* button would be lying. It means removed *from this workspace*: hidden from
* this list and switched off everywhere.
*
* The second half is why removal needs no other file to change. A removed id
* is also written to `disabledSkills`, which every surface, resolver and
* suggestion path already honours — so "removed" is exactly "disabled, and
* out of the way", and nothing had to learn a second rule.
*/
const removedSkills = preferences.removedSkills || [];
const customSkills = preferences.customSkills || [];
const skills = useMemo(() => allSkills(customSkills), [customSkills]);
/**
* Whether a skill is actually offered.
*
* Two independent things switch a skill off — the definition declaring
* `status: inactive`, and this account disabling it — and the renderer honours
* both. This list used to read only the second, so a definition that ships
* inactive appeared here as "Active" while every surface correctly refused to
* render it. The Skill Editor already reconciled the two; now so does this.
*/
const isEnabled = (skill) =>
skill.status !== 'inactive' && !disabledSkills.includes(skill.id);
/**
* Everything still in this workspace.
*
* Removal is filtered out *here*, at the source, rather than where the rows
* are drawn — because four things count these definitions: the two tab
* badges, the page filter's options, and the "N registered · N active"
* footer. Filtering at render would leave a tab reading "4" above an empty
* list and a footer counting definitions the reader has removed.
*/
const present = useMemo(
() => skills.filter((s) => !removedSkills.includes(s.id)),
/* Keyed on the content: `preferences` rebuilds its arrays every read. */
// eslint-disable-next-line react-hooks/exhaustive-deps
[skills, removedSkills.join('|')]
);
/** The definitions on this list, before the management filters. */
const listed = useMemo(() => skillsWithFacet(present, facet), [present, facet]);
/** The removed ones, for the drawer that lets them be put back. */
const removed = useMemo(
() => skills.filter((s) => removedSkills.includes(s.id)),
// eslint-disable-next-line react-hooks/exhaustive-deps
[skills, removedSkills.join('|')]
);
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]);
return listed.filter((s) => {
if (q && !`${s.name} ${s.description} ${s.pages.join(' ')}`.toLowerCase().includes(q)) return false;
if (status === 'active' && !isEnabled(s)) return false;
if (status === 'disabled' && isEnabled(s)) return false;
if (page !== 'all' && !s.pages.some((p) => (surfaceFor(p)?.id || p) === page)) return false;
return true;
});
/* `isEnabled` closes over the preference arrays, so `disabledSkills` is the
real input rather than the function itself. */
}, [listed, search, status, page, disabledSkills]);
/**
* Flipping the switch has to move whichever of the two is holding the skill
* off, or the control does nothing visible.
*
* Enabling one that ships inactive writes an account definition with
* `status: active` — the same override the Skill Editor writes, through the
* same helper — rather than a second kind of "enabled" state beside it.
*
* One definition is switched, never a list: a UI skill and an Owliver skill
* are separate definitions with separate ids, so disabling either leaves the
* other exactly as it was.
*/
const toggleSkill = (id, enabled) => {
const next = enabled
? disabledSkills.filter((s) => s !== id)
: [...new Set([...disabledSkills, id])];
updatePreferences.mutate({ disabledSkills: next });
const skill = skills.find((s) => s.id === id);
const patch = {
disabledSkills: enabled
? disabledSkills.filter((s) => s !== id)
: [...new Set([...disabledSkills, id])],
};
if (skill && enabled && skill.status === 'inactive') {
const source = /^status:\s*\w+$/m.test(skill.markdown)
? skill.markdown.replace(/^status:\s*\w+$/m, 'status: active')
: skill.markdown.replace(/^---\n/, '---\nstatus: active\n');
patch.customSkills = upsertCustomSkill(customSkills, source).next;
}
updatePreferences.mutate(patch);
toast.success(enabled ? 'Skill enabled' : 'Skill disabled');
};
/**
* Remove, which means two different things honestly rather than one thing
* badly.
*
* An **account** definition is stored here, so removing it deletes it — the
* behaviour this button has always had. A **shipped** definition is a file in
* the repository this page cannot delete, so removing it takes it out of the
* workspace instead: hidden from the list, and switched off everywhere by the
* same `disabledSkills` every surface already reads. Reversible either way for
* shipped definitions, and the copy on the dialog says which it is doing.
*/
const removeSkill = (skill) => {
updatePreferences.mutate({ customSkills: removeCustomSkill(customSkills, skill.id) });
const patch = skill.custom
? { customSkills: removeCustomSkill(customSkills, skill.id) }
: {
removedSkills: [...new Set([...removedSkills, skill.id])],
disabledSkills: [...new Set([...disabledSkills, skill.id])],
};
updatePreferences.mutate(patch);
setPendingDelete(null);
toast.success(`${skill.name} removed`);
};
const activeCount = skills.filter((s) => !disabledSkills.includes(s.id)).length;
/**
* Puts a removed definition back on the list — switched off.
*
* Deliberately not re-enabled. `disabledSkills` now carries two intentions:
* "the reader switched this off" and "this was switched off because it was
* removed", and nothing distinguishes them. Clearing it on restore would
* silently turn on a skill the reader had disabled *before* removing it,
* which is the kind of thing that surfaces as a bug report weeks later.
*
* Coming back visibly inactive has no silent failure mode: the row is on the
* list with its switch off, and one click is the whole cost.
*/
const restoreSkill = (skill) => {
updatePreferences.mutate({
removedSkills: removedSkills.filter((id) => id !== skill.id),
});
toast.success(`${skill.name} restored — switched off`);
};
/** A row opens the editor its own kind is authored in. */
const editRoute = (skill) => (facet === 'owliver'
? `/admin/workspace/skills/owliver/${skill.id}`
: `/admin/workspace/skills/${skill.id}`);
const current = facetFor(facet);
const tabs = FACETS.map((f) => ({
value: f.id,
label: f.label,
icon: f.icon,
count: skillsWithFacet(present, f.id).length,
}));
/* Only the pages something is actually attached to, so the filter never
offers a page that would empty the list. */
const pageOptions = useMemo(() => {
const used = new Set(listed.flatMap((s) => s.pages.map((p) => surfaceFor(p)?.id || p)));
return PAGE_KEYS.filter((key) => used.has(key));
}, [listed]);
const activeCount = listed.filter(isEnabled).length;
return (
<AdminPage
title="Skills"
subtitle="Manage the capabilities Owliver can use across KROW."
subtitle="Manage the capabilities that extend KROW pages and Owliver."
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 size="sm" shape="rounded" onClick={() => navigate(current.route)}>
<Plus aria-hidden="true" /> {current.add}
</Button>
</>
}
>
<Tabs
tabs={tabs}
value={facet}
onChange={setFacet}
variant="underline"
size="sm"
layoutGroupId="skills-facet"
ariaLabel="Skill kinds"
/>
<p className="text-caption leading-relaxed text-ink-3">{current.blurb}</p>
<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 className="w-full lg:w-72">
<SearchInput value={search} onChange={setSearch} placeholder={`Search ${current.label.toLowerCase()}`} size="sm" />
</div>
<div className="flex flex-wrap items-center gap-2">
<Select value={status} onValueChange={setStatus}>
<SelectTrigger className="h-9 w-36"><SelectValue placeholder="Status" /></SelectTrigger>
<SelectContent>
<SelectItem value="all">All</SelectItem>
<SelectItem value="active">Active</SelectItem>
<SelectItem value="disabled">Disabled</SelectItem>
</SelectContent>
</Select>
{pageOptions.length > 1 && (
<Select value={page} onValueChange={setPage}>
<SelectTrigger className="h-9 w-44"><SelectValue placeholder="Page" /></SelectTrigger>
<SelectContent>
<SelectItem value="all">All pages</SelectItem>
{pageOptions.map((key) => (
<SelectItem key={key} value={key}>{surfaceFor(key)?.label || key}</SelectItem>
))}
</SelectContent>
</Select>
)}
</div>
<p className="text-caption text-ink-4 lg:ml-auto">
{skills.length} registered · {activeCount} active
{listed.length} registered · {activeCount} active
</p>
</div>
@@ -181,34 +432,109 @@ export default function WorkspaceSkills() {
<SkillRow
key={skill.id}
skill={skill}
enabled={!disabledSkills.includes(skill.id)}
facet={facet}
enabled={isEnabled(skill)}
onToggle={toggleSkill}
onEdit={(s) => navigate(`/admin/workspace/skills/${s.id}`)}
onEdit={(s) => navigate(editRoute(s))}
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>}
title={listed.length ? 'No skills match those filters' : current.emptyTitle}
description={listed.length
? 'Try a shorter term, or clear the filters to see every skill on this list.'
: current.emptyBody}
action={listed.length ? (
<Button
variant="outline"
size="sm"
onClick={() => { setSearch(''); setStatus('all'); setPage('all'); }}
>
Clear filters
</Button>
) : (
<Button size="sm" onClick={() => navigate(current.route)}>
<Plus aria-hidden="true" /> {current.add}
</Button>
)}
/>
)}
{/* Removed definitions, and the way back.
A removal that cannot be undone from the same page it was made on is
a deletion wearing a softer word — so the list is here, collapsed
until there is something in it. */}
{removed.length > 0 && (
<section aria-labelledby="removed-skills" className="space-y-2">
<button
type="button"
onClick={() => setShowRemoved((v) => !v)}
aria-expanded={showRemoved}
className="inline-flex items-center gap-1.5 rounded text-caption font-medium text-ink-3 transition-colors
hover:text-krow-blue focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-krow-blue/50"
>
<span id="removed-skills">
Removed from this workspace ({removed.length})
</span>
<ChevronDown
className={cn('h-3.5 w-3.5 transition-transform', showRemoved && 'rotate-180')}
aria-hidden="true"
/>
</button>
{showRemoved && (
<Surface
variant="solid"
radius="lg"
padding="none"
elevation="xs"
className="divide-y divide-border overflow-hidden"
>
{removed.map((skill) => (
<div key={skill.id} className="flex flex-wrap items-center justify-between gap-3 px-4 py-3">
<div className="min-w-0">
<p className="text-body-sm font-medium text-ink-2">{skill.name}</p>
<p className="mt-0.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>
<Button variant="outline" size="sm" shape="rounded" onClick={() => restoreSkill(skill)}>
Restore
</Button>
</div>
))}
</Surface>
)}
</section>
)}
<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.
Each skill is a Markdown definition that attaches itself to the pages it names. A UI skill
draws a section on those pages; an Owliver skill answers questions on them. Both are read by
the same registry and the same data resolver, so a figure in a card and a figure in an answer
come from one reading. 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"
title={pendingDelete?.custom
? `Remove ${pendingDelete?.name || 'skill'}?`
: `Remove ${pendingDelete?.name || 'skill'} from this workspace?`}
description={pendingDelete?.custom
? `This deletes one definition from this account${
countSections(pendingDelete) > 0 && pendingDelete.facets?.includes('owliver')
? ' — including both the section it draws and the answers it offers'
: ''
}. Every other skill, and anything shipped with KROW, is unaffected.`
: `${pendingDelete?.name || 'This skill'} ships with KROW, so its definition stays in the repository. It is switched off everywhere and taken off this list — you can put it back from “Removed”.`}
confirmLabel={pendingDelete?.custom ? 'Remove skill' : 'Remove from workspace'}
tone="destructive"
onConfirm={() => pendingDelete && removeSkill(pendingDelete)}
/>