update position page design
This commit is contained in:
@@ -1,14 +1,17 @@
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import {
|
||||
ArrowRight, CheckCircle2, MapPin, MessageSquare, Sparkles, Star, Users, Wallet,
|
||||
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,
|
||||
Avatar, Badge, Button, Drawer, EmptyState, ProgressBar, SearchInput, SegmentedToggle,
|
||||
Select, SelectContent, SelectItem, SelectTrigger, SelectValue, Skeleton, StatusBadge, toast,
|
||||
} from '@/components/ds';
|
||||
import { useApplications, useJobPostings } from '@/lib/krowHooks';
|
||||
import {
|
||||
useApplications, useAssignments, useCourses, useJobPostings, useStaff, useWorkerProfiles,
|
||||
} from '@/lib/krowHooks';
|
||||
import { workforceStatusFor } from '@/lib/workforce';
|
||||
import { AdminPage, SectionTitle } from '@/pages/admin/_shell';
|
||||
import { RoleGlyph } from '@/pages/admin/RoleGlyph';
|
||||
import { buildPosition, payLabel } from '@/pages/admin/positionInsights';
|
||||
@@ -179,6 +182,223 @@ function Progression({ steps, className }) {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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. */
|
||||
}
|
||||
}];
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 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 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>
|
||||
);
|
||||
}
|
||||
|
||||
/** One position. The whole card is the target, so there is no hunting for a link. */
|
||||
function PositionCard({ position, onOpen }) {
|
||||
const p = position;
|
||||
@@ -232,6 +452,8 @@ function PositionCard({ position, onOpen }) {
|
||||
|
||||
<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">
|
||||
@@ -445,6 +667,12 @@ export default function AdminPositions() {
|
||||
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');
|
||||
@@ -452,12 +680,29 @@ export default function AdminPositions() {
|
||||
const [location, setLocation] = useState('all');
|
||||
const [sort, setSort] = useState('attention');
|
||||
const [selected, setSelected] = useState(null);
|
||||
const [view, setView] = useViewMode();
|
||||
|
||||
/**
|
||||
* 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]
|
||||
);
|
||||
|
||||
/* Derived once per position rather than per cell, so the grid does not run the
|
||||
same filter five times per card. */
|
||||
const rows = useMemo(
|
||||
() => postings.map((p) => buildPosition(p, applications)),
|
||||
[postings, applications]
|
||||
() => postings.map((p) => ({
|
||||
...buildPosition(p, applications),
|
||||
workforce: workforceStatusFor(p, workforceContext),
|
||||
})),
|
||||
[postings, applications, workforceContext]
|
||||
);
|
||||
|
||||
const departments = useMemo(
|
||||
@@ -494,12 +739,33 @@ export default function AdminPositions() {
|
||||
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');
|
||||
return [
|
||||
|
||||
/* 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`,
|
||||
].join(' · ');
|
||||
];
|
||||
|
||||
/* 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 (
|
||||
@@ -527,6 +793,21 @@ export default function AdminPositions() {
|
||||
<FilterSelect value={sort} onChange={setSort} label="Sort"
|
||||
options={Object.entries(SORTS).map(([value, s]) => ({ value, label: s.label }))} />
|
||||
</div>
|
||||
|
||||
{/* Presentation, at the end of the same toolbar. It changes how the rows
|
||||
are drawn, never which rows or what they say. */}
|
||||
<div className="lg:ml-auto">
|
||||
<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-caption text-ink-4">
|
||||
@@ -541,11 +822,16 @@ export default function AdminPositions() {
|
||||
))}
|
||||
</div>
|
||||
) : filtered.length ? (
|
||||
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-3">
|
||||
{filtered.map((p) => (
|
||||
<PositionCard key={p.id} position={p} onOpen={setSelected} />
|
||||
))}
|
||||
</div>
|
||||
/* 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={setSelected} />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<PositionsTable rows={filtered} onOpen={setSelected} />
|
||||
)
|
||||
) : (
|
||||
<EmptyState
|
||||
title={isFiltered ? 'No positions match these filters' : 'No positions yet'}
|
||||
|
||||
Reference in New Issue
Block a user