candidates and board ui agent issue
Some checks failed
CI / check (push) Failing after 4m58s

This commit is contained in:
2026-09-05 10:46:06 +05:30
parent e02a0c23d4
commit 6249e00a3a
78 changed files with 15071 additions and 1998 deletions

View File

@@ -1,15 +1,12 @@
import React, { useMemo, useState } from 'react';
import {
Activity, FilePlus2, LogIn, LogOut, Mic, ScanSearch, Send, ShieldAlert, UserCheck, UserPlus,
Users,
} from 'lucide-react';
import {
Avatar, Badge, DataTable, MetricStrip, SearchInput, Surface, Timeline,
} from '@/components/ds';
import { useUserActivity } from '@/lib/krowHooks';
import { AdminPage, SectionTitle, Toolbar } from '@/components/admin/PageShell';
import { FilterSelect } from '@/pages/admin/Positions';
import { SkillSurface } from '@/components/skills/SkillSurface';
import { AdminPage } from '@/components/admin/PageShell';
import { UiTreeRenderer } from '@/components/ui-tree/UiTreeRenderer';
import { composePage } from '@/lib/ui/composition';
import { useUiEditing } from '@/components/ui-tree/UiEditingProvider';
import { UiEditor } from '@/components/ui-editor/UiEditor';
import '@/components/ui-tree/nodeTypes';
import '@/pages/admin/activity/nodes';
/**
* Admin Activity — an audit centre.
@@ -33,35 +30,9 @@ const SEVERITY = {
apply_job: 'low',
};
const SEVERITY_META = {
high: { label: 'High', variant: 'destructive' },
medium: { label: 'Medium', variant: 'warning' },
low: { label: 'Low', variant: 'neutral' },
};
/** Severity carried through to the timeline node, so the two views agree. */
const TIMELINE_TONE = { high: 'destructive', medium: 'warning', low: 'neutral' };
/** How many events the timeline shows before the table takes over. */
const TIMELINE_LIMIT = 12;
/**
* An icon per event type. Worth the table: on a chronology the glyph is what
* makes a run of hires distinguishable from a run of logins at a glance, which is
* the whole reason to show a timeline rather than another list of rows.
*/
const EVENT_ICON = {
hire_candidate: UserCheck,
assign_employee: Users,
create_position: FilePlus2,
screen_candidate: ScanSearch,
start_interview: Mic,
apply_job: Send,
signup: UserPlus,
login: LogIn,
logout: LogOut,
};
/** Stable pseudo-IP from the email, so the demo shows metadata without inventing
* a value that changes on every render. */
const ipFor = (email) => {
@@ -143,162 +114,42 @@ export default function AdminActivity() {
return [...groups.entries()];
}, [recent]);
/**
* What this page's sections read.
*
* Published once, as one bag. The renderer passes it down untouched and never
* looks inside, which is what keeps it ignorant of what an Activity page
* contains — and what lets the next page publish something entirely
* different without the renderer changing.
*/
const context = useMemo(() => ({
isLoading, events, users, actions, filtered, isFiltered, clearFilters,
search, setSearch, user, setUser, action, setAction, severity, setSeverity, range, setRange,
highRisk, last24h, recent, byDay,
}), [
isLoading, events, users, actions, filtered, isFiltered,
search, user, action, severity, range, highRisk, last24h, recent, byDay,
]);
return (
<AdminPage
title="Activity"
subtitle="Monitor workforce operations, user actions and system events."
>
<SkillSurface page="activity" placement="after-header" />
<MetricStrip
columns={4}
loading={isLoading}
items={[
{ label: 'Total events', value: events.length },
{ label: 'Last 24 hours', value: last24h.length, tone: last24h.length ? 'default' : 'warning' },
{ label: 'Privileged actions', value: highRisk.length, tone: 'warning', sub: 'hires and position changes' },
{ label: 'Distinct users', value: users.length },
]}
/>
{highRisk.length > 0 && (
<Surface variant="solid" radius="lg" padding="sm" elevation="xs" className="border-warning/30 bg-warning-muted">
<div className="flex items-start gap-2.5">
<ShieldAlert className="mt-0.5 h-4 w-4 shrink-0 text-warning" aria-hidden="true" />
<p className="text-body-sm text-ink-2">
<span className="font-semibold text-ink-1">{highRisk.length} privileged actions</span> in this log —
hires and position changes. These should always trace to a named person.
</p>
</div>
</Surface>
)}
{/* The recent timeline. Deliberately above the log and deliberately not
filtered: this answers "what just happened", which is a different
question from the one the toolbar below exists to ask. Reading a
chronology is also how an auditor starts — sequence first, then
interrogate the specifics. */}
<section aria-labelledby="timeline" className="space-y-3">
<SectionTitle
id="timeline"
title="Operational timeline"
meta={`Most recent ${recent.length} events`}
/>
<Surface variant="solid" radius="lg" padding="none" elevation="xs" className="overflow-hidden">
{recent.length ? (
<div className="divide-y divide-border">
{byDay.map(([day, dayEvents]) => (
<div key={day} className="px-4 py-3.5">
<p className="mb-3 text-[10px] font-semibold uppercase tracking-wide text-ink-4">
{day}
</p>
<Timeline
compact
items={dayEvents.map((e) => ({
id: e.id,
icon: EVENT_ICON[e.event_type] || Activity,
tone: TIMELINE_TONE[e.severity],
title: e.event_type.replace(/_/g, ' '),
description: e.details || undefined,
meta: `${e.user_name || e.user_email}${e.account_type ? ` · ${e.account_type}` : ''}`,
timestamp: new Date(e.created_date).toLocaleTimeString(undefined, {
hour: 'numeric', minute: '2-digit',
}),
}))}
/>
</div>
))}
</div>
) : (
<p className="px-4 py-8 text-center text-body-sm text-ink-3">
No activity has been logged yet.
</p>
)}
</Surface>
</section>
<section aria-labelledby="audit" className="space-y-3">
<SectionTitle id="audit" title="Audit log" meta={`${filtered.length} of ${events.length} events`} />
<Toolbar
search={<SearchInput value={search} onChange={setSearch} placeholder="Search events" size="sm" />}
filters={
<>
<FilterSelect value={user} onChange={setUser} label="User"
options={[{ value: 'all', label: 'All users' }, ...users.map((u) => ({ value: u, label: u }))]} />
<FilterSelect value={action} onChange={setAction} label="Action"
options={[{ value: 'all', label: 'All actions' }, ...actions.map((a) => ({ value: a, label: a.replace(/_/g, ' ') }))]} />
<FilterSelect value={severity} onChange={setSeverity} label="Severity" options={[
{ value: 'all', label: 'All severities' }, { value: 'high', label: 'High' },
{ value: 'medium', label: 'Medium' }, { value: 'low', label: 'Low' },
]} />
<FilterSelect value={range} onChange={setRange} label="Date" options={[
{ value: 'all', label: 'All time' }, { value: '24h', label: 'Last 24 hours' },
{ value: '7d', label: 'Last 7 days' }, { value: '30d', label: 'Last 30 days' },
]} />
</>
}
/>
<DataTable
loading={isLoading}
rows={filtered}
pageSize={20}
isFiltered={isFiltered}
onClearFilters={clearFilters}
caption="Platform audit log"
rowClassName={(e) => (e.severity === 'high' ? 'bg-warning-muted/40' : undefined)}
columns={[
{
key: 'user_name', header: 'User', sortable: true,
cell: (e) => (
<div className="flex min-w-0 items-center gap-2.5">
<Avatar name={e.user_name || e.user_email} size="xs" />
<div className="min-w-0">
<p className="truncate font-medium text-ink-1">{e.user_name || '—'}</p>
<p className="truncate text-[10px] text-ink-4">{e.user_email}</p>
</div>
</div>
),
},
{
key: 'event_type', header: 'Action', sortable: true,
cell: (e) => <span className="whitespace-nowrap">{e.event_type.replace(/_/g, ' ')}</span>,
},
{
key: 'details', header: 'Entity', hideBelow: 'md',
cell: (e) => <span className="line-clamp-1 text-ink-3">{e.details || '—'}</span>,
},
{
key: 'account_type', header: 'Role', hideBelow: 'lg',
cell: (e) => <Badge variant={e.account_type === 'employer' ? 'info' : 'neutral'} size="sm">{e.account_type || 'unknown'}</Badge>,
},
{
key: 'ip', header: 'Source IP', hideBelow: 'lg',
cell: (e) => <span className="font-mono text-[11px] text-ink-4">{e.ip}</span>,
},
{
key: 'created_date', header: 'Timestamp', align: 'right', sortable: true,
sortValue: (e) => new Date(e.created_date).getTime(),
cell: (e) => (
<span className="whitespace-nowrap text-[11px] text-ink-4">
{new Date(e.created_date).toLocaleString(undefined, {
month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit',
})}
</span>
),
},
{
key: 'severity', header: 'Severity', align: 'right',
cell: (e) => (
<Badge variant={SEVERITY_META[e.severity].variant} size="sm">
{SEVERITY_META[e.severity].label}
</Badge>
),
},
]}
/>
</section>
<SkillSurface page="activity" placement="before-footer" />
{/* The editing session is mounted by the layout, so the panel beside this
page shares it. The page only renders what it is handed, which is why
a preview in chat and the applied state cannot diverge. */}
<UiEditor />
<ActivityComposition context={context} />
</AdminPage>
);
}
/** The composed page. Split out so it can read the tree the provider computed. */
function ActivityComposition({ context }) {
const editing = useUiEditing();
/* Outside a layout — a test, a storybook — there is no session, and the page
still has to draw. It falls back to the composition as shipped. */
const tree = editing?.tree || composePage('activity').tree;
return <UiTreeRenderer nodes={tree} context={context} />;
}

View File

