1182 lines
50 KiB
JavaScript
1182 lines
50 KiB
JavaScript
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>
|
||
);
|
||
}
|