update owliver skill
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
@@ -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)} />
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
640
src/pages/admin/OwliverSkillEditor.jsx
Normal file
640
src/pages/admin/OwliverSkillEditor.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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)}
|
||||
/>
|
||||
|
||||
Reference in New Issue
Block a user