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, 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).getTime() - new Date(a.updated_date || a.created_date).getTime(), }, 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. */ /** @param {any} props */ 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 progression — the single source of truth for the pipeline. A separate "Hiring status" block used to sit above this one repeating Shortlisted, Interviews and Hired as figures, which is exactly what the progression already draws. Two readings of the same funnel invite the reader to check whether they agree, so the duplicated stage counts are gone and the three figures the progression does *not* carry — supply, quality and the screening average — lead it as one line. */}
Total applicants
{s.applied}
Applicant{s.applied === 1 ? '' : 's'}
Qualified, scoring 70 or above
{s.qualified}
Qualified (70+)
Avg Score
{s.avgScore || '—'}
{/* Screening coverage was the one figure in the old block that the progression cannot show — a stage count says how many reached Screened, not what share of the intake that is. */} {s.applied > 0 && ( <>
Screening coverage
{s.coverage}%
screened
)}
{/* 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

  • ))}
) : ( /* Two short lines, not a paragraph. The reason nobody is ranked is always the same — screening has not run — so it needs stating once, plainly, rather than explained. */

No screened candidates yet

Candidates will appear here once screening is completed.

)}
{/* The timeline sits beside Top candidates, so it earns a rail and nodes: two plain label/date lists side by side read as one table split in half. The dots say "this one is chronological" without a heavier treatment. */}
    {timeline.map((e, i) => (
  1. ))}
{p.applications.length ? (
    {[...p.applications] .sort((a, b) => new Date(b.updated_date).getTime() - new Date(a.updated_date).getTime()) .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. */ /** @param {any} props */ export function FilterSelect({ value, onChange, label, options, className = '' }) { return ( ); }