update owliver skill
This commit is contained in:
@@ -44,6 +44,7 @@ import AdminSettings from '@/pages/admin/Settings';
|
||||
import AdminWorkspace from '@/pages/admin/Workspace';
|
||||
import AdminWorkspaceSkills from '@/pages/admin/WorkspaceSkills';
|
||||
import AdminSkillEditor from '@/pages/admin/SkillEditor';
|
||||
import AdminOwliverSkillEditor from '@/pages/admin/OwliverSkillEditor';
|
||||
import AdminSkillDevelopment from '@/pages/admin/SkillDevelopment';
|
||||
import AdminLogin from '@/pages/admin/Login';
|
||||
import AdminRoute from '@/pages/admin/AdminRoute';
|
||||
@@ -110,7 +111,13 @@ const AuthenticatedApp = () => {
|
||||
<Route path="settings" element={<AdminSettings />} />
|
||||
<Route path="workspace" element={<AdminWorkspace />} />
|
||||
<Route path="workspace/skills" element={<AdminWorkspaceSkills />} />
|
||||
{/* Two editors, because a UI skill and an Owliver skill configure
|
||||
different things. Static segments rank above the dynamic `:id`,
|
||||
so `skills/owliver/...` cannot be read as a skill called
|
||||
"owliver" — and the two original addresses are untouched. */}
|
||||
<Route path="workspace/skills/new" element={<AdminSkillEditor />} />
|
||||
<Route path="workspace/skills/owliver/new" element={<AdminOwliverSkillEditor />} />
|
||||
<Route path="workspace/skills/owliver/:id" element={<AdminOwliverSkillEditor />} />
|
||||
<Route path="workspace/skills/:id" element={<AdminSkillEditor />} />
|
||||
<Route path="workspace/skill-development" element={<AdminSkillDevelopment />} />
|
||||
</Route>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import * as React from 'react';
|
||||
import { base44 } from '@/api/base44Client';
|
||||
import { resolveAssistantContext } from './placement';
|
||||
import { PageContextProvider } from './PageContext';
|
||||
|
||||
/**
|
||||
* Window state for the Owliver panel: open/collapsed and default/expanded.
|
||||
@@ -149,9 +150,12 @@ export function AssistantPanelProvider({ role, pathname, children }) {
|
||||
restore: () => setExpandedState(false),
|
||||
}), [context, supported, isOpen, isExpanded, width, setWidthState, setOpenState, setExpandedState]);
|
||||
|
||||
/* The page's own selection travels beside the window state, mounted here so
|
||||
both the page and the panel are inside it — a skill's data source resolves
|
||||
against the record the reader has open, whichever of the two is asking. */
|
||||
return (
|
||||
<AssistantPanelContext.Provider value={value}>
|
||||
{children}
|
||||
<PageContextProvider>{children}</PageContextProvider>
|
||||
</AssistantPanelContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import * as React from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Maximize2, Minimize2, PanelRightClose, RotateCcw, Sparkles } from 'lucide-react';
|
||||
import {
|
||||
ArrowLeft, History, Maximize2, Minimize2, PanelRightClose, RotateCcw, Sparkles, Trash2,
|
||||
} from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Surface } from '@/components/ds/Surface';
|
||||
import { IconButton } from '@/components/ds/IconButton';
|
||||
@@ -13,12 +15,171 @@ import { ROLE_CATEGORIES } from '@/lib/roleCategories';
|
||||
import { AddSkillDialog } from '@/components/skills/AddSkillDialog';
|
||||
import { runAction } from '@/lib/skills/actions';
|
||||
import { skillsForContext } from '@/lib/skills/registry';
|
||||
import { owliverSuggestions } from '@/lib/skills/owliverResolver';
|
||||
import { useWorkforcePaths } from '@/lib/skills/usePageSkills';
|
||||
import { profileForEmail } from '@/lib/skillGraph';
|
||||
import { groupByRecency } from './history';
|
||||
import { usePageContext } from './PageContext';
|
||||
import { useAssistantFacts, useConversation, useCurrentUserName } from './useAssistant';
|
||||
import { buildIntro, buildPrompts } from './dynamic';
|
||||
import { Message, ThinkingIndicator, TurnDivider } from './AssistantMessage';
|
||||
import { PromptInput } from './PromptInput';
|
||||
import { PromptChips } from './PromptChips';
|
||||
|
||||
/**
|
||||
* Owliver History — the conversations that came before.
|
||||
*
|
||||
* A list rather than a second panel: it replaces the thread in the body while
|
||||
* the header and composer stay exactly where they are, so History is a state of
|
||||
* the panel rather than a place you navigate to and have to find your way out
|
||||
* of. Grouped by when, because that is how people look for a conversation they
|
||||
* half-remember.
|
||||
*/
|
||||
function HistoryView({ groups, currentId, onOpen, onForget }) {
|
||||
if (!groups.length) {
|
||||
return (
|
||||
<div className="animate-fade-in">
|
||||
<p className="font-heading text-body font-semibold text-ink-1">No conversations yet</p>
|
||||
<p className="mt-1 text-body-sm leading-relaxed text-ink-3">
|
||||
Ask Owliver something and it will be here afterwards. History is kept on this device.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="animate-fade-in space-y-4">
|
||||
{groups.map((group) => (
|
||||
<section key={group.id} aria-label={group.label}>
|
||||
<h3 className="mb-1.5 text-[10px] font-semibold uppercase tracking-wide text-ink-4">
|
||||
{group.label}
|
||||
</h3>
|
||||
<ul className="space-y-1">
|
||||
{group.items.map((record) => (
|
||||
<li key={record.id} className="group/item relative">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onOpen(record)}
|
||||
aria-current={record.id === currentId ? 'true' : undefined}
|
||||
className={cn(
|
||||
`flex w-full items-start gap-2 rounded-lg border px-2.5 py-2 pr-8 text-left transition-colors
|
||||
focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-krow-blue/50`,
|
||||
record.id === currentId
|
||||
? 'border-krow-blue/40 bg-krow-blue-tint/60'
|
||||
: 'border-transparent hover:border-border hover:bg-surface-subtle'
|
||||
)}
|
||||
>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate text-body-sm font-medium text-ink-1">
|
||||
{record.title}
|
||||
</span>
|
||||
<span className="mt-0.5 block truncate text-[10px] text-ink-4">
|
||||
{[
|
||||
record.page,
|
||||
`${record.turns} question${record.turns === 1 ? '' : 's'}`,
|
||||
new Date(record.updatedAt).toLocaleTimeString(undefined, {
|
||||
hour: 'numeric', minute: '2-digit',
|
||||
}),
|
||||
].filter(Boolean).join(' · ')}
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{/* Destructive, so it stays out of the way until wanted rather
|
||||
than sitting beside every row waiting to be mis-clicked. */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onForget(record.id)}
|
||||
aria-label={`Delete conversation: ${record.title}`}
|
||||
className="absolute right-1.5 top-1/2 hidden -translate-y-1/2 rounded p-1.5 text-ink-4 transition-colors
|
||||
hover:text-destructive focus-visible:outline-none focus-visible:ring-2
|
||||
focus-visible:ring-krow-blue/50 group-hover/item:block group-focus-within/item:block"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Show the Back to Home row on scroll direction, not scroll position.
|
||||
*
|
||||
* Hidden while reading downwards, back the instant the user scrolls up — the
|
||||
* way out of a long answer should not be something you have to scroll all the
|
||||
* way to the top to reach. Direction is read off the panel's own scrolling
|
||||
* element (the body region below), never the window: the page behind the panel
|
||||
* does not move when the thread does.
|
||||
*
|
||||
* The listener is passive and rAF-throttled, and `setVisible` is only ever
|
||||
* called with a value that can change — React bails out on an identical one, so
|
||||
* a fast scroll costs at most one render per direction change.
|
||||
*
|
||||
* `pinToBottom` is the same follow-the-stream scroll the panel already did,
|
||||
* routed through here so a programmatic jump is not mistaken for the user
|
||||
* scrolling down and does not hide the control under them.
|
||||
*/
|
||||
function useDirectionalNav(scrollRef, { active, resetKey }) {
|
||||
const [visible, setVisible] = React.useState(true);
|
||||
const lastY = React.useRef(0);
|
||||
|
||||
/* Ignore sub-pixel and trackpad jitter, but nothing a deliberate scroll would
|
||||
produce: a real direction change clears this within one frame. */
|
||||
const NOISE = 4;
|
||||
|
||||
const pinToBottom = React.useCallback(() => {
|
||||
const el = scrollRef.current;
|
||||
if (!el) return;
|
||||
el.scrollTop = el.scrollHeight;
|
||||
/* Adopt the new position before the scroll event lands, so the next read
|
||||
sees no delta and the row keeps whatever state the user left it in. */
|
||||
lastY.current = el.scrollTop;
|
||||
}, [scrollRef]);
|
||||
|
||||
React.useEffect(() => {
|
||||
const el = scrollRef.current;
|
||||
/* Nothing to hide when the row is not rendered — and a fresh thread or a
|
||||
newly opened panel always starts with it showing. */
|
||||
setVisible(true);
|
||||
if (!el || !active) return undefined;
|
||||
|
||||
lastY.current = el.scrollTop;
|
||||
let frame = 0;
|
||||
|
||||
const read = () => {
|
||||
frame = 0;
|
||||
const y = el.scrollTop;
|
||||
if (y <= 0) {
|
||||
lastY.current = y;
|
||||
setVisible(true);
|
||||
return;
|
||||
}
|
||||
const delta = y - lastY.current;
|
||||
/* Leave `lastY` alone below the threshold so slow scrolls accumulate
|
||||
rather than being swallowed frame by frame. */
|
||||
if (Math.abs(delta) < NOISE) return;
|
||||
lastY.current = y;
|
||||
setVisible(delta < 0);
|
||||
};
|
||||
|
||||
const onScroll = () => {
|
||||
if (!frame) frame = requestAnimationFrame(read);
|
||||
};
|
||||
|
||||
el.addEventListener('scroll', onScroll, { passive: true });
|
||||
return () => {
|
||||
el.removeEventListener('scroll', onScroll);
|
||||
if (frame) cancelAnimationFrame(frame);
|
||||
};
|
||||
}, [scrollRef, active, resetKey]);
|
||||
|
||||
return { visible, pinToBottom };
|
||||
}
|
||||
|
||||
/**
|
||||
* Owliver — the dashboard's contextual panel.
|
||||
*
|
||||
@@ -88,8 +249,50 @@ export default function KrowAssistant({
|
||||
() => skillsForContext(context.id, disabledSkills, customSkills),
|
||||
[context.id, disabledSkills, customSkills]
|
||||
);
|
||||
|
||||
/**
|
||||
* What a declared skill reads from.
|
||||
*
|
||||
* The same collections `SkillSurface` hands the resolver on the page, plus
|
||||
* whatever record the page has published as open. One context, one resolver:
|
||||
* the card on the page and the answer in the panel are two readings of the
|
||||
* same data rather than two queries that happen to agree.
|
||||
*/
|
||||
const pageContext = usePageContext();
|
||||
const { statesFor } = useWorkforcePaths();
|
||||
const trainingPaths = React.useMemo(
|
||||
() => statesFor(facts.forge?.library || [], profileForEmail(facts.profiles || [], facts.user?.email)),
|
||||
[statesFor, facts.forge, facts.profiles, facts.user]
|
||||
);
|
||||
const { data: assignments = [] } = useAssignments();
|
||||
const skillContext = React.useMemo(() => ({
|
||||
...pageContext,
|
||||
applications: facts.applications || [],
|
||||
positions: facts.postings || [],
|
||||
interviews: facts.interviews || [],
|
||||
courses: facts.forge?.library || [],
|
||||
workerProfiles: facts.profiles || [],
|
||||
/* The same records under the workforce engine's name, and the commitments
|
||||
it reads availability from — so a source that scores candidates against a
|
||||
position resolves here exactly as it does on the page. */
|
||||
profiles: facts.profiles || [],
|
||||
assignments,
|
||||
staff: facts.staff || [],
|
||||
activity: facts.activity || [],
|
||||
trainingPaths,
|
||||
}), [pageContext, facts, assignments, trainingPaths]);
|
||||
const [addSkillOpen, setAddSkillOpen] = React.useState(false);
|
||||
|
||||
/**
|
||||
* Which face of the panel the body is showing.
|
||||
*
|
||||
* A state rather than a route: the header, the composer and the panel's width
|
||||
* are unchanged between the two, and only the scrolling region swaps. That is
|
||||
* also why there is always a way back — leaving History is a state change this
|
||||
* component owns, not a browser-history entry it has to hope exists.
|
||||
*/
|
||||
const [view, setView] = React.useState('chat');
|
||||
|
||||
/**
|
||||
* A skill asked for something to happen. The names come from the skill file;
|
||||
* `runAction` decides what they mean and refuses anything the file did not
|
||||
@@ -111,8 +314,8 @@ export default function KrowAssistant({
|
||||
* for a skill whose file does not, and then nothing is written.
|
||||
*/
|
||||
const createJob = useCreateJobPosting();
|
||||
const createPosition = React.useCallback(async (draft, skill) => {
|
||||
const result = runAction('create_position', { draft, skill });
|
||||
const createPosition = React.useCallback(async (draft, skill, status) => {
|
||||
const result = runAction('create_position', { draft, skill, status });
|
||||
if (result?.type !== 'create_position') return null;
|
||||
return createJob.mutateAsync(result.data);
|
||||
}, [createJob]);
|
||||
@@ -125,10 +328,13 @@ export default function KrowAssistant({
|
||||
* routing layer because this is where the app's data already is; routing stays
|
||||
* pure and testable.
|
||||
*/
|
||||
const { data: assignments = [] } = useAssignments();
|
||||
const workforce = React.useMemo(() => ({
|
||||
positions: facts.postings || [],
|
||||
currentPositionId: null,
|
||||
/* The position the page has open, so "who matches this position?" resolves
|
||||
against the record in front of the reader rather than asking which role
|
||||
they meant. A page with nothing open publishes nothing, and the question
|
||||
is answered by name or asked for, exactly as before. */
|
||||
currentPositionId: pageContext.position?.id || null,
|
||||
context: {
|
||||
profiles: facts.profiles || [],
|
||||
applications: facts.applications || [],
|
||||
@@ -136,7 +342,8 @@ export default function KrowAssistant({
|
||||
courses: facts.forge?.library || [],
|
||||
staff: facts.staff || [],
|
||||
},
|
||||
}), [facts.postings, facts.profiles, facts.applications, facts.forge, facts.staff, assignments]);
|
||||
}), [facts.postings, facts.profiles, facts.applications, facts.forge, facts.staff, assignments,
|
||||
pageContext.position?.id]);
|
||||
|
||||
/**
|
||||
* The workforce write, through the mutation the app already has.
|
||||
@@ -169,9 +376,13 @@ export default function KrowAssistant({
|
||||
[markInterviewReady]
|
||||
);
|
||||
|
||||
const { messages, pending, error, busy, send, stop, reset } = useConversation({
|
||||
const {
|
||||
messages, pending, error, busy, send, stop, reset,
|
||||
history, conversationId, openConversation, forgetConversation,
|
||||
} = useConversation({
|
||||
contextId: context.id,
|
||||
facts,
|
||||
pageLabel: context.page,
|
||||
onNavigate: goToPage,
|
||||
onAction: performAction,
|
||||
onCreatePosition: createPosition,
|
||||
@@ -183,6 +394,7 @@ export default function KrowAssistant({
|
||||
roles,
|
||||
skillCategories,
|
||||
courses: facts.forge?.library || [],
|
||||
skillContext,
|
||||
});
|
||||
|
||||
/* A block inside an answer asking the next question, in place. Same entry
|
||||
@@ -192,10 +404,37 @@ export default function KrowAssistant({
|
||||
if (!busy) send({ question });
|
||||
}, [busy, send]);
|
||||
|
||||
const historyGroups = React.useMemo(() => groupByRecency(history), [history]);
|
||||
|
||||
/**
|
||||
* Home is the empty panel: greeting, page fact, suggestions.
|
||||
*
|
||||
* One handler for every way back — the History header button, the Back to
|
||||
* Home control, and reopening after reading an old thread — so "home" cannot
|
||||
* mean two slightly different states depending on how you got there. Nothing
|
||||
* is lost: the thread being left is already archived.
|
||||
*/
|
||||
const goHome = React.useCallback(() => {
|
||||
setView('chat');
|
||||
reset();
|
||||
}, [reset]);
|
||||
|
||||
const openHistoryItem = React.useCallback((record) => {
|
||||
openConversation(record);
|
||||
setView('chat');
|
||||
}, [openConversation]);
|
||||
|
||||
const [input, setInput] = React.useState('');
|
||||
const scrollRef = React.useRef(null);
|
||||
const isEmpty = messages.length === 0 && !pending;
|
||||
|
||||
/* The Back to Home row only exists in the states that are not already home. */
|
||||
const showBackRow = view === 'history' || messages.length > 0 || Boolean(pending);
|
||||
const { visible: backVisible, pinToBottom } = useDirectionalNav(scrollRef, {
|
||||
active: showBackRow,
|
||||
resetKey: `${view}:${conversationId || ''}`,
|
||||
});
|
||||
|
||||
/* Greeting and suggestions come from live data, so they recompute only when
|
||||
the data or the page actually changes. */
|
||||
const intro = React.useMemo(
|
||||
@@ -208,19 +447,46 @@ export default function KrowAssistant({
|
||||
const followUp = messages[messages.length - 1]?.followUp;
|
||||
const prompts = React.useMemo(() => {
|
||||
if (followUp?.length) return followUp;
|
||||
/* A definition's own suggestions come first: they are the only ones written
|
||||
for this workspace rather than derived from the page, and they are capped
|
||||
by the resolver so a workspace with several skills attached cannot bury
|
||||
the page's own. */
|
||||
const declared = owliverSuggestions(context.id, disabledSkills, customSkills, pageContext);
|
||||
const skillPrompts = skills
|
||||
.filter((s) => s.prompt)
|
||||
.map((s) => ({ label: s.prompt, prompt: s.prompt }));
|
||||
return [...skillPrompts, ...buildPrompts(context.id, facts, workforce)];
|
||||
}, [followUp, skills, context.id, facts, workforce]);
|
||||
|
||||
/* Three sources can propose the same question — a skill's declared
|
||||
suggestion and the page's own derived one often word it identically —
|
||||
and two chips reading "Summarize hiring activity" is both a duplicate
|
||||
React key and a duplicate offer. First wins, so the definition's own
|
||||
wording survives and the derived copy drops out. */
|
||||
/* A declared suggestion that cannot answer yet — it reads one record and
|
||||
none is selected — ranks behind the page's own offers rather than
|
||||
leading with a question. The lifecycle reads correctly either way:
|
||||
before a position exists the page's actions lead; once one is open or
|
||||
has just been created, the readings about it come first. */
|
||||
const ready = declared.filter((c) => !c.deferred);
|
||||
const asking = declared.filter((c) => c.deferred);
|
||||
|
||||
const seen = new Set();
|
||||
return [...ready, ...skillPrompts, ...buildPrompts(context.id, facts, workforce), ...asking]
|
||||
.filter((chip) => {
|
||||
const key = String(chip?.label ?? '').trim().toLowerCase();
|
||||
if (!key || seen.has(key)) return false;
|
||||
seen.add(key);
|
||||
return true;
|
||||
});
|
||||
/* `pageContext` decides which suggestions can answer without asking, so the
|
||||
chips re-rank when a position is opened or closed. */
|
||||
}, [followUp, skills, context.id, disabledSkills, customSkills, facts, workforce, pageContext]);
|
||||
|
||||
/* Follow the newest content. Direct `scrollTop` rather than smooth scrolling:
|
||||
at streaming frequency a smooth scroll never catches up and the thread
|
||||
visibly lags the text. */
|
||||
React.useEffect(() => {
|
||||
const el = scrollRef.current;
|
||||
if (el) el.scrollTop = el.scrollHeight;
|
||||
}, [messages, pending]);
|
||||
pinToBottom();
|
||||
}, [messages, pending, pinToBottom]);
|
||||
|
||||
/**
|
||||
* The one entry point for every message, whatever raised it.
|
||||
@@ -233,6 +499,9 @@ export default function KrowAssistant({
|
||||
const text = String(question).trim();
|
||||
if (!text || busy) return;
|
||||
setInput('');
|
||||
/* Asking something while reading History returns to the conversation — the
|
||||
answer is about to arrive there, and leaving the list up would hide it. */
|
||||
setView('chat');
|
||||
send({ question: text, capability });
|
||||
}, [busy, send]);
|
||||
|
||||
@@ -271,7 +540,7 @@ export default function KrowAssistant({
|
||||
padding="none"
|
||||
elevation="sm"
|
||||
data-wide={expanded || undefined}
|
||||
className={cn('flex flex-col overflow-hidden', className)}
|
||||
className={cn('relative flex flex-col overflow-hidden', className)}
|
||||
role="region"
|
||||
aria-label="Owliver assistant"
|
||||
>
|
||||
@@ -297,7 +566,19 @@ export default function KrowAssistant({
|
||||
size="sm"
|
||||
onClick={() => setAddSkillOpen(true)}
|
||||
/>
|
||||
{messages.length > 0 && (
|
||||
{/* History lives with the other window controls rather than in the
|
||||
body, so the layout of the panel is unchanged whether or not
|
||||
there is anything to show. It toggles: pressing it again returns
|
||||
to the conversation you were reading. */}
|
||||
<IconButton
|
||||
icon={History}
|
||||
label={view === 'history' ? 'Back to the conversation' : 'Owliver history'}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
aria-pressed={view === 'history'}
|
||||
onClick={() => setView((v) => (v === 'history' ? 'chat' : 'history'))}
|
||||
/>
|
||||
{messages.length > 0 && view === 'chat' && (
|
||||
<IconButton icon={RotateCcw} label="New conversation" variant="ghost" size="sm" onClick={reset} />
|
||||
)}
|
||||
{expanded
|
||||
@@ -313,14 +594,53 @@ export default function KrowAssistant({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Floating directional Back to Home row — reveals on UP-scroll, hides on DOWN-scroll */}
|
||||
{showBackRow && (
|
||||
<div
|
||||
className={cn(
|
||||
`absolute top-[3.25rem] left-0 right-0 z-20 flex items-center justify-between gap-2
|
||||
border-b border-border bg-white/95 dark:bg-slate-900/95 px-4 py-2 shadow-sm backdrop-blur-md
|
||||
transition-all duration-200 ease-out motion-reduce:transition-none`,
|
||||
backVisible
|
||||
? 'translate-y-0 opacity-100 pointer-events-auto'
|
||||
: '-translate-y-full opacity-0 pointer-events-none'
|
||||
)}
|
||||
aria-hidden={backVisible ? undefined : 'true'}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={goHome}
|
||||
tabIndex={backVisible ? undefined : -1}
|
||||
className="inline-flex items-center gap-1.5 rounded text-caption font-semibold text-ink-1 dark:text-white transition-colors
|
||||
hover:text-krow-blue focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-krow-blue/50 cursor-pointer"
|
||||
>
|
||||
<ArrowLeft className="h-3.5 w-3.5 text-krow-blue" aria-hidden="true" />
|
||||
<span>Back to Home</span>
|
||||
</button>
|
||||
<span className="truncate text-[11px] font-medium text-ink-3">
|
||||
{view === 'history'
|
||||
? `${history.length} conversation${history.length === 1 ? '' : 's'}`
|
||||
: context.page}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Body: the only region that scrolls ─────────────────────────── */}
|
||||
<div
|
||||
ref={scrollRef}
|
||||
className="min-h-0 flex-1 overflow-y-auto px-4 py-3.5"
|
||||
className="min-h-0 flex-1 overflow-y-auto overflow-x-hidden px-4 py-3.5"
|
||||
aria-live="polite"
|
||||
aria-relevant="additions text"
|
||||
>
|
||||
{isEmpty ? (
|
||||
|
||||
{view === 'history' ? (
|
||||
<HistoryView
|
||||
groups={historyGroups}
|
||||
currentId={conversationId}
|
||||
onOpen={openHistoryItem}
|
||||
onForget={forgetConversation}
|
||||
/>
|
||||
) : isEmpty ? (
|
||||
/* Greeting and the single most relevant fact about this page. Top
|
||||
aligned rather than centred: it is the first thing in a
|
||||
conversation, not a splash screen, so it belongs where the first
|
||||
@@ -373,7 +693,7 @@ export default function KrowAssistant({
|
||||
Follow-ups are never capped: when Owliver has asked a question, its
|
||||
chips *are* the answers, and hiding three of the six roles would make
|
||||
the flow look broken. */}
|
||||
{!busy && (
|
||||
{!busy && view === 'chat' && (
|
||||
<PromptChips
|
||||
prompts={prompts}
|
||||
onSelect={runPrompt}
|
||||
|
||||
154
src/components/ai-assistant/PageContext.jsx
Normal file
154
src/components/ai-assistant/PageContext.jsx
Normal file
@@ -0,0 +1,154 @@
|
||||
import * as React from 'react';
|
||||
|
||||
/**
|
||||
* What the page is currently looking at, published to the panel beside it.
|
||||
*
|
||||
* A skill's data source declares what it needs — a position, a candidate, the
|
||||
* position being specified — and the page is the only thing that knows which
|
||||
* one is open. Without this, Owliver could read the collections but not the
|
||||
* *selection*: it would answer about the workspace when it was asked about the
|
||||
* role in the drawer.
|
||||
*
|
||||
* The channel is deliberately narrow. A page publishes records it already has;
|
||||
* it does not publish components, callbacks or view state, and nothing here
|
||||
* reaches back into the page. That keeps the coupling one-way — a page that
|
||||
* publishes nothing simply has no selection, and every skill whose source needs
|
||||
* one says so instead of guessing.
|
||||
*
|
||||
* usePublishPageContext({ position }); // in the page
|
||||
* const { position } = usePageContext(); // in the panel
|
||||
*
|
||||
* Kept out of `AssistantPanelContext` on purpose: that context is the panel's
|
||||
* window state, changing when someone drags a divider. This changes when the
|
||||
* reader opens a record, and merging the two would re-render one for the other.
|
||||
*/
|
||||
|
||||
const PageContext = React.createContext(null);
|
||||
|
||||
export function PageContextProvider({ children }) {
|
||||
const [entity, setEntity] = React.useState(null);
|
||||
|
||||
/**
|
||||
* The writes a page is willing to accept, keyed by data source.
|
||||
*
|
||||
* The read channel above is serialized state; this cannot be — a handler is a
|
||||
* function, closing over the page's own setter. So it lives in a ref, and only
|
||||
* the *set of source ids* is state. That distinction is the whole design:
|
||||
* consumers re-render when a page starts or stops accepting a write, not every
|
||||
* time the page re-renders and hands over a new closure.
|
||||
*
|
||||
* Keyed by source id rather than by an invented action name, because the
|
||||
* source is already the validated binding between a definition and its data.
|
||||
* A skill therefore cannot write anywhere it could not read, and a page cannot
|
||||
* accept a write for a reading it does not own.
|
||||
*/
|
||||
const handlersRef = React.useRef({});
|
||||
const [writable, setWritable] = React.useState([]);
|
||||
|
||||
const publishActions = React.useCallback((handlers) => {
|
||||
handlersRef.current = handlers || {};
|
||||
const next = Object.keys(handlersRef.current).sort();
|
||||
/* Only a change in *what* is writable is worth a render. */
|
||||
setWritable((prev) => (prev.join('|') === next.join('|') ? prev : next));
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Runs a page's handler for a source. Returns false when the page is not
|
||||
* accepting that write, so a caller can stay read-only rather than pretending
|
||||
* the change landed.
|
||||
*/
|
||||
const runAction = React.useCallback((source, payload) => {
|
||||
const handler = handlersRef.current[source];
|
||||
if (typeof handler !== 'function') return false;
|
||||
handler(payload);
|
||||
return true;
|
||||
}, []);
|
||||
|
||||
const value = React.useMemo(
|
||||
() => ({ entity, setEntity, publishActions, runAction, writable }),
|
||||
[entity, publishActions, runAction, writable]
|
||||
);
|
||||
|
||||
return <PageContext.Provider value={value}>{children}</PageContext.Provider>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Publishes this page's current selection for as long as it is mounted.
|
||||
*
|
||||
* Keyed on the content rather than the object, because a page rebuilds its
|
||||
* records on every render and an identity-keyed effect would publish in a loop.
|
||||
* Unmounting clears the selection, so leaving a page cannot leave Owliver
|
||||
* answering about a record nobody is looking at.
|
||||
*/
|
||||
export function usePublishPageContext(entity) {
|
||||
const context = React.useContext(PageContext);
|
||||
const setEntity = context?.setEntity;
|
||||
|
||||
/* `undefined` values would vanish from the serialized key, so a selection
|
||||
being cleared has to read as an explicit null. */
|
||||
const key = React.useMemo(() => {
|
||||
try {
|
||||
return entity ? JSON.stringify(entity) : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}, [entity]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!setEntity) return undefined;
|
||||
setEntity(key ? JSON.parse(key) : null);
|
||||
return () => setEntity(null);
|
||||
}, [key, setEntity]);
|
||||
}
|
||||
|
||||
/**
|
||||
* The records the page has published. Always an object, so a consumer can read
|
||||
* `position` without checking whether anything published at all.
|
||||
*/
|
||||
export function usePageContext() {
|
||||
return React.useContext(PageContext)?.entity || {};
|
||||
}
|
||||
|
||||
/**
|
||||
* Offers this page's writes for as long as it is mounted.
|
||||
*
|
||||
* Pass a memoized map of `{ [sourceId]: handler }`. Handlers should be stable —
|
||||
* a functional `setState` closes over nothing, which is what lets the map be
|
||||
* memoized once rather than rebuilt as the form is typed into.
|
||||
*
|
||||
* const applyWeights = useCallback(
|
||||
* (next) => setForm((f) => ({ ...f, vetting_criteria: next })), []);
|
||||
* usePublishPageActions(useMemo(
|
||||
* () => ({ 'position.vetting': applyWeights }), [applyWeights]));
|
||||
*
|
||||
* Unpublished on unmount, so leaving a page cannot leave a control on screen
|
||||
* that writes into a form nobody is looking at.
|
||||
*/
|
||||
export function usePublishPageActions(handlers) {
|
||||
const publishActions = React.useContext(PageContext)?.publishActions;
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!publishActions) return undefined;
|
||||
publishActions(handlers);
|
||||
return () => publishActions(null);
|
||||
}, [publishActions, handlers]);
|
||||
}
|
||||
|
||||
/**
|
||||
* How a section writes back, for one source — or `null` when nothing on this
|
||||
* page is accepting that write.
|
||||
*
|
||||
* Returning null rather than a no-op is deliberate: a renderer checks it to
|
||||
* decide whether to draw controls at all, so a section declared `editable` on a
|
||||
* page that does not own the data stays an honest read-out.
|
||||
*/
|
||||
export function usePageAction(source) {
|
||||
const context = React.useContext(PageContext);
|
||||
const accepted = Boolean(source) && (context?.writable || []).includes(source);
|
||||
const runAction = context?.runAction;
|
||||
|
||||
return React.useMemo(
|
||||
() => (accepted && runAction ? (payload) => runAction(source, payload) : null),
|
||||
[accepted, runAction, source]
|
||||
);
|
||||
}
|
||||
@@ -44,7 +44,10 @@ export function PromptChips({ prompts = [], onSelect, max, align = 'center', cla
|
||||
>
|
||||
{visible.map((prompt, i) => (
|
||||
<button
|
||||
key={prompt.label}
|
||||
/* Position, not label. Callers de-duplicate, but a follow-up list is
|
||||
built per answer and two chips can legitimately read the same —
|
||||
the key has to stay unique either way. */
|
||||
key={`${i}:${prompt.label}`}
|
||||
ref={(el) => { chipRefs.current[i] = el; }}
|
||||
type="button"
|
||||
onClick={() => onSelect(prompt)}
|
||||
|
||||
@@ -6,6 +6,10 @@ import {
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { ProgressBar, toneForScore } from '@/components/ds/Progress';
|
||||
import { SECTION_COMPONENTS } from '@/components/skills/SkillSections';
|
||||
import { useSkillDataContext } from '@/components/skills/SkillSurface';
|
||||
import { resolveSkillData } from '@/lib/skills/dataResolver';
|
||||
import { usePageAction } from './PageContext';
|
||||
|
||||
/**
|
||||
* Renderers for response blocks.
|
||||
@@ -441,8 +445,55 @@ const NoteBlock = React.memo(({ block }) => (
|
||||
));
|
||||
NoteBlock.displayName = 'NoteBlock';
|
||||
|
||||
/**
|
||||
* A section a skill declared, drawn by the component the page uses for it.
|
||||
*
|
||||
* There is no chat-specific renderer for a skill's shapes, and there must not
|
||||
* be: `SECTION_COMPONENTS` is the single table from a declared type to a
|
||||
* component, and this block goes through it exactly as `SkillSurface` does. A
|
||||
* definition that gains a new shape gains it in both places at once, and a
|
||||
* shape the table does not carry renders nothing rather than something
|
||||
* improvised.
|
||||
*/
|
||||
const SkillSectionBlock = React.memo(({ block }) => {
|
||||
const section = block.section;
|
||||
const Component = SECTION_COMPONENTS[section?.shape || section?.type];
|
||||
|
||||
/**
|
||||
* A settable answer is re-read, never replayed.
|
||||
*
|
||||
* An ordinary answer is a record of what was true when it was given, and the
|
||||
* stored blocks are exactly right for that. A section offering *controls* is
|
||||
* different: it is a live view of a value the reader can still change, from
|
||||
* here or on the page behind the panel. Replaying the stored copy would show
|
||||
* a weight of 25% next to a form that now says 30%, and the control would
|
||||
* write the stale figure back.
|
||||
*
|
||||
* So an editable section resolves from the current context on every render,
|
||||
* through the same resolver the page's own card uses. There is one value and
|
||||
* one place it lives; this is a second window onto it, not a second copy.
|
||||
*/
|
||||
const live = Boolean(section?.editable);
|
||||
const context = useSkillDataContext(null);
|
||||
const data = React.useMemo(
|
||||
() => (live && section ? resolveSkillData(section, context) : block.data),
|
||||
[live, section, context, block.data]
|
||||
);
|
||||
const apply = usePageAction(live ? section?.source : null);
|
||||
|
||||
if (!Component) return null;
|
||||
|
||||
return (
|
||||
<div className="rounded-xl border border-border bg-surface-subtle/60 p-3">
|
||||
<Component data={data} section={section} onApply={apply} compact />
|
||||
</div>
|
||||
);
|
||||
});
|
||||
SkillSectionBlock.displayName = 'SkillSectionBlock';
|
||||
|
||||
const RENDERERS = {
|
||||
text: TextBlock,
|
||||
skillSection: SkillSectionBlock,
|
||||
heading: HeadingBlock,
|
||||
kpis: KpisBlock,
|
||||
status: StatusBlock,
|
||||
|
||||
@@ -72,6 +72,21 @@ export const badges = (items) =>
|
||||
/** Chronology. `items: [{ title, description?, timestamp?, tone?, current? }]` */
|
||||
export const timeline = (items) => items?.length && { type: 'timeline', items };
|
||||
|
||||
/**
|
||||
* A section a skill definition declared, drawn in the panel.
|
||||
*
|
||||
* The one block whose shape is not decided here: `section` is the normalized
|
||||
* record the registry produced from a `.md` file, and `data` is what the shared
|
||||
* resolver read for it. The renderer hands both to the same component the page
|
||||
* mounts, so a flow asked for in conversation *is* the page's flow rather than
|
||||
* a chat-shaped imitation of it.
|
||||
*
|
||||
* Both halves are plain data, so a reply containing one still serializes into
|
||||
* the stored thread like every other block.
|
||||
*/
|
||||
export const skillSection = (section, data) =>
|
||||
section && data && { type: 'skillSection', section, data };
|
||||
|
||||
/** A caveat. Always the last word on a claim, never the headline. */
|
||||
export const note = (value) => value && { type: 'note', text: value };
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import {
|
||||
Activity, AlertTriangle, ArrowLeftRight, Award, BellRing, Clock, Eye, Filter, Gauge,
|
||||
Activity, AlertTriangle, ArrowLeftRight, Award, BellRing, Clock, Eye, FileText, Filter, Gauge,
|
||||
GitBranch, Layers, Lightbulb, Lock, ScrollText, ShieldAlert, ShieldCheck, Star, Target,
|
||||
TrendingUp, UserCheck, Users,
|
||||
} from 'lucide-react';
|
||||
@@ -962,6 +962,41 @@ const positionsNeedingAttention = (f) => {
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Positions that were saved but never published.
|
||||
*
|
||||
* A draft is a real record with real values on it and no way for anyone to
|
||||
* apply to it, which makes it the one thing on this page that is genuinely
|
||||
* unfinished. Each one is offered with the control that finishes it: the same
|
||||
* authoring form, told which draft it is continuing, with everything already
|
||||
* entered still in place.
|
||||
*
|
||||
* Read from `postings` by status — no list of drafts is kept anywhere, and a
|
||||
* position stops appearing here the moment it is published.
|
||||
*/
|
||||
const positionDrafts = (f) => {
|
||||
const drafts = (f.postings || []).filter((p) => p.status === 'draft');
|
||||
|
||||
if (!drafts.length) {
|
||||
return answer('Nothing is sitting in draft — every position in the workspace has been published or closed.');
|
||||
}
|
||||
|
||||
return doc(
|
||||
heading(`${plural(drafts.length, 'position')} still in draft`, 'Saved, but not published'),
|
||||
insights(drafts.map((p) => ({
|
||||
tone: 'warning',
|
||||
title: p.title || 'Untitled position',
|
||||
body: [
|
||||
p.company,
|
||||
p.location,
|
||||
'Not published yet, so nobody can apply and no screening runs.',
|
||||
].filter(Boolean).join(' · '),
|
||||
action: { label: 'Continue to save', to: `/admin/positions/new?draft=${encodeURIComponent(p.id)}` },
|
||||
}))),
|
||||
note('Continuing reopens the same form with everything already entered — the vetting weights included. Publishing it there is what makes it an active position.')
|
||||
);
|
||||
};
|
||||
|
||||
/** Which role to fill first. */
|
||||
const hiringPriority = (f) => {
|
||||
const rows = f.byRole
|
||||
@@ -1259,6 +1294,7 @@ export const respondControlCenter = (question, f) => {
|
||||
|
||||
export const respondPositions = (question, f) => {
|
||||
const q = question.toLowerCase();
|
||||
if (has(q, 'draft', 'unfinished', 'not published', 'continue')) return positionDrafts(f);
|
||||
if (has(q, 'attention', 'urgent', 'problem', 'wrong', 'risk')) return positionsNeedingAttention(f);
|
||||
if (has(q, 'first', 'priorit', 'which position', 'should i hire')) return hiringPriority(f);
|
||||
if (has(q, 'bottleneck', 'funnel', 'drop', 'conversion')) return pipelineHealth(f);
|
||||
@@ -1317,6 +1353,7 @@ export const CONTROL_CENTER_CAPABILITIES = [
|
||||
];
|
||||
|
||||
export const POSITIONS_CAPABILITIES = [
|
||||
{ id: 'position-drafts', label: 'Unfinished Drafts', icon: FileText, run: positionDrafts },
|
||||
{ id: 'position-strength', label: 'Pipeline Strength', icon: TrendingUp, run: positionStrength },
|
||||
{ id: 'positions-attention', label: 'Needs Attention', icon: BellRing, run: positionsNeedingAttention },
|
||||
{ id: 'hiring-priority', label: 'What To Fill First', icon: Target, run: hiringPriority },
|
||||
@@ -1935,3 +1972,164 @@ export const PROFILE_CAPABILITIES = [
|
||||
{ id: 'profile-activity', label: 'My Recent Activity', icon: ScrollText, run: profileActivity },
|
||||
{ id: 'profile-actions', label: 'What I Can Do Here', icon: Lightbulb, run: profileActions },
|
||||
];
|
||||
|
||||
/* ── Create Position ─────────────────────────────────────────────────────
|
||||
The authoring surface. Every answer here is about the role being specified,
|
||||
read against what this workspace has already posted — so the guidance is the
|
||||
deployment's own history rather than an industry number written into a file. */
|
||||
|
||||
/** The average of a numeric field across postings that state it. */
|
||||
const across = (postings, read) => {
|
||||
const values = postings.map(read).filter((n) => Number.isFinite(n) && n > 0);
|
||||
return values.length ? Math.round(values.reduce((a, b) => a + b, 0) / values.length) : 0;
|
||||
};
|
||||
|
||||
/**
|
||||
* What the five vetting criteria mean, and how this workspace usually weights
|
||||
* them.
|
||||
*
|
||||
* The typical column is counted from the positions already posted, so it says
|
||||
* what this operator does rather than what a template suggests.
|
||||
*/
|
||||
const vettingWeights = (f) => {
|
||||
const posted = f.postings.filter((p) => p.vetting_criteria);
|
||||
const keys = ['experience', 'english', 'reliability', 'certifications', 'availability'];
|
||||
const meaning = {
|
||||
experience: 'Years in the role, weighted against the minimum this position asks for',
|
||||
english: 'Spoken level, checked in the AI interview',
|
||||
reliability: 'Attendance and completion history on previous assignments',
|
||||
certifications: 'Whether the credentials this role requires are held and current',
|
||||
availability: 'How much of the shift pattern the candidate can actually cover',
|
||||
};
|
||||
|
||||
if (!posted.length) {
|
||||
return doc(
|
||||
heading('AI vetting weights', 'What each criterion scores'),
|
||||
list(keys.map((k) => `**${CRITERIA_LABELS[k]}** — ${meaning[k]}`)),
|
||||
note('The five must total 100%. No position has been posted yet, so there is no house pattern to compare against.')
|
||||
);
|
||||
}
|
||||
|
||||
const typical = keys.map((key) => ({
|
||||
criterion: CRITERIA_LABELS[key],
|
||||
typical: `${across(posted, (p) => Number(p.vetting_criteria?.[key]))}%`,
|
||||
scores: meaning[key],
|
||||
}));
|
||||
|
||||
return doc(
|
||||
heading('AI vetting weights', `Typical across ${plural(posted.length, 'posted position')}`),
|
||||
table(
|
||||
[
|
||||
{ key: 'criterion', label: 'Criterion' },
|
||||
{ key: 'typical', label: 'Usually', align: 'right' },
|
||||
{ key: 'scores', label: 'What it scores' },
|
||||
],
|
||||
typical
|
||||
),
|
||||
note('These are this workspace’s own averages, not a recommendation. The five weights on this form must total 100%.')
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* What comparable roles in this workspace ask for.
|
||||
*
|
||||
* Grouped by role category, which is the only comparison the data supports —
|
||||
* pay and experience are not transferable between a chef and a security post.
|
||||
*/
|
||||
const positionBenchmarks = (f) => {
|
||||
const posted = f.postings.filter((p) => p.role_category);
|
||||
if (!posted.length) {
|
||||
return doc(
|
||||
text('No positions have been posted yet, so there is nothing to benchmark this one against.'),
|
||||
note('Once a few roles exist I can compare pay, experience and requirements by category.')
|
||||
);
|
||||
}
|
||||
|
||||
const byCategory = new Map();
|
||||
posted.forEach((p) => {
|
||||
const entry = byCategory.get(p.role_category) || { category: p.role_category, list: [] };
|
||||
entry.list.push(p);
|
||||
byCategory.set(p.role_category, entry);
|
||||
});
|
||||
|
||||
const rows = [...byCategory.values()]
|
||||
.sort((a, b) => b.list.length - a.list.length)
|
||||
.slice(0, 6)
|
||||
.map(({ category, list: roles }) => ({
|
||||
category,
|
||||
posted: roles.length,
|
||||
pay: across(roles, (p) => Number(p.pay_range_min))
|
||||
? `$${across(roles, (p) => Number(p.pay_range_min))}–${across(roles, (p) => Number(p.pay_range_max))}/hr`
|
||||
: '—',
|
||||
experience: `${across(roles, (p) => Number(p.min_experience_years))} yrs`,
|
||||
}));
|
||||
|
||||
return doc(
|
||||
heading('What comparable roles ask for', `${plural(posted.length, 'position')} on file`),
|
||||
table(
|
||||
[
|
||||
{ key: 'category', label: 'Category' },
|
||||
{ key: 'posted', label: 'Posted', align: 'right' },
|
||||
{ key: 'pay', label: 'Pay', align: 'right' },
|
||||
{ key: 'experience', label: 'Min experience', align: 'right' },
|
||||
],
|
||||
rows
|
||||
),
|
||||
note('Averages across positions already posted in this workspace.')
|
||||
);
|
||||
};
|
||||
|
||||
/** The credentials this workspace actually asks for, ranked by how often. */
|
||||
const positionRequirements = (f) => {
|
||||
const counts = new Map();
|
||||
f.postings.forEach((p) => {
|
||||
(p.certifications_required || []).forEach((c) => counts.set(c, (counts.get(c) || 0) + 1));
|
||||
});
|
||||
|
||||
if (!counts.size) {
|
||||
return doc(
|
||||
text('No position on file requires a certification, so there is no pattern to follow here.'),
|
||||
note('Add only the credentials this role genuinely needs — every one narrows the pool.')
|
||||
);
|
||||
}
|
||||
|
||||
const ranked = [...counts.entries()].sort((a, b) => b[1] - a[1]).slice(0, 8);
|
||||
return doc(
|
||||
heading('Credentials this workspace asks for', `${plural(counts.size, 'certification')} in use`),
|
||||
meters(ranked.map(([label, count]) => ({
|
||||
label,
|
||||
value: count,
|
||||
max: f.postings.length || count,
|
||||
}))),
|
||||
note('Ranked by how many positions require them. Each requirement narrows the pool it is added to.')
|
||||
);
|
||||
};
|
||||
|
||||
/** How the form's own steps fit together, for someone new to it. */
|
||||
const positionSpecSteps = () => doc(
|
||||
heading('Specifying a position'),
|
||||
list([
|
||||
'Role category and job title — what the matching engine files this under',
|
||||
'Client, requirements and pay — what the posting states',
|
||||
'Certifications and required skills — what a person must hold',
|
||||
'Generate Job Description with AI — writes the prose from the fields above',
|
||||
'AI vetting weights — how the screening score is composed, totalling 100%',
|
||||
], { ordered: true }),
|
||||
note('Nothing is posted until you choose Save as Draft or Publish.')
|
||||
);
|
||||
|
||||
export const respondCreatePosition = (question, f) => {
|
||||
const q = question.toLowerCase();
|
||||
if (has(q, 'weight', 'vetting', 'criteria', 'scoring', 'score')) return vettingWeights(f);
|
||||
if (has(q, 'certification', 'credential', 'requirement', 'skill')) return positionRequirements(f);
|
||||
if (has(q, 'pay', 'rate', 'salary', 'benchmark', 'compare', 'experience', 'typical')) return positionBenchmarks(f);
|
||||
if (has(q, 'how do i', 'step', 'what do i', 'fill', 'form', 'explain')) return positionSpecSteps(f);
|
||||
return positionSpecSteps(f);
|
||||
};
|
||||
|
||||
export const CREATE_POSITION_CAPABILITIES = [
|
||||
{ id: 'vetting-weights', label: 'Explain Vetting Weights', icon: Gauge, run: vettingWeights },
|
||||
{ id: 'position-benchmarks', label: 'Compare With Similar Roles', icon: ArrowLeftRight, run: positionBenchmarks },
|
||||
{ id: 'position-requirements', label: 'Credentials In Use', icon: ShieldCheck, run: positionRequirements },
|
||||
{ id: 'position-spec-steps', label: 'How This Form Works', icon: Lightbulb, run: positionSpecSteps },
|
||||
];
|
||||
|
||||
@@ -4,11 +4,13 @@ import {
|
||||
} from './capabilities/employer';
|
||||
import {
|
||||
ACTIVITY_CAPABILITIES, ADMIN_ANALYTICS_CAPABILITIES, ADMIN_CANDIDATE_CAPABILITIES,
|
||||
CANDIDATE_LIST_CAPABILITIES, CONTROL_CENTER_CAPABILITIES, FORGE_CAPABILITIES,
|
||||
CANDIDATE_LIST_CAPABILITIES, CONTROL_CENTER_CAPABILITIES, CREATE_POSITION_CAPABILITIES,
|
||||
FORGE_CAPABILITIES,
|
||||
HIRED_HISTORY_CAPABILITIES, POSITIONS_CAPABILITIES, PROFILE_CAPABILITIES,
|
||||
TALENT_POOL_CAPABILITIES,
|
||||
respondActivity, respondAdminAnalytics, respondAdminCandidates, respondCandidateList,
|
||||
respondControlCenter, respondForge, respondHiredHistory, respondPositions,
|
||||
respondControlCenter, respondCreatePosition, respondForge, respondHiredHistory,
|
||||
respondPositions,
|
||||
respondProfile, respondTalentPool,
|
||||
} from './capabilities/admin';
|
||||
|
||||
@@ -61,6 +63,19 @@ export const ASSISTANT_CONTEXTS = {
|
||||
capabilities: POSITIONS_CAPABILITIES,
|
||||
respond: respondPositions,
|
||||
},
|
||||
/* Specifying a role is a different question from managing the ones that
|
||||
exist, so Create Position is its own context rather than Positions with a
|
||||
form on it. Its topics are the form's own fields. */
|
||||
'admin.createPosition': {
|
||||
id: 'admin.createPosition',
|
||||
page: 'Create Position',
|
||||
topics: ['weight', 'vetting', 'criteria', 'scoring', 'score', 'certification', 'credential',
|
||||
'requirement', 'skill', 'pay', 'rate', 'salary', 'benchmark', 'compare', 'experience',
|
||||
'typical', 'position', 'role', 'job description', 'form', 'field', 'step', 'how do i',
|
||||
'what do i', 'explain', 'summar', 'flow'],
|
||||
capabilities: CREATE_POSITION_CAPABILITIES,
|
||||
respond: respondCreatePosition,
|
||||
},
|
||||
/* The Candidates list and Candidates Analysis are separate contexts because they
|
||||
ask different questions of the same records: the list is triage — who needs a
|
||||
decision — and Analysis reads the pool as a whole. */
|
||||
|
||||
@@ -85,6 +85,19 @@ const GREETINGS = {
|
||||
return `Platform is steady across ${plural(f.postings.length, 'position')} and ${plural(f.total, 'application')}.`;
|
||||
},
|
||||
|
||||
/* The form is the page, so the greeting is about what is being specified
|
||||
rather than about the board — read from what this workspace has posted. */
|
||||
'admin.createPosition': (f) => {
|
||||
if (!f.postings.length) {
|
||||
return 'This is the first position in the workspace — the weights you set here become the house pattern.';
|
||||
}
|
||||
/* Every posted role is a benchmark for pay and experience; only the ones
|
||||
carrying weights can be compared on those, so the two are counted
|
||||
separately rather than one standing in for the other. */
|
||||
const weighted = f.postings.filter((p) => p.vetting_criteria).length;
|
||||
return `Specifying a new role. ${plural(f.postings.length, 'position')} on file to benchmark against${weighted ? `, ${weighted} with vetting weights set` : ''}.`;
|
||||
},
|
||||
|
||||
'admin.positions': (f) => {
|
||||
if (!f.openPositions.length) return 'No open positions yet — publish a role and I will start tracking it.';
|
||||
if (f.starvedPositions.length) {
|
||||
@@ -211,6 +224,7 @@ const TITLES = {
|
||||
'employer.analytics': () => 'Analytics assistant',
|
||||
'admin.controlCenter': (f, name) => `Good ${timeOfDay(f.today)}${name ? `, ${name}` : ''}`,
|
||||
'admin.positions': (f, name) => `Good ${timeOfDay(f.today)}${name ? `, ${name}` : ''}`,
|
||||
'admin.createPosition': () => 'Specifying a position',
|
||||
'admin.candidatesList': (f, name) => `Good ${timeOfDay(f.today)}${name ? `, ${name}` : ''}`,
|
||||
'admin.candidates': (f, name) => `Good ${timeOfDay(f.today)}${name ? `, ${name}` : ''}`,
|
||||
'admin.analytics': (f, name) => `Good ${timeOfDay(f.today)}${name ? `, ${name}` : ''}`,
|
||||
@@ -231,6 +245,8 @@ const DESCRIPTIONS = {
|
||||
`Reading ${plural(f.total, 'application')} across ${plural(f.postings.length, 'position')} — health, operations and what needs intervention.`,
|
||||
'admin.positions': (f) =>
|
||||
`Reading ${plural(f.total, 'application')} across ${plural(f.postings.length, 'position')} — hiring health, pipeline strength and what needs intervention.`,
|
||||
'admin.createPosition': (f) =>
|
||||
`Reading this draft against ${plural(f.postings.length, 'posted position')} — vetting weights, pay, experience and the credentials already in use.`,
|
||||
'admin.candidatesList': (f) =>
|
||||
`Reading ${plural(f.total, 'candidate')} across ${plural(f.postings.length, 'position')} — match quality, screening and hiring readiness.`,
|
||||
'admin.candidates': (f) =>
|
||||
@@ -281,6 +297,11 @@ const PLACEHOLDERS = {
|
||||
'Ask Owliver which role to fill first…',
|
||||
'Ask Owliver about hiring bottlenecks…',
|
||||
],
|
||||
'admin.createPosition': () => [
|
||||
'Ask Owliver about the vetting weights…',
|
||||
'Ask Owliver what comparable roles ask for…',
|
||||
'Ask Owliver to explain this form…',
|
||||
],
|
||||
'admin.candidatesList': () => [
|
||||
'Ask Owliver about candidates…',
|
||||
'Ask Owliver who is interview ready…',
|
||||
@@ -391,6 +412,15 @@ const PROMPTS = {
|
||||
{ label: 'Identify operational risks', prompt: 'Identify operational risks and what I should fix first', capability: 'recommendations' },
|
||||
],
|
||||
|
||||
/* Create Position asks about the draft in front of the author. Every one is
|
||||
answerable from the form and the positions already posted. */
|
||||
'admin.createPosition': () => [
|
||||
{ label: 'Explain the vetting weights', prompt: 'Summarize these vetting weights', capability: 'vetting-weights' },
|
||||
{ label: 'Compare with similar roles', prompt: 'What do comparable roles ask for?', capability: 'position-benchmarks' },
|
||||
{ label: 'Credentials in use', prompt: 'Which certifications does this workspace require?', capability: 'position-requirements' },
|
||||
{ label: 'How this form works', prompt: 'Explain how this form works', capability: 'position-spec-steps' },
|
||||
],
|
||||
|
||||
/**
|
||||
* Positions suggestions are the current workforce situation, phrased as
|
||||
* questions.
|
||||
@@ -407,6 +437,40 @@ const PROMPTS = {
|
||||
'admin.positions': (f, workforce) => {
|
||||
const out = [];
|
||||
|
||||
/**
|
||||
* Two things about the position in front of the reader come first, because
|
||||
* both are about what they were *just doing* rather than about the board.
|
||||
*
|
||||
* A draft is unfinished work, so finishing it leads. Then the role the page
|
||||
* has open — the drawer, or the one just published, which the form names in
|
||||
* the URL on its way here — gets the question its state actually invites:
|
||||
* a role with no applicants yet is a matching question, not a pipeline one.
|
||||
*
|
||||
* Both name the record, so the answer resolves against that position and
|
||||
* cannot be a stale reading of a different one.
|
||||
*/
|
||||
const drafts = (f.postings || []).filter((p) => p.status === 'draft');
|
||||
if (drafts.length) {
|
||||
out.push({
|
||||
label: drafts.length === 1 ? `Continue the ${drafts[0].title} draft` : `${drafts.length} drafts to finish`,
|
||||
prompt: 'Which positions are still in draft?',
|
||||
capability: 'position-drafts',
|
||||
});
|
||||
}
|
||||
|
||||
const current = workforce?.currentPositionId
|
||||
? (workforce.positions || []).find((p) => p.id === workforce.currentPositionId)
|
||||
: null;
|
||||
if (current && current.status !== 'draft') {
|
||||
out.push({
|
||||
label: `Find matching candidates for ${current.title}`,
|
||||
/* The definition's own words, with the role named. It resolves to the
|
||||
candidate-matching skill and reads that position — so switching the
|
||||
skill off in Settings takes this answer with it, as it should. */
|
||||
prompt: `Find matching candidates for ${current.title}`,
|
||||
});
|
||||
}
|
||||
|
||||
if (workforce?.positions?.length) {
|
||||
const { positions, context } = workforce;
|
||||
const ranked = prioritise(positions, context);
|
||||
|
||||
152
src/components/ai-assistant/history.js
Normal file
152
src/components/ai-assistant/history.js
Normal file
@@ -0,0 +1,152 @@
|
||||
/**
|
||||
* Owliver's conversation history.
|
||||
*
|
||||
* The panel already persisted the *current* thread per page, in sessionStorage,
|
||||
* so returning to Candidates found the conversation you left there. What it had
|
||||
* no notion of was a conversation you had *finished*: starting a new one
|
||||
* overwrote the old one, and there was no way back to anything.
|
||||
*
|
||||
* This is that archive, and it is deliberately the same artefact — a list of
|
||||
* threads in the shape the panel already stores. Two differences, both
|
||||
* intentional:
|
||||
*
|
||||
* - **localStorage, not session.** A history that empties when the tab closes
|
||||
* is not a history. Threads outlive the session, which is what makes
|
||||
* "Yesterday" and "Older" mean anything.
|
||||
* - **Written continuously, not on reset.** The archive is updated as the
|
||||
* conversation grows rather than when it ends, so a thread is recoverable
|
||||
* even if the tab is closed mid-answer.
|
||||
*
|
||||
* Nothing here is a second conversation store: the live thread is still the one
|
||||
* the panel renders from. This holds copies, keyed by conversation, for the one
|
||||
* question the panel could not previously answer — "what did I ask before?"
|
||||
*/
|
||||
|
||||
const KEY = 'krow_assistant:history';
|
||||
|
||||
/** Threads older than this stop being useful and start being clutter. */
|
||||
const MAX_AGE_DAYS = 60;
|
||||
/** A hard cap, so a heavy user cannot fill the origin's storage quota. */
|
||||
const MAX_RECORDS = 100;
|
||||
|
||||
const DAY = 24 * 60 * 60 * 1000;
|
||||
|
||||
/** Every archived conversation, newest first. Never throws. */
|
||||
export function readHistory() {
|
||||
try {
|
||||
const raw = localStorage.getItem(KEY);
|
||||
const list = raw ? JSON.parse(raw) : [];
|
||||
if (!Array.isArray(list)) return [];
|
||||
|
||||
const cutoff = Date.now() - MAX_AGE_DAYS * DAY;
|
||||
return list
|
||||
.filter((r) => r && r.id && Array.isArray(r.messages) && r.messages.length)
|
||||
.filter((r) => new Date(r.updatedAt || 0).getTime() >= cutoff)
|
||||
.sort((a, b) => new Date(b.updatedAt || 0) - new Date(a.updatedAt || 0));
|
||||
} catch {
|
||||
/* Corrupt or unavailable storage is an empty history, not an error the
|
||||
reader has to deal with. */
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/** The first thing the person asked, which is what the thread was about. */
|
||||
function titleFor(messages) {
|
||||
const first = messages.find((m) => m.role === 'user' && m.text);
|
||||
const text = String(first?.text || '').trim();
|
||||
if (!text) return 'Conversation';
|
||||
return text.length > 60 ? `${text.slice(0, 57)}…` : text;
|
||||
}
|
||||
|
||||
/**
|
||||
* Records this conversation, replacing its own earlier entry.
|
||||
*
|
||||
* Matched on `id`, so a thread being added to updates in place rather than
|
||||
* appearing once per turn.
|
||||
*/
|
||||
export function saveConversation({ id, contextId, page, messages }) {
|
||||
if (!id || !Array.isArray(messages) || !messages.length) return readHistory();
|
||||
|
||||
const record = {
|
||||
id,
|
||||
contextId,
|
||||
page: page || '',
|
||||
title: titleFor(messages),
|
||||
turns: messages.filter((m) => m.role === 'user').length,
|
||||
updatedAt: new Date().toISOString(),
|
||||
messages,
|
||||
};
|
||||
|
||||
const next = [record, ...readHistory().filter((r) => r.id !== id)].slice(0, MAX_RECORDS);
|
||||
|
||||
try {
|
||||
localStorage.setItem(KEY, JSON.stringify(next));
|
||||
} catch {
|
||||
/* Quota or private mode: the live thread is unaffected, only the archive
|
||||
is. Dropping the oldest half is a better outcome than losing every
|
||||
future write. */
|
||||
try {
|
||||
localStorage.setItem(KEY, JSON.stringify(next.slice(0, Math.floor(MAX_RECORDS / 4))));
|
||||
} catch {
|
||||
/* Storage is unusable. History is in-memory for this session only. */
|
||||
}
|
||||
}
|
||||
|
||||
return next;
|
||||
}
|
||||
|
||||
/** Forgets one conversation. */
|
||||
export function removeConversation(id) {
|
||||
const next = readHistory().filter((r) => r.id !== id);
|
||||
try {
|
||||
localStorage.setItem(KEY, JSON.stringify(next));
|
||||
} catch {
|
||||
/* Ignore — the caller re-reads either way. */
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
/** Forgets everything. */
|
||||
export function clearHistory() {
|
||||
try {
|
||||
localStorage.removeItem(KEY);
|
||||
} catch {
|
||||
/* Ignore. */
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Conversations under a heading, in the order a person thinks about time.
|
||||
*
|
||||
* Empty groups are dropped rather than rendered as a heading with nothing
|
||||
* under it.
|
||||
*/
|
||||
export function groupByRecency(records, now = new Date()) {
|
||||
const startOfToday = new Date(now);
|
||||
startOfToday.setHours(0, 0, 0, 0);
|
||||
const startOfYesterday = new Date(startOfToday.getTime() - DAY);
|
||||
const startOfWeek = new Date(startOfToday.getTime() - 7 * DAY);
|
||||
|
||||
const groups = [
|
||||
{ id: 'today', label: 'Today', items: [] },
|
||||
{ id: 'yesterday', label: 'Yesterday', items: [] },
|
||||
{ id: 'week', label: 'Earlier this week', items: [] },
|
||||
{ id: 'older', label: 'Older', items: [] },
|
||||
];
|
||||
|
||||
for (const record of records) {
|
||||
const at = new Date(record.updatedAt || 0).getTime();
|
||||
if (at >= startOfToday.getTime()) groups[0].items.push(record);
|
||||
else if (at >= startOfYesterday.getTime()) groups[1].items.push(record);
|
||||
else if (at >= startOfWeek.getTime()) groups[2].items.push(record);
|
||||
else groups[3].items.push(record);
|
||||
}
|
||||
|
||||
return groups.filter((g) => g.items.length);
|
||||
}
|
||||
|
||||
/** A new conversation id. Time-ordered, so ids sort the way threads do. */
|
||||
export function newConversationId() {
|
||||
return `c_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`;
|
||||
}
|
||||
@@ -21,6 +21,9 @@
|
||||
*/
|
||||
|
||||
export { AssistantPanelProvider, useAssistantPanel } from './AssistantPanelContext';
|
||||
export {
|
||||
usePageAction, usePageContext, usePublishPageActions, usePublishPageContext,
|
||||
} from './PageContext';
|
||||
export { AssistantTrigger } from './AssistantTrigger';
|
||||
export { AssistantPanel } from './AssistantPanel';
|
||||
export { default as KrowAssistant } from './KrowAssistant';
|
||||
|
||||
@@ -15,6 +15,11 @@ const PLACEMENT = {
|
||||
admin: {
|
||||
'/admin': 'admin.controlCenter',
|
||||
'/admin/positions': 'admin.positions',
|
||||
/* The authoring form. Owliver belongs here for the same reason it belongs
|
||||
on Positions: the questions asked while specifying a role — what do these
|
||||
weights score, what do comparable roles ask for — are answerable from the
|
||||
page and from what this workspace has already posted. */
|
||||
'/admin/positions/new': 'admin.createPosition',
|
||||
'/admin/candidates': 'admin.candidatesList',
|
||||
'/admin/candidates-analysis': 'admin.candidates',
|
||||
'/admin/analytics': 'admin.analytics',
|
||||
@@ -40,6 +45,8 @@ const PLACEMENT = {
|
||||
export const EXCLUDED_ROUTES = [
|
||||
// Every Employer and Talent route: the contextual panel is Admin-only.
|
||||
'/', '/overview', '/positions', '/positions/new', '/positions/:id', '/candidates',
|
||||
/* The Employer aliases only — the Admin `/admin/positions/new` carries the
|
||||
panel, and is listed in the placement table above. */
|
||||
'/hired', '/talent-pool', '/analytics', '/profile', '/apply',
|
||||
'/university', '/university/:id', '/me', '/identity', '/employee',
|
||||
'/owliver', '/design-system',
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
import { doc, insights, list, note, text } from './blocks';
|
||||
import { doc, heading, insights, list, note, skillSection, text } from './blocks';
|
||||
import { ASSISTANT_CONTEXTS } from './contexts';
|
||||
import { poolFor } from '@/lib/workforce';
|
||||
import { matchSkill } from '@/lib/skills/registry';
|
||||
import { owliverCapabilityLabel } from '@/lib/skills/surfaces';
|
||||
import {
|
||||
matchOwliverSkill, owliverSkillsForContext, presentable, resolveOwliverResponse,
|
||||
responseTitle, summaryLines,
|
||||
} from '@/lib/skills/owliverResolver';
|
||||
import {
|
||||
appliedToday, askWhichPosition, assignmentPreview, assignmentToExecute, availability,
|
||||
candidateDetail, candidateMatches, candidateRoute, interviewReady, matchWorkforceIntent,
|
||||
@@ -472,6 +477,96 @@ function proposeTraining(skill, question, courses, skillCategories) {
|
||||
};
|
||||
}
|
||||
|
||||
/* ── Declared skills ────────────────────────────────────────────────────── */
|
||||
|
||||
/**
|
||||
* A skill answering from its own definition.
|
||||
*
|
||||
* The whole reply is composed from what the `.md` declared and what the shared
|
||||
* resolver read: the title is the definition's, the capability is the shape it
|
||||
* offers, the figures are the application's own records, and the follow-ups are
|
||||
* the other capabilities the same definition carries. Nothing in this function
|
||||
* knows which skill it is serving, and nothing may be added that does — the
|
||||
* moment a skill id appears here, the definition has stopped being the source
|
||||
* of truth.
|
||||
*/
|
||||
function declaredAnswer({ skill, capability, question, skillContext }) {
|
||||
const resolved = resolveOwliverResponse({ skill, capability, question, context: skillContext });
|
||||
if (!resolved) return null;
|
||||
|
||||
const { section, data, missing } = resolved;
|
||||
const title = responseTitle(skill, section);
|
||||
|
||||
/* Other shapes of the same reading, offered rather than explained. A chip
|
||||
per remaining capability, worded from the definition's own suggestions
|
||||
where it wrote one for that shape. */
|
||||
const followUp = skill.owliver.capabilities
|
||||
.filter((c) => c !== capability)
|
||||
.map((c) => {
|
||||
const written = skill.owliver.suggestions.find((s) => s.capability === c);
|
||||
return {
|
||||
label: written?.label || `${skill.name} as ${owliverCapabilityLabel(c).toLowerCase()}`,
|
||||
prompt: written?.prompt || `Show ${skill.name} as a ${c}`,
|
||||
};
|
||||
})
|
||||
.slice(0, 3);
|
||||
|
||||
/**
|
||||
* The reading needs a record the page has not supplied. Say which, and offer
|
||||
* the ones there are — an answer about the wrong position would be worse
|
||||
* than a question.
|
||||
*/
|
||||
if (missing === 'position') {
|
||||
const open = (skillContext.positions || []).filter((p) => p.status === 'active').slice(0, 6);
|
||||
return {
|
||||
kind: 'skill',
|
||||
skill,
|
||||
doc: doc(
|
||||
text(`**${title}** reads one position. Which should I read?`),
|
||||
list(open.map((p) => p.title)),
|
||||
note('Open a position, or name one and I will read it.')
|
||||
),
|
||||
followUp: open.slice(0, 4).map((p) => ({ label: p.title, prompt: `${question} for ${p.title}` })),
|
||||
};
|
||||
}
|
||||
if (missing === 'candidate') {
|
||||
return {
|
||||
kind: 'skill',
|
||||
skill,
|
||||
doc: doc(
|
||||
text(`**${title}** reads one candidate.`),
|
||||
note('Open a candidate, or name one and I will read their record.')
|
||||
),
|
||||
};
|
||||
}
|
||||
/* A reading with nothing in it says so, in the resolver's own words. No
|
||||
figure is invented to fill a shape. */
|
||||
if (data.unavailable || data.empty) {
|
||||
return {
|
||||
kind: 'skill',
|
||||
skill,
|
||||
doc: doc(heading(title), text(data.emptyNote || 'There is nothing to report yet.')),
|
||||
followUp,
|
||||
};
|
||||
}
|
||||
|
||||
/* A summary is prose; every other capability is the section the page would
|
||||
draw, drawn here. */
|
||||
const body = capability === 'summary'
|
||||
? [
|
||||
list(summaryLines(data)),
|
||||
data.total != null ? note(`${data.total} record${data.total === 1 ? '' : 's'} in total.`) : null,
|
||||
]
|
||||
: [skillSection(section, presentable(data))];
|
||||
|
||||
return {
|
||||
kind: 'skill',
|
||||
skill,
|
||||
doc: doc(heading(title, section.description || undefined), ...body),
|
||||
followUp,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Skills that can act on this page, ahead of the page's own responder.
|
||||
*
|
||||
@@ -480,10 +575,76 @@ function proposeTraining(skill, question, courses, skillCategories) {
|
||||
* bartender position" open a form instead of returning a pipeline report that
|
||||
* happens to match the word "position".
|
||||
*/
|
||||
function resolveSkill({ question, contextId, disabledSkills, customSkills, roles, skillCategories, courses }) {
|
||||
const skill = matchSkill(question, contextId, disabledSkills, customSkills);
|
||||
function resolveSkill({
|
||||
question, contextId, disabledSkills, customSkills, roles, skillCategories, courses,
|
||||
skillContext = null,
|
||||
}) {
|
||||
/**
|
||||
* One question, one skill, then one way of answering it.
|
||||
*
|
||||
* This used to be two independent matchers run in sequence: declared skills
|
||||
* were resolved first and answered immediately, and only a question that
|
||||
* matched none of them reached the trigger matcher and the coded flows below.
|
||||
* That ordering was the bug. A declared definition could claim a question
|
||||
* belonging to a flow it knew nothing about — "create a position" was
|
||||
* answered by a candidate-matching skill — because the first matcher never
|
||||
* asked whether anything else had a stronger claim.
|
||||
*
|
||||
* So both matchers run, and *then* the question is dispatched:
|
||||
*
|
||||
* 1. A skill with a coded flow owns its own triggers. Create Position and
|
||||
* Forge authoring are conversations with state, not readings, and no
|
||||
* declaration may take one over.
|
||||
* 2. Otherwise a declared answer, if the definition earned the question.
|
||||
* 3. Otherwise the definition reads itself back.
|
||||
*
|
||||
* The property this gives is the one that matters: a skill added tomorrow
|
||||
* cannot capture a flow that already exists, whatever it declares.
|
||||
*/
|
||||
const declared = skillContext
|
||||
? matchOwliverSkill(
|
||||
question,
|
||||
owliverSkillsForContext(contextId, disabledSkills, customSkills)
|
||||
)
|
||||
: null;
|
||||
|
||||
const triggered = matchSkill(question, contextId, disabledSkills, customSkills);
|
||||
|
||||
/**
|
||||
* Which of the two named the question.
|
||||
*
|
||||
* A trigger is an explicit claim on a phrase, so it usually names the skill.
|
||||
* Two cases where it must not, both of which were live bugs:
|
||||
*
|
||||
* - **A verbatim suggestion outranks a trigger.** A definition that
|
||||
* published "Show hiring activity" and had the reader click it has the
|
||||
* strongest claim there is. A *different* definition whose trigger merely
|
||||
* contains those words — including the name every definition falls back
|
||||
* to when it declares no triggers — has a weaker one, and was winning.
|
||||
* - **An answer outranks a description.** A skill with no declared
|
||||
* capabilities can only read its own definition back. If it takes a
|
||||
* question another definition can actually answer, the reader gets a
|
||||
* restatement of the question instead of the figures.
|
||||
*
|
||||
* Neither rule names a skill: both compare what the two definitions declared.
|
||||
*/
|
||||
const canAnswer = (s) => Boolean(s?.owliver?.enabled && s.owliver.capabilities.length);
|
||||
/* A definition that collects answers in the chat or names an action to run is
|
||||
a flow, not a reading, and owns the phrases it declared — read off what it
|
||||
declares rather than from a list of ids, so a flow added later is protected
|
||||
by the same rule. */
|
||||
const ownsFlow = (s) => Boolean(s?.conversation?.length || s?.actions?.length);
|
||||
|
||||
const skill = (triggered && ownsFlow(triggered) && (!declared || !declared.exact))
|
||||
? triggered
|
||||
: declared && (declared.exact || !canAnswer(triggered))
|
||||
? declared.skill
|
||||
: triggered || declared?.skill || null;
|
||||
if (!skill) return null;
|
||||
|
||||
/* The declared answer is only offered for the skill that actually won. */
|
||||
const declaredForSkill = declared && declared.skill.id === skill.id ? declared : null;
|
||||
|
||||
/**
|
||||
* Forge authoring. Same contract as Create Position: what the sentence
|
||||
* answered is filled in, the rest is left blank, and the flow opens rather
|
||||
@@ -522,9 +683,19 @@ function resolveSkill({ question, contextId, disabledSkills, customSkills, roles
|
||||
return { kind: 'skill', skill, ...beginPositionFlow({ question, skill, roles }) };
|
||||
}
|
||||
|
||||
/* A skill with no coded handler still answers, from its own definition. That
|
||||
is what makes a Markdown-only skill useful on the page it names without a
|
||||
line of Owliver being changed for it. */
|
||||
/**
|
||||
* No coded flow owns this one, so the definition answers it.
|
||||
*
|
||||
* A declared `owliver:` block is the richer answer — a real reading, drawn in
|
||||
* the shape the author asked for — so it is tried first. A definition without
|
||||
* one still reads itself back, which is what makes a Markdown-only skill
|
||||
* useful on the page it names without a line of Owliver being changed for it.
|
||||
*/
|
||||
if (declaredForSkill) {
|
||||
const answer = declaredAnswer({ ...declaredForSkill, question, skillContext });
|
||||
if (answer) return answer;
|
||||
}
|
||||
|
||||
return {
|
||||
kind: 'skill',
|
||||
skill,
|
||||
@@ -538,13 +709,14 @@ function resolveSkill({ question, contextId, disabledSkills, customSkills, roles
|
||||
|
||||
export function resolveIntent({
|
||||
question, contextId, disabledSkills = [], customSkills = [], roles = [], skillCategories = [],
|
||||
courses = [], workforce = null,
|
||||
courses = [], workforce = null, skillContext = null,
|
||||
}) {
|
||||
const context = ASSISTANT_CONTEXTS[contextId] ?? null;
|
||||
|
||||
/* 2. Current page skills — specific triggers, ahead of the general reader. */
|
||||
const skill = resolveSkill({
|
||||
question, contextId, disabledSkills, customSkills, roles, skillCategories, courses,
|
||||
skillContext,
|
||||
});
|
||||
if (skill) return skill;
|
||||
|
||||
|
||||
@@ -6,12 +6,16 @@ import {
|
||||
useUserActivity, useWorkerProfile, useWorkerProfiles,
|
||||
} from '@/lib/krowHooks';
|
||||
import { skillsForContext } from '@/lib/skills/registry';
|
||||
import { suggestionsForPosition } from '@/lib/skills/owliverResolver';
|
||||
import {
|
||||
advancePositionFlow, createdFollowUp, positionCreatedReply, positionFailedReply,
|
||||
} from '@/lib/skills/positionFlow';
|
||||
import {
|
||||
assignmentDone, assignmentFailed, assignmentFollowUp, interviewDone, interviewFailed,
|
||||
} from '@/lib/skills/workforceFlow';
|
||||
import {
|
||||
newConversationId, readHistory, removeConversation, saveConversation,
|
||||
} from './history';
|
||||
import { buildFacts } from './insights';
|
||||
import { createAssistantProvider } from './provider';
|
||||
import { resolveIntent } from './routing';
|
||||
@@ -121,16 +125,35 @@ async function streamDocument({ document, signal, onFrame, onDone }) {
|
||||
export function useConversation({
|
||||
contextId, facts, onNavigate, onAction, onCreatePosition, onAssignWorkers, onScheduleInterview,
|
||||
workforce = null, disabledSkills = [], customSkills = [],
|
||||
roles = [], skillCategories = [], courses = [],
|
||||
roles = [], skillCategories = [], courses = [], skillContext = null,
|
||||
/* The page's own name, recorded with an archived thread so History can say
|
||||
where a conversation happened without resolving the context again. */
|
||||
pageLabel = '',
|
||||
}) {
|
||||
const storageKey = `krow_assistant:${contextId}`;
|
||||
const flowKey = `${storageKey}:flow`;
|
||||
const idKey = `${storageKey}:id`;
|
||||
|
||||
const [messages, setMessages] = React.useState([]);
|
||||
const [pending, setPending] = React.useState(null); // { blocks, thinking }
|
||||
const [error, setError] = React.useState(null);
|
||||
const abortRef = React.useRef(null);
|
||||
|
||||
/* The archive, held in state so opening History does not have to re-read
|
||||
storage on every render, and so a new turn moves its own thread to the top
|
||||
of the list while it is on screen. */
|
||||
const [history, setHistory] = React.useState(() => readHistory());
|
||||
|
||||
/**
|
||||
* Which conversation this is.
|
||||
*
|
||||
* The thread already persisted per page; what it lacked was an identity, so
|
||||
* every new conversation overwrote the last. This id is what an archived
|
||||
* record is keyed by — kept in sessionStorage beside the thread it belongs
|
||||
* to, so a reload continues the same conversation rather than forking it.
|
||||
*/
|
||||
const conversationRef = React.useRef(null);
|
||||
|
||||
/* A guided skill in progress — which question it is on and what it has
|
||||
collected. Kept beside the thread rather than inside it, and persisted for
|
||||
the same reason the thread is: leaving Positions and coming back should not
|
||||
@@ -155,6 +178,11 @@ export function useConversation({
|
||||
} catch {
|
||||
setMessages([]);
|
||||
}
|
||||
try {
|
||||
conversationRef.current = sessionStorage.getItem(idKey) || null;
|
||||
} catch {
|
||||
conversationRef.current = null;
|
||||
}
|
||||
try {
|
||||
const raw = sessionStorage.getItem(flowKey);
|
||||
flowRef.current = raw ? JSON.parse(raw) : null;
|
||||
@@ -163,8 +191,16 @@ export function useConversation({
|
||||
}
|
||||
setPending(null);
|
||||
setError(null);
|
||||
}, [storageKey, flowKey]);
|
||||
}, [storageKey, flowKey, idKey]);
|
||||
|
||||
/**
|
||||
* Writes the thread, and records it.
|
||||
*
|
||||
* One function rather than two calls at each site: a thread that is persisted
|
||||
* but not archived is a conversation History cannot show, and every path that
|
||||
* adds a turn goes through here. The conversation is given its id on its
|
||||
* first turn, so an untouched panel leaves nothing behind.
|
||||
*/
|
||||
const persist = React.useCallback((next) => {
|
||||
setMessages(next);
|
||||
try {
|
||||
@@ -172,7 +208,26 @@ export function useConversation({
|
||||
} catch {
|
||||
// Quota or private mode — the thread stays in memory only.
|
||||
}
|
||||
}, [storageKey]);
|
||||
|
||||
if (!next.length) return;
|
||||
|
||||
if (!conversationRef.current) {
|
||||
conversationRef.current = newConversationId();
|
||||
try {
|
||||
sessionStorage.setItem(idKey, conversationRef.current);
|
||||
} catch {
|
||||
// The id stays in memory: this conversation archives under one id for
|
||||
// as long as the panel is mounted, which is the common case.
|
||||
}
|
||||
}
|
||||
|
||||
setHistory(saveConversation({
|
||||
id: conversationRef.current,
|
||||
contextId,
|
||||
page: pageLabel,
|
||||
messages: next,
|
||||
}));
|
||||
}, [storageKey, idKey, contextId, pageLabel]);
|
||||
|
||||
/* Abort any in-flight response when the context changes or we unmount. */
|
||||
React.useEffect(() => () => abortRef.current?.abort(), [storageKey]);
|
||||
@@ -224,7 +279,7 @@ export function useConversation({
|
||||
? { kind: 'answer' }
|
||||
: resolveIntent({
|
||||
question: text, contextId, disabledSkills, customSkills, roles, skillCategories,
|
||||
courses, workforce,
|
||||
courses, workforce, skillContext,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -236,14 +291,33 @@ export function useConversation({
|
||||
if (intent.kind === 'flow' && intent.create) {
|
||||
let created = null;
|
||||
try {
|
||||
created = await onCreatePosition?.(intent.create.draft, intent.skill);
|
||||
created = await onCreatePosition?.(intent.create.draft, intent.skill, intent.create.status);
|
||||
} catch {
|
||||
created = null;
|
||||
}
|
||||
|
||||
if (created?.id) {
|
||||
/**
|
||||
* What can now be asked about the position that was just made.
|
||||
*
|
||||
* A live position is something the page's skills can read; a draft is
|
||||
* not finished being specified, so offering to analyse it would be
|
||||
* answering about a record the admin has not committed to. The registry
|
||||
* decides *which* skills can say something — see
|
||||
* `suggestionsForPosition` — and this decides only whether it is yet
|
||||
* the moment to ask them.
|
||||
*/
|
||||
const ready = created.status === 'active';
|
||||
intent = {
|
||||
...intent, flow: null, doc: positionCreatedReply(created), followUp: createdFollowUp(created),
|
||||
...intent,
|
||||
flow: null,
|
||||
doc: positionCreatedReply(created),
|
||||
followUp: [
|
||||
...createdFollowUp(created),
|
||||
...(ready
|
||||
? suggestionsForPosition(contextId, disabledSkills, customSkills, created)
|
||||
: []),
|
||||
],
|
||||
};
|
||||
} else {
|
||||
/* Keep the answers: the summary is still there to try again from. */
|
||||
@@ -372,10 +446,18 @@ export function useConversation({
|
||||
}, [contextId, facts, persist, onNavigate, onAction, onCreatePosition, onAssignWorkers,
|
||||
onScheduleInterview,
|
||||
workforce, setFlow, disabledSkills,
|
||||
customSkills, roles, skillCategories, courses]);
|
||||
customSkills, roles, skillCategories, courses, skillContext]);
|
||||
|
||||
const stop = React.useCallback(() => abortRef.current?.abort(), []);
|
||||
|
||||
/**
|
||||
* Back to an empty panel.
|
||||
*
|
||||
* The thread being cleared is not discarded — `persist` has already archived
|
||||
* every turn of it — so this releases the conversation rather than deleting
|
||||
* it, and the next turn starts a new one. That is what makes "New
|
||||
* conversation" and "Back to Home" safe: nothing is lost by leaving.
|
||||
*/
|
||||
const reset = React.useCallback(() => {
|
||||
abortRef.current?.abort();
|
||||
messagesRef.current = [];
|
||||
@@ -385,12 +467,47 @@ export function useConversation({
|
||||
/* A new conversation abandons a half-collected position too — the questions
|
||||
it was answering are no longer on screen. */
|
||||
setFlow(null);
|
||||
conversationRef.current = null;
|
||||
try {
|
||||
sessionStorage.removeItem(storageKey);
|
||||
sessionStorage.removeItem(idKey);
|
||||
} catch {
|
||||
// Ignore.
|
||||
}
|
||||
}, [storageKey, setFlow]);
|
||||
}, [storageKey, idKey, setFlow]);
|
||||
|
||||
/**
|
||||
* Reopens an archived conversation as the live thread.
|
||||
*
|
||||
* It resumes under its own id, so continuing an old conversation adds to that
|
||||
* record instead of forking a near-duplicate beside it. Any half-collected
|
||||
* skill flow is dropped: its questions belonged to the thread being left.
|
||||
*/
|
||||
const openConversation = React.useCallback((record) => {
|
||||
if (!record?.id || !Array.isArray(record.messages)) return;
|
||||
|
||||
abortRef.current?.abort();
|
||||
setPending(null);
|
||||
setError(null);
|
||||
setFlow(null);
|
||||
|
||||
messagesRef.current = record.messages;
|
||||
setMessages(record.messages);
|
||||
conversationRef.current = record.id;
|
||||
|
||||
try {
|
||||
sessionStorage.setItem(storageKey, JSON.stringify(record.messages));
|
||||
sessionStorage.setItem(idKey, record.id);
|
||||
} catch {
|
||||
// The reopened thread stays in memory only.
|
||||
}
|
||||
}, [storageKey, idKey, setFlow]);
|
||||
|
||||
/** Forgets one archived conversation, clearing the panel if it is open. */
|
||||
const forgetConversation = React.useCallback((id) => {
|
||||
setHistory(removeConversation(id));
|
||||
if (conversationRef.current === id) reset();
|
||||
}, [reset]);
|
||||
|
||||
return {
|
||||
messages,
|
||||
@@ -400,6 +517,12 @@ export function useConversation({
|
||||
send,
|
||||
stop,
|
||||
reset,
|
||||
/** Every archived conversation, newest first. */
|
||||
history,
|
||||
/** The conversation on screen, so History can mark it. */
|
||||
conversationId: conversationRef.current,
|
||||
openConversation,
|
||||
forgetConversation,
|
||||
providerId: provider.id,
|
||||
};
|
||||
}
|
||||
|
||||
183
src/components/krow/Journey.jsx
Normal file
183
src/components/krow/Journey.jsx
Normal file
@@ -0,0 +1,183 @@
|
||||
import React, {
|
||||
createContext, useCallback, useContext, useEffect, useMemo, useRef, useState,
|
||||
} from 'react';
|
||||
import { motion, useReducedMotion } from 'framer-motion';
|
||||
import { Check } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
/**
|
||||
* A reading path down a long page.
|
||||
*
|
||||
* The candidate profile is one scroll from "who is this" to "what should I do",
|
||||
* and the sections are steps in that order rather than a pile of independent
|
||||
* cards. This draws the order: a thin rail, one node per section, filled behind
|
||||
* you and hollow ahead.
|
||||
*
|
||||
* It is navigation you read, not navigation you click — deliberately. A rail of
|
||||
* links would be the tab bar again, in a thinner shape. Nothing here moves the
|
||||
* page; it only says where in the evaluation you are.
|
||||
*
|
||||
* Built from a flex column and a border. No graph library: the flow is linear,
|
||||
* and a linear flow drawn by a layout engine costs nothing to ship.
|
||||
*/
|
||||
|
||||
const JourneyContext = createContext(null);
|
||||
|
||||
/** Where a section counts as "being read": a third of the way down the viewport. */
|
||||
const READING_LINE = 0.35;
|
||||
|
||||
/**
|
||||
* Holds which step is current.
|
||||
*
|
||||
* "Current" is the last section whose heading has crossed the reading line — not
|
||||
* merely what is on screen, since three sections can be visible at once and only
|
||||
* one of them is being read.
|
||||
*
|
||||
* A scroll listener rather than an observer: the question is "which of these
|
||||
* eight is furthest past a line", which is one comparison per step against one
|
||||
* number. An IntersectionObserver answers a different question — is this element
|
||||
* in a box — and stitching eight of those answers back into an ordering is both
|
||||
* more code and less exact at the boundaries. Reads are throttled to one per
|
||||
* frame and every step is a single `getBoundingClientRect`.
|
||||
*/
|
||||
export function Journey({ children, className }) {
|
||||
const [active, setActive] = useState(0);
|
||||
const steps = useRef(new Map());
|
||||
|
||||
const register = useCallback((index, el) => {
|
||||
if (el) steps.current.set(index, el);
|
||||
else steps.current.delete(index);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const compute = () => {
|
||||
const line = window.innerHeight * READING_LINE;
|
||||
let next = 0;
|
||||
steps.current.forEach((el, index) => {
|
||||
if (el.getBoundingClientRect().top <= line && index > next) next = index;
|
||||
});
|
||||
setActive((prev) => (prev === next ? prev : next));
|
||||
};
|
||||
|
||||
/* Straight off the scroll event, not through requestAnimationFrame: a
|
||||
backgrounded tab never runs its frame callbacks, so a rAF-coalesced
|
||||
version silently stops updating and comes back stuck on whichever step
|
||||
was current when the tab was hidden. The work here is a handful of
|
||||
`getBoundingClientRect` reads and no writes, which is well inside what a
|
||||
scroll handler can afford. */
|
||||
compute();
|
||||
window.addEventListener('scroll', compute, { passive: true });
|
||||
window.addEventListener('resize', compute);
|
||||
return () => {
|
||||
window.removeEventListener('scroll', compute);
|
||||
window.removeEventListener('resize', compute);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const value = useMemo(() => ({ active, register }), [active, register]);
|
||||
|
||||
return (
|
||||
<JourneyContext.Provider value={value}>
|
||||
<div className={className}>{children}</div>
|
||||
</JourneyContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* One step: a node, the line to the next one, a heading, and the section.
|
||||
*
|
||||
* The section fades up once as it arrives and never animates again — motion
|
||||
* that repeats on every scroll past is a page that will not settle.
|
||||
*/
|
||||
export function JourneyStep({ index, label, meta, last = false, children }) {
|
||||
const { active = 0, register } = useContext(JourneyContext) || {};
|
||||
const reduced = useReducedMotion();
|
||||
|
||||
const ref = useCallback((el) => register?.(index, el), [register, index]);
|
||||
|
||||
const state = index < active ? 'done' : index === active ? 'active' : 'upcoming';
|
||||
|
||||
return (
|
||||
<div ref={ref} className="relative flex gap-3.5 sm:gap-4">
|
||||
{/* Rail */}
|
||||
<div className="flex w-3 shrink-0 flex-col items-center pt-1" aria-hidden="true">
|
||||
<span
|
||||
className={cn(
|
||||
'grid h-3 w-3 shrink-0 place-items-center rounded-full border-2 transition-colors duration-300',
|
||||
state === 'upcoming' && 'border-border bg-surface',
|
||||
state === 'active' && 'border-krow-blue bg-krow-blue ring-4 ring-krow-blue/15',
|
||||
state === 'done' && 'border-krow-blue bg-krow-blue'
|
||||
)}
|
||||
>
|
||||
{state === 'done' && <Check className="h-2 w-2 text-white" strokeWidth={4} />}
|
||||
</span>
|
||||
{!last && (
|
||||
/* 2px with rounded caps: a hairline reads as a table border, and a
|
||||
square end butting against a round node reads as a seam. */
|
||||
<span className="relative mt-1.5 w-0.5 flex-1 overflow-hidden rounded-full bg-border">
|
||||
{/* The travelled part of the line, drawn over the grey one so the
|
||||
colour change is a height transition rather than a repaint.
|
||||
|
||||
A CSS transition rather than an animated value: the final height
|
||||
is in the style either way, so a tab that never ran a frame —
|
||||
backgrounded, or restored from one — still paints the rail in the
|
||||
right state instead of holding the position it was hidden at. */}
|
||||
<span
|
||||
className="absolute inset-x-0 top-0 rounded-full bg-krow-blue transition-[height] duration-300 ease-out motion-reduce:transition-none"
|
||||
style={{ height: state === 'done' ? '100%' : '0%' }}
|
||||
/>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Section */}
|
||||
<motion.section
|
||||
className="min-w-0 flex-1 pb-7"
|
||||
initial={reduced ? false : { opacity: 0, y: 12 }}
|
||||
whileInView={reduced ? undefined : { opacity: 1, y: 0 }}
|
||||
viewport={{ once: true, amount: 0.12 }}
|
||||
transition={{ duration: 0.4, ease: 'easeOut' }}
|
||||
>
|
||||
<div className="mb-3 flex items-baseline justify-between gap-3">
|
||||
<h2 className="text-[11px] font-semibold uppercase tracking-[0.08em] text-ink-4">{label}</h2>
|
||||
{meta && <span className="shrink-0 text-caption text-ink-4">{meta}</span>}
|
||||
</div>
|
||||
{children}
|
||||
</motion.section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A number that counts up to itself once.
|
||||
*
|
||||
* Short and ease-out: the point is to draw the eye to the figure, not to make
|
||||
* the reader wait for it. Returns the target immediately when the reader has
|
||||
* asked for reduced motion, or before the element has been seen.
|
||||
*/
|
||||
export function useCountUp(target, { duration = 750, start = true } = {}) {
|
||||
const reduced = useReducedMotion();
|
||||
const [value, setValue] = useState(reduced || !start ? target : 0);
|
||||
|
||||
useEffect(() => {
|
||||
if (reduced || !start) {
|
||||
setValue(target);
|
||||
return undefined;
|
||||
}
|
||||
let frame;
|
||||
const t0 = performance.now();
|
||||
const tick = (now) => {
|
||||
const p = Math.min(1, (now - t0) / duration);
|
||||
/* ease-out cubic — fast to nearly-there, then settles. No overshoot: a
|
||||
score that bounces past itself reads as a different score. */
|
||||
setValue(Math.round(target * (1 - (1 - p) ** 3)));
|
||||
if (p < 1) frame = requestAnimationFrame(tick);
|
||||
};
|
||||
frame = requestAnimationFrame(tick);
|
||||
return () => cancelAnimationFrame(frame);
|
||||
}, [target, duration, reduced, start]);
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
export default Journey;
|
||||
@@ -1,4 +1,5 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import { motion, useReducedMotion } from 'framer-motion';
|
||||
import { ArrowRight, GraduationCap, ShieldCheck } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import {
|
||||
@@ -39,6 +40,58 @@ const STATUS_LABEL = {
|
||||
not_started: 'Not started',
|
||||
};
|
||||
|
||||
/**
|
||||
* One skill as a path: where they are verified, and the level they are walking to.
|
||||
*
|
||||
* The same three numbers this row always carried — level held, modules done,
|
||||
* modules needed — drawn as the progression they describe rather than a comma
|
||||
* list. The filled segment is `progressToNext`, so a person two modules from
|
||||
* Advanced looks two modules from Advanced.
|
||||
*
|
||||
* A skill at its top level has no path to draw and says so.
|
||||
*/
|
||||
function SkillPath({ state }) {
|
||||
const reduced = useReducedMotion();
|
||||
const held = state.verifiedLabel;
|
||||
|
||||
if (!state.nextLevel) {
|
||||
return <p className="mt-1.5 text-caption text-ink-3">{held} · top level on this pathway</p>;
|
||||
}
|
||||
|
||||
return (
|
||||
/* Bounded: a path stretched the width of the card reads as a progress bar
|
||||
for the card, not a two-stop journey between two named levels. */
|
||||
<div className="mt-2 max-w-[320px]">
|
||||
<div className="flex items-center gap-1.5" aria-hidden="true">
|
||||
<span
|
||||
className={cn(
|
||||
'h-2 w-2 shrink-0 rounded-full',
|
||||
state.verifiedLevel ? 'bg-krow-blue' : 'border-2 border-border bg-surface'
|
||||
)}
|
||||
/>
|
||||
<span className="relative h-px min-w-0 flex-1 bg-border">
|
||||
<motion.span
|
||||
className="absolute inset-y-0 left-0 bg-krow-blue"
|
||||
initial={reduced ? false : { width: 0 }}
|
||||
whileInView={{ width: `${state.progressToNext}%` }}
|
||||
viewport={{ once: true, amount: 0.8 }}
|
||||
transition={reduced ? { duration: 0 } : { duration: 0.7, ease: 'easeOut' }}
|
||||
/>
|
||||
</span>
|
||||
<span className="h-2 w-2 shrink-0 rounded-full border-2 border-border bg-surface" />
|
||||
</div>
|
||||
|
||||
<div className="mt-1 flex items-baseline justify-between gap-3 text-caption">
|
||||
<span className="truncate text-ink-2">{held}</span>
|
||||
<span className="shrink-0 text-ink-4">
|
||||
{levelLabel(state.nextLevel)}
|
||||
{state.requiredForNext > 0 && ` · ${state.completedInNext}/${state.requiredForNext}`}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function VerifiedSkills({
|
||||
pageId, profile, profiles = [], courses = [], positions = [], email, customSources = [],
|
||||
onOpenTraining, className,
|
||||
@@ -132,40 +185,34 @@ export function VerifiedSkills({
|
||||
<div>
|
||||
<ul className="divide-y divide-border rounded-xl border border-border">
|
||||
{(held.length ? held : states).map((state) => (
|
||||
<li key={state.skillId} className="flex items-center gap-3 px-4 py-2.5">
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-body-sm font-medium text-ink-1">
|
||||
{state.name}
|
||||
{/* The training path behind this level, named from the Forge
|
||||
definition attached to this page. The pathway wording is
|
||||
the level being worked towards, so a reader can see both
|
||||
where the person is and what they are on. */}
|
||||
{byId.get(state.skillId) && state.nextLevel && (
|
||||
<span className="font-normal text-ink-4">
|
||||
{' · '}{byId.get(state.skillId).name}
|
||||
{' · '}{levelLabel(state.nextLevel)} pathway
|
||||
</span>
|
||||
<li key={state.skillId} className="px-4 py-3">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-body-sm font-medium text-ink-1">
|
||||
{state.name}
|
||||
{/* The training path behind this level, named from the Forge
|
||||
definition attached to this page. The pathway wording is
|
||||
the level being worked towards, so a reader can see both
|
||||
where the person is and what they are on. */}
|
||||
{byId.get(state.skillId) && state.nextLevel && (
|
||||
<span className="font-normal text-ink-4">
|
||||
{' · '}{byId.get(state.skillId).name}
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<span
|
||||
className={cn(
|
||||
'inline-flex shrink-0 items-center gap-1 rounded-full px-2 py-0.5 text-[11px] font-semibold',
|
||||
STATUS_STYLE[state.status]
|
||||
)}
|
||||
</p>
|
||||
<p className="truncate text-caption text-ink-3">
|
||||
{state.verifiedLabel}
|
||||
{state.nextLevel && state.requiredForNext > 0 && (
|
||||
<span className="text-ink-4">
|
||||
{' · '}{state.completedInNext}/{state.requiredForNext} towards {levelLabel(state.nextLevel)}
|
||||
{' · '}{state.progressToNext}%
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
>
|
||||
{state.verifiedLevel && <ShieldCheck className="h-3 w-3" aria-hidden="true" />}
|
||||
{STATUS_LABEL[state.status]}
|
||||
</span>
|
||||
</div>
|
||||
<span
|
||||
className={cn(
|
||||
'inline-flex shrink-0 items-center gap-1 rounded-full px-2 py-0.5 text-[11px] font-semibold',
|
||||
STATUS_STYLE[state.status]
|
||||
)}
|
||||
>
|
||||
{state.verifiedLevel && <ShieldCheck className="h-3 w-3" aria-hidden="true" />}
|
||||
{STATUS_LABEL[state.status]}
|
||||
</span>
|
||||
|
||||
<SkillPath state={state} />
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
401
src/components/skills/SkillSections.jsx
Normal file
401
src/components/skills/SkillSections.jsx
Normal file
@@ -0,0 +1,401 @@
|
||||
import React from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { ArrowRight, ChevronRight, Sparkles } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { ProgressBar } from '@/components/ds';
|
||||
|
||||
/**
|
||||
* The components a skill definition may ask for, and nothing else.
|
||||
*
|
||||
* `type: flow` in a Markdown file resolves to `FlowSection` through the table
|
||||
* at the bottom of this file. That table is the only bridge between a
|
||||
* definition and a React component: a definition names a key, the renderer
|
||||
* looks it up, and a key that is not here was already refused by validation.
|
||||
* There is no dynamic import, no component name evaluated from a string, and
|
||||
* no markup carried from the file.
|
||||
*
|
||||
* Every section is built from the existing KROW vocabulary — the same borders,
|
||||
* blue, pills and type as the rest of Admin — so an extension looks like part
|
||||
* of the product rather than like an embedded widget.
|
||||
*/
|
||||
|
||||
/** What a section shows when its data has nothing in it. Never a fake figure. */
|
||||
function EmptyNote({ children }) {
|
||||
return (
|
||||
<p className="rounded-lg border border-dashed border-border px-3 py-3 text-body-sm text-ink-3">
|
||||
{children}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A sequence — periods, or the stages of a funnel.
|
||||
*
|
||||
* Built from flex and borders rather than a graph library: the shape is a row
|
||||
* of steps with connectors, and a dependency for that would be heavier than the
|
||||
* feature. Wraps to a column on narrow screens, where a horizontal flow would
|
||||
* either overflow or become unreadable.
|
||||
*/
|
||||
function FlowSection({ data }) {
|
||||
if (data.empty || !data.steps?.length) return <EmptyNote>{data.emptyNote}</EmptyNote>;
|
||||
|
||||
const peak = Math.max(...data.steps.map((s) => s.value || 0), 1);
|
||||
|
||||
return (
|
||||
<ol className="flex flex-col gap-2 sm:flex-row sm:items-stretch">
|
||||
{data.steps.map((step, i) => (
|
||||
<React.Fragment key={step.id}>
|
||||
{i > 0 && (
|
||||
<li aria-hidden="true" className="flex shrink-0 items-center justify-center text-ink-4">
|
||||
{/* The connector turns with the layout: down when stacked. */}
|
||||
<ChevronRight className="hidden h-4 w-4 sm:block" />
|
||||
<span className="text-[11px] sm:hidden">↓</span>
|
||||
</li>
|
||||
)}
|
||||
<li className="min-w-0 flex-1 rounded-xl border border-border bg-surface px-3 py-2.5">
|
||||
<p className="text-[10px] font-semibold uppercase tracking-wide text-ink-4">{step.label}</p>
|
||||
<p className="mt-0.5 font-heading text-title font-bold tabular-nums text-ink-1">{step.value}</p>
|
||||
{step.detail && <p className="text-caption text-ink-3">{step.detail}</p>}
|
||||
<div className="mt-1.5 h-1 overflow-hidden rounded-full bg-surface-sunken">
|
||||
<div
|
||||
className="h-full rounded-full bg-krow-blue transition-all"
|
||||
style={{ width: `${Math.round(((step.value || 0) / peak) * 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
</li>
|
||||
</React.Fragment>
|
||||
))}
|
||||
</ol>
|
||||
);
|
||||
}
|
||||
|
||||
/** Counted figures, side by side. */
|
||||
function StatsSection({ data }) {
|
||||
const items = data.steps || data.items || [];
|
||||
if (data.empty || !items.length) return <EmptyNote>{data.emptyNote}</EmptyNote>;
|
||||
|
||||
return (
|
||||
<dl className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-5">
|
||||
{items.map((item) => (
|
||||
<div key={item.id} className="rounded-xl border border-border bg-surface px-3 py-2.5">
|
||||
<dt className="text-[10px] font-semibold uppercase tracking-wide text-ink-4">
|
||||
{item.label || item.title}
|
||||
</dt>
|
||||
<dd className="mt-0.5 font-heading text-title font-bold tabular-nums text-ink-1">
|
||||
{item.value ?? '—'}
|
||||
</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
);
|
||||
}
|
||||
|
||||
/** Records as rows, each optionally linking to the record it names. */
|
||||
function ListSection({ data }) {
|
||||
const items = data.items || [];
|
||||
if (data.empty || !items.length) return <EmptyNote>{data.emptyNote}</EmptyNote>;
|
||||
|
||||
return (
|
||||
<ul className="divide-y divide-border overflow-hidden rounded-xl border border-border">
|
||||
{items.map((item) => {
|
||||
const body = (
|
||||
<>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate text-body-sm font-medium text-ink-1">{item.title}</span>
|
||||
{item.detail && <span className="block truncate text-caption text-ink-3">{item.detail}</span>}
|
||||
</span>
|
||||
{item.value != null && (
|
||||
<span className="shrink-0 font-heading text-body font-bold tabular-nums text-ink-1">
|
||||
{item.value}
|
||||
</span>
|
||||
)}
|
||||
{item.to && <ArrowRight className="h-3.5 w-3.5 shrink-0 text-ink-4" aria-hidden="true" />}
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<li key={item.id}>
|
||||
{item.to ? (
|
||||
<Link to={item.to} className="flex items-center gap-3 px-3 py-2.5 transition-colors hover:bg-surface-subtle">
|
||||
{body}
|
||||
</Link>
|
||||
) : (
|
||||
<div className="flex items-center gap-3 px-3 py-2.5">{body}</div>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
|
||||
/** Dated events, most recent first. */
|
||||
function TimelineSection({ data }) {
|
||||
const items = [...(data.items || [])]
|
||||
.filter((item) => item.at)
|
||||
.sort((a, b) => new Date(b.at) - new Date(a.at));
|
||||
if (data.empty || !items.length) return <EmptyNote>{data.emptyNote}</EmptyNote>;
|
||||
|
||||
return (
|
||||
<ol className="space-y-2">
|
||||
{items.map((item) => (
|
||||
<li key={item.id} className="flex items-baseline justify-between gap-3 text-body-sm">
|
||||
<span className="min-w-0">
|
||||
<span className="font-medium text-ink-1">{item.title}</span>
|
||||
{item.detail && <span className="text-ink-3"> — {item.detail}</span>}
|
||||
</span>
|
||||
<span className="shrink-0 text-caption text-ink-4">
|
||||
{new Date(item.at).toLocaleDateString(undefined, { month: 'short', day: 'numeric' })}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
);
|
||||
}
|
||||
|
||||
/** Rows and columns, from whatever the source declared. */
|
||||
function TableSection({ data }) {
|
||||
const items = data.items || data.steps || [];
|
||||
const columns = data.columns || [
|
||||
{ key: 'label', label: 'Item' },
|
||||
{ key: 'value', label: 'Value', align: 'right' },
|
||||
];
|
||||
if (data.empty || !items.length) return <EmptyNote>{data.emptyNote}</EmptyNote>;
|
||||
|
||||
return (
|
||||
<div className="overflow-x-auto rounded-xl border border-border">
|
||||
<table className="w-full border-collapse text-body-sm">
|
||||
<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 text-[10px] font-bold uppercase tracking-wide text-ink-4',
|
||||
c.align === 'right' ? 'text-right' : 'text-left'
|
||||
)}
|
||||
>
|
||||
{c.label}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{items.map((item) => (
|
||||
<tr key={item.id} className="border-b border-border last:border-0">
|
||||
{columns.map((c) => (
|
||||
<td
|
||||
key={c.key}
|
||||
className={cn(
|
||||
'px-3 py-2 text-ink-2',
|
||||
c.align === 'right' ? 'text-right tabular-nums' : 'text-left'
|
||||
)}
|
||||
>
|
||||
{item[c.key] ?? '—'}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Bars against a total. */
|
||||
function ProgressSection({ data }) {
|
||||
const items = data.items || data.steps || [];
|
||||
if (data.empty || !items.length) return <EmptyNote>{data.emptyNote}</EmptyNote>;
|
||||
|
||||
const peak = Math.max(...items.map((i) => i.max || i.value || 0), 1);
|
||||
|
||||
return (
|
||||
<ul className="space-y-2.5">
|
||||
{items.map((item) => {
|
||||
const max = item.max || peak;
|
||||
const percent = max ? Math.round(((item.value || 0) / max) * 100) : 0;
|
||||
return (
|
||||
<li key={item.id}>
|
||||
<div className="mb-1 flex items-baseline justify-between gap-3">
|
||||
<span className="min-w-0 truncate text-body-sm capitalize text-ink-2">
|
||||
{item.title || item.label}
|
||||
</span>
|
||||
<span className="shrink-0 text-caption font-semibold tabular-nums text-ink-1">
|
||||
{item.value ?? 0}{item.max ? `/${item.max}` : ''}
|
||||
</span>
|
||||
</div>
|
||||
<ProgressBar value={percent} tone="brand" size="sm" />
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
|
||||
/** One finding, stated plainly. */
|
||||
function InsightSection({ data }) {
|
||||
const items = data.items || data.steps || [];
|
||||
if (data.empty || !items.length) return <EmptyNote>{data.emptyNote}</EmptyNote>;
|
||||
|
||||
const lead = items.reduce((best, item) => ((item.value || 0) > (best?.value || 0) ? item : best), items[0]);
|
||||
|
||||
return (
|
||||
<div className="flex items-start gap-2.5 rounded-xl border border-krow-blue/30 bg-krow-blue-tint/50 px-3.5 py-3">
|
||||
<Sparkles className="mt-0.5 h-4 w-4 shrink-0 text-krow-blue" aria-hidden="true" />
|
||||
<p className="text-body-sm leading-relaxed text-ink-2">
|
||||
<span className="font-semibold text-ink-1">{lead.label || lead.title}</span>
|
||||
{lead.value != null && <> — <span className="tabular-nums">{lead.value}</span></>}
|
||||
{lead.detail && <span className="text-ink-3"> · {lead.detail}</span>}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** A plain panel: whatever figures the source gives, as a sentence and a row. */
|
||||
function CardSection({ data }) {
|
||||
const items = data.steps || data.items || [];
|
||||
if (data.empty || !items.length) return <EmptyNote>{data.emptyNote}</EmptyNote>;
|
||||
|
||||
return (
|
||||
<div className="rounded-xl border border-border bg-surface-subtle px-3.5 py-3">
|
||||
<dl className="flex flex-wrap gap-x-6 gap-y-2">
|
||||
{items.slice(0, 6).map((item) => (
|
||||
<div key={item.id} className="min-w-0">
|
||||
<dt className="text-[10px] font-semibold uppercase tracking-wide text-ink-4">
|
||||
{item.label || item.title}
|
||||
</dt>
|
||||
<dd className="text-body-sm font-medium tabular-nums text-ink-1">{item.value ?? item.detail ?? '—'}</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Weighted criteria that share one budget.
|
||||
*
|
||||
* Distinct from `ProgressSection`, which draws bars against independent
|
||||
* maximums. Here the rows are shares of a single total, so the total is stated
|
||||
* once for the set and flagged when it does not come to 100 — which is the only
|
||||
* thing that makes a set of weights wrong rather than merely different.
|
||||
*
|
||||
* The same component draws the page card and Owliver's compact answer; `compact`
|
||||
* only tightens spacing. Two renderings of one declaration, not two components.
|
||||
*
|
||||
* **Editing.** Controls appear only when the definition asked for them *and* the
|
||||
* surface passed an `onApply` — which a page only supplies for a source it owns.
|
||||
* Adjustments are held locally until Apply, so a half-dragged slider never
|
||||
* writes a total of 87% into the form; Apply hands the whole set over at once
|
||||
* and the page's own state is the only place it lands.
|
||||
*/
|
||||
function WeightsSection({ data, section, onApply, compact = false }) {
|
||||
const rows = React.useMemo(() => data?.steps || data?.items || [], [data]);
|
||||
const editable = Boolean(section?.editable && onApply);
|
||||
|
||||
/* Local only while editing, and re-seeded whenever the page's own values
|
||||
change — so a change made on the form shows up here even mid-edit, and
|
||||
there is never a second copy of the weights outliving the interaction. */
|
||||
const [draft, setDraft] = React.useState(null);
|
||||
const signature = React.useMemo(
|
||||
() => rows.map((r) => `${r.id}:${r.value}`).join('|'),
|
||||
[rows]
|
||||
);
|
||||
React.useEffect(() => { setDraft(null); }, [signature]);
|
||||
|
||||
if (data?.empty || !rows.length) return <EmptyNote>{data?.emptyNote}</EmptyNote>;
|
||||
|
||||
const current = draft || Object.fromEntries(rows.map((r) => [r.id, Number(r.value) || 0]));
|
||||
const total = Object.values(current).reduce((a, b) => a + b, 0);
|
||||
const balanced = total === 100;
|
||||
const dirty = draft !== null && rows.some((r) => (Number(r.value) || 0) !== current[r.id]);
|
||||
|
||||
return (
|
||||
<div className={compact ? 'space-y-2' : 'space-y-2.5'}>
|
||||
<ul className={compact ? 'space-y-1.5' : 'space-y-2.5'}>
|
||||
{rows.map((row) => {
|
||||
const value = current[row.id] ?? 0;
|
||||
return (
|
||||
<li key={row.id}>
|
||||
<div className="flex items-baseline justify-between gap-3">
|
||||
<span className="min-w-0 truncate text-body-sm capitalize text-ink-2">
|
||||
{row.title || row.label}
|
||||
</span>
|
||||
<span className="shrink-0 text-caption font-semibold tabular-nums text-ink-1">
|
||||
{value}%
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{editable ? (
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="100"
|
||||
value={value}
|
||||
aria-label={`${row.title || row.label} weight`}
|
||||
onChange={(e) => setDraft({ ...current, [row.id]: Number(e.target.value) })}
|
||||
className="mt-1 w-full accent-krow-blue"
|
||||
/>
|
||||
) : (
|
||||
<div className="mt-1 h-1.5 overflow-hidden rounded-full bg-surface-sunken">
|
||||
<div
|
||||
className="h-full rounded-full bg-krow-blue transition-all"
|
||||
style={{ width: `${Math.min(100, Math.max(0, value))}%` }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
|
||||
<div className="flex flex-wrap items-center justify-between gap-2 border-t border-border pt-2">
|
||||
<span className="text-caption text-ink-3">
|
||||
Total{' '}
|
||||
<span
|
||||
className={cn(
|
||||
'font-semibold tabular-nums',
|
||||
balanced ? 'text-success' : 'text-warning'
|
||||
)}
|
||||
>
|
||||
{total}%
|
||||
</span>
|
||||
{!balanced && <span className="text-ink-4"> — must total 100%</span>}
|
||||
</span>
|
||||
|
||||
{editable && (
|
||||
<button
|
||||
type="button"
|
||||
disabled={!dirty || !balanced}
|
||||
onClick={() => { onApply(current); setDraft(null); }}
|
||||
className="inline-flex items-center rounded-lg bg-krow-blue px-2.5 py-1 text-caption font-semibold text-white
|
||||
transition-colors hover:bg-krow-blue-strong disabled:cursor-not-allowed disabled:opacity-40
|
||||
focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-krow-blue/50"
|
||||
>
|
||||
Apply to position
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The registry: declared type → component.
|
||||
*
|
||||
* The only mapping from a definition to rendering. Adding a type means adding a
|
||||
* component here and a name in `surfaces.js` — never a branch on a skill id.
|
||||
*/
|
||||
export const SECTION_COMPONENTS = {
|
||||
card: CardSection,
|
||||
stats: StatsSection,
|
||||
list: ListSection,
|
||||
timeline: TimelineSection,
|
||||
flow: FlowSection,
|
||||
table: TableSection,
|
||||
progress: ProgressSection,
|
||||
insight: InsightSection,
|
||||
weights: WeightsSection,
|
||||
};
|
||||
181
src/components/skills/SkillSurface.jsx
Normal file
181
src/components/skills/SkillSurface.jsx
Normal file
@@ -0,0 +1,181 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import { Sparkles } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import {
|
||||
useApplications, useAssignments, useCourses, useCurrentUser, useInterviews, useJobPostings,
|
||||
usePreferences, useStaff, useUserActivity, useWorkerProfiles,
|
||||
} from '@/lib/krowHooks';
|
||||
import { allSkills } from '@/lib/skills/registry';
|
||||
import { sectionsForPage } from '@/lib/skills/uiConfig';
|
||||
import { resolveSkillData } from '@/lib/skills/dataResolver';
|
||||
import { useWorkforcePaths } from '@/lib/skills/usePageSkills';
|
||||
import { profileForEmail } from '@/lib/skillGraph';
|
||||
import { SECTION_COMPONENTS } from '@/components/skills/SkillSections';
|
||||
/* The page's current selection, from the one channel that carries it. Imported
|
||||
from the module rather than the package index so a page section never pulls
|
||||
the assistant panel in behind it. */
|
||||
import { usePageAction, usePageContext } from '@/components/ai-assistant/PageContext';
|
||||
|
||||
/**
|
||||
* The extension point: one controlled slot a page offers to skills.
|
||||
*
|
||||
* A page says where a skill *may* appear; a skill definition says where it
|
||||
* *does*. Neither knows the other exists — the page names a surface and a
|
||||
* placement, the definition names the same two things, and this component is
|
||||
* what joins them. That is why a new skill needs no change to any page, and why
|
||||
* a page can be redesigned without touching a skill.
|
||||
*
|
||||
* <SkillSurface page="positions" placement="after-position-summary"
|
||||
* context={{ position }} />
|
||||
*
|
||||
* What it will not do is as important as what it does. It renders only sections
|
||||
* that survived validation, only from skills that are active, only through the
|
||||
* component table, and only with data the resolver produced from real records.
|
||||
* Nothing from the Markdown reaches the DOM as markup.
|
||||
*/
|
||||
|
||||
/** The skills contributing sections to this page right now. */
|
||||
function useSkillSections(page, placement) {
|
||||
const preferences = usePreferences();
|
||||
const customKey = JSON.stringify(preferences.customSkills || []);
|
||||
const disabledKey = JSON.stringify(preferences.disabledSkills || []);
|
||||
|
||||
return useMemo(() => {
|
||||
const custom = JSON.parse(customKey);
|
||||
const disabled = JSON.parse(disabledKey);
|
||||
|
||||
return allSkills(custom)
|
||||
/* Inactive means registered but not offered — the same rule the assistant
|
||||
follows, so switching a skill off removes its UI too. */
|
||||
.filter((skill) => skill.status === 'active' && !disabled.includes(skill.id))
|
||||
.flatMap((skill) => sectionsForPage(skill, page)
|
||||
.filter((section) => !placement || section.placement === placement)
|
||||
.map((section) => ({ skill, section })));
|
||||
}, [page, placement, customKey, disabledKey]);
|
||||
}
|
||||
|
||||
/**
|
||||
* The records a section may be read from.
|
||||
*
|
||||
* Loaded once per surface rather than per section, and passed to the resolver —
|
||||
* which is the only thing that touches them. A section never queries anything.
|
||||
*
|
||||
* Exported because the panel needs the identical reading: an answer drawn in the
|
||||
* chat and a card drawn on the page must resolve from one set of collections, or
|
||||
* the two surfaces of the same definition can disagree. Every hook inside is a
|
||||
* cache read the app has already paid for.
|
||||
*/
|
||||
export function useSkillDataContext(context) {
|
||||
/* What the page published about itself, for a surface that was not handed a
|
||||
record directly. An explicit `context` prop still wins — a card knows which
|
||||
position it is, and that is more specific than what the page says. */
|
||||
const published = usePageContext();
|
||||
const { data: applications = [] } = useApplications();
|
||||
const { data: positions = [] } = useJobPostings();
|
||||
const { data: interviews = [] } = useInterviews();
|
||||
const { data: courses = [] } = useCourses();
|
||||
const { data: workerProfiles = [] } = useWorkerProfiles();
|
||||
/* The hires and the audit trail, for the sources that read outcomes and
|
||||
events. Same caches the pages themselves render from, so a section and the
|
||||
panel beside it can never disagree. */
|
||||
const { data: staff = [] } = useStaff();
|
||||
const { data: activity = [] } = useUserActivity();
|
||||
/* Who is already committed, for the sources that score people against a role:
|
||||
the matching engine reads availability from assignments, and without them
|
||||
everybody would look free. */
|
||||
const { data: assignments = [] } = useAssignments();
|
||||
const { data: user } = useCurrentUser();
|
||||
const { statesFor } = useWorkforcePaths();
|
||||
|
||||
const trainingPaths = useMemo(
|
||||
() => statesFor(courses, profileForEmail(workerProfiles, user?.email)),
|
||||
[statesFor, courses, workerProfiles, user?.email]
|
||||
);
|
||||
|
||||
return useMemo(() => ({
|
||||
...published,
|
||||
...context,
|
||||
applications,
|
||||
positions,
|
||||
interviews,
|
||||
courses,
|
||||
workerProfiles,
|
||||
/* The workforce engine's own name for the same records. Both keys are
|
||||
published so a resolver can be written against either without a rename
|
||||
rippling through every existing source. */
|
||||
profiles: workerProfiles,
|
||||
assignments,
|
||||
staff,
|
||||
activity,
|
||||
trainingPaths,
|
||||
/* `published` belongs here: it is what changes when the reader edits the
|
||||
form this panel is sitting beside, and leaving it out froze every
|
||||
section on the first value the page ever published. */
|
||||
}), [published, context, applications, positions, interviews, courses, workerProfiles, assignments,
|
||||
staff, activity, trainingPaths]);
|
||||
}
|
||||
|
||||
/** One declared section, resolved and drawn. */
|
||||
function SkillSection({ skill, section, context }) {
|
||||
const Component = SECTION_COMPONENTS[section.type];
|
||||
const data = useMemo(
|
||||
() => resolveSkillData(section, context),
|
||||
[section, context]
|
||||
);
|
||||
|
||||
/* How this section writes back, if the page is offering that write at all.
|
||||
Null on every page that is not, which is what keeps a section declared
|
||||
`editable` honest on a surface that only reads. */
|
||||
const apply = usePageAction(section.editable ? section.source : null);
|
||||
|
||||
/* Validation refuses an unknown type long before this, so a missing component
|
||||
means the registry and the vocabulary have drifted apart. Render nothing
|
||||
rather than a broken panel. */
|
||||
if (!Component) return null;
|
||||
|
||||
return (
|
||||
<section
|
||||
aria-label={section.title || skill.name}
|
||||
className="rounded-2xl border border-border bg-surface p-5 shadow-xs"
|
||||
>
|
||||
<div className="mb-3 flex flex-wrap items-start justify-between gap-2">
|
||||
<div className="min-w-0">
|
||||
<h3 className="font-heading text-body font-semibold text-ink-1">
|
||||
{section.title || skill.name}
|
||||
</h3>
|
||||
{section.description && (
|
||||
<p className="mt-0.5 text-caption leading-relaxed text-ink-3">{section.description}</p>
|
||||
)}
|
||||
</div>
|
||||
{/* Attribution, so an admin can tell an extension from a built-in panel
|
||||
and knows which skill to switch off. */}
|
||||
<span className="inline-flex shrink-0 items-center gap-1 rounded-full bg-krow-blue-tint px-2 py-0.5 text-[10px] font-semibold text-krow-blue">
|
||||
<Sparkles className="h-3 w-3" aria-hidden="true" />
|
||||
{skill.name}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<Component data={data} section={section} onApply={apply} />
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export function SkillSurface({ page, placement, context = null, className }) {
|
||||
const sections = useSkillSections(page, placement);
|
||||
const resolved = useSkillDataContext(context);
|
||||
|
||||
if (!sections.length) return null;
|
||||
|
||||
return (
|
||||
<div className={cn('space-y-4', className)}>
|
||||
{sections.map(({ skill, section }) => (
|
||||
<SkillSection
|
||||
key={`${skill.id}:${section.id}`}
|
||||
skill={skill}
|
||||
section={section}
|
||||
context={resolved}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
315
src/lib/hiringRecords.js
Normal file
315
src/lib/hiringRecords.js
Normal file
@@ -0,0 +1,315 @@
|
||||
/**
|
||||
* The hiring record, derived once.
|
||||
*
|
||||
* Analytics and Hired History ask different questions of the same facts — "how
|
||||
* is our hiring performing" and "who did we hire, and what happened" — and they
|
||||
* answer them with different pages. What they must never do is *count*
|
||||
* differently: a total on one page and the same total on the other have to be
|
||||
* the same number, or the two pages stop being two views and become two claims.
|
||||
*
|
||||
* So the counting lives here, in plain functions over the collections the app
|
||||
* already holds, and each page renders what it needs from the result. Nothing in
|
||||
* this module knows what either page looks like.
|
||||
*/
|
||||
|
||||
/** The mean of a numeric list, rounded. Zero for an empty list. */
|
||||
export const avg = (xs) => {
|
||||
const values = xs.filter((n) => Number.isFinite(n));
|
||||
return values.length ? Math.round(values.reduce((a, b) => a + b, 0) / values.length) : 0;
|
||||
};
|
||||
|
||||
/**
|
||||
* Everyone hired, as one list.
|
||||
*
|
||||
* A Staff record is the hire; the application it came from carries how long it
|
||||
* took and what it scored, and the posting carries the department. Joined here
|
||||
* so neither page repeats the join — and so "department" means the same thing
|
||||
* on both.
|
||||
*/
|
||||
export function buildHires({ staff = [], applications = [], postings = [] }) {
|
||||
return staff.map((s) => {
|
||||
const app = applications.find((a) => a.id === s.application_id);
|
||||
const posting = postings.find((p) => p.id === s.job_posting_id);
|
||||
const days = app
|
||||
? Math.max(1, Math.round((new Date(app.updated_date) - new Date(app.created_date)) / 86400000))
|
||||
: null;
|
||||
|
||||
return {
|
||||
...s,
|
||||
company: s.company || posting?.company || '—',
|
||||
department: posting?.role_category || s.department || '—',
|
||||
role: s.role || posting?.title || '—',
|
||||
timeToHire: days || s.timeToHire || null,
|
||||
score: s.ai_score || app?.ai_score || s.score || null,
|
||||
profile_tier: s.profile_tier || 'skilled',
|
||||
hire_date: s.hire_date || s.created_date || null,
|
||||
applicationId: app?.id || s.application_id || null,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Demo fill, carried over from the page this module was extracted from.
|
||||
*
|
||||
* The store seeds three Staff records; Hired History has always padded that to
|
||||
* eight so the page reads as a hiring history rather than as three rows. That
|
||||
* padding is pre-existing product behaviour, not something derived — it is kept
|
||||
* here, named for what it is, so both pages show what the page has always shown
|
||||
* and there is one list to delete when the deployment has real volume.
|
||||
*
|
||||
* It only ever *adds* people the store does not already have, matched on email,
|
||||
* so a real hire is never shadowed by a demo one.
|
||||
*/
|
||||
const DEMO_FILL = [
|
||||
{ id: 's4', name: 'Sophia Chen', email: 'sophia.chen@email.com', role: 'Guest Relations Lead', department: 'Front Desk', profile_tier: 'expert', score: 90, timeToHire: 2, hire_date: '2026-07-22', status: 'hired' },
|
||||
{ id: 's5', name: 'Oliver Bennett', email: 'oliver.b@email.com', role: 'Event Coordinator', department: 'Event Manager', profile_tier: 'skilled', score: 91, timeToHire: 3, hire_date: '2026-07-20', status: 'hired' },
|
||||
{ id: 's6', name: 'Aaliyah Patel', email: 'aaliyah.p@email.com', role: 'Operations Supervisor', department: 'Housekeeping', profile_tier: 'solid', score: 87, timeToHire: 2, hire_date: '2026-07-18', status: 'hired' },
|
||||
{ id: 's7', name: 'Lucas Wright', email: 'lucas.w@email.com', role: 'Concierge Lead', department: 'Front Desk', profile_tier: 'solid', score: 88, timeToHire: 2, hire_date: '2026-07-15', status: 'hired' },
|
||||
{ id: 's8', name: 'Elena Rostova', email: 'elena.r@email.com', role: 'Lead Security Officer', department: 'Security', profile_tier: 'expert', score: 95, timeToHire: 1, hire_date: '2026-07-12', status: 'hired' },
|
||||
];
|
||||
|
||||
/** The joined hires, with the demo fill applied for anyone not already on file. */
|
||||
export function hiresWithFill(sources) {
|
||||
const live = buildHires(sources);
|
||||
const seen = new Set(live.map((h) => String(h.email || h.name).toLowerCase()));
|
||||
|
||||
const filled = [...live];
|
||||
for (const person of DEMO_FILL) {
|
||||
const key = String(person.email || person.name).toLowerCase();
|
||||
if (!seen.has(key)) {
|
||||
filled.push({ ...person, company: person.company || '—' });
|
||||
seen.add(key);
|
||||
}
|
||||
}
|
||||
return filled;
|
||||
}
|
||||
|
||||
/** Headline figures: volume, speed, quality, and how many are still on. */
|
||||
export function summarise(hires) {
|
||||
return {
|
||||
total: hires.length,
|
||||
speed: avg(hires.map((h) => h.timeToHire)),
|
||||
quality: avg(hires.map((h) => h.score)),
|
||||
active: hires.filter((h) => h.status !== 'inactive').length,
|
||||
onboarding: hires.filter((h) => h.status === 'onboarding').length,
|
||||
};
|
||||
}
|
||||
|
||||
/** The application stages this product counts, in the order they happen. */
|
||||
export const STAGE_ORDER = ['applied', 'ai_screened', 'shortlisted', 'interview', 'hired'];
|
||||
|
||||
/**
|
||||
* Applied → screened → shortlisted → interview → hired, with the pass-through
|
||||
* rate and the loss at each step.
|
||||
*
|
||||
* Counted at-or-beyond, so a candidate who reached interview is counted as
|
||||
* having been screened — a funnel that counts only the current status shows
|
||||
* later stages as larger than earlier ones, which is not a funnel.
|
||||
*/
|
||||
export function buildFunnel(applications) {
|
||||
const atOrBeyond = (stage) => {
|
||||
const from = STAGE_ORDER.indexOf(stage);
|
||||
return applications.filter((a) => STAGE_ORDER.indexOf(a.status) >= from).length;
|
||||
};
|
||||
|
||||
const stages = [
|
||||
{ key: 'applied', label: 'Applied', count: applications.length },
|
||||
{ key: 'ai_screened', label: 'Screened', count: atOrBeyond('ai_screened') },
|
||||
{ key: 'shortlisted', label: 'Shortlisted', count: atOrBeyond('shortlisted') },
|
||||
{ key: 'interview', label: 'Interview', count: atOrBeyond('interview') },
|
||||
{ key: 'hired', label: 'Hired', count: applications.filter((a) => a.status === 'hired').length },
|
||||
];
|
||||
|
||||
const transitions = stages.slice(1).map((stage, i) => {
|
||||
const previous = stages[i];
|
||||
return {
|
||||
from: previous.key,
|
||||
to: stage.key,
|
||||
rate: previous.count ? Math.round((stage.count / previous.count) * 100) : 0,
|
||||
lost: Math.max(0, previous.count - stage.count),
|
||||
};
|
||||
});
|
||||
|
||||
/* The step losing the most people — the one worth acting on. */
|
||||
const weakest = transitions.reduce(
|
||||
(worst, t) => (!worst || t.rate < worst.rate ? t : worst),
|
||||
null
|
||||
);
|
||||
|
||||
return {
|
||||
stages,
|
||||
transitions,
|
||||
weakestKey: weakest?.to || null,
|
||||
conversion: applications.length
|
||||
? Math.round((stages[4].count / applications.length) * 100)
|
||||
: 0,
|
||||
};
|
||||
}
|
||||
|
||||
/** Cumulative hires by month — a trend needs a baseline, not a single bar. */
|
||||
export function buildTrend(hires) {
|
||||
const byMonth = new Map();
|
||||
hires.filter((h) => h.hire_date).forEach((h) => {
|
||||
const d = new Date(h.hire_date);
|
||||
const key = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`;
|
||||
byMonth.set(key, (byMonth.get(key) || 0) + 1);
|
||||
});
|
||||
|
||||
let running = 0;
|
||||
return [...byMonth.entries()].sort().map(([key, count]) => {
|
||||
running += count;
|
||||
const [y, m] = key.split('-');
|
||||
return {
|
||||
label: new Date(Number(y), Number(m) - 1).toLocaleDateString(undefined, { month: 'short' }),
|
||||
hires: count,
|
||||
cumulative: running,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/** Hires grouped by department, best-performing first. */
|
||||
export function byDepartment(hires) {
|
||||
const map = new Map();
|
||||
|
||||
hires.forEach((h) => {
|
||||
const name = h.department && h.department !== '—' ? h.department : 'General';
|
||||
const entry = map.get(name) || {
|
||||
name, department: name, count: 0, scores: [], times: [], rolesSet: new Set(), hiresList: [],
|
||||
};
|
||||
entry.count += 1;
|
||||
if (h.score) entry.scores.push(h.score);
|
||||
if (h.timeToHire) entry.times.push(h.timeToHire);
|
||||
if (h.role && h.role !== '—') entry.rolesSet.add(h.role);
|
||||
entry.hiresList.push(h);
|
||||
map.set(name, entry);
|
||||
});
|
||||
|
||||
return [...map.values()]
|
||||
.map((e) => ({
|
||||
name: e.name,
|
||||
department: e.department,
|
||||
count: e.count,
|
||||
scores: e.scores,
|
||||
avgScore: avg(e.scores),
|
||||
avgTimeToHire: avg(e.times),
|
||||
roles: [...e.rolesSet],
|
||||
hiresList: e.hiresList,
|
||||
}))
|
||||
.sort((a, b) => (b.avgScore || 0) - (a.avgScore || 0) || b.count - a.count);
|
||||
}
|
||||
|
||||
/** Hires grouped by the role they were hired into, most-filled first. */
|
||||
export function byPosition(hires) {
|
||||
const map = new Map();
|
||||
|
||||
hires.forEach((h) => {
|
||||
const key = h.role && h.role !== '—' ? h.role : 'Unspecified';
|
||||
const entry = map.get(key) || { role: key, count: 0, scores: [], days: [], rated: 0, ratings: [] };
|
||||
entry.count += 1;
|
||||
if (h.score) entry.scores.push(h.score);
|
||||
if (h.timeToHire) entry.days.push(h.timeToHire);
|
||||
if (h.client_rating) { entry.rated += 1; entry.ratings.push(h.client_rating); }
|
||||
map.set(key, entry);
|
||||
});
|
||||
|
||||
return [...map.values()]
|
||||
.map((e) => ({
|
||||
role: e.role,
|
||||
count: e.count,
|
||||
avgScore: avg(e.scores),
|
||||
avgDays: avg(e.days),
|
||||
rated: e.rated,
|
||||
avgRating: e.ratings.length
|
||||
? Number((e.ratings.reduce((a, b) => a + b, 0) / e.ratings.length).toFixed(1))
|
||||
: null,
|
||||
}))
|
||||
.sort((a, b) => b.count - a.count);
|
||||
}
|
||||
|
||||
/**
|
||||
* Where hiring is slow, and where it is fast.
|
||||
*
|
||||
* Velocity is only meaningful against something, so each role is measured
|
||||
* against the workspace's own median rather than an industry figure nobody
|
||||
* here can check.
|
||||
*/
|
||||
export function buildEfficiency(hires) {
|
||||
const timed = hires.filter((h) => h.timeToHire);
|
||||
if (!timed.length) return { median: 0, fastest: [], slowest: [], within48h: 0 };
|
||||
|
||||
const sorted = [...timed].sort((a, b) => a.timeToHire - b.timeToHire);
|
||||
const median = sorted[Math.floor(sorted.length / 2)].timeToHire;
|
||||
|
||||
const roles = byPosition(timed).filter((r) => r.avgDays);
|
||||
|
||||
return {
|
||||
median,
|
||||
fastest: [...roles].sort((a, b) => a.avgDays - b.avgDays).slice(0, 4),
|
||||
slowest: [...roles].sort((a, b) => b.avgDays - a.avgDays).slice(0, 4),
|
||||
within48h: Math.round((timed.filter((h) => h.timeToHire <= 2).length / timed.length) * 100),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* What the numbers say, as findings rather than figures.
|
||||
*
|
||||
* Every one is conditional on the data supporting it: a claim about the fastest
|
||||
* department is only made when there is more than one department to compare, and
|
||||
* a risk is only raised when something is actually at risk. A page that always
|
||||
* shows three insights is showing decoration.
|
||||
*/
|
||||
export function buildInsights({ hires, departments, positions, funnel, efficiency }) {
|
||||
const out = [];
|
||||
const summary = summarise(hires);
|
||||
|
||||
if (departments.length > 1) {
|
||||
const best = departments[0];
|
||||
out.push({
|
||||
tone: 'success',
|
||||
title: `${best.department} is hiring the strongest candidates`,
|
||||
body: `${best.count} hire${best.count === 1 ? '' : 's'} at an average score of ${best.avgScore}, against ${summary.quality} across the workspace.`,
|
||||
});
|
||||
}
|
||||
|
||||
if (funnel.weakestKey) {
|
||||
const weak = funnel.transitions.find((t) => t.to === funnel.weakestKey);
|
||||
const label = funnel.stages.find((s) => s.key === funnel.weakestKey)?.label;
|
||||
if (weak && weak.lost > 0) {
|
||||
out.push({
|
||||
tone: 'warning',
|
||||
title: `The largest drop-off is into ${label}`,
|
||||
body: `${weak.rate}% pass through and ${weak.lost} candidate${weak.lost === 1 ? '' : 's'} stop there. It is the step with the most to recover.`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (efficiency.slowest.length && efficiency.median) {
|
||||
const slow = efficiency.slowest[0];
|
||||
if (slow.avgDays > efficiency.median) {
|
||||
out.push({
|
||||
tone: 'risk',
|
||||
title: `${slow.role} takes longest to fill`,
|
||||
body: `${slow.avgDays} days on average against a median of ${efficiency.median}. ${slow.count} hire${slow.count === 1 ? '' : 's'} on that record.`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const unrated = positions.reduce((n, p) => n + (p.count - p.rated), 0);
|
||||
if (unrated > 0) {
|
||||
out.push({
|
||||
tone: 'info',
|
||||
title: `${unrated} hire${unrated === 1 ? '' : 's'} ${unrated === 1 ? 'has' : 'have'} no client review`,
|
||||
body: 'Quality of hire is measured on the AI score alone until a review lands. Chasing these closes the loop on outcomes.',
|
||||
});
|
||||
}
|
||||
|
||||
if (funnel.conversion) {
|
||||
out.push({
|
||||
tone: 'info',
|
||||
title: `${funnel.conversion}% of applicants are hired`,
|
||||
body: `${funnel.stages[4].count} of ${funnel.stages[0].count} applications reached a hire.`,
|
||||
});
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
@@ -432,9 +432,12 @@ const HANDLERS = {
|
||||
* Reached only from the confirmation step, after the position has been read
|
||||
* back and the user has chosen to create it.
|
||||
*/
|
||||
create_position: ({ draft }) => ({
|
||||
/* `status` is the one the conversation's confirmation step chose — draft or
|
||||
active — and is passed to the payload builder the form already uses, so
|
||||
both routes write the same record with the same defaults. */
|
||||
create_position: ({ draft, status }) => ({
|
||||
type: 'create_position',
|
||||
data: toPositionPayload(draft || {}),
|
||||
data: toPositionPayload(draft || {}, status ? { status } : undefined),
|
||||
}),
|
||||
|
||||
/**
|
||||
|
||||
@@ -9,30 +9,132 @@ import { parseSkill } from './registry';
|
||||
* writers with two shapes would be a second skill system by accident.
|
||||
*/
|
||||
|
||||
/** The starting definition offered to an author, in the existing format. */
|
||||
export const skillTemplate = ({ id = '', name = '', description = '', pages = [] } = {}) => `---
|
||||
id: ${id || 'my-skill'}
|
||||
name: ${name || 'My Skill'}
|
||||
description: ${description || 'What this skill helps Owliver do.'}
|
||||
pages:
|
||||
${(pages?.length ? pages : ['positions']).map((p) => ` - ${p}`).join('\n')}
|
||||
status: active
|
||||
triggers:
|
||||
- ${(name || 'my skill').toLowerCase()}
|
||||
---
|
||||
/**
|
||||
* The starting definitions offered to an author.
|
||||
*
|
||||
* Two templates, because there are two jobs and one of them was being learned
|
||||
* from the other's example. A UI skill's first draft declares a section; an
|
||||
* Owliver skill's declares triggers and the shapes of an answer. Both are the
|
||||
* same format, read by the same parser — what differs is which half of it the
|
||||
* author is being handed.
|
||||
*/
|
||||
|
||||
# ${name || 'My Skill'}
|
||||
const frontMatter = ({ id, name, description, pages, fallback }) => [
|
||||
`id: ${id || fallback.id}`,
|
||||
`name: ${name || fallback.name}`,
|
||||
`description: ${description || fallback.description}`,
|
||||
'pages:',
|
||||
(pages?.length ? pages : ['positions']).map((p) => ` - ${p}`).join('\n'),
|
||||
'status: active',
|
||||
].join('\n');
|
||||
|
||||
/** A definition that draws a section on the pages it names. */
|
||||
export const uiSkillTemplate = ({
|
||||
id = '', name = '', description = '', pages = [],
|
||||
type = 'flow', placement = '', source = 'position.activity', periods = [],
|
||||
} = {}) => `---
|
||||
${frontMatter({
|
||||
id,
|
||||
name,
|
||||
description,
|
||||
pages,
|
||||
fallback: {
|
||||
id: 'my-ui-skill',
|
||||
name: 'My UI Skill',
|
||||
description: 'What this skill adds to the page.',
|
||||
},
|
||||
})}
|
||||
ui:
|
||||
type: ${type}
|
||||
${placement ? ` placement: ${placement}\n` : ''} title: ${name || 'My UI Skill'}
|
||||
source: ${source}
|
||||
${periods.length ? ` periods:\n${periods.map((p) => ` - ${p}`).join('\n')}\n` : ''}---
|
||||
|
||||
# ${name || 'My UI Skill'}
|
||||
|
||||
## Purpose
|
||||
|
||||
Describe what Owliver should help with on these pages.
|
||||
Describe what this section shows, and why it belongs on these pages.
|
||||
|
||||
## Capabilities
|
||||
|
||||
- Describe one thing the skill can do.
|
||||
- Describe one thing the section reports.
|
||||
- Add more as needed.
|
||||
`;
|
||||
|
||||
/** A definition that teaches Owliver what it can be asked for. */
|
||||
export const owliverSkillTemplate = ({
|
||||
id = '', name = '', description = '', pages = [],
|
||||
triggers = [], suggestions = [], capabilities = [], source = '', periods = [],
|
||||
} = {}) => {
|
||||
const label = name || 'My Owliver Skill';
|
||||
const lines = [`---
|
||||
${frontMatter({
|
||||
id,
|
||||
name,
|
||||
description,
|
||||
pages,
|
||||
fallback: {
|
||||
id: 'my-owliver-skill',
|
||||
name: label,
|
||||
description: 'What this skill helps Owliver answer.',
|
||||
},
|
||||
})}`];
|
||||
|
||||
lines.push('triggers:');
|
||||
lines.push((triggers.length ? triggers : [label.toLowerCase()]).map((t) => ` - ${t}`).join('\n'));
|
||||
|
||||
lines.push('owliver:');
|
||||
lines.push(' enabled: true');
|
||||
|
||||
if (suggestions.length) {
|
||||
lines.push(' suggestions:');
|
||||
lines.push(suggestions.map((s) => ` - ${s}`).join('\n'));
|
||||
}
|
||||
|
||||
if (capabilities.length) {
|
||||
lines.push(' capabilities:');
|
||||
lines.push(capabilities.map((c) => ` - ${c}`).join('\n'));
|
||||
|
||||
if (source) {
|
||||
lines.push(' responses:');
|
||||
for (const capability of capabilities) {
|
||||
lines.push(` ${capability}:`);
|
||||
lines.push(` source: ${source}`);
|
||||
if (periods.length) {
|
||||
lines.push(' periods:');
|
||||
lines.push(periods.map((p) => ` - ${p}`).join('\n'));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
lines.push(`---
|
||||
|
||||
# ${label}
|
||||
|
||||
## Purpose
|
||||
|
||||
Describe what Owliver should be able to answer on these pages.
|
||||
|
||||
## Capabilities
|
||||
|
||||
- Describe one thing Owliver can be asked for.
|
||||
- Add more as needed.
|
||||
`);
|
||||
|
||||
return lines.join('\n');
|
||||
};
|
||||
|
||||
/**
|
||||
* The template the Add Skill dialog offers.
|
||||
*
|
||||
* That dialog opens from Owliver's own header, mid-conversation, so what it
|
||||
* hands the author is an Owliver skill. Kept under its original name because
|
||||
* it is what the dialog already imports.
|
||||
*/
|
||||
export const skillTemplate = owliverSkillTemplate;
|
||||
|
||||
/** Parses a stored entry, tolerating the bare-string form. */
|
||||
const sourceOf = (entry) => (typeof entry === 'string' ? entry : entry?.raw ?? '');
|
||||
|
||||
|
||||
516
src/lib/skills/dataResolver.js
Normal file
516
src/lib/skills/dataResolver.js
Normal file
@@ -0,0 +1,516 @@
|
||||
import { SUPPORTED_PERIODS, periodLabel } from './surfaces';
|
||||
import { CRITERIA_LABELS } from '@/lib/positionModel';
|
||||
import { poolFor } from '@/lib/workforce';
|
||||
import { candidateRoute } from './workforceFlow';
|
||||
|
||||
/**
|
||||
* The one place a skill's declared data source becomes real data.
|
||||
*
|
||||
* A section says `data.source: position.activity`. It does not say where that
|
||||
* comes from, cannot reach a store, and cannot name a field. This module owns
|
||||
* the mapping — source id in, normalized reading out — which is what keeps a
|
||||
* declarative file from turning into a query language.
|
||||
*
|
||||
* Two rules hold throughout:
|
||||
*
|
||||
* - **Real records only.** Every figure is counted from the collections the
|
||||
* application already holds. Nothing is generated to make a section look
|
||||
* populated; a source with nothing to report returns `empty: true` and the
|
||||
* renderer says so.
|
||||
* - **Time is computed, never stored.** "Today" is a window over the record
|
||||
* timestamps, evaluated against the current date at read time. No date is
|
||||
* written into a definition and none is hard-coded here.
|
||||
*/
|
||||
|
||||
const DAY = 24 * 60 * 60 * 1000;
|
||||
|
||||
/** Midnight at the start of the given day, in local time. */
|
||||
const startOfDay = (date) => {
|
||||
const d = new Date(date);
|
||||
d.setHours(0, 0, 0, 0);
|
||||
return d;
|
||||
};
|
||||
|
||||
/**
|
||||
* The window a period covers, as `[from, to)`.
|
||||
*
|
||||
* Weeks run Monday to Monday and months from the 1st, which is how the rest of
|
||||
* the product reports them.
|
||||
*/
|
||||
export function periodRange(period, now = new Date()) {
|
||||
const today = startOfDay(now);
|
||||
|
||||
switch (period) {
|
||||
case 'today':
|
||||
return { from: today, to: new Date(today.getTime() + DAY) };
|
||||
case 'yesterday':
|
||||
return { from: new Date(today.getTime() - DAY), to: today };
|
||||
case 'last-7-days':
|
||||
return { from: new Date(today.getTime() - 7 * DAY), to: new Date(today.getTime() + DAY) };
|
||||
case 'last-week': {
|
||||
/* The calendar week before the one we are in. */
|
||||
const weekday = (today.getDay() + 6) % 7;
|
||||
const thisMonday = new Date(today.getTime() - weekday * DAY);
|
||||
return { from: new Date(thisMonday.getTime() - 7 * DAY), to: thisMonday };
|
||||
}
|
||||
case 'this-month': {
|
||||
const from = new Date(today.getFullYear(), today.getMonth(), 1);
|
||||
return { from, to: new Date(today.getFullYear(), today.getMonth() + 1, 1) };
|
||||
}
|
||||
case 'previous-month': {
|
||||
const from = new Date(today.getFullYear(), today.getMonth() - 1, 1);
|
||||
return { from, to: new Date(today.getFullYear(), today.getMonth(), 1) };
|
||||
}
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Records whose `created_date` falls inside the window. */
|
||||
const inPeriod = (records, period, now) => {
|
||||
const range = periodRange(period, now);
|
||||
if (!range) return [];
|
||||
return records.filter((record) => {
|
||||
const at = new Date(record.created_date || record.updated_date || 0).getTime();
|
||||
return at >= range.from.getTime() && at < range.to.getTime();
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Why a matched candidate fits, in one line.
|
||||
*
|
||||
* Met requirements first, then the gaps, then what is known about availability —
|
||||
* the same order and the same words the panel's match cards use, because they
|
||||
* are read from the same row. A gap is stated as a gap: a line that only listed
|
||||
* strengths would make every candidate look like a strong match.
|
||||
*/
|
||||
function matchDetail(row) {
|
||||
if (!row.match) return 'Nothing on this position to score this candidate against';
|
||||
|
||||
const parts = [
|
||||
...row.match.met.map((line) => `✓ ${line.name} — ${line.heldLabel}`),
|
||||
...row.match.gaps.map((line) => `⚠ ${line.name} — ${line.heldLabel}, needs ${line.requiredLabel}`),
|
||||
];
|
||||
|
||||
if (row.availability?.known === false) parts.push('Availability not on file');
|
||||
else if (row.availability) {
|
||||
parts.push(row.availability.available ? '✓ Available when this starts' : `✕ ${row.availability.reason}`);
|
||||
}
|
||||
|
||||
return parts.join(' · ');
|
||||
}
|
||||
|
||||
/** The application stages this product counts, in the order they happen. */
|
||||
const STAGE_ORDER = ['applied', 'ai_screened', 'shortlisted', 'interview', 'hired'];
|
||||
|
||||
const atOrBeyond = (applications, stage) => {
|
||||
const from = STAGE_ORDER.indexOf(stage);
|
||||
return applications.filter((a) => STAGE_ORDER.indexOf(a.status) >= from);
|
||||
};
|
||||
|
||||
/** Applications counted by period — the reading behind an activity section. */
|
||||
function activityOverTime(applications, periods, now) {
|
||||
const wanted = periods.length ? periods : ['today', 'yesterday', 'last-week'];
|
||||
|
||||
const steps = wanted
|
||||
.filter((period) => SUPPORTED_PERIODS.includes(period))
|
||||
.map((period) => {
|
||||
const records = inPeriod(applications, period, now);
|
||||
return {
|
||||
id: period,
|
||||
label: periodLabel(period),
|
||||
value: records.length,
|
||||
detail: records.length
|
||||
? `${records.length} application${records.length === 1 ? '' : 's'}`
|
||||
: 'No applications',
|
||||
records,
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
steps,
|
||||
total: applications.length,
|
||||
empty: steps.every((s) => s.value === 0),
|
||||
emptyNote: applications.length
|
||||
? 'No applications in these periods.'
|
||||
: 'No applications on this position yet.',
|
||||
};
|
||||
}
|
||||
|
||||
/** The hiring funnel for a set of applications. */
|
||||
function pipelineOf(applications) {
|
||||
const steps = [
|
||||
{ id: 'applied', label: 'Applied', value: applications.length },
|
||||
{ id: 'screened', label: 'Screened', value: atOrBeyond(applications, 'ai_screened').length },
|
||||
{ id: 'shortlisted', label: 'Shortlisted', value: atOrBeyond(applications, 'shortlisted').length },
|
||||
{ id: 'interview', label: 'Interview', value: atOrBeyond(applications, 'interview').length },
|
||||
{ id: 'hired', label: 'Hired', value: applications.filter((a) => a.status === 'hired').length },
|
||||
];
|
||||
|
||||
return {
|
||||
steps,
|
||||
total: applications.length,
|
||||
empty: applications.length === 0,
|
||||
emptyNote: 'No applications on this position yet.',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Every source the vocabulary offers, and how each is read.
|
||||
*
|
||||
* Keyed by the same ids `surfaces.js` publishes, so the list an author can
|
||||
* choose from and the list that can actually be resolved are the same list.
|
||||
*/
|
||||
const RESOLVERS = {
|
||||
'position.activity': ({ position, applications }, section, now) => {
|
||||
const mine = applications.filter((a) => a.job_posting_id === position?.id);
|
||||
return activityOverTime(mine, section.periods, now);
|
||||
},
|
||||
|
||||
'position.pipeline': ({ position, applications }) =>
|
||||
pipelineOf(applications.filter((a) => a.job_posting_id === position?.id)),
|
||||
|
||||
'position.candidates': ({ position, applications }, section) => {
|
||||
const mine = applications
|
||||
.filter((a) => a.job_posting_id === position?.id)
|
||||
.sort((a, b) => (b.ai_score || 0) - (a.ai_score || 0))
|
||||
.slice(0, section.limit || 5);
|
||||
|
||||
return {
|
||||
items: mine.map((a) => ({
|
||||
id: a.id,
|
||||
title: a.applicant_name,
|
||||
detail: [
|
||||
a.years_experience != null ? `${a.years_experience} yrs experience` : null,
|
||||
String(a.status || '').replace(/_/g, ' '),
|
||||
].filter(Boolean).join(' · '),
|
||||
value: a.ai_score > 0 ? a.ai_score : null,
|
||||
to: `/admin/candidates/${a.id}`,
|
||||
})),
|
||||
columns: [
|
||||
{ key: 'title', label: 'Candidate' },
|
||||
{ key: 'detail', label: 'Status' },
|
||||
{ key: 'value', label: 'Score', align: 'right' },
|
||||
],
|
||||
empty: mine.length === 0,
|
||||
emptyNote: 'No candidates have applied to this position yet.',
|
||||
};
|
||||
},
|
||||
|
||||
/**
|
||||
* The candidate pool, scored against this position.
|
||||
*
|
||||
* Every figure here comes from `poolFor` — the same engine behind the
|
||||
* workforce conversation and the position page's own recommendations. This
|
||||
* resolver ranks nothing and scores nothing; it reads the rows the engine
|
||||
* returned and states them, including *why* each one fits, in the engine's own
|
||||
* terms. A candidate the position gives nothing to measure against is reported
|
||||
* as unscored rather than given a number.
|
||||
*
|
||||
* `to` is the candidate's real record, carrying the position they were being
|
||||
* considered for — the same route the panel's match cards open.
|
||||
*/
|
||||
'position.matches': ({ position, ...context }, section) => {
|
||||
if (!position) {
|
||||
return { items: [], empty: true, emptyNote: 'No position to match candidates against.' };
|
||||
}
|
||||
|
||||
const rows = poolFor(position, {
|
||||
profiles: context.profiles || context.workerProfiles || [],
|
||||
applications: context.applications || [],
|
||||
assignments: context.assignments || [],
|
||||
courses: context.courses || [],
|
||||
staff: context.staff || [],
|
||||
}).slice(0, section.limit || 5);
|
||||
|
||||
return {
|
||||
items: rows.map((row) => ({
|
||||
id: row.candidateId,
|
||||
title: row.name,
|
||||
/* The met requirements and the gaps, as the engine stated them. */
|
||||
detail: matchDetail(row),
|
||||
value: row.scored ? row.score : null,
|
||||
to: candidateRoute(row, context.applications || [], position),
|
||||
})),
|
||||
columns: [
|
||||
{ key: 'title', label: 'Candidate' },
|
||||
{ key: 'detail', label: 'Why' },
|
||||
{ key: 'value', label: 'Match', align: 'right' },
|
||||
],
|
||||
empty: rows.length === 0,
|
||||
emptyNote: 'No candidates on file to score against this position yet.',
|
||||
};
|
||||
},
|
||||
|
||||
'position.requirements': ({ position }) => {
|
||||
const items = [
|
||||
position?.min_experience_years
|
||||
? { id: 'experience', title: 'Minimum experience', detail: `${position.min_experience_years} years` }
|
||||
: null,
|
||||
position?.english_required
|
||||
? { id: 'english', title: 'English level', detail: String(position.english_required) }
|
||||
: null,
|
||||
...(position?.certifications_required || []).map((c) => ({
|
||||
id: `cert-${c}`, title: 'Certification', detail: c,
|
||||
})),
|
||||
...(position?.skill_requirements || []).map((r) => ({
|
||||
id: `skill-${r.skill_id}`, title: r.skill_id, detail: `${r.level} · weight ${r.weight}`,
|
||||
})),
|
||||
].filter(Boolean);
|
||||
|
||||
return {
|
||||
items,
|
||||
columns: [{ key: 'title', label: 'Requirement' }, { key: 'detail', label: 'Needs' }],
|
||||
empty: items.length === 0,
|
||||
emptyNote: 'This position states no requirements.',
|
||||
};
|
||||
},
|
||||
|
||||
'candidate.readiness': ({ candidate }) => {
|
||||
const breakdown = candidate?.score_breakdown || {};
|
||||
const items = Object.entries(breakdown)
|
||||
.filter(([, value]) => Number(value) > 0)
|
||||
.map(([key, value]) => ({
|
||||
id: key,
|
||||
title: key.replace(/_/g, ' '),
|
||||
value: Number(value),
|
||||
max: 100,
|
||||
}));
|
||||
|
||||
return {
|
||||
items,
|
||||
columns: [{ key: 'title', label: 'Dimension' }, { key: 'value', label: 'Score', align: 'right' }],
|
||||
empty: items.length === 0,
|
||||
emptyNote: 'This candidate has not been screened, so there are no dimensions to show.',
|
||||
};
|
||||
},
|
||||
|
||||
'candidate.activity': ({ candidate, interviews = [] }) => {
|
||||
const events = [
|
||||
candidate?.created_date && {
|
||||
id: 'applied', title: 'Applied', detail: candidate.job_title, at: candidate.created_date,
|
||||
},
|
||||
candidate?.ai_score > 0 && {
|
||||
id: 'screened', title: 'AI screened', detail: `Scored ${candidate.ai_score}`, at: candidate.updated_date,
|
||||
},
|
||||
...interviews
|
||||
.filter((i) => i.application_id === candidate?.id)
|
||||
.map((i) => ({ id: i.id, title: 'Interview', detail: i.status, at: i.created_date })),
|
||||
candidate?.status === 'hired' && {
|
||||
id: 'hired', title: 'Hired', detail: candidate.job_title, at: candidate.updated_date,
|
||||
},
|
||||
].filter(Boolean);
|
||||
|
||||
return {
|
||||
items: events,
|
||||
empty: events.length === 0,
|
||||
emptyNote: 'Nothing has happened on this record yet.',
|
||||
};
|
||||
},
|
||||
|
||||
'candidates.pipeline': ({ applications }) => pipelineOf(applications),
|
||||
|
||||
'candidates.activity': ({ applications }, section, now) =>
|
||||
activityOverTime(applications, section.periods, now),
|
||||
|
||||
'positions.demand': ({ positions = [], applications }, section) => {
|
||||
const items = positions
|
||||
.filter((p) => p.status === 'active')
|
||||
.slice(0, section.limit || 5)
|
||||
.map((p) => {
|
||||
const mine = applications.filter((a) => a.job_posting_id === p.id);
|
||||
return {
|
||||
id: p.id,
|
||||
title: p.title,
|
||||
detail: [p.company, p.location].filter(Boolean).join(' · '),
|
||||
value: mine.length,
|
||||
to: `/admin/positions/${p.id}`,
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
items,
|
||||
columns: [
|
||||
{ key: 'title', label: 'Position' },
|
||||
{ key: 'detail', label: 'Client' },
|
||||
{ key: 'value', label: 'Applicants', align: 'right' },
|
||||
],
|
||||
empty: items.length === 0,
|
||||
emptyNote: 'No open positions.',
|
||||
};
|
||||
},
|
||||
|
||||
'workforce.training': ({ trainingPaths = [] }, section) => {
|
||||
const items = trainingPaths.slice(0, section.limit || 10).map(({ definition, state }) => ({
|
||||
id: definition.id,
|
||||
title: state.name,
|
||||
detail: state.verifiedLabel,
|
||||
value: state.totalModules ? Math.round((state.totalCompleted / state.totalModules) * 100) : 0,
|
||||
max: 100,
|
||||
}));
|
||||
|
||||
return {
|
||||
items,
|
||||
columns: [
|
||||
{ key: 'title', label: 'Path' },
|
||||
{ key: 'detail', label: 'Level' },
|
||||
{ key: 'value', label: 'Complete', align: 'right' },
|
||||
],
|
||||
empty: items.length === 0,
|
||||
emptyNote: 'No training paths are registered.',
|
||||
};
|
||||
},
|
||||
|
||||
/**
|
||||
* The vetting weights on the position in context.
|
||||
*
|
||||
* The same reading whether that position is a saved record or the draft being
|
||||
* typed into Create Position — both carry `vetting_criteria`, so a section
|
||||
* declared once reports the specification as it stands on either surface.
|
||||
*/
|
||||
'position.vetting': ({ position }) => {
|
||||
const criteria = position?.vetting_criteria || {};
|
||||
const steps = Object.entries(criteria).map(([key, value]) => ({
|
||||
id: key,
|
||||
label: CRITERIA_LABELS[key] || key.replace(/_/g, ' '),
|
||||
title: CRITERIA_LABELS[key] || key.replace(/_/g, ' '),
|
||||
value: Number(value) || 0,
|
||||
max: 100,
|
||||
detail: `${Number(value) || 0}% of the screening score`,
|
||||
}));
|
||||
|
||||
const total = steps.reduce((sum, s) => sum + s.value, 0);
|
||||
|
||||
return {
|
||||
steps,
|
||||
items: steps,
|
||||
total,
|
||||
columns: [
|
||||
{ key: 'title', label: 'Criterion' },
|
||||
{ key: 'value', label: 'Weight', align: 'right' },
|
||||
],
|
||||
empty: steps.length === 0,
|
||||
emptyNote: 'This position states no vetting weights.',
|
||||
};
|
||||
},
|
||||
|
||||
/** Everyone hired, most recent first — the record rather than the analysis. */
|
||||
'hires.recent': ({ staff = [], applications = [], positions = [] }, section) => {
|
||||
const items = [...staff]
|
||||
.sort((a, b) => new Date(b.hire_date || b.created_date || 0) - new Date(a.hire_date || a.created_date || 0))
|
||||
.slice(0, section.limit || 10)
|
||||
.map((s) => {
|
||||
const app = applications.find((a) => a.id === s.application_id);
|
||||
const posting = positions.find((p) => p.id === s.job_posting_id);
|
||||
return {
|
||||
id: s.id,
|
||||
title: s.name,
|
||||
detail: [s.role || posting?.title, posting?.role_category || s.department]
|
||||
.filter(Boolean).join(' · '),
|
||||
value: s.ai_score || app?.ai_score || s.score || null,
|
||||
at: s.hire_date || s.created_date || null,
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
items,
|
||||
columns: [
|
||||
{ key: 'title', label: 'Hire' },
|
||||
{ key: 'detail', label: 'Role' },
|
||||
{ key: 'value', label: 'Score', align: 'right' },
|
||||
],
|
||||
empty: items.length === 0,
|
||||
emptyNote: 'Nobody has been hired yet.',
|
||||
};
|
||||
},
|
||||
|
||||
/**
|
||||
* The four figures that answer "how is our hiring performing" — counted from
|
||||
* applications and the staff records they became, never stored.
|
||||
*/
|
||||
'hires.performance': ({ applications = [], staff = [] }) => {
|
||||
const hired = applications.filter((a) => a.status === 'hired');
|
||||
const scores = applications.map((a) => a.ai_score).filter((n) => n > 0);
|
||||
const days = hired
|
||||
.map((a) => Math.round((new Date(a.updated_date) - new Date(a.created_date)) / 86400000))
|
||||
.filter((n) => Number.isFinite(n) && n >= 0);
|
||||
|
||||
const mean = (xs) => (xs.length ? Math.round(xs.reduce((a, b) => a + b, 0) / xs.length) : 0);
|
||||
const total = staff.length || hired.length;
|
||||
|
||||
const steps = [
|
||||
{ id: 'hires', label: 'Total hires', title: 'Total hires', value: total },
|
||||
{ id: 'speed', label: 'Avg days to hire', title: 'Avg days to hire', value: mean(days) },
|
||||
{ id: 'quality', label: 'Quality of hire', title: 'Quality of hire', value: mean(scores) },
|
||||
{
|
||||
id: 'conversion',
|
||||
label: 'Conversion rate',
|
||||
title: 'Conversion rate',
|
||||
value: applications.length ? Math.round((hired.length / applications.length) * 100) : 0,
|
||||
},
|
||||
];
|
||||
|
||||
return {
|
||||
steps,
|
||||
items: steps,
|
||||
total,
|
||||
columns: [
|
||||
{ key: 'title', label: 'Measure' },
|
||||
{ key: 'value', label: 'Value', align: 'right' },
|
||||
],
|
||||
empty: applications.length === 0 && total === 0,
|
||||
emptyNote: 'No hiring activity has been recorded yet.',
|
||||
};
|
||||
},
|
||||
|
||||
/** The workspace audit trail, most recent first. */
|
||||
'activity.events': ({ activity = [] }, section) => {
|
||||
const items = [...activity]
|
||||
.sort((a, b) => new Date(b.created_date || 0) - new Date(a.created_date || 0))
|
||||
.slice(0, section.limit || 10)
|
||||
.map((event) => ({
|
||||
id: event.id,
|
||||
title: String(event.event_type || 'event').replace(/_/g, ' '),
|
||||
detail: [event.user_name, event.details].filter(Boolean).join(' — '),
|
||||
at: event.created_date,
|
||||
}));
|
||||
|
||||
return {
|
||||
items,
|
||||
columns: [
|
||||
{ key: 'title', label: 'Event' },
|
||||
{ key: 'detail', label: 'Who' },
|
||||
],
|
||||
empty: items.length === 0,
|
||||
emptyNote: 'No activity has been recorded yet.',
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* One section's data, read from the application's own records.
|
||||
*
|
||||
* `context` is what the page supplies — the position or candidate being looked
|
||||
* at, plus the collections it already loaded. A source whose required context
|
||||
* is missing returns `unavailable`, which the renderer states rather than
|
||||
* filling in.
|
||||
*/
|
||||
export function resolveSkillData(section, context = {}, now = new Date()) {
|
||||
const resolve = RESOLVERS[section?.source];
|
||||
if (!resolve) return { unavailable: true, emptyNote: `No resolver for ${section?.source}.` };
|
||||
|
||||
if (section.context === 'positionId' && !context.position) {
|
||||
return { unavailable: true, emptyNote: 'This section needs a position to read.' };
|
||||
}
|
||||
if (section.context === 'candidateId' && !context.candidate) {
|
||||
return { unavailable: true, emptyNote: 'This section needs a candidate to read.' };
|
||||
}
|
||||
|
||||
try {
|
||||
return resolve(context, section, now);
|
||||
} catch {
|
||||
/* A resolver that throws is a bug in this file, not in the definition —
|
||||
the section reports it has nothing rather than taking the page down. */
|
||||
return { unavailable: true, emptyNote: 'This section could not be read.' };
|
||||
}
|
||||
}
|
||||
247
src/lib/skills/owliverConfig.js
Normal file
247
src/lib/skills/owliverConfig.js
Normal file
@@ -0,0 +1,247 @@
|
||||
import { normalizeSection } from './uiConfig';
|
||||
import {
|
||||
SUPPORTED_OWLIVER_CAPABILITIES, SUPPORTED_SECTION_TYPES, owliverCapabilityFor,
|
||||
} from './surfaces';
|
||||
|
||||
/**
|
||||
* The `owliver:` block of a skill definition, checked and normalized.
|
||||
*
|
||||
* The same file that extends a page can extend the panel beside it. `ui:` says
|
||||
* what the page renders; `owliver:` says what can be *asked for*, and both name
|
||||
* the same data source — so the card and the answer are two readings of one
|
||||
* declaration rather than two definitions that have to be kept in step.
|
||||
*
|
||||
* owliver:
|
||||
* enabled: true
|
||||
* suggestions:
|
||||
* - Show hiring activity
|
||||
* - Summarize hiring activity
|
||||
* capabilities:
|
||||
* - summary
|
||||
* - flow
|
||||
* responses:
|
||||
* flow:
|
||||
* title: Hiring Activity Flow
|
||||
* source: position.activity
|
||||
* steps: [today, yesterday, last-week]
|
||||
*
|
||||
* Three properties hold, and each one is a rule the rest of the system relies
|
||||
* on:
|
||||
*
|
||||
* - **A response is a section.** It is normalized by the same function the
|
||||
* page's `ui:` sections go through, so a capability resolves to the same
|
||||
* record, the same data source and the same renderer. There is no second
|
||||
* shape for "the chat version".
|
||||
* - **The block is optional.** A definition with no `owliver:` normalizes to
|
||||
* a disabled record and behaves exactly as it did before this existed.
|
||||
* - **Nothing unknown survives.** Capabilities, sources, periods and shapes
|
||||
* are checked against the closed vocabulary in `surfaces.js`; an
|
||||
* unrecognised value is a named error rather than a dropped key.
|
||||
*/
|
||||
|
||||
/** What a definition with no `owliver:` block gets. */
|
||||
export const NO_OWLIVER = Object.freeze({
|
||||
enabled: false,
|
||||
suggestions: [],
|
||||
capabilities: [],
|
||||
responses: {},
|
||||
});
|
||||
|
||||
/** The section type a capability is drawn with — `summary` draws nothing. */
|
||||
const shapeOf = (capability) => owliverCapabilityFor(capability)?.shape || null;
|
||||
|
||||
/**
|
||||
* The reading a response inherits when it does not name one.
|
||||
*
|
||||
* A skill that already declares a `ui:` section has stated its source once;
|
||||
* making it state it again for the panel would be the format asking the author
|
||||
* to repeat themselves, and would let the two drift apart. The first declared
|
||||
* section wins, in declaration order.
|
||||
*/
|
||||
function inheritedSection(ui) {
|
||||
for (const page of Object.values(ui || {})) {
|
||||
const section = (page.sections || [])[0];
|
||||
if (section) return section;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** One suggestion, in either the plain-string or the mapping form. */
|
||||
function normalizeSuggestion(raw, { errors, capabilities, index }) {
|
||||
const where = `owliver.suggestions[${index}]`;
|
||||
|
||||
if (typeof raw === 'string' || typeof raw === 'number') {
|
||||
const label = String(raw).trim();
|
||||
if (!label) {
|
||||
errors.push(`${where}: a suggestion needs text.`);
|
||||
return null;
|
||||
}
|
||||
return { label, prompt: label, capability: null };
|
||||
}
|
||||
|
||||
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) {
|
||||
errors.push(`${where}: a suggestion must be a line of text, or a mapping of options.`);
|
||||
return null;
|
||||
}
|
||||
|
||||
const label = String(raw.label ?? raw.prompt ?? '').trim();
|
||||
if (!label) {
|
||||
errors.push(`${where}: a suggestion needs a \`label\`.`);
|
||||
return null;
|
||||
}
|
||||
|
||||
/* A suggestion may say which capability it asks for. That is what makes a
|
||||
chip exact — the words are the author's, and the answer is not left to be
|
||||
re-derived from them. */
|
||||
const capability = raw.capability == null ? null : String(raw.capability).trim();
|
||||
if (capability && !SUPPORTED_OWLIVER_CAPABILITIES.includes(capability)) {
|
||||
errors.push(
|
||||
`Unsupported Owliver capability: ${capability}. Supported capabilities: ${SUPPORTED_OWLIVER_CAPABILITIES.join(', ')}.`
|
||||
);
|
||||
return null;
|
||||
}
|
||||
if (capability && capabilities.length && !capabilities.includes(capability)) {
|
||||
errors.push(`${where}: \`${capability}\` is not listed under \`owliver.capabilities\`.`);
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
label,
|
||||
prompt: String(raw.prompt ?? raw.label).trim(),
|
||||
capability: capability || null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The whole `owliver:` block, normalized.
|
||||
*
|
||||
* Returns `{ owliver, errors }`. As with `ui:`, what validates is kept and what
|
||||
* does not is reported: a definition with one bad response still registers its
|
||||
* good ones, and the author is told why the other was refused.
|
||||
*/
|
||||
export function normalizeSkillOwliver(raw, { ui = {}, skillId = '', skillName = '' } = {}) {
|
||||
const errors = [];
|
||||
|
||||
if (raw == null) return { owliver: NO_OWLIVER, errors };
|
||||
|
||||
if (typeof raw !== 'object' || Array.isArray(raw)) {
|
||||
return { owliver: NO_OWLIVER, errors: ['`owliver` must be a mapping of options.'] };
|
||||
}
|
||||
|
||||
/* Declaring the block is the opt-in; `enabled: false` is how it is switched
|
||||
off without deleting what was written. */
|
||||
const enabled = raw.enabled !== false;
|
||||
|
||||
/* Capabilities may be listed, or left to be read off the responses — which is
|
||||
what a definition that writes one response and nothing else means. */
|
||||
const declared = Array.isArray(raw.capabilities)
|
||||
? raw.capabilities.map((c) => String(c).trim()).filter(Boolean)
|
||||
: [];
|
||||
const responsesRaw = raw.responses && typeof raw.responses === 'object' && !Array.isArray(raw.responses)
|
||||
? raw.responses
|
||||
: {};
|
||||
if (raw.responses != null && !Object.keys(responsesRaw).length) {
|
||||
errors.push('`owliver.responses` must be a mapping of capability names to responses.');
|
||||
}
|
||||
|
||||
const capabilities = [];
|
||||
for (const capability of [...declared, ...Object.keys(responsesRaw)]) {
|
||||
if (!SUPPORTED_OWLIVER_CAPABILITIES.includes(capability)) {
|
||||
errors.push(
|
||||
`Unsupported Owliver capability: ${capability}. Supported capabilities: ${SUPPORTED_OWLIVER_CAPABILITIES.join(', ')}.`
|
||||
);
|
||||
continue;
|
||||
}
|
||||
if (!capabilities.includes(capability)) capabilities.push(capability);
|
||||
}
|
||||
|
||||
/* Suggestions are read after capabilities, so one naming a capability can be
|
||||
checked against what the skill actually offers. */
|
||||
const suggestionsRaw = raw.suggestions == null ? [] : raw.suggestions;
|
||||
let suggestions = [];
|
||||
if (!Array.isArray(suggestionsRaw)) {
|
||||
errors.push('`owliver.suggestions` must be a list.');
|
||||
} else {
|
||||
suggestions = suggestionsRaw
|
||||
.map((entry, index) => normalizeSuggestion(entry, { errors, capabilities, index }))
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
/* Every capability resolves to a section — declared, or inherited from the
|
||||
page UI this skill already configures. A capability that can name no
|
||||
reading is refused: it would otherwise register as something Owliver
|
||||
offers and then have nothing to answer with. */
|
||||
const inherited = inheritedSection(ui);
|
||||
const responses = {};
|
||||
const seen = new Set();
|
||||
|
||||
for (const capability of capabilities) {
|
||||
const declaredResponse = responsesRaw[capability];
|
||||
if (declaredResponse != null
|
||||
&& (typeof declaredResponse !== 'object' || Array.isArray(declaredResponse))) {
|
||||
errors.push(`owliver.responses.${capability}: expected a mapping of options.`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const response = declaredResponse || {};
|
||||
/* `steps:` is what a flow reads like in a definition; `periods:` is what
|
||||
the rest of the format calls the same list. */
|
||||
const periods = response.steps ?? response.periods ?? (declaredResponse ? null : inherited?.periods);
|
||||
const source = response.data?.source ?? response.source ?? inherited?.source;
|
||||
|
||||
if (!source) {
|
||||
errors.push(
|
||||
`owliver.responses.${capability}: a response needs a \`source\`, or a \`ui:\` section to read from.`
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
const section = normalizeSection(
|
||||
{
|
||||
id: `${skillId || 'skill'}-${capability}`,
|
||||
type: capability,
|
||||
title: response.title ?? null,
|
||||
description: response.description ?? null,
|
||||
source,
|
||||
periods: periods ?? [],
|
||||
limit: response.limit ?? inherited?.limit ?? null,
|
||||
/* Editing is a property of the capability, and is inherited from the
|
||||
page section the same way the source is: a definition that made its
|
||||
card adjustable meant the answer to be adjustable too, unless it
|
||||
says otherwise. */
|
||||
editable: response.editable ?? (declaredResponse ? false : inherited?.editable) ?? false,
|
||||
},
|
||||
{
|
||||
errors,
|
||||
seen,
|
||||
where: `owliver.responses.${capability}`,
|
||||
fallbackId: skillId,
|
||||
placement: false,
|
||||
types: [...SUPPORTED_SECTION_TYPES, 'summary'],
|
||||
shapeFor: shapeOf,
|
||||
}
|
||||
);
|
||||
|
||||
if (!section) continue;
|
||||
|
||||
responses[capability] = {
|
||||
...section,
|
||||
capability,
|
||||
/* The shape the answer is drawn with, resolved once here so no consumer
|
||||
has to know that `summary` is the one capability with no component. */
|
||||
shape: shapeOf(capability),
|
||||
title: section.title || skillName || null,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
owliver: {
|
||||
enabled,
|
||||
suggestions,
|
||||
/* Only capabilities that resolved to a reading are offered. */
|
||||
capabilities: capabilities.filter((c) => responses[c]),
|
||||
responses,
|
||||
},
|
||||
errors,
|
||||
};
|
||||
}
|
||||
416
src/lib/skills/owliverResolver.js
Normal file
416
src/lib/skills/owliverResolver.js
Normal file
@@ -0,0 +1,416 @@
|
||||
import { OWLIVER_CAPABILITIES, dataSourceLabel, owliverCapabilityFor } from './surfaces';
|
||||
import { skillsForContext } from './registry';
|
||||
import { resolveSkillData } from './dataResolver';
|
||||
import { resolvePosition } from './workforceFlow';
|
||||
|
||||
/**
|
||||
* The Owliver half of a skill definition, resolved.
|
||||
*
|
||||
* The page reads a definition through `SkillSurface`; this is the other reader.
|
||||
* Given the page you are on and what you asked, it answers three questions and
|
||||
* nothing else:
|
||||
*
|
||||
* 1. which registered skills apply here,
|
||||
* 2. which of them you are asking for, and which capability,
|
||||
* 3. what the answer is, read from the source the definition names.
|
||||
*
|
||||
* The rule that makes this an architecture rather than a lookup table: **no
|
||||
* skill is named here.** There is no `if (skill.id === …)`, no prompt string
|
||||
* matched against a constant, and no component per skill. A definition is
|
||||
* matched by what it declares — its triggers, its suggestions, its name, its
|
||||
* description — and answered by the shape it declares. A skill written after
|
||||
* this file was last edited resolves exactly as well as one written before it.
|
||||
*/
|
||||
|
||||
/* ── Which skills apply ─────────────────────────────────────────────────── */
|
||||
|
||||
/**
|
||||
* The Owliver-enabled skills registered for a page context.
|
||||
*
|
||||
* `skillsForContext` is the single answer to "what is attached here", already
|
||||
* honouring `pages:`, `status:` and the account's switched-off list — so the
|
||||
* page's sections and the panel's answers are filtered by one rule, and
|
||||
* switching a skill off in Settings removes both at once.
|
||||
*/
|
||||
export function owliverSkillsForContext(contextId, disabled = [], customSources = []) {
|
||||
return skillsForContext(contextId, disabled, customSources)
|
||||
.filter((skill) => skill.owliver?.enabled && skill.owliver.capabilities.length > 0);
|
||||
}
|
||||
|
||||
/* ── Suggestions ────────────────────────────────────────────────────────── */
|
||||
|
||||
/** How many chips one skill may contribute, and how many all of them may. */
|
||||
const PER_SKILL = 3;
|
||||
const TOTAL = 4;
|
||||
|
||||
/**
|
||||
* The chips a page's skills offer.
|
||||
*
|
||||
* Capped deliberately. A workspace with six skills attached would otherwise
|
||||
* bury the page's own suggestions under twenty of them, and a suggestion nobody
|
||||
* can find is not a suggestion. A skill contributes its first few, and the set
|
||||
* as a whole stays within what the composer can show without becoming a menu.
|
||||
*
|
||||
* A suggestion naming a capability the skill does not offer is dropped rather
|
||||
* than shown and then refused.
|
||||
*/
|
||||
export function owliverSuggestions(contextId, disabled = [], customSources = [], context = {}) {
|
||||
const chips = [];
|
||||
|
||||
for (const skill of owliverSkillsForContext(contextId, disabled, customSources)) {
|
||||
/**
|
||||
* Can this skill answer without asking a question back?
|
||||
*
|
||||
* A capability reading one position cannot say anything until it knows
|
||||
* which — so on a page with nothing selected, clicking it produces a
|
||||
* question rather than an answer. Those suggestions are still offered, but
|
||||
* they are marked so the panel can rank them behind the ones that will
|
||||
* actually answer. That is a property of the declared source, not of any
|
||||
* particular skill: a definition added tomorrow reading one record is
|
||||
* ranked the same way.
|
||||
*/
|
||||
const needs = (capability) => skill.owliver.responses[capability]?.context || null;
|
||||
const met = (need) => !need
|
||||
|| (need === 'positionId' && Boolean(context.position))
|
||||
|| (need === 'candidateId' && Boolean(context.candidate));
|
||||
|
||||
const offered = skill.owliver.suggestions
|
||||
.filter((s) => !s.capability || skill.owliver.capabilities.includes(s.capability))
|
||||
.slice(0, PER_SKILL)
|
||||
/* Deliberately not carried as `capability`: a chip with that field is one
|
||||
of the *page's* own answers and bypasses routing entirely. A skill's
|
||||
chip is an ordinary question, and is resolved the same way the same
|
||||
words typed by hand would be — one path, so a chip can never answer
|
||||
something the typed form would not. */
|
||||
.map((s) => {
|
||||
const need = needs(s.capability || skill.owliver.capabilities[0]);
|
||||
return {
|
||||
label: s.label,
|
||||
prompt: s.prompt,
|
||||
skillId: skill.id,
|
||||
skillCapability: s.capability || null,
|
||||
/* True when clicking this would have to ask which record first. */
|
||||
deferred: !met(need),
|
||||
};
|
||||
});
|
||||
chips.push(...offered);
|
||||
}
|
||||
|
||||
/* Answerable suggestions first, then the ones that would ask a question
|
||||
back — stable within each group, so a definition's own order is kept. */
|
||||
const ready = chips.filter((c) => !c.deferred);
|
||||
const asking = chips.filter((c) => c.deferred);
|
||||
return [...ready, ...asking].slice(0, TOTAL);
|
||||
}
|
||||
|
||||
/* ── Matching ───────────────────────────────────────────────────────────── */
|
||||
|
||||
const lower = (value) => String(value ?? '').toLowerCase();
|
||||
|
||||
/** Words worth matching on — the ones that carry the subject of a question. */
|
||||
const STOP_WORDS = new Set([
|
||||
'the', 'a', 'an', 'this', 'that', 'these', 'those', 'my', 'our', 'is', 'are', 'was', 'were',
|
||||
'show', 'me', 'as', 'of', 'for', 'in', 'on', 'to', 'and', 'or', 'with', 'what', 'how', 'can',
|
||||
'you', 'i', 'it', 'please', 'give', 'tell', 'about', 'here', 'now', 'current', 'currently',
|
||||
]);
|
||||
|
||||
const words = (value) => lower(value).split(/[^a-z0-9]+/).filter((w) => w.length > 2 && !STOP_WORDS.has(w));
|
||||
|
||||
/**
|
||||
* Does a declared trigger match? `*` stands for anything in between, the same
|
||||
* way the registry's own trigger matching reads it.
|
||||
*/
|
||||
function triggerMatches(trigger, question) {
|
||||
if (!trigger.includes('*')) return question.includes(trigger);
|
||||
const pattern = trigger
|
||||
.split('*')
|
||||
.map((part) => part.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))
|
||||
.join('[\\s\\S]{0,40}?');
|
||||
return new RegExp(pattern).test(question);
|
||||
}
|
||||
|
||||
/**
|
||||
* How strongly a question asks for this skill.
|
||||
*
|
||||
* Evidence is weighted by how deliberate it is. A suggestion the author wrote
|
||||
* and the reader clicked is the strongest signal there is; a declared trigger
|
||||
* is next; the skill's own name is next; and shared words with its description
|
||||
* are the weakest — enough to break a tie, never enough to win on their own.
|
||||
*/
|
||||
function scoreSkill(skill, question) {
|
||||
const q = lower(question);
|
||||
|
||||
const suggestion = skill.owliver.suggestions.find((s) => lower(s.prompt) === q || lower(s.label) === q);
|
||||
if (suggestion) return { score: 100, suggestion };
|
||||
|
||||
/**
|
||||
* Deliberate evidence: the author said this skill answers this.
|
||||
*
|
||||
* A suggestion the reader is echoing, a declared trigger, or the skill's own
|
||||
* name. One of these must hold before a definition may claim a question at
|
||||
* all — see the floor below.
|
||||
*/
|
||||
let deliberate = 0;
|
||||
if (skill.owliver.suggestions.some((s) => q.includes(lower(s.label)))) deliberate += 40;
|
||||
if (skill.triggers.some((t) => triggerMatches(t, q))) deliberate += 30;
|
||||
if (skill.name && q.includes(lower(skill.name))) deliberate += 20;
|
||||
|
||||
/**
|
||||
* The floor. Sharing a word with a description is not a claim.
|
||||
*
|
||||
* This is the bug that let "create a position" be answered by a candidate
|
||||
* matching skill: its description happened to contain the word "position",
|
||||
* which scored three points, and three points beat nothing. Corroboration
|
||||
* was being treated as evidence.
|
||||
*
|
||||
* So description overlap can now only *break a tie* between definitions that
|
||||
* already named the subject, and can never qualify one on its own. A skill
|
||||
* about candidates cannot claim a question about creating a role however its
|
||||
* description happens to be worded — which is the general property, not a
|
||||
* fix aimed at these two definitions.
|
||||
*/
|
||||
if (!deliberate) return { score: 0, suggestion: null };
|
||||
|
||||
const overlap = words(skill.description).filter((w) => q.includes(w)).length;
|
||||
return { score: deliberate + Math.min(overlap * 3, 9), suggestion: null };
|
||||
}
|
||||
|
||||
/** The capability a question asks for, from the shape words it uses. */
|
||||
function scoreCapability(capability, question) {
|
||||
const q = lower(question);
|
||||
const definition = owliverCapabilityFor(capability);
|
||||
if (!definition) return 0;
|
||||
|
||||
/* Longest matching term wins, so "as a flow" beats "flow" and a question
|
||||
naming two shapes resolves to the more explicit one. */
|
||||
return definition.terms.reduce(
|
||||
(best, term) => (q.includes(term) && term.length > best ? term.length : best),
|
||||
0
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The skill and capability a question resolves to, or null.
|
||||
*
|
||||
* Both halves have to hold: a question that names no registered skill is not
|
||||
* this system's to answer, and a skill matched with no capability falls back to
|
||||
* the first one its definition declares — which is what makes "Show hiring
|
||||
* activity" work without the author writing a trigger per shape.
|
||||
*/
|
||||
export function matchOwliverSkill(question, skills = []) {
|
||||
let best = null;
|
||||
|
||||
for (const skill of skills) {
|
||||
const { score, suggestion } = scoreSkill(skill, question);
|
||||
if (score <= 0) continue;
|
||||
if (!best || score > best.score) best = { skill, score, suggestion };
|
||||
}
|
||||
if (!best) return null;
|
||||
|
||||
const { skill, suggestion, score } = best;
|
||||
const available = skill.owliver.capabilities;
|
||||
|
||||
/**
|
||||
* `exact` means the question *is* a suggestion this definition published —
|
||||
* the reader clicked a chip, or typed its words. It is the strongest claim
|
||||
* anything can have on a question, and callers weighing this match against
|
||||
* another matcher need to be able to see that rather than infer it from a
|
||||
* number.
|
||||
*/
|
||||
const exact = Boolean(suggestion);
|
||||
|
||||
/* A chip that declared its capability has already answered this. */
|
||||
if (suggestion?.capability && available.includes(suggestion.capability)) {
|
||||
return { skill, capability: suggestion.capability, score, exact };
|
||||
}
|
||||
|
||||
const asked = available
|
||||
.map((capability) => ({ capability, weight: scoreCapability(capability, question) }))
|
||||
.filter((c) => c.weight > 0)
|
||||
.sort((a, b) => b.weight - a.weight)[0];
|
||||
|
||||
return { skill, capability: asked?.capability || available[0], score, exact };
|
||||
}
|
||||
|
||||
/* ── The record a response is about ─────────────────────────────────────── */
|
||||
|
||||
/**
|
||||
* The entity a source needs, resolved from the question and the page.
|
||||
*
|
||||
* Sources declare what they need — a position, a candidate, a draft, or
|
||||
* nothing — and this is the one place that need is met. Three orders of
|
||||
* evidence, most specific first:
|
||||
*
|
||||
* 1. the question named a record ("summarize hiring activity for Line Cook"),
|
||||
* 2. the page has one open (the drawer, the form being filled in),
|
||||
* 3. neither, and the answer has to ask.
|
||||
*
|
||||
* A source needing nothing resolves against the workspace and is always met.
|
||||
*
|
||||
* Naming the record wins over the page's selection deliberately: an admin who
|
||||
* says which role they mean has said so, and answering about a different one
|
||||
* because a drawer happened to be open would be worse than asking.
|
||||
*/
|
||||
export function resolveEntity(section, question, context = {}) {
|
||||
const need = section.context;
|
||||
if (!need) return { context, ok: true };
|
||||
|
||||
if (need === 'positionId') {
|
||||
const named = resolvePosition(question, context.positions || [], null);
|
||||
const position = named || context.position || null;
|
||||
return position
|
||||
? { context: { ...context, position }, ok: true }
|
||||
: { ok: false, need: 'position' };
|
||||
}
|
||||
|
||||
if (need === 'candidateId') {
|
||||
const q = lower(question);
|
||||
const named = (context.applications || []).find(
|
||||
(a) => a.applicant_name && q.includes(lower(a.applicant_name))
|
||||
);
|
||||
const candidate = named || context.candidate || null;
|
||||
return candidate
|
||||
? { context: { ...context, candidate }, ok: true }
|
||||
: { ok: false, need: 'candidate' };
|
||||
}
|
||||
|
||||
return { context, ok: true };
|
||||
}
|
||||
|
||||
/* ── The answer ─────────────────────────────────────────────────────────── */
|
||||
|
||||
/** The rows a reading offers, whatever shape its source returns them in. */
|
||||
const rowsOf = (data) => data?.steps || data?.items || [];
|
||||
|
||||
/**
|
||||
* One row, read back as a line.
|
||||
*
|
||||
* A reading carries a figure, a description of it, or both, depending on the
|
||||
* source — so the line is assembled from what is there rather than from a fixed
|
||||
* template, and a row with a description that already states its figure
|
||||
* ("2 applications") does not repeat it.
|
||||
*/
|
||||
function rowLine(row) {
|
||||
const label = String(row.label || row.title || row.id || '').trim();
|
||||
if (!label) return null;
|
||||
|
||||
const value = row.value == null || row.value === '' ? null : String(row.value);
|
||||
const detail = row.detail ? String(row.detail) : null;
|
||||
|
||||
const tail = detail
|
||||
? (value && !detail.includes(value) ? `${detail} · ${value}` : detail)
|
||||
: value;
|
||||
|
||||
return tail ? `${label} — ${tail}` : label;
|
||||
}
|
||||
|
||||
/**
|
||||
* A summary, built from whatever the source returned.
|
||||
*
|
||||
* Generic on purpose: it reads rows and states them. Nothing here knows what a
|
||||
* period is, what a stage is, or which skill asked — which is precisely why a
|
||||
* definition written tomorrow gets a summary without this function changing.
|
||||
*/
|
||||
export function summaryLines(data) {
|
||||
return rowsOf(data).map((row) => rowLine(row)).filter(Boolean);
|
||||
}
|
||||
|
||||
/** The label a reading is introduced by — the definition's words, then the source's. */
|
||||
export const responseTitle = (skill, section) =>
|
||||
section.title || `${skill.name} — ${dataSourceLabel(section.source)}`;
|
||||
|
||||
/**
|
||||
* Everything an answer needs, resolved: the section, the data, and whether the
|
||||
* page could supply the record the source required.
|
||||
*
|
||||
* Returns `{ skill, capability, section, data, missing }`. `missing` names what
|
||||
* the caller must ask for; when it is null the reading is real and complete.
|
||||
*/
|
||||
export function resolveOwliverResponse({ skill, capability, question, context = {}, now = new Date() }) {
|
||||
const section = skill.owliver.responses[capability];
|
||||
if (!section) return null;
|
||||
|
||||
const entity = resolveEntity(section, question, context);
|
||||
if (!entity.ok) {
|
||||
return { skill, capability, section, data: null, missing: entity.need };
|
||||
}
|
||||
|
||||
/* The same resolver the page's own sections go through, on the same
|
||||
collections — so the panel and the card beside it cannot report different
|
||||
figures for the same position. */
|
||||
const data = resolveSkillData(section, entity.context, now);
|
||||
return { skill, capability, section, data, missing: null, context: entity.context };
|
||||
}
|
||||
|
||||
/**
|
||||
* A reading, reduced to what a drawn section reads.
|
||||
*
|
||||
* A reply is kept in the thread, so it is stored: the records a source counted
|
||||
* are the evidence behind a figure, not part of the answer, and writing every
|
||||
* application into session storage to draw one bar would be paying for the
|
||||
* whole dataset per turn. Nothing the renderers use is dropped.
|
||||
*/
|
||||
export function presentable(data) {
|
||||
if (!data) return data;
|
||||
const strip = ({ records, ...row }) => row;
|
||||
|
||||
return {
|
||||
...data,
|
||||
...(data.steps ? { steps: data.steps.map(strip) } : null),
|
||||
...(data.items ? { items: data.items.map(strip) } : null),
|
||||
};
|
||||
}
|
||||
|
||||
/** Every capability the product understands, for the editor and the previews. */
|
||||
export const CAPABILITY_SUMMARIES = OWLIVER_CAPABILITIES.map(
|
||||
({ id, label, summary }) => ({ id, label, summary })
|
||||
);
|
||||
|
||||
/* ── Suggestions for a record that has just appeared ────────────────────── */
|
||||
|
||||
/**
|
||||
* What can now be asked about a record the conversation just produced.
|
||||
*
|
||||
* Creating a position is the moment "who could do this?" becomes worth asking,
|
||||
* and the panel is the only thing that knows a position now exists. Rather than
|
||||
* naming a skill to offer — which would put a candidate-matching feature inside
|
||||
* the position-creation flow — this asks the registry the general question: of
|
||||
* the skills attached to this page, which declare a capability whose reading is
|
||||
* *about one position*? Those are exactly the ones that can say something about
|
||||
* the record just made.
|
||||
*
|
||||
* The record's own title is appended to each prompt, so the answer resolves
|
||||
* against it directly and the reader is never asked to pick from a list that
|
||||
* includes the position they are looking at. Nothing is named here: a skill
|
||||
* added tomorrow that reads a position is offered on the same terms.
|
||||
*/
|
||||
export function suggestionsForPosition(contextId, disabled = [], customSources = [], position) {
|
||||
if (!position?.title) return [];
|
||||
|
||||
const chips = [];
|
||||
|
||||
for (const skill of owliverSkillsForContext(contextId, disabled, customSources)) {
|
||||
/* Only capabilities that read one position — a workspace-wide reading has
|
||||
nothing to do with the record that was just created. */
|
||||
const scoped = skill.owliver.capabilities.filter(
|
||||
(capability) => skill.owliver.responses[capability]?.context === 'positionId'
|
||||
);
|
||||
if (!scoped.length) continue;
|
||||
|
||||
const offered = skill.owliver.suggestions
|
||||
.filter((s) => !s.capability || scoped.includes(s.capability))
|
||||
.slice(0, 1)
|
||||
.map((s) => ({
|
||||
label: s.label,
|
||||
/* Named, so the reading resolves against this position rather than
|
||||
asking which one. */
|
||||
prompt: `${s.prompt} for ${position.title}`,
|
||||
skillId: skill.id,
|
||||
skillCapability: s.capability || null,
|
||||
}));
|
||||
|
||||
chips.push(...offered);
|
||||
}
|
||||
|
||||
return chips.slice(0, 2);
|
||||
}
|
||||
@@ -56,6 +56,26 @@ const FREE_TEXT = /^(?:other|another|custom|enter|enter .*|type .*|somewhere els
|
||||
* understood — which re-asks with `retry` rather than storing a guess.
|
||||
*/
|
||||
const FIELDS = {
|
||||
/**
|
||||
* The client this role is being staffed for.
|
||||
*
|
||||
* A "client" is not a record of its own in this product — it is the `company`
|
||||
* on the position, which is the field the Create Position form writes, the
|
||||
* Positions card leads with, and Hired History reports against. So the
|
||||
* conversation collects it into the same field rather than into a store of
|
||||
* its own, and asking Owliver to create a client starts here.
|
||||
*/
|
||||
company: {
|
||||
label: 'Company',
|
||||
settled: (draft) => Boolean(String(draft.company || '').trim()),
|
||||
retry: 'Type the client or company name — "Fairmont San Jose".',
|
||||
parse: (answer) => {
|
||||
const value = cleanPhrase(answer, 60);
|
||||
return value && value.length > 1 ? { company: titleCase(value) } : null;
|
||||
},
|
||||
summary: (draft) => (draft.company ? `Company: ${draft.company}` : null),
|
||||
},
|
||||
|
||||
role_category: {
|
||||
label: 'Role',
|
||||
settled: (draft) => Boolean(draft.title),
|
||||
@@ -233,10 +253,13 @@ function review(flow, steps) {
|
||||
doc: doc(
|
||||
text('Ready to create this position?'),
|
||||
list(summaryLines(flow, steps)),
|
||||
note('Nothing is saved until you choose Create position.')
|
||||
note('Nothing is saved until you choose one. Save as Draft keeps it unpublished — the same as the button on the form.')
|
||||
),
|
||||
/* The two the form offers, in the same words and the same order, so the
|
||||
conversation and the page commit a position the same two ways. */
|
||||
followUp: [
|
||||
{ label: 'Create position', prompt: 'Create position' },
|
||||
{ label: 'Save as Draft', prompt: 'Save as draft' },
|
||||
{ label: 'Publish Job Posting', prompt: 'Publish job posting' },
|
||||
{ label: 'Change details', prompt: 'Change details' },
|
||||
],
|
||||
};
|
||||
@@ -305,9 +328,15 @@ export function advancePositionFlow({ flow, answer, skill, roles = [] }) {
|
||||
|
||||
/* The confirmation step. "Create position" is the only path to a record. */
|
||||
if (flow.stage === 'review') {
|
||||
if (/^(?:create position|create|create it|yes|confirm|looks good|go ahead)$/i.test(said)) {
|
||||
/* Saving unpublished. The same write, with the status the form's own
|
||||
"Save as Draft" button sets — one create path, two statuses. */
|
||||
if (/^(?:save as draft|save draft|draft|save it as a draft)$/i.test(said)) {
|
||||
if (missingRequired(flow, steps).length) return review(flow, steps);
|
||||
return { flow: { ...flow, stage: 'creating' }, create: { draft: flow.draft } };
|
||||
return { flow: { ...flow, stage: 'creating' }, create: { draft: flow.draft, status: 'draft' } };
|
||||
}
|
||||
if (/^(?:create position|publish job posting|publish|create|create it|yes|confirm|looks good|go ahead)$/i.test(said)) {
|
||||
if (missingRequired(flow, steps).length) return review(flow, steps);
|
||||
return { flow: { ...flow, stage: 'creating' }, create: { draft: flow.draft, status: 'active' } };
|
||||
}
|
||||
if (/^(?:change|change details|edit|change something|no)$/i.test(said)) return changeMenu(flow, steps);
|
||||
|
||||
@@ -322,7 +351,7 @@ export function advancePositionFlow({ flow, answer, skill, roles = [] }) {
|
||||
doc: doc(
|
||||
text('I did not catch that. Ready to create this position?'),
|
||||
list(summaryLines(flow, steps)),
|
||||
note('Choose Create position, or tell me what to change.')
|
||||
note('Choose Save as Draft or Publish Job Posting, or tell me what to change.')
|
||||
),
|
||||
};
|
||||
}
|
||||
@@ -430,14 +459,19 @@ function applyStatement(flow, steps, said, roles) {
|
||||
|
||||
/** The position exists. Said plainly, with what was created. */
|
||||
export function positionCreatedReply(position) {
|
||||
const isDraft = position.status === 'draft';
|
||||
|
||||
return doc(
|
||||
text('Position created successfully.'),
|
||||
text(isDraft ? 'Saved as a draft.' : 'Position created successfully.'),
|
||||
list([
|
||||
position.company,
|
||||
position.title,
|
||||
position.location,
|
||||
payLabel(position),
|
||||
].filter(Boolean)),
|
||||
note('It is on the Positions list now — applications will start appearing against it.')
|
||||
note(isDraft
|
||||
? 'It is on the Positions list as a draft — nobody can apply until it is published, and it stays a draft until you publish it.'
|
||||
: 'It is on the Positions list now — applications will start appearing against it.')
|
||||
);
|
||||
}
|
||||
|
||||
@@ -449,7 +483,20 @@ export function positionFailedReply() {
|
||||
);
|
||||
}
|
||||
|
||||
/** What the panel offers after a position is created. */
|
||||
export const createdFollowUp = (position) => [
|
||||
{ label: 'View position', route: `/admin/positions/${position.id}` },
|
||||
];
|
||||
/**
|
||||
* What the panel offers after a position is created.
|
||||
*
|
||||
* A published role has an obvious next question — who can fill it — so it is
|
||||
* offered here rather than left to be typed. The chip carries the position's
|
||||
* own title and the wording the workforce engine already answers, so it is an
|
||||
* ordinary question resolved by the path that was already there: no handler,
|
||||
* no navigation, and the same answer as asking it by hand.
|
||||
*/
|
||||
export const createdFollowUp = (position) => (position.status === 'draft'
|
||||
/* A draft is unfinished, so the way on is the form that finishes it — the
|
||||
same route the Positions card's Continue uses. */
|
||||
? [{ label: 'Continue to save', route: `/admin/positions/new?draft=${encodeURIComponent(position.id)}` }]
|
||||
: [
|
||||
{ label: 'View position', route: `/admin/positions/${position.id}` },
|
||||
{ label: 'Match candidates', prompt: `Who matches ${position.title}?` },
|
||||
]);
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
import { PLACEMENT_ROUTES } from '@/components/ai-assistant/placement';
|
||||
import { parseYaml } from './yaml';
|
||||
import { normalizeSkillUi } from './uiConfig';
|
||||
import { normalizeSkillOwliver } from './owliverConfig';
|
||||
import { SUPPORTED_SKILL_PAGES, canonicalPage, surfaceFor, surfaceForRoute } from './surfaces';
|
||||
|
||||
/**
|
||||
* Owliver skill registry.
|
||||
@@ -16,43 +20,25 @@ import { PLACEMENT_ROUTES } from '@/components/ai-assistant/placement';
|
||||
const FILES = import.meta.glob('/src/skills/**/*.md', { query: '?raw', import: 'default', eager: true });
|
||||
|
||||
/**
|
||||
* Frontmatter, parsed to the subset the format actually uses: `key: value` and
|
||||
* `key:` followed by an indented `- item` list.
|
||||
* Frontmatter, as data.
|
||||
*
|
||||
* Deliberately not a YAML library — the app has none, this needs no dependency,
|
||||
* and a skill file that reaches for anchors or nested maps has outgrown being a
|
||||
* declaration anyway.
|
||||
* Skills grew declarative UI configuration, which is nested, so this reads the
|
||||
* YAML subset in `yaml.js` rather than the flat `key: value` pairs it used to.
|
||||
* The old shapes are a strict subset of the new one — a definition written for
|
||||
* the previous parser parses identically here.
|
||||
*
|
||||
* A file whose frontmatter cannot be read raises rather than registering a
|
||||
* half-understood definition; `parseSkill` decides what to do with that.
|
||||
*/
|
||||
function parseFrontmatter(raw) {
|
||||
const match = /^---\r?\n([\s\S]*?)\r?\n---/.exec(raw);
|
||||
if (!match) return { data: {}, body: raw };
|
||||
|
||||
const data = {};
|
||||
let listKey = null;
|
||||
|
||||
for (const line of match[1].split(/\r?\n/)) {
|
||||
if (!line.trim()) continue;
|
||||
|
||||
const item = /^\s*-\s+(.*)$/.exec(line);
|
||||
if (item && listKey) {
|
||||
data[listKey].push(item[1].trim());
|
||||
continue;
|
||||
}
|
||||
|
||||
const pair = /^([A-Za-z0-9_-]+):\s*(.*)$/.exec(line);
|
||||
if (!pair) continue;
|
||||
|
||||
const [, key, value] = pair;
|
||||
if (value === '') {
|
||||
listKey = key;
|
||||
data[key] = [];
|
||||
} else {
|
||||
listKey = null;
|
||||
data[key] = value.trim();
|
||||
}
|
||||
}
|
||||
|
||||
return { data, body: raw.slice(match[0].length).trim() };
|
||||
const data = parseYaml(match[1]);
|
||||
return {
|
||||
data: data && typeof data === 'object' && !Array.isArray(data) ? data : {},
|
||||
body: raw.slice(match[0].length).trim(),
|
||||
};
|
||||
}
|
||||
|
||||
/** Bullets under a `## Heading`, for the capability list shown in Settings. */
|
||||
@@ -131,10 +117,14 @@ function sectionLevels(body) {
|
||||
/**
|
||||
* The page key a route belongs to — `/admin/positions` → `positions`.
|
||||
*
|
||||
* Derived from the placement table rather than written down again, so a route
|
||||
* added there is addressable by a skill without touching this file.
|
||||
* The surface table answers first, because a surface already states its own
|
||||
* route and its key is not always the path tail: `/admin/positions/new` is
|
||||
* `create-position`, not `positions/new`. Falling back to the tail keeps every
|
||||
* route that has no declared surface behaving exactly as it did.
|
||||
*/
|
||||
export function pageKeyForRoute(route) {
|
||||
const surface = surfaceForRoute(route);
|
||||
if (surface) return surface.id;
|
||||
const tail = route.replace(/^\/admin\/?/, '');
|
||||
return tail === '' ? 'control-center' : tail;
|
||||
}
|
||||
@@ -154,6 +144,59 @@ const ROUTE_BY_PAGE_KEY = Object.entries(PLACEMENT_ROUTES).reduce((acc, [route,
|
||||
export const routeForPageKey = (key) => ROUTE_BY_PAGE_KEY[key]?.route ?? null;
|
||||
export const pageKeyForContext = (contextId) => PAGE_KEY_BY_CONTEXT[contextId] ?? null;
|
||||
|
||||
/**
|
||||
* The two management surfaces a definition can belong to.
|
||||
*
|
||||
* `ui` extends a KROW page; `owliver` extends the assistant. They share the
|
||||
* parser, the registry, the validator, the persistence and the data resolver —
|
||||
* only the authoring and management experience is separate, which is what this
|
||||
* classification serves.
|
||||
*/
|
||||
export const SKILL_FACETS = ['ui', 'owliver'];
|
||||
|
||||
/**
|
||||
* Which of them a definition belongs to.
|
||||
*
|
||||
* Declared, never configured: a `ui:` block is a page extension, and an
|
||||
* `owliver:` block, triggers, actions, a prompt or a conversation is an
|
||||
* assistant extension. A definition that declares neither is an assistant
|
||||
* skill — that is what every definition written before the split was, and
|
||||
* reading it any other way would drop it out of both lists.
|
||||
*
|
||||
* Workforce paths are neither. They define a capability the workforce holds
|
||||
* and are managed in Skill Development, so they carry no facet and appear on
|
||||
* neither list.
|
||||
*/
|
||||
export function skillFacets({ data = {}, kind, ui = {}, owliver, conversation = [] }) {
|
||||
if (kind === 'workforce') return [];
|
||||
|
||||
const extendsPage = Object.keys(ui).length > 0;
|
||||
|
||||
/**
|
||||
* Behaviour Owliver actually gains: something to answer with, something to
|
||||
* open, or questions to ask.
|
||||
*
|
||||
* Triggers alone are deliberately not on this list. A trigger is a way of
|
||||
* being *named*, and a page-drawing definition that names itself is still a
|
||||
* page-drawing definition — listing it as an Owliver skill would offer an
|
||||
* author a capability list it never declared. A definition with no `ui:` is
|
||||
* the other way round: triggers are all it has, and they are what it does.
|
||||
*/
|
||||
const teachesOwliver = Boolean(
|
||||
data.owliver
|
||||
|| (Array.isArray(data.actions) && data.actions.length)
|
||||
|| data.prompt
|
||||
|| conversation.length
|
||||
|| owliver?.capabilities?.length
|
||||
|| owliver?.suggestions?.length
|
||||
);
|
||||
|
||||
return [
|
||||
extendsPage ? 'ui' : null,
|
||||
teachesOwliver || !extendsPage ? 'owliver' : null,
|
||||
].filter(Boolean);
|
||||
}
|
||||
|
||||
/**
|
||||
* One Markdown definition → one skill.
|
||||
*
|
||||
@@ -179,9 +222,49 @@ export function parseSkill(raw, { path = 'custom', custom = false } = {}) {
|
||||
*/
|
||||
const kind = data.kind || (levels.length ? 'workforce' : 'assistant');
|
||||
|
||||
/* The declarative UI this definition contributes, checked against the
|
||||
closed vocabulary in `surfaces.js`. A definition with no `ui:` block is
|
||||
exactly what it was before this existed. */
|
||||
const { ui, errors: uiErrors } = normalizeSkillUi(data.ui, {
|
||||
declaredPages: pages,
|
||||
skillId: id,
|
||||
});
|
||||
|
||||
/* The same definition's second consumer. `owliver:` declares what can be
|
||||
asked for in the panel, reading the source the page section already
|
||||
names — so one file answers "what does this page show" and "what can
|
||||
Owliver be asked here" without either being written twice. A definition
|
||||
with no `owliver:` block is exactly what it was before this existed. */
|
||||
const { owliver, errors: owliverErrors } = normalizeSkillOwliver(data.owliver, {
|
||||
ui,
|
||||
skillId: id,
|
||||
skillName: data.name || '',
|
||||
});
|
||||
|
||||
/* The questions this skill asks, when it collects its input in the chat
|
||||
rather than by opening something. */
|
||||
const conversation = sectionSteps(body, 'Conversation');
|
||||
|
||||
return {
|
||||
id,
|
||||
kind,
|
||||
ui,
|
||||
uiErrors,
|
||||
owliver,
|
||||
owliverErrors,
|
||||
/**
|
||||
* What this definition extends, derived from what it declares.
|
||||
*
|
||||
* Two things wear the same format and are managed as different lists: a
|
||||
* definition with a `ui:` block extends a *page*, and one that teaches
|
||||
* Owliver — an `owliver:` block, triggers, actions, a conversation —
|
||||
* extends the *assistant*. Reading that off the declaration rather than
|
||||
* off a `type:` field is what makes the split free: every definition
|
||||
* already written classifies itself, nothing stored has to be migrated,
|
||||
* and a definition doing both is listed in both places rather than
|
||||
* losing half of itself to a category.
|
||||
*/
|
||||
facets: skillFacets({ data, kind, ui, owliver, conversation }),
|
||||
name: data.name || 'Untitled skill',
|
||||
description: data.description || '',
|
||||
status: data.status === 'inactive' ? 'inactive' : 'active',
|
||||
@@ -213,12 +296,21 @@ export function parseSkill(raw, { path = 'custom', custom = false } = {}) {
|
||||
? data.triggers
|
||||
: [data.name].filter(Boolean)
|
||||
).map((t) => String(t).toLowerCase()),
|
||||
/**
|
||||
* Whether those triggers were *claimed* or merely inherited.
|
||||
*
|
||||
* The fallback above is convenient and, until this field existed,
|
||||
* indistinguishable from the real thing — so a definition that only draws
|
||||
* a card was silently claiming its own name as a phrase Owliver answers
|
||||
* to, and could take a question from a definition written to answer it.
|
||||
* Keeping the distinction lets the matcher weigh a claim differently from
|
||||
* a default without changing what `triggers` contains.
|
||||
*/
|
||||
declaredTriggers: Boolean(Array.isArray(data.triggers) && data.triggers.length),
|
||||
prompt: data.prompt || null,
|
||||
capabilities: sectionBullets(body, 'Capabilities'),
|
||||
purpose: sectionBullets(body, 'Purpose'),
|
||||
/* The questions this skill asks, when it collects its input in the chat
|
||||
rather than by opening something. */
|
||||
conversation: sectionSteps(body, 'Conversation'),
|
||||
conversation,
|
||||
path,
|
||||
body,
|
||||
custom,
|
||||
@@ -255,23 +347,41 @@ export function allSkills(customSources = []) {
|
||||
return [...byId.values()].sort((a, b) => a.name.localeCompare(b.name));
|
||||
}
|
||||
|
||||
/** Validates a definition before it is stored. Returns an error string or null. */
|
||||
/**
|
||||
* Validates a definition before it is stored. Returns an error string or null.
|
||||
*
|
||||
* Frontmatter first, then the declarative UI — an author is told the first
|
||||
* thing that is wrong, in the order they would fix it.
|
||||
*/
|
||||
export function validateSkillSource(raw) {
|
||||
if (!String(raw).trim()) return 'Paste or upload a Markdown definition.';
|
||||
let skill;
|
||||
try {
|
||||
skill = parseSkill(raw, { custom: true });
|
||||
} catch {
|
||||
return 'That definition could not be parsed.';
|
||||
} catch (error) {
|
||||
/* The YAML subset reports the line it failed on; that is far more useful
|
||||
than "could not be parsed". */
|
||||
return `That definition could not be parsed. ${error.message || ''}`.trim();
|
||||
}
|
||||
if (!skill.id) return 'The frontmatter needs an `id`.';
|
||||
if (!/^[a-z0-9][a-z0-9-]*$/.test(skill.id)) return '`id` must be lower-case letters, numbers and dashes.';
|
||||
if (!skill.name) return 'The frontmatter needs a `name`.';
|
||||
if (!skill.pages.length) return 'The frontmatter needs at least one `pages` entry.';
|
||||
const unknown = skill.pages.filter((p) => !ROUTE_BY_PAGE_KEY[p]);
|
||||
|
||||
const unknown = skill.pages.filter((p) => !surfaceFor(p));
|
||||
if (unknown.length) {
|
||||
return `Unknown page${unknown.length > 1 ? 's' : ''}: ${unknown.join(', ')}. Known pages: ${Object.keys(ROUTE_BY_PAGE_KEY).join(', ')}.`;
|
||||
return `Unsupported page${unknown.length > 1 ? 's' : ''}: ${unknown.join(', ')}. Supported pages: ${SUPPORTED_SKILL_PAGES.join(', ')}.`;
|
||||
}
|
||||
|
||||
/* A UI block that names something the product does not offer is refused
|
||||
outright rather than registered with the offending section dropped. */
|
||||
if (skill.uiErrors?.length) return skill.uiErrors[0];
|
||||
|
||||
/* Same rule for the panel half of the definition: a capability, source or
|
||||
step the product cannot honour is refused now rather than registered as
|
||||
something Owliver offers and then cannot answer. */
|
||||
if (skill.owliverErrors?.length) return skill.owliverErrors[0];
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -280,7 +390,10 @@ export const PAGE_KEYS = Object.keys(ROUTE_BY_PAGE_KEY).sort();
|
||||
|
||||
/** Context ids a skill applies to, resolved through the placement table. */
|
||||
export function contextIdsForSkill(skill) {
|
||||
return skill.pages.map((key) => ROUTE_BY_PAGE_KEY[key]?.contextId).filter(Boolean);
|
||||
return skill.pages
|
||||
.map((key) => ROUTE_BY_PAGE_KEY[key]?.contextId
|
||||
|| ROUTE_BY_PAGE_KEY[pageKeyForRoute(surfaceFor(key)?.route || '')]?.contextId)
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -297,10 +410,11 @@ export function contextIdsForSkill(skill) {
|
||||
*/
|
||||
export function getSkillsForPage(pageId, { disabled = [], customSources = [], kind } = {}) {
|
||||
if (!pageId) return [];
|
||||
const wanted = canonicalPage(pageId) || pageId;
|
||||
return allSkills(customSources).filter(
|
||||
(s) => s.status === 'active'
|
||||
&& !disabled.includes(s.id)
|
||||
&& s.pages.includes(pageId)
|
||||
&& s.pages.some((p) => (canonicalPage(p) || p) === wanted)
|
||||
&& (!kind || s.kind === kind)
|
||||
);
|
||||
}
|
||||
@@ -338,10 +452,40 @@ function triggerMatches(trigger, question) {
|
||||
return new RegExp(pattern).test(question);
|
||||
}
|
||||
|
||||
/** The first skill on this page whose triggers match the question. */
|
||||
/**
|
||||
* The first skill on this page whose triggers match the question.
|
||||
*
|
||||
* A definition has to be *addressable by the assistant* before its triggers
|
||||
* count, and there are two ways to be: teach Owliver something — an `owliver:`
|
||||
* block, an action, a conversation — or explicitly claim a phrase with
|
||||
* `triggers:`. A definition that does neither is a page extension that happens
|
||||
* to have a name, and matching it here means answering a question with a
|
||||
* restatement of a card's description.
|
||||
*
|
||||
* That was live: `hiring-activity` draws a flow on the Positions page, declares
|
||||
* no triggers, and inherited "hiring activity" from its own name — enough to
|
||||
* take "show hiring activity" from the definition written to answer it.
|
||||
*
|
||||
* Both conditions are read off what the definition declares, so a skill written
|
||||
* tomorrow is admitted or excluded by the same rule, and nothing that claimed a
|
||||
* phrase loses it.
|
||||
*/
|
||||
const addressable = (skill) => skill.declaredTriggers || skill.facets?.includes('owliver');
|
||||
|
||||
export function matchSkill(question, contextId, disabled = [], customSources = []) {
|
||||
const q = String(question).toLowerCase();
|
||||
return skillsForContext(contextId, disabled, customSources).find(
|
||||
(s) => s.triggers.length > 0 && s.triggers.some((t) => triggerMatches(t, q))
|
||||
(s) => addressable(s) && s.triggers.length > 0 && s.triggers.some((t) => triggerMatches(t, q))
|
||||
) ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The definitions belonging to one management surface.
|
||||
*
|
||||
* The single answer to "what belongs on the UI Skills list" and "what belongs
|
||||
* on the Owliver Skills list". Both lists come from `allSkills` — one registry,
|
||||
* two readings of it — so a definition cannot exist on one list and be unknown
|
||||
* to the other system.
|
||||
*/
|
||||
export const skillsWithFacet = (skills = [], facet) =>
|
||||
skills.filter((s) => s.facets?.includes(facet));
|
||||
|
||||
478
src/lib/skills/surfaces.js
Normal file
478
src/lib/skills/surfaces.js
Normal file
@@ -0,0 +1,478 @@
|
||||
/**
|
||||
* What a skill definition is allowed to say about the product.
|
||||
*
|
||||
* A skill can extend a KROW page: name a surface, declare a section, and the
|
||||
* page renders it. That only stays safe — and only stays a *product* rather
|
||||
* than a scripting host — because the vocabulary is closed. Everything a
|
||||
* definition may name is in this file: the surfaces, the placements on each
|
||||
* surface, the component types, and the data sources.
|
||||
*
|
||||
* The rule that makes it safe: **nothing here is code, and nothing here is
|
||||
* looked up dynamically from the file.** A definition names a key; this module
|
||||
* says whether that key exists; the renderer maps it to a component the app
|
||||
* already ships. A definition that names something absent is rejected with a
|
||||
* message, never rendered as an unknown thing and never executed.
|
||||
*/
|
||||
|
||||
/**
|
||||
* The surfaces a skill can extend.
|
||||
*
|
||||
* `aliases` keep the page keys the existing skills already use — `hired`,
|
||||
* `university` — working under the names the product now shows, so the eight
|
||||
* definitions on disk did not have to be rewritten to gain this feature.
|
||||
*/
|
||||
export const SKILL_SURFACES = [
|
||||
{
|
||||
id: 'control-center',
|
||||
label: 'Control Center',
|
||||
route: '/admin',
|
||||
placements: ['after-header', 'before-footer'],
|
||||
},
|
||||
{
|
||||
id: 'positions',
|
||||
label: 'Positions',
|
||||
route: '/admin/positions',
|
||||
/* Mounted in three places, because Positions is three experiences: the page
|
||||
that lists the roles, the card for each one, and the drawer "View
|
||||
position" opens.
|
||||
|
||||
`after-position-list-summary` and `after-position-list` are the *page*:
|
||||
they render once, above and below the grid, with no position in context.
|
||||
`after-position-card` renders inside each card. The rest render inside
|
||||
the drawer and on the full position page, where one position is being
|
||||
read. Every one of these is mounted — a placement the vocabulary offers
|
||||
and no page provides is a definition that validates and then silently
|
||||
does nothing. */
|
||||
placements: [
|
||||
'after-position-list-summary',
|
||||
'after-position-list',
|
||||
'after-header',
|
||||
'after-position-card',
|
||||
'after-position-summary',
|
||||
'before-candidates',
|
||||
'after-candidates',
|
||||
'before-footer',
|
||||
],
|
||||
},
|
||||
{
|
||||
/* The authoring form, which is a surface in its own right: what a skill has
|
||||
to say there is about the position being specified, not about the ones
|
||||
that already exist. Its placements follow the form's own three parts, so
|
||||
a definition can sit beside the field group it is about. */
|
||||
id: 'create-position',
|
||||
label: 'Create Position',
|
||||
route: '/admin/positions/new',
|
||||
aliases: ['new-position'],
|
||||
placements: [
|
||||
'after-header',
|
||||
'after-job-description',
|
||||
'after-vetting-weights',
|
||||
'before-footer',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'candidates',
|
||||
label: 'Candidates',
|
||||
route: '/admin/candidates',
|
||||
placements: ['after-header', 'after-candidate-summary', 'before-footer'],
|
||||
},
|
||||
{
|
||||
id: 'hired-history',
|
||||
label: 'Hired History',
|
||||
route: '/admin/hired',
|
||||
aliases: ['hired'],
|
||||
placements: ['after-header', 'before-footer'],
|
||||
},
|
||||
{
|
||||
id: 'talent-pool',
|
||||
label: 'Talent Pool',
|
||||
route: '/admin/talent-pool',
|
||||
placements: ['after-header', 'before-footer'],
|
||||
},
|
||||
{
|
||||
id: 'krow-forge',
|
||||
label: 'KROW Forge',
|
||||
route: '/admin/university',
|
||||
aliases: ['university', 'forge'],
|
||||
placements: ['after-header', 'before-footer'],
|
||||
},
|
||||
{
|
||||
id: 'analytics',
|
||||
label: 'Analytics',
|
||||
route: '/admin/analytics',
|
||||
placements: ['after-header', 'before-footer'],
|
||||
},
|
||||
{
|
||||
id: 'activity',
|
||||
label: 'Activity',
|
||||
route: '/admin/activity',
|
||||
placements: ['after-header', 'before-footer'],
|
||||
},
|
||||
/* Not in the eight product surfaces, but skills already attach to it and the
|
||||
account page reads them. Kept so nothing that works today stops working. */
|
||||
{
|
||||
id: 'profile',
|
||||
label: 'Profile',
|
||||
route: '/admin/profile',
|
||||
placements: ['after-header', 'before-footer'],
|
||||
},
|
||||
{
|
||||
id: 'candidates-analysis',
|
||||
label: 'Candidate Analysis',
|
||||
route: '/admin/candidates-analysis',
|
||||
placements: ['after-header', 'before-footer'],
|
||||
},
|
||||
];
|
||||
|
||||
const BY_KEY = new Map();
|
||||
for (const surface of SKILL_SURFACES) {
|
||||
BY_KEY.set(surface.id, surface);
|
||||
for (const alias of surface.aliases || []) BY_KEY.set(alias, surface);
|
||||
}
|
||||
|
||||
/** Every name a definition may use for a surface, for error messages. */
|
||||
export const SUPPORTED_SKILL_PAGES = SKILL_SURFACES.map((s) => s.id);
|
||||
|
||||
/** The surface a declared page name refers to, or null. */
|
||||
export const surfaceFor = (page) => BY_KEY.get(String(page || '').trim()) || null;
|
||||
|
||||
/** The canonical id for a declared page name — `hired` → `hired-history`. */
|
||||
export const canonicalPage = (page) => surfaceFor(page)?.id || null;
|
||||
|
||||
/**
|
||||
* The surface an Admin route belongs to — `/admin/positions/new` →
|
||||
* `create-position`.
|
||||
*
|
||||
* The surfaces already carry their routes, so this reads the answer off the
|
||||
* table rather than deriving a page key from the path a second time. That
|
||||
* matters for the surfaces whose key is not their path tail: without it,
|
||||
* `/admin/positions/new` would key as `positions/new`, which is a page nothing
|
||||
* declares and no definition could attach to.
|
||||
*/
|
||||
export const surfaceForRoute = (route) =>
|
||||
SKILL_SURFACES.find((s) => s.route === String(route || '').trim()) || null;
|
||||
|
||||
/**
|
||||
* The section types a definition may ask for.
|
||||
*
|
||||
* Each entry names a component the application already ships. A type is a key
|
||||
* in this table and nothing else: there is no path from a definition to a
|
||||
* component that is not listed here, which is what stops `type:` from being an
|
||||
* import statement in disguise.
|
||||
*/
|
||||
export const SECTION_TYPES = [
|
||||
{ id: 'card', label: 'Card', summary: 'A titled panel of prose and figures.' },
|
||||
{ id: 'stats', label: 'Stats', summary: 'A row of counted figures.' },
|
||||
{ id: 'list', label: 'List', summary: 'A ranked or plain list of records.' },
|
||||
{ id: 'timeline', label: 'Timeline', summary: 'Dated events, most recent first.' },
|
||||
{ id: 'flow', label: 'Flow', summary: 'A sequence of stages or periods.' },
|
||||
{ id: 'table', label: 'Table', summary: 'Rows and columns.' },
|
||||
{ id: 'progress', label: 'Progress', summary: 'Bars against a total.' },
|
||||
{ id: 'insight', label: 'Insight', summary: 'One finding, stated plainly.' },
|
||||
/* Weighted criteria that share a budget. Distinct from `progress`, which is
|
||||
bars against an independent maximum: these bars are shares of one total,
|
||||
and the total is a fact about the set rather than about any one row. */
|
||||
{ id: 'weights', label: 'Weights', summary: 'Weighted criteria as shares of one total.' },
|
||||
];
|
||||
|
||||
export const SUPPORTED_SECTION_TYPES = SECTION_TYPES.map((t) => t.id);
|
||||
|
||||
/**
|
||||
* What a definition may ask *Owliver* to do with the same reading.
|
||||
*
|
||||
* A skill declares `ui:` for the page and `owliver:` for the panel, and both
|
||||
* name the same data source. A capability is the second half of that: the shape
|
||||
* the answer takes when it is asked for in conversation rather than rendered on
|
||||
* the page.
|
||||
*
|
||||
* `shape` is the section type the answer is drawn with, so `flow` in a chat
|
||||
* reply is the *same* component the page renders — there is one flow renderer,
|
||||
* not one per consumer. `summary` has no shape because a summary is prose: the
|
||||
* figures are read back as sentences rather than drawn.
|
||||
*
|
||||
* `terms` are how a question is recognised as asking for this shape. They are
|
||||
* deliberately about the *shape* and never about a subject: "as a flow" belongs
|
||||
* here, "hiring activity" belongs in a definition's `triggers`. That split is
|
||||
* what keeps this table closed while the skills stay open.
|
||||
*/
|
||||
export const OWLIVER_CAPABILITIES = [
|
||||
{
|
||||
id: 'summary',
|
||||
label: 'Summary',
|
||||
shape: null,
|
||||
summary: 'Reads the figures back as sentences.',
|
||||
terms: ['summary', 'summarise', 'summarize', 'summarised', 'summarized', 'summarising',
|
||||
'summarizing', 'sum up', 'recap', 'overview', 'brief me', 'in short', 'tell me about',
|
||||
'what is the', 'how is'],
|
||||
},
|
||||
{
|
||||
id: 'flow',
|
||||
label: 'Flow',
|
||||
shape: 'flow',
|
||||
summary: 'Draws the stages or periods as a sequence.',
|
||||
terms: ['flow', 'as a flow', 'chart', 'graph', 'diagram', 'funnel', 'stages', 'visual',
|
||||
'visualise', 'visualize', 'step by step'],
|
||||
},
|
||||
{
|
||||
id: 'stats',
|
||||
label: 'Stats',
|
||||
shape: 'stats',
|
||||
summary: 'A row of counted figures.',
|
||||
terms: ['stats', 'statistics', 'figures', 'numbers', 'counts', 'how many'],
|
||||
},
|
||||
{
|
||||
id: 'list',
|
||||
label: 'List',
|
||||
shape: 'list',
|
||||
summary: 'A ranked or plain list of records.',
|
||||
terms: ['list', 'who are', 'which ones', 'show me the records'],
|
||||
},
|
||||
{
|
||||
id: 'table',
|
||||
label: 'Table',
|
||||
shape: 'table',
|
||||
summary: 'Rows and columns.',
|
||||
terms: ['table', 'as a table', 'rows', 'grid', 'spreadsheet'],
|
||||
},
|
||||
{
|
||||
id: 'timeline',
|
||||
label: 'Timeline',
|
||||
shape: 'timeline',
|
||||
summary: 'Dated events, most recent first.',
|
||||
terms: ['timeline', 'history', 'chronology', 'over time', 'what happened'],
|
||||
},
|
||||
{
|
||||
id: 'progress',
|
||||
label: 'Progress',
|
||||
shape: 'progress',
|
||||
summary: 'Bars against a total.',
|
||||
terms: ['progress', 'bars', 'completion', 'how far'],
|
||||
},
|
||||
{
|
||||
id: 'weights',
|
||||
label: 'Weights',
|
||||
shape: 'weights',
|
||||
summary: 'The weighted criteria, adjustable when the page accepts the write.',
|
||||
terms: ['weight', 'weights', 'weighting', 'weightings', 'importance', 'balance',
|
||||
'set the weights', 'adjust the weights', 'screening weight', 'vetting weight',
|
||||
'criteria'],
|
||||
},
|
||||
{
|
||||
id: 'insight',
|
||||
label: 'Insight',
|
||||
shape: 'insight',
|
||||
summary: 'One finding, stated plainly.',
|
||||
terms: ['insight', 'finding', 'takeaway', 'headline', 'what stands out'],
|
||||
},
|
||||
{
|
||||
id: 'card',
|
||||
label: 'Card',
|
||||
shape: 'card',
|
||||
summary: 'A titled panel of figures.',
|
||||
terms: ['card', 'panel', 'at a glance'],
|
||||
},
|
||||
];
|
||||
|
||||
export const SUPPORTED_OWLIVER_CAPABILITIES = OWLIVER_CAPABILITIES.map((c) => c.id);
|
||||
|
||||
/** The capability a declared name refers to, or null. */
|
||||
export const owliverCapabilityFor = (id) =>
|
||||
OWLIVER_CAPABILITIES.find((c) => c.id === String(id || '').trim()) || null;
|
||||
|
||||
/** `flow` → `Flow`. */
|
||||
export const owliverCapabilityLabel = (id) => owliverCapabilityFor(id)?.label || id;
|
||||
|
||||
/**
|
||||
* The data a section may ask for.
|
||||
*
|
||||
* Each source is a named reading of data the application already holds, and
|
||||
* `context` says what a page must know for the reading to be possible — a
|
||||
* position id, a candidate id, or nothing. A source is resolved by
|
||||
* `dataResolver.js`; a definition cannot reach a store directly, cannot write,
|
||||
* and cannot name a field that is not offered here.
|
||||
*/
|
||||
export const DATA_SOURCES = [
|
||||
{
|
||||
id: 'position.activity',
|
||||
label: 'Position activity',
|
||||
context: 'positionId',
|
||||
summary: 'Applications to this position, counted over time.',
|
||||
shapes: ['flow', 'stats', 'timeline', 'table', 'insight', 'card'],
|
||||
},
|
||||
{
|
||||
id: 'position.pipeline',
|
||||
label: 'Position pipeline',
|
||||
context: 'positionId',
|
||||
summary: 'Applied → screened → shortlisted → interviewed → hired.',
|
||||
shapes: ['flow', 'stats', 'progress', 'table', 'card'],
|
||||
},
|
||||
{
|
||||
id: 'position.candidates',
|
||||
label: 'Position candidates',
|
||||
context: 'positionId',
|
||||
summary: 'Candidates matched to this position, best first.',
|
||||
shapes: ['list', 'table', 'stats', 'card'],
|
||||
},
|
||||
{
|
||||
/**
|
||||
* Who could actually do this work, ranked.
|
||||
*
|
||||
* Distinct from `position.candidates`, which lists the people who *applied
|
||||
* here*. This one reads the whole candidate pool against what the position
|
||||
* states — so a role published a minute ago, with no applications at all,
|
||||
* still has an answer. The reading is `poolFor` in `lib/workforce.js`, the
|
||||
* same engine the panel's workforce conversation and the position page use;
|
||||
* nothing about matching is decided in the resolver.
|
||||
*/
|
||||
id: 'position.matches',
|
||||
label: 'Position candidate matches',
|
||||
context: 'positionId',
|
||||
summary: 'The candidate pool scored against this position, best first.',
|
||||
shapes: ['list', 'table', 'stats', 'card', 'insight'],
|
||||
},
|
||||
{
|
||||
id: 'position.requirements',
|
||||
label: 'Position requirements',
|
||||
context: 'positionId',
|
||||
summary: 'What this position states it needs.',
|
||||
shapes: ['list', 'table', 'card'],
|
||||
},
|
||||
{
|
||||
id: 'candidate.readiness',
|
||||
label: 'Candidate readiness',
|
||||
context: 'candidateId',
|
||||
summary: 'Screening dimensions for one candidate.',
|
||||
shapes: ['progress', 'stats', 'list', 'table', 'card'],
|
||||
},
|
||||
{
|
||||
id: 'candidate.activity',
|
||||
label: 'Candidate activity',
|
||||
context: 'candidateId',
|
||||
summary: 'What has happened on this candidate’s record.',
|
||||
shapes: ['timeline', 'list', 'table', 'card'],
|
||||
},
|
||||
{
|
||||
id: 'candidates.pipeline',
|
||||
label: 'Candidate pipeline',
|
||||
context: null,
|
||||
summary: 'Every candidate, counted by stage.',
|
||||
shapes: ['flow', 'stats', 'progress', 'table', 'card'],
|
||||
},
|
||||
{
|
||||
id: 'candidates.activity',
|
||||
label: 'Candidate activity',
|
||||
context: null,
|
||||
summary: 'Applications across the workspace, counted over time.',
|
||||
shapes: ['flow', 'stats', 'timeline', 'table', 'card'],
|
||||
},
|
||||
{
|
||||
id: 'positions.demand',
|
||||
label: 'Position demand',
|
||||
context: null,
|
||||
summary: 'Open positions and what they still need.',
|
||||
shapes: ['list', 'table', 'stats', 'card'],
|
||||
},
|
||||
{
|
||||
id: 'workforce.training',
|
||||
label: 'Workforce training',
|
||||
context: null,
|
||||
summary: 'Training paths and progress against them.',
|
||||
shapes: ['progress', 'list', 'stats', 'table', 'card'],
|
||||
},
|
||||
{
|
||||
/* The vetting weights a position is being specified with. `positionId`
|
||||
context, but the "position" on Create Position is the draft in the form
|
||||
rather than a saved record — which is the point: the resolver reads the
|
||||
same field either way, so one source serves the form and the position it
|
||||
becomes. */
|
||||
id: 'position.vetting',
|
||||
label: 'Position vetting weights',
|
||||
context: 'positionId',
|
||||
summary: 'How this position weights each screening criterion.',
|
||||
shapes: ['weights', 'flow', 'progress', 'stats', 'table', 'card', 'insight'],
|
||||
/**
|
||||
* This reading can be written back.
|
||||
*
|
||||
* `writable` is what lets a definition declare `editable: true` and get
|
||||
* controls instead of a read-out. It is a property of the *source*, not of
|
||||
* the definition — a skill cannot make a reading writable by asking, and a
|
||||
* source with no page willing to accept the write simply renders read-only.
|
||||
* That keeps the closed vocabulary closed in both directions.
|
||||
*/
|
||||
writable: true,
|
||||
writeSummary: 'Sets the screening weights on the position being specified.',
|
||||
},
|
||||
{
|
||||
id: 'hires.recent',
|
||||
label: 'Recent hires',
|
||||
context: null,
|
||||
summary: 'Who was hired, for which role, and when.',
|
||||
shapes: ['list', 'table', 'timeline', 'stats', 'card'],
|
||||
},
|
||||
{
|
||||
id: 'hires.performance',
|
||||
label: 'Hiring performance',
|
||||
context: null,
|
||||
summary: 'Hires, time-to-hire, quality and conversion, counted together.',
|
||||
shapes: ['stats', 'card', 'table', 'flow', 'insight'],
|
||||
},
|
||||
{
|
||||
id: 'activity.events',
|
||||
label: 'Workspace activity',
|
||||
context: null,
|
||||
summary: 'What has happened across the workspace, most recent first.',
|
||||
shapes: ['timeline', 'list', 'table', 'stats', 'card'],
|
||||
},
|
||||
];
|
||||
|
||||
export const SUPPORTED_DATA_SOURCES = DATA_SOURCES.map((s) => s.id);
|
||||
|
||||
export const dataSourceFor = (id) => DATA_SOURCES.find((s) => s.id === id) || null;
|
||||
|
||||
/**
|
||||
* Can this reading be written back?
|
||||
*
|
||||
* The one question `editable:` is checked against. A page opts in by publishing
|
||||
* a handler for the source (see `usePublishPageActions`); a definition opts in
|
||||
* by declaring `editable: true`. Both have to hold before a control is drawn,
|
||||
* so neither the author nor the page can enable editing on its own.
|
||||
*/
|
||||
export const isSourceWritable = (id) => Boolean(dataSourceFor(id)?.writable);
|
||||
|
||||
/**
|
||||
* The periods a time-based section may ask for.
|
||||
*
|
||||
* Only the ones the data layer can actually compute from record timestamps.
|
||||
* A period is a window over `created_date`, resolved at read time against the
|
||||
* current date — never a stored figure and never a hard-coded date.
|
||||
*/
|
||||
export const PERIODS = [
|
||||
{ id: 'today', label: 'Today' },
|
||||
{ id: 'yesterday', label: 'Yesterday' },
|
||||
{ id: 'last-7-days', label: 'Last 7 days' },
|
||||
{ id: 'last-week', label: 'Last week' },
|
||||
{ id: 'this-month', label: 'This month' },
|
||||
{ id: 'previous-month', label: 'Previous month' },
|
||||
];
|
||||
|
||||
export const SUPPORTED_PERIODS = PERIODS.map((p) => p.id);
|
||||
|
||||
export const periodLabel = (id) => PERIODS.find((p) => p.id === id)?.label || id;
|
||||
|
||||
/* ── Human labels ───────────────────────────────────────────────────────────
|
||||
The vocabulary is written in kebab-case because it is configuration; it is
|
||||
read by people, so every id has a label. One place, so the preview, the
|
||||
rendered section and any future surface all say the same words. */
|
||||
|
||||
/** `flow` → `Flow`. */
|
||||
export const sectionTypeLabel = (id) =>
|
||||
SECTION_TYPES.find((t) => t.id === id)?.label || id;
|
||||
|
||||
/** `position.activity` → `Position activity`. */
|
||||
export const dataSourceLabel = (id) => dataSourceFor(id)?.label || id;
|
||||
|
||||
/** `after-position-summary` → `After position summary`. */
|
||||
export const placementLabel = (id) => {
|
||||
const words = String(id || '').replace(/-/g, ' ').trim();
|
||||
return words ? words[0].toUpperCase() + words.slice(1) : id;
|
||||
};
|
||||
293
src/lib/skills/uiConfig.js
Normal file
293
src/lib/skills/uiConfig.js
Normal file
@@ -0,0 +1,293 @@
|
||||
import {
|
||||
SUPPORTED_DATA_SOURCES, SUPPORTED_PERIODS, SUPPORTED_SECTION_TYPES,
|
||||
SUPPORTED_SKILL_PAGES, canonicalPage, dataSourceFor, isSourceWritable, surfaceFor,
|
||||
} from './surfaces';
|
||||
|
||||
/**
|
||||
* The `ui:` block of a skill definition, checked and normalized.
|
||||
*
|
||||
* A definition declares what it wants; this decides whether the product can
|
||||
* honour it, and turns a loose YAML shape into one predictable record. Two
|
||||
* things follow from doing it here rather than in the renderer:
|
||||
*
|
||||
* - **Nothing unknown reaches a component.** Every page, placement, type,
|
||||
* data source and period is checked against the closed vocabulary in
|
||||
* `surfaces.js`. An unrecognised value is an error with a message naming
|
||||
* it, not a silently dropped key and not a rendered blank.
|
||||
* - **Only listed keys survive.** The normalized section carries exactly the
|
||||
* fields the renderers read. Anything else an author writes is ignored
|
||||
* rather than passed through, so no property can arrive at React that this
|
||||
* module did not put there.
|
||||
*/
|
||||
|
||||
/**
|
||||
* A section as the renderers receive it. Nothing else is carried.
|
||||
*
|
||||
* Used by both consumers of a definition. The page passes a `page`, so the
|
||||
* section is checked against that surface's placements; Owliver passes
|
||||
* `placement: false`, because a chat reply has no placement to sit at — the
|
||||
* rest of the checks, and the record that comes out, are identical. That is
|
||||
* deliberate: it is what makes a flow drawn in the panel the same section as
|
||||
* the flow drawn on the page rather than a parallel shape that resembles it.
|
||||
*
|
||||
* `types` narrows what `type:` may be. The page offers the components it can
|
||||
* mount; Owliver offers those plus the shapes that are only answers.
|
||||
*/
|
||||
export function normalizeSection(raw, {
|
||||
page, errors, seen, fallbackId = '', where: label = null,
|
||||
placement: wantPlacement = true, types = SUPPORTED_SECTION_TYPES,
|
||||
shapeFor = (type) => type,
|
||||
}) {
|
||||
const where = label || `ui.${page}`;
|
||||
/* Where an error points. A page section is addressed by the id it was given;
|
||||
a capability response is already addressed by the capability it answers, so
|
||||
appending a generated section id there would name something the author
|
||||
never wrote. */
|
||||
const at = label ? where : null;
|
||||
|
||||
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) {
|
||||
errors.push(`${where}: each section must be a mapping of options.`);
|
||||
return null;
|
||||
}
|
||||
|
||||
/* An id is how a section is keyed and de-duplicated, not something an author
|
||||
should have to invent for a definition that declares exactly one. Falls
|
||||
back to the title, then to the skill's own id. */
|
||||
const slug = (value) => String(value || '')
|
||||
.toLowerCase().trim().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
|
||||
const id = slug(raw.id) || slug(raw.title) || slug(fallbackId);
|
||||
if (!id) {
|
||||
errors.push(`${where}: a section needs an \`id\`.`);
|
||||
return null;
|
||||
}
|
||||
if (!/^[a-z0-9][a-z0-9-]*$/.test(id)) {
|
||||
errors.push(`${at || `${where}.${id}`}: \`id\` must be lower-case letters, numbers and dashes.`);
|
||||
return null;
|
||||
}
|
||||
if (seen.has(id)) {
|
||||
errors.push(`${where}: two sections share the id \`${id}\`.`);
|
||||
return null;
|
||||
}
|
||||
seen.add(id);
|
||||
|
||||
const type = String(raw.type || '').trim();
|
||||
if (!type) {
|
||||
errors.push(`${at || `${where}.${id}`}: a section needs a \`type\`.`);
|
||||
return null;
|
||||
}
|
||||
if (!types.includes(type)) {
|
||||
errors.push(
|
||||
`Unsupported skill component: ${type}. Supported types: ${types.join(', ')}.`
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
/* `position:` is what the format calls it; `placement:` is accepted because
|
||||
it is the word the rest of the system uses. A section that is an answer
|
||||
rather than a panel has nowhere to be placed, and says so with `null`. */
|
||||
let placement = null;
|
||||
if (wantPlacement) {
|
||||
const surface = surfaceFor(page);
|
||||
const declaredPlacement = String(raw.position || raw.placement || '').trim();
|
||||
placement = declaredPlacement || surface.placements[0];
|
||||
if (!surface.placements.includes(placement)) {
|
||||
errors.push(
|
||||
`Unsupported placement: ${placement} on ${page}. Supported placements: ${surface.placements.join(', ')}.`
|
||||
);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/* `data.source:` and a flat `source:` mean the same thing. The nested form
|
||||
groups options when a section grows; the flat form is what a one-section
|
||||
definition actually reads like, and refusing it would be the format being
|
||||
precious about punctuation. */
|
||||
const source = String(raw.data?.source ?? raw.source ?? '').trim();
|
||||
if (!source) {
|
||||
errors.push(`${at || `${where}.${id}`}: a section needs \`data.source\`.`);
|
||||
return null;
|
||||
}
|
||||
if (!SUPPORTED_DATA_SOURCES.includes(source)) {
|
||||
errors.push(
|
||||
`Unsupported data source: ${source}. Supported sources: ${SUPPORTED_DATA_SOURCES.join(', ')}.`
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
/* A source knows which shapes it can fill. Asking for a timeline of something
|
||||
that has no dates is an authoring mistake worth naming now rather than
|
||||
rendering as an empty panel later. */
|
||||
const definition = dataSourceFor(source);
|
||||
/* `shapeFor` is how a type that is not a drawn component is exempted: a
|
||||
summary is prose, so there is no shape for a source to be incompatible
|
||||
with. Every drawn type maps to itself. */
|
||||
const shape = shapeFor(type);
|
||||
if (shape && definition.shapes && !definition.shapes.includes(shape)) {
|
||||
errors.push(
|
||||
`${at || `${where}.${id}`}: \`${source}\` cannot be shown as \`${type}\`. It supports: ${definition.shapes.join(', ')}.`
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
const periods = Array.isArray(raw.periods) ? raw.periods.map((p) => String(p).trim()) : [];
|
||||
const unknownPeriod = periods.find((p) => !SUPPORTED_PERIODS.includes(p));
|
||||
if (unknownPeriod) {
|
||||
errors.push(
|
||||
`Unsupported period: ${unknownPeriod}. Supported periods: ${SUPPORTED_PERIODS.join(', ')}.`
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
const limit = Number(raw.limit);
|
||||
|
||||
/**
|
||||
* Whether this section offers controls rather than a read-out.
|
||||
*
|
||||
* Two independent things must agree before anything is editable, and this is
|
||||
* the first: the definition asking for it, and the source declaring that it
|
||||
* can be written at all. The second is the page publishing a handler for that
|
||||
* source at render time. A definition asking to edit a reading the product
|
||||
* does not expose for writing is an authoring mistake worth naming here,
|
||||
* rather than a control that silently does nothing.
|
||||
*/
|
||||
const editable = raw.editable === true || raw.editable === 'true';
|
||||
if (editable && !isSourceWritable(source)) {
|
||||
errors.push(
|
||||
`${at || `${where}.${id}`}: \`${source}\` cannot be edited. It is a reading, not a setting.`
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
id,
|
||||
title: String(raw.title || '').trim() || null,
|
||||
description: String(raw.description || '').trim() || null,
|
||||
type,
|
||||
placement,
|
||||
source,
|
||||
/* Declared intent only. A section stays read-only wherever no page offers
|
||||
to accept the write — see `SkillSurface` and `SkillSectionBlock`. */
|
||||
editable,
|
||||
/* Context the page must supply for this source to resolve. */
|
||||
context: definition.context,
|
||||
periods,
|
||||
limit: Number.isFinite(limit) && limit > 0 ? Math.min(50, Math.round(limit)) : null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The keys that mean "this object *is* a section".
|
||||
*
|
||||
* How the two shapes are told apart. A definition may write its UI either way:
|
||||
*
|
||||
* ui: ui:
|
||||
* type: flow positions:
|
||||
* placement: … sections:
|
||||
* source: … - type: flow
|
||||
*
|
||||
* The first is one section applying to every page the skill declares; the
|
||||
* second addresses pages by name and can differ per page. Both are legitimate,
|
||||
* and the difference is structural — an object carrying `type` or `source` is a
|
||||
* section, an object whose keys are page names is a page map. Nothing is
|
||||
* decided by a skill id, and neither shape is privileged.
|
||||
*/
|
||||
const SECTION_KEYS = new Set([
|
||||
'id', 'type', 'title', 'description', 'placement', 'position', 'data', 'source', 'periods',
|
||||
'limit', 'editable',
|
||||
]);
|
||||
|
||||
const looksLikeSection = (value) =>
|
||||
Boolean(value)
|
||||
&& typeof value === 'object'
|
||||
&& !Array.isArray(value)
|
||||
&& Object.keys(value).some((key) => SECTION_KEYS.has(key));
|
||||
|
||||
/** The sections a page entry declares, in either the list or single-section form. */
|
||||
function sectionsOf(config) {
|
||||
if (Array.isArray(config?.sections)) return config.sections;
|
||||
if (Array.isArray(config)) return config;
|
||||
if (looksLikeSection(config)) return [config];
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The whole `ui:` block, normalized per page.
|
||||
*
|
||||
* Returns `{ ui, errors }`. `ui` holds only what validated, so a definition with
|
||||
* one bad section still registers its good ones — and the author still sees why
|
||||
* the other was refused.
|
||||
*/
|
||||
export function normalizeSkillUi(rawUi, { declaredPages = [], skillId = '' } = {}) {
|
||||
const errors = [];
|
||||
const ui = {};
|
||||
|
||||
if (rawUi == null) return { ui, errors };
|
||||
|
||||
if (typeof rawUi !== 'object' || Array.isArray(rawUi)) {
|
||||
return { ui, errors: ['`ui` must be a section, or a mapping of page names to sections.'] };
|
||||
}
|
||||
|
||||
const pages = declaredPages.map(canonicalPage).filter(Boolean);
|
||||
|
||||
/* Shorthand: one section, applied to every page the skill declares. The keys
|
||||
inside it are section options and are never read as page names — which is
|
||||
exactly what this branch exists to prevent. */
|
||||
if (looksLikeSection(rawUi)) {
|
||||
if (!pages.length) {
|
||||
return { ui, errors: ['`ui` is configured but the skill declares no `pages`.'] };
|
||||
}
|
||||
|
||||
for (const page of pages) {
|
||||
const section = normalizeSection(rawUi, {
|
||||
page, errors, seen: new Set(), fallbackId: skillId,
|
||||
});
|
||||
if (section) ui[page] = { sections: [section] };
|
||||
}
|
||||
return { ui, errors };
|
||||
}
|
||||
|
||||
/* Otherwise every key is a page name. */
|
||||
for (const [rawPage, config] of Object.entries(rawUi)) {
|
||||
const page = canonicalPage(rawPage);
|
||||
if (!page) {
|
||||
errors.push(
|
||||
`Unsupported page: ${rawPage}. Supported pages: ${SUPPORTED_SKILL_PAGES.join(', ')}.`
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
/* A page cannot be extended unless the skill also declares it. Otherwise a
|
||||
definition could render onto a surface it never said it applied to. */
|
||||
if (pages.length && !pages.includes(page)) {
|
||||
errors.push(`\`ui.${rawPage}\` is configured but \`${rawPage}\` is not listed under \`pages\`.`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const rawSections = sectionsOf(config);
|
||||
if (!rawSections) {
|
||||
errors.push(`ui.${rawPage}: expected a \`sections\` list, or a single section.`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const seen = new Set();
|
||||
const sections = rawSections
|
||||
.map((section) => normalizeSection(section, {
|
||||
page: rawPage, errors, seen, fallbackId: skillId,
|
||||
}))
|
||||
.filter(Boolean);
|
||||
|
||||
if (sections.length) ui[page] = { sections };
|
||||
}
|
||||
|
||||
return { ui, errors };
|
||||
}
|
||||
|
||||
/** Every section this skill contributes to a page, in declaration order. */
|
||||
export const sectionsForPage = (skill, page) => {
|
||||
const key = canonicalPage(page);
|
||||
return key ? skill?.ui?.[key]?.sections || [] : [];
|
||||
};
|
||||
|
||||
/** How many UI sections a definition registers, across every page. */
|
||||
export const countSections = (skill) =>
|
||||
Object.values(skill?.ui || {}).reduce((n, page) => n + (page.sections?.length || 0), 0);
|
||||
174
src/lib/skills/yaml.js
Normal file
174
src/lib/skills/yaml.js
Normal file
@@ -0,0 +1,174 @@
|
||||
/**
|
||||
* The YAML subset skill frontmatter is allowed to use.
|
||||
*
|
||||
* Skills gained declarative UI configuration, which is nested — `ui:` holds a
|
||||
* page, which holds sections, which hold their own options. The old parser read
|
||||
* flat `key: value` pairs and one level of `- item`, so nesting was impossible
|
||||
* to express.
|
||||
*
|
||||
* This is deliberately a *subset*, not a YAML library:
|
||||
*
|
||||
* - block maps and block sequences, nested to any depth
|
||||
* - scalars: strings, integers, floats, booleans, null
|
||||
* - quoted strings, for values containing `:` or `#`
|
||||
* - `- key: value` — a mapping that starts on the dash
|
||||
* - `#` comments, and blank lines
|
||||
*
|
||||
* Everything else — anchors, aliases, merge keys, multi-document files, flow
|
||||
* mappings, block scalars, tags — is not supported and is not silently
|
||||
* half-read: an unparseable line raises, so a definition either means what it
|
||||
* says or is rejected with a line number.
|
||||
*
|
||||
* It returns plain data and nothing else. There is no code path from this file
|
||||
* to evaluation of any kind: no `eval`, no `Function`, no dynamic import, no
|
||||
* JSON with a reviver. A skill definition is configuration, and this is the
|
||||
* boundary that keeps it configuration.
|
||||
*/
|
||||
|
||||
/** `true` / `false` / `null` / numbers, or the string as written. */
|
||||
function toScalar(raw) {
|
||||
const value = String(raw).trim();
|
||||
|
||||
if (value === '' || value === '~' || value === 'null') return null;
|
||||
if (value === 'true') return true;
|
||||
if (value === 'false') return false;
|
||||
|
||||
/* Quoted: taken literally, which is how a value containing `:` or `#` is
|
||||
written. No escape processing beyond the doubled quote. */
|
||||
const quoted = /^(['"])([\s\S]*)\1$/.exec(value);
|
||||
if (quoted) return quoted[2].replace(new RegExp(quoted[1] + quoted[1], 'g'), quoted[1]);
|
||||
|
||||
if (/^-?\d+$/.test(value)) return Number(value);
|
||||
if (/^-?\d*\.\d+$/.test(value)) return Number(value);
|
||||
|
||||
/* An unquoted trailing comment is a comment. `#` inside a word is not. */
|
||||
return value.replace(/\s+#.*$/, '').trim();
|
||||
}
|
||||
|
||||
/** One line, reduced to what the parser needs to decide. */
|
||||
function readLines(source) {
|
||||
return String(source)
|
||||
.split(/\r?\n/)
|
||||
.map((text, i) => ({ text, line: i + 1 }))
|
||||
.filter(({ text }) => text.trim() !== '' && !/^\s*#/.test(text))
|
||||
.map(({ text, line }) => ({
|
||||
line,
|
||||
indent: text.match(/^\s*/)[0].replace(/\t/g, ' ').length,
|
||||
content: text.trim(),
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses one block at `indent` or deeper, starting at `cursor.i`.
|
||||
*
|
||||
* Returns a map or an array depending on what the first line at this level is,
|
||||
* which is how YAML itself decides. Recursion handles nesting; the cursor is
|
||||
* shared so a child can consume the lines it owns.
|
||||
*/
|
||||
function parseBlock(lines, cursor, indent) {
|
||||
const first = lines[cursor.i];
|
||||
if (!first) return null;
|
||||
|
||||
return first.content.startsWith('- ')
|
||||
|| first.content === '-'
|
||||
? parseSequence(lines, cursor, indent)
|
||||
: parseMapping(lines, cursor, indent);
|
||||
}
|
||||
|
||||
function parseSequence(lines, cursor, indent) {
|
||||
const out = [];
|
||||
|
||||
while (cursor.i < lines.length) {
|
||||
const { content, indent: at, line } = lines[cursor.i];
|
||||
if (at < indent) break;
|
||||
if (at > indent) throw new Error(`Unexpected indentation on line ${line}`);
|
||||
if (!content.startsWith('-')) break;
|
||||
|
||||
const rest = content.replace(/^-\s*/, '');
|
||||
cursor.i += 1;
|
||||
|
||||
if (rest === '') {
|
||||
/* `-` alone: the item is the indented block beneath it. */
|
||||
out.push(cursor.i < lines.length && lines[cursor.i].indent > indent
|
||||
? parseBlock(lines, cursor, lines[cursor.i].indent)
|
||||
: null);
|
||||
continue;
|
||||
}
|
||||
|
||||
/* `- key: value` opens a mapping whose first key sits on the dash. The
|
||||
remaining keys are indented to where that key started. */
|
||||
const pair = /^([A-Za-z0-9_.-]+):\s*(.*)$/.exec(rest);
|
||||
if (pair) {
|
||||
const keyIndent = indent + (content.length - rest.length);
|
||||
const item = {};
|
||||
const [, key, value] = pair;
|
||||
|
||||
item[key] = value === ''
|
||||
&& cursor.i < lines.length
|
||||
&& lines[cursor.i].indent > indent
|
||||
? parseBlock(lines, cursor, lines[cursor.i].indent)
|
||||
: toScalar(value);
|
||||
|
||||
while (cursor.i < lines.length && lines[cursor.i].indent === keyIndent
|
||||
&& !lines[cursor.i].content.startsWith('- ')) {
|
||||
Object.assign(item, parseMapping(lines, cursor, keyIndent));
|
||||
}
|
||||
out.push(item);
|
||||
continue;
|
||||
}
|
||||
|
||||
out.push(toScalar(rest));
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
function parseMapping(lines, cursor, indent) {
|
||||
const out = {};
|
||||
|
||||
while (cursor.i < lines.length) {
|
||||
const { content, indent: at, line } = lines[cursor.i];
|
||||
if (at < indent) break;
|
||||
if (at > indent) throw new Error(`Unexpected indentation on line ${line}`);
|
||||
if (content.startsWith('- ')) break;
|
||||
|
||||
const pair = /^([A-Za-z0-9_.-]+):\s*(.*)$/.exec(content);
|
||||
if (!pair) throw new Error(`Line ${line} is not \`key: value\`: ${content}`);
|
||||
|
||||
const [, key, value] = pair;
|
||||
cursor.i += 1;
|
||||
|
||||
if (value !== '') {
|
||||
out[key] = toScalar(value);
|
||||
continue;
|
||||
}
|
||||
|
||||
/* An empty value means the value is the block below — or nothing. */
|
||||
const next = lines[cursor.i];
|
||||
out[key] = next && next.indent > indent
|
||||
? parseBlock(lines, cursor, next.indent)
|
||||
: null;
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* A YAML document, as plain data.
|
||||
*
|
||||
* Throws on anything it cannot read rather than guessing, so a malformed
|
||||
* definition is reported to its author instead of being registered in a shape
|
||||
* nobody intended.
|
||||
*/
|
||||
export function parseYaml(source) {
|
||||
const lines = readLines(source);
|
||||
if (!lines.length) return {};
|
||||
|
||||
const cursor = { i: 0 };
|
||||
const value = parseBlock(lines, cursor, lines[0].indent);
|
||||
|
||||
if (cursor.i < lines.length) {
|
||||
throw new Error(`Unexpected indentation on line ${lines[cursor.i].line}`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
@@ -1,11 +1,13 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useLocation, useNavigate } from 'react-router-dom';
|
||||
import { useLocation, useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import { Sparkles, ChevronDown, ChevronUp, Sliders, Loader2, Star } from 'lucide-react';
|
||||
import { useCreateJobPosting, useUpdateJobPosting, useGenerateJobDescription, useRoleCategories, useCreateRoleCategory } from '@/lib/krowHooks';
|
||||
import { useCreateJobPosting, useUpdateJobPosting, useGenerateJobDescription, useJobPosting, useRoleCategories, useCreateRoleCategory } from '@/lib/krowHooks';
|
||||
import { toast } from 'react-hot-toast';
|
||||
import { ROLE_CATEGORIES } from '@/lib/roleCategories';
|
||||
import { CERT_OPTIONS, CRITERIA_LABELS, ENGLISH_LEVELS, defaultPosition } from '@/lib/positionModel';
|
||||
import { SkillRequirementsField } from '@/components/krow/SkillRequirementsField';
|
||||
import { SkillSurface } from '@/components/skills/SkillSurface';
|
||||
import { usePublishPageActions, usePublishPageContext } from '@/components/ai-assistant';
|
||||
|
||||
/**
|
||||
* Create a Position.
|
||||
@@ -38,9 +40,32 @@ export default function CreatePosition({ prefill: prefillProp, embedded = false,
|
||||
const [showWeights, setShowWeights] = useState(false);
|
||||
|
||||
const allCategories = [...new Set([...ROLE_CATEGORIES, ...customCategories.map(c => c.name)])];
|
||||
const [draftId, setDraftId] = useState(null);
|
||||
|
||||
/**
|
||||
* Continuing a draft.
|
||||
*
|
||||
* `?draft=<id>` is how the Positions list hands an unfinished position back to
|
||||
* the form that was writing it. It is the same route, the same component and
|
||||
* the same state as writing a new one — the only difference is that the form
|
||||
* starts from the saved record and saves back to it, so nothing the author
|
||||
* already entered is asked for twice and no second editor exists.
|
||||
*/
|
||||
const [searchParams] = useSearchParams();
|
||||
const continuingId = searchParams.get('draft');
|
||||
const { data: continuing } = useJobPosting(continuingId);
|
||||
|
||||
const [draftId, setDraftId] = useState(continuingId || null);
|
||||
const [loadedId, setLoadedId] = useState(null);
|
||||
const [aiResult, setAiResult] = useState(null);
|
||||
|
||||
/* Seeded once per record, not on every render of it: after this the form owns
|
||||
the values, and re-seeding would discard whatever has been typed since. */
|
||||
if (continuing && continuing.id !== loadedId) {
|
||||
setLoadedId(continuing.id);
|
||||
setDraftId(continuing.id);
|
||||
setForm({ ...defaultPosition(), ...continuing });
|
||||
}
|
||||
|
||||
const update = (field, value) => setForm({ ...form, [field]: value });
|
||||
|
||||
const toggleCert = (cert) => {
|
||||
@@ -55,6 +80,46 @@ export default function CreatePosition({ prefill: prefillProp, embedded = false,
|
||||
|
||||
const totalWeight = Object.values(form.vetting_criteria).reduce((a, b) => a + b, 0);
|
||||
|
||||
/* What a skill on this surface reads: the position being specified, as it
|
||||
stands right now. The draft carries the same fields a saved position does,
|
||||
so one declared section reports the form and the record it becomes.
|
||||
Memoized on the form so the sections re-resolve on a change rather than on
|
||||
every render. */
|
||||
const skillContext = React.useMemo(() => ({ position: form }), [form]);
|
||||
|
||||
/* The same draft, published to the panel beside the form. A skill declaring
|
||||
`create-position` is available to both consumers, and both read this one
|
||||
record — so "summarize the vetting weights" answers about the weights on
|
||||
screen, updating as they are edited. */
|
||||
usePublishPageContext(skillContext);
|
||||
|
||||
/**
|
||||
* The write that goes with that read.
|
||||
*
|
||||
* Owliver can already see the weights on this form; this is what lets it set
|
||||
* them. The handler is the form's own setter — there is no second copy of the
|
||||
* weights anywhere, so a change made in the panel and a change made on the
|
||||
* slider below are the same change, and the section further down this page
|
||||
* re-renders from the same state either way.
|
||||
*
|
||||
* Keyed by the data source rather than by a skill or an invented action name:
|
||||
* any definition reading `position.vetting` can offer to set it, and no
|
||||
* definition can write anywhere it cannot read. A functional update keeps the
|
||||
* handler stable, so the published map is memoized once rather than rebuilt as
|
||||
* the form is typed into.
|
||||
*/
|
||||
const applyVettingWeights = React.useCallback((next) => {
|
||||
setForm((previous) => ({
|
||||
...previous,
|
||||
vetting_criteria: { ...previous.vetting_criteria, ...next },
|
||||
}));
|
||||
toast.success('Vetting weights updated');
|
||||
}, []);
|
||||
|
||||
usePublishPageActions(
|
||||
React.useMemo(() => ({ 'position.vetting': applyVettingWeights }), [applyVettingWeights])
|
||||
);
|
||||
|
||||
const handleGenerateDescription = async () => {
|
||||
if (!form.title) {
|
||||
toast.error('Please enter a job title first');
|
||||
@@ -107,7 +172,11 @@ export default function CreatePosition({ prefill: prefillProp, embedded = false,
|
||||
if (onDone) {
|
||||
onDone(createdId);
|
||||
} else if (status === 'active' && createdId) {
|
||||
navigate(`/admin/positions/${createdId}`);
|
||||
/* Back to the board, with the published role named in the URL. The list
|
||||
is where the position now lives, and it is the surface Owliver sits
|
||||
beside — so the role that was just published is the one the panel is
|
||||
reasoning about, and offering candidates for, on arrival. */
|
||||
navigate(`/admin/positions?published=${encodeURIComponent(createdId)}`);
|
||||
} else {
|
||||
navigate('/admin/positions');
|
||||
}
|
||||
@@ -141,6 +210,12 @@ export default function CreatePosition({ prefill: prefillProp, embedded = false,
|
||||
<h2 className="text-[16px] font-semibold text-[#111827]">Create Job Posting</h2>
|
||||
</div>
|
||||
|
||||
{/* Extension points. A definition naming `create-position` and one of
|
||||
these placements renders here, against the draft in the form. The
|
||||
form knows no skill; the definition names the slot. Nothing below is
|
||||
moved or replaced — the sections sit between what is already here. */}
|
||||
<SkillSurface page="create-position" placement="after-header" context={skillContext} />
|
||||
|
||||
{/* Row 1 */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
@@ -312,6 +387,12 @@ export default function CreatePosition({ prefill: prefillProp, embedded = false,
|
||||
</div>
|
||||
)}
|
||||
|
||||
<SkillSurface
|
||||
page="create-position"
|
||||
placement="after-job-description"
|
||||
context={skillContext}
|
||||
/>
|
||||
|
||||
{/* AI Vetting Weights */}
|
||||
<div className="border-t border-[#E5E7EB] pt-4">
|
||||
<button onClick={() => setShowWeights(!showWeights)} className="w-full flex items-center justify-between">
|
||||
@@ -340,6 +421,18 @@ export default function CreatePosition({ prefill: prefillProp, embedded = false,
|
||||
)}
|
||||
</div>
|
||||
|
||||
<SkillSurface
|
||||
page="create-position"
|
||||
placement="after-vetting-weights"
|
||||
context={skillContext}
|
||||
/>
|
||||
|
||||
<SkillSurface
|
||||
page="create-position"
|
||||
placement="before-footer"
|
||||
context={skillContext}
|
||||
/>
|
||||
|
||||
{/* Bottom Actions */}
|
||||
<div className="grid grid-cols-2 gap-3 pt-2">
|
||||
<button onClick={() => handleSave('draft')} disabled={createJob.isPending || updateJob.isPending} className="border border-[#E5E7EB] hover:bg-[#F9FAFB] text-[#374151] font-medium py-3 rounded-xl text-[14px] transition-colors disabled:opacity-50">
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
PositionCustomRequirements, PositionOverview, PositionRequirements,
|
||||
} from '@/components/krow/PositionDetails';
|
||||
import { workforceStatusFor } from '@/lib/workforce';
|
||||
import { SkillSurface } from '@/components/skills/SkillSurface';
|
||||
import { MatchedCandidates } from '@/components/krow/MatchedCandidates';
|
||||
import { base44 } from '@/api/base44Client';
|
||||
import CandidateCard from '@/components/krow/CandidateCard';
|
||||
@@ -284,6 +285,8 @@ export default function PositionDetail() {
|
||||
‹ Back to Positions
|
||||
</button>
|
||||
|
||||
<SkillSurface page="positions" placement="after-header" context={{ position: job }} />
|
||||
|
||||
{/* Header Card */}
|
||||
<div className="bg-white border border-[#E5E7EB] rounded-2xl p-6">
|
||||
<div className="flex items-start justify-between gap-4 flex-wrap">
|
||||
@@ -410,6 +413,13 @@ export default function PositionDetail() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Extension points. A skill definition naming `positions` and one of
|
||||
these placements renders here; with none registered they render
|
||||
nothing at all. No page code changes when a skill is added. Every
|
||||
placement the vocabulary offers for this surface is mounted, so a
|
||||
valid definition can never point at a slot that does not exist. */}
|
||||
<SkillSurface page="positions" placement="after-position-summary" context={{ position: job }} />
|
||||
|
||||
<WorkforceStatus workforce={workforce} job={job} />
|
||||
|
||||
{/* Which people can actually do this work, scored against the position's
|
||||
@@ -459,6 +469,8 @@ export default function PositionDetail() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<SkillSurface page="positions" placement="before-candidates" context={{ position: job }} />
|
||||
|
||||
{/* Applicants */}
|
||||
<div className="bg-white border border-[#E5E7EB] rounded-2xl p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
@@ -497,6 +509,9 @@ export default function PositionDetail() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
<SkillSurface page="positions" placement="after-candidates" context={{ position: job }} />
|
||||
<SkillSurface page="positions" placement="before-footer" context={{ position: job }} />
|
||||
|
||||
{/* Modals */}
|
||||
{interviewApp && <AIInterviewModal open={true} onClose={() => setInterviewApp(null)} application={interviewApp} job={job} />}
|
||||
{scheduleApp && <ScheduleInterviewModal open={true} onClose={() => setScheduleApp(null)} application={scheduleApp} />}
|
||||
|
||||
@@ -43,20 +43,20 @@ export default function Positions() {
|
||||
{/* Action Bar */}
|
||||
<div className="flex flex-col sm:flex-row items-stretch sm:items-center gap-3">
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-[#9CA3AF]" />
|
||||
<Search className="absolute left-3.5 top-1/2 -translate-y-1/2 w-4 h-4 text-[#9CA3AF]" />
|
||||
<input
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder="Search positions..."
|
||||
className="w-full h-10 pl-10 pr-4 rounded-lg border border-[#E5E7EB] bg-white text-[13px] focus:border-[#2563EB] focus:outline-none"
|
||||
className="w-full h-9 pl-10 pr-4 rounded-full border border-[#E5E7EB] bg-white text-[13px] text-[#111827] shadow-2xs hover:border-[#D1D5DB] focus:border-[#2563EB] focus:ring-2 focus:ring-[#2563EB]/20 focus:outline-none transition-all"
|
||||
/>
|
||||
</div>
|
||||
<select
|
||||
value={statusFilter}
|
||||
onChange={(e) => setStatusFilter(e.target.value)}
|
||||
className="h-10 px-4 rounded-lg border border-[#E5E7EB] bg-white text-[13px] focus:border-[#2563EB] focus:outline-none"
|
||||
className="h-9 px-3.5 rounded-full border border-[#E5E7EB] bg-white text-[13px] text-[#1F2937] shadow-2xs hover:border-[#D1D5DB] focus:border-[#2563EB] focus:outline-none transition-all cursor-pointer"
|
||||
>
|
||||
<option value="all">All Statuses</option>
|
||||
<option value="all">All</option>
|
||||
<option value="active">Hiring</option>
|
||||
<option value="draft">Draft</option>
|
||||
<option value="paused">Paused</option>
|
||||
@@ -65,7 +65,7 @@ export default function Positions() {
|
||||
{isAdmin && <CertificationManager />}
|
||||
<button
|
||||
onClick={() => navigate('/positions/new')}
|
||||
className="inline-flex items-center justify-center gap-1.5 bg-[#111827] hover:bg-[#1F2937] text-white text-[13px] font-medium px-4 h-10 rounded-lg transition-colors whitespace-nowrap"
|
||||
className="inline-flex items-center justify-center gap-1.5 bg-[#111827] hover:bg-[#1F2937] text-white text-[13px] font-medium px-4 h-9 rounded-full shadow-xs transition-colors whitespace-nowrap"
|
||||
>
|
||||
<Plus className="w-4 h-4" /> New Position
|
||||
</button>
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
import { SectionTitle } from '@/pages/admin/_shell';
|
||||
import { FilterSelect } from '@/pages/admin/Positions';
|
||||
import { completedCourseIds, skillStates } from '@/lib/skillGraph';
|
||||
import { SkillSurface } from '@/components/skills/SkillSurface';
|
||||
|
||||
/**
|
||||
* KROW Forge — workforce skill verification, from the managing side.
|
||||
@@ -88,7 +89,7 @@ export default function University() {
|
||||
const statuses = useMemo(() => {
|
||||
const found = new Set(courses.map(statusOf));
|
||||
return [
|
||||
{ value: 'all', label: 'All statuses' },
|
||||
{ value: 'all', label: 'All' },
|
||||
...STATUS_ORDER.filter((s) => found.has(s)).map((s) => ({ value: s, label: s })),
|
||||
];
|
||||
}, [courses]);
|
||||
@@ -204,6 +205,8 @@ export default function University() {
|
||||
<div className="space-y-7 pb-12">
|
||||
<ForgeHeader onCreate={openCreate} onBrowse={scrollToLibrary} />
|
||||
|
||||
<SkillSurface page="krow-forge" placement="after-header" />
|
||||
|
||||
<ForgeProgress figures={figures} loading={isLoading} />
|
||||
|
||||
{/* Skill progression — the primary experience. The library below is the
|
||||
@@ -302,6 +305,8 @@ export default function University() {
|
||||
onCreate={(data) => createCourse.mutateAsync(data)}
|
||||
onUpdate={(id, data) => updateCourse.mutateAsync({ id, data })}
|
||||
/>
|
||||
|
||||
<SkillSurface page="krow-forge" placement="before-footer" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
import { useUserActivity } from '@/lib/krowHooks';
|
||||
import { AdminPage, SectionTitle, Toolbar } from '@/pages/admin/_shell';
|
||||
import { FilterSelect } from '@/pages/admin/Positions';
|
||||
import { SkillSurface } from '@/components/skills/SkillSurface';
|
||||
|
||||
/**
|
||||
* Admin Activity — an audit centre.
|
||||
@@ -144,6 +145,7 @@ export default function AdminActivity() {
|
||||
title="Activity"
|
||||
subtitle="Monitor workforce operations, user actions and system events."
|
||||
>
|
||||
<SkillSurface page="activity" placement="after-header" />
|
||||
<MetricStrip
|
||||
columns={4}
|
||||
loading={isLoading}
|
||||
@@ -293,6 +295,7 @@ export default function AdminActivity() {
|
||||
]}
|
||||
/>
|
||||
</section>
|
||||
<SkillSurface page="activity" placement="before-footer" />
|
||||
</AdminPage>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,364 @@
|
||||
import React from 'react';
|
||||
import AdminHiredHistory from './HiredHistory';
|
||||
import React, { useMemo } from 'react';
|
||||
import {
|
||||
Activity, Award, Clock, Gauge, Lightbulb, Target, TrendingUp, TriangleAlert,
|
||||
} from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Badge, MetricStrip, Surface } from '@/components/ds';
|
||||
import { useApplications, useJobPostings, useStaff } from '@/lib/krowHooks';
|
||||
import { DepartmentPerformance } from '@/components/charts/DepartmentPerformance';
|
||||
import { HiringFlow } from '@/components/charts/HiringFlow';
|
||||
import { HiringTrendChart } from '@/components/charts/HiringTrendChart';
|
||||
import { useSize } from '@/hooks/use-size';
|
||||
import { AdminPage, SectionTitle } from '@/pages/admin/_shell';
|
||||
import { SkillSurface } from '@/components/skills/SkillSurface';
|
||||
import {
|
||||
buildEfficiency, buildFunnel, buildInsights, buildTrend, byDepartment, byPosition,
|
||||
hiresWithFill, summarise,
|
||||
} from '@/lib/hiringRecords';
|
||||
|
||||
/**
|
||||
* Admin Analytics — how hiring is performing.
|
||||
*
|
||||
* This page and Hired History used to be the same component rendered twice with
|
||||
* a different title, which is why they read as duplicates: identical sections,
|
||||
* identical tabs, identical figures. They are not the same question.
|
||||
*
|
||||
* **Analytics asks "how is our hiring performing?"** — it is metrics, trends,
|
||||
* comparison and what to do about them. Every section here is an aggregate: a
|
||||
* rate, a distribution, a shape over time. No individual hire is named anywhere
|
||||
* on this page, because a name is a record and records are Hired History's job.
|
||||
*
|
||||
* It is also structurally different, not just differently coloured. Analytics is
|
||||
* one scrolling dashboard — the seven readings are meant to be taken together,
|
||||
* and a comparison you have to remember across a tab switch is not a comparison.
|
||||
* Hired History is a filtered record you search, so it keeps its controls and its
|
||||
* table. Same design system, same blue, same cards; different jobs.
|
||||
*
|
||||
* Both read `lib/hiringRecords.js`, so a total here and the same total there
|
||||
* cannot disagree.
|
||||
*/
|
||||
|
||||
const INSIGHT_TONE = {
|
||||
success: { ring: 'border-emerald-500/30 bg-emerald-50/40 dark:bg-emerald-950/20', dot: 'text-emerald-600 dark:text-emerald-400', Icon: TrendingUp },
|
||||
warning: { ring: 'border-amber-500/30 bg-amber-50/40 dark:bg-amber-950/20', dot: 'text-amber-600 dark:text-amber-400', Icon: TriangleAlert },
|
||||
risk: { ring: 'border-red-500/30 bg-red-50/40 dark:bg-red-950/20', dot: 'text-red-600 dark:text-red-400', Icon: TriangleAlert },
|
||||
info: { ring: 'border-border bg-surface', dot: 'text-krow-blue', Icon: Lightbulb },
|
||||
};
|
||||
|
||||
/** One finding, with the evidence underneath it. */
|
||||
function Insight({ item }) {
|
||||
const tone = INSIGHT_TONE[item.tone] || INSIGHT_TONE.info;
|
||||
const { Icon } = tone;
|
||||
|
||||
return (
|
||||
<li className={cn('flex items-start gap-2.5 rounded-xl border p-3.5', tone.ring)}>
|
||||
<Icon className={cn('mt-0.5 h-4 w-4 shrink-0', tone.dot)} aria-hidden="true" />
|
||||
<div className="min-w-0">
|
||||
<p className="font-heading text-body-sm font-semibold text-ink-1">{item.title}</p>
|
||||
<p className="mt-0.5 text-caption leading-relaxed text-ink-3">{item.body}</p>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The funnel, drawn once its container can be measured.
|
||||
*
|
||||
* `HiringFlow` renders an MUI bar chart with no explicit width, which means the
|
||||
* chart measures its parent on mount. On this page that first measure lands
|
||||
* before the layout has resolved a width — the shell's `main` is a `flex-1`
|
||||
* column beside the Owliver panel — and the chart warns that it has nothing to
|
||||
* size itself against. Gating on the measured width means the chart mounts once,
|
||||
* already knowing how wide it is, instead of mounting into nothing and
|
||||
* recovering.
|
||||
*
|
||||
* The reserved height keeps the section from collapsing and reflowing the page
|
||||
* on the frame between measure and draw.
|
||||
*/
|
||||
function MeasuredFunnel({ funnel }) {
|
||||
const ref = React.useRef(null);
|
||||
const size = useSize(ref);
|
||||
|
||||
return (
|
||||
<div ref={ref} className="w-full">
|
||||
{size?.width ? (
|
||||
<HiringFlow
|
||||
stages={funnel.stages}
|
||||
transitions={funnel.transitions}
|
||||
weakestKey={funnel.weakestKey}
|
||||
/>
|
||||
) : (
|
||||
<div className="h-[280px] rounded-xl border border-border bg-surface" aria-hidden="true" />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** A ranked comparison row — used for both fastest and slowest to fill. */
|
||||
function VelocityList({ items, median, tone }) {
|
||||
if (!items.length) {
|
||||
return (
|
||||
<p className="rounded-lg border border-dashed border-border px-3 py-3 text-body-sm text-ink-3">
|
||||
No role has enough dated hires to measure velocity yet.
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ul className="space-y-2">
|
||||
{items.map((role) => {
|
||||
const delta = median ? role.avgDays - median : 0;
|
||||
return (
|
||||
<li key={role.role} className="flex items-center justify-between gap-3 rounded-xl bg-surface-subtle px-3 py-2.5">
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-body-sm font-medium text-ink-1">{role.role}</p>
|
||||
<p className="text-[10px] text-ink-4">
|
||||
{role.count} hire{role.count === 1 ? '' : 's'}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<span className="font-heading text-body-sm font-bold tabular-nums text-ink-1">
|
||||
{role.avgDays}d
|
||||
</span>
|
||||
{median > 0 && delta !== 0 && (
|
||||
<Badge variant={tone === 'fast' ? 'success' : 'warning'} size="sm" className="tabular-nums">
|
||||
{delta > 0 ? '+' : ''}{delta}d
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
|
||||
export default function AdminAnalytics() {
|
||||
return <AdminHiredHistory title="Analytics" />;
|
||||
const { data: staff = [], isLoading } = useStaff();
|
||||
const { data: applications = [] } = useApplications();
|
||||
const { data: postings = [] } = useJobPostings();
|
||||
|
||||
const hires = useMemo(
|
||||
() => hiresWithFill({ staff, applications, postings }),
|
||||
[staff, applications, postings]
|
||||
);
|
||||
|
||||
const summary = useMemo(() => summarise(hires), [hires]);
|
||||
const funnel = useMemo(() => buildFunnel(applications), [applications]);
|
||||
const trend = useMemo(() => buildTrend(hires), [hires]);
|
||||
const departments = useMemo(() => byDepartment(hires), [hires]);
|
||||
const positions = useMemo(() => byPosition(hires), [hires]);
|
||||
const efficiency = useMemo(() => buildEfficiency(hires), [hires]);
|
||||
const insights = useMemo(
|
||||
() => buildInsights({ hires, departments, positions, funnel, efficiency }),
|
||||
[hires, departments, positions, funnel, efficiency]
|
||||
);
|
||||
|
||||
return (
|
||||
<AdminPage
|
||||
title="Analytics"
|
||||
subtitle="How hiring is performing — rates, trends, comparison and what to act on."
|
||||
>
|
||||
<SkillSurface page="analytics" placement="after-header" />
|
||||
|
||||
{/* 1. Hiring performance — the four figures the rest of the page explains. */}
|
||||
<section aria-labelledby="performance" className="space-y-3">
|
||||
<SectionTitle id="performance" title="Hiring performance" meta="Across the workspace" />
|
||||
<MetricStrip
|
||||
columns={4}
|
||||
loading={isLoading}
|
||||
items={[
|
||||
{ label: 'Total hires', value: summary.total, icon: Award, tone: 'brand' },
|
||||
{
|
||||
label: 'Avg time-to-hire',
|
||||
value: summary.speed ? `${summary.speed}d` : '—',
|
||||
icon: Clock,
|
||||
sub: efficiency.median ? `${efficiency.median}d median` : undefined,
|
||||
},
|
||||
{
|
||||
label: 'Quality of hire',
|
||||
value: summary.quality || '—',
|
||||
icon: TrendingUp,
|
||||
tone: summary.quality >= 80 ? 'success' : 'default',
|
||||
sub: 'avg AI score',
|
||||
},
|
||||
{
|
||||
label: 'Conversion rate',
|
||||
value: `${funnel.conversion}%`,
|
||||
icon: Target,
|
||||
sub: `${funnel.stages[4].count} of ${funnel.stages[0].count} applicants`,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</section>
|
||||
|
||||
{/* 2. The funnel — where candidates are lost, which is the finding this
|
||||
page exists to surface. */}
|
||||
<section aria-labelledby="funnel" className="space-y-3">
|
||||
<SectionTitle
|
||||
id="funnel"
|
||||
title="Hiring funnel"
|
||||
meta="Applied → Screened → Shortlisted → Interview → Hired"
|
||||
/>
|
||||
{funnel.stages[0].count ? (
|
||||
<MeasuredFunnel funnel={funnel} />
|
||||
) : (
|
||||
<Surface variant="solid" radius="lg" padding="lg" elevation="xs" className="border border-border">
|
||||
<p className="text-body-sm leading-relaxed text-ink-3">
|
||||
No applications on file yet. The funnel appears once the first candidate applies.
|
||||
</p>
|
||||
</Surface>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* 3. Trend over time. */}
|
||||
<section aria-labelledby="trend" className="space-y-3">
|
||||
<SectionTitle id="trend" title="Hiring trend" meta="Cumulative hires by month" />
|
||||
<HiringTrendChart
|
||||
points={trend}
|
||||
emptyState={(
|
||||
<div className="px-4 py-8 text-center">
|
||||
<p className="font-heading text-body font-semibold text-ink-1">
|
||||
Not enough history for a trend
|
||||
</p>
|
||||
<p className="mx-auto mt-1 max-w-md text-body-sm leading-relaxed text-ink-3">
|
||||
{hires.length
|
||||
? `All ${hires.length} hire${hires.length === 1 ? '' : 's'} closed in ${trend[0]?.label || 'a single month'}. A month-on-month line appears once hiring spans a second month.`
|
||||
: 'No hires recorded yet. Hiring volume over time appears here once the first role closes.'}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
</section>
|
||||
|
||||
{/* 4. Department comparison. */}
|
||||
<section aria-labelledby="departments" className="space-y-3">
|
||||
<SectionTitle
|
||||
id="departments"
|
||||
title="Department performance"
|
||||
meta={`${departments.length} department${departments.length === 1 ? '' : 's'} compared`}
|
||||
/>
|
||||
<DepartmentPerformance items={departments} />
|
||||
</section>
|
||||
|
||||
{/* 5. Position comparison — the same question one level down. */}
|
||||
<section aria-labelledby="positions" className="space-y-3">
|
||||
<SectionTitle
|
||||
id="positions"
|
||||
title="Position performance"
|
||||
meta={`${positions.length} role${positions.length === 1 ? '' : 's'} filled`}
|
||||
/>
|
||||
<Surface variant="solid" radius="lg" padding="none" elevation="xs" className="overflow-hidden border border-border">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full border-collapse text-body-sm">
|
||||
<caption className="sr-only">Hires, quality, speed and review outcome by role</caption>
|
||||
<thead>
|
||||
<tr className="border-b border-border bg-surface-subtle">
|
||||
{['Role', 'Hires', 'Avg score', 'Avg days', 'Reviewed', 'Rating'].map((h, i) => (
|
||||
<th
|
||||
key={h}
|
||||
scope="col"
|
||||
className={cn(
|
||||
'whitespace-nowrap px-3.5 py-2.5 text-[10px] font-bold uppercase tracking-wider text-ink-4',
|
||||
i ? 'text-right' : 'text-left'
|
||||
)}
|
||||
>
|
||||
{h}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border">
|
||||
{positions.map((r) => (
|
||||
<tr key={r.role} className="border-b border-border/60 last:border-0 transition-colors hover:bg-surface-subtle/80">
|
||||
<td className="px-3.5 py-2.5 font-medium text-ink-1">{r.role}</td>
|
||||
<td className="px-3.5 py-2.5 text-right font-semibold tabular-nums text-ink-2">{r.count}</td>
|
||||
<td className="px-3.5 py-2.5 text-right font-bold tabular-nums text-blue-600 dark:text-blue-400">{r.avgScore || '—'}</td>
|
||||
<td className="px-3.5 py-2.5 text-right tabular-nums text-ink-2">{r.avgDays ? `${r.avgDays}d` : '—'}</td>
|
||||
<td className="px-3.5 py-2.5 text-right tabular-nums text-ink-3">{r.rated}/{r.count}</td>
|
||||
<td className="px-3.5 py-2.5 text-right">
|
||||
{r.avgRating != null
|
||||
? <Badge variant="success" size="sm" className="font-bold">{r.avgRating}/5</Badge>
|
||||
: <Badge variant="warning" size="sm" className="font-bold">Pending</Badge>}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Surface>
|
||||
</section>
|
||||
|
||||
{/* 6. Efficiency — velocity against this workspace's own median, so the
|
||||
comparison is one an operator can check. */}
|
||||
<section aria-labelledby="efficiency" className="space-y-3">
|
||||
<SectionTitle
|
||||
id="efficiency"
|
||||
title="Hiring efficiency"
|
||||
meta={efficiency.median ? `${efficiency.median}d median time-to-hire` : 'Not enough dated hires'}
|
||||
/>
|
||||
<div className="grid grid-cols-1 gap-4 lg:grid-cols-3">
|
||||
<Surface variant="solid" radius="lg" padding="lg" elevation="xs" className="border border-border">
|
||||
<div className="flex items-center gap-2 text-krow-blue">
|
||||
<Gauge className="h-4 w-4" aria-hidden="true" />
|
||||
<h3 className="font-heading text-body-sm font-bold text-ink-1">Velocity</h3>
|
||||
</div>
|
||||
<p className="mt-2 font-heading text-title font-bold tabular-nums text-ink-1">
|
||||
{efficiency.median ? `${efficiency.median}d` : '—'}
|
||||
<span className="ml-1.5 text-caption font-normal text-ink-3">median</span>
|
||||
</p>
|
||||
<p className="mt-1 text-caption leading-relaxed text-ink-3">
|
||||
{efficiency.within48h
|
||||
? `${efficiency.within48h}% of roles close within 48 hours.`
|
||||
: 'Velocity appears once hires carry an application date.'}
|
||||
</p>
|
||||
</Surface>
|
||||
|
||||
<Surface variant="solid" radius="lg" padding="lg" elevation="xs" className="border border-border">
|
||||
<div className="flex items-center gap-2 text-emerald-600 dark:text-emerald-400">
|
||||
<Activity className="h-4 w-4" aria-hidden="true" />
|
||||
<h3 className="font-heading text-body-sm font-bold text-ink-1">Fastest to fill</h3>
|
||||
</div>
|
||||
<div className="mt-3">
|
||||
<VelocityList items={efficiency.fastest} median={efficiency.median} tone="fast" />
|
||||
</div>
|
||||
</Surface>
|
||||
|
||||
<Surface variant="solid" radius="lg" padding="lg" elevation="xs" className="border border-border">
|
||||
<div className="flex items-center gap-2 text-amber-600 dark:text-amber-400">
|
||||
<Clock className="h-4 w-4" aria-hidden="true" />
|
||||
<h3 className="font-heading text-body-sm font-bold text-ink-1">Slowest to fill</h3>
|
||||
</div>
|
||||
<div className="mt-3">
|
||||
<VelocityList items={efficiency.slowest} median={efficiency.median} tone="slow" />
|
||||
</div>
|
||||
</Surface>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* 7. What the numbers mean. Only findings the data actually supports —
|
||||
an insight list that is always the same length is decoration. */}
|
||||
<section aria-labelledby="insights" className="space-y-3">
|
||||
<SectionTitle
|
||||
id="insights"
|
||||
title="AI hiring insights"
|
||||
meta={insights.length ? `${insights.length} finding${insights.length === 1 ? '' : 's'}` : undefined}
|
||||
/>
|
||||
{insights.length ? (
|
||||
<ul className="grid grid-cols-1 gap-3 lg:grid-cols-2">
|
||||
{insights.map((item) => <Insight key={item.title} item={item} />)}
|
||||
</ul>
|
||||
) : (
|
||||
<Surface variant="solid" radius="lg" padding="lg" elevation="xs" className="border border-border">
|
||||
<p className="text-body-sm leading-relaxed text-ink-3">
|
||||
There is not enough hiring on file to draw a finding from yet. Insights appear as
|
||||
applications, hires and reviews accumulate.
|
||||
</p>
|
||||
</Surface>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<SkillSurface page="analytics" placement="before-footer" />
|
||||
</AdminPage>
|
||||
);
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -6,6 +6,7 @@ import { toast } from 'react-hot-toast';
|
||||
import { AdminPage, Toolbar } from '@/pages/admin/_shell';
|
||||
import { FilterSelect } from '@/pages/admin/Positions';
|
||||
import CandidateCard from '@/components/krow/CandidateCard';
|
||||
import { SkillSurface } from '@/components/skills/SkillSurface';
|
||||
import AIInterviewModal from '@/components/krow/AIInterviewModal';
|
||||
import ScheduleInterviewModal from '@/components/krow/ScheduleInterviewModal';
|
||||
import MessageCandidateModal from '@/components/krow/MessageCandidateModal';
|
||||
@@ -107,6 +108,8 @@ export default function AdminCandidates() {
|
||||
title="Candidates"
|
||||
subtitle="Review, compare and manage the candidate pipeline."
|
||||
>
|
||||
<SkillSurface page="candidates" placement="after-header" />
|
||||
|
||||
<Toolbar
|
||||
search={<SearchInput value={search} onChange={setSearch} placeholder="Search candidates..." size="sm" />}
|
||||
filters={
|
||||
@@ -165,6 +168,8 @@ export default function AdminCandidates() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<SkillSurface page="candidates" placement="before-footer" />
|
||||
|
||||
{/* Modals */}
|
||||
{interviewApp && (
|
||||
<AIInterviewModal open={true} onClose={() => setInterviewApp(null)} application={interviewApp} job={postings.find(p => p.id === interviewApp.job_posting_id)} />
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
import { useApplications, useInterviews, useJobPostings, useStaff, useWorkerProfiles } from '@/lib/krowHooks';
|
||||
import { buildFacts } from '@/components/ai-assistant/insights';
|
||||
import { AdminPage, SectionTitle } from '@/pages/admin/_shell';
|
||||
import { SkillSurface } from '@/components/skills/SkillSurface';
|
||||
|
||||
/**
|
||||
* Admin Candidates Analysis — the analytical counterpart to Candidates.
|
||||
@@ -165,6 +166,7 @@ export default function AdminCandidatesAnalysis() {
|
||||
title="Candidates Analysis"
|
||||
subtitle="Talent supply, quality distribution and pipeline risk."
|
||||
>
|
||||
<SkillSurface page="candidates-analysis" placement="after-header" />
|
||||
<MetricStrip
|
||||
columns={5}
|
||||
items={[
|
||||
@@ -188,6 +190,7 @@ export default function AdminCandidatesAnalysis() {
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
<div className="space-y-1.5">
|
||||
|
||||
{bands.map((b) => (
|
||||
<div key={b.name} className="flex items-center gap-2 text-caption">
|
||||
<span className="h-2 w-2 shrink-0 rounded-sm" style={{ background: b.color }} aria-hidden="true" />
|
||||
@@ -413,6 +416,7 @@ export default function AdminCandidatesAnalysis() {
|
||||
Full analytics <ArrowRight aria-hidden="true" />
|
||||
</Button>
|
||||
</section>
|
||||
<SkillSurface page="candidates-analysis" placement="before-footer" />
|
||||
</AdminPage>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
} from '@/lib/krowHooks';
|
||||
import { buildFacts } from '@/components/ai-assistant/insights';
|
||||
import { AdminPage } from '@/pages/admin/_shell';
|
||||
import { SkillSurface } from '@/components/skills/SkillSurface';
|
||||
|
||||
/**
|
||||
* Control Center — the KROW Admin command centre.
|
||||
@@ -180,6 +181,7 @@ function PipelineFunnel({ funnel, transitions, weakest, total }) {
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
|
||||
{/* 5 Stage Funnel Cards */}
|
||||
<div className="grid grid-cols-1 gap-3.5 sm:grid-cols-2 lg:grid-cols-5">
|
||||
{funnel.map((stage, i) => {
|
||||
@@ -584,6 +586,7 @@ export default function ControlCenter() {
|
||||
</Button>
|
||||
)}
|
||||
>
|
||||
<SkillSurface page="control-center" placement="after-header" />
|
||||
{/* 1 ── Operations snapshot */}
|
||||
<Snapshot metrics={metrics} loading={isLoading} />
|
||||
|
||||
@@ -973,6 +976,7 @@ export default function ControlCenter() {
|
||||
</div>
|
||||
</div>
|
||||
</Band>
|
||||
<SkillSurface page="control-center" placement="before-footer" />
|
||||
</AdminPage>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,459 +1,293 @@
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import { ArrowRight, Award, Briefcase, Building2, CheckCircle2, ChevronRight, Clock, ShieldCheck, Sparkles, Star, TrendingUp, Users, Zap } from 'lucide-react';
|
||||
import { Tab, TabList } from '@astryxdesign/core';
|
||||
import { Award, Building2, CalendarDays, Clock, Mail, Phone, UserCheck } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Avatar, Badge, DataTable, MetricStrip, StatusBadge, Surface } from '@/components/ds';
|
||||
import {
|
||||
Avatar, Badge, Button, DataTable, Drawer, EmptyState, FilterBar, MetricStrip, StatusBadge,
|
||||
Surface, Timeline,
|
||||
} from '@/components/ds';
|
||||
import { useApplications, useJobPostings, useStaff } from '@/lib/krowHooks';
|
||||
import { DepartmentPerformance } from '@/components/charts/DepartmentPerformance';
|
||||
import { HiringTrendChart } from '@/components/charts/HiringTrendChart';
|
||||
import { AdminPage, SectionTitle } from '@/pages/admin/_shell';
|
||||
import { SkillSurface } from '@/components/skills/SkillSurface';
|
||||
import { hiresWithFill, summarise } from '@/lib/hiringRecords';
|
||||
|
||||
/**
|
||||
* Admin Hired History — hiring outcomes over time.
|
||||
* Admin Hired History — the record of who was hired.
|
||||
*
|
||||
* Reporting rather than monitoring: the question here is "did our hiring work",
|
||||
* so the page leads with trend and quality and keeps the roster underneath,
|
||||
* instead of repeating the Control Center's live counts.
|
||||
* This page and Analytics used to be one component rendered twice, which is why
|
||||
* they read as duplicates. They answer different questions, and this is the one
|
||||
* about *people*: **who did we hire, for which position, when, and what
|
||||
* happened since.**
|
||||
*
|
||||
* The five sections are views of one workspace rather than a document to scroll:
|
||||
* every one is a full-size analysis, and stacking them made the page four
|
||||
* screens tall to reach the last. An Astryx TabList switches between them; the
|
||||
* metric strip stays above the tabs because it is the summary of all five, not
|
||||
* one of them. Nothing about the sections themselves changed — each is the same
|
||||
* component, reading the same derived data, rendered one at a time.
|
||||
* So it is built as a record rather than as a dashboard. There is no funnel, no
|
||||
* trend line and no departmental comparison here — those are aggregate readings
|
||||
* and they live on Analytics. What is here instead is what a record needs and a
|
||||
* dashboard cannot give you: a search across every hire, filters for the fields
|
||||
* you would search by, a chronology, and a full detail view for one person.
|
||||
*
|
||||
* The figures at the top are a count of what the filters currently select, not
|
||||
* a performance summary — they answer "how much of the record am I looking at".
|
||||
* Both pages read `lib/hiringRecords.js`, so the totals cannot disagree.
|
||||
*/
|
||||
|
||||
/** Section views. `overview` is the landing view. */
|
||||
const VIEWS = [
|
||||
{ value: 'overview', label: 'Overview' },
|
||||
{ value: 'departments', label: 'Department Breakdown' },
|
||||
{ value: 'outcomes', label: 'Position Outcomes' },
|
||||
{ value: 'comparison', label: 'Historical Comparison' },
|
||||
{ value: 'recent', label: 'Recent Hires' },
|
||||
/** Date-range windows, expressed as days back from today. */
|
||||
const RANGES = [
|
||||
{ value: 'all', label: 'Any time', days: null },
|
||||
{ value: '7', label: 'Last 7 days', days: 7 },
|
||||
{ value: '30', label: 'Last 30 days', days: 30 },
|
||||
{ value: '90', label: 'Last 90 days', days: 90 },
|
||||
];
|
||||
|
||||
export default function AdminHiredHistory({ title = 'Hired History' }) {
|
||||
const [view, setView] = useState('overview');
|
||||
const DAY = 24 * 60 * 60 * 1000;
|
||||
|
||||
const formatDate = (value) => (value
|
||||
? new Date(value).toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' })
|
||||
: '—');
|
||||
|
||||
/** One label/value row in the detail panel. */
|
||||
function Fact({ label, children }) {
|
||||
return (
|
||||
<div className="flex items-baseline justify-between gap-3 border-b border-border px-4 py-2.5 last:border-0">
|
||||
<span className="shrink-0 text-caption text-ink-4">{label}</span>
|
||||
<span className="min-w-0 text-right text-body-sm font-medium text-ink-1">{children ?? '—'}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** The full record for one hire — the page's own detail view. */
|
||||
function HireDetail({ hire, onClose }) {
|
||||
if (!hire) return null;
|
||||
|
||||
return (
|
||||
<Drawer
|
||||
open={Boolean(hire)}
|
||||
onOpenChange={(next) => { if (!next) onClose(); }}
|
||||
title={hire.name}
|
||||
description={[hire.role, hire.company].filter((v) => v && v !== '—').join(' · ') || 'Hired'}
|
||||
icon={UserCheck}
|
||||
size="default"
|
||||
>
|
||||
<div className="space-y-5 px-5 py-5 sm:px-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<Avatar name={hire.name} size="lg" />
|
||||
<div className="min-w-0">
|
||||
<p className="truncate font-heading text-body font-semibold text-ink-1">{hire.name}</p>
|
||||
<div className="mt-1 flex flex-wrap items-center gap-1.5">
|
||||
<StatusBadge status={hire.status || 'hired'} size="sm" />
|
||||
{hire.profile_tier && <Badge variant="neutral" size="sm">{hire.profile_tier}</Badge>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section>
|
||||
<SectionTitle title="The hire" />
|
||||
<Surface variant="solid" radius="lg" padding="none" elevation="xs" className="mt-2 border border-border">
|
||||
<Fact label="Position">{hire.role}</Fact>
|
||||
<Fact label="Client">{hire.company}</Fact>
|
||||
<Fact label="Department">{hire.department}</Fact>
|
||||
<Fact label="Hired">{formatDate(hire.hire_date)}</Fact>
|
||||
<Fact label="Time to hire">{hire.timeToHire ? `${hire.timeToHire} days` : null}</Fact>
|
||||
<Fact label="AI score">{hire.score}</Fact>
|
||||
<Fact label="Client review">
|
||||
{hire.client_rating ? `${hire.client_rating} / 5` : 'Not reviewed yet'}
|
||||
</Fact>
|
||||
</Surface>
|
||||
</section>
|
||||
|
||||
{(hire.email || hire.phone) && (
|
||||
<section>
|
||||
<SectionTitle title="Contact" />
|
||||
<Surface variant="solid" radius="lg" padding="lg" elevation="xs" className="mt-2 space-y-2 border border-border">
|
||||
{hire.email && (
|
||||
<a
|
||||
href={`mailto:${hire.email}`}
|
||||
className="flex items-center gap-2 text-body-sm text-ink-2 transition-colors hover:text-krow-blue"
|
||||
>
|
||||
<Mail className="h-3.5 w-3.5 shrink-0" aria-hidden="true" />
|
||||
<span className="truncate">{hire.email}</span>
|
||||
</a>
|
||||
)}
|
||||
{hire.phone && (
|
||||
<a
|
||||
href={`tel:${hire.phone}`}
|
||||
className="flex items-center gap-2 text-body-sm text-ink-2 transition-colors hover:text-krow-blue"
|
||||
>
|
||||
<Phone className="h-3.5 w-3.5 shrink-0" aria-hidden="true" />
|
||||
<span className="truncate">{hire.phone}</span>
|
||||
</a>
|
||||
)}
|
||||
</Surface>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Per-record extension point, on the same terms as every other
|
||||
surface: a definition names `hired-history`, this renders it. */}
|
||||
<SkillSurface page="hired-history" placement="before-footer" context={{ candidate: hire }} />
|
||||
</div>
|
||||
</Drawer>
|
||||
);
|
||||
}
|
||||
|
||||
export default function AdminHiredHistory() {
|
||||
const { data: staff = [], isLoading } = useStaff();
|
||||
const { data: applications = [] } = useApplications();
|
||||
const { data: postings = [] } = useJobPostings();
|
||||
|
||||
const DEFAULT_STAFF_HIRES = useMemo(() => [
|
||||
{ id: 's1', name: 'Marco Rivera', email: 'marco.rivera@email.com', role: 'Experienced Bartender – Corporate Events', department: 'Bartender', profile_tier: 'skilled', score: 92, timeToHire: 1, hire_date: '2026-07-25', status: 'hired' },
|
||||
{ id: 's2', name: 'Chef Antoine Dubois', email: 'antoine.dubois@email.com', role: 'Executive Chef – Catering', department: 'Chef', profile_tier: 'skilled', score: 97, timeToHire: 1, hire_date: '2026-07-25', status: 'hired' },
|
||||
{ id: 's3', name: 'Marcus Williams', email: 'marcus.w@email.com', role: 'Event Security Officer', department: 'Security', profile_tier: 'skilled', score: 94, timeToHire: 1, hire_date: '2026-07-25', status: 'hired' },
|
||||
{ id: 's4', name: 'Sophia Chen', email: 'sophia.chen@email.com', role: 'Guest Relations Lead', department: 'Front Desk', profile_tier: 'expert', score: 90, timeToHire: 2, hire_date: '2026-07-22', status: 'hired' },
|
||||
{ id: 's5', name: 'Oliver Bennett', email: 'oliver.b@email.com', role: 'Event Coordinator', department: 'Event Manager', profile_tier: 'skilled', score: 91, timeToHire: 3, hire_date: '2026-07-20', status: 'hired' },
|
||||
{ id: 's6', name: 'Aaliyah Patel', email: 'aaliyah.p@email.com', role: 'Operations Supervisor', department: 'Housekeeping', profile_tier: 'solid', score: 87, timeToHire: 2, hire_date: '2026-07-18', status: 'hired' },
|
||||
{ id: 's7', name: 'Lucas Wright', email: 'lucas.w@email.com', role: 'Concierge Lead', department: 'Front Desk', profile_tier: 'solid', score: 88, timeToHire: 2, hire_date: '2026-07-15', status: 'hired' },
|
||||
{ id: 's8', name: 'Elena Rostova', email: 'elena.r@email.com', role: 'Lead Security Officer', department: 'Security', profile_tier: 'expert', score: 95, timeToHire: 1, hire_date: '2026-07-12', status: 'hired' },
|
||||
], []);
|
||||
const [search, setSearch] = useState('');
|
||||
const [filters, setFilters] = useState({ position: 'all', department: 'all', range: 'all' });
|
||||
const [selected, setSelected] = useState(null);
|
||||
|
||||
const hires = useMemo(() => {
|
||||
const liveList = staff.map((s) => {
|
||||
const app = applications.find((a) => a.id === s.application_id);
|
||||
const posting = postings.find((p) => p.id === s.job_posting_id);
|
||||
const days = app
|
||||
? Math.max(1, Math.round((new Date(app.updated_date) - new Date(app.created_date)) / 86400000))
|
||||
: null;
|
||||
return {
|
||||
...s,
|
||||
department: posting?.role_category || s.department || '—',
|
||||
timeToHire: days || s.timeToHire || 1,
|
||||
score: s.ai_score || app?.ai_score || s.score || 90,
|
||||
profile_tier: s.profile_tier || 'skilled',
|
||||
};
|
||||
});
|
||||
const hires = useMemo(
|
||||
() => hiresWithFill({ staff, applications, postings }),
|
||||
[staff, applications, postings]
|
||||
);
|
||||
|
||||
const emails = new Set(liveList.map((h) => (h.email || h.name).toLowerCase()));
|
||||
const result = [...liveList];
|
||||
for (const def of DEFAULT_STAFF_HIRES) {
|
||||
if (!emails.has((def.email || def.name).toLowerCase())) {
|
||||
result.push(def);
|
||||
emails.add((def.email || def.name).toLowerCase());
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}, [staff, applications, postings, DEFAULT_STAFF_HIRES]);
|
||||
/* Filter options come from the record itself, so a department with no hires
|
||||
is never offered as a filter that returns nothing. */
|
||||
const positionOptions = useMemo(
|
||||
() => [...new Set(hires.map((h) => h.role).filter((r) => r && r !== '—'))].sort(),
|
||||
[hires]
|
||||
);
|
||||
const departmentOptions = useMemo(
|
||||
() => [...new Set(hires.map((h) => h.department).filter((d) => d && d !== '—'))].sort(),
|
||||
[hires]
|
||||
);
|
||||
|
||||
const avg = (xs) => (xs.length ? Math.round(xs.reduce((a, b) => a + b, 0) / xs.length) : 0);
|
||||
const quality = avg(hires.map((h) => h.score).filter(Boolean));
|
||||
const speed = avg(hires.map((h) => h.timeToHire).filter(Boolean));
|
||||
const filtered = useMemo(() => {
|
||||
const q = search.trim().toLowerCase();
|
||||
const range = RANGES.find((r) => r.value === filters.range);
|
||||
const cutoff = range?.days ? Date.now() - range.days * DAY : null;
|
||||
|
||||
/* Cumulative hires by month — a trend needs a baseline, not a single bar. */
|
||||
const trend = useMemo(() => {
|
||||
const byMonth = new Map();
|
||||
hires.forEach((h) => {
|
||||
const d = new Date(h.hire_date);
|
||||
const key = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`;
|
||||
byMonth.set(key, (byMonth.get(key) || 0) + 1);
|
||||
});
|
||||
let running = 0;
|
||||
return [...byMonth.entries()].sort().map(([key, count]) => {
|
||||
running += count;
|
||||
const [y, m] = key.split('-');
|
||||
return {
|
||||
label: new Date(Number(y), Number(m) - 1).toLocaleDateString(undefined, { month: 'short' }),
|
||||
hires: count,
|
||||
cumulative: running,
|
||||
};
|
||||
});
|
||||
}, [hires]);
|
||||
return hires
|
||||
.filter((h) => {
|
||||
if (q && ![h.name, h.role, h.company, h.department, h.email]
|
||||
.some((v) => String(v || '').toLowerCase().includes(q))) return false;
|
||||
if (filters.position !== 'all' && h.role !== filters.position) return false;
|
||||
if (filters.department !== 'all' && h.department !== filters.department) return false;
|
||||
if (cutoff && new Date(h.hire_date || 0).getTime() < cutoff) return false;
|
||||
return true;
|
||||
})
|
||||
.sort((a, b) => new Date(b.hire_date || 0) - new Date(a.hire_date || 0));
|
||||
}, [hires, search, filters]);
|
||||
|
||||
const DEFAULT_DEPARTMENTS = useMemo(() => [
|
||||
{ name: 'Chef', department: 'Chef', count: 1, avgScore: 97, avgTimeToHire: 1, roles: ['Executive Chef', 'Sous Chef'], hiresList: [{ id: 'h1', name: 'Marcus Vance', role: 'Executive Chef', score: 97 }] },
|
||||
{ name: 'Security', department: 'Security', count: 1, avgScore: 94, avgTimeToHire: 1, roles: ['Lead Security Guard'], hiresList: [{ id: 'h2', name: 'Elena Rostova', role: 'Lead Security Guard', score: 94 }] },
|
||||
{ name: 'Bartender', department: 'Bartender', count: 1, avgScore: 92, avgTimeToHire: 1, roles: ['Senior Mixologist'], hiresList: [{ id: 'h3', name: 'David Kim', role: 'Senior Mixologist', score: 92 }] },
|
||||
{ name: 'Front Desk', department: 'Front Desk', count: 2, avgScore: 89, avgTimeToHire: 2, roles: ['Guest Relations Lead', 'Concierge'], hiresList: [{ id: 'h4', name: 'Sophia Chen', role: 'Guest Relations Lead', score: 90 }, { id: 'h5', name: 'Lucas Wright', role: 'Concierge', score: 88 }] },
|
||||
{ name: 'Event Manager', department: 'Event Manager', count: 1, avgScore: 91, avgTimeToHire: 3, roles: ['Event Coordinator'], hiresList: [{ id: 'h6', name: 'Oliver Bennett', role: 'Event Coordinator', score: 91 }] },
|
||||
{ name: 'Housekeeping', department: 'Housekeeping', count: 3, avgScore: 86, avgTimeToHire: 2, roles: ['Operations Supervisor', 'Sanitation Lead'], hiresList: [{ id: 'h7', name: 'Aaliyah Patel', role: 'Operations Supervisor', score: 87 }] },
|
||||
], []);
|
||||
const summary = useMemo(() => summarise(filtered), [filtered]);
|
||||
const isFiltered = Boolean(search) || Object.values(filters).some((v) => v !== 'all');
|
||||
|
||||
const byDepartment = useMemo(() => {
|
||||
const map = new Map();
|
||||
hires.forEach((h) => {
|
||||
const deptName = h.department && h.department !== '—' ? h.department : 'General';
|
||||
const e = map.get(deptName) || {
|
||||
department: deptName,
|
||||
count: 0,
|
||||
scores: [],
|
||||
times: [],
|
||||
rolesSet: new Set(),
|
||||
hiresList: [],
|
||||
};
|
||||
e.count += 1;
|
||||
if (h.score) e.scores.push(h.score);
|
||||
if (h.timeToHire) e.times.push(h.timeToHire);
|
||||
if (h.role) e.rolesSet.add(h.role);
|
||||
e.hiresList.push(h);
|
||||
map.set(deptName, e);
|
||||
});
|
||||
/* Recent first, and only what a chronology can actually show — a timeline of
|
||||
forty rows is a table with worse density. */
|
||||
const recent = useMemo(() => filtered.filter((h) => h.hire_date).slice(0, 6), [filtered]);
|
||||
|
||||
const liveList = [...map.values()].map((e) => ({
|
||||
name: e.department,
|
||||
department: e.department,
|
||||
count: e.count,
|
||||
scores: e.scores,
|
||||
avgScore: avg(e.scores),
|
||||
avgTimeToHire: avg(e.times),
|
||||
roles: Array.from(e.rolesSet),
|
||||
hiresList: e.hiresList,
|
||||
}));
|
||||
|
||||
const names = new Set(liveList.map((d) => d.name.toLowerCase()));
|
||||
const result = [...liveList];
|
||||
for (const def of DEFAULT_DEPARTMENTS) {
|
||||
if (result.length >= 6) break;
|
||||
if (!names.has(def.name.toLowerCase())) {
|
||||
result.push(def);
|
||||
names.add(def.name.toLowerCase());
|
||||
}
|
||||
}
|
||||
|
||||
return result.sort((a, b) => (b.avgScore || 0) - (a.avgScore || 0) || b.count - a.count);
|
||||
}, [hires, DEFAULT_DEPARTMENTS]);
|
||||
|
||||
/**
|
||||
* Outcomes per position: how many were hired, how fast, and how well they
|
||||
* scored. This is the retrospective question Positions cannot answer, because
|
||||
* Positions is about roles that are still open.
|
||||
*/
|
||||
const DEFAULT_POSITIONS = useMemo(() => [
|
||||
{ role: 'Executive Chef', count: 1, avgScore: 97, avgDays: 1, rated: 1, avgRating: 5.0 },
|
||||
{ role: 'Lead Security Guard', count: 1, avgScore: 94, avgDays: 1, rated: 1, avgRating: 4.9 },
|
||||
{ role: 'Senior Mixologist', count: 1, avgScore: 92, avgDays: 1, rated: 1, avgRating: 4.8 },
|
||||
{ role: 'Guest Relations Lead', count: 2, avgScore: 89, avgDays: 2, rated: 2, avgRating: 4.7 },
|
||||
{ role: 'Event Coordinator', count: 1, avgScore: 91, avgDays: 3, rated: 1, avgRating: 4.9 },
|
||||
{ role: 'Operations Supervisor', count: 3, avgScore: 86, avgDays: 2, rated: 3, avgRating: 4.6 },
|
||||
], []);
|
||||
|
||||
const byPosition = useMemo(() => {
|
||||
const map = new Map();
|
||||
hires.forEach((h) => {
|
||||
const key = h.role || 'Unspecified';
|
||||
const e = map.get(key) || { role: key, count: 0, scores: [], days: [], rated: 0, ratings: [] };
|
||||
e.count += 1;
|
||||
if (h.score) e.scores.push(h.score);
|
||||
if (h.timeToHire) e.days.push(h.timeToHire);
|
||||
if (h.client_rating) { e.rated += 1; e.ratings.push(h.client_rating); }
|
||||
map.set(key, e);
|
||||
});
|
||||
|
||||
const liveList = [...map.values()].map((e) => ({
|
||||
role: e.role,
|
||||
count: e.count,
|
||||
avgScore: avg(e.scores),
|
||||
avgDays: avg(e.days),
|
||||
rated: e.rated,
|
||||
avgRating: e.ratings.length
|
||||
? Number((e.ratings.reduce((x, y) => x + y, 0) / e.ratings.length).toFixed(1))
|
||||
: null,
|
||||
}));
|
||||
|
||||
const roleNames = new Set(liveList.map((p) => p.role.toLowerCase()));
|
||||
const result = [...liveList];
|
||||
for (const def of DEFAULT_POSITIONS) {
|
||||
if (result.length >= 6) break;
|
||||
if (!roleNames.has(def.role.toLowerCase())) {
|
||||
result.push(def);
|
||||
roleNames.add(def.role.toLowerCase());
|
||||
}
|
||||
}
|
||||
|
||||
return result.sort((a, b) => b.count - a.count);
|
||||
}, [hires, DEFAULT_POSITIONS]);
|
||||
|
||||
/**
|
||||
* Historical comparison: this half of the dataset against the previous half.
|
||||
*
|
||||
* Split at the median hire date rather than by calendar month, because a demo
|
||||
* dataset spanning a few weeks has no month boundary to compare across — and a
|
||||
* comparison that silently returns zero is worse than one that adapts.
|
||||
*/
|
||||
const comparison = useMemo(() => {
|
||||
const dated = hires
|
||||
.filter((h) => h.hire_date)
|
||||
.sort((a, b) => new Date(a.hire_date) - new Date(b.hire_date));
|
||||
if (dated.length < 2) return null;
|
||||
|
||||
const midpoint = Math.floor(dated.length / 2);
|
||||
const earlier = dated.slice(0, midpoint);
|
||||
const later = dated.slice(midpoint);
|
||||
|
||||
const summarise = (set) => ({
|
||||
hires: set.length,
|
||||
quality: avg(set.map((h) => h.score).filter(Boolean)),
|
||||
speed: avg(set.map((h) => h.timeToHire).filter(Boolean)),
|
||||
});
|
||||
|
||||
const from = summarise(earlier);
|
||||
const to = summarise(later);
|
||||
const label = (set) => new Date(set[0].hire_date).toLocaleDateString(undefined, { month: 'short', day: 'numeric' });
|
||||
|
||||
return {
|
||||
fromLabel: label(earlier),
|
||||
toLabel: label(later),
|
||||
rows: [
|
||||
{ metric: 'Hires closed', from: from.hires, to: to.hires, betterIsUp: true },
|
||||
{ metric: 'Quality of hire', from: from.quality, to: to.quality, betterIsUp: true },
|
||||
{ metric: 'Days to hire', from: from.speed, to: to.speed, betterIsUp: false },
|
||||
],
|
||||
};
|
||||
}, [hires]);
|
||||
const clearFilters = () => {
|
||||
setSearch('');
|
||||
setFilters({ position: 'all', department: 'all', range: 'all' });
|
||||
};
|
||||
|
||||
return (
|
||||
<AdminPage
|
||||
title={title}
|
||||
subtitle="Hiring outcomes, velocity and quality over time."
|
||||
tabs={(
|
||||
<div className="krow-astryx krow-astryx-scroll">
|
||||
<TabList value={view} onChange={setView} size="sm" hasDivider aria-label="Hired History sections">
|
||||
{VIEWS.map((v) => <Tab key={v.value} value={v.value} label={v.label} />)}
|
||||
</TabList>
|
||||
</div>
|
||||
)}
|
||||
title="Hired History"
|
||||
subtitle="Who was hired, for which position, and what has happened since."
|
||||
meta={`${hires.length} on record`}
|
||||
>
|
||||
{view === 'overview' && (
|
||||
<>
|
||||
<SkillSurface page="hired-history" placement="after-header" />
|
||||
|
||||
{/* 1. Search and filters — the page is a record, so finding one is the
|
||||
first thing it has to do well. */}
|
||||
<FilterBar
|
||||
search={search}
|
||||
onSearchChange={setSearch}
|
||||
searchPlaceholder="Search by name, position, client or department…"
|
||||
filters={[
|
||||
{ key: 'position', label: 'Position', type: 'select', options: [{ value: 'all', label: 'All positions' }, ...positionOptions.map((p) => ({ value: p, label: p }))] },
|
||||
{ key: 'department', label: 'Department', type: 'select', options: [{ value: 'all', label: 'All departments' }, ...departmentOptions.map((d) => ({ value: d, label: d }))] },
|
||||
{ key: 'range', label: 'Hired', type: 'select', options: RANGES.map((r) => ({ value: r.value, label: r.label })) },
|
||||
]}
|
||||
values={filters}
|
||||
onChange={setFilters}
|
||||
/>
|
||||
|
||||
{/* 2. What the current selection contains. A count of the record, not a
|
||||
performance verdict — that reading is Analytics'. */}
|
||||
<MetricStrip
|
||||
columns={4}
|
||||
columns={3}
|
||||
loading={isLoading}
|
||||
items={[
|
||||
{ label: 'Total hires', value: hires.length, icon: Award, tone: 'brand' },
|
||||
{ label: 'Avg time-to-hire', value: speed ? `${speed}d` : '—', icon: Clock, delta: -38, invertTrend: true, deltaLabel: 'vs 5d norm' },
|
||||
{ label: 'Quality of hire', value: quality || '—', icon: TrendingUp, tone: quality >= 80 ? 'success' : 'default', sub: 'avg AI score' },
|
||||
{ label: 'Still active', value: hires.filter((h) => h.status !== 'inactive').length, icon: Users, sub: `${hires.filter((h) => h.status === 'onboarding').length} onboarding` },
|
||||
{
|
||||
label: isFiltered ? 'Hires matching' : 'Total hires',
|
||||
value: summary.total,
|
||||
icon: Award,
|
||||
tone: 'brand',
|
||||
sub: isFiltered ? `of ${hires.length} on record` : undefined,
|
||||
},
|
||||
{
|
||||
label: 'Most recent hire',
|
||||
value: recent[0] ? formatDate(recent[0].hire_date) : '—',
|
||||
icon: CalendarDays,
|
||||
sub: recent[0]?.name,
|
||||
},
|
||||
{
|
||||
label: 'Avg time-to-hire',
|
||||
value: summary.speed ? `${summary.speed}d` : '—',
|
||||
icon: Clock,
|
||||
sub: `${summary.active} still active`,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
<section aria-labelledby="trend" className="space-y-3">
|
||||
<SectionTitle id="trend" title="Hiring trend" meta="Cumulative hires by month" />
|
||||
<HiringTrendChart
|
||||
points={trend}
|
||||
emptyState={(
|
||||
<div className="px-4 py-8 text-center">
|
||||
<p className="font-heading text-body font-semibold text-ink-1">
|
||||
Not enough history for a trend
|
||||
</p>
|
||||
<p className="mx-auto mt-1 max-w-md text-body-sm leading-relaxed text-ink-3">
|
||||
{hires.length
|
||||
? `All ${hires.length} hire${hires.length === 1 ? '' : 's'} closed in ${trend[0]?.label || 'a single month'}. A month-on-month line appears once hiring spans a second month.`
|
||||
: 'No hires recorded yet. Hiring volume over time appears here once the first role closes.'}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
</section>
|
||||
|
||||
{/* Executive Highlights & Quick Insights Section (Fills empty vertical space with valuable analytics) */}
|
||||
<div className="mt-6 space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="font-heading text-body-lg font-bold text-ink-1 flex items-center gap-2">
|
||||
<Sparkles className="h-4 w-4 text-blue-600" />
|
||||
Executive Hiring Insights
|
||||
</h3>
|
||||
<p className="text-caption text-ink-3">Key performance highlights derived from active hiring data</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 3 Executive Insight Cards */}
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
|
||||
{/* Card 1: AI Score & Quality */}
|
||||
<div className="rounded-2xl border border-border/80 bg-surface p-4 shadow-xs transition-all hover:border-blue-500/40">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-[10px] font-bold uppercase tracking-wider text-blue-600 dark:text-blue-400">Quality Index</span>
|
||||
<div className="flex h-7 w-7 items-center justify-center rounded-lg bg-blue-50 text-blue-600 dark:bg-blue-950 dark:text-blue-400">
|
||||
<Sparkles className="h-3.5 w-3.5" />
|
||||
</div>
|
||||
</div>
|
||||
<p className="mt-2 font-heading text-title font-bold text-ink-1">
|
||||
{quality || 92} <span className="text-caption font-normal text-ink-3">/ 100 AI Score</span>
|
||||
</p>
|
||||
<p className="mt-1 text-caption text-ink-3">
|
||||
Top Dept: <span className="font-semibold text-ink-1">{byDepartment[0]?.department || 'Chef'}</span> ({byDepartment[0]?.avgScore || 97} Avg)
|
||||
</p>
|
||||
<div className="mt-3 flex items-center gap-1.5 rounded-lg bg-emerald-50 dark:bg-emerald-950/40 px-2.5 py-1 text-[11px] font-medium text-emerald-700 dark:text-emerald-400">
|
||||
<CheckCircle2 className="h-3.5 w-3.5 shrink-0" />
|
||||
<span>100% Top Verified Profiles</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Card 2: Velocity & Fill Speed */}
|
||||
<div className="rounded-2xl border border-border/80 bg-surface p-4 shadow-xs transition-all hover:border-emerald-500/40">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-[10px] font-bold uppercase tracking-wider text-emerald-600 dark:text-emerald-400">Time-to-Hire Efficiency</span>
|
||||
<div className="flex h-7 w-7 items-center justify-center rounded-lg bg-emerald-50 text-emerald-600 dark:bg-emerald-950 dark:text-emerald-400">
|
||||
<Zap className="h-3.5 w-3.5" />
|
||||
</div>
|
||||
</div>
|
||||
<p className="mt-2 font-heading text-title font-bold text-ink-1">
|
||||
{speed || 2}d <span className="text-caption font-normal text-emerald-600 font-semibold">(-38% vs norm)</span>
|
||||
</p>
|
||||
<p className="mt-1 text-caption text-ink-3">
|
||||
Industry norm: 5 days average
|
||||
</p>
|
||||
<div className="mt-3 flex items-center gap-1.5 rounded-lg bg-blue-50 dark:bg-blue-950/40 px-2.5 py-1 text-[11px] font-medium text-blue-700 dark:text-blue-400">
|
||||
<Clock className="h-3.5 w-3.5 shrink-0" />
|
||||
<span>80% roles filled within 48h</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Card 3: Retention & Client Satisfaction */}
|
||||
<div className="rounded-2xl border border-border/80 bg-surface p-4 shadow-xs transition-all hover:border-purple-500/40">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-[10px] font-bold uppercase tracking-wider text-purple-600 dark:text-purple-400">Workforce Health</span>
|
||||
<div className="flex h-7 w-7 items-center justify-center rounded-lg bg-purple-50 text-purple-600 dark:bg-purple-950 dark:text-purple-400">
|
||||
<ShieldCheck className="h-3.5 w-3.5" />
|
||||
</div>
|
||||
</div>
|
||||
<p className="mt-2 font-heading text-title font-bold text-ink-1">
|
||||
100% <span className="text-caption font-normal text-ink-3">Active Retention</span>
|
||||
</p>
|
||||
<p className="mt-1 text-caption text-ink-3">
|
||||
{hires.filter((h) => h.status === 'onboarding').length || 3} hires currently in onboarding
|
||||
</p>
|
||||
<div className="mt-3 flex items-center gap-1.5 rounded-lg bg-amber-50 dark:bg-amber-950/40 px-2.5 py-1 text-[11px] font-medium text-amber-700 dark:text-amber-400">
|
||||
<Star className="h-3.5 w-3.5 shrink-0 fill-amber-400 text-amber-500" />
|
||||
<span>4.9 / 5.0 Avg Rating</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 2-Column Summary Cards: Top Departments & Position Breakdown */}
|
||||
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
|
||||
{/* Top Departments Summary */}
|
||||
<div className="rounded-2xl border border-border bg-surface p-4 shadow-xs">
|
||||
<div className="flex items-center justify-between border-b border-border pb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Building2 className="h-4 w-4 text-blue-600" />
|
||||
<h4 className="font-heading text-body-sm font-bold text-ink-1">Top Performing Departments</h4>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setView('departments')}
|
||||
className="text-caption font-semibold text-blue-600 hover:text-blue-700 flex items-center gap-1 transition-colors"
|
||||
>
|
||||
<span>View all</span>
|
||||
<ChevronRight className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="mt-3 space-y-2.5">
|
||||
{byDepartment.slice(0, 3).map((dept, i) => (
|
||||
<div key={dept.department} className="flex items-center justify-between rounded-xl bg-surface-subtle p-2.5">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<span className="flex h-6 w-6 items-center justify-center rounded-lg bg-blue-100 dark:bg-blue-950 text-[10px] font-bold text-blue-700 dark:text-blue-400">
|
||||
#{i + 1}
|
||||
</span>
|
||||
<div>
|
||||
<p className="font-heading text-body-sm font-semibold text-ink-1">{dept.department}</p>
|
||||
<p className="text-[10px] text-ink-3">{dept.count} {dept.count === 1 ? 'hire' : 'hires'} • {dept.avgDays || 2}d avg velocity</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<span className="font-heading text-body-sm font-bold text-blue-600 dark:text-blue-400">{dept.avgScore || 90}</span>
|
||||
<span className="block text-[9px] text-ink-4">AI Score</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Position Outcomes Summary */}
|
||||
<div className="rounded-2xl border border-border bg-surface p-4 shadow-xs">
|
||||
<div className="flex items-center justify-between border-b border-border pb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Briefcase className="h-4 w-4 text-emerald-600" />
|
||||
<h4 className="font-heading text-body-sm font-bold text-ink-1">Position Highlights</h4>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setView('outcomes')}
|
||||
className="text-caption font-semibold text-blue-600 hover:text-blue-700 flex items-center gap-1 transition-colors"
|
||||
>
|
||||
<span>View all</span>
|
||||
<ChevronRight className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="mt-3 space-y-2.5">
|
||||
{byPosition.slice(0, 3).map((pos) => (
|
||||
<div key={pos.role} className="flex items-center justify-between rounded-xl bg-surface-subtle p-2.5">
|
||||
<div className="min-w-0 flex-1 pr-3">
|
||||
<p className="truncate font-heading text-body-sm font-semibold text-ink-1">{pos.role}</p>
|
||||
<p className="text-[10px] text-ink-3">{pos.count} filled • {pos.avgDays ? `${pos.avgDays}d fill time` : '1d fill time'}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<Badge variant="success" size="sm" className="font-bold">{pos.avgRating || 5}/5 Rating</Badge>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
{/* 3. The chronology. */}
|
||||
{recent.length > 0 && (
|
||||
<section aria-labelledby="chronology" className="space-y-3">
|
||||
<SectionTitle
|
||||
id="chronology"
|
||||
title="Recent hiring timeline"
|
||||
meta={`Last ${recent.length} of ${filtered.length}`}
|
||||
/>
|
||||
<Surface variant="solid" radius="lg" padding="lg" elevation="xs" className="border border-border">
|
||||
<Timeline
|
||||
items={recent.map((h, i) => ({
|
||||
id: h.id,
|
||||
title: h.name,
|
||||
description: [h.role, h.company !== '—' ? h.company : null]
|
||||
.filter(Boolean).join(' · '),
|
||||
timestamp: formatDate(h.hire_date),
|
||||
tone: i === 0 ? 'brand' : 'neutral',
|
||||
current: i === 0,
|
||||
icon: UserCheck,
|
||||
}))}
|
||||
/>
|
||||
</Surface>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{view === 'departments' && (
|
||||
<section aria-labelledby="dept" className="space-y-3">
|
||||
<SectionTitle id="dept" title="Department breakdown" meta={`${byDepartment.length} departments`} />
|
||||
<DepartmentPerformance
|
||||
items={byDepartment}
|
||||
{/* 4. The records themselves — every field a hire carries, one row each. */}
|
||||
<section aria-labelledby="records" className="space-y-3">
|
||||
<SectionTitle
|
||||
id="records"
|
||||
title="Hired candidate records"
|
||||
meta={isFiltered ? `${filtered.length} of ${hires.length}` : `${hires.length} people`}
|
||||
/>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{view === 'recent' && (
|
||||
<section aria-labelledby="roster" className="space-y-3">
|
||||
<SectionTitle id="roster" title="Recent hires" meta={`${hires.length} people`} />
|
||||
<DataTable
|
||||
loading={isLoading}
|
||||
rows={hires}
|
||||
pageSize={10}
|
||||
caption="Everyone hired, with tier and time-to-hire"
|
||||
rows={filtered}
|
||||
pageSize={12}
|
||||
caption="Everyone hired, with position, client, score and time-to-hire"
|
||||
onRowClick={setSelected}
|
||||
isFiltered={isFiltered}
|
||||
onClearFilters={clearFilters}
|
||||
emptyState={(
|
||||
<EmptyState
|
||||
title={isFiltered ? 'No hires match these filters' : 'No hires recorded yet'}
|
||||
description={
|
||||
isFiltered
|
||||
? 'Try a broader search, or clear the filters to see the whole record.'
|
||||
: 'Hires appear here as positions close.'
|
||||
}
|
||||
action={isFiltered
|
||||
? <Button variant="outline" size="sm" onClick={clearFilters}>Clear filters</Button>
|
||||
: undefined}
|
||||
/>
|
||||
)}
|
||||
columns={[
|
||||
{
|
||||
key: 'name', header: 'Hire', sortable: true,
|
||||
key: 'name', header: 'Candidate', sortable: true,
|
||||
cell: (h) => (
|
||||
<div className="flex min-w-0 items-center gap-2.5">
|
||||
<Avatar name={h.name} size="sm" />
|
||||
@@ -464,235 +298,48 @@ export default function AdminHiredHistory({ title = 'Hired History' }) {
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{ key: 'role', header: 'Role', sortable: true, hideBelow: 'md' },
|
||||
{ key: 'department', header: 'Department', sortable: true, hideBelow: 'lg' },
|
||||
{ key: 'profile_tier', header: 'Tier', cell: (h) => <StatusBadge status={h.profile_tier} size="sm" /> },
|
||||
{ key: 'role', header: 'Position', sortable: true, hideBelow: 'md' },
|
||||
{
|
||||
key: 'score', header: 'Score', align: 'right', sortable: true,
|
||||
cell: (h) => (h.score ? <span className="font-semibold tabular-nums text-ink-1">{h.score}</span> : '—'),
|
||||
key: 'company', header: 'Company', sortable: true, hideBelow: 'lg',
|
||||
cell: (h) => (h.company && h.company !== '—'
|
||||
? (
|
||||
<span className="inline-flex items-center gap-1.5 text-ink-2">
|
||||
<Building2 className="h-3 w-3 shrink-0 text-ink-4" aria-hidden="true" />
|
||||
<span className="truncate">{h.company}</span>
|
||||
</span>
|
||||
)
|
||||
: '—'),
|
||||
},
|
||||
{ key: 'department', header: 'Department', sortable: true, hideBelow: 'lg' },
|
||||
{
|
||||
key: 'hire_date', header: 'Hired', align: 'right', sortable: true,
|
||||
cell: (h) => (
|
||||
<span className="whitespace-nowrap text-[11px] text-ink-4">{formatDate(h.hire_date)}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'score', header: 'AI score', align: 'right', sortable: true,
|
||||
cell: (h) => (h.score
|
||||
? <span className="font-semibold tabular-nums text-ink-1">{h.score}</span>
|
||||
: '—'),
|
||||
},
|
||||
{
|
||||
key: 'timeToHire', header: 'Time to hire', align: 'right', sortable: true, hideBelow: 'lg',
|
||||
cell: (h) => (h.timeToHire ? `${h.timeToHire}d` : '—'),
|
||||
},
|
||||
{
|
||||
key: 'hire_date', header: 'Hired', align: 'right', sortable: true,
|
||||
cell: (h) => (
|
||||
<span className="whitespace-nowrap text-[11px] text-ink-4">
|
||||
{new Date(h.hire_date).toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' })}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'client_rating', header: 'Review', align: 'right', hideBelow: 'md',
|
||||
cell: (h) => (h.client_rating
|
||||
? <Badge variant="success" size="sm">{h.client_rating}/5</Badge>
|
||||
: <Badge variant="warning" size="sm">Pending</Badge>),
|
||||
key: 'status', header: 'Status', align: 'right',
|
||||
cell: (h) => <StatusBadge status={h.status || 'hired'} size="sm" />,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
<p className={cn('text-caption text-ink-4')}>
|
||||
Select a row to open the full record.
|
||||
</p>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Position outcomes and Historical comparison each own a view now, so the
|
||||
two-column pairing they used to share is no longer needed: each has the
|
||||
full width of the workspace to itself. */}
|
||||
{view === 'outcomes' && (
|
||||
<section aria-labelledby="outcomes" className="space-y-3">
|
||||
<SectionTitle id="outcomes" title="Position outcomes" meta={`${byPosition.length} roles filled`} />
|
||||
<Surface variant="solid" radius="lg" padding="none" elevation="xs" className="overflow-hidden border border-border">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full border-collapse text-body-sm">
|
||||
<caption className="sr-only">Hires, quality, speed and review outcome by role</caption>
|
||||
<thead>
|
||||
<tr className="border-b border-border bg-surface-subtle">
|
||||
{['Role', 'Hires', 'Avg score', 'Avg days', 'Reviewed', 'Rating'].map((h, i) => (
|
||||
<th
|
||||
key={h}
|
||||
scope="col"
|
||||
className={`whitespace-nowrap px-3.5 py-2.5 text-[10px] font-bold uppercase tracking-wider text-ink-4 ${i ? 'text-right' : 'text-left'}`}
|
||||
>
|
||||
{h}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border">
|
||||
{byPosition.map((r) => (
|
||||
<tr key={r.role} className="border-b border-border/60 last:border-0 hover:bg-surface-subtle/80 transition-colors">
|
||||
<td className="px-3.5 py-2.5 font-medium text-ink-1">{r.role}</td>
|
||||
<td className="px-3.5 py-2.5 text-right tabular-nums text-ink-2 font-semibold">{r.count}</td>
|
||||
<td className="px-3.5 py-2.5 text-right tabular-nums text-blue-600 dark:text-blue-400 font-bold">{r.avgScore || '—'}</td>
|
||||
<td className="px-3.5 py-2.5 text-right tabular-nums text-ink-2">{r.avgDays ? `${r.avgDays}d` : '—'}</td>
|
||||
<td className="px-3.5 py-2.5 text-right tabular-nums text-ink-3">{r.rated}/{r.count}</td>
|
||||
<td className="px-3.5 py-2.5 text-right">
|
||||
{r.avgRating != null
|
||||
? <Badge variant="success" size="sm" className="font-bold">{r.avgRating}/5</Badge>
|
||||
: <Badge variant="warning" size="sm" className="font-bold">Pending</Badge>}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Surface>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{view === 'comparison' && (
|
||||
comparison ? (
|
||||
<section aria-labelledby="compare" className="space-y-5">
|
||||
<SectionTitle
|
||||
id="compare"
|
||||
title="Historical comparison"
|
||||
meta={`Period 1 (${comparison.fromLabel}) vs Period 2 (${comparison.toLabel})`}
|
||||
/>
|
||||
|
||||
{/* 1. Period Cohort Highlights (2 Cards) */}
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<div className="rounded-2xl border border-border bg-surface p-4 shadow-xs">
|
||||
<div className="flex items-center justify-between border-b border-border/60 pb-2.5">
|
||||
<span className="text-[10px] font-bold uppercase tracking-wider text-ink-4">Cohort 1 (Earlier Half)</span>
|
||||
<span className="rounded-full bg-surface-subtle border border-border px-2 py-0.5 text-[10px] font-semibold text-ink-3">
|
||||
{comparison.fromLabel}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-3 grid grid-cols-3 gap-2 text-center">
|
||||
<div className="rounded-xl bg-surface-subtle p-2">
|
||||
<p className="text-[10px] text-ink-4 font-semibold uppercase">Hires</p>
|
||||
<p className="font-heading text-body-lg font-bold text-ink-1 mt-0.5">4</p>
|
||||
</div>
|
||||
<div className="rounded-xl bg-surface-subtle p-2">
|
||||
<p className="text-[10px] text-ink-4 font-semibold uppercase">Avg Score</p>
|
||||
<p className="font-heading text-body-lg font-bold text-blue-600 dark:text-blue-400 mt-0.5">90</p>
|
||||
</div>
|
||||
<div className="rounded-xl bg-surface-subtle p-2">
|
||||
<p className="text-[10px] text-ink-4 font-semibold uppercase">Avg Speed</p>
|
||||
<p className="font-heading text-body-lg font-bold text-ink-1 mt-0.5">2d</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-2xl border border-emerald-500/30 bg-gradient-to-br from-emerald-50/30 via-surface to-surface dark:from-emerald-950/20 p-4 shadow-xs">
|
||||
<div className="flex items-center justify-between border-b border-border/60 pb-2.5">
|
||||
<span className="text-[10px] font-bold uppercase tracking-wider text-emerald-600 dark:text-emerald-400">Cohort 2 (Recent Half)</span>
|
||||
<span className="rounded-full bg-emerald-100 dark:bg-emerald-950 px-2 py-0.5 text-[10px] font-bold text-emerald-700 dark:text-emerald-400">
|
||||
{comparison.toLabel} • +3pt Quality Gain
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-3 grid grid-cols-3 gap-2 text-center">
|
||||
<div className="rounded-xl bg-surface-subtle p-2">
|
||||
<p className="text-[10px] text-ink-4 font-semibold uppercase">Hires</p>
|
||||
<p className="font-heading text-body-lg font-bold text-ink-1 mt-0.5">4</p>
|
||||
</div>
|
||||
<div className="rounded-xl bg-surface-subtle p-2">
|
||||
<p className="text-[10px] text-ink-4 font-semibold uppercase">Avg Score</p>
|
||||
<p className="font-heading text-body-lg font-bold text-emerald-600 dark:text-emerald-400 mt-0.5">93</p>
|
||||
</div>
|
||||
<div className="rounded-xl bg-surface-subtle p-2">
|
||||
<p className="text-[10px] text-ink-4 font-semibold uppercase">Avg Speed</p>
|
||||
<p className="font-heading text-body-lg font-bold text-emerald-600 dark:text-emerald-400 mt-0.5">1d</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 2. Expanded Benchmark Comparison Metrics Table */}
|
||||
<Surface variant="solid" radius="lg" padding="none" elevation="xs" className="overflow-hidden border border-border">
|
||||
<div className="border-b border-border bg-surface-subtle px-4 py-2.5 flex items-center justify-between">
|
||||
<span className="text-[11px] font-bold uppercase tracking-wider text-ink-4">Comparative Benchmark Metrics</span>
|
||||
<span className="text-[10px] text-ink-4">Median benchmark split</span>
|
||||
</div>
|
||||
<ul className="divide-y divide-border">
|
||||
{[
|
||||
...comparison.rows,
|
||||
{ metric: 'Top Tier Candidates Ratio', from: '75%', to: '100%', flat: false, improved: true, deltaText: '+25%' },
|
||||
{ metric: 'Client Satisfaction Rating', from: '4.7', to: '4.9', flat: false, improved: true, deltaText: '+0.2' },
|
||||
{ metric: 'AI Match Precision Index', from: '92%', to: '98%', flat: false, improved: true, deltaText: '+6%' },
|
||||
].map((row) => {
|
||||
const delta = row.to !== undefined && row.from !== undefined && typeof row.to === 'number' ? row.to - row.from : null;
|
||||
const improved = row.improved !== undefined ? row.improved : (row.betterIsUp ? delta > 0 : delta < 0);
|
||||
const flat = row.flat !== undefined ? row.flat : (delta === 0);
|
||||
const deltaDisplay = row.deltaText || (flat ? 'level' : `${delta > 0 ? '+' : ''}${delta}`);
|
||||
|
||||
return (
|
||||
<li key={row.metric} className="flex items-center justify-between gap-3 px-4 py-3.5 hover:bg-surface-subtle/40 transition-colors">
|
||||
<div className="min-w-0 flex-1">
|
||||
<span className="block text-body-sm font-semibold text-ink-1">{row.metric}</span>
|
||||
<span className="text-[10px] text-ink-4">Period-over-period benchmark</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2.5 shrink-0">
|
||||
<span className="w-12 text-right font-heading text-body-sm font-semibold tabular-nums text-ink-4">
|
||||
{row.from || '—'}
|
||||
</span>
|
||||
<ArrowRight className="h-3.5 w-3.5 shrink-0 text-ink-4" aria-hidden="true" />
|
||||
<span className="w-12 text-right font-heading text-body-sm font-bold tabular-nums text-ink-1">
|
||||
{row.to || '—'}
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
'inline-flex items-center justify-center rounded-full px-2.5 py-0.5 text-[10px] font-bold tabular-nums border min-w-[50px]',
|
||||
flat
|
||||
? 'bg-surface-subtle text-ink-4 border-border'
|
||||
: improved
|
||||
? 'bg-emerald-50 text-emerald-700 border-emerald-200 dark:bg-emerald-950 dark:text-emerald-400'
|
||||
: 'bg-amber-50 text-amber-700 border-amber-200 dark:bg-amber-950 dark:text-amber-400'
|
||||
)}
|
||||
>
|
||||
{deltaDisplay}
|
||||
</span>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</Surface>
|
||||
|
||||
{/* 3. Executive Analysis Insights Cards */}
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<div className="rounded-2xl border border-border bg-surface p-4 shadow-xs">
|
||||
<div className="flex items-center gap-2 text-blue-600 dark:text-blue-400">
|
||||
<TrendingUp className="h-4 w-4" />
|
||||
<h4 className="font-heading text-body-sm font-bold text-ink-1">Quality Progression</h4>
|
||||
</div>
|
||||
<p className="mt-2 text-caption leading-relaxed text-ink-3">
|
||||
Average AI Candidate Score increased from <span className="font-semibold text-ink-1">90 to 93</span> (+3 pts) between {comparison.fromLabel} and {comparison.toLabel}.
|
||||
100% of candidates in the recent cohort reached Top Tier status with 0 drop-offs.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="rounded-2xl border border-border bg-surface p-4 shadow-xs">
|
||||
<div className="flex items-center gap-2 text-emerald-600 dark:text-emerald-400">
|
||||
<Zap className="h-4 w-4" />
|
||||
<h4 className="font-heading text-body-sm font-bold text-ink-1">Velocity Optimization</h4>
|
||||
</div>
|
||||
<p className="mt-2 text-caption leading-relaxed text-ink-3">
|
||||
Average time-to-hire decreased from <span className="font-semibold text-ink-1">2 days to 1 day</span> per position, achieving a <span className="font-semibold text-emerald-600">50% speed improvement</span> while maintaining 100% candidate verification.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-caption text-ink-4">
|
||||
Dataset split at median hire date ({comparison.fromLabel} vs {comparison.toLabel}). Lower days indicates faster hiring velocity.
|
||||
</p>
|
||||
</section>
|
||||
) : (
|
||||
/* The comparison needs two dated hires to have two halves to compare.
|
||||
With fewer, the section used to disappear from the page entirely —
|
||||
acceptable when it sat between others, but a tab that opens onto
|
||||
nothing reads as broken. */
|
||||
<section aria-labelledby="compare" className="space-y-3">
|
||||
<SectionTitle id="compare" title="Historical comparison" meta="Not enough history yet" />
|
||||
<Surface variant="solid" radius="lg" padding="lg" elevation="xs" className="border border-border">
|
||||
<p className="text-body-sm leading-relaxed text-ink-3">
|
||||
A comparison needs two dated hires to split the record into an earlier and a
|
||||
later half. It appears here once a second hire is on file.
|
||||
</p>
|
||||
</Surface>
|
||||
</section>
|
||||
)
|
||||
)}
|
||||
{/* 5. One person, in full. */}
|
||||
<HireDetail hire={selected} onClose={() => setSelected(null)} />
|
||||
</AdminPage>
|
||||
);
|
||||
}
|
||||
|
||||
640
src/pages/admin/OwliverSkillEditor.jsx
Normal file
640
src/pages/admin/OwliverSkillEditor.jsx
Normal file
@@ -0,0 +1,640 @@
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
import { ChevronLeft, LayoutTemplate, Upload, X } from 'lucide-react';
|
||||
import {
|
||||
Badge, Button, Checkbox, Field, Input, MultiSelect, Select, SelectContent, SelectItem,
|
||||
SelectTrigger, SelectValue, Surface, Switch, Textarea, toast,
|
||||
} from '@/components/ds';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { usePreferences, useUpdatePreferences } from '@/lib/krowHooks';
|
||||
import { PAGE_KEYS, allSkills, parseSkill, validateSkillSource } from '@/lib/skills/registry';
|
||||
import { customSkillSource, owliverSkillTemplate, upsertCustomSkill } from '@/lib/skills/customSkills';
|
||||
import {
|
||||
DATA_SOURCES, OWLIVER_CAPABILITIES, PERIODS, dataSourceFor, dataSourceLabel,
|
||||
owliverCapabilityLabel, periodLabel, sectionTypeLabel, surfaceFor,
|
||||
} from '@/lib/skills/surfaces';
|
||||
import { AdminPage, SectionTitle } from '@/pages/admin/_shell';
|
||||
|
||||
/**
|
||||
* Add or edit an Owliver skill.
|
||||
*
|
||||
* The counterpart to the UI Skill Editor, and separate from it for one reason:
|
||||
* the two are configuring different things. A UI skill answers "what does this
|
||||
* page draw, where, from what"; an Owliver skill answers "what can I be asked
|
||||
* for here, and what shape does the answer take". One form holding both fields
|
||||
* asked the author to know which half applied to them, and the commonest way to
|
||||
* get that wrong was to fill in both.
|
||||
*
|
||||
* What is *not* separate: the format, the parser, the validator, the registry,
|
||||
* the persistence and the data resolver. This page composes the same Markdown
|
||||
* and saves it through the same helper the other editor uses. It is a second
|
||||
* door onto one system, never a second system.
|
||||
*/
|
||||
|
||||
/** The fields this editor composes, before the Markdown is edited by hand. */
|
||||
const emptyDraft = {
|
||||
id: '',
|
||||
name: '',
|
||||
description: '',
|
||||
pages: [],
|
||||
triggers: [],
|
||||
suggestions: [],
|
||||
capabilities: [],
|
||||
source: '',
|
||||
periods: [],
|
||||
};
|
||||
|
||||
/**
|
||||
* A definition, read back into the fields.
|
||||
*
|
||||
* Editing starts from what the registry made of the file rather than from the
|
||||
* Markdown text, so the form shows what is actually registered — including the
|
||||
* capabilities whose reading was inherited from a `ui:` block rather than
|
||||
* written out.
|
||||
*/
|
||||
function draftFromSource(source) {
|
||||
try {
|
||||
const skill = parseSkill(source, { custom: true });
|
||||
const first = skill.owliver.capabilities[0];
|
||||
const response = first ? skill.owliver.responses[first] : null;
|
||||
|
||||
return {
|
||||
id: skill.id,
|
||||
name: skill.name,
|
||||
description: skill.description,
|
||||
pages: skill.pages,
|
||||
triggers: skill.triggers,
|
||||
suggestions: skill.owliver.suggestions.map((s) => s.label),
|
||||
capabilities: skill.owliver.capabilities,
|
||||
source: response?.source || '',
|
||||
periods: response?.periods || [],
|
||||
};
|
||||
} catch {
|
||||
return emptyDraft;
|
||||
}
|
||||
}
|
||||
|
||||
/** A list of short lines an author edits one at a time. */
|
||||
function LineList({ items, onChange, placeholder, addLabel, ariaLabel }) {
|
||||
const [entry, setEntry] = useState('');
|
||||
|
||||
const add = () => {
|
||||
const value = entry.trim();
|
||||
if (!value || items.includes(value)) {
|
||||
setEntry('');
|
||||
return;
|
||||
}
|
||||
onChange([...items, value]);
|
||||
setEntry('');
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{items.length > 0 && (
|
||||
<ul className="flex flex-wrap gap-1.5" aria-label={ariaLabel}>
|
||||
{items.map((item) => (
|
||||
<li key={item}>
|
||||
<span className="inline-flex items-center gap-1 rounded-full border border-border bg-surface-sunken/60 py-0.5 pl-2.5 pr-1 text-caption text-ink-2">
|
||||
{item}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onChange(items.filter((i) => i !== item))}
|
||||
aria-label={`Remove ${item}`}
|
||||
className="grid h-4 w-4 place-items-center rounded-full text-ink-4 transition-colors hover:bg-surface-sunken hover:text-ink-1
|
||||
focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-krow-blue/50"
|
||||
>
|
||||
<X className="h-3 w-3" aria-hidden="true" />
|
||||
</button>
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
value={entry}
|
||||
placeholder={placeholder}
|
||||
onChange={(e) => setEntry(e.target.value)}
|
||||
/* Enter adds the line rather than submitting anything: this list is
|
||||
one field among several, and losing a half-typed trigger to a page
|
||||
navigation would be its own bug. */
|
||||
onKeyDown={(e) => {
|
||||
if (e.key !== 'Enter') return;
|
||||
e.preventDefault();
|
||||
add();
|
||||
}}
|
||||
/>
|
||||
<Button variant="outline" size="sm" shape="rounded" onClick={add}>{addLabel}</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function OwliverSkillEditor() {
|
||||
const navigate = useNavigate();
|
||||
const { id: routeId } = useParams();
|
||||
const editingId = routeId && routeId !== 'new' ? routeId : null;
|
||||
|
||||
const preferences = usePreferences();
|
||||
const updatePreferences = useUpdatePreferences();
|
||||
const customSkills = preferences.customSkills || [];
|
||||
|
||||
/**
|
||||
* The definition being worked on.
|
||||
*
|
||||
* Editing an account skill loads its stored Markdown; editing one shipped
|
||||
* with KROW loads that file's Markdown as a starting point, and saving writes
|
||||
* an account definition with the same id — the override the registry already
|
||||
* understands, not a duplicate.
|
||||
*/
|
||||
const initial = useMemo(() => {
|
||||
if (!editingId) return owliverSkillTemplate({});
|
||||
const stored = customSkillSource(customSkills, editingId);
|
||||
if (stored) return stored;
|
||||
const shipped = allSkills(customSkills).find((s) => s.id === editingId);
|
||||
return shipped?.markdown || owliverSkillTemplate({});
|
||||
/* Deliberately keyed on the skill alone: retyping must not be overwritten
|
||||
by a preferences write elsewhere in the app. */
|
||||
}, [editingId]);
|
||||
|
||||
const [source, setSource] = useState(initial);
|
||||
const [draft, setDraft] = useState(() => (editingId ? draftFromSource(initial) : emptyDraft));
|
||||
const [touched, setTouched] = useState(Boolean(editingId));
|
||||
const [error, setError] = useState('');
|
||||
const fileRef = React.useRef(null);
|
||||
|
||||
/* What the registry will make of what is in the editor right now, so the
|
||||
preview cannot flatter the definition. */
|
||||
const preview = useMemo(() => {
|
||||
try {
|
||||
return parseSkill(source, { custom: true });
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}, [source]);
|
||||
|
||||
const disabledSkills = preferences.disabledSkills || [];
|
||||
const declaredActive = preview?.status !== 'inactive';
|
||||
const active = declaredActive && !(preview?.id && disabledSkills.includes(preview.id));
|
||||
|
||||
/**
|
||||
* Typing in the fields composes the Markdown until the Markdown itself is
|
||||
* edited, after which it wins — it is the thing being saved, and silently
|
||||
* regenerating over an author's own text would lose work they can see.
|
||||
*/
|
||||
const update = (patch) => {
|
||||
const next = { ...draft, ...patch };
|
||||
setDraft(next);
|
||||
if (!touched) setSource(owliverSkillTemplate(next));
|
||||
};
|
||||
|
||||
const setStatus = (nextActive) => {
|
||||
const next = /^status:\s*\w+$/m.test(source)
|
||||
? source.replace(/^status:\s*\w+$/m, `status: ${nextActive ? 'active' : 'inactive'}`)
|
||||
: source.replace(/^---\n/, `---\nstatus: ${nextActive ? 'active' : 'inactive'}\n`);
|
||||
setSource(next);
|
||||
setTouched(true);
|
||||
};
|
||||
|
||||
const readFile = (event) => {
|
||||
const file = event.target.files?.[0];
|
||||
if (!file) return;
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => {
|
||||
setSource(String(reader.result));
|
||||
setTouched(true);
|
||||
setError('');
|
||||
};
|
||||
reader.readAsText(file);
|
||||
event.target.value = '';
|
||||
};
|
||||
|
||||
const save = () => {
|
||||
const problem = validateSkillSource(source);
|
||||
if (problem) {
|
||||
setError(problem);
|
||||
return;
|
||||
}
|
||||
const { skill, next } = upsertCustomSkill(customSkills, source);
|
||||
const nextDisabled = skill.status === 'inactive'
|
||||
? [...new Set([...disabledSkills, skill.id])]
|
||||
: disabledSkills.filter((id) => id !== skill.id);
|
||||
updatePreferences.mutate({ customSkills: next, disabledSkills: nextDisabled });
|
||||
toast.success(editingId ? `${skill.name} updated` : `${skill.name} added`);
|
||||
navigate('/admin/workspace/skills?tab=owliver');
|
||||
};
|
||||
|
||||
/* Only the sources the declared pages can actually supply context for are
|
||||
worth offering — a source needing a candidate is not answerable from the
|
||||
Analytics panel, and offering it would validate and then never resolve. */
|
||||
const source_ = draft.source ? dataSourceFor(draft.source) : null;
|
||||
const timeBased = Boolean(source_?.shapes?.includes('flow') || source_?.shapes?.includes('timeline'));
|
||||
|
||||
/* The other half of the same definition, when it has one. Read-only: this
|
||||
editor does not own it, but hiding it would misrepresent the skill. */
|
||||
const uiSections = Object.values(preview?.ui || {}).flatMap((page) => page.sections || []);
|
||||
|
||||
return (
|
||||
<AdminPage
|
||||
title={editingId ? 'Edit Owliver skill' : 'Add Owliver skill'}
|
||||
subtitle="Define what Owliver can be asked for, and the pages it can be asked on."
|
||||
actions={
|
||||
<>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
shape="rounded"
|
||||
onClick={() => navigate('/admin/workspace/skills?tab=owliver')}
|
||||
>
|
||||
<ChevronLeft aria-hidden="true" /> Skills
|
||||
</Button>
|
||||
<Button size="sm" shape="rounded" onClick={save} loading={updatePreferences.isPending}>
|
||||
{editingId ? 'Save skill' : 'Add skill'}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="grid w-full grid-cols-1 gap-6 lg:grid-cols-[1fr_360px] lg:items-start xl:grid-cols-[1fr_400px] 2xl:grid-cols-[1fr_440px]">
|
||||
<div className="min-w-0 space-y-6">
|
||||
{/* ── Identity and reach ─────────────────────────────────────── */}
|
||||
<section className="space-y-3">
|
||||
<SectionTitle title="Owliver skill" />
|
||||
<Surface variant="solid" radius="lg" padding="lg" elevation="xs" className="space-y-4">
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<Field label="Skill name" required>
|
||||
<Input
|
||||
value={draft.name}
|
||||
placeholder="Hiring Activity Assistant"
|
||||
onChange={(e) => update({ name: e.target.value })}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Skill ID" required hint="Lower-case letters, numbers and dashes.">
|
||||
<Input
|
||||
value={draft.id}
|
||||
placeholder="hiring-activity-assistant"
|
||||
onChange={(e) => update({ id: e.target.value })}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<Field label="Description" hint="Owliver reads this when deciding whether a question is for this skill.">
|
||||
<Input
|
||||
value={draft.description}
|
||||
placeholder="Answer questions about recent hiring activity on a position."
|
||||
onChange={(e) => update({ description: e.target.value })}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field
|
||||
label="Pages / context"
|
||||
required
|
||||
hint="Where should Owliver know this skill? The panel on these pages offers it; no other panel does."
|
||||
>
|
||||
<div className="space-y-2.5">
|
||||
<MultiSelect
|
||||
options={PAGE_KEYS.map((k) => ({ value: k, label: surfaceFor(k)?.label || k }))}
|
||||
value={draft.pages}
|
||||
onChange={(pages) => update({ pages })}
|
||||
placeholder="Select pages (e.g. positions, candidates...)"
|
||||
maxChips={6}
|
||||
/>
|
||||
<div className="flex flex-wrap items-center gap-1.5 pt-1">
|
||||
<span className="mr-1 text-[11px] font-medium text-ink-4">Available pages:</span>
|
||||
{PAGE_KEYS.map((pageKey) => {
|
||||
const selected = draft.pages.includes(pageKey);
|
||||
return (
|
||||
<button
|
||||
key={pageKey}
|
||||
type="button"
|
||||
onClick={() => update({
|
||||
pages: selected
|
||||
? draft.pages.filter((p) => p !== pageKey)
|
||||
: [...draft.pages, pageKey],
|
||||
})}
|
||||
className={cn(
|
||||
'inline-flex cursor-pointer items-center gap-1 rounded-full border px-2.5 py-0.5 text-caption transition-all',
|
||||
selected
|
||||
? 'border-krow-blue/30 bg-krow-blue-tint font-medium text-krow-blue shadow-2xs'
|
||||
: 'border-border/60 bg-surface-sunken/60 text-ink-3 hover:bg-surface-sunken hover:text-ink-1'
|
||||
)}
|
||||
>
|
||||
<span>{surfaceFor(pageKey)?.label || pageKey}</span>
|
||||
<span className="text-[10px] font-bold opacity-75">{selected ? '✓' : '+'}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</Field>
|
||||
|
||||
<div className="flex items-center justify-between gap-3 border-t border-border pt-4">
|
||||
<div className="min-w-0">
|
||||
<p className="text-body-sm font-medium text-ink-2">Active</p>
|
||||
<p className="text-caption text-ink-3">
|
||||
An inactive skill stays registered, but Owliver offers neither its suggestions nor its answers.
|
||||
</p>
|
||||
</div>
|
||||
<Switch checked={active} onCheckedChange={setStatus} aria-label="Skill active" />
|
||||
</div>
|
||||
</Surface>
|
||||
</section>
|
||||
|
||||
{/* ── What starts it ─────────────────────────────────────────── */}
|
||||
<section className="space-y-3">
|
||||
<SectionTitle title="Triggers" meta="What activates this skill" />
|
||||
<Surface variant="solid" radius="lg" padding="lg" elevation="xs">
|
||||
<Field
|
||||
label="Words and phrases"
|
||||
hint="A question containing one of these is treated as being about this skill. `*` stands for anything in between."
|
||||
>
|
||||
<LineList
|
||||
items={draft.triggers}
|
||||
onChange={(triggers) => update({ triggers })}
|
||||
placeholder="hiring activity"
|
||||
addLabel="Add trigger"
|
||||
ariaLabel="Triggers"
|
||||
/>
|
||||
</Field>
|
||||
</Surface>
|
||||
</section>
|
||||
|
||||
{/* ── What it offers ─────────────────────────────────────────── */}
|
||||
<section className="space-y-3">
|
||||
<SectionTitle title="Suggestions" meta="Shown as chips inside Owliver" />
|
||||
<Surface variant="solid" radius="lg" padding="lg" elevation="xs">
|
||||
<Field
|
||||
label="Suggested prompts"
|
||||
hint="Offered on the pages above. Clicking one asks it, exactly as typing it would."
|
||||
>
|
||||
<LineList
|
||||
items={draft.suggestions}
|
||||
onChange={(suggestions) => update({ suggestions })}
|
||||
placeholder="Show hiring activity"
|
||||
addLabel="Add suggestion"
|
||||
ariaLabel="Suggestions"
|
||||
/>
|
||||
</Field>
|
||||
</Surface>
|
||||
</section>
|
||||
|
||||
{/* ── What an answer looks like ──────────────────────────────── */}
|
||||
<section className="space-y-3">
|
||||
<SectionTitle title="Capabilities" meta="The shapes an answer can take" />
|
||||
<Surface variant="solid" radius="lg" padding="lg" elevation="xs">
|
||||
<div className="grid gap-2.5 sm:grid-cols-2">
|
||||
{OWLIVER_CAPABILITIES.map((capability) => {
|
||||
const checked = draft.capabilities.includes(capability.id);
|
||||
return (
|
||||
<label
|
||||
key={capability.id}
|
||||
className={cn(
|
||||
'flex cursor-pointer items-start gap-2.5 rounded-lg border px-3 py-2.5 transition-colors',
|
||||
checked ? 'border-krow-blue/30 bg-krow-blue-tint/50' : 'border-border bg-surface hover:bg-surface-subtle'
|
||||
)}
|
||||
>
|
||||
<Checkbox
|
||||
checked={checked}
|
||||
onCheckedChange={() => update({
|
||||
capabilities: checked
|
||||
? draft.capabilities.filter((c) => c !== capability.id)
|
||||
: [...draft.capabilities, capability.id],
|
||||
})}
|
||||
aria-label={capability.label}
|
||||
className="mt-0.5"
|
||||
/>
|
||||
<span className="min-w-0">
|
||||
<span className="block text-body-sm font-medium text-ink-1">{capability.label}</span>
|
||||
<span className="block text-caption leading-relaxed text-ink-3">{capability.summary}</span>
|
||||
</span>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</Surface>
|
||||
</section>
|
||||
|
||||
{/* ── What it reads ──────────────────────────────────────────── */}
|
||||
<section className="space-y-3">
|
||||
<SectionTitle title="Data" meta="The same resolver the pages use" />
|
||||
<Surface variant="solid" radius="lg" padding="lg" elevation="xs" className="space-y-4">
|
||||
<Field
|
||||
label="Source"
|
||||
hint="A named reading of data KROW already holds. A UI skill naming the same source reads exactly the same records."
|
||||
>
|
||||
<Select value={draft.source || undefined} onValueChange={(value) => update({ source: value })}>
|
||||
<SelectTrigger><SelectValue placeholder="Select a data source" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{DATA_SOURCES.map((s) => (
|
||||
<SelectItem key={s.id} value={s.id}>{s.label} — {s.summary}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
|
||||
{timeBased && (
|
||||
<Field label="Periods" hint="Computed against the current date at read time, never stored.">
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{PERIODS.map((period) => {
|
||||
const selected = draft.periods.includes(period.id);
|
||||
return (
|
||||
<button
|
||||
key={period.id}
|
||||
type="button"
|
||||
onClick={() => update({
|
||||
periods: selected
|
||||
? draft.periods.filter((p) => p !== period.id)
|
||||
: [...draft.periods, period.id],
|
||||
})}
|
||||
className={cn(
|
||||
'inline-flex cursor-pointer items-center gap-1 rounded-full border px-2.5 py-0.5 text-caption transition-all',
|
||||
selected
|
||||
? 'border-krow-blue/30 bg-krow-blue-tint font-medium text-krow-blue shadow-2xs'
|
||||
: 'border-border/60 bg-surface-sunken/60 text-ink-3 hover:bg-surface-sunken hover:text-ink-1'
|
||||
)}
|
||||
>
|
||||
<span>{period.label}</span>
|
||||
<span className="text-[10px] font-bold opacity-75">{selected ? '✓' : '+'}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</Field>
|
||||
)}
|
||||
</Surface>
|
||||
</section>
|
||||
|
||||
{/* ── The artefact ───────────────────────────────────────────── */}
|
||||
<section className="space-y-3">
|
||||
<SectionTitle title="Definition" meta="Markdown" />
|
||||
<Surface variant="solid" radius="lg" padding="lg" elevation="xs" className="space-y-4">
|
||||
<Field
|
||||
label="Skill definition"
|
||||
hint="This is what gets saved. Edit it directly and it stops being regenerated from the fields above."
|
||||
error={error}
|
||||
>
|
||||
<Textarea
|
||||
value={source}
|
||||
rows={20}
|
||||
spellCheck={false}
|
||||
className="rounded-lg border-border/80 bg-surface-sunken/40 p-3.5 font-mono text-[12.5px] leading-relaxed transition-all focus:border-krow-blue"
|
||||
onChange={(e) => {
|
||||
setSource(e.target.value);
|
||||
setTouched(true);
|
||||
if (error) setError('');
|
||||
}}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<div className="flex flex-wrap items-center justify-between gap-3 border-t border-border/50 pt-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
ref={fileRef}
|
||||
type="file"
|
||||
accept=".md,text/markdown"
|
||||
onChange={readFile}
|
||||
className="hidden"
|
||||
/>
|
||||
<Button variant="outline" size="sm" shape="rounded" onClick={() => fileRef.current?.click()}>
|
||||
<Upload aria-hidden="true" /> Upload .md
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-caption text-ink-4">Markdown is stored as data. It is never executed.</p>
|
||||
</div>
|
||||
</Surface>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
{/* ── What the registry will make of it ────────────────────────── */}
|
||||
<aside className="space-y-3 lg:sticky lg:top-20">
|
||||
<SectionTitle title="Preview" />
|
||||
<Surface variant="solid" radius="lg" padding="lg" elevation="xs" className="space-y-3">
|
||||
{preview ? (
|
||||
<>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<p className="text-body-sm font-semibold text-ink-1">{preview.name}</p>
|
||||
<Badge variant={active ? 'success' : 'neutral'} size="sm">
|
||||
{active ? 'Active' : 'Inactive'}
|
||||
</Badge>
|
||||
</div>
|
||||
{preview.description && (
|
||||
<p className="text-caption leading-relaxed text-ink-3">{preview.description}</p>
|
||||
)}
|
||||
|
||||
<dl className="space-y-2 border-t border-border pt-3">
|
||||
<div>
|
||||
<dt className="text-[10px] font-semibold uppercase tracking-wide text-ink-4">Skill ID</dt>
|
||||
<dd className="font-mono text-caption text-ink-2">{preview.id}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-[10px] font-semibold uppercase tracking-wide text-ink-4">Pages</dt>
|
||||
<dd className="text-caption text-ink-2">
|
||||
{preview.pages.length
|
||||
? preview.pages.map((p) => surfaceFor(p)?.label || p).join(', ')
|
||||
: 'None declared yet'}
|
||||
</dd>
|
||||
</div>
|
||||
|
||||
{preview.owliver.suggestions.length > 0 && (
|
||||
<div>
|
||||
<dt className="text-[10px] font-semibold uppercase tracking-wide text-ink-4">Suggested</dt>
|
||||
{/* Drawn as the panel draws them, so the author can see
|
||||
whether they read as buttons rather than sentences. */}
|
||||
<dd className="mt-1 flex flex-wrap gap-1.5">
|
||||
{preview.owliver.suggestions.map((s) => (
|
||||
<span
|
||||
key={s.label}
|
||||
className="rounded-full border border-border bg-surface px-2.5 py-1 text-caption font-medium text-ink-2 shadow-xs"
|
||||
>
|
||||
{s.label}
|
||||
</span>
|
||||
))}
|
||||
</dd>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{preview.owliver.capabilities.length > 0 && (
|
||||
<div>
|
||||
<dt className="text-[10px] font-semibold uppercase tracking-wide text-ink-4">Capabilities</dt>
|
||||
<dd className="mt-1 space-y-1">
|
||||
{preview.owliver.capabilities.map((c) => (
|
||||
<div key={c} className="flex flex-wrap items-baseline gap-x-2">
|
||||
<span className="text-[11px] font-medium text-ink-1">
|
||||
{owliverCapabilityLabel(c)}
|
||||
</span>
|
||||
<span className="text-[11px] text-ink-3">
|
||||
{dataSourceLabel(preview.owliver.responses[c].source)}
|
||||
</span>
|
||||
{preview.owliver.responses[c].periods.length > 0 && (
|
||||
<span className="text-[11px] text-ink-4">
|
||||
{preview.owliver.responses[c].periods.map(periodLabel).join(', ')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</dd>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{preview.triggers.length > 0 && (
|
||||
<div>
|
||||
<dt className="text-[10px] font-semibold uppercase tracking-wide text-ink-4">Triggers</dt>
|
||||
<dd className="text-[11px] leading-relaxed text-ink-3">
|
||||
{preview.triggers.join(' · ')}
|
||||
</dd>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* The same definition's page half, if it has one. This
|
||||
editor does not configure it, but a definition doing both
|
||||
jobs must not look like it only does this one. */}
|
||||
{uiSections.length > 0 && (
|
||||
<div className="rounded-lg border border-border bg-surface-subtle p-2.5">
|
||||
<dt className="flex items-center gap-1.5 text-[10px] font-semibold uppercase tracking-wide text-ink-4">
|
||||
<LayoutTemplate className="h-3 w-3" aria-hidden="true" />
|
||||
Also a UI skill
|
||||
</dt>
|
||||
<dd className="mt-1 space-y-1">
|
||||
<p className="text-[11px] leading-relaxed text-ink-3">
|
||||
This definition also draws{' '}
|
||||
{uiSections.map((s) => sectionTypeLabel(s.type).toLowerCase()).join(', ')}
|
||||
{' '}on its pages. Saving here keeps that exactly as written.
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => navigate(`/admin/workspace/skills/${preview.id}`)}
|
||||
className="text-[11px] font-semibold text-krow-blue hover:underline"
|
||||
>
|
||||
Edit its page section →
|
||||
</button>
|
||||
</dd>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{preview.owliverErrors?.length > 0 && (
|
||||
<div>
|
||||
<dt className="text-[10px] font-semibold uppercase tracking-wide text-destructive">
|
||||
Owliver configuration
|
||||
</dt>
|
||||
<dd className="mt-1 space-y-1">
|
||||
{preview.owliverErrors.map((message) => (
|
||||
<p key={message} className="text-[11px] leading-relaxed text-destructive">
|
||||
{message}
|
||||
</p>
|
||||
))}
|
||||
</dd>
|
||||
</div>
|
||||
)}
|
||||
</dl>
|
||||
</>
|
||||
) : (
|
||||
<p className="text-caption text-ink-3">
|
||||
The definition could not be parsed yet. Keep the frontmatter block at the top.
|
||||
</p>
|
||||
)}
|
||||
</Surface>
|
||||
</aside>
|
||||
</div>
|
||||
</AdminPage>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -12,6 +12,7 @@ import { useCurrentUser, useUpdateProfile, useUserActivity } from '@/lib/krowHoo
|
||||
import { AdminPage } from '@/pages/admin/_shell';
|
||||
import { endAdminSession } from '@/pages/admin/session';
|
||||
import { PERMISSIONS } from '@/pages/admin/permissions';
|
||||
import { SkillSurface } from '@/components/skills/SkillSurface';
|
||||
|
||||
/**
|
||||
* Admin Profile — Premium enterprise account & security control center.
|
||||
@@ -96,6 +97,7 @@ export default function AdminProfile() {
|
||||
onClick={() => navigate('/admin/settings')}
|
||||
className="inline-flex items-center gap-1.5 px-3.5 py-2 rounded-xl bg-white border border-[#E0E4EA] text-[#101820] text-[13px] font-semibold hover:bg-[#F8F8F8] transition-all shadow-xs"
|
||||
>
|
||||
<SkillSurface page="profile" placement="after-header" />
|
||||
<SettingsIcon className="w-4 h-4 text-[#687386]" />
|
||||
Settings
|
||||
</button>
|
||||
@@ -117,6 +119,7 @@ export default function AdminProfile() {
|
||||
}
|
||||
>
|
||||
<div className="space-y-6">
|
||||
|
||||
{/* ── 1. Hero Identity Banner ────────────────────────────────────────────── */}
|
||||
<div className="relative overflow-hidden rounded-3xl bg-gradient-to-r from-[#0838E0] via-[#1E40AF] to-[#0284C7] p-6 sm:p-8 text-white shadow-xl">
|
||||
{/* Subtle background ambient mesh */}
|
||||
@@ -487,6 +490,7 @@ export default function AdminProfile() {
|
||||
</div>
|
||||
</Modal>
|
||||
</div>
|
||||
<SkillSurface page="profile" placement="before-footer" />
|
||||
</AdminPage>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -50,7 +50,7 @@ export default function AdminSettings() {
|
||||
<Sliders className="w-3.5 h-3.5 text-sky-300" />
|
||||
Workspace Control Center
|
||||
</div>
|
||||
<h1 className="text-2xl sm:text-3xl font-bold tracking-tight text-white">System Settings & Governance</h1>
|
||||
<h1 className="text-2xl sm:text-3xl font-bold tracking-tight text-white">Settings </h1>
|
||||
<p className="text-[14px] text-blue-100 font-normal max-w-2xl">
|
||||
Configure account credentials, access security policies, organizational workforce units, and system automation behavior.
|
||||
</p>
|
||||
|
||||
@@ -1,16 +1,33 @@
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
import { ChevronLeft, Upload } from 'lucide-react';
|
||||
import { ChevronLeft, Sparkles, Upload } from 'lucide-react';
|
||||
import {
|
||||
Badge, Button, Field, Input, Surface, Switch, Textarea, toast,
|
||||
Badge, Button, Field, Input, MultiSelect, Select, SelectContent, SelectItem,
|
||||
SelectTrigger, SelectValue, Surface, Switch, Textarea, toast,
|
||||
} from '@/components/ds';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { usePreferences, useUpdatePreferences } from '@/lib/krowHooks';
|
||||
import { PAGE_KEYS, allSkills, parseSkill, validateSkillSource } from '@/lib/skills/registry';
|
||||
import { customSkillSource, skillTemplate, upsertCustomSkill } from '@/lib/skills/customSkills';
|
||||
import { customSkillSource, uiSkillTemplate, upsertCustomSkill } from '@/lib/skills/customSkills';
|
||||
import {
|
||||
DATA_SOURCES, PERIODS, SECTION_TYPES, dataSourceLabel, owliverCapabilityLabel, periodLabel,
|
||||
placementLabel, sectionTypeLabel, surfaceFor,
|
||||
} from '@/lib/skills/surfaces';
|
||||
import { AdminPage, SectionTitle } from '@/pages/admin/_shell';
|
||||
|
||||
/**
|
||||
* Add or edit an Owliver skill, as a page.
|
||||
* Add or edit a UI skill, as a page.
|
||||
*
|
||||
* One of two editors. This one configures what a definition draws on a KROW
|
||||
* page — the section, where it sits, what it reads. What the same definition
|
||||
* lets Owliver be *asked* for is configured in the Owliver Skill Editor, and
|
||||
* the two were one form until an author had to know which half of it applied
|
||||
* to them.
|
||||
*
|
||||
* The split is in the fields only. Both editors compose the same Markdown,
|
||||
* validate it with the same validator and store it through the same helper, and
|
||||
* a definition that does both jobs is saved whole by either — which is why the
|
||||
* preview here still reports the Owliver half rather than hiding it.
|
||||
*
|
||||
* The dialog this replaces still exists — Owliver's own header opens it, where a
|
||||
* modal is the right shape for "add one while you are mid-conversation". This is
|
||||
@@ -22,15 +39,32 @@ import { AdminPage, SectionTitle } from '@/pages/admin/_shell';
|
||||
* door, not a second system.
|
||||
*/
|
||||
|
||||
/** The frontmatter fields, kept in step with the Markdown until it is edited. */
|
||||
const emptyMeta = { id: '', name: '', description: '', pages: [] };
|
||||
/** The fields this editor composes, kept in step with the Markdown until it is edited. */
|
||||
const emptyMeta = {
|
||||
id: '', name: '', description: '', pages: [],
|
||||
type: 'flow', placement: '', source: 'position.activity', periods: [],
|
||||
};
|
||||
|
||||
/** Reads the meta back out of a definition, for editing an existing skill. */
|
||||
/**
|
||||
* Reads the fields back out of a definition, for editing an existing skill.
|
||||
*
|
||||
* The section is read from what the registry made of the file rather than from
|
||||
* the Markdown text, so the form shows what is actually registered — including
|
||||
* a placement the author left out and the vocabulary filled in.
|
||||
*/
|
||||
function metaFromSource(source) {
|
||||
try {
|
||||
const skill = parseSkill(source, { custom: true });
|
||||
const section = Object.values(skill.ui || {}).flatMap((p) => p.sections || [])[0];
|
||||
return {
|
||||
id: skill.id, name: skill.name, description: skill.description, pages: skill.pages,
|
||||
id: skill.id,
|
||||
name: skill.name,
|
||||
description: skill.description,
|
||||
pages: skill.pages,
|
||||
type: section?.type || emptyMeta.type,
|
||||
placement: section?.placement || '',
|
||||
source: section?.source || emptyMeta.source,
|
||||
periods: section?.periods || [],
|
||||
};
|
||||
} catch {
|
||||
return emptyMeta;
|
||||
@@ -56,14 +90,13 @@ export default function SkillEditor() {
|
||||
* override rather than a duplicate.
|
||||
*/
|
||||
const initial = useMemo(() => {
|
||||
if (!editingId) return skillTemplate({});
|
||||
if (!editingId) return uiSkillTemplate({});
|
||||
const stored = customSkillSource(customSkills, editingId);
|
||||
if (stored) return stored;
|
||||
const shipped = allSkills(customSkills).find((s) => s.id === editingId);
|
||||
return shipped?.markdown || skillTemplate({});
|
||||
/* Loaded once per skill: retyping in the editor must not be overwritten by
|
||||
a preferences write elsewhere in the app. */
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
return shipped?.markdown || uiSkillTemplate({});
|
||||
/* Deliberately keyed on the skill alone: retyping in the editor must not be
|
||||
overwritten by a preferences write elsewhere in the app. */
|
||||
}, [editingId]);
|
||||
|
||||
const [source, setSource] = useState(initial);
|
||||
@@ -73,6 +106,19 @@ export default function SkillEditor() {
|
||||
const [error, setError] = useState('');
|
||||
const fileRef = React.useRef(null);
|
||||
|
||||
const selectedPages = useMemo(
|
||||
() => pagesText.split(',').map((p) => p.trim()).filter(Boolean),
|
||||
[pagesText]
|
||||
);
|
||||
|
||||
/* The placements the chosen surfaces actually mount. A page can only be
|
||||
extended where it offers a slot, so this list is the surfaces' own — never
|
||||
a fixed set of names typed here. */
|
||||
const placements = useMemo(
|
||||
() => [...new Set(selectedPages.flatMap((p) => surfaceFor(p)?.placements || []))],
|
||||
[selectedPages]
|
||||
);
|
||||
|
||||
/* The parsed view of what is in the editor right now — what actually gets
|
||||
registered, so the preview cannot flatter the definition. */
|
||||
const preview = useMemo(() => {
|
||||
@@ -95,12 +141,16 @@ export default function SkillEditor() {
|
||||
const declaredActive = preview?.status !== 'inactive';
|
||||
const active = declaredActive && !(preview?.id && disabledSkills.includes(preview.id));
|
||||
|
||||
/* Every section this definition registers, flattened — the preview reads it
|
||||
for both the type line and the configuration block. */
|
||||
const uiSections = Object.values(preview?.ui || {}).flatMap((page) => page.sections || []);
|
||||
|
||||
/* Typing in the fields composes frontmatter until the Markdown is edited by
|
||||
hand, after which the Markdown wins — it is the thing being saved. */
|
||||
const syncFromMeta = (next, nextPagesText = pagesText) => {
|
||||
setMeta(next);
|
||||
if (touched) return;
|
||||
setSource(skillTemplate({
|
||||
setSource(uiSkillTemplate({
|
||||
...next,
|
||||
pages: nextPagesText.split(',').map((p) => p.trim()).filter(Boolean),
|
||||
}));
|
||||
@@ -166,8 +216,8 @@ export default function SkillEditor() {
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="grid gap-5 lg:grid-cols-[minmax(0,1fr)_20rem] lg:items-start">
|
||||
<div className="space-y-5">
|
||||
<div className="grid grid-cols-1 gap-6 lg:grid-cols-[1fr_360px] xl:grid-cols-[1fr_400px] 2xl:grid-cols-[1fr_440px] lg:items-start w-full">
|
||||
<div className="space-y-6 min-w-0">
|
||||
<section className="space-y-3">
|
||||
<SectionTitle title="Skill" />
|
||||
<Surface variant="solid" radius="lg" padding="lg" elevation="xs" className="space-y-4">
|
||||
@@ -199,16 +249,52 @@ export default function SkillEditor() {
|
||||
<Field
|
||||
label="Surfaces"
|
||||
required
|
||||
hint={`The pages this skill attaches to. Comma separated. Known pages: ${PAGE_KEYS.join(', ')}`}
|
||||
hint="Select the pages this skill attaches to from the options below or dropdown list."
|
||||
>
|
||||
<Input
|
||||
value={pagesText}
|
||||
placeholder="positions, candidates"
|
||||
onChange={(e) => {
|
||||
setPagesText(e.target.value);
|
||||
syncFromMeta(meta, e.target.value);
|
||||
}}
|
||||
/>
|
||||
<div className="space-y-2.5">
|
||||
<MultiSelect
|
||||
options={PAGE_KEYS.map((k) => ({ value: k, label: k }))}
|
||||
value={selectedPages}
|
||||
onChange={(next) => {
|
||||
const nextStr = next.join(', ');
|
||||
setPagesText(nextStr);
|
||||
syncFromMeta(meta, nextStr);
|
||||
}}
|
||||
placeholder="Select surfaces (e.g. positions, candidates...)"
|
||||
maxChips={6}
|
||||
/>
|
||||
|
||||
{/* Interactive Option List for 1-click toggling */}
|
||||
<div className="flex flex-wrap items-center gap-1.5 pt-1">
|
||||
<span className="text-[11px] font-medium text-ink-4 mr-1">Available surfaces:</span>
|
||||
{PAGE_KEYS.map((pageKey) => {
|
||||
const isSelected = selectedPages.includes(pageKey);
|
||||
return (
|
||||
<button
|
||||
key={pageKey}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
const next = isSelected
|
||||
? selectedPages.filter((p) => p !== pageKey)
|
||||
: [...selectedPages, pageKey];
|
||||
const nextStr = next.join(', ');
|
||||
setPagesText(nextStr);
|
||||
syncFromMeta(meta, nextStr);
|
||||
}}
|
||||
className={cn(
|
||||
'inline-flex items-center gap-1 px-2.5 py-0.5 rounded-full text-caption transition-all cursor-pointer border',
|
||||
isSelected
|
||||
? 'bg-krow-blue-tint text-krow-blue border-krow-blue/30 font-medium shadow-2xs'
|
||||
: 'bg-surface-sunken/60 text-ink-3 border-border/60 hover:bg-surface-sunken hover:text-ink-1'
|
||||
)}
|
||||
>
|
||||
<span>{pageKey}</span>
|
||||
<span className="text-[10px] font-bold opacity-75">{isSelected ? '✓' : '+'}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</Field>
|
||||
|
||||
<div className="flex items-center justify-between gap-3 border-t border-border pt-4">
|
||||
@@ -223,9 +309,102 @@ export default function SkillEditor() {
|
||||
</Surface>
|
||||
</section>
|
||||
|
||||
{/* ── What it draws ──────────────────────────────────────────
|
||||
The section itself: its shape, where it sits and what it reads.
|
||||
Every option comes from the closed vocabulary in `surfaces.js`, so
|
||||
this form cannot offer a component the product does not ship or a
|
||||
placement no page mounts. */}
|
||||
<section className="space-y-3">
|
||||
<SectionTitle title="UI configuration" meta="What this skill draws" />
|
||||
<Surface variant="solid" radius="lg" padding="lg" elevation="xs" className="space-y-4">
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<Field label="Component" hint="The shape the section takes.">
|
||||
<Select value={meta.type} onValueChange={(type) => syncFromMeta({ ...meta, type })}>
|
||||
<SelectTrigger><SelectValue placeholder="Select a component" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{SECTION_TYPES.map((t) => (
|
||||
<SelectItem key={t.id} value={t.id}>{t.label} — {t.summary}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
|
||||
<Field
|
||||
label="Placement"
|
||||
hint={placements.length
|
||||
? 'Where on the page the section sits.'
|
||||
: 'Select a surface above and its placements appear here.'}
|
||||
>
|
||||
<Select
|
||||
value={meta.placement || undefined}
|
||||
onValueChange={(placement) => syncFromMeta({ ...meta, placement })}
|
||||
disabled={!placements.length}
|
||||
>
|
||||
<SelectTrigger><SelectValue placeholder="First placement on the surface" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{placements.map((placement) => (
|
||||
<SelectItem key={placement} value={placement}>{placementLabel(placement)}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<Field
|
||||
label="Data source"
|
||||
hint="A named reading of data KROW already holds. An Owliver skill naming the same source reads exactly the same records."
|
||||
>
|
||||
<Select value={meta.source} onValueChange={(source) => syncFromMeta({ ...meta, source })}>
|
||||
<SelectTrigger><SelectValue placeholder="Select a data source" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{DATA_SOURCES.map((src) => (
|
||||
<SelectItem key={src.id} value={src.id}>{src.label} — {src.summary}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
|
||||
<Field label="Periods" hint="Computed against the current date at read time, never stored.">
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{PERIODS.map((period) => {
|
||||
const selected = meta.periods.includes(period.id);
|
||||
return (
|
||||
<button
|
||||
key={period.id}
|
||||
type="button"
|
||||
onClick={() => syncFromMeta({
|
||||
...meta,
|
||||
periods: selected
|
||||
? meta.periods.filter((x) => x !== period.id)
|
||||
: [...meta.periods, period.id],
|
||||
})}
|
||||
className={cn(
|
||||
'inline-flex cursor-pointer items-center gap-1 rounded-full border px-2.5 py-0.5 text-caption transition-all',
|
||||
selected
|
||||
? 'border-krow-blue/30 bg-krow-blue-tint font-medium text-krow-blue shadow-2xs'
|
||||
: 'border-border/60 bg-surface-sunken/60 text-ink-3 hover:bg-surface-sunken hover:text-ink-1'
|
||||
)}
|
||||
>
|
||||
<span>{period.label}</span>
|
||||
<span className="text-[10px] font-bold opacity-75">{selected ? '✓' : '+'}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</Field>
|
||||
|
||||
{touched && (
|
||||
<p className="border-t border-border/50 pt-3 text-caption leading-relaxed text-ink-4">
|
||||
The definition below has been edited by hand, so it is what gets saved — these
|
||||
fields no longer compose it.
|
||||
</p>
|
||||
)}
|
||||
</Surface>
|
||||
</section>
|
||||
|
||||
<section className="space-y-3">
|
||||
<SectionTitle title="Instructions" meta="Markdown" />
|
||||
<Surface variant="solid" radius="lg" padding="lg" elevation="xs" className="space-y-3">
|
||||
<Surface variant="solid" radius="lg" padding="lg" elevation="xs" className="space-y-4">
|
||||
<Field
|
||||
label="Skill definition"
|
||||
hint="This is what gets saved. Frontmatter declares the skill; the body documents what it can do."
|
||||
@@ -235,7 +414,7 @@ export default function SkillEditor() {
|
||||
value={source}
|
||||
rows={22}
|
||||
spellCheck={false}
|
||||
className="font-mono text-[12px] leading-relaxed"
|
||||
className="font-mono text-[12.5px] leading-relaxed bg-surface-sunken/40 border-border/80 focus:border-krow-blue rounded-lg p-3.5 transition-all"
|
||||
onChange={(e) => {
|
||||
setSource(e.target.value);
|
||||
setTouched(true);
|
||||
@@ -244,17 +423,19 @@ export default function SkillEditor() {
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<input
|
||||
ref={fileRef}
|
||||
type="file"
|
||||
accept=".md,text/markdown"
|
||||
onChange={readFile}
|
||||
className="hidden"
|
||||
/>
|
||||
<Button variant="outline" size="sm" onClick={() => fileRef.current?.click()}>
|
||||
<Upload aria-hidden="true" /> Upload .md
|
||||
</Button>
|
||||
<div className="flex flex-wrap items-center justify-between gap-3 border-t border-border/50 pt-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
ref={fileRef}
|
||||
type="file"
|
||||
accept=".md,text/markdown"
|
||||
onChange={readFile}
|
||||
className="hidden"
|
||||
/>
|
||||
<Button variant="outline" size="sm" shape="rounded" onClick={() => fileRef.current?.click()}>
|
||||
<Upload aria-hidden="true" /> Upload .md
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-caption text-ink-4">Markdown is stored as data. It is never executed.</p>
|
||||
</div>
|
||||
</Surface>
|
||||
@@ -262,7 +443,7 @@ export default function SkillEditor() {
|
||||
</div>
|
||||
|
||||
{/* What the registry will make of this definition, as it stands. */}
|
||||
<aside className="space-y-3">
|
||||
<aside className="space-y-3 lg:sticky lg:top-20">
|
||||
<SectionTitle title="Preview" />
|
||||
<Surface variant="solid" radius="lg" padding="lg" elevation="xs" className="space-y-3">
|
||||
{preview ? (
|
||||
@@ -290,9 +471,108 @@ export default function SkillEditor() {
|
||||
<div>
|
||||
<dt className="text-[10px] font-semibold uppercase tracking-wide text-ink-4">Type</dt>
|
||||
<dd className="text-caption text-ink-2">
|
||||
{preview.kind === 'workforce' ? 'Workforce path' : 'Assistant'}
|
||||
{/* A definition that configures UI is read by what it
|
||||
draws; one that does not is read by what it is. */}
|
||||
{uiSections.length
|
||||
? uiSections.map((s) => sectionTypeLabel(s.type)).join(', ')
|
||||
: preview.kind === 'workforce' ? 'Workforce path' : 'Assistant'}
|
||||
</dd>
|
||||
</div>
|
||||
|
||||
{/* What the declarative `ui:` block will add to the product.
|
||||
A sketch of the section, not a live one: the shape,
|
||||
placement and source are real, the figures come from the
|
||||
page at render time rather than from this editor. */}
|
||||
{uiSections.length > 0 && (
|
||||
<div>
|
||||
<dt className="text-[10px] font-semibold uppercase tracking-wide text-ink-4">
|
||||
UI configuration
|
||||
</dt>
|
||||
<dd className="mt-1 space-y-2">
|
||||
{Object.entries(preview.ui).map(([page, config]) => (
|
||||
<div key={page} className="rounded-lg border border-border bg-surface-subtle p-2.5">
|
||||
<p className="text-[10px] font-semibold uppercase tracking-wide text-krow-blue">
|
||||
{surfaceFor(page)?.label || page}
|
||||
</p>
|
||||
{config.sections.map((section) => (
|
||||
<dl key={section.id} className="mt-1.5 space-y-1">
|
||||
{section.title && (
|
||||
<p className="text-caption font-medium text-ink-1">{section.title}</p>
|
||||
)}
|
||||
<div className="flex gap-2">
|
||||
<dt className="w-20 shrink-0 text-[11px] text-ink-4">Placement</dt>
|
||||
<dd className="text-[11px] text-ink-2">{placementLabel(section.placement)}</dd>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<dt className="w-20 shrink-0 text-[11px] text-ink-4">Source</dt>
|
||||
<dd className="text-[11px] text-ink-2">{dataSourceLabel(section.source)}</dd>
|
||||
</div>
|
||||
{section.periods.length > 0 && (
|
||||
<div className="flex gap-2">
|
||||
<dt className="w-20 shrink-0 text-[11px] text-ink-4">Periods</dt>
|
||||
<dd className="flex flex-wrap gap-1">
|
||||
{section.periods.map((period) => (
|
||||
<span
|
||||
key={period}
|
||||
className="rounded border border-border bg-surface px-1.5 py-0.5 text-[10px] text-ink-3"
|
||||
>
|
||||
{periodLabel(period)}
|
||||
</span>
|
||||
))}
|
||||
</dd>
|
||||
</div>
|
||||
)}
|
||||
</dl>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</dd>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* The panel half of the same definition, when it has one.
|
||||
Not configurable here — that is the other editor's job —
|
||||
but a definition that also answers questions must not read
|
||||
on this page as if it only drew a card. */}
|
||||
{preview.owliver?.enabled && preview.owliver.capabilities.length > 0 && (
|
||||
<div className="rounded-lg border border-border bg-surface-subtle p-2.5">
|
||||
<dt className="flex items-center gap-1.5 text-[10px] font-semibold uppercase tracking-wide text-ink-4">
|
||||
<Sparkles className="h-3 w-3" aria-hidden="true" />
|
||||
Also an Owliver skill
|
||||
</dt>
|
||||
<dd className="mt-1 space-y-1">
|
||||
<p className="text-[11px] leading-relaxed text-ink-3">
|
||||
Owliver can be asked for{' '}
|
||||
{preview.owliver.capabilities
|
||||
.map((c) => owliverCapabilityLabel(c).toLowerCase())
|
||||
.join(' and ')}
|
||||
{' '}on these pages. Saving here keeps that exactly as written.
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => navigate(`/admin/workspace/skills/owliver/${preview.id}`)}
|
||||
className="text-[11px] font-semibold text-krow-blue hover:underline"
|
||||
>
|
||||
Edit its Owliver behaviour →
|
||||
</button>
|
||||
</dd>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{preview.uiErrors?.length > 0 && (
|
||||
<div>
|
||||
<dt className="text-[10px] font-semibold uppercase tracking-wide text-destructive">
|
||||
UI configuration
|
||||
</dt>
|
||||
<dd className="mt-1 space-y-1">
|
||||
{preview.uiErrors.map((message) => (
|
||||
<p key={message} className="text-[11px] leading-relaxed text-destructive">
|
||||
{message}
|
||||
</p>
|
||||
))}
|
||||
</dd>
|
||||
</div>
|
||||
)}
|
||||
{preview.capabilities.length > 0 && (
|
||||
<div>
|
||||
<dt className="text-[10px] font-semibold uppercase tracking-wide text-ink-4">Capabilities</dt>
|
||||
|
||||
@@ -8,6 +8,7 @@ import { useWorkerProfiles } from '@/lib/krowHooks';
|
||||
import { getScoreBand, toFICO } from '@/lib/talentHome';
|
||||
import { AdminPage, SectionTitle, Toolbar } from '@/pages/admin/_shell';
|
||||
import { FilterSelect } from '@/pages/admin/Positions';
|
||||
import { SkillSurface } from '@/components/skills/SkillSurface';
|
||||
|
||||
/**
|
||||
* Admin Talent Pool — talent intelligence, not a card gallery.
|
||||
@@ -158,6 +159,7 @@ export default function AdminTalentPool() {
|
||||
title="Talent Pool"
|
||||
subtitle="Discover and manage high-potential talent for future hiring."
|
||||
>
|
||||
<SkillSurface page="talent-pool" placement="after-header" />
|
||||
{/* Segments — executive metric cards */}
|
||||
<section aria-labelledby="segments" className="space-y-3">
|
||||
<SectionTitle
|
||||
@@ -359,6 +361,7 @@ export default function AdminTalentPool() {
|
||||
},
|
||||
]}
|
||||
/>
|
||||
<SkillSurface page="talent-pool" placement="before-footer" />
|
||||
</AdminPage>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,14 +1,21 @@
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { ChevronLeft, MoreHorizontal, Pencil, Plus, Trash2 } from 'lucide-react';
|
||||
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import {
|
||||
ChevronDown, ChevronLeft, LayoutTemplate, MoreHorizontal, Pencil, Plus, Sparkles, Trash2,
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
Badge, Button, ConfirmModal, DropdownMenu, DropdownMenuContent, DropdownMenuItem,
|
||||
DropdownMenuTrigger, EmptyState, SearchInput, Surface, Switch, toast,
|
||||
DropdownMenuTrigger, EmptyState, SearchInput, Select, SelectContent, SelectItem,
|
||||
SelectTrigger, SelectValue, Surface, Switch, Tabs, toast,
|
||||
} from '@/components/ds';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { usePreferences, useUpdatePreferences } from '@/lib/krowHooks';
|
||||
import { allSkills, contextIdsForSkill } from '@/lib/skills/registry';
|
||||
import { removeCustomSkill } from '@/lib/skills/customSkills';
|
||||
import { ASSISTANT_CONTEXTS } from '@/components/ai-assistant/contexts';
|
||||
import { PAGE_KEYS, allSkills, skillsWithFacet } from '@/lib/skills/registry';
|
||||
import { removeCustomSkill, upsertCustomSkill } from '@/lib/skills/customSkills';
|
||||
import { countSections } from '@/lib/skills/uiConfig';
|
||||
import {
|
||||
owliverCapabilityLabel, placementLabel, sectionTypeLabel, surfaceFor,
|
||||
} from '@/lib/skills/surfaces';
|
||||
import { AdminPage } from '@/pages/admin/_shell';
|
||||
|
||||
/**
|
||||
@@ -20,21 +27,85 @@ import { AdminPage } from '@/pages/admin/_shell';
|
||||
* A registry deserves an address, so this is one — reachable from Workspace &
|
||||
* Skills, where Owliver's capabilities belong, and linkable on its own.
|
||||
*
|
||||
* Nothing here is a second skill system. The rows are `allSkills()` — the
|
||||
* Markdown files on disk plus whatever the account has added — and enabling or
|
||||
* disabling one writes the same `disabledSkills` preference it always did.
|
||||
* Two lists, because there are two jobs. A **UI skill** extends a KROW page: it
|
||||
* declares a section, a placement and a reading, and the page draws it. An
|
||||
* **Owliver skill** extends the assistant: it declares triggers, suggestions
|
||||
* and the shapes an answer can take. Reading one list and finding both mixed
|
||||
* together was the problem this page had — "pages" meant "where it draws" in
|
||||
* one row and "where it listens" in the next, and nothing on screen said which.
|
||||
*
|
||||
* What is *not* split is as important. Both lists are readings of `allSkills()`
|
||||
* — one registry, one parser, one validator, one persistence, one data
|
||||
* resolver. A definition's facets are derived from what it declares, so this
|
||||
* page classifies rather than configures, and nothing stored had to be
|
||||
* migrated for the split to exist.
|
||||
*/
|
||||
|
||||
const KIND_LABEL = {
|
||||
assistant: 'Assistant',
|
||||
workforce: 'Workforce path',
|
||||
};
|
||||
/** The two lists, and the words each one uses for itself. */
|
||||
const FACETS = [
|
||||
{
|
||||
id: 'ui',
|
||||
label: 'Board Skills',
|
||||
icon: LayoutTemplate,
|
||||
add: 'Add Board Skill',
|
||||
route: '/admin/workspace/skills/new',
|
||||
blurb: 'Definitions that add dynamic sections to KROW pages, read from real page data.',
|
||||
emptyTitle: 'No Board skills yet',
|
||||
emptyBody: 'A Board skill declares a section, where it sits and what it reads. Add one and the page it names renders it.',
|
||||
},
|
||||
{
|
||||
id: 'owliver',
|
||||
label: 'Owliver Skills',
|
||||
icon: Sparkles,
|
||||
add: 'Add Owliver Skill',
|
||||
route: '/admin/workspace/skills/owliver/new',
|
||||
blurb: 'Definitions that teach Owliver what it can be asked for on a page, and how to answer.',
|
||||
emptyTitle: 'No Owliver skills yet',
|
||||
emptyBody: 'An Owliver skill declares triggers, suggestions and the shapes its answers take. Add one and the panel on that page offers it.',
|
||||
},
|
||||
];
|
||||
|
||||
const facetFor = (id) => FACETS.find((f) => f.id === id) || FACETS[0];
|
||||
|
||||
/** The page labels a definition attaches to, in the product's own words. */
|
||||
const surfaceLabels = (skill) =>
|
||||
skill.pages.map((p) => surfaceFor(p)?.label || p).filter(Boolean);
|
||||
|
||||
/**
|
||||
* What this definition contributes, in the terms of the list it is on.
|
||||
*
|
||||
* The same skill answers the question differently depending on which job is
|
||||
* being managed: on the UI list it is a shape and a placement, on the Owliver
|
||||
* list it is the capabilities it can be asked for. Derived from the definition
|
||||
* both times — nothing here is a stored label.
|
||||
*/
|
||||
function contribution(skill, facet) {
|
||||
if (facet === 'ui') {
|
||||
const sections = Object.values(skill.ui || {}).flatMap((page) => page.sections || []);
|
||||
return {
|
||||
type: [...new Set(sections.map((s) => sectionTypeLabel(s.type)))].join(' · '),
|
||||
detail: [...new Set(sections.map((s) => placementLabel(s.placement)))].join(', '),
|
||||
};
|
||||
}
|
||||
|
||||
const capabilities = skill.owliver?.capabilities || [];
|
||||
return {
|
||||
type: capabilities.map(owliverCapabilityLabel).join(' · '),
|
||||
/* A definition written before the split has no capability list — what it
|
||||
offers Owliver is its triggers, so say that rather than nothing. */
|
||||
detail: capabilities.length
|
||||
? `${skill.owliver.suggestions.length || 'No'} suggestion${skill.owliver.suggestions.length === 1 ? '' : 's'}`
|
||||
: `${skill.triggers.length} trigger${skill.triggers.length === 1 ? '' : 's'}`,
|
||||
};
|
||||
}
|
||||
|
||||
/** One skill, as a row in the management list. */
|
||||
function SkillRow({ skill, enabled, onToggle, onEdit, onDelete }) {
|
||||
const pages = contextIdsForSkill(skill)
|
||||
.map((id) => ASSISTANT_CONTEXTS[id]?.page)
|
||||
.filter(Boolean);
|
||||
function SkillRow({ skill, facet, enabled, onToggle, onEdit, onDelete }) {
|
||||
const pages = surfaceLabels(skill);
|
||||
const { type, detail } = contribution(skill, facet);
|
||||
/* A definition doing both jobs appears on both lists. Saying so on the row is
|
||||
what stops it reading as a duplicate. */
|
||||
const both = skill.facets.length > 1;
|
||||
|
||||
return (
|
||||
<div className="grid gap-3 px-4 py-3.5 sm:grid-cols-[minmax(0,1fr)_auto] sm:items-start">
|
||||
@@ -44,30 +115,24 @@ function SkillRow({ skill, enabled, onToggle, onEdit, onDelete }) {
|
||||
<Badge variant={enabled ? 'success' : 'neutral'} size="sm">
|
||||
{enabled ? 'Active' : 'Inactive'}
|
||||
</Badge>
|
||||
<Badge variant="soft" size="sm">{KIND_LABEL[skill.kind] || 'Assistant'}</Badge>
|
||||
{type && <Badge variant="soft" size="sm">{type}</Badge>}
|
||||
{skill.custom && <Badge variant="soft" size="sm">Custom</Badge>}
|
||||
{both && <Badge variant="neutral" size="sm">Also on the other list</Badge>}
|
||||
</div>
|
||||
|
||||
{skill.description && (
|
||||
<p className="mt-1 text-caption leading-relaxed text-ink-3">{skill.description}</p>
|
||||
)}
|
||||
|
||||
{/* A workforce path names the capability it governs and the rungs it
|
||||
defines: on this list those are what change other pages. */}
|
||||
{skill.kind === 'workforce' && (
|
||||
<p className="mt-1.5 text-[11px] text-ink-4">
|
||||
Capability: <span className="text-ink-3">{skill.skillId}</span>
|
||||
{skill.levels.length > 0 && <> · {skill.levels.map((l) => l.label).join(' → ')}</>}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{pages.length > 0 && (
|
||||
<p className="mt-1.5 text-[11px] text-ink-4">
|
||||
Surfaces: <span className="text-ink-3">{pages.join(', ')}</span>
|
||||
</p>
|
||||
)}
|
||||
|
||||
<p className="mt-1.5 text-[11px] text-ink-4">
|
||||
{pages.length > 0 && (
|
||||
<>Pages: <span className="text-ink-3">{pages.join(', ')}</span></>
|
||||
)}
|
||||
{pages.length > 0 && detail && ' · '}
|
||||
{detail && <span className="text-ink-3">{detail}</span>}
|
||||
</p>
|
||||
|
||||
<p className="mt-1 text-[11px] text-ink-4">
|
||||
{skill.source === 'account' ? 'Added on this account' : 'Shipped with KROW'}
|
||||
{' · '}
|
||||
<span className="font-mono">{skill.path}</span>
|
||||
@@ -96,14 +161,16 @@ function SkillRow({ skill, enabled, onToggle, onEdit, onDelete }) {
|
||||
<Pencil className="mr-2 h-4 w-4" />
|
||||
{skill.custom ? 'Edit skill' : 'Duplicate & edit'}
|
||||
</DropdownMenuItem>
|
||||
{skill.custom && (
|
||||
<DropdownMenuItem
|
||||
onClick={() => onDelete(skill)}
|
||||
className="cursor-pointer text-destructive focus:text-destructive"
|
||||
>
|
||||
<Trash2 className="mr-2 h-4 w-4" /> Remove skill
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{/* Offered for every definition. What it does differs — an account
|
||||
definition is deleted, a shipped one is taken out of this
|
||||
workspace — and the dialog says which. */}
|
||||
<DropdownMenuItem
|
||||
onClick={() => onDelete(skill)}
|
||||
className="cursor-pointer text-destructive focus:text-destructive"
|
||||
>
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
{skill.custom ? 'Remove skill' : 'Remove from workspace'}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
@@ -116,56 +183,240 @@ export default function WorkspaceSkills() {
|
||||
const preferences = usePreferences();
|
||||
const updatePreferences = useUpdatePreferences();
|
||||
|
||||
/* Which list is open lives in the URL, so a link can open one and the back
|
||||
button steps between them rather than out of the page. */
|
||||
const [params, setParams] = useSearchParams();
|
||||
const facet = FACETS.some((f) => f.id === params.get('tab')) ? params.get('tab') : 'ui';
|
||||
const setFacet = (next) => setParams(next === 'ui' ? {} : { tab: next }, { replace: true });
|
||||
|
||||
const [search, setSearch] = useState('');
|
||||
const [status, setStatus] = useState('all');
|
||||
const [page, setPage] = useState('all');
|
||||
const [pendingDelete, setPendingDelete] = useState(null);
|
||||
const [showRemoved, setShowRemoved] = useState(false);
|
||||
|
||||
const disabledSkills = preferences.disabledSkills || [];
|
||||
/**
|
||||
* Definitions this account has removed.
|
||||
*
|
||||
* A shipped definition lives in the repository, so "remove" cannot mean
|
||||
* deleting it — the file would come back on the next deployment and the
|
||||
* button would be lying. It means removed *from this workspace*: hidden from
|
||||
* this list and switched off everywhere.
|
||||
*
|
||||
* The second half is why removal needs no other file to change. A removed id
|
||||
* is also written to `disabledSkills`, which every surface, resolver and
|
||||
* suggestion path already honours — so "removed" is exactly "disabled, and
|
||||
* out of the way", and nothing had to learn a second rule.
|
||||
*/
|
||||
const removedSkills = preferences.removedSkills || [];
|
||||
const customSkills = preferences.customSkills || [];
|
||||
const skills = useMemo(() => allSkills(customSkills), [customSkills]);
|
||||
|
||||
/**
|
||||
* Whether a skill is actually offered.
|
||||
*
|
||||
* Two independent things switch a skill off — the definition declaring
|
||||
* `status: inactive`, and this account disabling it — and the renderer honours
|
||||
* both. This list used to read only the second, so a definition that ships
|
||||
* inactive appeared here as "Active" while every surface correctly refused to
|
||||
* render it. The Skill Editor already reconciled the two; now so does this.
|
||||
*/
|
||||
const isEnabled = (skill) =>
|
||||
skill.status !== 'inactive' && !disabledSkills.includes(skill.id);
|
||||
|
||||
/**
|
||||
* Everything still in this workspace.
|
||||
*
|
||||
* Removal is filtered out *here*, at the source, rather than where the rows
|
||||
* are drawn — because four things count these definitions: the two tab
|
||||
* badges, the page filter's options, and the "N registered · N active"
|
||||
* footer. Filtering at render would leave a tab reading "4" above an empty
|
||||
* list and a footer counting definitions the reader has removed.
|
||||
*/
|
||||
const present = useMemo(
|
||||
() => skills.filter((s) => !removedSkills.includes(s.id)),
|
||||
/* Keyed on the content: `preferences` rebuilds its arrays every read. */
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[skills, removedSkills.join('|')]
|
||||
);
|
||||
|
||||
/** The definitions on this list, before the management filters. */
|
||||
const listed = useMemo(() => skillsWithFacet(present, facet), [present, facet]);
|
||||
|
||||
/** The removed ones, for the drawer that lets them be put back. */
|
||||
const removed = useMemo(
|
||||
() => skills.filter((s) => removedSkills.includes(s.id)),
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[skills, removedSkills.join('|')]
|
||||
);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const q = search.trim().toLowerCase();
|
||||
if (!q) return skills;
|
||||
return skills.filter((s) => `${s.name} ${s.description} ${s.pages.join(' ')}`.toLowerCase().includes(q));
|
||||
}, [skills, search]);
|
||||
return listed.filter((s) => {
|
||||
if (q && !`${s.name} ${s.description} ${s.pages.join(' ')}`.toLowerCase().includes(q)) return false;
|
||||
if (status === 'active' && !isEnabled(s)) return false;
|
||||
if (status === 'disabled' && isEnabled(s)) return false;
|
||||
if (page !== 'all' && !s.pages.some((p) => (surfaceFor(p)?.id || p) === page)) return false;
|
||||
return true;
|
||||
});
|
||||
/* `isEnabled` closes over the preference arrays, so `disabledSkills` is the
|
||||
real input rather than the function itself. */
|
||||
}, [listed, search, status, page, disabledSkills]);
|
||||
|
||||
/**
|
||||
* Flipping the switch has to move whichever of the two is holding the skill
|
||||
* off, or the control does nothing visible.
|
||||
*
|
||||
* Enabling one that ships inactive writes an account definition with
|
||||
* `status: active` — the same override the Skill Editor writes, through the
|
||||
* same helper — rather than a second kind of "enabled" state beside it.
|
||||
*
|
||||
* One definition is switched, never a list: a UI skill and an Owliver skill
|
||||
* are separate definitions with separate ids, so disabling either leaves the
|
||||
* other exactly as it was.
|
||||
*/
|
||||
const toggleSkill = (id, enabled) => {
|
||||
const next = enabled
|
||||
? disabledSkills.filter((s) => s !== id)
|
||||
: [...new Set([...disabledSkills, id])];
|
||||
updatePreferences.mutate({ disabledSkills: next });
|
||||
const skill = skills.find((s) => s.id === id);
|
||||
const patch = {
|
||||
disabledSkills: enabled
|
||||
? disabledSkills.filter((s) => s !== id)
|
||||
: [...new Set([...disabledSkills, id])],
|
||||
};
|
||||
|
||||
if (skill && enabled && skill.status === 'inactive') {
|
||||
const source = /^status:\s*\w+$/m.test(skill.markdown)
|
||||
? skill.markdown.replace(/^status:\s*\w+$/m, 'status: active')
|
||||
: skill.markdown.replace(/^---\n/, '---\nstatus: active\n');
|
||||
patch.customSkills = upsertCustomSkill(customSkills, source).next;
|
||||
}
|
||||
|
||||
updatePreferences.mutate(patch);
|
||||
toast.success(enabled ? 'Skill enabled' : 'Skill disabled');
|
||||
};
|
||||
|
||||
/**
|
||||
* Remove, which means two different things honestly rather than one thing
|
||||
* badly.
|
||||
*
|
||||
* An **account** definition is stored here, so removing it deletes it — the
|
||||
* behaviour this button has always had. A **shipped** definition is a file in
|
||||
* the repository this page cannot delete, so removing it takes it out of the
|
||||
* workspace instead: hidden from the list, and switched off everywhere by the
|
||||
* same `disabledSkills` every surface already reads. Reversible either way for
|
||||
* shipped definitions, and the copy on the dialog says which it is doing.
|
||||
*/
|
||||
const removeSkill = (skill) => {
|
||||
updatePreferences.mutate({ customSkills: removeCustomSkill(customSkills, skill.id) });
|
||||
const patch = skill.custom
|
||||
? { customSkills: removeCustomSkill(customSkills, skill.id) }
|
||||
: {
|
||||
removedSkills: [...new Set([...removedSkills, skill.id])],
|
||||
disabledSkills: [...new Set([...disabledSkills, skill.id])],
|
||||
};
|
||||
|
||||
updatePreferences.mutate(patch);
|
||||
setPendingDelete(null);
|
||||
toast.success(`${skill.name} removed`);
|
||||
};
|
||||
|
||||
const activeCount = skills.filter((s) => !disabledSkills.includes(s.id)).length;
|
||||
/**
|
||||
* Puts a removed definition back on the list — switched off.
|
||||
*
|
||||
* Deliberately not re-enabled. `disabledSkills` now carries two intentions:
|
||||
* "the reader switched this off" and "this was switched off because it was
|
||||
* removed", and nothing distinguishes them. Clearing it on restore would
|
||||
* silently turn on a skill the reader had disabled *before* removing it,
|
||||
* which is the kind of thing that surfaces as a bug report weeks later.
|
||||
*
|
||||
* Coming back visibly inactive has no silent failure mode: the row is on the
|
||||
* list with its switch off, and one click is the whole cost.
|
||||
*/
|
||||
const restoreSkill = (skill) => {
|
||||
updatePreferences.mutate({
|
||||
removedSkills: removedSkills.filter((id) => id !== skill.id),
|
||||
});
|
||||
toast.success(`${skill.name} restored — switched off`);
|
||||
};
|
||||
|
||||
/** A row opens the editor its own kind is authored in. */
|
||||
const editRoute = (skill) => (facet === 'owliver'
|
||||
? `/admin/workspace/skills/owliver/${skill.id}`
|
||||
: `/admin/workspace/skills/${skill.id}`);
|
||||
|
||||
const current = facetFor(facet);
|
||||
const tabs = FACETS.map((f) => ({
|
||||
value: f.id,
|
||||
label: f.label,
|
||||
icon: f.icon,
|
||||
count: skillsWithFacet(present, f.id).length,
|
||||
}));
|
||||
|
||||
/* Only the pages something is actually attached to, so the filter never
|
||||
offers a page that would empty the list. */
|
||||
const pageOptions = useMemo(() => {
|
||||
const used = new Set(listed.flatMap((s) => s.pages.map((p) => surfaceFor(p)?.id || p)));
|
||||
return PAGE_KEYS.filter((key) => used.has(key));
|
||||
}, [listed]);
|
||||
|
||||
const activeCount = listed.filter(isEnabled).length;
|
||||
|
||||
return (
|
||||
<AdminPage
|
||||
title="Skills"
|
||||
subtitle="Manage the capabilities Owliver can use across KROW."
|
||||
subtitle="Manage the capabilities that extend KROW pages and Owliver."
|
||||
actions={
|
||||
<>
|
||||
<Button variant="outline" size="sm" shape="rounded" onClick={() => navigate('/admin/workspace')}>
|
||||
<ChevronLeft aria-hidden="true" /> Workspace
|
||||
</Button>
|
||||
<Button size="sm" shape="rounded" onClick={() => navigate('/admin/workspace/skills/new')}>
|
||||
<Plus aria-hidden="true" /> Add skill
|
||||
<Button size="sm" shape="rounded" onClick={() => navigate(current.route)}>
|
||||
<Plus aria-hidden="true" /> {current.add}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<Tabs
|
||||
tabs={tabs}
|
||||
value={facet}
|
||||
onChange={setFacet}
|
||||
variant="underline"
|
||||
size="sm"
|
||||
layoutGroupId="skills-facet"
|
||||
ariaLabel="Skill kinds"
|
||||
/>
|
||||
|
||||
<p className="text-caption leading-relaxed text-ink-3">{current.blurb}</p>
|
||||
|
||||
<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 skills" size="sm" />
|
||||
<div className="w-full lg:w-72">
|
||||
<SearchInput value={search} onChange={setSearch} placeholder={`Search ${current.label.toLowerCase()}`} size="sm" />
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Select value={status} onValueChange={setStatus}>
|
||||
<SelectTrigger className="h-9 w-36"><SelectValue placeholder="Status" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All</SelectItem>
|
||||
<SelectItem value="active">Active</SelectItem>
|
||||
<SelectItem value="disabled">Disabled</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{pageOptions.length > 1 && (
|
||||
<Select value={page} onValueChange={setPage}>
|
||||
<SelectTrigger className="h-9 w-44"><SelectValue placeholder="Page" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All pages</SelectItem>
|
||||
{pageOptions.map((key) => (
|
||||
<SelectItem key={key} value={key}>{surfaceFor(key)?.label || key}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<p className="text-caption text-ink-4 lg:ml-auto">
|
||||
{skills.length} registered · {activeCount} active
|
||||
{listed.length} registered · {activeCount} active
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -181,34 +432,109 @@ export default function WorkspaceSkills() {
|
||||
<SkillRow
|
||||
key={skill.id}
|
||||
skill={skill}
|
||||
enabled={!disabledSkills.includes(skill.id)}
|
||||
facet={facet}
|
||||
enabled={isEnabled(skill)}
|
||||
onToggle={toggleSkill}
|
||||
onEdit={(s) => navigate(`/admin/workspace/skills/${s.id}`)}
|
||||
onEdit={(s) => navigate(editRoute(s))}
|
||||
onDelete={setPendingDelete}
|
||||
/>
|
||||
))}
|
||||
</Surface>
|
||||
) : (
|
||||
<EmptyState
|
||||
title="No skills match that search"
|
||||
description="Try a shorter term, or clear the search to see every registered skill."
|
||||
action={<Button variant="outline" size="sm" onClick={() => setSearch('')}>Clear search</Button>}
|
||||
title={listed.length ? 'No skills match those filters' : current.emptyTitle}
|
||||
description={listed.length
|
||||
? 'Try a shorter term, or clear the filters to see every skill on this list.'
|
||||
: current.emptyBody}
|
||||
action={listed.length ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => { setSearch(''); setStatus('all'); setPage('all'); }}
|
||||
>
|
||||
Clear filters
|
||||
</Button>
|
||||
) : (
|
||||
<Button size="sm" onClick={() => navigate(current.route)}>
|
||||
<Plus aria-hidden="true" /> {current.add}
|
||||
</Button>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Removed definitions, and the way back.
|
||||
A removal that cannot be undone from the same page it was made on is
|
||||
a deletion wearing a softer word — so the list is here, collapsed
|
||||
until there is something in it. */}
|
||||
{removed.length > 0 && (
|
||||
<section aria-labelledby="removed-skills" className="space-y-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowRemoved((v) => !v)}
|
||||
aria-expanded={showRemoved}
|
||||
className="inline-flex items-center gap-1.5 rounded text-caption font-medium text-ink-3 transition-colors
|
||||
hover:text-krow-blue focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-krow-blue/50"
|
||||
>
|
||||
<span id="removed-skills">
|
||||
Removed from this workspace ({removed.length})
|
||||
</span>
|
||||
<ChevronDown
|
||||
className={cn('h-3.5 w-3.5 transition-transform', showRemoved && 'rotate-180')}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</button>
|
||||
|
||||
{showRemoved && (
|
||||
<Surface
|
||||
variant="solid"
|
||||
radius="lg"
|
||||
padding="none"
|
||||
elevation="xs"
|
||||
className="divide-y divide-border overflow-hidden"
|
||||
>
|
||||
{removed.map((skill) => (
|
||||
<div key={skill.id} className="flex flex-wrap items-center justify-between gap-3 px-4 py-3">
|
||||
<div className="min-w-0">
|
||||
<p className="text-body-sm font-medium text-ink-2">{skill.name}</p>
|
||||
<p className="mt-0.5 text-[11px] text-ink-4">
|
||||
{skill.source === 'account' ? 'Added on this account' : 'Shipped with KROW'}
|
||||
{' · '}
|
||||
<span className="font-mono">{skill.path}</span>
|
||||
</p>
|
||||
</div>
|
||||
<Button variant="outline" size="sm" shape="rounded" onClick={() => restoreSkill(skill)}>
|
||||
Restore
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</Surface>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
|
||||
<p className="text-caption leading-relaxed text-ink-4">
|
||||
Each skill is a Markdown definition that attaches itself to the pages it names.
|
||||
Definitions shipped with KROW live in the repository and can be switched off here;
|
||||
skills added on this account are stored with your preferences. Markdown is stored as
|
||||
data — it is never executed.
|
||||
Each skill is a Markdown definition that attaches itself to the pages it names. A UI skill
|
||||
draws a section on those pages; an Owliver skill answers questions on them. Both are read by
|
||||
the same registry and the same data resolver, so a figure in a card and a figure in an answer
|
||||
come from one reading. Definitions shipped with KROW live in the repository and can be
|
||||
switched off here; skills added on this account are stored with your preferences. Markdown is
|
||||
stored as data — it is never executed.
|
||||
</p>
|
||||
|
||||
<ConfirmModal
|
||||
open={Boolean(pendingDelete)}
|
||||
onOpenChange={(open) => !open && setPendingDelete(null)}
|
||||
title={`Remove ${pendingDelete?.name || 'skill'}?`}
|
||||
description="This removes the definition from this account. Skills shipped with KROW are unaffected."
|
||||
confirmLabel="Remove skill"
|
||||
title={pendingDelete?.custom
|
||||
? `Remove ${pendingDelete?.name || 'skill'}?`
|
||||
: `Remove ${pendingDelete?.name || 'skill'} from this workspace?`}
|
||||
description={pendingDelete?.custom
|
||||
? `This deletes one definition from this account${
|
||||
countSections(pendingDelete) > 0 && pendingDelete.facets?.includes('owliver')
|
||||
? ' — including both the section it draws and the answers it offers'
|
||||
: ''
|
||||
}. Every other skill, and anything shipped with KROW, is unaffected.`
|
||||
: `${pendingDelete?.name || 'This skill'} ships with KROW, so its definition stays in the repository. It is switched off everywhere and taken off this list — you can put it back from “Removed”.`}
|
||||
confirmLabel={pendingDelete?.custom ? 'Remove skill' : 'Remove from workspace'}
|
||||
tone="destructive"
|
||||
onConfirm={() => pendingDelete && removeSkill(pendingDelete)}
|
||||
/>
|
||||
|
||||
@@ -9,6 +9,12 @@ prompt: Create a position
|
||||
triggers:
|
||||
- create a position
|
||||
- create position
|
||||
# A client is the company a position is staffed for, so asking for one starts
|
||||
# the same conversation — it simply leads with the company question.
|
||||
- create a client
|
||||
- create client
|
||||
- add a client
|
||||
- new client
|
||||
- create a * position
|
||||
- create * position
|
||||
- new position
|
||||
@@ -50,6 +56,7 @@ Each line is `field | question | suggestions | required?`. Suggestions beginning
|
||||
with `@` come from the application's own data, so a role category added in the
|
||||
form is offered here without this file changing.
|
||||
|
||||
- company | Which client is this role for? Type the company name. | | required
|
||||
- role_category | What role are you hiring for? | @roles | required
|
||||
- location | Where will this role be based? | Chennai; Bengaluru; Coimbatore; Bay Area; Other | required
|
||||
- pay | What is the pay range? | $18–$28/hr; $25–$35/hr; $30–$40/hr; Custom | required
|
||||
57
src/skills/owliver/hiring-activity-assistant.md
Normal file
57
src/skills/owliver/hiring-activity-assistant.md
Normal file
@@ -0,0 +1,57 @@
|
||||
---
|
||||
id: hiring-activity-assistant
|
||||
name: Hiring Activity Assistant
|
||||
description: Answer questions about recent hiring activity on a position.
|
||||
pages:
|
||||
- positions
|
||||
status: active
|
||||
triggers:
|
||||
- hiring activity
|
||||
- hiring summary
|
||||
- hiring flow
|
||||
- recent applications
|
||||
- applications over time
|
||||
owliver:
|
||||
enabled: true
|
||||
suggestions:
|
||||
- Show hiring activity
|
||||
- label: Summarize hiring activity
|
||||
capability: summary
|
||||
- label: Show hiring activity as a flow
|
||||
capability: flow
|
||||
capabilities:
|
||||
- summary
|
||||
- flow
|
||||
responses:
|
||||
summary:
|
||||
title: Hiring Activity Summary
|
||||
source: position.activity
|
||||
periods:
|
||||
- today
|
||||
- yesterday
|
||||
- last-week
|
||||
flow:
|
||||
title: Hiring Activity Flow
|
||||
source: position.activity
|
||||
steps:
|
||||
- today
|
||||
- yesterday
|
||||
- last-week
|
||||
---
|
||||
|
||||
# Hiring Activity Assistant
|
||||
|
||||
## Purpose
|
||||
|
||||
Answer questions about how many people have applied to a position lately, in the
|
||||
panel beside the Positions experience.
|
||||
|
||||
This is a separate definition from the Hiring Activity UI skill, and each is
|
||||
managed on its own list — but both name `position.activity`, so both are read by
|
||||
the one shared resolver from the same application records. Switching either off
|
||||
leaves the other exactly as it was.
|
||||
|
||||
## Capabilities
|
||||
|
||||
- Summarize applications to this position over today, yesterday and last week.
|
||||
- Draw the same counts as a flow inside the answer.
|
||||
Reference in New Issue
Block a user