update owliver skill
This commit is contained in:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user