@@ -165,6 +165,7 @@ export default function AdminAgentDetail() {
/* A new agent needs an id before it has an address. Derived from the name
so the author never has to invent one. */
const composed = applyAgentFields(baseSource, fields);
const problem = validateAgentSource(composed);
if (problem) { toast.error(problem); return null; }

View File

@@ -1,16 +1,13 @@
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 '@/components/admin/PageShell';
import { SkillSurface } from '@/components/skills/SkillSurface';
import { AdminPage } from '@/components/admin/PageShell';
import { UiTreeRenderer } from '@/components/ui-tree/UiTreeRenderer';
import { useUiEditing } from '@/components/ui-tree/UiEditingProvider';
import { UiEditor } from '@/components/ui-editor/UiEditor';
import { composePage } from '@/lib/ui/composition';
import '@/components/ui-tree/nodeTypes';
import '@/pages/admin/analytics/nodes';
import {
buildEfficiency, buildFunnel, buildInsights, buildTrend, byDepartment, byPosition,
hiresWithFill, summarise,
@@ -38,101 +35,6 @@ import {
* 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() {
const { data: staff = [], isLoading } = useStaff();
const { data: applications = [] } = useApplications();
@@ -154,211 +56,24 @@ export default function AdminAnalytics() {
[hires, departments, positions, funnel, efficiency]
);
const context = useMemo(() => ({
summary, funnel, trend, departments, positions, efficiency, insights, hires, isLoading, applications,
}), [summary, funnel, trend, departments, positions, efficiency, insights, hires, isLoading, applications]);
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 min-w-[36rem] 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" />
<UiEditor />
<AnalyticsComposition context={context} />
</AdminPage>
);
}
/** The composed page. */
function AnalyticsComposition({ context }) {
const editing = useUiEditing();
const tree = editing?.tree || composePage('analytics').tree;
return <UiTreeRenderer nodes={tree} context={context} />;
}

View File

@@ -1,12 +1,16 @@
import React, { useMemo, useState } from 'react';
import { Button, SearchInput } from '@/components/ds';
import { useApplications, useInterviews, useJobPostings, useUpdateApplication, useHireCandidate } from '@/lib/krowHooks';
import { base44 } from '@/api/base44Client';
import { toast } from 'react-hot-toast';
import { AdminPage, Toolbar } from '@/components/admin/PageShell';
import { FilterSelect } from '@/pages/admin/Positions';
import CandidateCard from '@/components/krow/CandidateCard';
import { SkillSurface } from '@/components/skills/SkillSurface';
import { AdminPage } from '@/components/admin/PageShell';
import { UiTreeRenderer } from '@/components/ui-tree/UiTreeRenderer';
import { useUiEditing } from '@/components/ui-tree/UiEditingProvider';
import { UiEditor } from '@/components/ui-editor/UiEditor';
import { composePage } from '@/lib/ui/composition';
import { SORTS } from '@/pages/admin/candidates/nodes';
import '@/components/ui-tree/nodeTypes';
import '@/pages/admin/candidates/nodes';
import AIInterviewModal from '@/components/krow/AIInterviewModal';
import ScheduleInterviewModal from '@/components/krow/ScheduleInterviewModal';
import MessageCandidateModal from '@/components/krow/MessageCandidateModal';
@@ -19,12 +23,6 @@ const SCORE_BANDS = {
unscored: (s) => !s,
};
const SORTS = {
score: { label: 'Highest score', compare: (a, b) => (b.ai_score || 0) - (a.ai_score || 0) },
recent: { label: 'Recent activity', compare: (a, b) => new Date(b.updated_date).getTime() - new Date(a.updated_date).getTime() },
name: { label: 'Name A–Z', compare: (a, b) => a.applicant_name.localeCompare(b.applicant_name) },
experience: { label: 'Most experience', compare: (a, b) => (b.years_experience || 0) - (a.years_experience || 0) },
};
export default function AdminCandidates() {
const { data: applications = [], isLoading } = useApplications();
@@ -103,74 +101,21 @@ export default function AdminCandidates() {
const postingById = useMemo(() => Object.fromEntries(postings.map(p => [p.id, p])), [postings]);
const resolveTitle = (a) => a.job_title || postingById[a.job_posting_id]?.title || '';
const context = useMemo(() => ({
applications, filtered, isLoading, isFiltered, clearFilters, positions, resolveTitle,
search, setSearch, position, setPosition, stage, setStage, band, setBand, sort, setSort,
handleAction, setMessageApp, setScheduleApp, handleDecline, handleDelete, handleHire,
}), [applications, filtered, isLoading, isFiltered, positions, search, position, stage, band, sort]);
return (
<AdminPage
title="Candidates"
subtitle="Review, compare and manage the candidate pipeline."
>
<SkillSurface page="candidates" placement="after-header" />
<UiEditor />
<CandidatesComposition context={context} />
<Toolbar
search={<SearchInput value={search} onChange={setSearch} placeholder="Search candidates..." size="sm" />}
filters={
<>
<FilterSelect value={position} onChange={setPosition} label="Position"
options={[{ value: 'all', label: 'All positions' }, ...positions.map((p) => ({ value: p, label: p }))]} />
<FilterSelect value={stage} onChange={setStage} label="Stage" options={[
{ value: 'all', label: 'All stages' }, { value: 'applied', label: 'Applied' },
{ value: 'ai_screened', label: 'AI Screened' }, { value: 'interview', label: 'Interviewing' },
{ value: 'hired', label: 'Hired' }, { value: 'rejected', label: 'Declined' },
]} />
<FilterSelect value={band} onChange={setBand} label="Score" options={[
{ value: 'all', label: 'All scores' }, { value: 'top', label: '80+ Top' },
{ value: 'strong', label: '60–79 Strong' }, { value: 'weak', label: 'Under 60' },
{ value: 'unscored', label: 'Unscored' },
]} />
<FilterSelect value={sort} onChange={setSort} label="Sort"
options={Object.entries(SORTS).map(([value, s]) => ({ value, label: s.label }))} />
</>
}
meta={`${filtered.length} of ${applications.length} candidates${isFiltered ? ' · filtered' : ''}`}
/>
{isLoading ? (
<div className="space-y-3">
{[...Array(5)].map((_, i) => (
<div key={i} className="h-24 bg-white border border-[#E5E7EB] rounded-xl animate-pulse" />
))}
</div>
) : filtered.length === 0 ? (
<div className="text-center py-16">
<p className="text-body-sm text-ink-3">No candidates match your filters</p>
{isFiltered && (
<Button size="xs" variant="outline" className="mt-3" onClick={clearFilters}>
Clear filters
</Button>
)}
</div>
) : (
<div className="space-y-4">
{filtered.map((app, idx) => (
<CandidateCard
key={app.id}
application={app}
jobTitle={resolveTitle(app)}
rank={idx + 1}
onAction={handleAction}
onMessage={setMessageApp}
onCall={setMessageApp}
onSchedule={setScheduleApp}
onDecline={handleDecline}
onDelete={handleDelete}
onHire={handleHire}
/>
))}
</div>
)}
<SkillSurface page="candidates" placement="before-footer" />
{/* Modals */}
{/* Modals — overlays rather than sections, so they stay outside the tree. */}
{interviewApp && (
<AIInterviewModal open={true} onClose={() => setInterviewApp(null)} application={interviewApp} job={postings.find(p => p.id === interviewApp.job_posting_id)} />
)}
@@ -183,3 +128,10 @@ export default function AdminCandidates() {
</AdminPage>
);
}
/** The composed page. */
function CandidatesComposition({ context }) {
const editing = useUiEditing();
const tree = editing?.tree || composePage('candidates').tree;
return <UiTreeRenderer nodes={tree} context={context} />;
}

View File

@@ -1,17 +1,17 @@
import React, { useMemo } from 'react';
import { useNavigate } from 'react-router-dom';
import {
Bar, BarChart, CartesianGrid, Cell, Pie, PieChart, ResponsiveContainer, Tooltip, XAxis, YAxis,
} from 'recharts';
import { ArrowRight } from 'lucide-react';
import {
AXIS_PROPS, Avatar, Badge, Button, CHART_TONES, ChartTooltip, InsightList, InsightRow,
MetricStrip, ProgressBar, ProgressRing, Surface,
import { CHART_TONES,
} from '@/components/ds';
import { useApplications, useInterviews, useJobPostings, useStaff, useWorkerProfiles } from '@/lib/krowHooks';
import { buildFacts } from '@/components/ai-assistant/insights';
import { AdminPage, SectionTitle } from '@/components/admin/PageShell';
import { SkillSurface } from '@/components/skills/SkillSurface';
import { AdminPage } from '@/components/admin/PageShell';
import { UiTreeRenderer } from '@/components/ui-tree/UiTreeRenderer';
import { useUiEditing } from '@/components/ui-tree/UiEditingProvider';
import { UiEditor } from '@/components/ui-editor/UiEditor';
import { composePage } from '@/lib/ui/composition';
import '@/components/ui-tree/nodeTypes';
import '@/pages/admin/candidates-analysis/nodes';
/**
* Admin Candidates Analysis — the analytical counterpart to Candidates.
@@ -161,262 +161,24 @@ export default function AdminCandidatesAnalysis() {
},
].filter(Boolean), [f, skillGaps]);
const context = useMemo(() => ({ navigate, applications, postings, interviews, staff, profiles, f, bands, risks, top, skillSupply, skillGaps, positionFit, recommendations }), [
navigate, applications, postings, interviews, staff, profiles, f, bands, risks, top, skillSupply, skillGaps, positionFit, recommendations,
]);
return (
<AdminPage
title="Candidates Analysis"
subtitle="Talent supply, quality distribution and pipeline risk."
>
<SkillSurface page="candidates-analysis" placement="after-header" />
<MetricStrip
columns={5}
items={[
{ label: 'In pipeline', value: f.total },
{ label: 'Scored', value: f.scored.length, sub: `${f.standardizedPct}% coverage`, tone: f.standardizedPct >= 80 ? 'success' : 'warning' },
{ label: 'At 80+', value: f.ranked.filter((a) => a.ai_score >= 80).length, tone: 'brand' },
{ label: 'Avg score', value: f.avgScore || '—' },
{ label: 'Risk flags', value: risks.length, tone: risks.length ? 'warning' : 'success' },
]}
/>
<div className="grid gap-6 lg:grid-cols-5">
<section aria-labelledby="dist" className="space-y-3 lg:col-span-2">
<SectionTitle id="dist" title="Quality distribution" meta={`${f.total} candidates`} />
<ResponsiveContainer width="100%" height={200}>
<PieChart>
<Pie data={bands} dataKey="value" nameKey="name" innerRadius={48} outerRadius={78} paddingAngle={2} strokeWidth={0}>
{bands.map((b) => <Cell key={b.name} fill={b.color} />)}
</Pie>
<Tooltip content={<ChartTooltip />} />
</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" />
<span className="flex-1 text-ink-3">{b.name}</span>
<span className="font-semibold tabular-nums text-ink-1">{b.value}</span>
</div>
))}
</div>
</section>
<section aria-labelledby="strongest" className="space-y-3 lg:col-span-3">
<SectionTitle
id="strongest"
title="Strongest candidates"
meta="By AI score"
action={{ label: 'All candidates', onClick: () => navigate('/admin/candidates') }}
/>
<Surface variant="solid" radius="lg" padding="none" elevation="xs" className="divide-y divide-border overflow-hidden">
{top.length ? top.map((c, i) => (
<div key={c.id} className="flex items-center gap-3 px-4 py-2.5">
<span className="w-4 shrink-0 text-caption font-bold text-ink-4">{i + 1}</span>
<Avatar name={c.applicant_name} src={c.selfie_url} size="sm" />
<div className="min-w-0 flex-1">
<p className="truncate text-body-sm font-medium text-ink-1">{c.applicant_name}</p>
<p className="truncate text-[10px] text-ink-4">{c.job_title}</p>
</div>
{c.ai_match_label && (
<Badge variant="soft" size="sm" className="hidden sm:inline-flex">{c.ai_match_label}</Badge>
)}
<div className="w-20 shrink-0">
<ProgressBar value={c.ai_score} tone="score" size="xs" />
</div>
<ProgressRing value={c.ai_score} size={30} strokeWidth={3} tone="score" />
</div>
)) : (
<p className="px-4 py-8 text-center text-body-sm text-ink-3">No scored candidates yet.</p>
)}
</Surface>
</section>
</div>
<section aria-labelledby="risk" className="space-y-3">
<SectionTitle id="risk" title="Candidate risk" meta={risks.length ? `${risks.length} flags` : 'No flags'} />
<Surface variant="solid" radius="lg" padding="none" elevation="xs" className="overflow-hidden">
{risks.length ? (
<InsightList>{risks.map((r, i) => <InsightRow key={i} {...r} />)}</InsightList>
) : (
<p className="px-4 py-8 text-center text-body-sm text-ink-3">
No material risk flags. Credentials, availability and interview integrity all check out.
</p>
)}
</Surface>
<p className="text-caption text-ink-4">
Flags are questions for a human, not rejections.
</p>
</section>
{/* Skill supply and the gaps in it, side by side: what the pool has, and
what the open roles ask for and nobody offers. */}
<div className="grid gap-6 lg:grid-cols-5">
<section aria-labelledby="skills" className="space-y-3 lg:col-span-3">
<SectionTitle
id="skills"
title="Skill supply"
meta={`Top ${skillSupply.length} across the scored pool`}
/>
{skillSupply.length ? (
<ResponsiveContainer width="100%" height={Math.max(180, skillSupply.length * 30)}>
<BarChart data={skillSupply} layout="vertical" margin={{ top: 0, right: 28, left: 0, bottom: 0 }}>
<CartesianGrid strokeDasharray="3 3" stroke={CHART_TONES.grid} horizontal={false} />
<XAxis type="number" {...AXIS_PROPS} allowDecimals={false} />
<YAxis
type="category"
dataKey="skill"
{...AXIS_PROPS}
width={128}
tick={{ fontSize: 11, fill: CHART_TONES.axis }}
/>
<Tooltip content={<ChartTooltip />} />
<Bar dataKey="count" name="Candidates" radius={[0, 4, 4, 0]} maxBarSize={16}>
{/* Tinted by the average score of the people holding the skill,
so breadth and quality read together. */}
{skillSupply.map((s) => (
<Cell
key={s.skill}
fill={s.avgScore >= 75 ? CHART_TONES.brand : s.avgScore >= 60 ? CHART_TONES.accentPale : CHART_TONES.mint}
/>
))}
</Bar>
</BarChart>
</ResponsiveContainer>
) : (
<p className="text-body-sm text-ink-3">
Nothing is scored yet, so there is no skill supply to read.
</p>
)}
<p className="text-caption text-ink-4">
Bar length is how many candidates claim the skill. Darker blue means the
people holding it average 75 or above.
</p>
</section>
<section aria-labelledby="gaps" className="space-y-3 lg:col-span-2">
<SectionTitle
id="gaps"
title="Skill gaps"
meta={skillGaps.length ? `${skillGaps.length} unmet` : 'None'}
/>
<Surface variant="solid" radius="lg" padding="none" elevation="xs" className="overflow-hidden">
{skillGaps.length ? (
<ul className="divide-y divide-border">
{skillGaps.map((g) => (
<li key={g.name} className="px-4 py-2.5">
<p className="text-body-sm font-medium text-ink-1">{g.name}</p>
<p className="mt-0.5 text-caption leading-relaxed text-ink-3">
Required by {g.roles.join(', ')} — held by nobody in the scored pool.
</p>
</li>
))}
</ul>
) : (
<p className="px-4 py-8 text-center text-body-sm text-ink-3">
Every credential an open role requires is held by someone in the scored pool.
</p>
)}
</Surface>
</section>
</div>
{/* Position fit — which roles can actually be filled from current supply. */}
<section aria-labelledby="fit" className="space-y-3">
<SectionTitle id="fit" title="Position fit" meta={`${positionFit.length} open roles`} />
<Surface variant="solid" radius="lg" padding="none" elevation="xs" className="overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full min-w-[40rem] border-collapse text-body-sm">
<caption className="sr-only">
Applicant supply against candidates clearing the bar, per open role
</caption>
<thead>
<tr className="border-b border-border bg-surface-subtle">
{['Role', 'Applied', 'Qualified', 'Avg score', 'Fit'].map((h, i) => (
<th
key={h}
scope="col"
className={`whitespace-nowrap px-4 py-2.5 text-[10px] font-semibold uppercase tracking-wide text-ink-4 ${i ? 'text-right' : 'text-left'}`}
>
{h}
</th>
))}
</tr>
</thead>
<tbody>
{positionFit.map((r) => (
<tr key={r.title} className="border-b border-border last:border-0 hover:bg-surface-subtle">
<td className="px-4 py-2.5 font-medium text-ink-1">{r.title}</td>
<td className={`px-4 py-2.5 text-right tabular-nums ${r.applied ? 'text-ink-2' : 'font-semibold text-destructive'}`}>
{r.applied}
</td>
<td className="px-4 py-2.5 text-right tabular-nums text-ink-2">{r.qualified}</td>
<td className="px-4 py-2.5 text-right tabular-nums text-ink-2">{r.avgScore || '—'}</td>
<td className="px-4 py-2.5">
<div className="ml-auto w-24">
<ProgressBar
value={r.fit}
tone={r.fit >= 50 ? 'success' : r.fit > 0 ? 'warning' : 'destructive'}
size="xs"
/>
<p className="mt-1 text-right text-[10px] tabular-nums text-ink-4">{r.fit}% qualified</p>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
</Surface>
</section>
{/* Recommendations — the actions the analysis above implies. */}
{recommendations.length > 0 && (
<section aria-labelledby="recommend" className="space-y-3">
<SectionTitle
id="recommend"
title="Recommendations"
meta={`${recommendations.length} ordered by impact`}
/>
<Surface variant="solid" radius="lg" padding="none" elevation="xs" className="overflow-hidden">
<ol className="divide-y divide-border">
{recommendations.map((r, i) => (
<li key={r.title} className="flex gap-3 px-4 py-3">
<span className="mt-0.5 grid h-5 w-5 shrink-0 place-items-center rounded-full bg-krow-blue-tint text-[10px] font-bold text-krow-blue">
{i + 1}
</span>
<div className="min-w-0">
<p className="text-body-sm font-medium text-ink-1">{r.title}</p>
<p className="mt-0.5 text-caption leading-relaxed text-ink-3">{r.body}</p>
</div>
</li>
))}
</ol>
</Surface>
</section>
)}
<section aria-labelledby="screening" className="space-y-3">
<SectionTitle id="screening" title="Screening efficiency" />
<div className="grid gap-4 sm:grid-cols-3">
{[
{ label: 'Coverage', value: `${f.standardizedPct}%`, detail: `${f.scored.length} of ${f.total} scored`, tone: f.standardizedPct >= 80 ? 'success' : 'warning' },
{ label: 'Interview completion', value: `${f.interviewCompletion}%`, detail: `${f.completedInterviews.length} of ${f.interviews.length} scored` },
{ label: 'Quality lift', value: f.hiredAvgScore && f.avgScore ? `+${f.hiredAvgScore - f.avgScore}` : '—', detail: 'hires vs pool average', tone: 'success' },
].map((s) => (
<Surface key={s.label} variant="solid" radius="lg" padding="default" elevation="xs">
<p className="text-[11px] font-medium uppercase tracking-wide text-ink-4">{s.label}</p>
<p className={`mt-1 font-heading text-title-lg font-bold tabular-nums ${s.tone === 'success' ? 'text-success' : s.tone === 'warning' ? 'text-warning' : 'text-ink-1'}`}>
{s.value}
</p>
<p className="mt-0.5 text-caption text-ink-3">{s.detail}</p>
</Surface>
))}
</div>
<Button variant="ghost" size="sm" onClick={() => navigate('/admin/analytics')} className="w-fit">
Full analytics <ArrowRight aria-hidden="true" />
</Button>
</section>
<SkillSurface page="candidates-analysis" placement="before-footer" />
<UiEditor />
<CandidatesAnalysisComposition context={context} />
</AdminPage>
);
}
/** The composed page. */
function CandidatesAnalysisComposition({ context }) {
const editing = useUiEditing();
const tree = editing?.tree || composePage('candidates-analysis').tree;
return <UiTreeRenderer nodes={tree} context={context} />;
}

View File

@@ -1,23 +1,23 @@
import React, { useMemo, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import {
Area, Bar, CartesianGrid, Cell, ComposedChart, Legend, Line, ResponsiveContainer,
Tooltip, XAxis, YAxis,
} from 'recharts';
import {
AlertTriangle, ArrowRight, Building2, CalendarCheck, CheckCircle2, ChevronRight, Clock, Download, Filter, Sparkles, Users, Zap,
} from 'lucide-react';
import { cn } from '@/lib/utils';
import {
AXIS_PROPS, Avatar, Badge, Button, CHART_TONES, ChartTooltip, EmptyState, SegmentedToggle,
Skeleton, StatusBadge, toast,
import { Button, EmptyState, toast,
} from '@/components/ds';
import {
useApplications, useInterviews, useJobPostings, useStaff, useUserActivity, useWorkerProfiles,
} from '@/lib/krowHooks';
import { buildFacts } from '@/components/ai-assistant/insights';
import { AdminPage } from '@/components/admin/PageShell';
import { SkillSurface } from '@/components/skills/SkillSurface';
import { UiTreeRenderer } from '@/components/ui-tree/UiTreeRenderer';
import { useUiEditing } from '@/components/ui-tree/UiEditingProvider';
import { UiEditor } from '@/components/ui-editor/UiEditor';
import { composePage } from '@/lib/ui/composition';
import '@/components/ui-tree/nodeTypes';
import '@/pages/admin/control-center/nodes';
/**
* Control Center — the KROW Admin command centre.
@@ -577,6 +577,10 @@ export default function ControlCenter() {
toast.success('Operations snapshot exported');
};
const context = useMemo(() => ({ navigate, range, setRange, applications, isLoading, postings, interviews, staff, profiles, activity, f, metrics, activitySeries, positionPerformance, strongest, weakestRole, actions, activePositions, topTalent, recent, exportSnapshot }), [
navigate, range, setRange, applications, isLoading, postings, interviews, staff, profiles, activity, f, metrics, activitySeries, positionPerformance, strongest, weakestRole, actions, activePositions, topTalent, recent, exportSnapshot,
]);
return (
<AdminPage
title="Control Center"
@@ -587,397 +591,15 @@ export default function ControlCenter() {
</Button>
)}
>
<SkillSurface page="control-center" placement="after-header" />
{/* 1 ── Operations snapshot */}
<Snapshot metrics={metrics} loading={isLoading} />
{/* 2 ── Hiring activity */}
<Band
id="cc-activity"
title="Hiring activity"
meta={
RANGES[range].step === 1
? `Applications, screening, interviews and hires · last ${RANGES[range].days} days, daily`
: `Applications, screening, interviews and hires · last ${RANGES[range].days} days, ${RANGES[range].step}-day totals`
}
action={
<SegmentedToggle
options={Object.entries(RANGES).map(([value, r]) => ({ value, label: r.label }))}
value={range}
onChange={setRange}
size="sm"
ariaLabel="Hiring activity range"
/>
}
>
<div className="rounded-xl border border-border bg-surface px-2 py-4">
{isLoading ? (
<Skeleton className="mx-2 h-[272px] rounded-lg" />
) : f.total ? (
<ResponsiveContainer width="100%" height={272}>
<ComposedChart data={activitySeries} margin={{ top: 4, right: 12, left: 0, bottom: 0 }}>
<defs>
<linearGradient id="ccApplications" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor={CHART_TONES.brand} stopOpacity={0.22} />
<stop offset="100%" stopColor={CHART_TONES.brand} stopOpacity={0} />
</linearGradient>
</defs>
<CartesianGrid strokeDasharray="3 3" stroke={CHART_TONES.grid} vertical={false} />
<XAxis dataKey="label" {...AXIS_PROPS} interval="preserveStartEnd" minTickGap={20} />
<YAxis {...AXIS_PROPS} allowDecimals={false} width={30} />
<Tooltip content={<ChartTooltip />} />
<Legend
verticalAlign="top"
align="right"
height={28}
iconType="circle"
iconSize={8}
wrapperStyle={{ fontSize: 12, color: CHART_TONES.axis }}
/>
{/* Applications are the volume everything else is drawn from, so
they take the filled area and the downstream stages are lines
over it. */}
<Area
type="monotone"
dataKey="applications"
name="Applications"
stroke={CHART_TONES.brand}
strokeWidth={2}
fill="url(#ccApplications)"
activeDot={{ r: 4, strokeWidth: 0 }}
/>
<Line
type="monotone"
dataKey="screened"
name="AI Screened"
stroke={CHART_TONES.navy}
strokeWidth={2}
strokeDasharray="4 3"
dot={false}
activeDot={{ r: 4, strokeWidth: 0 }}
/>
<Line
type="monotone"
dataKey="interviews"
name="Interviews"
stroke={CHART_TONES.navy}
strokeWidth={1.5}
strokeOpacity={0.55}
dot={false}
activeDot={{ r: 4, strokeWidth: 0 }}
/>
{/* Hires are sparse and are the outcome, so they take the accent
and a bar — a line at this volume reads as flat. */}
<Bar
dataKey="hires"
name="Hires"
fill={CHART_TONES.accent}
radius={[3, 3, 0, 0]}
maxBarSize={14}
/>
</ComposedChart>
</ResponsiveContainer>
) : (
<div className="px-2 py-8">
<EmptyState
title="No applications yet"
description="Once candidates start applying, daily hiring activity appears here."
/>
</div>
)}
</div>
</Band>
{/* 3 ── Pipeline intelligence */}
<Band
id="cc-pipeline"
title="Pipeline intelligence"
meta={`${f.total} entered · conversion and drop-off by stage`}
>
<PipelineFunnel
funnel={f.funnel}
transitions={f.transitions}
weakest={f.bottleneck && f.bottleneck.rate < WEAK_TRANSITION ? f.bottleneck : null}
total={f.total}
/>
</Band>
{/* 4 ── Position performance */}
<Band
id="cc-positions"
title="Position performance"
meta={strongest ? `Strongest conversion: ${strongest}` : undefined}
action={<ViewAll label="View all positions" onClick={() => navigate('/admin/positions')} />}
>
<div className="rounded-xl border border-border bg-surface px-2 py-4">
{positionPerformance.length ? (
<>
<ResponsiveContainer width="100%" height={236}>
<ComposedChart
data={positionPerformance}
barGap={3}
margin={{ top: 4, right: 12, left: 0, bottom: 0 }}
>
<CartesianGrid strokeDasharray="3 3" stroke={CHART_TONES.grid} vertical={false} />
<XAxis
dataKey="name"
{...AXIS_PROPS}
interval={0}
tick={{ fontSize: 10, fill: CHART_TONES.axis }}
/>
<YAxis {...AXIS_PROPS} allowDecimals={false} width={30} />
<Tooltip content={<ChartTooltip />} />
<Legend
verticalAlign="top"
align="right"
height={28}
iconType="circle"
iconSize={8}
wrapperStyle={{ fontSize: 12, color: CHART_TONES.axis }}
/>
<Bar
dataKey="applicants"
name="Applicants"
fill={CHART_TONES.brand}
radius={[3, 3, 0, 0]}
maxBarSize={24}
/>
<Bar dataKey="qualified" name="Qualified (70+)" radius={[3, 3, 0, 0]} maxBarSize={24}>
{/* The best converter takes the accent at full strength and the
worst is left pale, so both ends of the comparison are
visible without a callout. */}
{positionPerformance.map((r) => (
<Cell
key={r.name}
fill={r.name === strongest ? CHART_TONES.accent
: r.name === weakestRole ? CHART_TONES.mint : CHART_TONES.accentPale}
/>
))}
</Bar>
<Bar
dataKey="hired"
name="Hired"
fill={CHART_TONES.navy}
radius={[3, 3, 0, 0]}
maxBarSize={24}
/>
</ComposedChart>
</ResponsiveContainer>
{weakestRole && (
<p className="px-2 pt-2 text-caption leading-relaxed text-ink-3">
<span className="font-semibold text-ink-1">{strongest}</span> converts applicants
into qualified candidates best;{' '}
<span className="font-semibold text-ink-1">{weakestRole}</span> converts worst and
is where sourcing — or the bar itself — is worth a look.
</p>
)}
</>
) : (
<div className="px-2 py-8">
<EmptyState
title="No open positions"
description="Publish a role and its hiring performance appears here."
/>
</div>
)}
</div>
</Band>
{/* 5 ── Action center */}
<Band
id="cc-actions"
title="Action center"
meta={actions.length ? `${actions.length} items, most consequential first` : 'All clear'}
>
<ActionQueue items={actions} />
</Band>
{/* 6 / 7 ── Active positions and top talent. Two lists of comparable weight,
so they share a row rather than each taking one. */}
<div className="grid gap-6 xl:grid-cols-2">
<Band
id="cc-active"
title="Active positions"
meta={`${f.openPositions.length} hiring`}
action={<ViewAll label="View all" onClick={() => navigate('/admin/positions')} />}
>
{activePositions.length ? (
<ul className="divide-y divide-border overflow-hidden rounded-xl border border-border bg-surface">
{activePositions.map((r) => {
const coverage = r.applied ? Math.round((r.screened / r.applied) * 100) : 0;
return (
<li key={r.title}>
<button
type="button"
onClick={() => navigate('/admin/positions')}
className="group flex w-full items-center gap-3 px-4 py-3 text-left transition-colors
hover:bg-surface-subtle focus-visible:outline-none focus-visible:ring-2
focus-visible:ring-inset focus-visible:ring-krow-blue/50"
>
<span className="min-w-0 flex-1">
<span className="block truncate text-body-sm font-medium text-ink-1">
{r.title}
</span>
<span className="block truncate text-caption text-ink-3">
{r.applied} applicant{r.applied === 1 ? '' : 's'} · {r.screened} screened
{' · '}{r.hired} hired
</span>
</span>
{/* Screening coverage as a small bar — the one number that
says whether this role is actually being worked. */}
<span className="hidden w-16 shrink-0 sm:block">
<span className="block h-1.5 overflow-hidden rounded-full bg-surface-sunken">
<span
className={cn(
'block h-full rounded-full',
coverage === 100 ? 'bg-success'
: coverage >= 50 ? 'bg-krow-blue' : 'bg-warning'
)}
style={{ width: `${Math.max(coverage, r.applied ? 4 : 0)}%` }}
/>
</span>
<span className="mt-1 block text-right text-[10px] tabular-nums text-ink-4">
{coverage}%
</span>
</span>
<StatusBadge status={r.posting.status} size="sm" />
<ChevronRight
className="h-3.5 w-3.5 shrink-0 text-ink-4 transition-transform duration-base group-hover:translate-x-0.5"
aria-hidden="true"
/>
</button>
</li>
);
})}
</ul>
) : (
<EmptyState title="No open positions" description="Publish a role to start hiring." />
)}
</Band>
<Band
id="cc-talent"
title="Top talent"
meta={topTalent.length ? 'By KROW Score' : undefined}
action={<ViewAll label="View all" onClick={() => navigate('/admin/candidates')} />}
>
{topTalent.length ? (
<ul className="divide-y divide-border overflow-hidden rounded-xl border border-border bg-surface">
{topTalent.map((c) => (
<li key={c.id}>
<button
type="button"
onClick={() => navigate('/admin/candidates')}
className="group flex w-full items-center gap-3 px-4 py-3 text-left transition-colors
hover:bg-surface-subtle focus-visible:outline-none focus-visible:ring-2
focus-visible:ring-inset focus-visible:ring-krow-blue/50"
>
{/* The seeded portrait where there is one, otherwise the design
system's initials avatar. Never an invented face. */}
<Avatar name={c.applicant_name} src={c.selfie_url} size="sm" />
<span className="min-w-0 flex-1">
<span className="block truncate text-body-sm font-medium text-ink-1">
{c.applicant_name}
</span>
<span className="block truncate text-caption text-ink-3">{c.job_title}</span>
</span>
<span className="shrink-0 text-right">
<span className="block font-heading text-body font-bold tabular-nums text-ink-1">
{c.ai_score}
</span>
<span className="block text-[10px] text-ink-4">KROW</span>
</span>
<StatusBadge status={c.status} size="sm" />
<ChevronRight
className="h-3.5 w-3.5 shrink-0 text-ink-4 transition-transform duration-base group-hover:translate-x-0.5"
aria-hidden="true"
/>
</button>
</li>
))}
</ul>
) : (
<EmptyState
title="Nobody scored yet"
description="Screen the pipeline and the strongest candidates appear here."
/>
)}
</Band>
</div>
{/* 8 ── Recent activity */}
<Band
id="cc-recent"
title="Recent activity"
meta={`${activity.length} events logged`}
action={<ViewAll label="View all" onClick={() => navigate('/admin/activity')} />}
>
<div className="overflow-hidden rounded-xl border border-border bg-surface">
<div className="overflow-x-auto">
<table className="w-full min-w-[36rem] border-collapse">
<caption className="sr-only">Most recent platform activity</caption>
<thead>
<tr className="border-b border-border bg-surface-subtle">
{['User', 'Action', 'Entity', 'Time', 'Role'].map((h) => (
<th
key={h}
scope="col"
className={cn(
'whitespace-nowrap px-4 py-2.5 text-[10px] font-semibold uppercase tracking-wide text-ink-4',
h === 'Role' ? 'text-right' : 'text-left'
)}
>
{h}
</th>
))}
</tr>
</thead>
<tbody>
{recent.map((event) => (
<tr key={event.id} className="border-b border-border last:border-0 hover:bg-surface-subtle">
<td className="px-4 py-2.5">
<div className="flex items-center gap-2.5">
<Avatar name={event.user_name || event.user_email} size="xs" />
<div className="min-w-0">
<p className="truncate text-body-sm font-medium text-ink-1">
{event.user_name || '—'}
</p>
<p className="truncate text-[10px] text-ink-4">{event.user_email}</p>
</div>
</div>
</td>
<td className="whitespace-nowrap px-4 py-2.5 text-body-sm text-ink-2">
{String(event.event_type).replace(/_/g, ' ')}
</td>
<td className="max-w-[24rem] px-4 py-2.5 text-body-sm text-ink-3">
<span className="line-clamp-1">{event.details || '—'}</span>
</td>
<td className="whitespace-nowrap px-4 py-2.5 text-caption text-ink-4">
{new Date(event.created_date).toLocaleDateString(undefined, {
month: 'short', day: 'numeric',
})}
</td>
<td className="px-4 py-2.5 text-right">
<Badge variant={event.account_type === 'employer' ? 'info' : 'neutral'} size="sm">
{event.account_type || 'unknown'}
</Badge>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</Band>
<SkillSurface page="control-center" placement="before-footer" />
<UiEditor />
<ControlCenterComposition context={context} />
</AdminPage>
);
}
/** The composed page. */
function ControlCenterComposition({ context }) {
const editing = useUiEditing();
const tree = editing?.tree || composePage('control-center').tree;
return <UiTreeRenderer nodes={tree} context={context} />;
}

View File

@@ -1,14 +1,17 @@
import React, { useMemo, useState } from 'react';
import { Award, Building2, CalendarDays, Clock, Mail, Phone, UserCheck } from 'lucide-react';
import { cn } from '@/lib/utils';
import {
Avatar, Badge, Button, DataTable, Drawer, EmptyState, FilterBar, MetricStrip, StatusBadge,
Surface, Timeline,
} from '@/components/ds';
import { Mail, Phone, UserCheck } from 'lucide-react';
import { Avatar, Badge, Drawer, StatusBadge, Surface } from '@/components/ds';
import { useApplications, useJobPostings, useStaff } from '@/lib/krowHooks';
import { AdminPage, SectionTitle } from '@/components/admin/PageShell';
import { SkillSurface } from '@/components/skills/SkillSurface';
import { hiresWithFill, summarise } from '@/lib/hiringRecords';
import { UiTreeRenderer } from '@/components/ui-tree/UiTreeRenderer';
import { useUiEditing } from '@/components/ui-tree/UiEditingProvider';
import { UiEditor } from '@/components/ui-editor/UiEditor';
import { composePage } from '@/lib/ui/composition';
import { DAY, RANGES, formatDate } from '@/pages/admin/hired-history/nodes';
import '@/components/ui-tree/nodeTypes';
import '@/pages/admin/hired-history/nodes';
/**
* Admin Hired History — the record of who was hired.
@@ -29,20 +32,6 @@ import { hiresWithFill, summarise } from '@/lib/hiringRecords';
* Both pages read `lib/hiringRecords.js`, so the totals cannot disagree.
*/
/** 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 },
];
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 (
@@ -181,165 +170,39 @@ export default function AdminHiredHistory() {
setFilters({ position: 'all', department: 'all', range: 'all' });
};
/* What this page's sections read. Published once; the renderer passes it
down untouched and never looks inside. */
const context = useMemo(() => ({
isLoading, hires, filtered, recent, summary, isFiltered,
search, setSearch, filters, setFilters,
positionOptions, departmentOptions, clearFilters, setSelected,
}), [
isLoading, hires, filtered, recent, summary, isFiltered,
search, filters, positionOptions, departmentOptions,
]);
return (
<AdminPage
title="Hired History"
subtitle="Who was hired, for which position, and what has happened since."
meta={`${hires.length} on record`}
>
<SkillSurface page="hired-history" placement="after-header" />
<UiEditor />
<HiredComposition context={context} />
{/* 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={3}
loading={isLoading}
items={[
{
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`,
},
]}
/>
{/* 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>
)}
{/* 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`}
/>
<DataTable
loading={isLoading}
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: 'Candidate', sortable: true,
cell: (h) => (
<div className="flex min-w-0 items-center gap-2.5">
<Avatar name={h.name} size="sm" />
<div className="min-w-0">
<p className="truncate font-medium text-ink-1">{h.name}</p>
<p className="truncate text-[10px] text-ink-4">{h.email}</p>
</div>
</div>
),
},
{ key: 'role', header: 'Position', sortable: true, hideBelow: 'md' },
{
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: '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>
{/* 5. One person, in full. */}
{/* One person, in full. An overlay rather than a section of the page, so
it stays outside the tree — there is nothing to reorder about a
drawer. */}
<HireDetail hire={selected} onClose={() => setSelected(null)} />
</AdminPage>
);
}
/** The composed page. Split out so it can read the tree the provider computed. */
function HiredComposition({ context }) {
const editing = useUiEditing();
/* Outside a layout — a test, a baseline capture — there is no session, and
the page still has to draw. It falls back to the composition as shipped. */
const tree = editing?.tree || composePage('hired-history').tree;
return <UiTreeRenderer nodes={tree} context={context} />;
}

View File

@@ -19,6 +19,10 @@ import {
PositionCustomRequirements, PositionOverview, PositionRequirements,
} from '@/components/krow/PositionDetails';
import { SkillSurface } from '@/components/skills/SkillSurface';
import { UiNodeSlot } from '@/components/ui-tree/UiTreeRenderer';
import { UiEditor } from '@/components/ui-editor/UiEditor';
import '@/components/ui-tree/nodeTypes';
import '@/pages/admin/positions/nodes';
import { useAssistantPanel, usePublishPageContext } from '@/components/ai-assistant';
import { continueDraftRequest } from '@/lib/skills/draftFlow';
import { AdminPage, SectionTitle } from '@/components/admin/PageShell';
@@ -1128,11 +1132,17 @@ export default function AdminPositions() {
{isFiltered ? `${filtered.length} of ${rows.length} positions · filtered` : summary}
</p>
{/* The editor for what this page composes. Positions is migrated at page
level only, so the tree it offers is the two extension slots and
whatever definitions have claimed them — which is exactly what a
person needs in order to move or hide a Board card. */}
<UiEditor />
{/* 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" />
<UiNodeSlot page="positions" id="positions-extensions-summary" />
{/* Three columns on desktop, two on tablet, one on mobile. */}
{isLoading ? (
@@ -1173,7 +1183,7 @@ export default function AdminPositions() {
/>
)}
<SkillSurface page="positions" placement="after-position-list" className="mt-6" />
<UiNodeSlot page="positions" id="positions-extensions-list" />
<PositionDrawer
position={selected}

View File

@@ -1,25 +1,19 @@
import React, { useMemo, useState } from 'react';
import { Bookmark, Star, UserRound, Trophy, CheckCircle2, TrendingUp, Sparkles } from 'lucide-react';
import { cn } from '@/lib/utils';
import {
Avatar, Badge, DataTable, IconButton, ProgressRing, SearchInput, toast,
} from '@/components/ds';
import { toast } from '@/components/ds';
import { useWorkerProfiles } from '@/lib/krowHooks';
import { getScoreBand, toFICO } from '@/lib/talentHome';
import { AdminPage, SectionTitle, Toolbar } from '@/components/admin/PageShell';
import { FilterSelect } from '@/pages/admin/Positions';
import { SkillSurface } from '@/components/skills/SkillSurface';
import { AdminPage } from '@/components/admin/PageShell';
import { UiTreeRenderer } from '@/components/ui-tree/UiTreeRenderer';
import { useUiEditing } from '@/components/ui-tree/UiEditingProvider';
import { UiEditor } from '@/components/ui-editor/UiEditor';
import { composePage } from '@/lib/ui/composition';
import { SORTS } from '@/pages/admin/talent-pool/nodes';
import '@/components/ui-tree/nodeTypes';
import '@/pages/admin/talent-pool/nodes';
/**
* Admin Talent Pool — talent intelligence, not a card gallery.
*/
const SORTS = {
score: { label: 'Highest score', compare: (a, b) => (b.krow_score || 0) - (a.krow_score || 0) },
experience: { label: 'Most experience', compare: (a, b) => (b.experience_years || 0) - (a.experience_years || 0) },
name: { label: 'Name A–Z', compare: (a, b) => a.full_name.localeCompare(b.full_name) },
recent: { label: 'Recently added', compare: (a, b) => new Date(b.created_date).getTime() - new Date(a.created_date).getTime() },
};
const BANDS = {
all: () => true,
@@ -30,53 +24,6 @@ const BANDS = {
unscored: (s) => !s,
};
const SEGMENT_CONFIG = {
Elite: {
key: 'elite',
icon: Trophy,
bg: 'bg-blue-50/80 text-blue-600 dark:bg-blue-950/60 dark:text-blue-400',
border: 'border-blue-200/50 dark:border-blue-800/40 hover:border-blue-400',
activeBorder: 'border-blue-600 ring-2 ring-blue-500/20',
text: 'text-blue-600 dark:text-blue-400',
accent: 'from-blue-500 to-indigo-500',
},
Excellent: {
key: 'excellent',
icon: Star,
bg: 'bg-emerald-50/80 text-emerald-600 dark:bg-emerald-950/60 dark:text-emerald-400',
border: 'border-emerald-200/50 dark:border-emerald-800/40 hover:border-emerald-400',
activeBorder: 'border-emerald-600 ring-2 ring-emerald-500/20',
text: 'text-emerald-600 dark:text-emerald-400',
accent: 'from-emerald-500 to-teal-500',
},
Solid: {
key: 'solid',
icon: CheckCircle2,
bg: 'bg-indigo-50/80 text-indigo-600 dark:bg-indigo-950/60 dark:text-indigo-400',
border: 'border-indigo-200/50 dark:border-indigo-800/40 hover:border-indigo-400',
activeBorder: 'border-indigo-600 ring-2 ring-indigo-500/20',
text: 'text-indigo-600 dark:text-indigo-400',
accent: 'from-indigo-500 to-purple-500',
},
Building: {
key: 'building',
icon: TrendingUp,
bg: 'bg-amber-50/80 text-amber-600 dark:bg-amber-950/60 dark:text-amber-400',
border: 'border-amber-200/50 dark:border-amber-800/40 hover:border-amber-400',
activeBorder: 'border-amber-600 ring-2 ring-amber-500/20',
text: 'text-amber-600 dark:text-amber-400',
accent: 'from-amber-500 to-orange-500',
},
Unscored: {
key: 'unscored',
icon: Sparkles,
bg: 'bg-orange-50/80 text-orange-600 dark:bg-orange-950/60 dark:text-orange-400',
border: 'border-orange-200/50 dark:border-orange-800/40 hover:border-orange-400',
activeBorder: 'border-orange-600 ring-2 ring-orange-500/20',
text: 'text-orange-600 dark:text-orange-400',
accent: 'from-orange-500 to-red-500',
},
};
export default function AdminTalentPool() {
const { data: profiles = [], isLoading } = useWorkerProfiles();
@@ -159,214 +106,30 @@ export default function AdminTalentPool() {
});
};
const context = useMemo(() => ({
profiles, segments, filtered, isLoading, isFiltered, clearFilters, saved, toggleSave,
search, setSearch, skill, setSkill, experience, setExperience, location, setLocation,
band, setBand, availability, setAvailability, sort, setSort,
skills, locations, availabilities,
}), [
profiles, segments, filtered, isLoading, isFiltered, saved,
search, skill, experience, location, band, availability, sort, skills, locations, availabilities,
]);
return (
<AdminPage
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
id="segments"
title="Talent segments"
meta={`${profiles.length} in the pool`}
/>
<div>
<div className="grid grid-cols-1 gap-3.5 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-5">
{segments.map((s) => {
const cfg = SEGMENT_CONFIG[s.label] || SEGMENT_CONFIG.Elite;
const Icon = cfg.icon;
const isSelected = band === cfg.key;
const empty = s.count === 0;
return (
<div
key={s.label}
onClick={() => setBand(isSelected ? 'all' : cfg.key)}
className={cn(
'group relative flex flex-col justify-between cursor-pointer rounded-xl border bg-surface p-4 shadow-xs backdrop-blur-xs transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md',
isSelected ? cfg.activeBorder : cfg.border
)}
>
<div>
{/* Header Row: Label, Hint & Icon */}
<div className="flex items-center justify-between gap-1.5">
<div className="flex items-baseline gap-1.5 min-w-0">
<span className="truncate text-[11px] font-bold uppercase tracking-wider text-ink-1">
{s.label}
</span>
<span className="truncate text-[10px] font-medium text-ink-4">
{s.hint}
</span>
</div>
<div className={cn('flex h-7 w-7 shrink-0 items-center justify-center rounded-lg shadow-xs', cfg.bg)}>
<Icon className="h-3.5 w-3.5" />
</div>
</div>
{/* Main count */}
<div className="mt-2.5 flex items-baseline gap-1.5">
<span
className={cn(
'font-heading text-title-xl font-bold leading-none tabular-nums',
empty ? 'text-ink-4' : cfg.text
)}
>
{s.count}
</span>
</div>
</div>
{/* Sub pill & Accent bar */}
<div className="mt-3.5 space-y-2">
<span className="inline-flex items-center rounded-full bg-surface-subtle px-2 py-0.5 text-[10px] font-semibold text-ink-3 border border-border/60">
{empty
? 'No profiles'
: [
`${s.available} available`,
s.label === 'Unscored'
? 'needs verification'
: s.avgExperience > 0
? `${s.avgExperience}y avg`
: null,
]
.filter(Boolean)
.join(' · ')}
</span>
<div className={cn('h-0.5 w-full rounded-full bg-gradient-to-r opacity-40 group-hover:opacity-100 transition-opacity', cfg.accent)} />
</div>
</div>
);
})}
</div>
<p className="mt-3 border-t border-border/60 pt-3 text-caption leading-relaxed text-ink-3">
{segments[4].count > segments[0].count + segments[1].count
? `${segments[4].count} workers carry no career score against ${segments[0].count + segments[1].count} at Excellent or above. Supply is not the constraint here — verification is.`
: 'The scored part of the pool outweighs the unscored, so matching has enough to work with.'}
</p>
</div>
</section>
<SectionTitle title="Talent directory" meta={`${profiles.length} profiles`} />
<Toolbar
search={<SearchInput value={search} onChange={setSearch} placeholder="Search name, role or skill" size="sm" />}
filters={
<>
<FilterSelect value={skill} onChange={setSkill} label="Skills"
options={[{ value: 'all', label: 'All skills' }, ...skills.map((s) => ({ value: s, label: s }))]} />
<FilterSelect value={experience} onChange={setExperience} label="Experience" options={[
{ value: 'all', label: 'Any experience' }, { value: '5plus', label: '5+ years' },
{ value: '2to5', label: '2–5 years' }, { value: 'under2', label: 'Under 2 years' },
]} />
<FilterSelect value={location} onChange={setLocation} label="Location"
options={[{ value: 'all', label: 'All locations' }, ...locations.map((l) => ({ value: l, label: l }))]} />
<FilterSelect value={band} onChange={setBand} label="Score" options={[
{ value: 'all', label: 'All scores' }, { value: 'elite', label: 'Elite 90+' },
{ value: 'excellent', label: 'Excellent 75+' }, { value: 'solid', label: 'Solid 60+' },
{ value: 'building', label: 'Building' }, { value: 'unscored', label: 'Unscored' },
]} />
<FilterSelect value={availability} onChange={setAvailability} label="Availability"
options={[{ value: 'all', label: 'Any availability' }, ...availabilities.map((a) => ({ value: a, label: a }))]} />
<FilterSelect value={sort} onChange={setSort} label="Sort"
options={Object.entries(SORTS).map(([value, s]) => ({ value, label: s.label }))} />
</>
}
meta={`${filtered.length} of ${profiles.length} talents${isFiltered ? ' · filtered' : ''}${saved.length ? ` · ${saved.length} saved` : ''}`}
/>
<DataTable
loading={isLoading}
rows={filtered}
pageSize={15}
isFiltered={isFiltered}
onClearFilters={clearFilters}
caption="Talent pool ranked by career score"
columns={[
{
key: 'full_name', header: 'Candidate', sortable: true,
cell: (p) => (
<div className="flex min-w-0 items-center gap-2.5">
<Avatar name={p.full_name} src={p.selfie_url} size="sm" />
<div className="min-w-0">
<p className="truncate font-medium text-ink-1">{p.full_name}</p>
<p className="truncate text-[10px] text-ink-4">{p.address || p.email}</p>
</div>
</div>
),
},
{
key: 'role', header: 'Role', hideBelow: 'md',
cell: (p) => (
<div className="min-w-0">
<p className="truncate text-ink-2">{p.current_position || p.desired_position || '—'}</p>
{p.desired_position && p.current_position && (
<p className="truncate text-[10px] text-ink-4">wants {p.desired_position}</p>
)}
</div>
),
},
{
key: 'skills', header: 'Skills', hideBelow: 'lg',
cell: (p) => (p.skills?.length ? (
<div className="flex items-center gap-1">
<Badge variant="neutral" size="sm">{p.skills[0]}</Badge>
{p.skills.length > 1 && <span className="text-[10px] text-ink-4">+{p.skills.length - 1}</span>}
</div>
) : <span className="text-[11px] text-ink-4">—</span>),
},
{
key: 'experience_years', header: 'Exp', align: 'right', sortable: true, hideBelow: 'md',
cell: (p) => (p.experience_years ? `${p.experience_years}y` : '—'),
},
{
key: 'availability', header: 'Availability', hideBelow: 'lg',
cell: (p) => (p.availability?.length
? <span className="text-[11px] text-ink-3">{p.availability.join(', ')}</span>
: <Badge variant="warning" size="sm">Not set</Badge>),
},
{
key: 'krow_score', header: 'Career score', align: 'right', sortable: true,
cell: (p) => {
const score = p.krow_score || 0;
const bandInfo = getScoreBand(score);
return (
<div className="flex items-center justify-end gap-2">
<div className="text-right">
<p className="font-heading text-body font-bold tabular-nums text-ink-1">{toFICO(score)}</p>
<p className="text-[10px] text-ink-4">{bandInfo.label}</p>
</div>
<ProgressRing value={score} size={26} strokeWidth={3} tone="score" label="" />
</div>
);
},
},
{
key: 'actions', header: '', align: 'right', width: 92,
cell: (p) => (
<div className="flex items-center justify-end gap-0.5" onClick={(e) => e.stopPropagation()} role="presentation">
<IconButton
icon={saved.includes(p.id) ? Star : Bookmark}
label={saved.includes(p.id) ? `Remove ${p.full_name} from saved` : `Save ${p.full_name}`}
variant="ghost"
size="sm"
onClick={() => toggleSave(p.id, p.full_name)}
/>
<IconButton
icon={UserRound}
label={`View ${p.full_name}'s profile`}
variant="ghost"
size="sm"
onClick={() => toast.info(`Opening ${p.full_name}'s KROW Identity`)}
/>
</div>
),
},
]}
/>
<SkillSurface page="talent-pool" placement="before-footer" />
<UiEditor />
<TalentPoolComposition context={context} />
</AdminPage>
);
}
/** The composed page. */
function TalentPoolComposition({ context }) {
const editing = useUiEditing();
const tree = editing?.tree || composePage('talent-pool').tree;
return <UiTreeRenderer nodes={tree} context={context} />;
}

View File

@@ -0,0 +1,313 @@
import React from 'react';
import {
Activity as ActivityIcon, FilePlus2, LogIn, LogOut, Mic, ScanSearch, Send, ShieldAlert,
UserCheck, UserPlus, Users,
} from 'lucide-react';
import {
Avatar, Badge, DataTable, MetricStrip, SearchInput, Surface, Timeline,
} from '@/components/ds';
import { SectionTitle, Toolbar } from '@/components/admin/PageShell';
import { FilterSelect } from '@/pages/admin/Positions';
import { registerNodeType } from '@/lib/ui/registry';
import { registerPageComposition } from '@/lib/ui/composition';
import { useUiContext } from '@/components/ui-tree/UiTreeRenderer';
/**
* The Activity page, as addressable nodes.
*
* This is the first page composed through the UI node system, and it is the
* pattern every other page follows. Nothing about the sections changed: the
* markup below was moved here verbatim from `Activity.jsx`, which is what lets
* the page render exactly as it did while becoming something a person can
* reorder and hide.
*
* Two things are worth understanding before copying the pattern.
*
* **The page still owns its state.** Filters, the loading flag, the derived
* rows — all of it stays in the page component and is published once through
* `useUiContext`. A section reads what it needs. The renderer never looks
* inside the bag, so it stays ignorant of what any page contains.
*
* **Each section is its own node type.** They are singletons — there is no
* sense in which a page could have two audit logs — so they declare only the
* capabilities that mean something for a built-in: `move` and `hide`. A page
* section is not addable, removable or replaceable, and saying so in the
* registration is what stops the engine from ever offering it.
*/
/** Severity carried through to the timeline node, so the two views agree. */
const TIMELINE_TONE = { high: 'destructive', medium: 'warning', low: 'neutral' };
const SEVERITY_META = {
high: { label: 'High', variant: 'destructive' },
medium: { label: 'Medium', variant: 'warning' },
low: { label: 'Low', variant: 'neutral' },
};
/**
* An icon per event type. Worth the table: on a chronology the glyph is what
* makes a run of hires distinguishable from a run of logins at a glance, which is
* the whole reason to show a timeline rather than another list of rows.
*/
const EVENT_ICON = {
hire_candidate: UserCheck,
assign_employee: Users,
create_position: FilePlus2,
screen_candidate: ScanSearch,
start_interview: Mic,
apply_job: Send,
signup: UserPlus,
login: LogIn,
logout: LogOut,
};
/** The counted figures across the top. */
function ActivitySummary() {
const { isLoading, events, last24h, highRisk, users } = useUiContext();
return (
<MetricStrip
columns={4}
loading={isLoading}
items={[
{ label: 'Total events', value: events.length },
{ label: 'Last 24 hours', value: last24h.length, tone: last24h.length ? 'default' : 'warning' },
{ label: 'Privileged actions', value: highRisk.length, tone: 'warning', sub: 'hires and position changes' },
{ label: 'Distinct users', value: users.length },
]}
/>
);
}
/** The privileged-action notice. Absent, rather than empty, when there are none. */
function ActivityPrivilegedNotice({ attrs = {} }) {
const { highRisk } = useUiContext();
if (!highRisk.length) return null;
return (
<Surface variant="solid" radius="lg" padding="sm" elevation="xs" className="border-warning/30 bg-warning-muted" {...attrs}>
<div className="flex items-start gap-2.5">
<ShieldAlert className="mt-0.5 h-4 w-4 shrink-0 text-warning" aria-hidden="true" />
<p className="text-body-sm text-ink-2">
<span className="font-semibold text-ink-1">{highRisk.length} privileged actions</span> in this log —
hires and position changes. These should always trace to a named person.
</p>
</div>
</Surface>
);
}
/* The recent timeline. Deliberately above the log and deliberately not
filtered: this answers "what just happened", which is a different
question from the one the toolbar below exists to ask. Reading a
chronology is also how an auditor starts — sequence first, then
interrogate the specifics. */
function ActivityTimeline({ attrs = {} }) {
const { recent, byDay } = useUiContext();
return (
<section aria-labelledby="timeline" className="space-y-3" {...attrs}>
<SectionTitle
id="timeline"
title="Operational timeline"
meta={`Most recent ${recent.length} events`}
/>
<Surface variant="solid" radius="lg" padding="none" elevation="xs" className="overflow-hidden">
{recent.length ? (
<div className="divide-y divide-border">
{byDay.map(([day, dayEvents]) => (
<div key={day} className="px-4 py-3.5">
<p className="mb-3 text-[10px] font-semibold uppercase tracking-wide text-ink-4">
{day}
</p>
<Timeline
compact
items={dayEvents.map((e) => ({
id: e.id,
icon: EVENT_ICON[e.event_type] || ActivityIcon,
tone: TIMELINE_TONE[e.severity],
title: e.event_type.replace(/_/g, ' '),
description: e.details || undefined,
meta: `${e.user_name || e.user_email}${e.account_type ? ` · ${e.account_type}` : ''}`,
timestamp: new Date(e.created_date).toLocaleTimeString(undefined, {
hour: 'numeric', minute: '2-digit',
}),
}))}
/>
</div>
))}
</div>
) : (
<p className="px-4 py-8 text-center text-body-sm text-ink-3">
No activity has been logged yet.
</p>
)}
</Surface>
</section>
);
}
/** The filterable audit log. */
function ActivityAuditLog({ attrs = {} }) {
const {
isLoading, events, filtered, users, actions, isFiltered, clearFilters,
search, setSearch, user, setUser, action, setAction, severity, setSeverity, range, setRange,
} = useUiContext();
return (
<section aria-labelledby="audit" className="space-y-3" {...attrs}>
<SectionTitle id="audit" title="Audit log" meta={`${filtered.length} of ${events.length} events`} />
<Toolbar
search={<SearchInput value={search} onChange={setSearch} placeholder="Search events" size="sm" />}
filters={
<>
<FilterSelect value={user} onChange={setUser} label="User"
options={[{ value: 'all', label: 'All users' }, ...users.map((u) => ({ value: u, label: u }))]} />
<FilterSelect value={action} onChange={setAction} label="Action"
options={[{ value: 'all', label: 'All actions' }, ...actions.map((a) => ({ value: a, label: a.replace(/_/g, ' ') }))]} />
<FilterSelect value={severity} onChange={setSeverity} label="Severity" options={[
{ value: 'all', label: 'All severities' }, { value: 'high', label: 'High' },
{ value: 'medium', label: 'Medium' }, { value: 'low', label: 'Low' },
]} />
<FilterSelect value={range} onChange={setRange} label="Date" options={[
{ value: 'all', label: 'All time' }, { value: '24h', label: 'Last 24 hours' },
{ value: '7d', label: 'Last 7 days' }, { value: '30d', label: 'Last 30 days' },
]} />
</>
}
/>
<DataTable
loading={isLoading}
rows={filtered}
pageSize={20}
isFiltered={isFiltered}
onClearFilters={clearFilters}
caption="Platform audit log"
rowClassName={(e) => (e.severity === 'high' ? 'bg-warning-muted/40' : undefined)}
columns={[
{
key: 'user_name', header: 'User', sortable: true,
cell: (e) => (
<div className="flex min-w-0 items-center gap-2.5">
<Avatar name={e.user_name || e.user_email} size="xs" />
<div className="min-w-0">
<p className="truncate font-medium text-ink-1">{e.user_name || '—'}</p>
<p className="truncate text-[10px] text-ink-4">{e.user_email}</p>
</div>
</div>
),
},
{
key: 'event_type', header: 'Action', sortable: true,
cell: (e) => <span className="whitespace-nowrap">{e.event_type.replace(/_/g, ' ')}</span>,
},
{
key: 'details', header: 'Entity', hideBelow: 'md',
cell: (e) => <span className="line-clamp-1 text-ink-3">{e.details || '—'}</span>,
},
{
key: 'account_type', header: 'Role', hideBelow: 'lg',
cell: (e) => <Badge variant={e.account_type === 'employer' ? 'info' : 'neutral'} size="sm">{e.account_type || 'unknown'}</Badge>,
},
{
key: 'ip', header: 'Source IP', hideBelow: 'lg',
cell: (e) => <span className="font-mono text-[11px] text-ink-4">{e.ip}</span>,
},
{
key: 'created_date', header: 'Timestamp', align: 'right', sortable: true,
sortValue: (e) => new Date(e.created_date).getTime(),
cell: (e) => (
<span className="whitespace-nowrap text-[11px] text-ink-4">
{new Date(e.created_date).toLocaleString(undefined, {
month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit',
})}
</span>
),
},
{
key: 'severity', header: 'Severity', align: 'right',
cell: (e) => (
<Badge variant={SEVERITY_META[e.severity].variant} size="sm">
{SEVERITY_META[e.severity].label}
</Badge>
),
},
]}
/>
</section>
);
}
/* ── Registration ───────────────────────────────────────────────────────────
A page section is a singleton: `move` and `hide` are the only operations that
mean anything for one, and declaring exactly those is what makes the engine
refuse the rest without knowing what an audit log is. */
const SECTION = ['move', 'hide'];
registerNodeType({
type: 'activity-summary',
/* This page's own section: it reads what this page publishes, so it belongs
nowhere else. See `page` in the registry. */
page: 'activity',
label: 'Activity summary',
summary: 'Total events, recent volume, privileged actions and distinct users.',
component: ActivitySummary,
capabilities: SECTION,
/* `MetricStrip` destructures its props, so it cannot carry the node's
identity itself. The renderer supplies a bare wrapper instead — no styling,
no layout of its own, and the page's own vertical rhythm applies to it
exactly as it applied to the grid before. */
wrap: true,
});
registerNodeType({
type: 'activity-privileged-notice',
/* This page's own section: it reads what this page publishes, so it belongs
nowhere else. See `page` in the registry. */
page: 'activity',
label: 'Privileged actions notice',
summary: 'A warning banner when the log contains hires or position changes.',
component: ActivityPrivilegedNotice,
capabilities: SECTION,
});
registerNodeType({
type: 'activity-timeline',
/* This page's own section: it reads what this page publishes, so it belongs
nowhere else. See `page` in the registry. */
page: 'activity',
label: 'Operational timeline',
summary: 'The most recent events, grouped by day.',
component: ActivityTimeline,
capabilities: SECTION,
});
registerNodeType({
type: 'activity-audit-log',
/* This page's own section: it reads what this page publishes, so it belongs
nowhere else. See `page` in the registry. */
page: 'activity',
label: 'Audit log',
summary: 'The filterable table of every recorded event.',
component: ActivityAuditLog,
capabilities: SECTION,
});
/**
* The page as it ships.
*
* The order here is the order on screen, and the ids are the page's stable
* addresses. `timeline` and `audit` are the ids the headings already carried in
* the DOM, so a node's identity is continuous with what the page has always
* said about itself rather than a second naming invented alongside it.
*/
registerPageComposition('activity', [
{ id: 'activity-extensions-top', type: 'skill-surface', props: { page: 'activity', placement: 'after-header' } },
{ id: 'activity-summary', type: 'activity-summary' },
{ id: 'activity-privileged-notice', type: 'activity-privileged-notice' },
{ id: 'timeline', type: 'activity-timeline' },
{ id: 'audit', type: 'activity-audit-log' },
{ id: 'activity-extensions-bottom', type: 'skill-surface', props: { page: 'activity', placement: 'before-footer' } },
]);

View File

@@ -0,0 +1,424 @@
import React 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 { DepartmentPerformance } from '@/components/charts/DepartmentPerformance';
import { HiringFlow } from '@/components/charts/HiringFlow';
import { HiringTrendChart } from '@/components/charts/HiringTrendChart';
import { useSize } from '@/hooks/use-size';
import { SectionTitle } from '@/components/admin/PageShell';
/**
* Analytics, as addressable nodes.
*
* Seven readings, moved verbatim. Each keeps the id its heading already carried
* in the DOM, so what a person names in conversation and what the markup says
* are the same string.
*/
import { registerNodeType } from '@/lib/ui/registry';
import { registerPageComposition } from '@/lib/ui/composition';
import { useUiContext } from '@/components/ui-tree/UiTreeRenderer';
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>
);
}
function AnalyticsPerformance({ attrs = {} }) {
const { summary, funnel, trend, departments, positions, efficiency, insights, hires, isLoading, applications } = useUiContext();
return (
<section aria-labelledby="performance" className="space-y-3" {...attrs}>
<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>
);
}
function AnalyticsFunnel({ attrs = {} }) {
const { summary, funnel, trend, departments, positions, efficiency, insights, hires, isLoading, applications } = useUiContext();
return (
<section aria-labelledby="funnel" className="space-y-3" {...attrs}>
<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>
);
}
function AnalyticsTrend({ attrs = {} }) {
const { summary, funnel, trend, departments, positions, efficiency, insights, hires, isLoading, applications } = useUiContext();
return (
<section aria-labelledby="trend" className="space-y-3" {...attrs}>
<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>
);
}
function AnalyticsDepartments({ attrs = {} }) {
const { summary, funnel, trend, departments, positions, efficiency, insights, hires, isLoading, applications } = useUiContext();
return (
<section aria-labelledby="departments" className="space-y-3" {...attrs}>
<SectionTitle
id="departments"
title="Department performance"
meta={`${departments.length} department${departments.length === 1 ? '' : 's'} compared`}
/>
<DepartmentPerformance items={departments} />
</section>
);
}
function AnalyticsPositions({ attrs = {} }) {
const { summary, funnel, trend, departments, positions, efficiency, insights, hires, isLoading, applications } = useUiContext();
return (
<section aria-labelledby="positions" className="space-y-3" {...attrs}>
<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 min-w-[36rem] 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>
);
}
function AnalyticsEfficiency({ attrs = {} }) {
const { summary, funnel, trend, departments, positions, efficiency, insights, hires, isLoading, applications } = useUiContext();
return (
<section aria-labelledby="efficiency" className="space-y-3" {...attrs}>
<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>
);
}
function AnalyticsInsights({ attrs = {} }) {
const { summary, funnel, trend, departments, positions, efficiency, insights, hires, isLoading, applications } = useUiContext();
return (
<section aria-labelledby="insights" className="space-y-3" {...attrs}>
<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>
);
}
const SECTION = ['move', 'hide'];
registerNodeType({
type: 'analytics-performance',
/* This page's own section: it reads what this page publishes, so it belongs
nowhere else. See `page` in the registry. */
page: 'analytics',
label: 'Hiring performance',
component: AnalyticsPerformance,
capabilities: SECTION,
});
registerNodeType({
type: 'analytics-funnel',
/* This page's own section: it reads what this page publishes, so it belongs
nowhere else. See `page` in the registry. */
page: 'analytics',
label: 'Hiring funnel',
component: AnalyticsFunnel,
capabilities: SECTION,
});
registerNodeType({
type: 'analytics-trend',
/* This page's own section: it reads what this page publishes, so it belongs
nowhere else. See `page` in the registry. */
page: 'analytics',
label: 'Hiring trend',
component: AnalyticsTrend,
capabilities: SECTION,
});
registerNodeType({
type: 'analytics-departments',
/* This page's own section: it reads what this page publishes, so it belongs
nowhere else. See `page` in the registry. */
page: 'analytics',
label: 'Departments',
component: AnalyticsDepartments,
capabilities: SECTION,
});
registerNodeType({
type: 'analytics-positions',
/* This page's own section: it reads what this page publishes, so it belongs
nowhere else. See `page` in the registry. */
page: 'analytics',
label: 'Position comparison',
component: AnalyticsPositions,
capabilities: SECTION,
});
registerNodeType({
type: 'analytics-efficiency',
/* This page's own section: it reads what this page publishes, so it belongs
nowhere else. See `page` in the registry. */
page: 'analytics',
label: 'Hiring efficiency',
component: AnalyticsEfficiency,
capabilities: SECTION,
});
registerNodeType({
type: 'analytics-insights',
/* This page's own section: it reads what this page publishes, so it belongs
nowhere else. See `page` in the registry. */
page: 'analytics',
label: 'Insights',
component: AnalyticsInsights,
capabilities: SECTION,
});
registerPageComposition('analytics', [
{ id: 'analytics-extensions-top', type: 'skill-surface', props: { page: 'analytics', placement: 'after-header' } },
{ id: 'performance', type: 'analytics-performance' },
{ id: 'funnel', type: 'analytics-funnel' },
{ id: 'trend', type: 'analytics-trend' },
{ id: 'departments', type: 'analytics-departments' },
{ id: 'positions', type: 'analytics-positions' },
{ id: 'efficiency', type: 'analytics-efficiency' },
{ id: 'insights', type: 'analytics-insights' },
{ id: 'analytics-extensions-bottom', type: 'skill-surface', props: { page: 'analytics', placement: 'before-footer' } },
]);

View File

@@ -0,0 +1,415 @@
import React from 'react';
import {
Bar, BarChart, CartesianGrid, Cell, Pie, PieChart, ResponsiveContainer, Tooltip, XAxis, YAxis,
} from 'recharts';
import { ArrowRight } from 'lucide-react';
import {
AXIS_PROPS, Avatar, Badge, Button, CHART_TONES, ChartTooltip, InsightList, InsightRow,
MetricStrip, ProgressBar, ProgressRing, Surface,
} from '@/components/ds';
import { SectionTitle } from '@/components/admin/PageShell';
/**
* Admin Candidates Analysis — the analytical counterpart to Candidates.
*
* Candidates is for working through people one by one; this page is for reading
* the pool as a whole: supply, quality distribution, and where the risk sits.
* It is one of the three pages with the contextual Owliver panel, because these
* are the questions worth asking in language rather than filters.
*/
import { registerNodeType } from '@/lib/ui/registry';
import { registerPageComposition } from '@/lib/ui/composition';
import { useUiContext } from '@/components/ui-tree/UiTreeRenderer';
/**
* Candidates Analysis, as addressable nodes.
*
* Two of its readings are laid out side by side inside a grid, so each grid is
* one node holding both — splitting them would let somebody move half a pair
* out of its own layout.
*/
function CASupply({ attrs = {} }) {
const { navigate, applications, postings, interviews, staff, profiles, f, bands, risks, top, skillSupply, skillGaps, positionFit, recommendations } = useUiContext();
return (
<>
<MetricStrip
columns={5}
items={[
{ label: 'In pipeline', value: f.total },
{ label: 'Scored', value: f.scored.length, sub: `${f.standardizedPct}% coverage`, tone: f.standardizedPct >= 80 ? 'success' : 'warning' },
{ label: 'At 80+', value: f.ranked.filter((a) => a.ai_score >= 80).length, tone: 'brand' },
{ label: 'Avg score', value: f.avgScore || '—' },
{ label: 'Risk flags', value: risks.length, tone: risks.length ? 'warning' : 'success' },
]}
/>
</>
);
}
function CADistribution({ attrs = {} }) {
const { navigate, applications, postings, interviews, staff, profiles, f, bands, risks, top, skillSupply, skillGaps, positionFit, recommendations } = useUiContext();
return (
<>
<div className="grid gap-6 lg:grid-cols-5">
<section aria-labelledby="dist" className="space-y-3 lg:col-span-2">
<SectionTitle id="dist" title="Quality distribution" meta={`${f.total} candidates`} />
<ResponsiveContainer width="100%" height={200}>
<PieChart>
<Pie data={bands} dataKey="value" nameKey="name" innerRadius={48} outerRadius={78} paddingAngle={2} strokeWidth={0}>
{bands.map((b) => <Cell key={b.name} fill={b.color} />)}
</Pie>
<Tooltip content={<ChartTooltip />} />
</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" />
<span className="flex-1 text-ink-3">{b.name}</span>
<span className="font-semibold tabular-nums text-ink-1">{b.value}</span>
</div>
))}
</div>
</section>
<section aria-labelledby="strongest" className="space-y-3 lg:col-span-3">
<SectionTitle
id="strongest"
title="Strongest candidates"
meta="By AI score"
action={{ label: 'All candidates', onClick: () => navigate('/admin/candidates') }}
/>
<Surface variant="solid" radius="lg" padding="none" elevation="xs" className="divide-y divide-border overflow-hidden">
{top.length ? top.map((c, i) => (
<div key={c.id} className="flex items-center gap-3 px-4 py-2.5">
<span className="w-4 shrink-0 text-caption font-bold text-ink-4">{i + 1}</span>
<Avatar name={c.applicant_name} src={c.selfie_url} size="sm" />
<div className="min-w-0 flex-1">
<p className="truncate text-body-sm font-medium text-ink-1">{c.applicant_name}</p>
<p className="truncate text-[10px] text-ink-4">{c.job_title}</p>
</div>
{c.ai_match_label && (
<Badge variant="soft" size="sm" className="hidden sm:inline-flex">{c.ai_match_label}</Badge>
)}
<div className="w-20 shrink-0">
<ProgressBar value={c.ai_score} tone="score" size="xs" />
</div>
<ProgressRing value={c.ai_score} size={30} strokeWidth={3} tone="score" />
</div>
)) : (
<p className="px-4 py-8 text-center text-body-sm text-ink-3">No scored candidates yet.</p>
)}
</Surface>
</section>
</div>
</>
);
}
function CARisk({ attrs = {} }) {
const { navigate, applications, postings, interviews, staff, profiles, f, bands, risks, top, skillSupply, skillGaps, positionFit, recommendations } = useUiContext();
return (
<section aria-labelledby="risk" className="space-y-3" {...attrs}>
<SectionTitle id="risk" title="Candidate risk" meta={risks.length ? `${risks.length} flags` : 'No flags'} />
<Surface variant="solid" radius="lg" padding="none" elevation="xs" className="overflow-hidden">
{risks.length ? (
<InsightList>{risks.map((r, i) => <InsightRow key={i} {...r} />)}</InsightList>
) : (
<p className="px-4 py-8 text-center text-body-sm text-ink-3">
No material risk flags. Credentials, availability and interview integrity all check out.
</p>
)}
</Surface>
<p className="text-caption text-ink-4">
Flags are questions for a human, not rejections.
</p>
</section>
);
}
function CASkills({ attrs = {} }) {
const { navigate, applications, postings, interviews, staff, profiles, f, bands, risks, top, skillSupply, skillGaps, positionFit, recommendations } = useUiContext();
return (
<>
{/* Skill supply and the gaps in it, side by side: what the pool has, and
what the open roles ask for and nobody offers. */}
<div className="grid gap-6 lg:grid-cols-5">
<section aria-labelledby="skills" className="space-y-3 lg:col-span-3">
<SectionTitle
id="skills"
title="Skill supply"
meta={`Top ${skillSupply.length} across the scored pool`}
/>
{skillSupply.length ? (
<ResponsiveContainer width="100%" height={Math.max(180, skillSupply.length * 30)}>
<BarChart data={skillSupply} layout="vertical" margin={{ top: 0, right: 28, left: 0, bottom: 0 }}>
<CartesianGrid strokeDasharray="3 3" stroke={CHART_TONES.grid} horizontal={false} />
<XAxis type="number" {...AXIS_PROPS} allowDecimals={false} />
<YAxis
type="category"
dataKey="skill"
{...AXIS_PROPS}
width={128}
tick={{ fontSize: 11, fill: CHART_TONES.axis }}
/>
<Tooltip content={<ChartTooltip />} />
<Bar dataKey="count" name="Candidates" radius={[0, 4, 4, 0]} maxBarSize={16}>
{/* Tinted by the average score of the people holding the skill,
so breadth and quality read together. */}
{skillSupply.map((s) => (
<Cell
key={s.skill}
fill={s.avgScore >= 75 ? CHART_TONES.brand : s.avgScore >= 60 ? CHART_TONES.accentPale : CHART_TONES.mint}
/>
))}
</Bar>
</BarChart>
</ResponsiveContainer>
) : (
<p className="text-body-sm text-ink-3">
Nothing is scored yet, so there is no skill supply to read.
</p>
)}
<p className="text-caption text-ink-4">
Bar length is how many candidates claim the skill. Darker blue means the
people holding it average 75 or above.
</p>
</section>
<section aria-labelledby="gaps" className="space-y-3 lg:col-span-2">
<SectionTitle
id="gaps"
title="Skill gaps"
meta={skillGaps.length ? `${skillGaps.length} unmet` : 'None'}
/>
<Surface variant="solid" radius="lg" padding="none" elevation="xs" className="overflow-hidden">
{skillGaps.length ? (
<ul className="divide-y divide-border">
{skillGaps.map((g) => (
<li key={g.name} className="px-4 py-2.5">
<p className="text-body-sm font-medium text-ink-1">{g.name}</p>
<p className="mt-0.5 text-caption leading-relaxed text-ink-3">
Required by {g.roles.join(', ')} — held by nobody in the scored pool.
</p>
</li>
))}
</ul>
) : (
<p className="px-4 py-8 text-center text-body-sm text-ink-3">
Every credential an open role requires is held by someone in the scored pool.
</p>
)}
</Surface>
</section>
</div>
</>
);
}
function CAFit({ attrs = {} }) {
const { navigate, applications, postings, interviews, staff, profiles, f, bands, risks, top, skillSupply, skillGaps, positionFit, recommendations } = useUiContext();
return (
<>
{/* Position fit — which roles can actually be filled from current supply. */}
<section aria-labelledby="fit" className="space-y-3" {...attrs}>
<SectionTitle id="fit" title="Position fit" meta={`${positionFit.length} open roles`} />
<Surface variant="solid" radius="lg" padding="none" elevation="xs" className="overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full min-w-[40rem] border-collapse text-body-sm">
<caption className="sr-only">
Applicant supply against candidates clearing the bar, per open role
</caption>
<thead>
<tr className="border-b border-border bg-surface-subtle">
{['Role', 'Applied', 'Qualified', 'Avg score', 'Fit'].map((h, i) => (
<th
key={h}
scope="col"
className={`whitespace-nowrap px-4 py-2.5 text-[10px] font-semibold uppercase tracking-wide text-ink-4 ${i ? 'text-right' : 'text-left'}`}
>
{h}
</th>
))}
</tr>
</thead>
<tbody>
{positionFit.map((r) => (
<tr key={r.title} className="border-b border-border last:border-0 hover:bg-surface-subtle">
<td className="px-4 py-2.5 font-medium text-ink-1">{r.title}</td>
<td className={`px-4 py-2.5 text-right tabular-nums ${r.applied ? 'text-ink-2' : 'font-semibold text-destructive'}`}>
{r.applied}
</td>
<td className="px-4 py-2.5 text-right tabular-nums text-ink-2">{r.qualified}</td>
<td className="px-4 py-2.5 text-right tabular-nums text-ink-2">{r.avgScore || '—'}</td>
<td className="px-4 py-2.5">
<div className="ml-auto w-24">
<ProgressBar
value={r.fit}
tone={r.fit >= 50 ? 'success' : r.fit > 0 ? 'warning' : 'destructive'}
size="xs"
/>
<p className="mt-1 text-right text-[10px] tabular-nums text-ink-4">{r.fit}% qualified</p>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
</Surface>
</section>
</>
);
}
function CARecommend({ attrs = {} }) {
const { navigate, applications, postings, interviews, staff, profiles, f, bands, risks, top, skillSupply, skillGaps, positionFit, recommendations } = useUiContext();
return (
<>
{/* Recommendations — the actions the analysis above implies. */}
{recommendations.length > 0 && (
<section aria-labelledby="recommend" className="space-y-3">
<SectionTitle
id="recommend"
title="Recommendations"
meta={`${recommendations.length} ordered by impact`}
/>
<Surface variant="solid" radius="lg" padding="none" elevation="xs" className="overflow-hidden">
<ol className="divide-y divide-border">
{recommendations.map((r, i) => (
<li key={r.title} className="flex gap-3 px-4 py-3">
<span className="mt-0.5 grid h-5 w-5 shrink-0 place-items-center rounded-full bg-krow-blue-tint text-[10px] font-bold text-krow-blue">
{i + 1}
</span>
<div className="min-w-0">
<p className="text-body-sm font-medium text-ink-1">{r.title}</p>
<p className="mt-0.5 text-caption leading-relaxed text-ink-3">{r.body}</p>
</div>
</li>
))}
</ol>
</Surface>
</section>
)}
</>
);
}
function CAScreening({ attrs = {} }) {
const { navigate, applications, postings, interviews, staff, profiles, f, bands, risks, top, skillSupply, skillGaps, positionFit, recommendations } = useUiContext();
return (
<section aria-labelledby="screening" className="space-y-3" {...attrs}>
<SectionTitle id="screening" title="Screening efficiency" />
<div className="grid gap-4 sm:grid-cols-3">
{[
{ label: 'Coverage', value: `${f.standardizedPct}%`, detail: `${f.scored.length} of ${f.total} scored`, tone: f.standardizedPct >= 80 ? 'success' : 'warning' },
{ label: 'Interview completion', value: `${f.interviewCompletion}%`, detail: `${f.completedInterviews.length} of ${f.interviews.length} scored` },
{ label: 'Quality lift', value: f.hiredAvgScore && f.avgScore ? `+${f.hiredAvgScore - f.avgScore}` : '—', detail: 'hires vs pool average', tone: 'success' },
].map((s) => (
<Surface key={s.label} variant="solid" radius="lg" padding="default" elevation="xs">
<p className="text-[11px] font-medium uppercase tracking-wide text-ink-4">{s.label}</p>
<p className={`mt-1 font-heading text-title-lg font-bold tabular-nums ${s.tone === 'success' ? 'text-success' : s.tone === 'warning' ? 'text-warning' : 'text-ink-1'}`}>
{s.value}
</p>
<p className="mt-0.5 text-caption text-ink-3">{s.detail}</p>
</Surface>
))}
</div>
<Button variant="ghost" size="sm" onClick={() => navigate('/admin/analytics')} className="w-fit">
Full analytics <ArrowRight aria-hidden="true" />
</Button>
</section>
);
}
const SECTION = ['move', 'hide'];
registerNodeType({
type: 'ca-supply',
/* This page's own section: it reads what this page publishes, so it belongs
nowhere else. See `page` in the registry. */
page: 'candidates-analysis',
label: 'Supply summary',
component: CASupply,
capabilities: SECTION,
wrap: true,
});
registerNodeType({
type: 'ca-distribution',
/* This page's own section: it reads what this page publishes, so it belongs
nowhere else. See `page` in the registry. */
page: 'candidates-analysis',
label: 'Score distribution',
component: CADistribution,
capabilities: SECTION,
wrap: true,
});
registerNodeType({
type: 'ca-risk',
/* This page's own section: it reads what this page publishes, so it belongs
nowhere else. See `page` in the registry. */
page: 'candidates-analysis',
label: 'Pipeline risk',
component: CARisk,
capabilities: SECTION,
});
registerNodeType({
type: 'ca-skills',
/* This page's own section: it reads what this page publishes, so it belongs
nowhere else. See `page` in the registry. */
page: 'candidates-analysis',
label: 'Skill supply and gaps',
component: CASkills,
capabilities: SECTION,
wrap: true,
});
registerNodeType({
type: 'ca-fit',
/* This page's own section: it reads what this page publishes, so it belongs
nowhere else. See `page` in the registry. */
page: 'candidates-analysis',
label: 'Position fit',
component: CAFit,
capabilities: SECTION,
});
registerNodeType({
type: 'ca-recommend',
/* This page's own section: it reads what this page publishes, so it belongs
nowhere else. See `page` in the registry. */
page: 'candidates-analysis',
label: 'Recommendations',
component: CARecommend,
capabilities: SECTION,
wrap: true,
});
registerNodeType({
type: 'ca-screening',
/* This page's own section: it reads what this page publishes, so it belongs
nowhere else. See `page` in the registry. */
page: 'candidates-analysis',
label: 'Screening',
component: CAScreening,
capabilities: SECTION,
});
registerPageComposition('candidates-analysis', [
{ id: 'ca-extensions-top', type: 'skill-surface', props: { page: 'candidates-analysis', placement: 'after-header' } },
{ id: 'supply', type: 'ca-supply' },
{ id: 'distribution', type: 'ca-distribution' },
{ id: 'risk', type: 'ca-risk' },
{ id: 'skills', type: 'ca-skills' },
{ id: 'fit', type: 'ca-fit' },
{ id: 'recommend', type: 'ca-recommend' },
{ id: 'screening', type: 'ca-screening' },
{ id: 'ca-extensions-bottom', type: 'skill-surface', props: { page: 'candidates-analysis', placement: 'before-footer' } },
]);

View File

@@ -0,0 +1,137 @@
import React from 'react';
import { Button, SearchInput } from '@/components/ds';
import { Toolbar } from '@/components/admin/PageShell';
import { FilterSelect } from '@/pages/admin/Positions';
import CandidateCard from '@/components/krow/CandidateCard';
import { registerNodeType } from '@/lib/ui/registry';
import { registerPageComposition } from '@/lib/ui/composition';
import { useUiContext } from '@/components/ui-tree/UiTreeRenderer';
/**
* Candidates, as addressable nodes.
*
* The markup was moved here verbatim. The page keeps its state, its mutations
* and its modals; what moved is only the part of it that is *layout*.
*/
export const SORTS = {
score: { label: 'Highest score', compare: (a, b) => (b.ai_score || 0) - (a.ai_score || 0) },
recent: { label: 'Recent activity', compare: (a, b) => new Date(b.updated_date).getTime() - new Date(a.updated_date).getTime() },
name: { label: 'Name A–Z', compare: (a, b) => a.applicant_name.localeCompare(b.applicant_name) },
experience: { label: 'Most experience', compare: (a, b) => (b.years_experience || 0) - (a.years_experience || 0) },
};
function CandidatesToolbar() {
const {
search, setSearch, position, setPosition, stage, setStage, band, setBand, sort, setSort,
positions, filtered, applications, isFiltered,
} = useUiContext();
return (
<Toolbar
search={<SearchInput value={search} onChange={setSearch} placeholder="Search candidates..." size="sm" />}
filters={
<>
<FilterSelect value={position} onChange={setPosition} label="Position"
options={[{ value: 'all', label: 'All positions' }, ...positions.map((p) => ({ value: p, label: p }))]} />
<FilterSelect value={stage} onChange={setStage} label="Stage" options={[
{ value: 'all', label: 'All stages' }, { value: 'applied', label: 'Applied' },
{ value: 'ai_screened', label: 'AI Screened' }, { value: 'interview', label: 'Interviewing' },
{ value: 'hired', label: 'Hired' }, { value: 'rejected', label: 'Declined' },
]} />
<FilterSelect value={band} onChange={setBand} label="Score" options={[
{ value: 'all', label: 'All scores' }, { value: 'top', label: '80+ Top' },
{ value: 'strong', label: '60–79 Strong' }, { value: 'weak', label: 'Under 60' },
{ value: 'unscored', label: 'Unscored' },
]} />
<FilterSelect value={sort} onChange={setSort} label="Sort"
options={Object.entries(SORTS).map(([value, s]) => ({ value, label: s.label }))} />
</>
}
meta={`${filtered.length} of ${applications.length} candidates${isFiltered ? ' · filtered' : ''}`}
/>
);
}
function CandidatesList() {
const {
isLoading, filtered, isFiltered, clearFilters, resolveTitle,
handleAction, setMessageApp, setScheduleApp, handleDecline, handleDelete, handleHire,
} = useUiContext();
if (isLoading) {
return (
<div className="space-y-3">
{[...Array(5)].map((_, i) => (
<div key={i} className="h-24 bg-white border border-[#E5E7EB] rounded-xl animate-pulse" />
))}
</div>
);
}
if (filtered.length === 0) {
return (
<div className="text-center py-16">
<p className="text-body-sm text-ink-3">No candidates match your filters</p>
{isFiltered && (
<Button size="xs" variant="outline" className="mt-3" onClick={clearFilters}>
Clear filters
</Button>
)}
</div>
);
}
return (
<div className="space-y-4">
{filtered.map((app, idx) => (
<CandidateCard
key={app.id}
application={app}
jobTitle={resolveTitle(app)}
rank={idx + 1}
onAction={handleAction}
onMessage={setMessageApp}
onCall={setMessageApp}
onSchedule={setScheduleApp}
onDecline={handleDecline}
onDelete={handleDelete}
onHire={handleHire}
/>
))}
</div>
);
}
const SECTION = ['move', 'hide'];
registerNodeType({
type: 'candidates-toolbar',
/* This page's own section: it reads what this page publishes, so it belongs
nowhere else. See `page` in the registry. */
page: 'candidates',
label: 'Search and filters',
summary: 'Search, filter and sort the candidate pipeline.',
component: CandidatesToolbar,
capabilities: SECTION,
wrap: true,
});
registerNodeType({
type: 'candidates-list',
/* This page's own section: it reads what this page publishes, so it belongs
nowhere else. See `page` in the registry. */
page: 'candidates',
label: 'Candidate list',
summary: 'Every candidate matching the current filters, ranked.',
component: CandidatesList,
capabilities: SECTION,
wrap: true,
});
registerPageComposition('candidates', [
{ id: 'candidates-extensions-top', type: 'skill-surface', props: { page: 'candidates', placement: 'after-header' } },
{ id: 'candidates-toolbar', type: 'candidates-toolbar' },
{ id: 'candidates-list', type: 'candidates-list' },
{ id: 'candidates-extensions-bottom', type: 'skill-surface', props: { page: 'candidates', placement: 'before-footer' } },
]);

View File

@@ -0,0 +1,909 @@
import React from 'react';
import {
Area, Bar, CartesianGrid, Cell, ComposedChart, Legend, Line, ResponsiveContainer,
Tooltip, XAxis, YAxis,
} from 'recharts';
import {
AlertTriangle, ArrowRight, Building2, CalendarCheck, CheckCircle2, ChevronRight, Clock, Filter, Sparkles, Users, Zap,
} from 'lucide-react';
import { cn } from '@/lib/utils';
import {
AXIS_PROPS, Avatar, Badge, CHART_TONES, ChartTooltip, EmptyState, SegmentedToggle,
Skeleton, StatusBadge,
} from '@/components/ds';
/**
* Control Center — the KROW Admin command centre.
*/
const RANGES = {
'7d': { label: '7D', days: 7, step: 1 },
'30d': { label: '30D', days: 30, step: 3 },
'90d': { label: '90D', days: 90, step: 9 },
};
const WEAK_TRANSITION = 60;
/** @param {any} props */
function Band({ id, title, meta = '', children, action = null }) {
return (
<section aria-labelledby={id} className="space-y-3">
<div className="flex flex-wrap items-baseline justify-between gap-x-3 gap-y-2">
<div className="flex items-baseline gap-2">
<h2 id={id} className="font-heading text-body font-semibold text-ink-1">{title}</h2>
{meta && <span className="text-caption text-ink-4">{meta}</span>}
</div>
{action}
</div>
{children}
</section>
);
}
function ViewAll({ label, onClick }) {
return (
<button
type="button"
onClick={onClick}
className="inline-flex items-center gap-0.5 rounded text-caption font-semibold text-krow-blue transition-colors
hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-krow-blue/50"
>
{label}
<ArrowRight className="h-3 w-3" aria-hidden="true" />
</button>
);
}
/* ── 1. Operations snapshot ─────────────────────────────────────────────── */
const snapshotConfig = {
'Open positions': {
icon: Building2,
bg: 'bg-indigo-50/80 dark:bg-indigo-950/50',
iconColor: 'text-indigo-600 dark:text-indigo-400',
border: 'border-indigo-200/50 dark:border-indigo-800/40 hover:border-indigo-400',
text: 'text-indigo-600 dark:text-indigo-400',
accent: 'from-indigo-500 to-blue-500',
},
Candidates: {
icon: Users,
bg: 'bg-blue-50/80 dark:bg-blue-950/50',
iconColor: 'text-blue-600 dark:text-blue-400',
border: 'border-blue-200/50 dark:border-blue-800/40 hover:border-blue-400',
text: 'text-blue-600 dark:text-blue-400',
accent: 'from-blue-500 to-cyan-500',
},
'In review': {
icon: Clock,
bg: 'bg-amber-50/80 dark:bg-amber-950/50',
iconColor: 'text-amber-600 dark:text-amber-400',
border: 'border-amber-200/50 dark:border-amber-800/40 hover:border-amber-400',
text: 'text-amber-600 dark:text-amber-400',
accent: 'from-amber-500 to-orange-500',
},
'AI screened': {
icon: Sparkles,
bg: 'bg-purple-50/80 dark:bg-purple-950/50',
iconColor: 'text-purple-600 dark:text-purple-400',
border: 'border-purple-200/50 dark:border-purple-800/40 hover:border-purple-400',
text: 'text-purple-600 dark:text-purple-400',
accent: 'from-purple-500 to-indigo-500',
},
Hired: {
icon: CheckCircle2,
bg: 'bg-emerald-50/80 dark:bg-emerald-950/50',
iconColor: 'text-emerald-600 dark:text-emerald-400',
border: 'border-emerald-200/50 dark:border-emerald-800/40 hover:border-emerald-400',
text: 'text-emerald-600 dark:text-emerald-400',
accent: 'from-emerald-500 to-teal-500',
},
'Avg KROW score': {
icon: Zap,
bg: 'bg-blue-50/80 dark:bg-blue-950/50',
iconColor: 'text-blue-600 dark:text-blue-400',
border: 'border-blue-200/50 dark:border-blue-800/40 hover:border-blue-400',
text: 'text-blue-600 dark:text-blue-400',
accent: 'from-blue-600 to-indigo-600',
},
};
function Snapshot({ metrics, loading }) {
return (
<div className="grid grid-cols-2 gap-3.5 sm:grid-cols-3 xl:grid-cols-6">
{metrics.map((m) => {
const cfg = snapshotConfig[m.label] || snapshotConfig['Candidates'];
const Icon = cfg.icon;
return (
<div
key={m.label}
className={cn(
'group relative flex flex-col justify-between rounded-xl border bg-surface p-4 shadow-xs backdrop-blur-xs transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md',
cfg.border
)}
>
<div>
<div className="flex items-center justify-between gap-1.5">
<span className="truncate text-[10px] font-bold uppercase tracking-wider text-ink-4">
{m.label}
</span>
<div className={cn('flex h-7 w-7 shrink-0 items-center justify-center rounded-lg shadow-xs', cfg.bg, cfg.iconColor)}>
<Icon className="h-3.5 w-3.5" />
</div>
</div>
{loading ? (
<div className="mt-2.5 h-8 w-16 animate-pulse rounded-lg bg-surface-sunken" />
) : (
<div className="mt-2 flex items-baseline gap-1.5">
<span className={cn('font-heading text-title-xl font-bold leading-none tabular-nums', cfg.text)}>
{m.value}
</span>
</div>
)}
</div>
<div className="mt-3 space-y-2">
{m.sub && (
<span className="inline-flex items-center rounded-full bg-surface-subtle px-2 py-0.5 text-[10px] font-semibold text-ink-3 border border-border/60">
{m.sub}
</span>
)}
<div className={cn('h-0.5 w-full rounded-full bg-gradient-to-r opacity-40 group-hover:opacity-100 transition-opacity', cfg.accent)} />
</div>
</div>
);
})}
</div>
);
}
/* ── 3. Pipeline intelligence ───────────────────────────────────────────── */
function PipelineFunnel({ funnel, transitions, weakest, total }) {
if (!total) {
return (
<EmptyState
title="Nothing in the pipeline"
description="Once candidates apply, stage conversion and drop-off appear here."
/>
);
}
const stageIcons = [Users, Sparkles, Filter, CalendarCheck, CheckCircle2];
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) => {
const transition = i === 0 ? null : transitions[i - 1];
const isWeak = transition && weakest
&& transition.from === weakest.from && transition.to === weakest.to;
const share = Math.round((stage.count / total) * 100);
const StageIcon = stageIcons[i] || Users;
return (
<div
key={stage.key}
className={cn(
'group relative flex flex-col justify-between rounded-xl border p-4 shadow-xs backdrop-blur-xs transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md',
isWeak
? 'border-amber-500/50 bg-gradient-to-br from-amber-50/70 via-surface to-amber-50/20 dark:from-amber-950/40 dark:to-surface'
: 'border-border/80 bg-surface hover:border-blue-500/40'
)}
>
<div>
{/* Header: Stage Name & Icon */}
<div className="flex items-center justify-between gap-1.5">
<div className="flex items-center gap-1.5 min-w-0">
<span className="text-[10px] font-extrabold text-ink-4 tabular-nums">0{i + 1}</span>
<h4 className="truncate font-heading text-body-sm font-bold text-ink-1">
{stage.label}
</h4>
</div>
<div
className={cn(
'flex h-7 w-7 shrink-0 items-center justify-center rounded-lg shadow-xs',
isWeak
? 'bg-amber-500 text-white shadow-amber-500/25'
: i === funnel.length - 1
? 'bg-emerald-600 text-white shadow-emerald-500/25'
: 'bg-blue-50 text-blue-600 dark:bg-blue-950 dark:text-blue-400'
)}
>
<StageIcon className="h-3.5 w-3.5" />
</div>
</div>
{/* Main Candidate Count & Pool Share */}
<div className="mt-3 flex items-baseline justify-between">
<span className="font-heading text-title-xl font-bold leading-none tabular-nums text-ink-1">
{stage.count}
</span>
<span
className={cn(
'rounded-full px-2 py-0.5 text-[10px] font-semibold tabular-nums border',
isWeak
? 'bg-amber-100 text-amber-800 border-amber-300 dark:bg-amber-950 dark:text-amber-300'
: 'bg-surface-subtle text-ink-3 border-border/60'
)}
>
{share}% pool
</span>
</div>
</div>
{/* Stage Fill Bar & Transition Metrics */}
<div className="mt-4 space-y-2">
<div className="h-1.5 w-full overflow-hidden rounded-full bg-surface-sunken">
<div
className={cn(
'h-full rounded-full transition-all duration-500',
isWeak
? 'bg-gradient-to-r from-amber-500 to-orange-500'
: i === funnel.length - 1
? 'bg-gradient-to-r from-emerald-500 to-teal-500'
: 'bg-gradient-to-r from-blue-600 to-indigo-600'
)}
style={{ width: `${Math.max(share, stage.count ? 5 : 0)}%` }}
/>
</div>
<div className="flex items-center justify-between text-[10px] text-ink-4 pt-0.5">
{transition ? (
<>
<span className={cn(isWeak ? 'font-bold text-amber-600 dark:text-amber-400' : 'text-ink-3')}>
{transition.rate}% pass
</span>
<span>{transition.lost ? `−${transition.lost} lost` : '0 lost'}</span>
</>
) : (
<span className="text-ink-4">Entry Stage</span>
)}
</div>
</div>
</div>
);
})}
</div>
{/* Primary Pipeline Bottleneck Alert Banner */}
{weakest && weakest.lost > 0 && (
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3 rounded-xl border border-amber-500/40 bg-gradient-to-r from-amber-50/90 via-surface to-orange-50/40 dark:from-amber-950/40 dark:via-surface dark:to-orange-950/30 p-4 shadow-xs">
<div className="flex items-start gap-3">
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-xl bg-amber-500 text-white shadow-md shadow-amber-500/20">
<AlertTriangle className="h-5 w-5" />
</div>
<div>
<div className="flex items-center gap-2">
<span className="text-[10px] font-bold uppercase tracking-wider text-amber-700 dark:text-amber-400">
Primary Pipeline Bottleneck
</span>
<span className="rounded-full bg-amber-200/60 dark:bg-amber-900/60 px-2 py-0.5 text-[10px] font-bold text-amber-800 dark:text-amber-300">
{weakest.from} → {weakest.to}
</span>
</div>
<p className="mt-0.5 text-body-sm text-ink-2">
Only <span className="font-bold text-amber-700 dark:text-amber-400">{weakest.rate}%</span> pass through and{' '}
<span className="font-bold text-ink-1">{weakest.lost} candidates</span> are lost at this transition. Recovering this conversion yields the highest ROI.
</p>
</div>
</div>
</div>
)}
</div>
);
}
/* ── 5. Action center ───────────────────────────────────────────────────── */
const SEVERITY = {
critical: { dot: 'bg-destructive', label: 'Critical' },
warning: { dot: 'bg-warning', label: 'Warning' },
info: { dot: 'bg-krow-blue', label: 'For review' },
};
/** One operational queue, divided rows. Not five cards in a grid. */
function ActionQueue({ items }) {
if (!items.length) {
return (
<div className="rounded-xl border border-border bg-surface px-4 py-8 text-center">
<p className="text-body-sm text-ink-3">
Nothing needs intervention. Every role has candidates, every applicant is scored,
and no hire is awaiting review.
</p>
</div>
);
}
return (
<ul className="divide-y divide-border overflow-hidden rounded-xl border border-border bg-surface">
{items.map((item) => (
<li key={item.title}>
<button
type="button"
onClick={item.onClick}
className="group flex w-full items-center gap-3.5 px-4 py-3 text-left transition-colors
hover:bg-surface-subtle focus-visible:outline-none focus-visible:ring-2
focus-visible:ring-inset focus-visible:ring-krow-blue/50"
>
<span
className={cn('h-2 w-2 shrink-0 rounded-full', SEVERITY[item.severity].dot)}
aria-hidden="true"
/>
<span className="sr-only">{SEVERITY[item.severity].label}.</span>
<span className="min-w-0 flex-1">
<span className="block truncate text-body-sm font-medium text-ink-1">{item.title}</span>
<span className="block truncate text-caption text-ink-3">{item.detail}</span>
</span>
<span className="shrink-0 text-right">
<span className="block font-heading text-body font-bold tabular-nums text-ink-1">
{item.metric}
</span>
<span className="block text-[10px] text-ink-4">{item.metricLabel}</span>
</span>
<span className="hidden shrink-0 items-center gap-0.5 text-caption font-semibold text-krow-blue sm:inline-flex">
Review
<ChevronRight
className="h-3 w-3 transition-transform duration-base group-hover:translate-x-0.5"
aria-hidden="true"
/>
</span>
</button>
</li>
))}
</ul>
);
}
/* ── Page ───────────────────────────────────────────────────────────────── */
import { registerNodeType } from '@/lib/ui/registry';
import { registerPageComposition } from '@/lib/ui/composition';
import { useUiContext } from '@/components/ui-tree/UiTreeRenderer';
/**
* Control Center, as addressable nodes.
*
* The largest composition in the product, and migrated last for that reason.
* Every band was moved verbatim. Each is wrapped so it carries its node
* identity: `Band` and `Snapshot` render their own roots and do not forward
* unknown props, and the wrapper is a bare div that paints nothing.
*/
function CCSnapshot({ attrs = {} }) {
const { navigate, range, setRange, applications, isLoading, postings, interviews, staff, profiles, activity, f, metrics, activitySeries, positionPerformance, strongest, weakestRole, actions, activePositions, topTalent, recent, exportSnapshot } = useUiContext();
return (
<>
{/* 1 ── Operations snapshot */}
<Snapshot metrics={metrics} loading={isLoading} />
</>
);
}
function CCActivity({ attrs = {} }) {
const { navigate, range, setRange, applications, isLoading, postings, interviews, staff, profiles, activity, f, metrics, activitySeries, positionPerformance, strongest, weakestRole, actions, activePositions, topTalent, recent, exportSnapshot } = useUiContext();
return (
<>
{/* 2 ── Hiring activity */}
<Band
id="cc-activity"
title="Hiring activity"
meta={
RANGES[range].step === 1
? `Applications, screening, interviews and hires · last ${RANGES[range].days} days, daily`
: `Applications, screening, interviews and hires · last ${RANGES[range].days} days, ${RANGES[range].step}-day totals`
}
action={
<SegmentedToggle
options={Object.entries(RANGES).map(([value, r]) => ({ value, label: r.label }))}
value={range}
onChange={setRange}
size="sm"
ariaLabel="Hiring activity range"
/>
}
>
<div className="rounded-xl border border-border bg-surface px-2 py-4">
{isLoading ? (
<Skeleton className="mx-2 h-[272px] rounded-lg" />
) : f.total ? (
<ResponsiveContainer width="100%" height={272}>
<ComposedChart data={activitySeries} margin={{ top: 4, right: 12, left: 0, bottom: 0 }}>
<defs>
<linearGradient id="ccApplications" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor={CHART_TONES.brand} stopOpacity={0.22} />
<stop offset="100%" stopColor={CHART_TONES.brand} stopOpacity={0} />
</linearGradient>
</defs>
<CartesianGrid strokeDasharray="3 3" stroke={CHART_TONES.grid} vertical={false} />
<XAxis dataKey="label" {...AXIS_PROPS} interval="preserveStartEnd" minTickGap={20} />
<YAxis {...AXIS_PROPS} allowDecimals={false} width={30} />
<Tooltip content={<ChartTooltip />} />
<Legend
verticalAlign="top"
align="right"
height={28}
iconType="circle"
iconSize={8}
wrapperStyle={{ fontSize: 12, color: CHART_TONES.axis }}
/>
{/* Applications are the volume everything else is drawn from, so
they take the filled area and the downstream stages are lines
over it. */}
<Area
type="monotone"
dataKey="applications"
name="Applications"
stroke={CHART_TONES.brand}
strokeWidth={2}
fill="url(#ccApplications)"
activeDot={{ r: 4, strokeWidth: 0 }}
/>
<Line
type="monotone"
dataKey="screened"
name="AI Screened"
stroke={CHART_TONES.navy}
strokeWidth={2}
strokeDasharray="4 3"
dot={false}
activeDot={{ r: 4, strokeWidth: 0 }}
/>
<Line
type="monotone"
dataKey="interviews"
name="Interviews"
stroke={CHART_TONES.navy}
strokeWidth={1.5}
strokeOpacity={0.55}
dot={false}
activeDot={{ r: 4, strokeWidth: 0 }}
/>
{/* Hires are sparse and are the outcome, so they take the accent
and a bar — a line at this volume reads as flat. */}
<Bar
dataKey="hires"
name="Hires"
fill={CHART_TONES.accent}
radius={[3, 3, 0, 0]}
maxBarSize={14}
/>
</ComposedChart>
</ResponsiveContainer>
) : (
<div className="px-2 py-8">
<EmptyState
title="No applications yet"
description="Once candidates start applying, daily hiring activity appears here."
/>
</div>
)}
</div>
</Band>
</>
);
}
function CCPipeline({ attrs = {} }) {
const { navigate, range, setRange, applications, isLoading, postings, interviews, staff, profiles, activity, f, metrics, activitySeries, positionPerformance, strongest, weakestRole, actions, activePositions, topTalent, recent, exportSnapshot } = useUiContext();
return (
<>
{/* 3 ── Pipeline intelligence */}
<Band
id="cc-pipeline"
title="Pipeline intelligence"
meta={`${f.total} entered · conversion and drop-off by stage`}
>
<PipelineFunnel
funnel={f.funnel}
transitions={f.transitions}
weakest={f.bottleneck && f.bottleneck.rate < WEAK_TRANSITION ? f.bottleneck : null}
total={f.total}
/>
</Band>
</>
);
}
function CCPositions({ attrs = {} }) {
const { navigate, range, setRange, applications, isLoading, postings, interviews, staff, profiles, activity, f, metrics, activitySeries, positionPerformance, strongest, weakestRole, actions, activePositions, topTalent, recent, exportSnapshot } = useUiContext();
return (
<>
{/* 4 ── Position performance */}
<Band
id="cc-positions"
title="Position performance"
meta={strongest ? `Strongest conversion: ${strongest}` : undefined}
action={<ViewAll label="View all positions" onClick={() => navigate('/admin/positions')} />}
>
<div className="rounded-xl border border-border bg-surface px-2 py-4">
{positionPerformance.length ? (
<>
<ResponsiveContainer width="100%" height={236}>
<ComposedChart
data={positionPerformance}
barGap={3}
margin={{ top: 4, right: 12, left: 0, bottom: 0 }}
>
<CartesianGrid strokeDasharray="3 3" stroke={CHART_TONES.grid} vertical={false} />
<XAxis
dataKey="name"
{...AXIS_PROPS}
interval={0}
tick={{ fontSize: 10, fill: CHART_TONES.axis }}
/>
<YAxis {...AXIS_PROPS} allowDecimals={false} width={30} />
<Tooltip content={<ChartTooltip />} />
<Legend
verticalAlign="top"
align="right"
height={28}
iconType="circle"
iconSize={8}
wrapperStyle={{ fontSize: 12, color: CHART_TONES.axis }}
/>
<Bar
dataKey="applicants"
name="Applicants"
fill={CHART_TONES.brand}
radius={[3, 3, 0, 0]}
maxBarSize={24}
/>
<Bar dataKey="qualified" name="Qualified (70+)" radius={[3, 3, 0, 0]} maxBarSize={24}>
{/* The best converter takes the accent at full strength and the
worst is left pale, so both ends of the comparison are
visible without a callout. */}
{positionPerformance.map((r) => (
<Cell
key={r.name}
fill={r.name === strongest ? CHART_TONES.accent
: r.name === weakestRole ? CHART_TONES.mint : CHART_TONES.accentPale}
/>
))}
</Bar>
<Bar
dataKey="hired"
name="Hired"
fill={CHART_TONES.navy}
radius={[3, 3, 0, 0]}
maxBarSize={24}
/>
</ComposedChart>
</ResponsiveContainer>
{weakestRole && (
<p className="px-2 pt-2 text-caption leading-relaxed text-ink-3">
<span className="font-semibold text-ink-1">{strongest}</span> converts applicants
into qualified candidates best;{' '}
<span className="font-semibold text-ink-1">{weakestRole}</span> converts worst and
is where sourcing — or the bar itself — is worth a look.
</p>
)}
</>
) : (
<div className="px-2 py-8">
<EmptyState
title="No open positions"
description="Publish a role and its hiring performance appears here."
/>
</div>
)}
</div>
</Band>
</>
);
}
function CCActions({ attrs = {} }) {
const { navigate, range, setRange, applications, isLoading, postings, interviews, staff, profiles, activity, f, metrics, activitySeries, positionPerformance, strongest, weakestRole, actions, activePositions, topTalent, recent, exportSnapshot } = useUiContext();
return (
<>
{/* 5 ── Action center */}
<Band
id="cc-actions"
title="Action center"
meta={actions.length ? `${actions.length} items, most consequential first` : 'All clear'}
>
<ActionQueue items={actions} />
</Band>
</>
);
}
function CCLists({ attrs = {} }) {
const { navigate, range, setRange, applications, isLoading, postings, interviews, staff, profiles, activity, f, metrics, activitySeries, positionPerformance, strongest, weakestRole, actions, activePositions, topTalent, recent, exportSnapshot } = useUiContext();
return (
<>
{/* 6 / 7 ── Active positions and top talent. Two lists of comparable weight,
so they share a row rather than each taking one. */}
<div className="grid gap-6 xl:grid-cols-2">
<Band
id="cc-active"
title="Active positions"
meta={`${f.openPositions.length} hiring`}
action={<ViewAll label="View all" onClick={() => navigate('/admin/positions')} />}
>
{activePositions.length ? (
<ul className="divide-y divide-border overflow-hidden rounded-xl border border-border bg-surface">
{activePositions.map((r) => {
const coverage = r.applied ? Math.round((r.screened / r.applied) * 100) : 0;
return (
<li key={r.title}>
<button
type="button"
onClick={() => navigate('/admin/positions')}
className="group flex w-full items-center gap-3 px-4 py-3 text-left transition-colors
hover:bg-surface-subtle focus-visible:outline-none focus-visible:ring-2
focus-visible:ring-inset focus-visible:ring-krow-blue/50"
>
<span className="min-w-0 flex-1">
<span className="block truncate text-body-sm font-medium text-ink-1">
{r.title}
</span>
<span className="block truncate text-caption text-ink-3">
{r.applied} applicant{r.applied === 1 ? '' : 's'} · {r.screened} screened
{' · '}{r.hired} hired
</span>
</span>
{/* Screening coverage as a small bar — the one number that
says whether this role is actually being worked. */}
<span className="hidden w-16 shrink-0 sm:block">
<span className="block h-1.5 overflow-hidden rounded-full bg-surface-sunken">
<span
className={cn(
'block h-full rounded-full',
coverage === 100 ? 'bg-success'
: coverage >= 50 ? 'bg-krow-blue' : 'bg-warning'
)}
style={{ width: `${Math.max(coverage, r.applied ? 4 : 0)}%` }}
/>
</span>
<span className="mt-1 block text-right text-[10px] tabular-nums text-ink-4">
{coverage}%
</span>
</span>
<StatusBadge status={r.posting.status} size="sm" />
<ChevronRight
className="h-3.5 w-3.5 shrink-0 text-ink-4 transition-transform duration-base group-hover:translate-x-0.5"
aria-hidden="true"
/>
</button>
</li>
);
})}
</ul>
) : (
<EmptyState title="No open positions" description="Publish a role to start hiring." />
)}
</Band>
<Band
id="cc-talent"
title="Top talent"
meta={topTalent.length ? 'By KROW Score' : undefined}
action={<ViewAll label="View all" onClick={() => navigate('/admin/candidates')} />}
>
{topTalent.length ? (
<ul className="divide-y divide-border overflow-hidden rounded-xl border border-border bg-surface">
{topTalent.map((c) => (
<li key={c.id}>
<button
type="button"
onClick={() => navigate('/admin/candidates')}
className="group flex w-full items-center gap-3 px-4 py-3 text-left transition-colors
hover:bg-surface-subtle focus-visible:outline-none focus-visible:ring-2
focus-visible:ring-inset focus-visible:ring-krow-blue/50"
>
{/* The seeded portrait where there is one, otherwise the design
system's initials avatar. Never an invented face. */}
<Avatar name={c.applicant_name} src={c.selfie_url} size="sm" />
<span className="min-w-0 flex-1">
<span className="block truncate text-body-sm font-medium text-ink-1">
{c.applicant_name}
</span>
<span className="block truncate text-caption text-ink-3">{c.job_title}</span>
</span>
<span className="shrink-0 text-right">
<span className="block font-heading text-body font-bold tabular-nums text-ink-1">
{c.ai_score}
</span>
<span className="block text-[10px] text-ink-4">KROW</span>
</span>
<StatusBadge status={c.status} size="sm" />
<ChevronRight
className="h-3.5 w-3.5 shrink-0 text-ink-4 transition-transform duration-base group-hover:translate-x-0.5"
aria-hidden="true"
/>
</button>
</li>
))}
</ul>
) : (
<EmptyState
title="Nobody scored yet"
description="Screen the pipeline and the strongest candidates appear here."
/>
)}
</Band>
</div>
</>
);
}
function CCRecent({ attrs = {} }) {
const { navigate, range, setRange, applications, isLoading, postings, interviews, staff, profiles, activity, f, metrics, activitySeries, positionPerformance, strongest, weakestRole, actions, activePositions, topTalent, recent, exportSnapshot } = useUiContext();
return (
<>
{/* 8 ── Recent activity */}
<Band
id="cc-recent"
title="Recent activity"
meta={`${activity.length} events logged`}
action={<ViewAll label="View all" onClick={() => navigate('/admin/activity')} />}
>
<div className="overflow-hidden rounded-xl border border-border bg-surface">
<div className="overflow-x-auto">
<table className="w-full min-w-[36rem] border-collapse">
<caption className="sr-only">Most recent platform activity</caption>
<thead>
<tr className="border-b border-border bg-surface-subtle">
{['User', 'Action', 'Entity', 'Time', 'Role'].map((h) => (
<th
key={h}
scope="col"
className={cn(
'whitespace-nowrap px-4 py-2.5 text-[10px] font-semibold uppercase tracking-wide text-ink-4',
h === 'Role' ? 'text-right' : 'text-left'
)}
>
{h}
</th>
))}
</tr>
</thead>
<tbody>
{recent.map((event) => (
<tr key={event.id} className="border-b border-border last:border-0 hover:bg-surface-subtle">
<td className="px-4 py-2.5">
<div className="flex items-center gap-2.5">
<Avatar name={event.user_name || event.user_email} size="xs" />
<div className="min-w-0">
<p className="truncate text-body-sm font-medium text-ink-1">
{event.user_name || '—'}
</p>
<p className="truncate text-[10px] text-ink-4">{event.user_email}</p>
</div>
</div>
</td>
<td className="whitespace-nowrap px-4 py-2.5 text-body-sm text-ink-2">
{String(event.event_type).replace(/_/g, ' ')}
</td>
<td className="max-w-[24rem] px-4 py-2.5 text-body-sm text-ink-3">
<span className="line-clamp-1">{event.details || '—'}</span>
</td>
<td className="whitespace-nowrap px-4 py-2.5 text-caption text-ink-4">
{new Date(event.created_date).toLocaleDateString(undefined, {
month: 'short', day: 'numeric',
})}
</td>
<td className="px-4 py-2.5 text-right">
<Badge variant={event.account_type === 'employer' ? 'info' : 'neutral'} size="sm">
{event.account_type || 'unknown'}
</Badge>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</Band>
</>
);
}
const SECTION = ['move', 'hide'];
registerNodeType({
type: 'cc-snapshot',
/* This page's own section: it reads what this page publishes, so it belongs
nowhere else. See `page` in the registry. */
page: 'control-center',
label: 'Operations snapshot',
component: CCSnapshot,
capabilities: SECTION,
wrap: true,
});
registerNodeType({
type: 'cc-activity',
/* This page's own section: it reads what this page publishes, so it belongs
nowhere else. See `page` in the registry. */
page: 'control-center',
label: 'Hiring activity',
component: CCActivity,
capabilities: SECTION,
wrap: true,
});
registerNodeType({
type: 'cc-pipeline',
/* This page's own section: it reads what this page publishes, so it belongs
nowhere else. See `page` in the registry. */
page: 'control-center',
label: 'Pipeline intelligence',
component: CCPipeline,
capabilities: SECTION,
wrap: true,
});
registerNodeType({
type: 'cc-positions',
/* This page's own section: it reads what this page publishes, so it belongs
nowhere else. See `page` in the registry. */
page: 'control-center',
label: 'Position performance',
component: CCPositions,
capabilities: SECTION,
wrap: true,
});
registerNodeType({
type: 'cc-actions',
/* This page's own section: it reads what this page publishes, so it belongs
nowhere else. See `page` in the registry. */
page: 'control-center',
label: 'Action center',
component: CCActions,
capabilities: SECTION,
wrap: true,
});
registerNodeType({
type: 'cc-lists',
/* This page's own section: it reads what this page publishes, so it belongs
nowhere else. See `page` in the registry. */
page: 'control-center',
label: 'Active positions and top talent',
component: CCLists,
capabilities: SECTION,
wrap: true,
});
registerNodeType({
type: 'cc-recent',
/* This page's own section: it reads what this page publishes, so it belongs
nowhere else. See `page` in the registry. */
page: 'control-center',
label: 'Recent activity',
component: CCRecent,
capabilities: SECTION,
wrap: true,
});
registerPageComposition('control-center', [
{ id: 'cc-extensions-top', type: 'skill-surface', props: { page: 'control-center', placement: 'after-header' } },
{ id: 'cc-snapshot', type: 'cc-snapshot' },
{ id: 'cc-activity', type: 'cc-activity' },
{ id: 'cc-pipeline', type: 'cc-pipeline' },
{ id: 'cc-positions', type: 'cc-positions' },
{ id: 'cc-actions', type: 'cc-actions' },
{ id: 'cc-lists', type: 'cc-lists' },
{ id: 'cc-recent', type: 'cc-recent' },
{ id: 'cc-extensions-bottom', type: 'skill-surface', props: { page: 'control-center', placement: 'before-footer' } },
]);

View File

@@ -0,0 +1,275 @@
import React from 'react';
import { Award, Building2, CalendarDays, Clock, UserCheck } from 'lucide-react';
import { cn } from '@/lib/utils';
import {
Avatar, Button, DataTable, EmptyState, FilterBar, MetricStrip, StatusBadge, Surface, Timeline,
} from '@/components/ds';
import { SectionTitle } from '@/components/admin/PageShell';
import { registerNodeType } from '@/lib/ui/registry';
import { registerPageComposition } from '@/lib/ui/composition';
import { useUiContext } from '@/components/ui-tree/UiTreeRenderer';
/**
* Hired History, as addressable nodes.
*
* The second page composed through the UI node tree, and it follows Activity's
* pattern exactly: the markup below was moved here verbatim, the page keeps its
* own state and publishes it once, and each section is a singleton type that
* declares only the operations that mean something for a built-in.
*
* `chronology` is the Recent Hiring Timeline. It keeps the id its heading has
* always carried in the DOM, so a person naming it in conversation and the
* markup on screen refer to the same thing.
*/
/** Date-range windows, expressed as days back from today. */
export 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 const DAY = 24 * 60 * 60 * 1000;
export const formatDate = (value) => (value
? new Date(value).toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' })
: '—');
/* 1. Search and filters — the page is a record, so finding one is the
first thing it has to do well. */
function HiredFilters() {
const { search, setSearch, filters, setFilters, positionOptions, departmentOptions } = useUiContext();
return (
<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'. */
function HiredSummary() {
const { isLoading, hires, summary, recent, isFiltered } = useUiContext();
return (
<MetricStrip
columns={3}
loading={isLoading}
items={[
{
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`,
},
]}
/>
);
}
/* 3. The chronology. */
function HiredChronology({ attrs = {} }) {
const { recent, filtered } = useUiContext();
if (!recent.length) return null;
return (
<section aria-labelledby="chronology" className="space-y-3" {...attrs}>
<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>
);
}
/* 4. The records themselves — every field a hire carries, one row each. */
function HiredRecords({ attrs = {} }) {
const { isLoading, hires, filtered, isFiltered, clearFilters, setSelected } = useUiContext();
return (
<section aria-labelledby="records" className="space-y-3" {...attrs}>
<SectionTitle
id="records"
title="Hired candidate records"
meta={isFiltered ? `${filtered.length} of ${hires.length}` : `${hires.length} people`}
/>
<DataTable
loading={isLoading}
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: 'Candidate', sortable: true,
cell: (h) => (
<div className="flex min-w-0 items-center gap-2.5">
<Avatar name={h.name} size="sm" />
<div className="min-w-0">
<p className="truncate font-medium text-ink-1">{h.name}</p>
<p className="truncate text-[10px] text-ink-4">{h.email}</p>
</div>
</div>
),
},
{ key: 'role', header: 'Position', sortable: true, hideBelow: 'md' },
{
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: '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>
);
}
/* A page section is a singleton: `move` and `hide` are the only operations that
mean anything for one. */
const SECTION = ['move', 'hide'];
registerNodeType({
type: 'hired-filters',
/* This page's own section: it reads what this page publishes, so it belongs
nowhere else. See `page` in the registry. */
page: 'hired-history',
label: 'Search and filters',
summary: 'Search across every hire, and the fields worth filtering by.',
component: HiredFilters,
capabilities: SECTION,
/* `FilterBar` destructures its props, so the renderer supplies a bare wrapper
to carry the node's identity. No styling, no layout of its own. */
wrap: true,
});
registerNodeType({
type: 'hired-summary',
/* This page's own section: it reads what this page publishes, so it belongs
nowhere else. See `page` in the registry. */
page: 'hired-history',
label: 'Hire summary',
summary: 'How much of the record the current filters select.',
component: HiredSummary,
capabilities: SECTION,
wrap: true,
});
registerNodeType({
type: 'hired-chronology',
/* This page's own section: it reads what this page publishes, so it belongs
nowhere else. See `page` in the registry. */
page: 'hired-history',
label: 'Recent hiring timeline',
summary: 'The most recent hires, newest first.',
component: HiredChronology,
capabilities: SECTION,
});
registerNodeType({
type: 'hired-records',
/* This page's own section: it reads what this page publishes, so it belongs
nowhere else. See `page` in the registry. */
page: 'hired-history',
label: 'Hired candidate records',
summary: 'Every hire, one row each, with position, client and score.',
component: HiredRecords,
capabilities: SECTION,
});
/**
* The page as it ships.
*
* `chronology` and `records` keep the ids their headings already carried, so a
* node's identity is continuous with what the page has always said about
* itself. The detail drawer is deliberately absent: it is an overlay, not a
* section of the page, and nothing sensible would come of reordering it.
*/
registerPageComposition('hired-history', [
{ id: 'hired-extensions-top', type: 'skill-surface', props: { page: 'hired-history', placement: 'after-header' } },
{ id: 'hired-filters', type: 'hired-filters' },
{ id: 'hired-summary', type: 'hired-summary' },
{ id: 'chronology', type: 'hired-chronology' },
{ id: 'records', type: 'hired-records' },
]);

View File

@@ -0,0 +1,36 @@
import { registerPageComposition } from '@/lib/ui/composition';
/**
* Positions, page level only.
*
* Positions has three scopes and only one of them is flat. These two slots
* render **once for the page**, with no position in context, which is what a
* definition reporting across every role needs — and it is where the Board
* skill's section lives.
*
* The other seven slots are *instanced*: `after-position-card` renders inside
* each card, and five more render inside the drawer for one open position, each
* with `context={{ position }}`. A single tree cannot address those, because
* there is no one node for them — there are as many renderings as there are
* records. Giving them identity needs a repeating-subtree model and a decision
* about whether a change to one card means every card; that is deliberately not
* attempted here, and the page keeps rendering them exactly as it always has.
*
* So this composition is honest about being partial: two nodes, drawn where
* they have always been drawn, addressable like anything else.
*/
registerPageComposition('positions', [
{
id: 'positions-extensions-summary',
type: 'skill-surface',
props: { page: 'positions', placement: 'after-position-list-summary' },
/* The separation this slot has always had from the grid below it. */
layout: { spacingAfter: 'md' },
},
{
id: 'positions-extensions-list',
type: 'skill-surface',
props: { page: 'positions', placement: 'after-position-list' },
layout: { spacingBefore: 'lg' },
},
]);

View File

@@ -0,0 +1,332 @@
import React from 'react';
import { Bookmark, Star, UserRound, Trophy, CheckCircle2, TrendingUp, Sparkles } from 'lucide-react';
import { cn } from '@/lib/utils';
import {
Avatar, Badge, DataTable, IconButton, ProgressRing, SearchInput, toast,
} from '@/components/ds';
import { getScoreBand, toFICO } from '@/lib/talentHome';
import { SectionTitle, Toolbar } from '@/components/admin/PageShell';
import { FilterSelect } from '@/pages/admin/Positions';
import { registerNodeType } from '@/lib/ui/registry';
import { registerPageComposition } from '@/lib/ui/composition';
import { useUiContext } from '@/components/ui-tree/UiTreeRenderer';
/**
* Talent Pool, as addressable nodes.
*
* Moved verbatim from the page. The directory — its heading, its toolbar and
* its table — is one node rather than three: hiding a table and leaving its
* title behind is not a state anybody wants, and the three move together.
*/
export const SORTS = {
score: { label: 'Highest score', compare: (a, b) => (b.krow_score || 0) - (a.krow_score || 0) },
experience: { label: 'Most experience', compare: (a, b) => (b.experience_years || 0) - (a.experience_years || 0) },
name: { label: 'Name A\u2013Z', compare: (a, b) => a.full_name.localeCompare(b.full_name) },
recent: { label: 'Recently added', compare: (a, b) => new Date(b.created_date).getTime() - new Date(a.created_date).getTime() },
};
export const SEGMENT_CONFIG = {
Elite: {
key: 'elite',
icon: Trophy,
bg: 'bg-blue-50/80 text-blue-600 dark:bg-blue-950/60 dark:text-blue-400',
border: 'border-blue-200/50 dark:border-blue-800/40 hover:border-blue-400',
activeBorder: 'border-blue-600 ring-2 ring-blue-500/20',
text: 'text-blue-600 dark:text-blue-400',
accent: 'from-blue-500 to-indigo-500',
},
Excellent: {
key: 'excellent',
icon: Star,
bg: 'bg-emerald-50/80 text-emerald-600 dark:bg-emerald-950/60 dark:text-emerald-400',
border: 'border-emerald-200/50 dark:border-emerald-800/40 hover:border-emerald-400',
activeBorder: 'border-emerald-600 ring-2 ring-emerald-500/20',
text: 'text-emerald-600 dark:text-emerald-400',
accent: 'from-emerald-500 to-teal-500',
},
Solid: {
key: 'solid',
icon: CheckCircle2,
bg: 'bg-indigo-50/80 text-indigo-600 dark:bg-indigo-950/60 dark:text-indigo-400',
border: 'border-indigo-200/50 dark:border-indigo-800/40 hover:border-indigo-400',
activeBorder: 'border-indigo-600 ring-2 ring-indigo-500/20',
text: 'text-indigo-600 dark:text-indigo-400',
accent: 'from-indigo-500 to-purple-500',
},
Building: {
key: 'building',
icon: TrendingUp,
bg: 'bg-amber-50/80 text-amber-600 dark:bg-amber-950/60 dark:text-amber-400',
border: 'border-amber-200/50 dark:border-amber-800/40 hover:border-amber-400',
activeBorder: 'border-amber-600 ring-2 ring-amber-500/20',
text: 'text-amber-600 dark:text-amber-400',
accent: 'from-amber-500 to-orange-500',
},
Unscored: {
key: 'unscored',
icon: Sparkles,
bg: 'bg-orange-50/80 text-orange-600 dark:bg-orange-950/60 dark:text-orange-400',
border: 'border-orange-200/50 dark:border-orange-800/40 hover:border-orange-400',
activeBorder: 'border-orange-600 ring-2 ring-orange-500/20',
text: 'text-orange-600 dark:text-orange-400',
accent: 'from-orange-500 to-red-500',
},
};
/* Segments — executive metric cards */
function TalentSegments({ attrs = {} }) {
const { profiles, segments, band, setBand } = useUiContext();
return (
<section aria-labelledby="segments" className="space-y-3" {...attrs}>
<SectionTitle
id="segments"
title="Talent segments"
meta={`${profiles.length} in the pool`}
/>
<div>
<div className="grid grid-cols-1 gap-3.5 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-5">
{segments.map((s) => {
const cfg = SEGMENT_CONFIG[s.label] || SEGMENT_CONFIG.Elite;
const Icon = cfg.icon;
const isSelected = band === cfg.key;
const empty = s.count === 0;
return (
<div
key={s.label}
onClick={() => setBand(isSelected ? 'all' : cfg.key)}
className={cn(
'group relative flex flex-col justify-between cursor-pointer rounded-xl border bg-surface p-4 shadow-xs backdrop-blur-xs transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md',
isSelected ? cfg.activeBorder : cfg.border
)}
>
<div>
{/* Header Row: Label, Hint & Icon */}
<div className="flex items-center justify-between gap-1.5">
<div className="flex items-baseline gap-1.5 min-w-0">
<span className="truncate text-[11px] font-bold uppercase tracking-wider text-ink-1">
{s.label}
</span>
<span className="truncate text-[10px] font-medium text-ink-4">
{s.hint}
</span>
</div>
<div className={cn('flex h-7 w-7 shrink-0 items-center justify-center rounded-lg shadow-xs', cfg.bg)}>
<Icon className="h-3.5 w-3.5" />
</div>
</div>
{/* Main count */}
<div className="mt-2.5 flex items-baseline gap-1.5">
<span
className={cn(
'font-heading text-title-xl font-bold leading-none tabular-nums',
empty ? 'text-ink-4' : cfg.text
)}
>
{s.count}
</span>
</div>
</div>
{/* Sub pill & Accent bar */}
<div className="mt-3.5 space-y-2">
<span className="inline-flex items-center rounded-full bg-surface-subtle px-2 py-0.5 text-[10px] font-semibold text-ink-3 border border-border/60">
{empty
? 'No profiles'
: [
`${s.available} available`,
s.label === 'Unscored'
? 'needs verification'
: s.avgExperience > 0
? `${s.avgExperience}y avg`
: null,
]
.filter(Boolean)
.join(' · ')}
</span>
<div className={cn('h-0.5 w-full rounded-full bg-gradient-to-r opacity-40 group-hover:opacity-100 transition-opacity', cfg.accent)} />
</div>
</div>
);
})}
</div>
<p className="mt-3 border-t border-border/60 pt-3 text-caption leading-relaxed text-ink-3">
{segments[4].count > segments[0].count + segments[1].count
? `${segments[4].count} workers carry no career score against ${segments[0].count + segments[1].count} at Excellent or above. Supply is not the constraint here — verification is.`
: 'The scored part of the pool outweighs the unscored, so matching has enough to work with.'}
</p>
</div>
</section>
);
}
/** The directory: its heading, its filters and the ranked table. */
function TalentDirectory({ attrs = {} }) {
const {
profiles, filtered, isLoading, isFiltered, clearFilters, saved, toggleSave,
search, setSearch, skill, setSkill, experience, setExperience, location, setLocation,
band, setBand, availability, setAvailability, sort, setSort,
skills, locations, availabilities,
} = useUiContext();
/* `space-y-6` because these three were direct children of the page's own
stack and drew their separation from it. Grouping them into one node has to
carry that rhythm inward, or the heading, the filters and the table close
up against each other. */
return (
<div className="space-y-6" {...attrs}>
<SectionTitle title="Talent directory" meta={`${profiles.length} profiles`} />
<Toolbar
search={<SearchInput value={search} onChange={setSearch} placeholder="Search name, role or skill" size="sm" />}
filters={
<>
<FilterSelect value={skill} onChange={setSkill} label="Skills"
options={[{ value: 'all', label: 'All skills' }, ...skills.map((s) => ({ value: s, label: s }))]} />
<FilterSelect value={experience} onChange={setExperience} label="Experience" options={[
{ value: 'all', label: 'Any experience' }, { value: '5plus', label: '5+ years' },
{ value: '2to5', label: '2–5 years' }, { value: 'under2', label: 'Under 2 years' },
]} />
<FilterSelect value={location} onChange={setLocation} label="Location"
options={[{ value: 'all', label: 'All locations' }, ...locations.map((l) => ({ value: l, label: l }))]} />
<FilterSelect value={band} onChange={setBand} label="Score" options={[
{ value: 'all', label: 'All scores' }, { value: 'elite', label: 'Elite 90+' },
{ value: 'excellent', label: 'Excellent 75+' }, { value: 'solid', label: 'Solid 60+' },
{ value: 'building', label: 'Building' }, { value: 'unscored', label: 'Unscored' },
]} />
<FilterSelect value={availability} onChange={setAvailability} label="Availability"
options={[{ value: 'all', label: 'Any availability' }, ...availabilities.map((a) => ({ value: a, label: a }))]} />
<FilterSelect value={sort} onChange={setSort} label="Sort"
options={Object.entries(SORTS).map(([value, s]) => ({ value, label: s.label }))} />
</>
}
meta={`${filtered.length} of ${profiles.length} talents${isFiltered ? ' · filtered' : ''}${saved.length ? ` · ${saved.length} saved` : ''}`}
/>
<DataTable
loading={isLoading}
rows={filtered}
pageSize={15}
isFiltered={isFiltered}
onClearFilters={clearFilters}
caption="Talent pool ranked by career score"
columns={[
{
key: 'full_name', header: 'Candidate', sortable: true,
cell: (p) => (
<div className="flex min-w-0 items-center gap-2.5">
<Avatar name={p.full_name} src={p.selfie_url} size="sm" />
<div className="min-w-0">
<p className="truncate font-medium text-ink-1">{p.full_name}</p>
<p className="truncate text-[10px] text-ink-4">{p.address || p.email}</p>
</div>
</div>
),
},
{
key: 'role', header: 'Role', hideBelow: 'md',
cell: (p) => (
<div className="min-w-0">
<p className="truncate text-ink-2">{p.current_position || p.desired_position || '—'}</p>
{p.desired_position && p.current_position && (
<p className="truncate text-[10px] text-ink-4">wants {p.desired_position}</p>
)}
</div>
),
},
{
key: 'skills', header: 'Skills', hideBelow: 'lg',
cell: (p) => (p.skills?.length ? (
<div className="flex items-center gap-1">
<Badge variant="neutral" size="sm">{p.skills[0]}</Badge>
{p.skills.length > 1 && <span className="text-[10px] text-ink-4">+{p.skills.length - 1}</span>}
</div>
) : <span className="text-[11px] text-ink-4">—</span>),
},
{
key: 'experience_years', header: 'Exp', align: 'right', sortable: true, hideBelow: 'md',
cell: (p) => (p.experience_years ? `${p.experience_years}y` : '—'),
},
{
key: 'availability', header: 'Availability', hideBelow: 'lg',
cell: (p) => (p.availability?.length
? <span className="text-[11px] text-ink-3">{p.availability.join(', ')}</span>
: <Badge variant="warning" size="sm">Not set</Badge>),
},
{
key: 'krow_score', header: 'Career score', align: 'right', sortable: true,
cell: (p) => {
const score = p.krow_score || 0;
const bandInfo = getScoreBand(score);
return (
<div className="flex items-center justify-end gap-2">
<div className="text-right">
<p className="font-heading text-body font-bold tabular-nums text-ink-1">{toFICO(score)}</p>
<p className="text-[10px] text-ink-4">{bandInfo.label}</p>
</div>
<ProgressRing value={score} size={26} strokeWidth={3} tone="score" label="" />
</div>
);
},
},
{
key: 'actions', header: '', align: 'right', width: 92,
cell: (p) => (
<div className="flex items-center justify-end gap-0.5" onClick={(e) => e.stopPropagation()} role="presentation">
<IconButton
icon={saved.includes(p.id) ? Star : Bookmark}
label={saved.includes(p.id) ? `Remove ${p.full_name} from saved` : `Save ${p.full_name}`}
variant="ghost"
size="sm"
onClick={() => toggleSave(p.id, p.full_name)}
/>
<IconButton
icon={UserRound}
label={`View ${p.full_name}'s profile`}
variant="ghost"
size="sm"
onClick={() => toast.info(`Opening ${p.full_name}'s KROW Identity`)}
/>
</div>
),
},
]}
/>
</div>
);
}
const SECTION = ['move', 'hide'];
registerNodeType({
type: 'talent-segments',
/* This page's own section: it reads what this page publishes, so it belongs
nowhere else. See `page` in the registry. */
page: 'talent-pool',
label: 'Talent segments',
summary: 'The pool split into score bands, each a filter.',
component: TalentSegments,
capabilities: SECTION,
});
registerNodeType({
type: 'talent-directory',
/* This page's own section: it reads what this page publishes, so it belongs
nowhere else. See `page` in the registry. */
page: 'talent-pool',
label: 'Talent directory',
summary: 'Every profile, filtered and ranked by career score.',
component: TalentDirectory,
capabilities: SECTION,
});
registerPageComposition('talent-pool', [
{ id: 'talent-extensions-top', type: 'skill-surface', props: { page: 'talent-pool', placement: 'after-header' } },
{ id: 'segments', type: 'talent-segments' },
{ id: 'directory', type: 'talent-directory' },
{ id: 'talent-extensions-bottom', type: 'skill-surface', props: { page: 'talent-pool', placement: 'before-footer' } },
]);