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 (
{health.label}
);
}
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 (
);
}
/**
* 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 (
{parts.map((part, i) => (
{i > 0 && |}
{part}
))}
);
}
/**
* 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 (
Open positions, their hiring pipeline and workforce demand
{/* 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
{p.location || 'Location not set'}
Pay
{payLabel(p)}
{/* 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
? <> AI Built>
: 'Manual'}
{/* 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. */}
);
}
/** 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 */}
);
}
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 (
);
}