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 ( ); } 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 (
{/* Top Proportional Funnel Progress Bar */}
{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 (
); })}
{/* 5 Executive Stage Micro-Cards */}
{steps.map((step) => { const hasCount = step.count > 0; const isHired = step.key === 'hired' && hasCount; const Icon = STEP_ICONS[step.key] || Users; return (
{/* Icon & Count Badge */}
{step.count}
{/* Stage Micro Label */} {step.label === 'Shortlisted' ? 'Shortlist' : step.label === 'Interviewed' ? 'Interview' : step.label}
); })}
); } /** * 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 && ( {demand.required} required · {demand.assigned} assigned · {demand.remaining} remaining ), strong.length > 0 && ( {strong.length} strong {strong.length === 1 ? ' match' : ' matches'} ), newToday.length > 0 && ( {newToday.length} new today ), ].filter(Boolean); if (!parts.length) return null; return (
); } /** * 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 (
{columns.map((c) => ( ))} {rows.map((p) => { const { demand, strong } = p.workforce; return ( onOpen(p)} className="cursor-pointer border-b border-border last:border-0 transition-colors hover:bg-surface-subtle" > {showDemand && ( <> )} ); })}
Open positions, their hiring pipeline and workforce demand
{c.label}
{p.company && (

{p.company}

)}

{p.title}

{p.role_category} {p.location ? ` · ${p.location}` : ''}

{p.stats.applied} {p.stats.screened} {p.stats.shortlisted} {p.stats.interviews} {p.stats.hired} {demand.declared ? demand.required : —} {demand.declared ? demand.assigned : —} {demand.declared ? demand.remaining : —} {strong.length} View
{/* 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. */}
    {rows.map((p) => (
  • ))}
); } /** * "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 (
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 */}
{/* 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. */}
{p.company && (

{p.company}

)}

{p.title}

{p.role_category || 'Uncategorized'}

{/* 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. */}
{/* Terms */}
Location
Pay
{/* 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) && (

{candidateCriteria(p)}

)}
{/* 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`. */}

{p.insight}

{/* 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. */}
{p.ai_generated ? <> {/* 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. */} {isDraft ? 'Continue' : 'View position'}
{/* 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. */}
e.stopPropagation()} onKeyDown={(e) => e.stopPropagation()} role="presentation" >
); } /** 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 ( !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 ? ( {p.company} {p.title} ) : p.title} description={roleMetaLine(p) || 'No terms set on this position yet'} size="xl" footer={ <> {/* The drawer holds the record; the page holds the record plus every applicant on it. This is the way through to the second. */} } >
{/* Overview */}
{p.ai_generated ? 'AI Built' : 'Manual'}

{p.health.note} {p.insight}

{/* 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. */} {/* 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. */}
{/* 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) && (
)} {customRequirementsText(p) && (
)} {/* Hiring status */}
{[ { 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) => (
{m.label}
{m.value}
))}
Screening progress {s.coverage}%
= 60 ? 'brand' : 'warning'} size="sm" />

{s.applied - s.unscreened} of {s.applied} applicants scored

{/* Two short lists side by side. The extra drawer width is spent on information density rather than on longer lines of the same stack. */}
{/* Top candidates */}
{p.topCandidates.length ? (
    {p.topCandidates.map((c) => (
  • {c.applicant_name}

    {String(c.status).replace(/_/g, ' ')} {c.ai_recommendation ? ` · ${c.ai_recommendation}` : ''}

    {c.ai_score}

    KROW Score

  • ))}
) : (

Nobody has been screened for this role yet, so there is no ranking to show.

)}
    {timeline.map((e, i) => (
  1. {e.label} {e.date ? new Date(e.date).toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' }) : '—'}
  2. ))}
{p.applications.length ? (
    {[...p.applications] .sort((a, b) => new Date(b.updated_date) - new Date(a.updated_date)) .slice(0, 5) .map((a) => (
  1. {a.applicant_name} {' — '} {a.ai_score > 0 ? `scored ${a.ai_score}` : 'applied, not yet screened'} {new Date(a.updated_date).toLocaleDateString(undefined, { month: 'short', day: 'numeric' })}
  2. ))}
) : (

No activity on this position yet.

)}
); } 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=`, 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=` 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=` 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 ( {/* Controls toolbar — unified flex row with clean alignment across viewports */}
({ value: d, label: d }))]} /> ({ value: l, label: l }))]} /> ({ value, label: s.label }))} />
{/* View mode presentation toggle on far right */}

{isFiltered ? `${filtered.length} of ${rows.length} positions · filtered` : summary}

{/* 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. */} {/* Three columns on desktop, two on tablet, one on mobile. */} {isLoading ? (
{Array.from({ length: 6 }).map((_, i) => ( ))}
) : filtered.length ? ( /* One filtered array, two drawings of it. */ view === 'grid' ? (
{filtered.map((p) => ( ))}
) : ( ) ) : ( Clear filters : undefined } /> )} 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`); }} />
); } /** A compact labelled select pill — consistent on every Admin management page. */ export function FilterSelect({ value, onChange, label, options, className }) { return ( ); }