update owliver skill

This commit is contained in:
2026-08-14 17:33:16 +05:30
parent cccada9bd2
commit 4fdfb90326
58 changed files with 8015 additions and 1339 deletions

View File

@@ -1,4 +1,4 @@
import React, { useMemo, useState } from 'react';
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,
@@ -18,6 +18,8 @@ import {
import {
PositionCustomRequirements, PositionOverview, PositionRequirements,
} from '@/components/krow/PositionDetails';
import { SkillSurface } from '@/components/skills/SkillSurface';
import { usePublishPageContext } from '@/components/ai-assistant';
import { AdminPage, SectionTitle } from '@/pages/admin/_shell';
import { RoleGlyph } from '@/pages/admin/RoleGlyph';
import { buildPosition, payLabel } from '@/pages/admin/positionInsights';
@@ -272,6 +274,78 @@ function useViewMode() {
}];
}
/* ── 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.
*
@@ -428,9 +502,17 @@ function candidateCriteria(position) {
].filter(Boolean).join(' · ') || null;
}
/** One position. The whole card is the target, so there is no hunting for a link. */
function PositionCard({ position, onOpen }) {
/**
* One position. The whole card is the target, so there is no hunting for a link.
*
* A draft opens where a draft belongs — back in the form that was writing it —
* 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
@@ -443,11 +525,17 @@ function PositionCard({ position, onOpen }) {
onOpen(p);
}
}}
aria-label={`${p.title}. ${p.health.label}. ${p.stats.applied} applicants. ${p.insight}`}
className="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"
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">
@@ -469,6 +557,9 @@ function PositionCard({ position, onOpen }) {
<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>
@@ -516,11 +607,31 @@ function PositionCard({ position, onOpen }) {
? <><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 goes back to the form, not to 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">
View position
{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>
);
}
@@ -595,6 +706,12 @@ function PositionDrawer({ position, onClose, onViewCandidates, onOpenPosition })
</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. */}
@@ -603,6 +720,8 @@ function PositionDrawer({ position, onClose, onViewCandidates, onOpenPosition })
<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. */}
@@ -660,6 +779,8 @@ function PositionDrawer({ position, onClose, onViewCandidates, onOpenPosition })
<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">
@@ -712,6 +833,8 @@ function PositionDrawer({ position, onClose, onViewCandidates, onOpenPosition })
</section>
</div>
<SkillSurface page="positions" placement="after-candidates" context={{ position: p }} />
<section>
<SectionTitle title="Recent activity" />
{p.applications.length ? (
@@ -736,6 +859,8 @@ function PositionDrawer({ position, onClose, onViewCandidates, onOpenPosition })
<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>
);
@@ -767,6 +892,44 @@ export default function AdminPositions() {
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.
*
* The same authoring form, on the same route, told which record it is
* finishing. Not a second editor and not a copy of the values: the form loads
* the draft and saves back to it, so publishing from there is the one publish
* action there has ever been.
*/
const openPosition = (p) => (p.status === 'draft'
? navigate(`/admin/positions/new?draft=${encodeURIComponent(p.id)}`)
: 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.
@@ -858,30 +1021,37 @@ export default function AdminPositions() {
title="Positions"
subtitle="Manage open roles, hiring demand and position performance."
>
{/* Controls. Search gets a fixed, sensible measure — wide enough for a role
title, not so wide it becomes the page's subject. */}
<div className="flex flex-col gap-2 lg:flex-row lg:items-center">
<div className="w-full lg:w-80">
<SearchInput value={search} onChange={setSearch} placeholder="Search positions" size="sm" />
{/* 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>
<div className="flex flex-wrap items-center gap-2">
<FilterSelect value={status} onChange={setStatus} label="Status" options={[
{ value: 'all', label: 'All statuses' }, { 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>
{/* 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">
{/* View mode presentation toggle on far right */}
<div className="shrink-0 self-end lg:self-center">
<SegmentedToggle
variant="surface"
ariaLabel="View mode"
@@ -895,10 +1065,16 @@ export default function AdminPositions() {
</div>
</div>
<p className="text-caption text-ink-4">
<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">
@@ -911,11 +1087,16 @@ export default function AdminPositions() {
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} />
<PositionCard
key={p.id}
position={p}
onOpen={openPosition}
isRecent={recentIds.has(p.id)}
/>
))}
</div>
) : (
<PositionsTable rows={filtered} onOpen={setSelected} />
<PositionsTable rows={filtered} onOpen={openPosition} />
)
) : (
<EmptyState
@@ -933,6 +1114,8 @@ export default function AdminPositions() {
/>
)}
<SkillSurface page="positions" placement="after-position-list" className="mt-6" />
<PositionDrawer
position={selected}
onClose={() => setSelected(null)}
@@ -947,11 +1130,17 @@ export default function AdminPositions() {
);
}
/** A compact labelled select — the same control on every Admin management page. */
/** 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-[8.5rem] text-body-sm', className)}>
<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>