position ui and owliver update
This commit is contained in:
@@ -12,6 +12,12 @@ 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 { AdminPage, SectionTitle } from '@/pages/admin/_shell';
|
||||
import { RoleGlyph } from '@/pages/admin/RoleGlyph';
|
||||
import { buildPosition, payLabel } from '@/pages/admin/positionInsights';
|
||||
@@ -324,6 +330,9 @@ function PositionsTable({ rows, onOpen }) {
|
||||
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}
|
||||
@@ -375,6 +384,9 @@ function PositionsTable({ rows, onOpen }) {
|
||||
>
|
||||
<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}
|
||||
@@ -399,6 +411,23 @@ function PositionsTable({ rows, onOpen }) {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* "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. */
|
||||
function PositionCard({ position, onOpen }) {
|
||||
const p = position;
|
||||
@@ -424,7 +453,16 @@ function PositionCard({ position, onOpen }) {
|
||||
<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>
|
||||
@@ -448,6 +486,14 @@ function PositionCard({ position, onOpen }) {
|
||||
</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} />
|
||||
@@ -480,7 +526,7 @@ function PositionCard({ position, onOpen }) {
|
||||
}
|
||||
|
||||
/** The full record, over the grid rather than instead of it. */
|
||||
function PositionDrawer({ position, onClose, onViewCandidates }) {
|
||||
function PositionDrawer({ position, onClose, onViewCandidates, onOpenPosition }) {
|
||||
if (!position) return null;
|
||||
const p = position;
|
||||
const s = p.stats;
|
||||
@@ -511,12 +557,22 @@ function PositionDrawer({ position, onClose, onViewCandidates }) {
|
||||
<Drawer
|
||||
open={Boolean(p)}
|
||||
onOpenChange={(open) => !open && onClose()}
|
||||
title={p.title}
|
||||
description={`${p.role_category || 'Uncategorized'} · ${p.location || 'Location not set'} · ${payLabel(p)}`}
|
||||
size="lg"
|
||||
/* 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>
|
||||
@@ -539,6 +595,31 @@ function PositionDrawer({ position, onClose, onViewCandidates }) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 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>
|
||||
|
||||
{/* 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" />
|
||||
@@ -579,6 +660,9 @@ function PositionDrawer({ position, onClose, onViewCandidates }) {
|
||||
<Progression steps={p.progression} />
|
||||
</section>
|
||||
|
||||
{/* 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
|
||||
@@ -626,6 +710,7 @@ function PositionDrawer({ position, onClose, onViewCandidates }) {
|
||||
))}
|
||||
</ol>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<section>
|
||||
<SectionTitle title="Recent activity" />
|
||||
@@ -718,7 +803,7 @@ export default function AdminPositions() {
|
||||
const q = search.trim().toLowerCase();
|
||||
return rows
|
||||
.filter((p) => {
|
||||
if (q && !`${p.title} ${p.role_category} ${p.location}`.toLowerCase().includes(q)) return false;
|
||||
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;
|
||||
@@ -851,6 +936,7 @@ export default function AdminPositions() {
|
||||
<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)}`);
|
||||
|
||||
Reference in New Issue
Block a user