Files
krow_talent_app/src/pages/admin/Positions.jsx
2026-08-15 23:24:06 +05:30

1182 lines
50 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import React, { useEffect, useMemo, useState } from 'react';
import { useNavigate, useSearchParams } from 'react-router-dom';
import {
ArrowRight, CheckCircle2, LayoutGrid, List, MapPin, MessageSquare, Sparkles, Star, Users, Wallet,
} from 'lucide-react';
import { cn } from '@/lib/utils';
import {
Avatar, Badge, Button, Drawer, EmptyState, ProgressBar, SearchInput, SegmentedToggle,
Select, SelectContent, SelectItem, SelectTrigger, SelectValue, Skeleton, StatusBadge, toast,
} from '@/components/ds';
import {
useApplications, useAssignments, useCourses, useJobPostings, useStaff, useWorkerProfiles,
} from '@/lib/krowHooks';
import { workforceStatusFor } from '@/lib/workforce';
import {
customRequirementsText, englishLabel, hasRequirements, roleMetaLine,
} from '@/lib/positionModel';
import {
PositionCustomRequirements, PositionOverview, PositionRequirements,
} from '@/components/krow/PositionDetails';
import { SkillSurface } from '@/components/skills/SkillSurface';
import { useAssistantPanel, usePublishPageContext } from '@/components/ai-assistant';
import { continueDraftRequest } from '@/lib/skills/draftFlow';
import { AdminPage, SectionTitle } from '@/pages/admin/_shell';
import { RoleGlyph } from '@/pages/admin/RoleGlyph';
import { buildPosition, payLabel } from '@/pages/admin/positionInsights';
/**
* Admin Positions — a hiring workspace, one card per role.
*
* The table this replaces was good at exactly one thing: comparing a single column
* down every row. But the question an operator brings to this page is "how is
* *this* role doing", and answering it from a table meant reading nine cells and
* assembling them yourself. A card can state the funnel, the health and the one
* thing to do next as a single thought.
*
* What keeps it from becoming a wall of decoration:
*
* - **One insight per card, not every field.** The most actionable finding, chosen
* in positionInsights.js, and nothing else.
* - **No cards inside cards.** Hierarchy comes from type, dividers and the
* progression strip.
* - **The drawer holds the depth.** Clicking a card opens the full record over the
* grid instead of navigating away, so reviewing seven roles never costs you your
* scroll position or your filters.
*/
const SORTS = {
attention: {
label: 'Needs attention first',
/* Health first, then the biggest backlog — so roles that need a person are
the ones visible without scrolling. */
compare: (a, b) => {
const rank = { risk: 0, warning: 1, success: 2, neutral: 3 };
return (rank[a.health.tone] - rank[b.health.tone]) || (b.stats.unscreened - a.stats.unscreened);
},
},
applicants: { label: 'Most applicants', compare: (a, b) => b.stats.applied - a.stats.applied },
starved: { label: 'Fewest applicants', compare: (a, b) => a.stats.applied - b.stats.applied },
updated: {
label: 'Recently updated',
compare: (a, b) => new Date(b.updated_date || b.created_date) - new Date(a.updated_date || a.created_date),
},
title: { label: 'Title A–Z', compare: (a, b) => a.title.localeCompare(b.title) },
};
const HEALTH_TONE = {
success: 'bg-success-muted text-success',
warning: 'bg-warning-muted text-warning',
risk: 'bg-destructive-muted text-destructive',
neutral: 'bg-surface-sunken text-ink-3',
};
/** Hiring health as a small filled chip — it is a state, so it reads as one. */
function HealthChip({ health, className }) {
return (
<span
className={cn(
'inline-flex shrink-0 items-center gap-1 rounded-full px-2 py-0.5 text-[11px] font-semibold',
HEALTH_TONE[health.tone],
className
)}
>
<span className="h-1.5 w-1.5 rounded-full bg-current" aria-hidden="true" />
{health.label}
</span>
);
}
const STEP_ICONS = {
applied: Users,
screened: Sparkles,
shortlisted: Star,
interviewed: MessageSquare,
hired: CheckCircle2,
};
/**
* The hiring progression: Applied → Screened → Shortlisted → Interviewed → Hired.
*
* Rendered as an executive multi-segment funnel bar and 5 stage micro-cards with tailored icons.
*/
function Progression({ steps, className }) {
const entered = steps[0]?.count || 0;
return (
<div className={cn('w-full space-y-2', className)} role="group" aria-label="Hiring progression">
{/* Top Proportional Funnel Progress Bar */}
<div className="flex h-1.5 w-full overflow-hidden rounded-full bg-surface-sunken p-0.5 gap-0.5 border border-border/40">
{steps.map((step) => {
const share = entered ? (step.count / entered) * 100 : 0;
if (!step.count) return null;
const colorMap = {
applied: 'bg-blue-600',
screened: 'bg-indigo-500',
shortlisted: 'bg-violet-500',
interviewed: 'bg-cyan-500',
hired: 'bg-emerald-500',
};
return (
<div
key={step.key}
className={cn('h-full rounded-full transition-all duration-500', colorMap[step.key] || 'bg-blue-600')}
style={{ width: `${Math.max(share, 10)}%` }}
/>
);
})}
</div>
{/* 5 Executive Stage Micro-Cards */}
<div className="grid grid-cols-5 gap-1.5">
{steps.map((step) => {
const hasCount = step.count > 0;
const isHired = step.key === 'hired' && hasCount;
const Icon = STEP_ICONS[step.key] || Users;
return (
<div
key={step.key}
className={cn(
'group/step relative flex flex-col items-center justify-between rounded-xl p-1.5 border transition-all duration-200 text-center min-w-0',
hasCount
? isHired
? 'bg-gradient-to-b from-emerald-50/90 to-emerald-100/40 border-emerald-300/80 shadow-2xs dark:from-emerald-950/40 dark:to-emerald-900/20 dark:border-emerald-800'
: 'bg-gradient-to-b from-surface to-surface-subtle/80 border-border/70 shadow-2xs hover:border-blue-500/40 hover:shadow-xs'
: 'bg-surface-subtle/30 border-border/40 text-ink-4'
)}
>
{/* Icon & Count Badge */}
<div className="flex items-center gap-1">
<Icon
className={cn(
'h-3 w-3 shrink-0',
hasCount
? isHired
? 'text-emerald-600 dark:text-emerald-400'
: 'text-blue-600 dark:text-blue-400'
: 'text-ink-4/40'
)}
/>
<span
className={cn(
'font-heading text-body-sm font-extrabold tabular-nums leading-none',
hasCount
? isHired
? 'text-emerald-700 dark:text-emerald-400'
: 'text-ink-1'
: 'text-ink-4'
)}
>
{step.count}
</span>
</div>
{/* Stage Micro Label */}
<span
className={cn(
'mt-1.5 truncate text-[9.5px] font-semibold tracking-tight leading-none w-full text-center',
hasCount ? 'text-ink-2' : 'text-ink-4/60'
)}
>
{step.label === 'Shortlisted' ? 'Shortlist' : step.label === 'Interviewed' ? 'Interview' : step.label}
</span>
</div>
);
})}
</div>
</div>
);
}
/**
* The workforce line — one compact row under the hiring pipeline.
*
* Deliberately a single line of text rather than a panel. The pipeline above it
* is what this card has always been about; workforce demand is a second, quieter
* fact, and giving it equal weight turned the card into a dashboard.
*
* Every clause appears only if the data behind it exists. A position that
* declares no headcount has no coverage to report, and one with no strong
* matches simply omits that clause instead of announcing an absence. If nothing
* is known, the row does not render at all.
*/
function WorkforceRow({ workforce }) {
if (!workforce) return null;
const { demand, strong, newToday } = workforce;
const parts = [
demand.declared && (
<span key="demand" className="tabular-nums">
<span className="font-semibold text-ink-1">{demand.required}</span> required
<span className="text-ink-4"> · </span>
<span className="font-semibold text-ink-1">{demand.assigned}</span> assigned
<span className="text-ink-4"> · </span>
<span className={cn('font-semibold', demand.remaining ? 'text-warning' : 'text-success')}>
{demand.remaining}
</span> remaining
</span>
),
strong.length > 0 && (
<span key="strong" className="tabular-nums">
<span className="font-semibold text-ink-1">{strong.length}</span> strong
{strong.length === 1 ? ' match' : ' matches'}
</span>
),
newToday.length > 0 && (
<span key="new" className="tabular-nums font-semibold text-krow-blue">
{newToday.length} new today
</span>
),
].filter(Boolean);
if (!parts.length) return null;
return (
<div className="mt-2.5 flex flex-wrap items-center gap-x-2 gap-y-0.5 rounded-lg bg-surface-subtle px-2.5 py-1.5 text-caption text-ink-3">
<Users className="h-3 w-3 shrink-0 text-ink-4" aria-hidden="true" />
{parts.map((part, i) => (
<React.Fragment key={part.key}>
{i > 0 && <span className="text-ink-4/60">|</span>}
{part}
</React.Fragment>
))}
</div>
);
}
/**
* The chosen presentation, remembered.
*
* localStorage rather than the account preferences the rest of the app uses:
* this is how one person likes to look at one page on one machine, not a
* setting worth a write to the user record.
*/
const VIEW_KEY = 'krow:positions:view';
function useViewMode() {
const [view, setView] = useState(() => {
try {
return localStorage.getItem(VIEW_KEY) === 'list' ? 'list' : 'grid';
} catch {
return 'grid';
}
});
return [view, (next) => {
setView(next);
try {
localStorage.setItem(VIEW_KEY, next);
} catch {
/* Preference is not persisted; the page still works. */
}
}];
}
/* ── Just saved ────────────────────────────────────────────────────────────
*
* A position saved in the last two minutes is held a shade closer to the brand
* on its own card, so an admin returning to a list of thirty roles can find the
* one they were just working on without a toast telling them where to look.
*
* This is emphasis only, and it is the *only* part of the card that expires.
* What the card says about the position — Draft, Hiring, Paused — is the
* position's own status and is never on a timer: a draft stays a draft until
* the publish action makes it something else.
*
* It is derived from `updated_date`, which the store stamps on every write, so
* the mark follows the last save rather than the first. Because the source is
* the record itself, it survives a refresh, a navigation away and back, and any
* filter or sort the page is in. Each position expires on its own clock; there
* is no page-level "something happened" flag.
*/
const RECENT_WINDOW_MS = 2 * 60 * 1000;
const savedAt = (posting) => Date.parse(posting.updated_date || posting.created_date);
/**
* The ids currently inside their window, and a single timer that re-reads the
* clock when the soonest of them expires. Nothing recent on screen means no
* timer at all.
*/
function useRecentlySavedIds(postings) {
const [now, setNow] = useState(() => Date.now());
/* The creation times themselves, as a value rather than an array identity.
The page re-reads `postings` from the query on every render, so depending
on the array would re-run the effects below on renders where nothing about
the data changed. */
const stamps = useMemo(
() => postings.map((p) => `${p.id}@${p.updated_date || p.created_date}`).join('|'),
[postings]
);
/* The clock is re-read whenever a position is added or saved, not only when a
timer fires. A position written while this page is already open arrives
through a query invalidation, and against a clock last read at mount it
would look like it had not happened yet. */
useEffect(() => { setNow(Date.now()); }, [stamps]);
const recentIds = useMemo(() => {
const ids = new Set();
for (const p of postings) {
const age = now - savedAt(p);
if (Number.isFinite(age) && age >= 0 && age < RECENT_WINDOW_MS) ids.add(p.id);
}
return ids;
/* `stamps` stands in for `postings` here: the same information, as a value
that only changes when the set of positions does. */
}, [stamps, now]);
useEffect(() => {
if (!recentIds.size) return undefined;
/* Wake once, at the first expiry rather than on an interval. Whatever is
still recent after that wake re-arms the next one. */
const soonest = Math.min(
...postings
.filter((p) => recentIds.has(p.id))
.map((p) => savedAt(p) + RECENT_WINDOW_MS)
);
const timer = setTimeout(() => setNow(Date.now()), Math.max(0, soonest - Date.now()) + 50);
return () => clearTimeout(timer);
}, [recentIds]);
return recentIds;
}
/**
* The same positions, as a table.
*
* Takes the rows the page already built, filtered and sorted — the same objects
* the cards render, carrying both the pipeline and the workforce reading. It
* derives nothing, so a number here cannot differ from the same number there.
*
* Columns follow the data: the workforce columns appear only if some position on
* screen actually declares a headcount, rather than filling a column with dashes.
*/
function PositionsTable({ rows, onOpen }) {
const showDemand = rows.some((p) => p.workforce.demand.declared);
const columns = [
{ key: 'position', label: 'Position' },
{ key: 'applied', label: 'Applied', align: 'right' },
{ key: 'screened', label: 'Screened', align: 'right' },
{ key: 'shortlisted', label: 'Shortlist', align: 'right' },
{ key: 'interviews', label: 'Interview', align: 'right' },
{ key: 'hired', label: 'Hired', align: 'right' },
showDemand && { key: 'required', label: 'Required', align: 'right' },
showDemand && { key: 'assigned', label: 'Assigned', align: 'right' },
showDemand && { key: 'remaining', label: 'Remaining', align: 'right' },
{ key: 'strong', label: 'Strong', align: 'right' },
{ key: 'health', label: 'Status' },
{ key: 'action', label: '' },
].filter(Boolean);
return (
<div className="overflow-hidden rounded-xl border border-border bg-surface shadow-xs">
<table className="hidden w-full border-collapse text-body-sm lg:table">
<caption className="sr-only">Open positions, their hiring pipeline and workforce demand</caption>
<thead>
<tr className="border-b border-border bg-surface-subtle">
{columns.map((c) => (
<th
key={c.key}
scope="col"
className={cn(
'whitespace-nowrap px-3 py-2.5 text-[10px] font-bold uppercase tracking-wide text-ink-4',
c.align === 'right' ? 'text-right' : 'text-left'
)}
>
{c.label}
</th>
))}
</tr>
</thead>
<tbody>
{rows.map((p) => {
const { demand, strong } = p.workforce;
return (
<tr
key={p.id}
onClick={() => onOpen(p)}
className="cursor-pointer border-b border-border last:border-0 transition-colors hover:bg-surface-subtle"
>
<td className="px-3 py-2.5">
{p.company && (
<p className="text-[10px] font-semibold uppercase tracking-wide text-krow-blue">{p.company}</p>
)}
<p className="font-medium text-ink-1">{p.title}</p>
<p className="text-caption text-ink-4">
{p.role_category}
{p.location ? ` · ${p.location}` : ''}
</p>
</td>
<td className="px-3 py-2.5 text-right font-semibold tabular-nums text-ink-1">{p.stats.applied}</td>
<td className="px-3 py-2.5 text-right tabular-nums text-ink-2">{p.stats.screened}</td>
<td className="px-3 py-2.5 text-right tabular-nums text-ink-2">{p.stats.shortlisted}</td>
<td className="px-3 py-2.5 text-right tabular-nums text-ink-2">{p.stats.interviews}</td>
<td className="px-3 py-2.5 text-right tabular-nums text-ink-2">{p.stats.hired}</td>
{showDemand && (
<>
<td className="px-3 py-2.5 text-right tabular-nums text-ink-2">
{demand.declared ? demand.required : <span className="text-ink-4">—</span>}
</td>
<td className="px-3 py-2.5 text-right tabular-nums text-ink-2">
{demand.declared ? demand.assigned : <span className="text-ink-4">—</span>}
</td>
<td className={cn('px-3 py-2.5 text-right font-semibold tabular-nums', demand.declared && demand.remaining ? 'text-warning' : 'text-ink-2')}>
{demand.declared ? demand.remaining : <span className="font-normal text-ink-4">—</span>}
</td>
</>
)}
<td className="px-3 py-2.5 text-right tabular-nums text-ink-2">{strong.length}</td>
<td className="px-3 py-2.5"><HealthChip health={p.health} /></td>
<td className="px-3 py-2.5 text-right">
<span className="inline-flex items-center gap-1 text-caption font-semibold text-krow-blue">
View <ArrowRight className="h-3 w-3" aria-hidden="true" />
</span>
</td>
</tr>
);
})}
</tbody>
</table>
{/* Below `lg` the same rows stack: twelve columns cannot be read on a
phone, and a table that scrolls the page sideways is worse than one
that changes shape. */}
<ul className="divide-y divide-border lg:hidden">
{rows.map((p) => (
<li key={p.id}>
<button
type="button"
onClick={() => onOpen(p)}
className="flex w-full flex-col gap-2 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"
>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
{p.company && (
<p className="truncate text-[10px] font-semibold uppercase tracking-wide text-krow-blue">{p.company}</p>
)}
<p className="truncate font-medium text-ink-1">{p.title}</p>
<p className="truncate text-caption text-ink-4">
{p.role_category}
{p.location ? ` · ${p.location}` : ''}
</p>
</div>
<HealthChip health={p.health} />
</div>
<p className="flex flex-wrap items-center gap-x-2 text-caption tabular-nums text-ink-3">
<span><span className="font-semibold text-ink-1">{p.stats.applied}</span> applied</span>
<span className="text-ink-4">·</span>
<span><span className="font-semibold text-ink-1">{p.stats.interviews}</span> interview</span>
<span className="text-ink-4">·</span>
<span><span className="font-semibold text-ink-1">{p.stats.hired}</span> hired</span>
</p>
<p className="text-caption text-ink-2">{p.insight}</p>
</button>
</li>
))}
</ul>
</div>
);
}
/**
* "4 yrs experience · Fluent English" — the two candidate criteria, or nothing.
*
* Abbreviated deliberately: this is a supporting line under the terms, not a
* field list. A position that states neither returns null and the line is not
* drawn, rather than a card carrying a label with no answer.
*/
function candidateCriteria(position) {
const years = Number(position.min_experience_years);
const english = englishLabel(position.english_required);
return [
Number.isFinite(years) && years > 0 ? `${years} yrs experience` : null,
english ? `${english} English` : null,
].filter(Boolean).join(' · ') || null;
}
/**
* One position. The whole card is the target, so there is no hunting for a link.
*
* A draft continues where the work now happens — in the panel beside the list —
* while everything else opens the drawer it always did. That is the only
* difference between the two states on this card: same layout, same figures,
* and the status pill the card has always drawn now reads Draft.
*/
function PositionCard({ position, onOpen, isRecent = false }) {
const p = position;
const isDraft = p.status === 'draft';
return (
<article
role="button"
tabIndex={0}
onClick={() => onOpen(p)}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
onOpen(p);
}
}}
aria-label={`${p.title}. ${p.health.label}. ${p.stats.applied} applicants. ${p.insight}${isDraft ? '. Draft — not published yet.' : ''}`}
className={cn(
`group flex cursor-pointer flex-col rounded-xl border border-border bg-surface p-4 text-left shadow-xs
transition-[border-color,box-shadow] duration-base ease-out
hover:border-krow-blue/40 hover:shadow-md
focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-krow-blue/50`,
/* Just saved: the same card, held a shade closer to the brand for two
minutes. Border, ring and surface only — the layout does not move,
and the entrance settles once rather than pulsing. */
isRecent && 'border-krow-blue/50 bg-krow-blue-tint/40 ring-1 ring-krow-blue/20 motion-safe:animate-scale-in'
)}
>
{/* Identity */}
<div className="flex items-start gap-3">
<RoleGlyph category={p.role_category} title={p.title} aiGenerated={p.ai_generated} />
{/* Client above title: on a staffing console the company a role is being
filled for is part of its identity, and it is read from the position
record — a position with no client stated simply leads with its
title, as it always did. */}
<div className="min-w-0 flex-1">
{p.company && (
<p className="truncate text-[11px] font-semibold uppercase tracking-wide text-krow-blue">
{p.company}
</p>
)}
<h3 className="truncate font-heading text-body font-semibold leading-snug text-ink-1">
{p.title}
</h3>
<p className="truncate text-caption text-ink-3">{p.role_category || 'Uncategorized'}</p>
</div>
{/* The position's own status, in the pill this card has always drawn.
A draft says Draft here — nothing else is added, and nothing about
this is on a timer. */}
<StatusBadge status={p.status} size="sm" />
</div>
{/* Terms */}
<dl className="mt-3 flex flex-wrap items-center gap-x-3 gap-y-1 text-caption text-ink-3">
<div className="flex min-w-0 items-center gap-1">
<dt className="sr-only">Location</dt>
<MapPin className="h-3 w-3 shrink-0 text-ink-4" aria-hidden="true" />
<dd className="truncate">{p.location || 'Location not set'}</dd>
</div>
<div className="flex min-w-0 items-center gap-1">
<dt className="sr-only">Pay</dt>
<Wallet className="h-3 w-3 shrink-0 text-ink-4" aria-hidden="true" />
<dd className="truncate">{payLabel(p)}</dd>
</div>
</dl>
{/* One quiet line of candidate criteria — the two that help decide whether
a role is worth opening. Everything else the position states belongs in
the detail view; a card that lists requirements stops being scannable,
which is the only thing a card is for. */}
{candidateCriteria(p) && (
<p className="mt-1.5 truncate text-[11px] text-ink-4">{candidateCriteria(p)}</p>
)}
<div className="my-3 h-px bg-border" />
<Progression steps={p.progression} />
<WorkforceRow workforce={p.workforce} />
{/* Health, and the one thing worth knowing. `mb-3.5` rather than a margin on
the footer, so the footer is free to take `mt-auto`. */}
<div className="mb-3.5 mt-3.5 flex items-start gap-2">
<HealthChip health={p.health} className="mt-px" />
<p className="min-w-0 flex-1 text-caption leading-relaxed text-ink-2">{p.insight}</p>
</div>
{/* Footer */}
{/* `mt-auto` pins the footer to the bottom of the card, so a grid row of
cards with different insight lengths still has its footers aligned. */}
<div className="mt-auto flex items-center justify-between border-t border-border pt-2.5">
<span className="inline-flex items-center gap-1 text-[11px] font-medium text-ink-4">
{p.ai_generated
? <><Sparkles className="h-3 w-3" aria-hidden="true" /> AI Built</>
: 'Manual'}
</span>
{/* A draft is unfinished work, so the card says what happens next on it:
it is continued in Owliver, not opened in a hiring drawer for a role
nobody can apply to yet. */}
<span className="inline-flex items-center gap-1 text-[11px] font-semibold text-krow-blue">
{isDraft ? 'Continue' : 'View position'}
<ArrowRight className="h-3 w-3 transition-transform duration-base group-hover:translate-x-0.5" aria-hidden="true" />
</span>
</div>
{/* The card's own extension point. A definition can target the list and
the drawer separately, or both — `after-position-card` renders per
card, with that card's position as context. Clicks inside a section
are its own; they do not open the drawer behind it. */}
<div
onClick={(e) => e.stopPropagation()}
onKeyDown={(e) => e.stopPropagation()}
role="presentation"
>
<SkillSurface
page="positions"
placement="after-position-card"
context={{ position: p }}
className="mt-3"
/>
</div>
</article>
);
}
/** The full record, over the grid rather than instead of it. */
function PositionDrawer({ position, onClose, onViewCandidates, onOpenPosition }) {
if (!position) return null;
const p = position;
const s = p.stats;
const earliest = p.applications.reduce(
(oldest, a) => (!oldest || new Date(a.created_date) < new Date(oldest) ? a.created_date : oldest),
null
);
const lastScored = p.applications
.filter((a) => a.ai_score > 0)
.map((a) => a.updated_date)
.sort()
.pop();
const lastHired = p.applications
.filter((a) => a.status === 'hired')
.map((a) => a.updated_date)
.sort()
.pop();
const timeline = [
{ label: 'Position created', date: p.created_date },
s.applied && { label: `First of ${s.applied} applications`, date: earliest },
s.screened && { label: `${s.screened} screened`, date: lastScored },
s.hired && { label: `${s.hired} hired`, date: lastHired },
].filter(Boolean);
return (
<Drawer
open={Boolean(p)}
onOpenChange={(open) => !open && onClose()}
/* Client → job title → terms. The company leads because it is who the
hire is for; it never replaces the stored job title. */
title={p.company ? (
<span className="block">
<span className="block text-[11px] font-semibold uppercase tracking-wide text-krow-blue">{p.company}</span>
{p.title}
</span>
) : p.title}
description={roleMetaLine(p) || 'No terms set on this position yet'}
size="xl"
footer={
<>
<Button variant="outline" size="sm" onClick={onClose}>Close</Button>
{/* The drawer holds the record; the page holds the record plus every
applicant on it. This is the way through to the second. */}
<Button variant="outline" size="sm" onClick={() => onOpenPosition(p)}>Open position</Button>
<Button size="sm" onClick={() => onViewCandidates(p)}>
View {s.applied} candidate{s.applied === 1 ? '' : 's'}
</Button>
</>
}
>
<div className="space-y-5">
{/* Overview */}
<div className="flex items-start gap-3.5">
<RoleGlyph category={p.role_category} title={p.title} size="lg" aiGenerated={p.ai_generated} />
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-1.5">
<StatusBadge status={p.status} size="sm" />
<HealthChip health={p.health} />
<Badge variant="neutral" size="sm">{p.ai_generated ? 'AI Built' : 'Manual'}</Badge>
</div>
<p className="mt-2 text-caption leading-relaxed text-ink-3">
{p.health.note} {p.insight}
</p>
</div>
</div>
{/* Extension points. A skill definition naming `positions` and one of
these placements renders here — this drawer is what "View position"
opens, so it is where a positions skill has to appear. The page
knows no skill; the definition names the slot. */}
<SkillSurface page="positions" placement="after-header" context={{ position: p }} />
{/* The position as it was specified. Every field Create Position asks
for is read back here — the three sections below mirror the three
groups of that form, in the same order. */}
<section>
<SectionTitle title="Position details" />
<PositionOverview position={p} className="mt-2" />
</section>
<SkillSurface page="positions" placement="after-position-summary" context={{ position: p }} />
{/* Only what this role actually asks for. A position that states no
requirements has no Requirements section — an empty heading is a
demand nobody made. */}
{hasRequirements(p) && (
<section>
<SectionTitle title="Requirements" />
<PositionRequirements position={p} className="mt-2" />
</section>
)}
{customRequirementsText(p) && (
<section>
<SectionTitle title="Custom requirements" />
<PositionCustomRequirements position={p} className="mt-2" />
</section>
)}
{/* Hiring status */}
<section>
<SectionTitle title="Hiring status" />
<dl className="grid grid-cols-2 gap-x-4 gap-y-3 sm:grid-cols-3">
{[
{ label: 'Applicants', value: s.applied },
{ label: 'Shortlisted', value: s.shortlisted },
{ label: 'Interviews', value: s.interviews },
{ label: 'Hired', value: s.hired },
{ label: 'Qualified (70+)', value: s.qualified },
{ label: 'Avg score', value: s.avgScore || '—' },
].map((m) => (
<div key={m.label}>
<dt className="text-[10px] font-medium uppercase tracking-wide text-ink-4">{m.label}</dt>
<dd className="font-heading text-title font-bold tabular-nums text-ink-1">{m.value}</dd>
</div>
))}
</dl>
<div className="mt-4">
<div className="mb-1.5 flex items-baseline justify-between">
<span className="text-caption text-ink-2">Screening progress</span>
<span className="text-caption font-semibold tabular-nums text-ink-1">{s.coverage}%</span>
</div>
<ProgressBar
value={s.coverage}
tone={s.coverage === 100 ? 'success' : s.coverage >= 60 ? 'brand' : 'warning'}
size="sm"
/>
<p className="mt-1 text-[11px] text-ink-4">
{s.applied - s.unscreened} of {s.applied} applicants scored
</p>
</div>
</section>
<section>
<SectionTitle title="Hiring progression" />
<Progression steps={p.progression} />
</section>
<SkillSurface page="positions" placement="before-candidates" context={{ position: p }} />
{/* Two short lists side by side. The extra drawer width is spent on
information density rather than on longer lines of the same stack. */}
<div className="grid gap-5 lg:grid-cols-2">
{/* Top candidates */}
<section>
<SectionTitle
title="Top candidates"
meta={p.topCandidates.length ? `${p.topCandidates.length} scored` : 'None scored'}
/>
{p.topCandidates.length ? (
<ul className="divide-y divide-border rounded-lg border border-border">
{p.topCandidates.map((c) => (
<li key={c.id} className="flex items-center gap-2.5 px-3 py-2.5">
<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">
{String(c.status).replace(/_/g, ' ')}
{c.ai_recommendation ? ` · ${c.ai_recommendation}` : ''}
</p>
</div>
<div className="shrink-0 text-right">
<p className="font-heading text-body font-bold tabular-nums text-ink-1">{c.ai_score}</p>
<p className="text-[10px] text-ink-4">KROW Score</p>
</div>
</li>
))}
</ul>
) : (
<p className="text-body-sm text-ink-3">
Nobody has been screened for this role yet, so there is no ranking to show.
</p>
)}
</section>
<section>
<SectionTitle title="Hiring timeline" />
<ol className="space-y-2">
{timeline.map((e, i) => (
<li key={i} className="flex items-baseline justify-between gap-3 text-body-sm">
<span className="text-ink-2">{e.label}</span>
<span className="shrink-0 text-caption text-ink-4">
{e.date
? new Date(e.date).toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' })
: '—'}
</span>
</li>
))}
</ol>
</section>
</div>
<SkillSurface page="positions" placement="after-candidates" context={{ position: p }} />
<section>
<SectionTitle title="Recent activity" />
{p.applications.length ? (
<ol className="space-y-2">
{[...p.applications]
.sort((a, b) => new Date(b.updated_date) - new Date(a.updated_date))
.slice(0, 5)
.map((a) => (
<li key={a.id} className="flex items-baseline justify-between gap-3 text-body-sm">
<span className="min-w-0 truncate text-ink-2">
<span className="font-medium text-ink-1">{a.applicant_name}</span>
{' — '}
{a.ai_score > 0 ? `scored ${a.ai_score}` : 'applied, not yet screened'}
</span>
<span className="shrink-0 text-caption text-ink-4">
{new Date(a.updated_date).toLocaleDateString(undefined, { month: 'short', day: 'numeric' })}
</span>
</li>
))}
</ol>
) : (
<p className="text-body-sm text-ink-3">No activity on this position yet.</p>
)}
</section>
<SkillSurface page="positions" placement="before-footer" context={{ position: p }} />
</div>
</Drawer>
);
}
export default function AdminPositions() {
const navigate = useNavigate();
/* How this page starts a turn in the panel beside it — see `openPosition`. */
const { ask } = useAssistantPanel();
/* No create form on this page, in any form — no drawer, no modal, no route.
Creating a position is a conversation with Owliver beside the list (see
lib/skills/positionFlow.js); it writes through `useCreateJobPosting`, which
invalidates the query this page reads, so a position created in the panel
appears in the grid without this component knowing it happened. */
const [searchParams] = useSearchParams();
const { data: postings = [], isLoading } = useJobPostings();
const { data: applications = [] } = useApplications();
/* The workforce side of the record. Read alongside the pipeline, never in
place of it — the two describe different things about the same position. */
const { data: assignments = [] } = useAssignments();
const { data: profiles = [] } = useWorkerProfiles();
const { data: courses = [] } = useCourses();
const { data: staff = [] } = useStaff();
const [search, setSearch] = useState(searchParams.get('search') || '');
const [status, setStatus] = useState(searchParams.get('status') || 'all');
const [department, setDepartment] = useState('all');
const [location, setLocation] = useState('all');
const [sort, setSort] = useState('attention');
const [selected, setSelected] = useState(null);
const [view, setView] = useViewMode();
/* Read off the postings, not the filtered rows: which positions were saved
recently does not depend on what is currently on screen, so searching or
sorting cannot shift the window. */
const recentIds = useRecentlySavedIds(postings);
/**
* Continuing a draft, in the panel beside the list.
*
* It used to open `/admin/positions/new?draft=<id>`, which answered the wrong
* question: the reader had not asked for the form back, they had asked to
* finish the position — and the form is only where finishing it used to have
* to happen. Owliver can generate the description, read and set the vetting
* weights, and publish, all through the mutations the form calls, so the work
* no longer needs the page it was written on.
*
* So the card asks Owliver instead, naming the record by id, and the URL stays
* `/admin/positions`. The form is untouched and still reachable — someone who
* opens `?draft=<id>` deliberately gets exactly what they always did.
*/
const openPosition = (p) => (p.status === 'draft'
? ask(continueDraftRequest(p))
: setSelected(p));
/**
* The position the reader has open, published for Owliver.
*
* The panel beside this page can read every collection already; what it
* cannot know is which record is in front of you. Publishing the raw posting
* — not the derived row — means a skill's declared source resolves against
* the same record the drawer is showing, and reading a different position
* changes the answer without either side knowing about the other.
*
* A position that has just been published counts as "in front of you" too: the
* form leaves `?published=<id>` behind it, which is how the panel knows which
* role to offer candidates for without this page telling it anything about
* candidates. The drawer still wins while one is open.
*/
const publishedId = searchParams.get('published');
usePublishPageContext(useMemo(() => {
const current = selected?.id || publishedId;
if (!current) return null;
return { position: postings.find((p) => p.id === current) || null };
}, [selected, publishedId, postings]));
/**
* Derived once per position rather than per cell, so the grid does not run the
* same filter five times per card.
*
* `buildPosition` owns the hiring pipeline — applied, screened, shortlisted,
* interviewed, hired — exactly as it always has. `workforce` is attached
* beside it, not merged into it: a position can have four applicants moving
* through screening and no strong workforce match at the same time, and both
* of those are true. Collapsing them would make one of the two a lie.
*/
const workforceContext = useMemo(
() => ({ profiles, applications, assignments, courses, staff }),
[profiles, applications, assignments, courses, staff]
);
const rows = useMemo(
() => postings.map((p) => ({
...buildPosition(p, applications),
workforce: workforceStatusFor(p, workforceContext),
})),
[postings, applications, workforceContext]
);
const departments = useMemo(
() => [...new Set(postings.map((p) => p.role_category).filter(Boolean))].sort(),
[postings]
);
const locations = useMemo(
() => [...new Set(postings.map((p) => p.location).filter(Boolean))].sort(),
[postings]
);
const filtered = useMemo(() => {
const q = search.trim().toLowerCase();
const ordered = rows
.filter((p) => {
if (q && !`${p.title} ${p.company || ''} ${p.role_category} ${p.location}`.toLowerCase().includes(q)) return false;
if (status !== 'all' && p.status !== status) return false;
if (department !== 'all' && p.role_category !== department) return false;
if (location !== 'all' && p.location !== location) return false;
return true;
})
.sort(SORTS[sort].compare);
/**
* The most recently created position leads.
*
* A position saved from the panel is the thing the reader was just doing,
* and under "needs attention" a draft sorts to the bottom — it has no
* applicants, so it reads as the least urgent row on the board. It is not:
* it is unfinished work they just put down.
*
* Only in the default ordering. Someone who has explicitly chosen "Title
* A–Z" or "Most applicants" asked for that order and gets it, and no
* preference is written either way — this is one hoist over the sorted
* list, not a change to how anything is stored.
*/
if (sort !== 'attention' || ordered.length < 2) return ordered;
const newest = ordered.reduce(
(best, p) => (new Date(p.created_date) > new Date(best.created_date) ? p : best),
ordered[0]
);
return [newest, ...ordered.filter((p) => p.id !== newest.id)];
}, [rows, search, status, department, location, sort]);
const isFiltered = Boolean(search) || [status, department, location].some((v) => v !== 'all');
const clearFilters = () => {
setSearch(''); setStatus('all'); setDepartment('all'); setLocation('all');
};
/* The page summary as a sentence rather than a KPI row: these are counts you
read once for orientation, and four cards would outweigh the grid they
introduce. */
const summary = useMemo(() => {
const open = rows.filter((p) => p.status === 'active').length;
const sum = (key) => rows.reduce((total, p) => total + p.stats[key], 0);
const applied = sum('applied');
/* The pipeline sentence the page has always shown, unchanged. */
const parts = [
`${open} open position${open === 1 ? '' : 's'}`,
`${applied} active candidate${applied === 1 ? '' : 's'}`,
`${sum('shortlisted')} shortlisted`,
`${sum('hired')} hired`,
];
/* Workforce demand joins it only where positions actually declare one.
Summing a schema default across every row would produce a total nobody
could reconcile against the cards. */
const declared = rows.filter((p) => p.status === 'active' && p.workforce.demand.declared);
if (declared.length) {
const required = declared.reduce((n, p) => n + p.workforce.demand.required, 0);
const assignedTotal = declared.reduce((n, p) => n + p.workforce.demand.assigned, 0);
parts.push(
`${required} required`,
`${assignedTotal} assigned`,
`${Math.max(0, required - assignedTotal)} remaining`
);
}
const newToday = rows.reduce((n, p) => n + p.workforce.newToday.length, 0);
if (newToday) parts.push(`${newToday} new today`);
return parts.join(' · ');
}, [rows]);
return (
<AdminPage
title="Positions"
subtitle="Manage open roles, hiring demand and position performance."
>
{/* Controls toolbar — unified flex row with clean alignment across viewports */}
<div className="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
<div className="flex flex-wrap items-center gap-2 flex-1 min-w-0">
<div className="w-full sm:w-60 md:w-64 xl:w-72 shrink-0">
<SearchInput
value={search}
onChange={setSearch}
placeholder="Search positions"
size="sm"
shape="pill"
className="bg-white border-border/80 hover:border-border transition-colors"
/>
</div>
<div className="flex flex-wrap items-center gap-2 min-w-0">
<FilterSelect value={status} onChange={setStatus} label="Status" options={[
{ value: 'all', label: 'All' }, { value: 'active', label: 'Hiring' },
{ value: 'paused', label: 'Paused' }, { value: 'draft', label: 'Draft' },
{ value: 'closed', label: 'Closed' },
]} />
<FilterSelect value={department} onChange={setDepartment} label="Department"
options={[{ value: 'all', label: 'All departments' }, ...departments.map((d) => ({ value: d, label: d }))]} />
<FilterSelect value={location} onChange={setLocation} label="Location"
options={[{ value: 'all', label: 'All locations' }, ...locations.map((l) => ({ value: l, label: l }))]} />
<FilterSelect value={sort} onChange={setSort} label="Sort"
options={Object.entries(SORTS).map(([value, s]) => ({ value, label: s.label }))} />
</div>
</div>
{/* View mode presentation toggle on far right */}
<div className="shrink-0 self-end lg:self-center">
<SegmentedToggle
variant="surface"
ariaLabel="View mode"
value={view}
onChange={setView}
options={[
{ value: 'grid', icon: LayoutGrid, ariaLabel: 'Grid view' },
{ value: 'list', icon: List, ariaLabel: 'List view' },
]}
/>
</div>
</div>
<p className="text-body-sm text-ink-3 font-normal mt-1 mb-4 flex items-center gap-1.5 flex-wrap">
{isFiltered ? `${filtered.length} of ${rows.length} positions · filtered` : summary}
</p>
{/* The list's own extension points. These render once for the page — no
position in context — which is what a definition reporting across
every role needs. The per-card and per-drawer placements are separate
slots, so one skill can address the board and another one role. */}
<SkillSurface page="positions" placement="after-position-list-summary" className="mb-4" />
{/* Three columns on desktop, two on tablet, one on mobile. */}
{isLoading ? (
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-3">
{Array.from({ length: 6 }).map((_, i) => (
<Skeleton key={i} className="h-64 rounded-xl" />
))}
</div>
) : filtered.length ? (
/* One filtered array, two drawings of it. */
view === 'grid' ? (
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-3">
{filtered.map((p) => (
<PositionCard
key={p.id}
position={p}
onOpen={openPosition}
isRecent={recentIds.has(p.id)}
/>
))}
</div>
) : (
<PositionsTable rows={filtered} onOpen={openPosition} />
)
) : (
<EmptyState
title={isFiltered ? 'No positions match these filters' : 'No positions yet'}
description={
isFiltered
? 'Try a broader search, or clear the filters to see every role.'
: 'Ask Owliver to create a position and applications will start appearing here.'
}
action={
isFiltered
? <Button variant="outline" size="sm" onClick={clearFilters}>Clear filters</Button>
: undefined
}
/>
)}
<SkillSurface page="positions" placement="after-position-list" className="mt-6" />
<PositionDrawer
position={selected}
onClose={() => setSelected(null)}
onOpenPosition={(p) => navigate(`/admin/positions/${p.id}`)}
onViewCandidates={(p) => {
setSelected(null);
if (p.stats.applied) navigate(`/admin/candidates?search=${encodeURIComponent(p.title)}`);
else toast.info(`${p.title} has no applicants yet`);
}}
/>
</AdminPage>
);
}
/** A compact labelled select pill — consistent on every Admin management page. */
export function FilterSelect({ value, onChange, label, options, className }) {
return (
<Select value={value} onValueChange={onChange}>
<SelectTrigger
aria-label={label}
className={cn(
'h-8 w-auto min-w-[7.5rem] rounded-full border border-border/80 bg-white px-3 text-[13px] font-normal text-ink-1 shadow-2xs hover:bg-surface-hover hover:border-border transition-colors outline-none focus:ring-2 focus:ring-krow-blue/20',
className
)}
>
<SelectValue />
</SelectTrigger>
<SelectContent>
{options.map((o) => <SelectItem key={o.value} value={o.value}>{o.label}</SelectItem>)}
</SelectContent>
</Select>
);
}