position ui and owliver update

This commit is contained in:
2026-08-14 01:18:59 +05:30
parent 1c653264b5
commit cccada9bd2
29 changed files with 3718 additions and 1313 deletions

View File

@@ -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)}`);