This commit is contained in:
@@ -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} />;
|
||||
}
|
||||
|
||||
@@ -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; }
|
||||
|
||||
|
||||
@@ -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} />;
|
||||
}
|
||||
|
||||
@@ -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} />;
|
||||
}
|
||||
|
||||
@@ -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} />;
|
||||
}
|
||||
|
||||
@@ -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} />;
|
||||
}
|
||||
|
||||
@@ -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} />;
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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} />;
|
||||
}
|
||||
|
||||
313
src/pages/admin/activity/nodes.jsx
Normal file
313
src/pages/admin/activity/nodes.jsx
Normal 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' } },
|
||||
]);
|
||||
424
src/pages/admin/analytics/nodes.jsx
Normal file
424
src/pages/admin/analytics/nodes.jsx
Normal 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' } },
|
||||
]);
|
||||
415
src/pages/admin/candidates-analysis/nodes.jsx
Normal file
415
src/pages/admin/candidates-analysis/nodes.jsx
Normal 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' } },
|
||||
]);
|
||||
137
src/pages/admin/candidates/nodes.jsx
Normal file
137
src/pages/admin/candidates/nodes.jsx
Normal 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' } },
|
||||
]);
|
||||
909
src/pages/admin/control-center/nodes.jsx
Normal file
909
src/pages/admin/control-center/nodes.jsx
Normal 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' } },
|
||||
]);
|
||||
275
src/pages/admin/hired-history/nodes.jsx
Normal file
275
src/pages/admin/hired-history/nodes.jsx
Normal 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' },
|
||||
]);
|
||||
36
src/pages/admin/positions/nodes.js
Normal file
36
src/pages/admin/positions/nodes.js
Normal 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' },
|
||||
},
|
||||
]);
|
||||
332
src/pages/admin/talent-pool/nodes.jsx
Normal file
332
src/pages/admin/talent-pool/nodes.jsx
Normal 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' } },
|
||||
]);
|
||||
Reference in New Issue
Block a user