This commit is contained in:
@@ -98,6 +98,12 @@ const AuthenticatedApp = () => {
|
||||
screening dimensions, which a 480px column cannot show without
|
||||
hiding most of it. */}
|
||||
<Route path="candidates/:id" element={<AdminCandidateProfile />} />
|
||||
{/* Candidate Analysis. A sibling segment, not `candidates/:id`:
|
||||
the analysis reads the whole pool, so it is not addressed by a
|
||||
candidate id and must not be matched as one. The route is the
|
||||
one the `candidates-analysis` surface declares, so the skill
|
||||
table, the assistant placement table and this agree. */}
|
||||
<Route path="candidates-analysis" element={<AdminCandidatesAnalysis />} />
|
||||
<Route path="hired" element={<AdminHiredHistory />} />
|
||||
<Route path="talent-pool" element={<AdminTalentPool />} />
|
||||
<Route path="university" element={<University />} />
|
||||
|
||||
@@ -394,6 +394,10 @@ export function AgentSkillWorkspace({
|
||||
busy={busy}
|
||||
query={boardQuery}
|
||||
onQueryChange={setBoardQuery}
|
||||
/* Ownership, through the one handler that writes `agent.skills` —
|
||||
the same one the Owliver catalog attaches with. */
|
||||
attachedIds={attachedIds}
|
||||
onAttach={onToggleSkill}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import * as React from 'react';
|
||||
import { LayoutTemplate, Pencil, Plus, SearchX } from 'lucide-react';
|
||||
import { LayoutTemplate, Minus, Pencil, Plus, SearchX } from 'lucide-react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Alert, Button, SearchInput, Switch } from '@/components/ds';
|
||||
@@ -9,24 +9,30 @@ import { Alert, Button, SearchInput, Switch } from '@/components/ds';
|
||||
*
|
||||
* The second half of one Skills section — the same heading, a tab away from the
|
||||
* Owliver catalog — so nobody has to decide between two top-level destinations
|
||||
* ever again. What it is *not* is a second copy of that catalog with the word
|
||||
* Board on it, and the reason is worth stating because it is the one thing here
|
||||
* that could quietly become a lie:
|
||||
* ever again.
|
||||
*
|
||||
* **A Board skill is not carried by an agent.** `SkillSurface` renders a
|
||||
* section from the page it names and the account's `disabledSkills`; it does not
|
||||
* read `agent.skills` and has no agent in scope. Writing a Board skill id into
|
||||
* `agent.skills` would therefore record an assignment nothing honours — a card
|
||||
* that says "Added" and a product that behaves identically either way.
|
||||
* **Two controls, because there are two different questions.** This card used to
|
||||
* offer only the workspace switch, and said so at length: a Board skill was not
|
||||
* carried by an agent, because `SkillSurface` read the page and the account's
|
||||
* `disabledSkills` and never looked at `agent.skills`. That was true, and it is
|
||||
* not any more — `useSkillSections` now asks `agentPermitsSkill`, so an id
|
||||
* written into `agent.skills` is honoured on the page.
|
||||
*
|
||||
* So the relationship shown is the real one: these are the sections the pages
|
||||
* *this agent answers on* will draw, and the control offered is the one that
|
||||
* actually governs them — the workspace switch every surface already reads. It
|
||||
* is labelled as workspace-wide, because it is.
|
||||
* So both questions are asked here, and neither is dressed up as the other:
|
||||
*
|
||||
* - **Active** is the workspace switch. Off means off for everyone, on every
|
||||
* page, for every agent. It is the account's `disabledSkills`.
|
||||
* - **Add to agent** is ownership. It writes this skill's id into
|
||||
* `agent.skills`, through the same handler the Owliver catalog uses.
|
||||
*
|
||||
* Ownership is opt-in and that is what makes the two safe together: a skill no
|
||||
* agent claims still draws wherever its `pages:` say, exactly as before. The
|
||||
* first agent to claim it is what narrows it — which is why attaching is worth
|
||||
* saying out loud on the card rather than leaving as a silent side effect.
|
||||
*/
|
||||
|
||||
/** One Board skill: what it draws, where, and whether it is switched on. */
|
||||
function BoardCard({ entry, enabled, onToggle, onSurfaces, busy }) {
|
||||
function BoardCard({ entry, enabled, onToggle, onSurfaces, busy, attached = false, onAttach = null }) {
|
||||
const switchId = `board-${entry.id}`;
|
||||
|
||||
return (
|
||||
@@ -110,6 +116,31 @@ function BoardCard({ entry, enabled, onToggle, onSurfaces, busy }) {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Ownership, separate from the workspace switch above it. The button
|
||||
writes `agent.skills` through the same handler the Owliver catalog
|
||||
uses — there is no second assignment path and nothing new stored. */}
|
||||
{onAttach && (
|
||||
<div className="relative z-10 mt-3 flex items-center justify-between gap-2 rounded-lg border border-border/60 bg-surface-subtle px-2.5 py-2">
|
||||
<span className="min-w-0 text-caption text-ink-3">
|
||||
{attached
|
||||
? 'Owned by this agent'
|
||||
: 'Not owned by any agent on this page'}
|
||||
</span>
|
||||
<Button
|
||||
size="xs"
|
||||
variant={attached ? 'outline' : 'default'}
|
||||
shape="rounded"
|
||||
disabled={busy}
|
||||
onClick={() => onAttach(entry.id)}
|
||||
aria-label={`${attached ? 'Remove' : 'Add'} ${entry.name} ${attached ? 'from' : 'to'} this agent`}
|
||||
>
|
||||
{attached
|
||||
? <><Minus className="mr-1 h-3.5 w-3.5" aria-hidden="true" />Remove</>
|
||||
: <><Plus className="mr-1 h-3.5 w-3.5" aria-hidden="true" />Add to agent</>}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="relative z-10 mt-4 flex items-center justify-between gap-2 border-t border-border/60 pt-3 font-medium">
|
||||
<span className="text-caption text-ink-4">
|
||||
{entry.surfaces.some((s) => onSurfaces.has(s.id))
|
||||
@@ -131,6 +162,7 @@ function BoardCard({ entry, enabled, onToggle, onSurfaces, busy }) {
|
||||
/** @param {any} props */
|
||||
export function BoardSkillList({
|
||||
entries, agentPages = [], disabledIds, onToggle, busy = false, query, onQueryChange, total,
|
||||
attachedIds = null, onAttach = null,
|
||||
}) {
|
||||
const searchId = React.useId();
|
||||
const onSurfaces = React.useMemo(() => new Set(agentPages), [agentPages]);
|
||||
@@ -147,9 +179,11 @@ export function BoardSkillList({
|
||||
|
||||
return (
|
||||
<section aria-label="Board skills" className="flex min-w-0 flex-col gap-4">
|
||||
<Alert tone="info" title="Board skills belong to a page, not to an agent">
|
||||
They draw sections on KROW pages and are switched on for the whole workspace —
|
||||
so a change here affects every agent answering on that page, not just this one.
|
||||
<Alert tone="info" title="Two switches, two different questions">
|
||||
<strong>Active</strong> is workspace-wide: off means off on every page, for every
|
||||
agent. <strong>Add to agent</strong> is ownership — once any agent owns a board
|
||||
skill, it draws only where an owning agent is answering. A skill no agent owns
|
||||
keeps drawing wherever its pages say, as before.
|
||||
</Alert>
|
||||
|
||||
{total > 0 && (
|
||||
@@ -192,6 +226,8 @@ export function BoardSkillList({
|
||||
onToggle={onToggle}
|
||||
onSurfaces={onSurfaces}
|
||||
busy={busy}
|
||||
attached={Boolean(attachedIds?.has(entry.id))}
|
||||
onAttach={onAttach}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import * as React from 'react';
|
||||
import { usePreferences } from '@/lib/krowHooks';
|
||||
import { allAgents } from '@/lib/agents/registry';
|
||||
import { AGENTS, readAgentRegistry } from '@/lib/agents/registry';
|
||||
import { sourcesFrom, useAgentDefinitions } from '@/lib/agents/agentStore';
|
||||
import {
|
||||
agentCovers, nativeAgentForContext, resolveAgentForTurn, resolveDefaultAgent, resolveSelection,
|
||||
} from '@/lib/agents/runtime';
|
||||
@@ -73,12 +74,30 @@ function writeSelection(selection) {
|
||||
export function AgentProvider({ contextId = null, children }) {
|
||||
const preferences = usePreferences();
|
||||
|
||||
/* Shipped definitions plus anything this account has authored, read through
|
||||
the one registry so the switcher and the management page cannot disagree
|
||||
about what exists. */
|
||||
/**
|
||||
* Shipped definitions plus anything this account has authored.
|
||||
*
|
||||
* Read from `agent-definitions` — the store the Agent Registry and Agent
|
||||
* Configure write to — rather than from `preferences.customAgents`, which is
|
||||
* where authored agents used to live. That move happened for the management
|
||||
* screens and this was left behind, so the panel's idea of an agent was the
|
||||
* shipped file and nothing else: an agent edited in Configure looked saved,
|
||||
* and the agent answering beside it was still the version off disk.
|
||||
*
|
||||
* It is only visible once something actually depends on an authored field.
|
||||
* Attaching a skill is that: ownership is read off `agent.skills`, and an
|
||||
* attachment made in Configure has to be the one the page sees, or the two
|
||||
* halves of the product disagree about what this agent owns.
|
||||
*/
|
||||
const definitions = useAgentDefinitions();
|
||||
const shippedIds = React.useMemo(() => new Set(AGENTS.map((a) => a.id)), []);
|
||||
const stored = React.useMemo(
|
||||
() => sourcesFrom(definitions.data || [], shippedIds),
|
||||
[definitions.data, shippedIds]
|
||||
);
|
||||
const agents = React.useMemo(
|
||||
() => allAgents(preferences.customAgents || [], { customSkills: preferences.customSkills || [] }),
|
||||
[preferences.customAgents, preferences.customSkills]
|
||||
() => readAgentRegistry(stored, { customSkills: preferences.customSkills || [] }).agents,
|
||||
[stored, preferences.customSkills]
|
||||
);
|
||||
|
||||
const [selection, setSelection] = React.useState(() => readSelection());
|
||||
|
||||
@@ -2,14 +2,14 @@ import * as React from 'react';
|
||||
import { useLocation, useNavigate } from 'react-router-dom';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import {
|
||||
ArrowLeft, History, Maximize2, Minimize2, PanelRightClose, RotateCcw, Trash2, X,
|
||||
History, Home, Maximize2, Minimize2, PanelRightClose, Trash2, X,
|
||||
} from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Surface } from '@/components/ds/Surface';
|
||||
import { IconButton } from '@/components/ds/IconButton';
|
||||
import { Alert } from '@/components/ds/Alert';
|
||||
import {
|
||||
useAssignments, useAssignWorkers, useCreateEmployeeRole, useCreateJobPosting,
|
||||
useAssignments, useAssignWorkers, useCreateWorkerWithRole, useCreateJobPosting,
|
||||
useGenerateJobDescription,
|
||||
fetchOwliverSuggestions, useMarkInterviewReady, useOwliverSuggestions, useShiftRecords,
|
||||
useUpdateJobPosting,
|
||||
@@ -17,7 +17,9 @@ import {
|
||||
} from '@/lib/krowHooks';
|
||||
import { ROLE_CATEGORIES } from '@/lib/roleCategories';
|
||||
import { runAction } from '@/lib/skills/actions';
|
||||
import { allSkills, pageKeyForContext } from '@/lib/skills/registry';
|
||||
import { useUiEditing } from '@/components/ui-tree/UiEditingProvider';
|
||||
import { allSkills, pageKeyForContext, skillsForContext } from '@/lib/skills/registry';
|
||||
import { actionSuggestions } from '@/lib/skills/tools';
|
||||
import { suggestionChips } from '@/lib/skills/serverSuggestions';
|
||||
import { useWorkforcePaths } from '@/lib/skills/usePageSkills';
|
||||
import { profileForEmail } from '@/lib/skillGraph';
|
||||
@@ -39,7 +41,7 @@ import { PromptChips } from './PromptChips';
|
||||
*
|
||||
* Identity, not emptiness: `prompts` is recomputed on every keystroke, and a
|
||||
* fresh `[]` each time would rerender the row — and anything memoized against
|
||||
* it — throughout the whole of a query that is still too short to match.
|
||||
* it — on every turn that offers nothing.
|
||||
*/
|
||||
const EMPTY_PROMPTS = [];
|
||||
|
||||
@@ -53,41 +55,6 @@ const EMPTY_PROMPTS = [];
|
||||
*/
|
||||
const EMPTY_SUGGESTIONS = [];
|
||||
|
||||
/**
|
||||
* How long a pause counts as having finished typing.
|
||||
*
|
||||
* Short enough that the chips feel like they are keeping up, long enough that a
|
||||
* word typed at speed is one request rather than eight. The endpoint is cached
|
||||
* per query, so a reader deleting back to something already asked pays nothing
|
||||
* either way.
|
||||
*/
|
||||
const SUGGEST_DEBOUNCE_MS = 180;
|
||||
|
||||
/**
|
||||
* A value, held still until it stops changing.
|
||||
*
|
||||
* Deliberately generic and local: it debounces the composer's contents and
|
||||
* nothing else, and the alternative — debouncing inside the query hook — would
|
||||
* make every other caller of that hook pay for a delay it did not ask for.
|
||||
*/
|
||||
function useDebounced(value, delay) {
|
||||
const [settled, setSettled] = React.useState(value);
|
||||
|
||||
React.useEffect(() => {
|
||||
/* An emptied composer settles immediately. Waiting would leave the previous
|
||||
query's chips under a blank input for a fifth of a second, which reads as
|
||||
the panel not having noticed. */
|
||||
if (!value) {
|
||||
setSettled(value);
|
||||
return undefined;
|
||||
}
|
||||
const timer = setTimeout(() => setSettled(value), delay);
|
||||
return () => clearTimeout(timer);
|
||||
}, [value, delay]);
|
||||
|
||||
return settled;
|
||||
}
|
||||
|
||||
/**
|
||||
* Owliver History — the conversations that came before.
|
||||
*
|
||||
@@ -169,77 +136,98 @@ function HistoryView({ groups, currentId, onOpen, onForget }) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Show the Back to Home row on scroll direction, not scroll position.
|
||||
* Whether the composer is offering anything, as one rule in one place.
|
||||
*
|
||||
* 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.
|
||||
* Extracted because it is the whole of the interaction and every clause is a
|
||||
* decision somebody could reasonably make differently:
|
||||
*
|
||||
* 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.
|
||||
* focused — an offer belongs to the thing you are about to type into. An
|
||||
* unfocused composer showing suggestions is the panel talking
|
||||
* first.
|
||||
* !busy — nothing is offered while an answer is still arriving; the next
|
||||
* question is not knowable until this one lands.
|
||||
* chat — History is a different view with a different body.
|
||||
* count — nothing to say, nothing shown, and this clause now carries what
|
||||
* a `!typed` test used to. Typing does not hide the panel by
|
||||
* rule; it changes what the panel HAS. An empty composer offers
|
||||
* follow-ups, a typed action intent offers the matching actions,
|
||||
* and arbitrary partial text matches no action and so offers
|
||||
* nothing — which is the same outcome by a more honest route,
|
||||
* and the reason "Create" can be answered while "he" cannot.
|
||||
*/
|
||||
function useDirectionalNav(scrollRef, { active, resetKey }) {
|
||||
const [visible, setVisible] = React.useState(true);
|
||||
const lastY = React.useRef(0);
|
||||
export const shouldShowSuggestions = ({ focused, busy, view, count }) => Boolean(
|
||||
focused && !busy && view === 'chat' && count > 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;
|
||||
/**
|
||||
* The questions on offer, above the composer they belong to.
|
||||
*
|
||||
* A labelled panel rather than a bare row of chips: the label is what makes
|
||||
* three sentences read as an offer rather than as something the assistant just
|
||||
* said. It sits inside the composer's own region, above the input and below the
|
||||
* conversation, so it reads as part of the thing you are about to type into.
|
||||
*
|
||||
* Always mounted, height animated. Mounting on open would move the input the
|
||||
* instant the panel appeared and again when it left, so the caret would jump
|
||||
* under the reader's hands every time they focused the box. Animating a
|
||||
* collapsed height keeps the geometry continuous, and `pointer-events-none`
|
||||
* plus `inert`-style tab removal means the closed panel cannot be clicked or
|
||||
* tabbed into.
|
||||
*
|
||||
* `max-h` is generous enough for four wrapped questions and scrolls past that,
|
||||
* so a narrow panel on a small screen cannot push the composer off the bottom.
|
||||
*/
|
||||
function SuggestedQuestions({ prompts, open, onSelect }) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'overflow-hidden transition-all duration-200 ease-out motion-reduce:transition-none',
|
||||
open
|
||||
? 'max-h-56 translate-y-0 opacity-100'
|
||||
: 'pointer-events-none max-h-0 translate-y-1 opacity-0'
|
||||
)}
|
||||
aria-hidden={open ? undefined : 'true'}
|
||||
>
|
||||
<p className="px-1 pb-1.5 text-[10px] font-semibold uppercase tracking-wide text-ink-4">
|
||||
Suggested questions
|
||||
</p>
|
||||
{/* The existing chip component, and the existing submit path behind it —
|
||||
`runPrompt` is the same handler a typed question goes through. */}
|
||||
<PromptChips
|
||||
prompts={prompts}
|
||||
onSelect={onSelect}
|
||||
align="start"
|
||||
focusable={open}
|
||||
className="pb-1"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const pinToBottom = React.useCallback(() => {
|
||||
/**
|
||||
* Keep the thread pinned to its newest content.
|
||||
*
|
||||
* All that survives of a floating "Back to Home" row that used to sit between
|
||||
* the header and the conversation. That row was `absolute`, so it did not take
|
||||
* part in the layout — it OVERLAID the top of the scrolling region, and the
|
||||
* first line or two of a long answer arrived underneath it. It hid on
|
||||
* down-scroll to compensate, which meant the fix for a control covering the
|
||||
* answer was to make the control disappear while you read.
|
||||
*
|
||||
* The navigation it carried now lives in the header, where the panel's other
|
||||
* controls already are and where nothing can cover the response. What is left
|
||||
* here is the scroll behaviour, which was always a separate concern that had
|
||||
* been folded in because the two happened to share a listener.
|
||||
*
|
||||
* Direct `scrollTop` rather than smooth scrolling: at streaming frequency a
|
||||
* smooth scroll never catches up and the thread visibly lags the text.
|
||||
*/
|
||||
function usePinToBottom(scrollRef) {
|
||||
return 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 };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -449,13 +437,18 @@ export default function KrowAssistant({
|
||||
* so adding a conversation is adding an entry here rather than another prop
|
||||
* threaded through the panel.
|
||||
*/
|
||||
const createRole = useCreateEmployeeRole();
|
||||
const createRole = useCreateWorkerWithRole();
|
||||
const createEmployeeRole = React.useCallback(async (draft, skill, status) => {
|
||||
const result = runAction('create_employee_role', { draft, skill, status });
|
||||
if (result?.type !== 'create_employee_role') return null;
|
||||
return createRole.mutateAsync(result.data);
|
||||
}, [createRole]);
|
||||
|
||||
/* The page's layout session, mounted by the layout above both this panel and
|
||||
the page. Null on a surface that composes no tree, which is every page that
|
||||
has not migrated — and every branch that reads it checks first. */
|
||||
const uiEditing = useUiEditing();
|
||||
|
||||
const flowWriters = React.useMemo(() => ({
|
||||
position: createPosition,
|
||||
'employee-role': createEmployeeRole,
|
||||
@@ -516,13 +509,16 @@ export default function KrowAssistant({
|
||||
* used here — it was computed before the row existed.
|
||||
*/
|
||||
const queryClient = useQueryClient();
|
||||
const refreshSuggestions = React.useCallback(async () => {
|
||||
const refreshSuggestions = React.useCallback(async ({ query = '' } = {}) => {
|
||||
if (!owliverPage) return [];
|
||||
const fresh = await queryClient.fetchQuery({
|
||||
/* The empty query string is the untyped request — the same key
|
||||
`useOwliverSuggestions` uses when the composer is empty. */
|
||||
queryKey: ['owliverSuggestions', owliverPage, ''],
|
||||
queryFn: () => fetchOwliverSuggestions({ page: owliverPage }),
|
||||
/* An empty query is the untyped request — the same key
|
||||
`useOwliverSuggestions` uses when the composer is empty, and what a
|
||||
write wants: the page changed, so ask what matters on it now.
|
||||
A question passed in ranks the same catalogue AGAINST that question,
|
||||
which is what makes a follow-up follow from something. */
|
||||
queryKey: ['owliverSuggestions', owliverPage, query],
|
||||
queryFn: () => fetchOwliverSuggestions({ page: owliverPage, query }),
|
||||
staleTime: 0,
|
||||
});
|
||||
return suggestionChips(fresh || [], context.id);
|
||||
@@ -619,7 +615,11 @@ export default function KrowAssistant({
|
||||
onNavigate: goToPage,
|
||||
onAction: performAction,
|
||||
flowWriters,
|
||||
uiEditing,
|
||||
companies,
|
||||
/* The postings this caller can already see — the evidence behind
|
||||
role-aware certification suggestions. */
|
||||
postings: facts.postings || [],
|
||||
workers,
|
||||
onRefreshSuggestions: refreshSuggestions,
|
||||
onUpdatePosition,
|
||||
@@ -670,12 +670,12 @@ export default function KrowAssistant({
|
||||
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 || ''}`,
|
||||
});
|
||||
const pinToBottom = usePinToBottom(scrollRef);
|
||||
|
||||
/* Whether there is anything to leave. The header's own control is shown on
|
||||
exactly the states that are not already home — the same test the removed
|
||||
row used, now deciding a button rather than an overlay. */
|
||||
const canGoHome = view === 'history' || messages.length > 0 || Boolean(pending);
|
||||
|
||||
/* Greeting and suggestions come from live data, so they recompute only when
|
||||
the data or the page actually changes. */
|
||||
@@ -685,60 +685,98 @@ export default function KrowAssistant({
|
||||
);
|
||||
|
||||
/**
|
||||
* What the chip row actually shows, which is one of three separate things.
|
||||
* What the composer offers, and when.
|
||||
*
|
||||
* They are separate states, not one merged list, because they answer to
|
||||
* different owners. Follow-ups belong to the answer that raised them; the
|
||||
* suggestions belong to the server. Only one of them can be true at a time,
|
||||
* and the order below is that precedence.
|
||||
* Three rules, and they are about DIFFERENT questions — what to show, and
|
||||
* whether to show anything at all.
|
||||
*
|
||||
* 1. Follow-ups. When an answer ends by asking something, its chips *are*
|
||||
* the answers to it — the role list after "create a position". They are
|
||||
* never capped and never filtered, and they stand until the next turn or
|
||||
* until the reader starts typing something else.
|
||||
* WHAT. Before the first question, the page's own suggested questions: the
|
||||
* reader has asked nothing, so there is nothing to follow up and the useful
|
||||
* offer is the range of what this page can answer. After an answer, the
|
||||
* follow-ups that answer carried — questions this conversation has not
|
||||
* already covered, worked out in `nextSteps`. Never both: a thread that has
|
||||
* run out of new ground shows nothing rather than falling back to the
|
||||
* catalogue it has already been through.
|
||||
*
|
||||
* 2. The server's suggestions. From the moment there is something in the
|
||||
* composer, `GET /api/v1/owliver/suggestions` is asked what this page
|
||||
* can usefully answer for this query, and its reply is rendered in the
|
||||
* order it arrived. The panel does not rank, score, filter or reorder
|
||||
* it: which readings exist depends on the caller's role and on what is
|
||||
* actually in the database, and neither of those is knowable here.
|
||||
* WHEN. Only while the composer has focus and is empty. Suggestions used to
|
||||
* appear from the second character typed, which is the wrong moment twice
|
||||
* over: a reader who is typing has already decided what to ask, and two
|
||||
* characters is not enough to know what they mean. So typing hides them and
|
||||
* the reader's own text is never touched.
|
||||
*
|
||||
* 3. Nothing. An empty composer offers no chips at all. The panel used to
|
||||
* open on a dozen of them, which taught the range of what could be asked
|
||||
* by saying all of it at once and pushed the composer — the thing the
|
||||
* reader came for — under a wall of suggestions. The greeting still
|
||||
* carries the page's context; `buildIntro` reads the same fact sheet it
|
||||
* always did.
|
||||
* The typed-query branch is gone with it. The endpoint still takes a query
|
||||
* and `nextSteps` still uses it — that is what makes a follow-up follow from
|
||||
* something — but nothing asks it on a keystroke any more.
|
||||
*/
|
||||
const followUp = messages[messages.length - 1]?.followUp;
|
||||
const typed = input.trim();
|
||||
const [composerFocused, setComposerFocused] = React.useState(false);
|
||||
|
||||
/**
|
||||
* The request behind (2), debounced.
|
||||
*
|
||||
* The endpoint is cheap and cached per query, but a keystroke is not a
|
||||
* decision — a reader typing "positions" would otherwise fire nine requests
|
||||
* to see the answer to the ninth. A short delay means one request per pause,
|
||||
* and `placeholderData` in the hook keeps the previous answer on screen
|
||||
* meanwhile so the row does not empty and refill.
|
||||
*
|
||||
* Only asked while there is something in the composer. An empty one offers no
|
||||
* chips, so there would be nothing to render the answer into — and a reader
|
||||
* who starts typing has left the follow-up behind, which is why typing
|
||||
* supersedes it rather than being ranked against it.
|
||||
*/
|
||||
const debouncedQuery = useDebounced(typed, SUGGEST_DEBOUNCE_MS);
|
||||
const { data: suggested = EMPTY_SUGGESTIONS } = useOwliverSuggestions({
|
||||
/* The page's own questions, for a thread that has not started. One untyped
|
||||
request, cached by the hook, asked only while it could be shown. */
|
||||
const { data: pageSuggestions = EMPTY_SUGGESTIONS } = useOwliverSuggestions({
|
||||
page: owliverPage,
|
||||
query: debouncedQuery,
|
||||
enabled: Boolean(debouncedQuery),
|
||||
enabled: Boolean(owliverPage) && messages.length === 0,
|
||||
});
|
||||
|
||||
/**
|
||||
* What is on offer, which now depends on whether anything has been typed.
|
||||
*
|
||||
* TYPED — the actions this page can perform that the text is starting to
|
||||
* name, and nothing else. "Create" reaches "Create a position" here because
|
||||
* that is a skill on this page that declares an action; "he" reaches nothing,
|
||||
* and neither does "abc". This is the narrow case the composer was missing:
|
||||
* a reader typing an action intent had to finish the sentence unaided, while
|
||||
* a reader typing anything at all used to get the whole page catalogue.
|
||||
*
|
||||
* EMPTY — the follow-ups the last answer left, or, before a thread starts,
|
||||
* what this page can be asked. Unchanged.
|
||||
*/
|
||||
const reachableSkills = React.useMemo(
|
||||
() => skillsForContext(context.id, disabledSkills, preferences.customSkills || []),
|
||||
[context.id, disabledSkills, preferences.customSkills]
|
||||
);
|
||||
|
||||
const prompts = React.useMemo(() => {
|
||||
if (!typed) return followUp?.length ? followUp : EMPTY_PROMPTS;
|
||||
return suggestionChips(suggested, context.id);
|
||||
}, [typed, followUp, suggested, context.id]);
|
||||
if (typed) return actionSuggestions(typed, reachableSkills);
|
||||
if (messages.length) return followUp?.length ? followUp : EMPTY_PROMPTS;
|
||||
return suggestionChips(pageSuggestions, context.id);
|
||||
}, [typed, reachableSkills, messages.length, followUp, pageSuggestions, context.id]);
|
||||
|
||||
const showSuggestions = shouldShowSuggestions({
|
||||
focused: composerFocused, busy, view, count: prompts.length,
|
||||
});
|
||||
|
||||
/**
|
||||
* Focus, read at the composer rather than at the input.
|
||||
*
|
||||
* A chip lives inside the same region, so moving to one keeps the region
|
||||
* focused and the panel open long enough for the click to land — which a
|
||||
* `blur` handler on the textarea alone would not do. `relatedTarget` is what
|
||||
* makes "clicked outside" mean it: focus leaving for anywhere else in the
|
||||
* document closes the panel.
|
||||
*/
|
||||
const onComposerBlur = React.useCallback((event) => {
|
||||
if (!event.currentTarget.contains(event.relatedTarget)) setComposerFocused(false);
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Asking closes the panel, and only focus reopens it.
|
||||
*
|
||||
* A blur handler alone was not enough, which a live run showed: after a
|
||||
* question was sent, focus ended up on `document.body` while `composerFocused`
|
||||
* was still true, so the panel came back on its own under the finished answer
|
||||
* with nobody's cursor in the box. The subtree re-renders while the answer
|
||||
* streams, and a focus lost that way does not always arrive as a blur this
|
||||
* handler sees.
|
||||
*
|
||||
* So submitting is treated as what it is — the reader has finished with the
|
||||
* composer for now — rather than relying on a blur that may never come. The
|
||||
* state table is unchanged: focus opens it, everything else leaves it shut.
|
||||
*/
|
||||
React.useEffect(() => {
|
||||
if (busy) setComposerFocused(false);
|
||||
}, [busy]);
|
||||
|
||||
/* The newest assistant turn, which is the one that carries the rating. */
|
||||
const lastAnswerIndex = React.useMemo(
|
||||
@@ -891,6 +929,10 @@ export default function KrowAssistant({
|
||||
different places with different affordances. The panel does the
|
||||
first job only; the registry behind it is unchanged. */}
|
||||
<div className="flex items-center gap-0.5">
|
||||
{/* The way back to a clean panel, positioned in front of History */}
|
||||
{canGoHome && (
|
||||
<IconButton icon={Home} label="Back to home" variant="ghost" size="sm" onClick={goHome} />
|
||||
)}
|
||||
{/* 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
|
||||
@@ -903,9 +945,6 @@ export default function KrowAssistant({
|
||||
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
|
||||
? onRestore && (
|
||||
<IconButton icon={Minimize2} label="Restore the default workspace width" variant="ghost" size="sm" onClick={onRestore} />
|
||||
@@ -919,37 +958,6 @@ 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>
|
||||
)}
|
||||
|
||||
{/**
|
||||
* A capability test running through this panel.
|
||||
*
|
||||
@@ -1059,23 +1067,18 @@ export default function KrowAssistant({
|
||||
</div>
|
||||
|
||||
{/* ── Composer: fixed to the bottom in both states ───────────────── */}
|
||||
<div className="shrink-0 space-y-2 border-t border-white/60 px-3 pb-2.5 pt-2.5">
|
||||
{/* All suggestions on the landing screen, where they teach what can be
|
||||
asked. Capped once a thread exists, because from then on the vertical
|
||||
space belongs to the conversation. Expanded fits more per line, so it
|
||||
can afford one more.
|
||||
|
||||
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 && view === 'chat' && (
|
||||
<PromptChips
|
||||
prompts={prompts}
|
||||
onSelect={runPrompt}
|
||||
max={isEmpty || followUp?.length ? undefined : expanded ? 4 : 3}
|
||||
align="start"
|
||||
/>
|
||||
)}
|
||||
{/* Focus is tracked on the whole region rather than on the textarea, so
|
||||
reaching for a suggestion does not close the panel out from under the
|
||||
click. In normal flow, never floating: an overlay here would sit on
|
||||
top of the answer, which is the mistake the removed Back to Home row
|
||||
made. The body above is `flex-1`, so it yields the height and the
|
||||
response stays whole and scrollable. */}
|
||||
<div
|
||||
className="shrink-0 space-y-2 border-t border-white/60 px-3 pb-2.5 pt-2.5"
|
||||
onFocusCapture={() => setComposerFocused(true)}
|
||||
onBlurCapture={onComposerBlur}
|
||||
>
|
||||
<SuggestedQuestions prompts={prompts} open={showSuggestions} onSelect={runPrompt} />
|
||||
{composer}
|
||||
<p className="px-1 text-[10px] leading-tight text-ink-4">
|
||||
Owliver reads this page's data. Check anything you act on.
|
||||
|
||||
@@ -24,7 +24,13 @@ import { cn } from '@/lib/utils';
|
||||
* Arrow keys move between chips, so the whole set is one tab stop.
|
||||
*/
|
||||
/** @param {any} props */
|
||||
export function PromptChips({ prompts = [], onSelect, max = 0, align = 'center', className = '' }) {
|
||||
export function PromptChips({
|
||||
prompts = [], onSelect, max = 0, align = 'center', className = '',
|
||||
/* Taken out of the tab order while the row is collapsed but still mounted:
|
||||
a chip inside a zero-height container is invisible, and a Tab that lands on
|
||||
something invisible is a keyboard user losing their place. */
|
||||
focusable = true,
|
||||
}) {
|
||||
const chipRefs = React.useRef([]);
|
||||
const visible = max ? prompts.slice(0, max) : prompts;
|
||||
|
||||
@@ -59,6 +65,7 @@ export function PromptChips({ prompts = [], onSelect, max = 0, align = 'center',
|
||||
type="button"
|
||||
onClick={() => onSelect(prompt)}
|
||||
onKeyDown={(e) => onKeyDown(e, i)}
|
||||
tabIndex={focusable ? undefined : -1}
|
||||
title={prompt.prompt}
|
||||
className={cn(
|
||||
/* 14px is the radius `rounded-full` already produces on a one-line
|
||||
|
||||
@@ -22,10 +22,42 @@ import { usePageAction } from './PageContext';
|
||||
* snapshot, and settled blocks must not re-render with it.
|
||||
*/
|
||||
|
||||
const INLINE = /(\*\*[^*]+\*\*|_[^_]+_)/g;
|
||||
const INLINE = /(\*\*[^*]+\*\*|_[^_]+_|\[[^\]\n]+\]\([^)\s]*\))/g;
|
||||
const LINK = /^\[([^\]\n]+)\]\(([^)\s]*)\)$/;
|
||||
|
||||
/** Inline `**bold**` and `_italic_`. Kept deliberately small — structure is
|
||||
* carried by blocks, not by markup inside a paragraph. */
|
||||
/**
|
||||
* Where a link in an answer is allowed to point.
|
||||
*
|
||||
* An allow-list, and it is a security boundary rather than a tidiness rule: the
|
||||
* text being parsed here was written by a model, and a model reading a document
|
||||
* that says "link to javascript:…" is exactly the injection §I7 of CLAUDE.md
|
||||
* calls untrusted input. Anything not on this list renders as the plain text it
|
||||
* came from — visible, inert, and obvious.
|
||||
*
|
||||
* `#` alone is deliberately absent. A bare empty anchor is the shape the
|
||||
* citation sanitiser removes; one that reaches here is a link to nowhere, and
|
||||
* showing its label as text is better than an anchor that does nothing.
|
||||
*/
|
||||
const isSafeHref = (href) => (
|
||||
/^\/(?!\/)/.test(href) // in-app route
|
||||
|| /^#[^\s]+$/.test(href) // an anchor on this page, but not a bare '#'
|
||||
|| /^https?:\/\//i.test(href) // the open web
|
||||
|| /^mailto:[^\s]+$/i.test(href)
|
||||
);
|
||||
|
||||
/**
|
||||
* Inline `**bold**`, `_italic_` and `[label](href)`.
|
||||
*
|
||||
* Links were the gap: `markdownToBlocks` never touched them, and this renderer
|
||||
* had no case for them, so `[staffing policy](#staffing)` reached the reader as
|
||||
* its own source. Structure is still carried by blocks rather than by markup —
|
||||
* this stays three constructs, not a markdown library.
|
||||
*
|
||||
* A citation-shaped link never arrives here at all. `[337042b3](#)` is removed
|
||||
* upstream in `provider.js`, by the wrapper's shape, before a block is built —
|
||||
* so the two concerns stay apart: the sanitiser decides what is an internal
|
||||
* reference, and this decides how a real link looks.
|
||||
*/
|
||||
function Inline({ value }) {
|
||||
const parts = React.useMemo(() => String(value).split(INLINE).filter(Boolean), [value]);
|
||||
|
||||
@@ -36,6 +68,33 @@ function Inline({ value }) {
|
||||
if (part.startsWith('_') && part.endsWith('_')) {
|
||||
return <em key={i} className="text-ink-3">{part.slice(1, -1)}</em>;
|
||||
}
|
||||
|
||||
const link = LINK.exec(part);
|
||||
if (link) {
|
||||
const [, label, href] = link;
|
||||
if (!isSafeHref(href)) return part;
|
||||
/* An in-app route goes through the router, like every other internal link
|
||||
in this file — a full page load would throw away the conversation the
|
||||
reader is being pointed away from. Everything else is an anchor, and
|
||||
anything leaving the app opens away from it. */
|
||||
const external = /^https?:\/\//i.test(href);
|
||||
const className = 'font-medium text-krow-blue underline decoration-krow-blue/30 underline-offset-2'
|
||||
+ ' transition-colors hover:decoration-krow-blue focus-visible:outline-none'
|
||||
+ ' focus-visible:ring-2 focus-visible:ring-krow-blue/50 rounded-sm';
|
||||
|
||||
if (href.startsWith('/')) return <Link key={i} to={href} className={className}>{label}</Link>;
|
||||
return (
|
||||
<a
|
||||
key={i}
|
||||
href={href}
|
||||
className={className}
|
||||
{...(external ? { target: '_blank', rel: 'noreferrer noopener' } : null)}
|
||||
>
|
||||
{label}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
return part;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -53,7 +53,22 @@ export function createAgentProvider({ baseUrl = '/api/v1' } = {}) {
|
||||
return {
|
||||
id: 'agent',
|
||||
|
||||
async *stream({ question, agent = null, confirmation = null, agentVersion = 0, signal }) {
|
||||
/**
|
||||
* Every snapshot leaves through here, and every snapshot is sanitised.
|
||||
*
|
||||
* The wrapper is the point. Below it there are four ways a response gets
|
||||
* built — streamed deltas, a completed run, a bounded run's trailing
|
||||
* message, and the two failure notes — and only one of them passes through
|
||||
* the markdown parser that removes citation ids. Sanitising at the yield
|
||||
* rather than at each construction means a fifth way, added later, cannot
|
||||
* reintroduce the leak by forgetting a call.
|
||||
*/
|
||||
async *stream(request) {
|
||||
for await (const snapshot of this.run(request)) yield sanitizeBlocks(snapshot);
|
||||
},
|
||||
|
||||
/** The run itself. Public only so `stream` can wrap it; call `stream`. */
|
||||
async *run({ question, agent = null, confirmation = null, agentVersion = 0, signal }) {
|
||||
/* No agent, no run. The panel resolves which agent covers the page before
|
||||
calling; reaching here without one means the routing layer changed and
|
||||
this should say so rather than guess at an agent id. */
|
||||
@@ -169,7 +184,9 @@ async function* readRunStream(response, signal) {
|
||||
|
||||
if (typeof event.delta === 'string') {
|
||||
text += event.delta;
|
||||
yield markdownToBlocks(text);
|
||||
/* Still arriving: the frontier rules apply, so a citation split
|
||||
across two frames is never rendered half-written. */
|
||||
yield markdownToBlocks(text, { partial: true });
|
||||
continue;
|
||||
}
|
||||
if (event.run) final = event.run;
|
||||
@@ -226,6 +243,290 @@ function toBlocks(run) {
|
||||
return blocks;
|
||||
}
|
||||
|
||||
/* ── Citations ──────────────────────────────────────────────────────────── */
|
||||
|
||||
/**
|
||||
* Why any of this exists.
|
||||
*
|
||||
* The backend asks the model to cite: `knowledge/context.go` tells it "Each
|
||||
* <source> carries an id: cite it when you use what it says", and the
|
||||
* `knowledge_search` tool repeats it. What neither does is say HOW — so the
|
||||
* model picks a format, and picks a different one on a different day. The ids
|
||||
* themselves are `knowledge_chunks.id`, which are UUIDs, and the model quotes
|
||||
* them whole or truncated to their first block.
|
||||
*
|
||||
* The panel has no citation surface to render any of that into, so whatever
|
||||
* shape the model chose arrives on screen as raw markup. The formats seen so
|
||||
* far are a `<cite>` tag and a markdown link to an empty anchor; the rules
|
||||
* below are written against the SHAPE of an identifier rather than against
|
||||
* either format's syntax, so a third spelling of the same idea is far more
|
||||
* likely to be caught than to be a new bug.
|
||||
*
|
||||
* A citation id is hex and dashes — a UUID or a leading run of one. That is
|
||||
* what makes `10 applications`, `97% coverage` and `113 shifts` safe: decimal
|
||||
* counts in prose are not addresses, are never inside citation syntax, and no
|
||||
* rule here looks at a bare number.
|
||||
*/
|
||||
const CITATION_ID = String.raw`[0-9a-f]{4,}(?:-[0-9a-f]{4,})*`;
|
||||
|
||||
/**
|
||||
* The tag spelling — `<cite id="…">…</cite>`, whatever attributes it carries.
|
||||
*
|
||||
* Matched by TAG NAME, never by id: the ids are minted per run, so a rule
|
||||
* written against the ones in today's output would let tomorrow's through.
|
||||
*/
|
||||
const CITATION_TAG = /<\/?cit(?:e|ation)\b[^>]*>/gi;
|
||||
|
||||
/**
|
||||
* The link spelling, and the brackets the model wraps a run of them in —
|
||||
* `([337042b3](#), [2b94bc43](#))`.
|
||||
*
|
||||
* Identified by two conditions TOGETHER, never either alone: the target must be
|
||||
* a bare `#` anchor, AND the label must look like an identifier rather than
|
||||
* words. A real link has a real href, a real anchor link has a destination
|
||||
* after the `#`, and a link a person would click has a label they could read.
|
||||
* Requiring both is what keeps `[staffing policy](#staffing)`, `[Read more](/docs)`
|
||||
* and even `[Read more](#)` on screen.
|
||||
*
|
||||
* The group is removed whole rather than link by link, because removing them
|
||||
* one at a time leaves `(, )` behind — which reads worse than the ids did.
|
||||
*/
|
||||
const CITATION_GROUP = new RegExp(
|
||||
String.raw`\s*\(\s*\[${CITATION_ID}\]\(#\)(?:\s*,\s*\[${CITATION_ID}\]\(#\))*\s*\)`,
|
||||
'gi'
|
||||
);
|
||||
const CITATION_LINK = new RegExp(String.raw`\s*\[${CITATION_ID}\]\(#\)`, 'gi');
|
||||
|
||||
/**
|
||||
* The prose spelling: the model narrating the attribute rather than marking it
|
||||
* up — `(id `f34e8ef0-…`)`, `(reference `…`)`, `(source: `…`)`.
|
||||
*
|
||||
* Why this exists is the same reason the other two do. `context.go` hands the
|
||||
* model `<source id="…">` and tells it to cite the id without saying how, so
|
||||
* the model reaches for whatever syntax feels natural that day. This one is not
|
||||
* markup at all — it is the id written out in a parenthesis, which is why no
|
||||
* tag rule and no link rule saw it.
|
||||
*
|
||||
* THE WRAPPER IS WHAT IDENTIFIES IT, NEVER THE ID. That distinction is the
|
||||
* whole rule and it has to survive future edits: a worker's record id is the
|
||||
* same shape as a chunk id, so a rule that recognised ids rather than wrappers
|
||||
* would delete real data off a profile. `Worker ID: f34e8ef0-…` has no
|
||||
* parenthesis, so nothing here reaches it; `(id `f34e8ef0-…`)` is a
|
||||
* parenthesis containing a reference word and nothing but ids, which is not a
|
||||
* shape prose takes for any other reason.
|
||||
*
|
||||
* Backticks are optional on each side independently, because a model that opens
|
||||
* a code span and forgets to close it before the bracket must not defeat this.
|
||||
*/
|
||||
const REF_WORD = String.raw`(?:ids?|refs?|references?|sources?|citations?)`;
|
||||
const TICKED_ID = String.raw`\x60?${CITATION_ID}\x60?`;
|
||||
const CITATION_LABELLED = new RegExp(
|
||||
String.raw`\s*\(\s*${REF_WORD}\s*:?\s*${TICKED_ID}(?:\s*,\s*${TICKED_ID})*\s*\)`,
|
||||
'gi'
|
||||
);
|
||||
|
||||
/**
|
||||
* Ids in square brackets with nothing else in them — `[uuid]`, `[uuid, uuid]`.
|
||||
*
|
||||
* A markdown link is `[label](target)`; a bracket holding only identifiers is
|
||||
* not a link and is not something prose does. The lookahead leaves anything
|
||||
* followed by `(` to the link rules, so a genuine link whose label happens to
|
||||
* be a reference number keeps its destination and stays on screen.
|
||||
*/
|
||||
/* Stricter than CITATION_ID, and only here. A bare bracket has no reference
|
||||
word to disambiguate it, so the id itself has to carry the evidence: at least
|
||||
eight hex characters, or a dashed group. Without that `[2026]` is four hex
|
||||
digits and a year in brackets would disappear from an answer. */
|
||||
const BRACKETABLE_ID = String.raw`(?:[0-9a-f]{8,}|[0-9a-f]{4,}(?:-[0-9a-f]{4,})+)`;
|
||||
const CITATION_BRACKETED = new RegExp(
|
||||
String.raw`\s*\[\s*\x60?${BRACKETABLE_ID}\x60?(?:\s*,\s*\x60?${BRACKETABLE_ID}\x60?)*\s*\](?!\()`,
|
||||
'gi'
|
||||
);
|
||||
|
||||
/**
|
||||
* Whatever is still arriving at the end of the text.
|
||||
*
|
||||
* The streaming half of the problem, and it is a real one rather than a
|
||||
* theoretical one: `readRunStream` re-parses the WHOLE accumulated answer on
|
||||
* every delta, so a citation split across two frames is a state the reader can
|
||||
* see. `…before a first shift ([3370` renders for as long as the next delta
|
||||
* takes to arrive.
|
||||
*
|
||||
* Both rules are anchored to the end of the text, so they can only ever
|
||||
* describe the frontier of the stream and never something the answer has
|
||||
* already moved past. The fragment is held back until it completes, at which
|
||||
* point the rules above remove it properly — which is buffering, expressed as
|
||||
* a parse rather than as a second copy of the text.
|
||||
*/
|
||||
const CITATION_TAG_PARTIAL = /<\/?[a-z]*$|<\/?cit(?:e|ation)\b[^>]*$/i;
|
||||
const CITATION_LINK_PARTIAL = new RegExp([
|
||||
/* An open bracket holding at least one COMPLETE citation and not yet closed:
|
||||
`…shift ([337042b3](#)` and `…shift ([337042b3](#), `. Without this the
|
||||
complete link is removed by the rule above and the `(` is stranded. */
|
||||
String.raw`\s*\((?:\s*\[${CITATION_ID}\]\(#\)\s*,?)+\s*(?:\[[0-9a-f-]*(?:\](?:\(#?)?)?)?$`,
|
||||
/* One part-written, with or without its bracket: `([3370`, `[337042b3`,
|
||||
`[337042b3](`, `[337042b3](#`. */
|
||||
String.raw`\s*\(?\s*\[[0-9a-f-]*(?:\](?:\(#?)?)?$`,
|
||||
].join('|'), 'i');
|
||||
|
||||
/**
|
||||
* The same two, part-written.
|
||||
*
|
||||
* `Policy (id `f34e8ef0-9a01-5921` is a state the reader can see, because the
|
||||
* stream re-parses everything on every delta. Both are anchored to the end, so
|
||||
* they describe only the frontier.
|
||||
*
|
||||
* The labelled rule accepts any short leading word rather than only a reference
|
||||
* word, because `(i` and `(id` are prefixes of one and `(` is a prefix of
|
||||
* everything. The cost is that an ordinary parenthetical is held back for the
|
||||
* frames between its bracket and its first non-hex character —
|
||||
* `Omar Haddad (hired 2026-01-0` waits, `…(hired 2026-01-04) starts` does not.
|
||||
* A parenthesis arriving a frame late is not something a reader can notice; a
|
||||
* half-written reference id is exactly what they reported.
|
||||
*/
|
||||
const CITATION_LABELLED_PARTIAL = new RegExp(
|
||||
String.raw`\s*\((?:[a-z]{0,12}[\s:]*\x60?[0-9a-f-]*(?:\s*,\s*\x60?[0-9a-f-]*)*\x60?)?$`,
|
||||
'i'
|
||||
);
|
||||
const CITATION_BRACKETED_PARTIAL = new RegExp(
|
||||
String.raw`\s*\[\s*\x60?[0-9a-f-]*(?:\s*,\s*\x60?[0-9a-f-]*)*\x60?$`,
|
||||
'i'
|
||||
);
|
||||
|
||||
/**
|
||||
* What a lifted citation leaves behind.
|
||||
*
|
||||
* `check ()`, `check (, )`, `check .` — the wrapper is gone and its punctuation
|
||||
* is not, and a sentence ending in an empty bracket reads as broken markup
|
||||
* rather than as a clean sentence. Applied after the removals, never before.
|
||||
*/
|
||||
const EMPTY_PARENS = /\s*\(\s*[,;]*\s*\)/g;
|
||||
const SPACE_BEFORE_PUNCTUATION = /\s+([.,;:!?])/g;
|
||||
const DOUBLED_SPACES = / {2,}/g;
|
||||
|
||||
/**
|
||||
* Removes citation markup, keeping the sentence inside it.
|
||||
*
|
||||
* The wrapper is addressing, not content — it tells a client which retrieved
|
||||
* chunk a claim came from — and with nowhere to render it the honest move is to
|
||||
* show the claim and drop the envelope. The backend's citation metadata is
|
||||
* untouched: it is still on the run, still in the trajectory, and this only
|
||||
* decides what reaches a reader.
|
||||
*
|
||||
* Content is never altered, only the wrapper around it, so markdown inside a
|
||||
* citation — bold, a bullet, a table row — parses exactly as it would have
|
||||
* unwrapped.
|
||||
*
|
||||
* If the panel ever grows a real citation affordance, this is the seam: parse
|
||||
* the ids out here into a block the renderer can draw, rather than discarding
|
||||
* them. Nothing else has to move.
|
||||
*/
|
||||
export function stripCitations(markdown, { partial = false } = {}) {
|
||||
let out = String(markdown ?? '')
|
||||
.replace(CITATION_TAG, '')
|
||||
.replace(CITATION_GROUP, '')
|
||||
.replace(CITATION_LABELLED, '')
|
||||
.replace(CITATION_BRACKETED, '');
|
||||
|
||||
/**
|
||||
* The frontier rules, and ONLY while there is a frontier.
|
||||
*
|
||||
* They describe something that is still being written, so they are wrong to
|
||||
* apply to a finished answer — and quietly so. `…and note [12]` ends in a
|
||||
* bracket holding two hex characters, which is indistinguishable from the
|
||||
* first two characters of an id still arriving. Mid-stream, holding it back
|
||||
* for a frame is right. At the end of a completed answer there is nothing
|
||||
* more coming, the bracket is all there will ever be, and removing it deletes
|
||||
* a footnote marker from the reader's answer.
|
||||
*
|
||||
* The caller knows which it is: `readRunStream` passes `partial` on a delta
|
||||
* and not on the final snapshot. That is the only place the distinction
|
||||
* exists, so it is the only place it can be made.
|
||||
*
|
||||
* Order is load-bearing. `…shift ([337042b3](#),` is a COMPLETE link inside
|
||||
* an unclosed bracket — strip the link first and `(,` is left on screen,
|
||||
* which is the broken bracket this exists to prevent. Matching the
|
||||
* unterminated group first takes the whole fragment.
|
||||
*/
|
||||
if (partial) {
|
||||
out = out
|
||||
.replace(CITATION_TAG_PARTIAL, '')
|
||||
.replace(CITATION_LINK_PARTIAL, '')
|
||||
.replace(CITATION_LABELLED_PARTIAL, '')
|
||||
.replace(CITATION_BRACKETED_PARTIAL, '');
|
||||
}
|
||||
|
||||
return out
|
||||
.replace(CITATION_LINK, '')
|
||||
.replace(EMPTY_PARENS, '')
|
||||
.replace(SPACE_BEFORE_PUNCTUATION, '$1')
|
||||
.replace(DOUBLED_SPACES, ' ');
|
||||
}
|
||||
|
||||
/**
|
||||
* Keys that carry text a person reads, on any block.
|
||||
*
|
||||
* An allow-list rather than a deny-list, because the two mistakes do not cost
|
||||
* the same: missing a display field leaks an id, while sanitising an address
|
||||
* field would corrupt a confirmation token, a route or a record id and break
|
||||
* what it points at. A new block type gets its display keys covered for free; a
|
||||
* new addressing key is safe by default.
|
||||
*/
|
||||
const DISPLAY_KEYS = new Set([
|
||||
'text', 'sub', 'label', 'title', 'summary', 'caption',
|
||||
'description', 'detail', 'note', 'heading', 'hint',
|
||||
]);
|
||||
|
||||
/** Keys whose value is a list of sentences rather than one. */
|
||||
const DISPLAY_LISTS = new Set(['items', 'warnings', 'details', 'lines', 'rows']);
|
||||
|
||||
/**
|
||||
* Citation-proofs a whole response, whatever shape it arrived in.
|
||||
*
|
||||
* `markdownToBlocks` strips the model's markdown, and for a completed answer
|
||||
* that is the whole story. It is NOT the whole story for the response: the same
|
||||
* provider also emits `note(run.message)` when a run did not complete,
|
||||
* `note(event.error.message)` when the stream fails, and `confirmation(payload)`
|
||||
* carrying server wording composed around model-supplied arguments. None of
|
||||
* those go through the markdown parser, so each was a way for an id to reach
|
||||
* the DOM without passing the one place that removes them.
|
||||
*
|
||||
* Rather than a `stripCitations` call at each — three sites today, and a fourth
|
||||
* the next time the provider learns to say something — every block the agent
|
||||
* provider yields goes through here.
|
||||
*
|
||||
* Walks recursively so nested shapes are reached (a table's rows, a
|
||||
* confirmation's warnings, an insight's items) and touches only the keys above:
|
||||
* `token`, `id`, `route`, `to` and everything else addressing-like is left
|
||||
* exactly as the server sent it.
|
||||
*/
|
||||
export function sanitizeBlocks(blocks) {
|
||||
return (blocks || []).map((block) => sanitizeValue(block, null));
|
||||
}
|
||||
|
||||
function sanitizeValue(value, key) {
|
||||
if (typeof value === 'string') {
|
||||
return DISPLAY_KEYS.has(key) || DISPLAY_LISTS.has(key) ? stripCitations(value) : value;
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
/* The key travels into the elements, strings and objects alike. A string in
|
||||
`items` is display text; an object in `rows` is a row, and only the key
|
||||
says so — its own cell keys are positional (`c0`, `c1`) and carry no
|
||||
meaning at all. An object in `columns` still defers to its own keys. */
|
||||
return value.map((entry) => sanitizeValue(entry, key));
|
||||
}
|
||||
if (value && typeof value === 'object') {
|
||||
const out = {};
|
||||
for (const [k, v] of Object.entries(value)) {
|
||||
/* A table row is `{ c0: 'Applied', c1: '4' }` — the cell keys are
|
||||
positional and carry no meaning, so the row itself marks them. */
|
||||
out[k] = key === 'rows' && typeof v === 'string' ? stripCitations(v) : sanitizeValue(v, k);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Turns a model's markdown into the block vocabulary the panel already renders.
|
||||
*
|
||||
@@ -247,8 +548,8 @@ function toBlocks(run) {
|
||||
* parser would be a large dependency in exchange for handling footnotes nobody
|
||||
* writes.
|
||||
*/
|
||||
export function markdownToBlocks(markdown) {
|
||||
const lines = String(markdown).replace(/\r\n/g, '\n').split('\n');
|
||||
export function markdownToBlocks(markdown, { partial = false } = {}) {
|
||||
const lines = stripCitations(markdown, { partial }).replace(/\r\n/g, '\n').split('\n');
|
||||
const blocks = [];
|
||||
let paragraph = [];
|
||||
let listItems = null;
|
||||
@@ -353,7 +654,15 @@ function parseTable(lines, start) {
|
||||
* left alone — those go through `Inline`, which renders bold properly.
|
||||
*/
|
||||
function stripInline(value) {
|
||||
return String(value).replace(/\*\*(.+?)\*\*/g, '$1').replace(/`(.+?)`/g, '$1').trim();
|
||||
return String(value)
|
||||
.replace(/\*\*(.+?)\*\*/g, '$1')
|
||||
.replace(/`(.+?)`/g, '$1')
|
||||
/* A link keeps its label and loses its target. Headings and cells are drawn
|
||||
as plain strings by their components, so an anchor cannot survive here —
|
||||
and the label alone reads correctly, where the raw `[label](href)` does
|
||||
not. `Inline` renders the real thing everywhere a link CAN be one. */
|
||||
.replace(/\[([^\]\n]+)\]\([^)\s]*\)/g, '$1')
|
||||
.trim();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -438,7 +747,7 @@ export function createAssistantProvider() {
|
||||
export function createUnconfiguredProvider() {
|
||||
return {
|
||||
id: 'unconfigured',
|
||||
// eslint-disable-next-line require-yield
|
||||
|
||||
async *stream() {
|
||||
yield [note(
|
||||
'Owliver is not configured on this deployment. Set VITE_AGENT_API and give the '
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { doc, heading, insights, list, note, skillSection, text } from './blocks';
|
||||
import { resolveUiEdit } from './uiEdit';
|
||||
import { ASSISTANT_CONTEXTS } from './contexts';
|
||||
import { poolFor } from '@/lib/workforce';
|
||||
import { matchSkill } from '@/lib/skills/registry';
|
||||
@@ -634,7 +635,7 @@ function declaredAnswer({ skill, capability, question, skillContext }) {
|
||||
*/
|
||||
function resolveSkill({
|
||||
question, contextId, disabledSkills, customSkills, roles, skillCategories, courses,
|
||||
companies = [], skillContext = null,
|
||||
companies = [], postings = null, skillContext = null,
|
||||
}) {
|
||||
/**
|
||||
* One question, one skill, then one way of answering it.
|
||||
@@ -743,7 +744,7 @@ function resolveSkill({
|
||||
*/
|
||||
const registry = flowFor(skill);
|
||||
if (registry) {
|
||||
return { kind: 'skill', skill, ...beginFlow({ registry, question, skill, ctx: { roles, companies } }) };
|
||||
return { kind: 'skill', skill, ...beginFlow({ registry, question, skill, ctx: { roles, companies, postings } }) };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -866,6 +867,7 @@ export function resolveIntent({
|
||||
company question. Read off the postings the caller can already see, so it
|
||||
expands nobody's view — see the `@companies` note in flows/position.js. */
|
||||
companies = [],
|
||||
postings = null,
|
||||
/**
|
||||
* The active agent, and where the reader is.
|
||||
*
|
||||
@@ -880,6 +882,14 @@ export function resolveIntent({
|
||||
was one. Only the draft flow reads it; a typed question carries none and
|
||||
resolves exactly as it always did. */
|
||||
positionId = null,
|
||||
/**
|
||||
* The layout session for this page, when there is one.
|
||||
*
|
||||
* Carries the tree on screen and whether something is already being
|
||||
* previewed. Absent — or on a page that composes no tree — every branch below
|
||||
* resolves exactly as it did before this existed.
|
||||
*/
|
||||
ui = null,
|
||||
}) {
|
||||
const context = ASSISTANT_CONTEXTS[contextId] ?? null;
|
||||
|
||||
@@ -911,9 +921,21 @@ export function resolveIntent({
|
||||
const draftIntent = resolveDraftAction(question, workforce || { positions: [] }, positionId);
|
||||
if (draftIntent) return draftIntent;
|
||||
|
||||
/**
|
||||
* 1b. Changing the page itself.
|
||||
*
|
||||
* Ahead of the skills because a request to hide a section is about the
|
||||
* interface, and a skill trigger reading the same words would answer about
|
||||
* the data behind it. Tightly gated: `resolveUiEdit` returns null unless the
|
||||
* page composes a tree AND the words name something on it, a registered
|
||||
* panel type, or the layout — so an ordinary question is never taken.
|
||||
*/
|
||||
const uiIntent = resolveUiEdit({ question, ui });
|
||||
if (uiIntent) return uiIntent;
|
||||
|
||||
/* 2. Current page skills — specific triggers, ahead of the general reader. */
|
||||
const skill = resolveSkill({
|
||||
question, contextId, disabledSkills, customSkills, roles, skillCategories, courses, companies,
|
||||
question, contextId, disabledSkills, customSkills, roles, skillCategories, courses, companies, postings,
|
||||
/* The envelope travels beside the collections rather than replacing them:
|
||||
a resolver reads records, and the envelope says where the reader is. A
|
||||
source that needs a position still finds it exactly where it always was. */
|
||||
|
||||
177
src/components/ai-assistant/uiEdit.js
Normal file
177
src/components/ai-assistant/uiEdit.js
Normal file
@@ -0,0 +1,177 @@
|
||||
import { doc, list, note, text } from './blocks';
|
||||
import { matchUiEdit } from '@/lib/ui/intent';
|
||||
import { outlineTree } from '@/lib/ui/inspect';
|
||||
import { dataSourceLabel } from '@/lib/skills/surfaces';
|
||||
|
||||
/**
|
||||
* Owliver's half of a layout change.
|
||||
*
|
||||
* Turns a request into an intent the panel can act on, and into the words that
|
||||
* go back. The understanding itself is in `lib/ui/intent.js`; this decides what
|
||||
* to say about it.
|
||||
*
|
||||
* Every outcome is one of four kinds, and the split matters:
|
||||
*
|
||||
* - `ui-preview` an operation to show, not to keep
|
||||
* - `ui-apply` / `ui-discard` acting on what is already shown
|
||||
* - `ui-answer` a question back, or a refusal — nothing changes
|
||||
*
|
||||
* A preview is never applied in the same turn. The person asked for a change;
|
||||
* they have not yet seen it, and agreeing to something unseen is not agreement.
|
||||
*/
|
||||
|
||||
/** The chips offered while something is being previewed. */
|
||||
const PREVIEW_CHIPS = [
|
||||
{ label: 'Apply', prompt: 'Apply the layout change' },
|
||||
{ label: 'Discard', prompt: 'Discard the layout change' },
|
||||
];
|
||||
|
||||
/**
|
||||
* Read a layout request.
|
||||
*
|
||||
* Returns null for anything that is not one, which is most of what is typed —
|
||||
* and returning null is what leaves every existing Owliver answer exactly as it
|
||||
* was. The gate is in `matchUiEdit`: a verb alone is never enough.
|
||||
*/
|
||||
export function resolveUiEdit({ question, ui }) {
|
||||
if (!ui?.available) return null;
|
||||
|
||||
const match = matchUiEdit(question, {
|
||||
tree: ui.tree,
|
||||
registry: ui.registry,
|
||||
role: ui.role,
|
||||
previewing: ui.previewing,
|
||||
/* Which page this is. Owliver may only offer, and only accept, what this
|
||||
page can actually hold — the same scope the visual editor's picker uses,
|
||||
so the two can never disagree about what is addable here. */
|
||||
page: ui.page,
|
||||
});
|
||||
if (!match) return null;
|
||||
|
||||
switch (match.kind) {
|
||||
case 'inspect':
|
||||
return { kind: 'ui-answer', doc: describe(ui.tree, ui.registry) };
|
||||
|
||||
case 'apply':
|
||||
return {
|
||||
kind: 'ui-apply',
|
||||
doc: doc(text('Saved. This page will look like this the next time you open it.')),
|
||||
};
|
||||
|
||||
case 'discard':
|
||||
return {
|
||||
kind: 'ui-discard',
|
||||
doc: doc(text('Put back the way it was. Nothing was saved.')),
|
||||
};
|
||||
|
||||
case 'plan':
|
||||
return {
|
||||
kind: 'ui-preview',
|
||||
op: match.op,
|
||||
doc: doc(
|
||||
text(`${match.summary}. This is a preview — nothing is saved yet.`),
|
||||
note('Choose Apply to keep it, or Discard to put it back.')
|
||||
),
|
||||
followUp: PREVIEW_CHIPS,
|
||||
};
|
||||
|
||||
/**
|
||||
* More than one thing fits.
|
||||
*
|
||||
* Named back rather than guessed at. Editing the wrong section while
|
||||
* somebody is looking at another one is the failure the whole target
|
||||
* resolver exists to avoid, and a coin toss here would reintroduce it.
|
||||
*/
|
||||
case 'ambiguous':
|
||||
return {
|
||||
kind: 'ui-answer',
|
||||
doc: doc(
|
||||
text('More than one part of this page fits that. Which did you mean?'),
|
||||
list(match.candidates.map((node) => `${node.title || node.label} (${node.id})`))
|
||||
),
|
||||
followUp: match.candidates.slice(0, 3).map((node) => ({
|
||||
label: node.title || node.label,
|
||||
prompt: `${node.id}`,
|
||||
})),
|
||||
};
|
||||
|
||||
case 'unknown':
|
||||
return {
|
||||
kind: 'ui-answer',
|
||||
doc: doc(
|
||||
text('I could not find that on this page.'),
|
||||
note('Ask what is on this page to see what can be changed.')
|
||||
),
|
||||
followUp: [{ label: 'What is on this page?', prompt: 'What is on this page?' }],
|
||||
};
|
||||
|
||||
/** A type nobody has registered. Offered the real ones rather than invented. */
|
||||
case 'unknown-type':
|
||||
return {
|
||||
kind: 'ui-answer',
|
||||
doc: doc(
|
||||
text('I do not have that kind of panel.'),
|
||||
text(`I can use: ${match.offered.join(', ')}.`)
|
||||
),
|
||||
};
|
||||
|
||||
/**
|
||||
* A shape with no reading named.
|
||||
*
|
||||
* The one place a data source could be invented, and the place it is most
|
||||
* firmly refused: the choices come from the closed vocabulary, and the
|
||||
* person picks.
|
||||
*/
|
||||
case 'needs-source':
|
||||
return {
|
||||
kind: 'ui-answer',
|
||||
doc: doc(
|
||||
text(`What should the ${match.type.label} show?`),
|
||||
list(match.options.map(dataSourceLabel))
|
||||
),
|
||||
followUp: match.options.slice(0, 3).map((id) => ({
|
||||
label: dataSourceLabel(id),
|
||||
prompt: `Add a ${match.type.label} showing ${dataSourceLabel(id)}`,
|
||||
})),
|
||||
};
|
||||
|
||||
/**
|
||||
* Asked to apply or discard with nothing being previewed.
|
||||
*
|
||||
* Answered here rather than left to fall through, because falling through
|
||||
* sent the panel's own chip text to the model, which replied — correctly
|
||||
* for what it is — that layout changes are not in its scope. The honest
|
||||
* answer is that there is nothing to act on.
|
||||
*/
|
||||
case 'nothing-previewed':
|
||||
return {
|
||||
kind: 'ui-answer',
|
||||
doc: doc(
|
||||
text(match.op === 'apply'
|
||||
? 'There is nothing to apply — no layout change is being previewed.'
|
||||
: 'There is nothing to discard — no layout change is being previewed.'),
|
||||
note('Ask what is on this page to see what can be changed.')
|
||||
),
|
||||
followUp: [{ label: 'What is on this page?', prompt: 'What is on this page?' }],
|
||||
};
|
||||
|
||||
case 'refused':
|
||||
return { kind: 'ui-answer', doc: doc(text(match.message)) };
|
||||
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** What is on the page, as a reading rather than a change. */
|
||||
function describe(tree, registry) {
|
||||
const lines = outlineTree(tree, { registry });
|
||||
if (!lines.length) {
|
||||
return doc(text('This page is not one I can rearrange yet.'));
|
||||
}
|
||||
return doc(
|
||||
text('This page is made of these parts. You can hide, show or reorder any of them.'),
|
||||
list(lines),
|
||||
note('Say for example "hide the audit log" or "move the timeline to the top".')
|
||||
);
|
||||
}
|
||||
@@ -185,6 +185,60 @@ function normalizeDraftChip(chip) {
|
||||
return chip;
|
||||
}
|
||||
|
||||
/**
|
||||
* One question, reduced to what it asks.
|
||||
*
|
||||
* Case, surrounding space and a trailing question mark are not differences, so
|
||||
* "What should I do next?" and "what should i do next" are one question and are
|
||||
* not offered twice.
|
||||
*/
|
||||
const asQuestion = (value) => String(value || '').trim().toLowerCase().replace(/[?.!]+$/, '');
|
||||
|
||||
/**
|
||||
* The chips to offer after an answer: what this conversation has not covered.
|
||||
*
|
||||
* Two rules, and the second is the one that matters. The suggestions are ranked
|
||||
* by the SERVER against the question just asked — the panel does not decide what
|
||||
* is worth asking, it only decides what has already been said — and then
|
||||
* anything this thread has asked or already offered is removed.
|
||||
*
|
||||
* Without that second rule the row repeats. A page carries a handful of intents
|
||||
* and the top of that list barely moves between turns, so the same three chips
|
||||
* come back after every answer, including the one the reader has just pressed.
|
||||
* Removing what has been used leaves genuinely new ground each time and runs out
|
||||
* honestly rather than looping.
|
||||
*
|
||||
* There is NO fallback to the page's own ranking, and that is the correction a
|
||||
* live run forced. Asking "Summarize hiring activity" matches nothing in the
|
||||
* catalogue, so nothing was excluded, so the fallback returned the page's top
|
||||
* three — and the reader got "How healthy is the platform right now?" under an
|
||||
* answer about hiring activity, which is the generic-catalogue behaviour this
|
||||
* function exists to end. A page ranking is what to ask on a PAGE; it is not a
|
||||
* follow-up to anything. When the conversation has no next question, the honest
|
||||
* answer is none.
|
||||
*/
|
||||
export async function nextSteps({ question, history, refresh }) {
|
||||
if (!refresh) return undefined;
|
||||
|
||||
const used = new Set([asQuestion(question)]);
|
||||
for (const message of history) {
|
||||
if (message.role === 'user') used.add(asQuestion(message.text));
|
||||
for (const chip of message.followUp || []) used.add(asQuestion(chip.prompt || chip.label));
|
||||
}
|
||||
|
||||
const unused = (chips) => (chips || []).filter((chip) => {
|
||||
const key = asQuestion(chip.prompt || chip.label);
|
||||
if (!key || used.has(key)) return false;
|
||||
/* A list that repeats itself within one turn is the same defect at a
|
||||
smaller scale. */
|
||||
used.add(key);
|
||||
return true;
|
||||
});
|
||||
|
||||
const onTopic = unused(await refresh({ query: question }));
|
||||
return onTopic.length ? onTopic : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* A stored thread, with any completed-then-reopen action stripped out.
|
||||
*
|
||||
@@ -246,6 +300,14 @@ export function useConversation({
|
||||
* "and now employee roles too". This is one entry in a map.
|
||||
*/
|
||||
flowWriters = {},
|
||||
/**
|
||||
* The page's layout session, when the surface has one.
|
||||
*
|
||||
* Read for the tree Owliver inspects and called to preview or keep a change.
|
||||
* Absent on every page that composes no tree, and every branch that touches
|
||||
* it checks first — so the panel behaves exactly as it did before on those.
|
||||
*/
|
||||
uiEditing = null,
|
||||
onAssignWorkers, onScheduleInterview,
|
||||
/* Finishing a draft: the same two mutations the Create Position form calls.
|
||||
Passed in rather than reached for, so this layer still writes nothing
|
||||
@@ -256,6 +318,9 @@ export function useConversation({
|
||||
/* The clients this organization already staffs for, offered as chips on the
|
||||
company question. Derived from postings the caller can already read. */
|
||||
companies = [],
|
||||
/* The caller's own postings, which is where role-to-certification relevance
|
||||
is observed from. See `certificationsForRole`. */
|
||||
postings = null,
|
||||
/* The worker profiles a declared role can be recorded against, as
|
||||
`{ id, name, email }`. Same rule: already-loaded, already-permitted rows. */
|
||||
workers = [],
|
||||
@@ -491,7 +556,7 @@ export function useConversation({
|
||||
skill,
|
||||
...advanceFlow({
|
||||
registry: flowFor(skill), flow: flowRef.current, answer: text, skill,
|
||||
ctx: { roles, companies, workers },
|
||||
ctx: { roles, companies, workers, postings },
|
||||
}),
|
||||
};
|
||||
}
|
||||
@@ -507,11 +572,24 @@ export function useConversation({
|
||||
question: text,
|
||||
contextId: turnContext,
|
||||
disabledSkills: turnDisabled,
|
||||
customSkills, roles, skillCategories, companies,
|
||||
customSkills, roles, skillCategories, companies, postings,
|
||||
courses, workforce, skillContext, positionId,
|
||||
agent: turnAgent,
|
||||
agentCoversPage: turnCovers,
|
||||
agentSuggestion, owliverContext,
|
||||
ui: uiEditing
|
||||
? {
|
||||
available: Boolean(uiEditing.tree?.length),
|
||||
tree: uiEditing.tree,
|
||||
previewing: uiEditing.previewing,
|
||||
role: uiEditing.role || null,
|
||||
registry: uiEditing.registry || undefined,
|
||||
/* Where the reader is standing. What can be added here is a
|
||||
property of the page, not of the registry, and this is how
|
||||
the conversation learns it. */
|
||||
page: uiEditing.page || null,
|
||||
}
|
||||
: null,
|
||||
}),
|
||||
/* Only while a real agent is behind the panel. With the local
|
||||
simulator there is nothing better to defer TO, and deferring
|
||||
@@ -520,6 +598,37 @@ export function useConversation({
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A layout change, acted on before the reply says what happened.
|
||||
*
|
||||
* Three kinds, and the split is the safety property: a preview is shown and
|
||||
* nothing is stored; an apply keeps what was already shown; an answer — a
|
||||
* question back, or a refusal — changes nothing at all. Owliver never
|
||||
* applies in the same turn it proposes.
|
||||
*
|
||||
* `propose` validates against the tree on screen and refuses rather than
|
||||
* previewing something that could not be kept, so a refusal here is
|
||||
* reported in the words the engine gave rather than a generic apology.
|
||||
*/
|
||||
if (intent.kind === 'ui-preview' && uiEditing) {
|
||||
const result = uiEditing.propose(intent.op);
|
||||
if (!result.ok) {
|
||||
intent = {
|
||||
...intent,
|
||||
kind: 'ui-answer',
|
||||
doc: doc(textBlock(result.problems[0]?.message || 'That change is not possible here.')),
|
||||
followUp: undefined,
|
||||
};
|
||||
}
|
||||
} else if (intent.kind === 'ui-apply' && uiEditing) {
|
||||
const result = await uiEditing.apply();
|
||||
if (!result.ok) {
|
||||
intent = { ...intent, doc: doc(textBlock('That change could not be saved.')) };
|
||||
}
|
||||
} else if (intent.kind === 'ui-discard' && uiEditing) {
|
||||
uiEditing.discard();
|
||||
}
|
||||
|
||||
/**
|
||||
* The one step that writes. It happens before the reply rather than after,
|
||||
* because the reply is the outcome — "Position created successfully" has to
|
||||
@@ -785,9 +894,53 @@ export function useConversation({
|
||||
// half-written answer loses what they were already reading. Stopping
|
||||
// before the first block, though, should leave no empty turn behind.
|
||||
if (latest.length) {
|
||||
/**
|
||||
* What to ask NEXT — which is not the same as what is worth asking.
|
||||
*
|
||||
* Every other path in this file ends its turn with `followUp`; the
|
||||
* agent path was the one that did not, so an agent answer was the only
|
||||
* kind that left the chip row empty.
|
||||
*
|
||||
* The first attempt at fixing that asked for the page's untyped
|
||||
* suggestions, and they are ranked by signal rather than by the
|
||||
* conversation — so a page with six intents offered its top three, and
|
||||
* offered the same three after every answer, including the one that had
|
||||
* just been asked. Three standing highlights repeated verbatim are not
|
||||
* follow-ups; they are the landing screen redrawn under a reply.
|
||||
*
|
||||
* So the question is passed to the server as the query, which ranks the
|
||||
* same catalogue against what was actually asked, and anything this
|
||||
* thread has already asked or already offered is removed. What is left
|
||||
* is what this conversation has not covered yet — which is what a
|
||||
* follow-up is. When nothing is left, nothing is shown: a panel with
|
||||
* nothing new to suggest should say so by being quiet, not by repeating
|
||||
* itself.
|
||||
*
|
||||
* A stopped run is offered nothing. The reader interrupted the answer,
|
||||
* so the next step it implies has not been established.
|
||||
*/
|
||||
let followUp;
|
||||
if (!controller.signal.aborted) {
|
||||
try {
|
||||
followUp = await nextSteps({
|
||||
question: text,
|
||||
history: messagesRef.current,
|
||||
refresh: onRefreshSuggestions,
|
||||
});
|
||||
} catch {
|
||||
/* The answer arrived; failing to fetch what to ask next is not a
|
||||
reason to withhold it. */
|
||||
}
|
||||
}
|
||||
|
||||
const next = [
|
||||
...messagesRef.current,
|
||||
{ role: 'assistant', blocks: latest, stopped: controller.signal.aborted || undefined },
|
||||
{
|
||||
role: 'assistant',
|
||||
blocks: latest,
|
||||
stopped: controller.signal.aborted || undefined,
|
||||
...(followUp ? { followUp } : null),
|
||||
},
|
||||
];
|
||||
messagesRef.current = next;
|
||||
persist(next);
|
||||
@@ -810,8 +963,13 @@ export function useConversation({
|
||||
onGenerateDescription, onAssignWorkers,
|
||||
onScheduleInterview,
|
||||
workforce, setFlow, disabledSkills,
|
||||
customSkills, roles, skillCategories, courses, skillContext, companies, workers,
|
||||
agent, agentCoversPage, agentSuggestion, owliverContext]);
|
||||
customSkills, roles, skillCategories, courses, skillContext, companies, workers, postings,
|
||||
agent, agentCoversPage, agentSuggestion, owliverContext,
|
||||
/* The layout session changes as a page's composition and the account's
|
||||
skills resolve, and a stale one means the tree Owliver inspects is the
|
||||
empty one from the first render — so a layout request falls through to
|
||||
the model and comes back as "I don't cover that". */
|
||||
uiEditing]);
|
||||
|
||||
const stop = React.useCallback(() => abortRef.current?.abort(), []);
|
||||
|
||||
|
||||
@@ -15,6 +15,11 @@ import { SECTION_COMPONENTS } from '@/components/skills/SkillSections';
|
||||
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';
|
||||
/* Which agent is answering on this page. Imported from the module rather than
|
||||
the package index for the same reason `PageContext` is: a page section must
|
||||
not pull the assistant panel in behind it. */
|
||||
import { useActiveAgent } from '@/components/ai-assistant/AgentContext';
|
||||
import { agentPermitsSkill } from '@/lib/agents/runtime';
|
||||
|
||||
/**
|
||||
* The extension point: one controlled slot a page offers to skills.
|
||||
@@ -35,23 +40,71 @@ import { usePageAction, usePageContext } from '@/components/ai-assistant/PageCon
|
||||
*/
|
||||
|
||||
/** The skills contributing sections to this page right now. */
|
||||
function useSkillSections(page, placement) {
|
||||
/**
|
||||
* The sections definitions contribute to a page, optionally narrowed to one
|
||||
* placement.
|
||||
*
|
||||
* Exported because the node tree needs the identical reading: a skill's section
|
||||
* drawn as a child node and the same section drawn by this surface must come
|
||||
* from one resolution, or the two would disagree about what a page carries.
|
||||
* Called with no placement it returns every section on the page.
|
||||
*/
|
||||
export function useSkillSections(page, placement) {
|
||||
const preferences = usePreferences();
|
||||
/**
|
||||
* Who is answering here.
|
||||
*
|
||||
* Read through the same context the panel reads, which is mounted around the
|
||||
* page as well as around the panel — see `AssistantPanelContext`. Outside a
|
||||
* provider this returns an inert value, so a surface drawn anywhere else sees
|
||||
* no agents and no ownership, which is exactly today's behaviour.
|
||||
*/
|
||||
const { agent, agents } = useActiveAgent();
|
||||
|
||||
const customKey = JSON.stringify(preferences.customSkills || []);
|
||||
const disabledKey = JSON.stringify(preferences.disabledSkills || []);
|
||||
/* Stable keys, because these are fresh arrays on every render and a memo
|
||||
keyed on their identity would recompute forever. */
|
||||
const ownedKey = JSON.stringify((agents || []).map((a) => a.skills || []));
|
||||
const mineKey = JSON.stringify(agent?.skills || []);
|
||||
|
||||
return useMemo(() => {
|
||||
const custom = JSON.parse(customKey);
|
||||
const disabled = JSON.parse(disabledKey);
|
||||
|
||||
/**
|
||||
* Agent ownership, and why it is opt-in.
|
||||
*
|
||||
* A skill that an agent claims belongs to that agent: its UI is drawn where
|
||||
* that agent is answering and nowhere else. A skill that **no** agent claims
|
||||
* is unowned, and unowned means unchanged — page scope and the account
|
||||
* switch decide it, exactly as they did before this existed.
|
||||
*
|
||||
* That asymmetry is the whole design. Every skill this product ships is
|
||||
* conversation-only and claimed for conversation; the definitions that draw
|
||||
* page UI are authored on the account and claimed by nobody. Enforcing
|
||||
* ownership on all of them would have removed every skill section in the
|
||||
* product on the day it shipped. Attaching a skill to an agent is therefore
|
||||
* the act that brings it under an agent's control — a decision an author
|
||||
* makes in Agent Configure, not one taken on their behalf here.
|
||||
*
|
||||
* Three separate ideas meet here and none of them is the others: what the
|
||||
* *agent* owns, what pages the *skill* declares, and what the *account* has
|
||||
* switched off. All three must pass.
|
||||
*/
|
||||
/* The rule itself lives with the other agent rules, so it can be reasoned
|
||||
about and tested without rendering a page. */
|
||||
const scope = { agents: JSON.parse(ownedKey).map((skills) => ({ skills })), agent: { skills: JSON.parse(mineKey) } };
|
||||
|
||||
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))
|
||||
.filter((skill) => agentPermitsSkill(skill.id, scope))
|
||||
.flatMap((skill) => sectionsForPage(skill, page)
|
||||
.filter((section) => !placement || section.placement === placement)
|
||||
.map((section) => ({ skill, section })));
|
||||
}, [page, placement, customKey, disabledKey]);
|
||||
}, [page, placement, customKey, disabledKey, ownedKey, mineKey]);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
233
src/components/ui-editor/NodeInspector.jsx
Normal file
233
src/components/ui-editor/NodeInspector.jsx
Normal file
@@ -0,0 +1,233 @@
|
||||
import React from 'react';
|
||||
import { ArrowDown, ArrowUp, Eye, EyeOff, Replace, Trash2 } from 'lucide-react';
|
||||
import { Button } from '@/components/ds';
|
||||
import { cn } from '@/lib/utils';
|
||||
import {
|
||||
ALIGN_VALUES, GAP_VALUES, MAX_COLUMNS, MIN_COLUMNS, SPACING_VALUES,
|
||||
} from '@/lib/ui/node';
|
||||
import { dataSourceFor, dataSourceLabel } from '@/lib/skills/surfaces';
|
||||
import { nodeRegistry } from '@/lib/ui/registry';
|
||||
import { hideOp, layoutOp, nudgeOp, presentationOp, propOp, removeOp, replaceOp } from './ops';
|
||||
|
||||
/**
|
||||
* Everything a person may change about the selected node.
|
||||
*
|
||||
* Every control below is *derived*: the properties come from the type's own
|
||||
* `propSchema`, the buttons from its declared `capabilities`, the replacement
|
||||
* options from `registry.replacements`, and the layout values from the closed
|
||||
* vocabulary in `lib/ui/node.js`. Nothing is listed by hand, which is what makes
|
||||
* a type registered tomorrow editable tomorrow — and what makes it impossible
|
||||
* for this panel to offer something the validator would refuse.
|
||||
*
|
||||
* There is no free-text style field anywhere, by construction. A person can set
|
||||
* a title, pick from an enum, choose a column count — and there is no control
|
||||
* that accepts a class name, a style, or markup, because no `propSchema`
|
||||
* declares one and the layout vocabulary is four fixed steps.
|
||||
*/
|
||||
|
||||
const field = 'w-full rounded-lg border border-border bg-surface px-2 py-1.5 text-body-sm text-ink-1 outline-none focus:border-krow-blue';
|
||||
const labelled = 'block text-[10px] font-semibold uppercase tracking-wide text-ink-4';
|
||||
|
||||
export function NodeInspector({ node, siblings, page = null, onOperate, registry = nodeRegistry }) {
|
||||
const entry = registry.get(node.type);
|
||||
if (!entry) {
|
||||
return <p className="text-caption text-ink-3">This node’s type is no longer available.</p>;
|
||||
}
|
||||
|
||||
const can = (capability) => entry.capabilities.includes(capability) && !node.locked;
|
||||
const index = siblings.findIndex((s) => s.id === node.id);
|
||||
|
||||
/** Reorder this node's own container by moving it one step. */
|
||||
const nudge = (delta) => {
|
||||
const op = nudgeOp(node, siblings, delta);
|
||||
if (op) onOperate(op);
|
||||
};
|
||||
|
||||
const shapes = node.data ? dataSourceFor(node.data.source)?.shapes || [] : null;
|
||||
const replacements = can('replace') ? registry.replacements(node.type, { shapes, page }) : [];
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<header className="space-y-1">
|
||||
<p className="font-heading text-body font-semibold text-ink-1">{node.title || node.label}</p>
|
||||
<p className="font-mono text-[10px] text-ink-4">{node.id} · {node.type} · {node.origin}</p>
|
||||
</header>
|
||||
|
||||
{/* ── What may be done at all ─────────────────────────────────────── */}
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{can('hide') && (
|
||||
<Button
|
||||
size="xs" variant="outline" shape="rounded"
|
||||
onClick={() => onOperate(hideOp(node))}
|
||||
>
|
||||
{node.hidden
|
||||
? <><Eye className="mr-1 h-3.5 w-3.5" aria-hidden="true" />Show</>
|
||||
: <><EyeOff className="mr-1 h-3.5 w-3.5" aria-hidden="true" />Hide</>}
|
||||
</Button>
|
||||
)}
|
||||
{can('move') && (
|
||||
<>
|
||||
<Button size="xs" variant="outline" shape="rounded" disabled={index <= 0}
|
||||
onClick={() => nudge(-1)} aria-label="Move up">
|
||||
<ArrowUp className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
</Button>
|
||||
<Button size="xs" variant="outline" shape="rounded" disabled={index < 0 || index >= siblings.length - 1}
|
||||
onClick={() => nudge(1)} aria-label="Move down">
|
||||
<ArrowDown className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
{can('remove') && (
|
||||
<Button size="xs" variant="outline" shape="rounded"
|
||||
onClick={() => onOperate(removeOp(node))}>
|
||||
<Trash2 className="mr-1 h-3.5 w-3.5" aria-hidden="true" />Remove
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ── Turn it into something else ─────────────────────────────────── */}
|
||||
{replacements.length > 0 && (
|
||||
<label className="block space-y-1">
|
||||
<span className={labelled}>
|
||||
<Replace className="mr-1 inline h-3 w-3" aria-hidden="true" />Show as
|
||||
</span>
|
||||
<select
|
||||
className={field}
|
||||
value=""
|
||||
onChange={(e) => e.target.value && onOperate(replaceOp(node, e.target.value))}
|
||||
>
|
||||
<option value="">Keep {entry.label}</option>
|
||||
{replacements.map((type) => (
|
||||
<option key={type} value={type}>{registry.get(type)?.label || type}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
)}
|
||||
|
||||
{/* ── Properties, exactly as the type declared them ───────────────── */}
|
||||
{can('update') && node.editable.length > 0 && (
|
||||
<div className="space-y-2.5">
|
||||
{node.editable.map((prop) => (
|
||||
<label key={prop.key} className="block space-y-1">
|
||||
<span className={labelled}>{prop.label}{prop.required ? ' *' : ''}</span>
|
||||
|
||||
{prop.kind === 'enum' && (
|
||||
<select
|
||||
className={field}
|
||||
value={prop.value ?? ''}
|
||||
onChange={(e) => onOperate(propOp(node, prop.key, e.target.value))}
|
||||
>
|
||||
<option value="">—</option>
|
||||
{prop.options.map((option) => <option key={option} value={option}>{option}</option>)}
|
||||
</select>
|
||||
)}
|
||||
|
||||
{prop.kind === 'boolean' && (
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={Boolean(prop.value)}
|
||||
onChange={(e) => onOperate(propOp(node, prop.key, e.target.checked || null))}
|
||||
/>
|
||||
)}
|
||||
|
||||
{prop.kind === 'number' && (
|
||||
<input
|
||||
type="number" className={field} defaultValue={prop.value ?? ''}
|
||||
onBlur={(e) => onOperate(propOp(node, prop.key, e.target.value === '' ? null : Number(e.target.value)))}
|
||||
/>
|
||||
)}
|
||||
|
||||
{prop.kind === 'string' && (
|
||||
<input
|
||||
type="text" className={field} defaultValue={prop.value ?? ''}
|
||||
onBlur={(e) => onOperate(propOp(node, prop.key, e.target.value))}
|
||||
/>
|
||||
)}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── What it is reading ──────────────────────────────────────────── */}
|
||||
{node.data && (
|
||||
<div className="space-y-1">
|
||||
<span className={labelled}>Reading</span>
|
||||
<p className="rounded-lg border border-border bg-surface-subtle px-2 py-1.5 text-body-sm text-ink-2">
|
||||
{dataSourceLabel(node.data.source)}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── How it looks ────────────────────────────────────────────────
|
||||
Offered only where the type says it can draw them, so a picker never
|
||||
shows a setting the component would ignore. */}
|
||||
{can('update') && (node.variants.length > 0 || node.densities.length > 0) && (
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{node.variants.length > 0 && (
|
||||
<Choice
|
||||
label="Style" value={node.presentation.variant} options={node.variants}
|
||||
onPick={(v) => onOperate(presentationOp(node, 'variant', v))}
|
||||
/>
|
||||
)}
|
||||
{node.densities.length > 0 && (
|
||||
<Choice
|
||||
label="Density" value={node.presentation.density} options={node.densities}
|
||||
onPick={(v) => onOperate(presentationOp(node, 'density', v))}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Layout: four closed vocabularies, nothing typed ─────────────── */}
|
||||
{can('update') && (
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<Choice
|
||||
label="Columns" value={node.layout.columns}
|
||||
options={range(entry.constraints.minColumns ?? MIN_COLUMNS, entry.constraints.maxColumns ?? MAX_COLUMNS)}
|
||||
onPick={(v) => onOperate(layoutOp(node, 'columns', v))}
|
||||
/>
|
||||
<Choice
|
||||
label="Span" value={node.layout.span} options={range(MIN_COLUMNS, MAX_COLUMNS)}
|
||||
onPick={(v) => onOperate(layoutOp(node, 'span', v))}
|
||||
/>
|
||||
<Choice
|
||||
label="Gap" value={node.layout.gap} options={GAP_VALUES}
|
||||
onPick={(v) => onOperate(layoutOp(node, 'gap', v))}
|
||||
/>
|
||||
<Choice
|
||||
label="Align" value={node.layout.align} options={ALIGN_VALUES}
|
||||
onPick={(v) => onOperate(layoutOp(node, 'align', v))}
|
||||
/>
|
||||
<Choice
|
||||
label="Space above" value={node.layout.spacingBefore} options={SPACING_VALUES}
|
||||
onPick={(v) => onOperate(layoutOp(node, 'spacingBefore', v))}
|
||||
/>
|
||||
<Choice
|
||||
label="Space below" value={node.layout.spacingAfter} options={SPACING_VALUES}
|
||||
onPick={(v) => onOperate(layoutOp(node, 'spacingAfter', v))}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** One closed-vocabulary picker. Empty means "leave it to the page". */
|
||||
function Choice({ label, value, options, onPick }) {
|
||||
return (
|
||||
<label className="block space-y-1">
|
||||
<span className={labelled}>{label}</span>
|
||||
<select
|
||||
className={cn(field, 'text-caption')}
|
||||
value={value ?? ''}
|
||||
onChange={(e) => onPick(e.target.value === '' ? null : coerce(e.target.value))}
|
||||
>
|
||||
<option value="">Default</option>
|
||||
{options.map((option) => <option key={option} value={option}>{option}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
const range = (from, to) => Array.from({ length: to - from + 1 }, (_, i) => from + i);
|
||||
const coerce = (value) => (/^\d+$/.test(value) ? Number(value) : value);
|
||||
90
src/components/ui-editor/NodePicker.jsx
Normal file
90
src/components/ui-editor/NodePicker.jsx
Normal file
@@ -0,0 +1,90 @@
|
||||
import React from 'react';
|
||||
import { Plus } from 'lucide-react';
|
||||
import { Button } from '@/components/ds';
|
||||
import { addableTypes } from '@/lib/ui/inspect';
|
||||
import { nodeRegistry } from '@/lib/ui/registry';
|
||||
import { dataSourceLabel, placementProvides, sourcesForShape } from '@/lib/skills/surfaces';
|
||||
import { addOp } from './ops';
|
||||
|
||||
/**
|
||||
* What can be added here, and what it would show.
|
||||
*
|
||||
* Both lists are answers to questions the product already knows how to answer:
|
||||
* `addableTypes` asks the registry what this container accepts and what this
|
||||
* role may use, and `sourcesForShape` asks the closed data vocabulary which
|
||||
* readings can fill that shape. Nothing is offered that validation would then
|
||||
* refuse, and nothing can be typed — a source is chosen from what exists, which
|
||||
* is what makes an invented metric impossible to compose here.
|
||||
*/
|
||||
/** A property's value on a described node, read the way the inspector reads it. */
|
||||
const propOf = (node, key) => node?.editable?.find((prop) => prop.key === key)?.value || null;
|
||||
|
||||
export function NodePicker({ tree, parent, page = null, role = null, onAdd, registry = nodeRegistry }) {
|
||||
const [type, setType] = React.useState('');
|
||||
const [source, setSource] = React.useState('');
|
||||
|
||||
const options = addableTypes(parent?.type ?? null, { registry, role, page });
|
||||
const chosen = options.find((option) => option.type === type) || null;
|
||||
|
||||
/**
|
||||
* What this location can actually answer.
|
||||
*
|
||||
* Every reading in the vocabulary was offered here, including the ones that
|
||||
* need a record — so a Card bound to "Position activity" could be added to a
|
||||
* page that has no position, and rendered "This section needs a position to
|
||||
* read." forever. Offered, accepted, saved, and dead.
|
||||
*
|
||||
* `provides` on the surface already records which context each placement
|
||||
* supplies, and `sourcesForShape` already filters on it. Only the question
|
||||
* was missing. A node at the page root is inside no record, so it is offered
|
||||
* the readings that need none.
|
||||
*/
|
||||
const slot = propOf(parent, 'placement');
|
||||
const provided = slot ? placementProvides(propOf(parent, 'page') || page, slot) : [];
|
||||
const sources = chosen?.dataShapes.length
|
||||
? sourcesForShape(chosen.dataShapes[0], { context: provided }).map((s) => s.id)
|
||||
: [];
|
||||
|
||||
/* A type that reads data cannot be added until a reading is picked. The
|
||||
button says so by staying disabled rather than by failing on submit. */
|
||||
const ready = Boolean(chosen) && (!chosen.dataRequired || Boolean(source));
|
||||
|
||||
const add = () => {
|
||||
if (!ready) return;
|
||||
onAdd(addOp(tree, parent, chosen.type, source || null));
|
||||
setType('');
|
||||
setSource('');
|
||||
};
|
||||
|
||||
if (!options.length) {
|
||||
return <p className="text-caption text-ink-4">Nothing can be added here.</p>;
|
||||
}
|
||||
|
||||
const field = 'w-full rounded-lg border border-border bg-surface px-2 py-1.5 text-body-sm text-ink-1 outline-none focus:border-krow-blue';
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<p className="text-[10px] font-semibold uppercase tracking-wide text-ink-4">
|
||||
Add {parent ? `inside ${parent.title || parent.label}` : 'to the page'}
|
||||
</p>
|
||||
|
||||
<select className={field} value={type} onChange={(e) => { setType(e.target.value); setSource(''); }}>
|
||||
<option value="">Choose a panel…</option>
|
||||
{options.map((option) => (
|
||||
<option key={option.type} value={option.type}>{option.label}</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
{chosen?.dataShapes.length > 0 && (
|
||||
<select className={field} value={source} onChange={(e) => setSource(e.target.value)}>
|
||||
<option value="">What should it show?</option>
|
||||
{sources.map((id) => <option key={id} value={id}>{dataSourceLabel(id)}</option>)}
|
||||
</select>
|
||||
)}
|
||||
|
||||
<Button size="xs" shape="rounded" disabled={!ready} onClick={add}>
|
||||
<Plus className="mr-1 h-3.5 w-3.5" aria-hidden="true" />Add
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
87
src/components/ui-editor/TreePanel.jsx
Normal file
87
src/components/ui-editor/TreePanel.jsx
Normal file
@@ -0,0 +1,87 @@
|
||||
import React from 'react';
|
||||
import { Eye, EyeOff, Sparkles } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { inspectTree } from '@/lib/ui/inspect';
|
||||
import { nodeRegistry } from '@/lib/ui/registry';
|
||||
|
||||
/**
|
||||
* The page, as an outline.
|
||||
*
|
||||
* Everything shown here comes from `inspectTree` — the same inventory Owliver
|
||||
* reads before it resolves "the recent hiring timeline". There is no second
|
||||
* description of a page anywhere: if the editor can see a node, so can the
|
||||
* conversation, and vice versa.
|
||||
*
|
||||
* **Hidden nodes are listed.** A hidden node is still in the tree and still
|
||||
* addressable; the renderer skips it and nothing else does. Leaving it out here
|
||||
* would make the editor the one place a person could not undo a hide.
|
||||
*/
|
||||
export function TreePanel({ tree, selectedId, onSelect, registry = nodeRegistry }) {
|
||||
const nodes = inspectTree(tree, { registry });
|
||||
|
||||
if (!nodes.length) {
|
||||
return (
|
||||
<p className="rounded-lg border border-dashed border-border px-3 py-6 text-center text-caption text-ink-3">
|
||||
This page has no addressable sections yet.
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
/* Depth from parentage rather than from a nested walk, so the flat inventory
|
||||
stays the one source and this only decides indentation. */
|
||||
const depthOf = (node) => {
|
||||
let depth = 0;
|
||||
let cursor = node;
|
||||
while (cursor?.parent) {
|
||||
cursor = nodes.find((n) => n.id === cursor.parent);
|
||||
depth += 1;
|
||||
}
|
||||
return depth;
|
||||
};
|
||||
|
||||
return (
|
||||
<ul className="divide-y divide-border overflow-hidden rounded-xl border border-border bg-surface">
|
||||
{nodes.map((node) => {
|
||||
const selected = node.id === selectedId;
|
||||
return (
|
||||
<li key={node.id}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onSelect(node.id)}
|
||||
aria-current={selected ? 'true' : undefined}
|
||||
className={cn(
|
||||
'flex w-full items-center gap-2 px-3 py-2 text-left transition-colors',
|
||||
selected ? 'bg-krow-blue-tint' : 'hover:bg-surface-subtle'
|
||||
)}
|
||||
>
|
||||
<span style={{ paddingLeft: `${depthOf(node) * 14}px` }} className="flex min-w-0 flex-1 items-center gap-2">
|
||||
<span className={cn('truncate text-body-sm', node.hidden ? 'text-ink-4' : 'text-ink-1')}>
|
||||
{node.title || node.label}
|
||||
</span>
|
||||
|
||||
{/* Provenance. A person needs to know which panels came from a
|
||||
definition they could switch off, and which are the page. */}
|
||||
{node.origin === 'skill' && (
|
||||
<span className="inline-flex shrink-0 items-center gap-1 rounded-full bg-krow-blue-tint px-1.5 py-0.5 text-[10px] font-semibold text-krow-blue">
|
||||
<Sparkles className="h-2.5 w-2.5" aria-hidden="true" />
|
||||
Skill
|
||||
</span>
|
||||
)}
|
||||
{node.origin === 'user' && (
|
||||
<span className="shrink-0 rounded-full bg-surface-sunken px-1.5 py-0.5 text-[10px] font-semibold text-ink-3">
|
||||
Added
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
|
||||
<span className="shrink-0 font-mono text-[10px] text-ink-4">{node.type}</span>
|
||||
{node.hidden
|
||||
? <EyeOff className="h-3.5 w-3.5 shrink-0 text-ink-4" aria-label="Hidden" />
|
||||
: <Eye className="h-3.5 w-3.5 shrink-0 text-ink-4/40" aria-hidden="true" />}
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
146
src/components/ui-editor/UiEditor.jsx
Normal file
146
src/components/ui-editor/UiEditor.jsx
Normal file
@@ -0,0 +1,146 @@
|
||||
import React from 'react';
|
||||
import { RotateCcw, SlidersHorizontal, Undo2 } from 'lucide-react';
|
||||
import { Button } from '@/components/ds';
|
||||
import { useUiEditing } from '@/components/ui-tree/UiEditingProvider';
|
||||
import { inspectTree } from '@/lib/ui/inspect';
|
||||
import { nodeRegistry } from '@/lib/ui/registry';
|
||||
import { TreePanel } from './TreePanel';
|
||||
import { NodeInspector } from './NodeInspector';
|
||||
import { NodePicker } from './NodePicker';
|
||||
|
||||
/**
|
||||
* The visual editor.
|
||||
*
|
||||
* It is a *client* of the UI system, not a second implementation of it. Every
|
||||
* control it draws ends in one call — `propose(op)` on the editing session —
|
||||
* with an operation object of exactly the shape Owliver produces for the same
|
||||
* change. From there the two are indistinguishable: same validation, same
|
||||
* preview merge, same Apply, same `preferences.uiLayouts`.
|
||||
*
|
||||
* That is the whole architecture:
|
||||
*
|
||||
* editor / Owliver → operation → propose → validate → preview
|
||||
* ↓ Apply
|
||||
* preferences.uiLayouts
|
||||
*
|
||||
* There is no page in this file, no component name, and no branch on what a
|
||||
* node is. What can be done to the selected node comes from its registration;
|
||||
* what can be added comes from the registry and the closed data vocabulary;
|
||||
* what it is showing comes from the tree. A page that migrates tomorrow is
|
||||
* editable tomorrow with nothing here changed.
|
||||
*/
|
||||
export function UiEditor({ registry = nodeRegistry }) {
|
||||
const editing = useUiEditing();
|
||||
const [open, setOpen] = React.useState(false);
|
||||
const [selectedId, setSelectedId] = React.useState(null);
|
||||
|
||||
/* Rendered only where a page has opted into composition. A page that has not
|
||||
is not broken; it simply has nothing to arrange. */
|
||||
if (!editing) return null;
|
||||
|
||||
const {
|
||||
tree, propose, discard, apply, undo, reset,
|
||||
previewing, customised, saving, problems, skipped,
|
||||
/* The page this session belongs to. Handed to the picker so what can be
|
||||
added here is decided by the registry rather than by the picker being
|
||||
shown everything that exists. */
|
||||
page,
|
||||
} = editing;
|
||||
|
||||
const nodes = inspectTree(tree, { registry });
|
||||
const selected = nodes.find((node) => node.id === selectedId) || null;
|
||||
/* A node's own container, for the reorder buttons and for the picker. */
|
||||
const siblings = selected ? nodes.filter((node) => node.parent === selected.parent) : [];
|
||||
const parent = selected?.container ? selected : nodes.find((n) => n.id === selected?.parent) || null;
|
||||
|
||||
/**
|
||||
* The one door out of this component.
|
||||
*
|
||||
* Everything the panels do arrives here as an operation and goes straight to
|
||||
* the session. Nothing is applied, nothing is stored, and nothing is
|
||||
* validated locally — `propose` refuses what cannot be kept and the refusal
|
||||
* is shown below.
|
||||
*/
|
||||
const operate = (op) => {
|
||||
const result = propose(op);
|
||||
/* A removed node cannot stay selected; a replaced one keeps its id. */
|
||||
if (result.ok && op.op === 'remove') setSelectedId(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<div data-ui-controls="editor" className="space-y-2">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<Button
|
||||
size="xs"
|
||||
variant={open ? 'default' : 'outline'}
|
||||
shape="rounded"
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
>
|
||||
<SlidersHorizontal className="mr-1.5 h-3.5 w-3.5" aria-hidden="true" />
|
||||
Customise layout
|
||||
</Button>
|
||||
|
||||
{/* The preview bar. Unsaved and saved have to be told apart at a
|
||||
glance, because the whole promise is that nothing is kept until
|
||||
somebody says so. */}
|
||||
{(previewing || customised) && (
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{previewing && (
|
||||
<span className="rounded-full bg-krow-blue-tint px-2 py-0.5 text-caption font-semibold text-krow-blue">
|
||||
Previewing — not saved yet
|
||||
</span>
|
||||
)}
|
||||
{!previewing && customised && (
|
||||
<span className="rounded-full bg-surface-sunken px-2 py-0.5 text-caption font-semibold text-ink-3">
|
||||
Saved layout
|
||||
</span>
|
||||
)}
|
||||
{previewing && (
|
||||
<>
|
||||
<Button size="xs" shape="rounded" loading={saving} onClick={apply}>Apply</Button>
|
||||
<Button size="xs" variant="outline" shape="rounded" onClick={discard}>Discard</Button>
|
||||
</>
|
||||
)}
|
||||
<Button size="xs" variant="ghost" shape="rounded" onClick={undo}>
|
||||
<Undo2 className="mr-1 h-3.5 w-3.5" aria-hidden="true" />Undo
|
||||
</Button>
|
||||
{customised && (
|
||||
<Button size="xs" variant="ghost" shape="rounded" onClick={reset}>
|
||||
<RotateCcw className="mr-1 h-3.5 w-3.5" aria-hidden="true" />Reset
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{problems.length > 0 && (
|
||||
<p className="text-caption text-destructive">{problems[0].message}</p>
|
||||
)}
|
||||
|
||||
{skipped.length > 0 && (
|
||||
<p className="text-caption text-ink-4">
|
||||
{skipped.length} saved change{skipped.length === 1 ? '' : 's'} no longer apply to this page.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{open && (
|
||||
<div className="grid gap-3 rounded-xl border border-border bg-surface-subtle p-3 lg:grid-cols-2">
|
||||
<div className="space-y-3">
|
||||
<TreePanel tree={tree} selectedId={selectedId} onSelect={setSelectedId} registry={registry} />
|
||||
<NodePicker tree={tree} parent={parent} page={page} onAdd={operate} registry={registry} />
|
||||
</div>
|
||||
|
||||
<div className="rounded-xl border border-border bg-surface p-3">
|
||||
{selected
|
||||
? <NodeInspector node={selected} siblings={siblings} page={page} onOperate={operate} registry={registry} />
|
||||
: (
|
||||
<p className="text-caption text-ink-3">
|
||||
Choose a section on the left to see what can be changed about it.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
99
src/components/ui-editor/ops.js
Normal file
99
src/components/ui-editor/ops.js
Normal file
@@ -0,0 +1,99 @@
|
||||
import { freeNodeId } from '@/lib/ui/node';
|
||||
import { dataSourceLabel } from '@/lib/skills/surfaces';
|
||||
|
||||
/**
|
||||
* The operations the editor's controls stand for.
|
||||
*
|
||||
* Pure, and separate from the components, for one reason: it is the claim that
|
||||
* the editor and Owliver do the same thing, and a claim buried inside a click
|
||||
* handler cannot be checked. Here it can — `hideOp(node)` and the operation
|
||||
* `matchUiEdit('hide the recent hiring timeline')` produces are the same object,
|
||||
* and a test says so.
|
||||
*
|
||||
* Nothing here validates or applies. Every one of these is handed to
|
||||
* `propose()` on the editing session, which is the single gate both surfaces
|
||||
* pass through.
|
||||
*/
|
||||
|
||||
/** Hide a visible node, or show a hidden one. */
|
||||
export const hideOp = (node) => ({ op: 'hide', target: node.id, hidden: !node.hidden });
|
||||
|
||||
/** Set a node's hidden state explicitly, which is what a conversation says. */
|
||||
export const visibilityOp = (node, hidden) => ({ op: 'hide', target: node.id, hidden });
|
||||
|
||||
/**
|
||||
* Move a node one place within its own container.
|
||||
*
|
||||
* A reorder of the whole sibling list rather than a `move`, because that is
|
||||
* what the button means: nothing changes parent, the order around it changes.
|
||||
* The same shape Owliver produces for "move the timeline above the notice".
|
||||
*/
|
||||
export function nudgeOp(node, siblings, delta) {
|
||||
const order = siblings.map((s) => s.id);
|
||||
const from = order.indexOf(node.id);
|
||||
const to = from + delta;
|
||||
if (from < 0 || to < 0 || to >= order.length) return null;
|
||||
order.splice(to, 0, ...order.splice(from, 1));
|
||||
return { op: 'reorder', parent: node.parent ?? null, order };
|
||||
}
|
||||
|
||||
/** Put a node at an explicit index among its siblings. */
|
||||
export function reorderOp(node, siblings, index) {
|
||||
const order = siblings.map((s) => s.id);
|
||||
const from = order.indexOf(node.id);
|
||||
if (from < 0 || index < 0 || index >= order.length) return null;
|
||||
order.splice(index, 0, ...order.splice(from, 1));
|
||||
return { op: 'reorder', parent: node.parent ?? null, order };
|
||||
}
|
||||
|
||||
/** Turn a node into another registered type. */
|
||||
export const replaceOp = (node, type) => ({ op: 'replace', target: node.id, type });
|
||||
|
||||
/** Remove a node the person added. */
|
||||
export const removeOp = (node) => ({ op: 'remove', target: node.id });
|
||||
|
||||
/**
|
||||
* Change one declared property.
|
||||
*
|
||||
* An empty value becomes `null`, which the engine reads as "unset" — the only
|
||||
* way to clear a property, and the reason a blank field is not the same as a
|
||||
* field nobody touched.
|
||||
*/
|
||||
export const propOp = (node, key, value) => ({
|
||||
op: 'update', target: node.id, props: { [key]: value === '' || value === undefined ? null : value },
|
||||
});
|
||||
|
||||
/** Change one layout value, from the closed vocabulary. */
|
||||
export const layoutOp = (node, key, value) => ({
|
||||
op: 'update', target: node.id, layout: { [key]: value === '' || value === undefined ? null : value },
|
||||
});
|
||||
|
||||
/**
|
||||
* Change how a node presents itself, from the closed vocabulary.
|
||||
*
|
||||
* The same `update` operation everything else here produces — the editor has no
|
||||
* mutation of its own, and this is the shape Owliver emits for the same words.
|
||||
*/
|
||||
export const presentationOp = (node, key, value) => ({
|
||||
op: 'update',
|
||||
target: node.id,
|
||||
presentation: { [key]: value === '' || value === undefined ? null : value },
|
||||
});
|
||||
|
||||
/**
|
||||
* Add a node of a registered type, optionally bound to a reading.
|
||||
*
|
||||
* The id is generated by the shared helper, so a node added here and a node
|
||||
* added by Owliver get their addresses from one rule.
|
||||
*/
|
||||
export function addOp(tree, parent, type, source = null) {
|
||||
return {
|
||||
op: 'add',
|
||||
parent: parent?.id ?? null,
|
||||
node: {
|
||||
id: freeNodeId(tree, type),
|
||||
type,
|
||||
...(source ? { data: { source }, props: { title: dataSourceLabel(source) } } : {}),
|
||||
},
|
||||
};
|
||||
}
|
||||
171
src/components/ui-tree/UiEditingProvider.jsx
Normal file
171
src/components/ui-tree/UiEditingProvider.jsx
Normal file
@@ -0,0 +1,171 @@
|
||||
import React from 'react';
|
||||
import { useUiLayouts } from '@/lib/krowHooks';
|
||||
import { useSkillSections } from '@/components/skills/SkillSurface';
|
||||
import { skillNodesByPlacement } from '@/lib/ui/skillNodes';
|
||||
import { composePage } from '@/lib/ui/composition';
|
||||
import { applyOperation } from '@/lib/ui/operations';
|
||||
import { clearOps, emptyPatch, popOp, pushOp } from '@/lib/ui/patch';
|
||||
import { nodeRegistry } from '@/lib/ui/registry';
|
||||
|
||||
/**
|
||||
* Preview, Apply, and the line between them.
|
||||
*
|
||||
* Three states of a UI change live here, and keeping them apart is the whole
|
||||
* job of this module:
|
||||
*
|
||||
* - **Preview** is in React state. It is what the person is trying. It never
|
||||
* reaches the network, and a reload discards it.
|
||||
* - **Saved** is the person's own stored operation list, read from and
|
||||
* written to their account preferences. It survives a reload and a new
|
||||
* session.
|
||||
* - **Source** — the page's registered composition and the Markdown skill
|
||||
* files — is never written by anything here. Not on preview, not on apply.
|
||||
*
|
||||
* A proposed operation is validated *before* it becomes a preview, so a change
|
||||
* that could not be saved is never shown as though it could. Preview and apply
|
||||
* then run the identical merge through `composePage` and the identical
|
||||
* renderer, which is what makes a preview honest: there is no second code path
|
||||
* for the applied state that could disagree with it.
|
||||
*/
|
||||
|
||||
const UiEditingContext = React.createContext(null);
|
||||
|
||||
/** The editing session for the page around it. Null outside a provider. */
|
||||
export const useUiEditing = () => React.useContext(UiEditingContext);
|
||||
|
||||
export function UiEditingProvider({ page, role = null, registry = nodeRegistry, children }) {
|
||||
const { layouts, save, saving } = useUiLayouts();
|
||||
|
||||
/**
|
||||
* What the person is trying, not yet theirs.
|
||||
*
|
||||
* Held per page so that navigating away and back does not carry an
|
||||
* unfinished experiment onto a different surface.
|
||||
*/
|
||||
const [preview, setPreview] = React.useState(() => emptyPatch(page));
|
||||
const [problems, setProblems] = React.useState([]);
|
||||
|
||||
/* A page change is a new editing session. Anything unsaved was about the page
|
||||
that is no longer on screen. */
|
||||
React.useEffect(() => {
|
||||
setPreview(emptyPatch(page));
|
||||
setProblems([]);
|
||||
}, [page]);
|
||||
|
||||
const saved = layouts[page] || emptyPatch(page);
|
||||
|
||||
/**
|
||||
* What this page's definitions contribute, as nodes.
|
||||
*
|
||||
* Resolved here because it needs the account's custom skills and disabled
|
||||
* list, and handed to `composePage` so that module stays a pure function.
|
||||
* The result is that a Board skill's card is a node in the same tree as the
|
||||
* page's own sections — addressable by the same operations, and hidden or
|
||||
* moved by a patch rather than by editing the definition.
|
||||
*/
|
||||
const sections = useSkillSections(page);
|
||||
const skillNodes = React.useMemo(() => skillNodesByPlacement(sections), [sections]);
|
||||
|
||||
/**
|
||||
* The tree on screen: what the application ships, with what the person saved,
|
||||
* with what they are trying, in that order.
|
||||
*/
|
||||
const composed = React.useMemo(
|
||||
() => composePage(page, { patch: saved, preview, registry, role, skillNodes }),
|
||||
[page, saved, preview, registry, role, skillNodes]
|
||||
);
|
||||
|
||||
/**
|
||||
* Try an operation.
|
||||
*
|
||||
* Validated against the tree as it currently stands — saved changes included
|
||||
* — so an operation is judged against what the person is actually looking at.
|
||||
* A refusal returns the reasons and changes nothing; there is no partially
|
||||
* applied preview.
|
||||
*/
|
||||
const propose = React.useCallback((op) => {
|
||||
const result = applyOperation(composed.tree, op, { registry, role });
|
||||
if (!result.ok) {
|
||||
setProblems(result.problems);
|
||||
return result;
|
||||
}
|
||||
setProblems([]);
|
||||
setPreview((current) => pushOp(current, op));
|
||||
return result;
|
||||
}, [composed.tree, registry, role]);
|
||||
|
||||
/** Throw the experiment away. Nothing was stored, so nothing is undone. */
|
||||
const discard = React.useCallback(() => {
|
||||
setPreview(emptyPatch(page));
|
||||
setProblems([]);
|
||||
}, [page]);
|
||||
|
||||
/**
|
||||
* Keep it.
|
||||
*
|
||||
* The preview's operations are appended to what was already saved and written
|
||||
* as one list. The preview is only cleared once the write resolves, so a
|
||||
* failed save leaves the person looking at the change they asked for rather
|
||||
* than watching it disappear with an error beside it.
|
||||
*/
|
||||
const apply = React.useCallback(async () => {
|
||||
if (!preview.ops.length) return { ok: true, saved };
|
||||
const next = { ...saved, page, ops: [...saved.ops, ...preview.ops], updatedAt: new Date().toISOString() };
|
||||
const result = await save(page, next);
|
||||
if (result?.persisted === false) {
|
||||
setProblems([{ at: null, message: result.error || 'That change could not be saved.' }]);
|
||||
return { ok: false, saved };
|
||||
}
|
||||
setPreview(emptyPatch(page));
|
||||
return { ok: true, saved: next };
|
||||
}, [preview, saved, page, save]);
|
||||
|
||||
/**
|
||||
* Undo one step.
|
||||
*
|
||||
* The most recent thing first: an unsaved operation if there is one, and only
|
||||
* then a saved one. Undoing a saved change is a write, because the saved list
|
||||
* is the record of what the person chose.
|
||||
*/
|
||||
const undo = React.useCallback(async () => {
|
||||
if (preview.ops.length) {
|
||||
setPreview((current) => popOp(current));
|
||||
return { ok: true };
|
||||
}
|
||||
if (!saved.ops.length) return { ok: true };
|
||||
await save(page, popOp(saved));
|
||||
return { ok: true };
|
||||
}, [preview, saved, page, save]);
|
||||
|
||||
/** Back to the page as the application ships it. Clears both tiers. */
|
||||
const reset = React.useCallback(async () => {
|
||||
setPreview(emptyPatch(page));
|
||||
setProblems([]);
|
||||
if (saved.ops.length) await save(page, clearOps(saved));
|
||||
return { ok: true };
|
||||
}, [page, saved, save]);
|
||||
|
||||
const value = React.useMemo(() => ({
|
||||
page,
|
||||
tree: composed.tree,
|
||||
/* Operations that no longer apply — a saved change naming a section a
|
||||
release has since removed. Surfaced so a page can say so quietly rather
|
||||
than leaving the person wondering why nothing happened. */
|
||||
skipped: composed.skipped,
|
||||
saved,
|
||||
preview,
|
||||
problems,
|
||||
previewing: preview.ops.length > 0,
|
||||
customised: saved.ops.length > 0,
|
||||
saving,
|
||||
propose,
|
||||
discard,
|
||||
apply,
|
||||
undo,
|
||||
reset,
|
||||
}), [
|
||||
page, composed, saved, preview, problems, saving, propose, discard, apply, undo, reset,
|
||||
]);
|
||||
|
||||
return <UiEditingContext.Provider value={value}>{children}</UiEditingContext.Provider>;
|
||||
}
|
||||
76
src/components/ui-tree/UiNodeBoundary.jsx
Normal file
76
src/components/ui-tree/UiNodeBoundary.jsx
Normal file
@@ -0,0 +1,76 @@
|
||||
import React from 'react';
|
||||
|
||||
/**
|
||||
* One node's blast radius.
|
||||
*
|
||||
* A section is a component like any other, and a component can throw. Without
|
||||
* this, one that did took the entire application down: adding Hired History's
|
||||
* chronology to Candidates Analysis — which the picker offered, and which the
|
||||
* registry now refuses — left the section destructuring `hires` and `filtered`
|
||||
* from a render context that publishes neither, and `undefined.length` unmounted
|
||||
* the whole tree to a white screen with no way back but a reload.
|
||||
*
|
||||
* The scope fix means that particular node can no longer be placed there. This
|
||||
* exists because that was never the only way to get here: a page can rename
|
||||
* what it publishes, a release can change a section's data shape, and a saved
|
||||
* layout is replayed months after it was made. A layout a person saved must
|
||||
* never be able to cost them the page.
|
||||
*
|
||||
* So a node that throws renders as a node that could not be drawn — in place,
|
||||
* named, and still in the tree, so the editor can still select it and the
|
||||
* conversation can still hide or remove it. Everything around it keeps working.
|
||||
*
|
||||
* Deliberately not a retry: the same props render the same failure, and a
|
||||
* boundary that re-throws in a loop is worse than one that stops. It resets when
|
||||
* the node it is holding changes, which is what makes removing the broken node
|
||||
* put the page right without a reload.
|
||||
*/
|
||||
export class UiNodeBoundary extends React.Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = { failed: false };
|
||||
}
|
||||
|
||||
static getDerivedStateFromError() {
|
||||
return { failed: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset when the node changes.
|
||||
*
|
||||
* Without this, hiding or removing a failed node would leave the boundary
|
||||
* latched and the placeholder on screen — the fix applied, and invisible.
|
||||
*/
|
||||
static getDerivedStateFromProps(props, state) {
|
||||
if (state.failed && state.forNode !== props.node?.id) return { failed: false, forNode: props.node?.id };
|
||||
return state.forNode === props.node?.id ? null : { ...state, forNode: props.node?.id };
|
||||
}
|
||||
|
||||
componentDidCatch(error) {
|
||||
/* The node, not just the stack: the stack names React, and what a person
|
||||
debugging this needs is which section and which page. */
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(`[ui] node "${this.props.node?.id}" (${this.props.node?.type}) failed to render`, error);
|
||||
}
|
||||
|
||||
render() {
|
||||
if (!this.state.failed) return this.props.children;
|
||||
|
||||
const { node } = this.props;
|
||||
return (
|
||||
<section
|
||||
data-ui-node={node?.id}
|
||||
data-ui-type={node?.type}
|
||||
data-ui-failed="true"
|
||||
className="rounded-2xl border border-dashed border-border bg-surface-subtle p-4"
|
||||
>
|
||||
<p className="text-body-sm font-semibold text-ink-2">
|
||||
{node?.props?.title || 'This section could not be shown'}
|
||||
</p>
|
||||
<p className="mt-0.5 text-caption text-ink-4">
|
||||
It is still on the page and can be hidden or removed from Customise layout.
|
||||
</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
}
|
||||
308
src/components/ui-tree/UiTreeRenderer.jsx
Normal file
308
src/components/ui-tree/UiTreeRenderer.jsx
Normal file
@@ -0,0 +1,308 @@
|
||||
import React from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { nodeRegistry } from '@/lib/ui/registry';
|
||||
import { UiNodeBoundary } from './UiNodeBoundary';
|
||||
import { composePage } from '@/lib/ui/composition';
|
||||
import { useUiEditing } from './UiEditingProvider';
|
||||
|
||||
/**
|
||||
* Draws a UI tree.
|
||||
*
|
||||
* One recursive renderer for every node type there will ever be. It looks a
|
||||
* type up in the registry and renders the component that registration named —
|
||||
* and that is the whole of its knowledge. There is no branch on a type here, no
|
||||
* component name held as a string, no dynamic import, and no path by which
|
||||
* configuration becomes code. A node names a key; the key was registered by a
|
||||
* module the bundler resolved at build time; anything else renders nothing.
|
||||
*
|
||||
* That is what makes agent-authored UI safe. The worst a malformed or hostile
|
||||
* configuration can do is name a type that does not exist, and the answer to
|
||||
* that is an empty space — never an evaluated string, never injected markup.
|
||||
*
|
||||
* **It adds no markup of its own.** A node's component renders its own root and
|
||||
* receives `attrs` to spread onto it, so a migrated page emits the elements it
|
||||
* always did. The one exception is a type that declares `wrap`, for components
|
||||
* that cannot forward unknown props; those get a bare `div` whose only purpose
|
||||
* is to carry the node's identity.
|
||||
*/
|
||||
|
||||
/**
|
||||
* What a page hands its own sections.
|
||||
*
|
||||
* A page's built-in nodes need the page's own state — its filtered rows, its
|
||||
* loading flag, its handlers — and threading that through the tree as props
|
||||
* would make the renderer know what a page contains. So the page publishes one
|
||||
* opaque bag and its sections read what they need out of it. The renderer never
|
||||
* looks inside.
|
||||
*
|
||||
* Deliberately separate from `PageContext`, which is the panel's read-only view
|
||||
* of a page's *records*. This is a page talking to its own parts.
|
||||
*/
|
||||
const UiRenderContext = React.createContext(null);
|
||||
|
||||
/** The bag the page published. Empty when a component is rendered outside a tree. */
|
||||
export const useUiContext = () => React.useContext(UiRenderContext) || {};
|
||||
|
||||
/**
|
||||
* The node currently being drawn.
|
||||
*
|
||||
* Lets a section know its own id without being passed it — which is what a
|
||||
* future selection affordance needs, and what keeps the identity in one place
|
||||
* rather than repeated in every registration.
|
||||
*/
|
||||
const UiNodeContext = React.createContext(null);
|
||||
export const useUiNode = () => React.useContext(UiNodeContext);
|
||||
|
||||
/**
|
||||
* The DOM attributes that make a node addressable.
|
||||
*
|
||||
* Two, not one: the id answers "which node is this", and the type answers "what
|
||||
* is it" without a lookup. Both are `data-` attributes, so they carry no
|
||||
* styling and cannot collide with anything the design system uses.
|
||||
*/
|
||||
export const nodeAttrs = (node) => ({
|
||||
'data-ui-node': node.id,
|
||||
'data-ui-type': node.type,
|
||||
});
|
||||
|
||||
/**
|
||||
* The grid a container arranges its children in.
|
||||
*
|
||||
* Only emitted when a node actually asks for columns. A node with no layout
|
||||
* renders its children exactly as a page would have written them, which is what
|
||||
* lets an existing page migrate without its markup changing.
|
||||
*/
|
||||
function layoutClass(layout) {
|
||||
if (!layout?.columns) return null;
|
||||
/* Written out rather than interpolated: Tailwind scans source text for class
|
||||
names, and `grid-cols-${n}` is invisible to that scan and absent from the
|
||||
build. One column is not a grid at all. */
|
||||
const columns = {
|
||||
1: null,
|
||||
2: 'grid grid-cols-1 sm:grid-cols-2',
|
||||
3: 'grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3',
|
||||
4: 'grid grid-cols-2 lg:grid-cols-4',
|
||||
6: 'grid grid-cols-2 sm:grid-cols-3 xl:grid-cols-6',
|
||||
12: 'grid grid-cols-2 sm:grid-cols-4 xl:grid-cols-6',
|
||||
}[layout.columns] || 'grid grid-cols-1 sm:grid-cols-2';
|
||||
|
||||
const gap = { none: 'gap-0', sm: 'gap-2', md: 'gap-4', lg: 'gap-6' }[layout.gap] || 'gap-4';
|
||||
return cn(columns, columns && gap);
|
||||
}
|
||||
|
||||
/**
|
||||
* The margin a node asks for above or below itself.
|
||||
*
|
||||
* Written out rather than interpolated, for the same reason the column classes
|
||||
* are: Tailwind scans source text, and a class assembled at run time is absent
|
||||
* from the build. Four steps, each a class the app already uses.
|
||||
*/
|
||||
export function spacingClasses(layout) {
|
||||
/**
|
||||
* Marked important, and that is not a shortcut.
|
||||
*
|
||||
* Pages stack their sections with `space-y-*`, which Tailwind implements as
|
||||
* `.space-y-6 > :not([hidden]) ~ :not([hidden]) { margin-top: … }` — two
|
||||
* classes and a pseudo-class, so it outranks a plain `mt-6` on the child no
|
||||
* matter which is written last. The class landed on the element, the computed
|
||||
* margin never moved, and the page looked identical: exactly the failure this
|
||||
* whole change exists to end, one level further down.
|
||||
*
|
||||
* `none` is therefore a real value rather than the absence of one. Asking for
|
||||
* no space above has to be able to say so, or the container's default is
|
||||
* simply unopposable.
|
||||
*
|
||||
* Still a closed set: four steps, each written out, each a class Tailwind can
|
||||
* see in this file. There is no path here from a stored value to arbitrary
|
||||
* CSS — an unknown value maps to nothing.
|
||||
*/
|
||||
const before = {
|
||||
none: '!mt-0', sm: '!mt-2', md: '!mt-4', lg: '!mt-6',
|
||||
}[layout?.spacingBefore];
|
||||
const after = {
|
||||
none: '!mb-0', sm: '!mb-2', md: '!mb-4', lg: '!mb-6',
|
||||
}[layout?.spacingAfter];
|
||||
return cn(before, after) || undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Everything a node's own layout asks of the element it is drawn as.
|
||||
*
|
||||
* `layoutClass` above is the other half and answers a different question: it is
|
||||
* the grid a *container* arranges its children in. This is what a node asks for
|
||||
* *itself* — the margin above and below it, how many columns of its parent's
|
||||
* grid it occupies, and how it sits in the row.
|
||||
*
|
||||
* It existed only as spacing, and only one component ever called it. Everything
|
||||
* else — the nine readings a person can add, and every section a definition
|
||||
* draws — took `layout` as a prop and dropped it, so an operation validated,
|
||||
* changed the tree, persisted, and produced no visible change at all. A layout
|
||||
* value that cannot be seen is worse than one that is refused.
|
||||
*
|
||||
* **Every class is written out.** Tailwind scans source text, so a class
|
||||
* assembled at run time is absent from the build and would silently do nothing —
|
||||
* the same failure in a new place. That is also what keeps this closed: these
|
||||
* are the only classes a layout value can ever produce, there is no path from
|
||||
* configuration to arbitrary CSS, and a value outside the vocabulary maps to
|
||||
* nothing rather than to itself.
|
||||
*/
|
||||
export function layoutClasses(layout) {
|
||||
const span = {
|
||||
1: 'col-span-1', 2: 'col-span-2', 3: 'col-span-3', 4: 'col-span-4',
|
||||
5: 'col-span-5', 6: 'col-span-6', 7: 'col-span-7', 8: 'col-span-8',
|
||||
9: 'col-span-9', 10: 'col-span-10', 11: 'col-span-11', 12: 'col-span-12',
|
||||
}[layout?.span];
|
||||
|
||||
const align = {
|
||||
start: 'self-start', center: 'self-center', end: 'self-end', stretch: 'self-stretch',
|
||||
}[layout?.align];
|
||||
|
||||
return cn(spacingClasses(layout), span, align) || undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* The panel a node draws itself as.
|
||||
*
|
||||
* The closed half of variant and density: two short maps, every class written
|
||||
* out so Tailwind can see it, and nothing that reads a value from configuration
|
||||
* into a class name. A value outside the vocabulary yields the default, so the
|
||||
* worst a malformed patch can do is look ordinary.
|
||||
*
|
||||
* `default` and `comfortable` reproduce **exactly** the panel every reading
|
||||
* section has always drawn — that is what lets this ship without changing a
|
||||
* single existing page, and what the migration baselines check.
|
||||
*
|
||||
* Returned as parts rather than one string because a component composes them
|
||||
* with its own classes and needs to control the order.
|
||||
*/
|
||||
export function presentationClasses(presentation) {
|
||||
const variant = {
|
||||
default: 'border-border bg-surface shadow-xs',
|
||||
subtle: 'border-border bg-surface-subtle shadow-none',
|
||||
emphasis: 'border-krow-blue/40 bg-krow-blue-tint/30 shadow-md ring-1 ring-krow-blue/20',
|
||||
}[presentation?.variant] || 'border-border bg-surface shadow-xs';
|
||||
|
||||
const density = {
|
||||
comfortable: { padding: 'p-5', gap: 'mb-3', title: 'text-body' },
|
||||
compact: { padding: 'p-3', gap: 'mb-1.5', title: 'text-body-sm' },
|
||||
}[presentation?.density] || { padding: 'p-5', gap: 'mb-3', title: 'text-body' };
|
||||
|
||||
return { variant, ...density };
|
||||
}
|
||||
|
||||
/** One node: its component, its identity, and its children if it holds any. */
|
||||
function UiNode({ node, registry }) {
|
||||
const entry = registry.get(node.type);
|
||||
|
||||
/* Hidden is a user's decision and is honoured before anything else, so a
|
||||
hidden node costs nothing to render. */
|
||||
if (node.hidden) return null;
|
||||
|
||||
/* Validation refuses an unknown type long before a tree is rendered, so
|
||||
reaching here means the registry and a stored patch have drifted — a type
|
||||
removed by a release, most likely. Render nothing rather than throw: one
|
||||
stale node must not take the page down. */
|
||||
if (!entry) return null;
|
||||
|
||||
const Component = entry.component;
|
||||
const attrs = nodeAttrs(node);
|
||||
|
||||
const children = entry.container
|
||||
? <UiTree nodes={node.children} registry={registry} layout={node.layout} />
|
||||
: null;
|
||||
|
||||
const drawn = (
|
||||
<Component
|
||||
node={node}
|
||||
attrs={entry.wrap ? {} : attrs}
|
||||
layout={node.layout}
|
||||
presentation={node.presentation}
|
||||
{...node.props}
|
||||
>
|
||||
{children}
|
||||
</Component>
|
||||
);
|
||||
|
||||
/* Wrapped so a section that throws costs the page that section, not the
|
||||
application. See `UiNodeBoundary`. */
|
||||
return (
|
||||
<UiNodeContext.Provider value={node}>
|
||||
<UiNodeBoundary node={node}>
|
||||
{entry.wrap ? <div {...attrs}>{drawn}</div> : drawn}
|
||||
</UiNodeBoundary>
|
||||
</UiNodeContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A list of nodes.
|
||||
*
|
||||
* Renders a bare fragment unless a layout was asked for, so the nodes sit
|
||||
* directly inside whatever container the page already had — and a page that
|
||||
* migrates its sections into the tree keeps the spacing it always had.
|
||||
*/
|
||||
function UiTree({ nodes, registry, layout = null }) {
|
||||
const grid = layoutClass(layout);
|
||||
const drawn = (nodes || []).map((node) => (
|
||||
<UiNode key={node.id} node={node} registry={registry} />
|
||||
));
|
||||
return grid ? <div className={grid}>{drawn}</div> : <>{drawn}</>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a composed page.
|
||||
*
|
||||
* `context` is the bag the page publishes for its own sections. `nodes` is the
|
||||
* merged tree — built-ins with the user's saved changes and any preview already
|
||||
* folded in by `composePage`, so this component neither reads storage nor knows
|
||||
* that a patch exists.
|
||||
*/
|
||||
export function UiTreeRenderer({ nodes = [], context = null, registry = nodeRegistry }) {
|
||||
/* Identity-stable across renders so a page's sections do not remount every
|
||||
time the page re-renders for an unrelated reason. */
|
||||
const value = React.useMemo(() => context || {}, [context]);
|
||||
|
||||
return (
|
||||
<UiRenderContext.Provider value={value}>
|
||||
<UiTree nodes={nodes} registry={registry} />
|
||||
</UiRenderContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* One node of a page's tree, drawn where the page puts it.
|
||||
*
|
||||
* For a page that is only **partly** composed. Positions is the case this
|
||||
* exists for: its two page-level extension slots sit at fixed points in a large
|
||||
* hand-written layout, and migrating that whole layout is a separate piece of
|
||||
* work. Until then the page keeps its own JSX and renders the composed nodes it
|
||||
* does have, each in the place it has always been.
|
||||
*
|
||||
* The consequence is worth being clear about: nodes anchored this way can be
|
||||
* hidden, and their children moved and reordered, but reordering the page's
|
||||
* *roots* has nowhere to happen — there is no single container drawing them in
|
||||
* sequence. A fully composed page has no such limit.
|
||||
*
|
||||
* `page` is named rather than inferred so the component works during a baseline
|
||||
* capture, where no editing session is mounted.
|
||||
*/
|
||||
export function UiNodeSlot({ page, id, context = null, registry = nodeRegistry }) {
|
||||
const editing = useUiEditing();
|
||||
|
||||
const node = React.useMemo(() => {
|
||||
const tree = editing?.page === page && editing.tree
|
||||
? editing.tree
|
||||
: composePage(page, { registry }).tree;
|
||||
return (tree || []).find((candidate) => candidate.id === id) || null;
|
||||
}, [editing, page, id, registry]);
|
||||
|
||||
if (!node || node.hidden) return null;
|
||||
|
||||
/* A container with nothing in it draws nothing — the rule `SkillSurface` has
|
||||
always followed, and the reason an empty extension point costs no space on
|
||||
the pages that have no definitions for it. */
|
||||
const entry = registry.get(node.type);
|
||||
if (entry?.container && !node.children?.length) return null;
|
||||
|
||||
return <UiTreeRenderer nodes={[node]} context={context} registry={registry} />;
|
||||
}
|
||||
176
src/components/ui-tree/chartNodeTypes.jsx
Normal file
176
src/components/ui-tree/chartNodeTypes.jsx
Normal file
@@ -0,0 +1,176 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
Bar, BarChart, Cell, Pie, PieChart, ResponsiveContainer, Tooltip, XAxis, YAxis,
|
||||
} from 'recharts';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { AXIS_PROPS, CHART_COLORS, CHART_TONES } from '@/components/ds/ChartContainer';
|
||||
import { SECTION_TYPES } from '@/lib/skills/surfaces';
|
||||
import { useSkillDataContext } from '@/components/skills/SkillSurface';
|
||||
import { resolveSkillData } from '@/lib/skills/dataResolver';
|
||||
import { DENSITY_VALUES, VARIANT_VALUES } from '@/lib/ui/node';
|
||||
import { registerNodeType } from '@/lib/ui/registry';
|
||||
import { layoutClasses, presentationClasses } from './UiTreeRenderer';
|
||||
|
||||
/**
|
||||
* Two ways of drawing a series: as bars, and as parts of a whole.
|
||||
*
|
||||
* These are the first node types that are **not** skill section types. The nine
|
||||
* readings exist in `surfaces.js` because a Markdown definition may name them;
|
||||
* chart and pie are named by nobody but this registry, so adding them here adds
|
||||
* nothing to the MD vocabulary, obliges no change in the Go parser, and cannot
|
||||
* drift from its conformance oracle. A person reaches them by turning an
|
||||
* existing node into one.
|
||||
*
|
||||
* **Nothing is transformed and nothing is invented.** A `flow` or a `stats`
|
||||
* reading already resolves to `{ steps: [{ id, label, value }] }` — a labelled
|
||||
* numeric series, which is precisely what a bar chart and a pie chart each
|
||||
* need. So these draw the same payload the Flow section draws, from the same
|
||||
* resolver, keyed by the same source. There is no adapter layer here because
|
||||
* there is nothing to adapt.
|
||||
*
|
||||
* The consequence is the compatibility rule, and it is the existing one: a
|
||||
* source can become a chart exactly when it declares `flow` or `stats`, decided
|
||||
* by `sourceSupportsShape` like every other replacement. No new shape, no new
|
||||
* vocabulary, no per-source table.
|
||||
*/
|
||||
|
||||
/** The series a reading resolved to, in the one shape both charts read. */
|
||||
function useSeries(node) {
|
||||
const context = useSkillDataContext(null);
|
||||
const section = React.useMemo(() => ({
|
||||
id: node.id,
|
||||
/* `flow` because that is the shape whose payload these draw. Resolvers are
|
||||
keyed by source and never read this, but a section without a type is not
|
||||
a section, and naming the shape it consumes keeps that honest. */
|
||||
type: 'flow',
|
||||
source: node.data?.source || '',
|
||||
periods: node.data?.params?.periods || [],
|
||||
limit: node.data?.params?.limit || null,
|
||||
}), [node.id, node.data]);
|
||||
|
||||
const data = React.useMemo(() => resolveSkillData(section, context), [section, context]);
|
||||
const rows = (data.steps || data.items || [])
|
||||
.map((step, i) => ({
|
||||
id: step.id || `${i}`,
|
||||
label: String(step.label || step.title || ''),
|
||||
value: Number(step.value) || 0,
|
||||
}))
|
||||
/* A slice of nothing is not a slice, and a bar of nothing is a gap. Both
|
||||
charts read real numbers or draw the empty note. */
|
||||
.filter((row) => row.label);
|
||||
|
||||
return { data, rows };
|
||||
}
|
||||
|
||||
/** How tall the plot is, and therefore how big a pie fits in it. */
|
||||
const plotHeight = (presentation) => (presentation?.density === 'compact' ? 160 : 240);
|
||||
|
||||
/** The panel both share — the same chrome every reading section draws. */
|
||||
function ChartPanel({ node, attrs, layout, presentation, title, children, empty }) {
|
||||
const look = presentationClasses(presentation);
|
||||
const height = plotHeight(presentation);
|
||||
|
||||
return (
|
||||
<section
|
||||
{...attrs}
|
||||
aria-label={title || undefined}
|
||||
className={cn('rounded-2xl border', look.variant, look.padding, layoutClasses(layout))}
|
||||
>
|
||||
{title && (
|
||||
<div className={look.gap}>
|
||||
<h3 className={cn('font-heading font-semibold text-ink-1', look.title)}>{title}</h3>
|
||||
</div>
|
||||
)}
|
||||
{empty
|
||||
? <p className="text-caption text-ink-3">{empty}</p>
|
||||
: <div style={{ height }}>{children}</div>}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
/** Bars, one per step, in the brand's own colour. */
|
||||
function ChartNode({ node, attrs = {}, layout = null, presentation = null, title = null }) {
|
||||
const { data, rows } = useSeries(node);
|
||||
const empty = !rows.length ? (data.emptyNote || 'Nothing to chart yet.') : null;
|
||||
|
||||
return (
|
||||
<ChartPanel node={node} attrs={attrs} layout={layout} presentation={presentation} title={title} empty={empty}>
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<BarChart data={rows} margin={{ top: 8, right: 8, bottom: 0, left: -16 }}>
|
||||
<XAxis dataKey="label" {...AXIS_PROPS} interval={0} height={40} />
|
||||
<YAxis {...AXIS_PROPS} allowDecimals={false} />
|
||||
<Tooltip cursor={{ fill: CHART_TONES.mint, fillOpacity: 0.25 }} />
|
||||
<Bar dataKey="value" fill={CHART_TONES.brand} radius={[4, 4, 0, 0]} />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</ChartPanel>
|
||||
);
|
||||
}
|
||||
|
||||
/** The same series as parts of a whole. */
|
||||
function PieNode({ node, attrs = {}, layout = null, presentation = null, title = null }) {
|
||||
const { data, rows } = useSeries(node);
|
||||
const total = rows.reduce((sum, row) => sum + row.value, 0);
|
||||
/* A pie of nothing is a blank disc, so an all-zero series is empty rather
|
||||
than drawn — the reading has values, they are simply all zero. */
|
||||
const empty = !rows.length
|
||||
? (data.emptyNote || 'Nothing to chart yet.')
|
||||
: (total === 0 ? 'Every value here is zero.' : null);
|
||||
|
||||
const height = plotHeight(presentation);
|
||||
const radius = { outer: Math.round(height * 0.38) };
|
||||
|
||||
return (
|
||||
<ChartPanel node={node} attrs={attrs} layout={layout} presentation={presentation} title={title} empty={empty}>
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<PieChart>
|
||||
{/* Centre and radii given explicitly, in pixels.
|
||||
Percentage radii with no `cx`/`cy` produced sectors that existed in
|
||||
the DOM, carried no fill and drew nothing — a blank panel that
|
||||
every automated check called a success because the elements were
|
||||
there. The one pie already in this repository does it this way,
|
||||
and it works. */}
|
||||
<Pie
|
||||
data={rows}
|
||||
dataKey="value"
|
||||
nameKey="label"
|
||||
cx="50%"
|
||||
cy="50%"
|
||||
outerRadius={radius.outer}
|
||||
isAnimationActive={false}
|
||||
>
|
||||
{rows.map((row, i) => (
|
||||
<Cell key={row.id} fill={CHART_COLORS[i % CHART_COLORS.length]} />
|
||||
))}
|
||||
</Pie>
|
||||
<Tooltip />
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
</ChartPanel>
|
||||
);
|
||||
}
|
||||
|
||||
/* The shapes whose payload both of these draw, read from the vocabulary rather
|
||||
than written out, so a shape renamed there cannot leave these claiming one
|
||||
that no longer exists. */
|
||||
const SERIES_SHAPES = ['flow', 'stats'].filter((shape) => SECTION_TYPES.some((t) => t.id === shape));
|
||||
|
||||
for (const [type, label, summary, component] of [
|
||||
['chart', 'Chart', 'A bar for each step in a reading.', ChartNode],
|
||||
['pie', 'Pie', 'The same steps as parts of a whole.', PieNode],
|
||||
]) {
|
||||
registerNodeType({
|
||||
type,
|
||||
label,
|
||||
summary,
|
||||
component,
|
||||
dataShapes: SERIES_SHAPES,
|
||||
dataRequired: true,
|
||||
propSchema: { title: { type: 'string', label: 'Title' } },
|
||||
variants: VARIANT_VALUES,
|
||||
/* Both densities change the plot height, which is the one thing a chart has
|
||||
to give. Declared because they are honoured, not because they exist. */
|
||||
densities: DENSITY_VALUES,
|
||||
capabilities: ['update', 'remove', 'move', 'replace', 'hide'],
|
||||
});
|
||||
}
|
||||
102
src/components/ui-tree/nodeTypes.jsx
Normal file
102
src/components/ui-tree/nodeTypes.jsx
Normal file
@@ -0,0 +1,102 @@
|
||||
import React from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { SUPPORTED_SECTION_TYPES } from '@/lib/skills/surfaces';
|
||||
import { layoutClasses } from './UiTreeRenderer';
|
||||
import { registerNodeType } from '@/lib/ui/registry';
|
||||
/* The nine reading components, registered from the skill vocabulary. Imported
|
||||
here so one import gives a page every type it can offer. */
|
||||
import './sectionNodeTypes';
|
||||
/* The two visualisation types. Not skill sections — see the note in the file. */
|
||||
import './chartNodeTypes';
|
||||
|
||||
/**
|
||||
* Node types every page can use.
|
||||
*
|
||||
* Page-specific sections register beside their own page; this file is for the
|
||||
* types that belong to no page in particular. Today that is one: the slot a
|
||||
* skill definition renders into.
|
||||
*/
|
||||
|
||||
/**
|
||||
* A skill surface, as a node.
|
||||
*
|
||||
* This is the join between the two systems, and it is deliberately thin. A
|
||||
* `ui:` block in a skill definition is still normalized by `uiConfig.js`,
|
||||
* resolved by `dataResolver.js` and drawn by `SkillSurface` exactly as it is
|
||||
* today — nothing about Board skills changes. What changes is that the *slot*
|
||||
* is now a node, so a person can move the extension point up the page or hide
|
||||
* it, using the same operations that move a built-in section.
|
||||
*
|
||||
* It renders **nothing of its own**. `SkillSurface` already returns `null` when
|
||||
* no definition claims the placement, and that must stay true: a slot that
|
||||
* rendered an empty wrapper would add a gap to every page it sits on, on every
|
||||
* account that has authored no skills — which is nearly all of them. So this
|
||||
* type does not declare `wrap`, and accepts having no DOM identity while it is
|
||||
* empty over changing what an empty page looks like.
|
||||
*/
|
||||
/**
|
||||
* The slot, as a container.
|
||||
*
|
||||
* It draws nothing of its own and holds no logic: the sections that belong to
|
||||
* it are children in the tree, put there by `composePage`, and the renderer
|
||||
* walks them like any other children. That is what makes a Board skill's card
|
||||
* addressable — it is a node, not something a component conjured up while
|
||||
* rendering.
|
||||
*
|
||||
* Empty means **nothing**, not an empty box. `SkillSurface` has always returned
|
||||
* `null` where no definition claims a placement, and every page that has not
|
||||
* migrated still calls it directly. A slot that rendered a wrapper regardless
|
||||
* would put a gap on every page of every account that has authored no skills,
|
||||
* which is nearly all of them.
|
||||
*/
|
||||
function SkillSurfaceNode({ node, children, layout }) {
|
||||
if (!node.children?.length) return null;
|
||||
/* The same spacing `SkillSurface` puts between sections, plus whatever margin
|
||||
the page's composition asked for — which is how a slot keeps the exact
|
||||
separation it had before it became a node. */
|
||||
return <div className={cn('space-y-4', layoutClasses(layout))}>{children}</div>;
|
||||
}
|
||||
|
||||
registerNodeType({
|
||||
type: 'skill-surface',
|
||||
label: 'Skill sections',
|
||||
summary: 'Where definitions authored for this page render.',
|
||||
component: SkillSurfaceNode,
|
||||
container: true,
|
||||
/* Only readings may sit in a slot — the nine the skill format already allows.
|
||||
Derived from the vocabulary rather than listed, so the two cannot drift. */
|
||||
accepts: SUPPORTED_SECTION_TYPES,
|
||||
/* Moving and hiding the slot is meaningful; replacing it with a chart is not,
|
||||
and neither is deleting the only way a page can be extended. Its children
|
||||
are separately addressable and carry their own capabilities. */
|
||||
capabilities: ['move', 'hide', 'reorder'],
|
||||
/**
|
||||
* Never something a person adds.
|
||||
*
|
||||
* A slot exists because a page offered an extension point at a particular
|
||||
* spot, and a second one conjured up by a picker would anchor nothing — no
|
||||
* definition names it, so it would render empty forever. It was offered and
|
||||
* then refused with "'Skill sections' cannot be added to", which is the
|
||||
* product asking a question it already knew the answer to. It is a container
|
||||
* and an anchor; the two are not the same claim.
|
||||
*/
|
||||
addable: false,
|
||||
/**
|
||||
* Which slot this is.
|
||||
*
|
||||
* A page mounts several, and they all share one label — so an outline listed
|
||||
* "Skill sections" twice with nothing to choose between them, and "hide the
|
||||
* skill sections" had no answer. The placement is the only thing that
|
||||
* distinguishes them and it is already on the node, so it is what they are
|
||||
* called: "Skill sections (after header)".
|
||||
*/
|
||||
describe: (node) => {
|
||||
const placement = String(node?.props?.placement || '').trim();
|
||||
if (!placement) return 'Skill sections';
|
||||
return `Skill sections (${placement.replace(/-/g, ' ')})`;
|
||||
},
|
||||
propSchema: {
|
||||
page: { type: 'string', required: true, label: 'Page' },
|
||||
placement: { type: 'string', required: true, label: 'Placement' },
|
||||
},
|
||||
});
|
||||
155
src/components/ui-tree/sectionNodeTypes.jsx
Normal file
155
src/components/ui-tree/sectionNodeTypes.jsx
Normal file
@@ -0,0 +1,155 @@
|
||||
import React from 'react';
|
||||
import { Sparkles } from 'lucide-react';
|
||||
import { SECTION_COMPONENTS } from '@/components/skills/SkillSections';
|
||||
import { useSkillDataContext } from '@/components/skills/SkillSurface';
|
||||
import { usePageAction } from '@/components/ai-assistant/PageContext';
|
||||
import { resolveSkillData } from '@/lib/skills/dataResolver';
|
||||
import { SECTION_TYPES, dataSourceFor } from '@/lib/skills/surfaces';
|
||||
import { DENSITY_VALUES, VARIANT_VALUES } from '@/lib/ui/node';
|
||||
import { registerNodeType } from '@/lib/ui/registry';
|
||||
import { layoutClasses, presentationClasses } from './UiTreeRenderer';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
/**
|
||||
* The nine reading components, registered as node types.
|
||||
*
|
||||
* These are the same components a Board skill draws with — `SECTION_COMPONENTS`
|
||||
* from `SkillSections.jsx`, resolved through the same `resolveSkillData` and the
|
||||
* same closed data vocabulary. Registering them here is what makes "add a card"
|
||||
* and "change this to a table" mean something: a person can put one of the
|
||||
* product's own readings on a page without authoring a skill for it.
|
||||
*
|
||||
* Nothing about skills changes. A `ui:` block still renders through
|
||||
* `SkillSurface` exactly as before; this is a second consumer of the same
|
||||
* renderer, which is the arrangement `SkillSections.jsx` was already built for —
|
||||
* the page and the chat panel were the first two.
|
||||
*
|
||||
* **A node of these types cannot invent data.** It carries a data *binding*, not
|
||||
* data: a source id from the closed vocabulary, resolved at render time against
|
||||
* records the caller already has. A binding naming something that is not a
|
||||
* source is refused by validation before it can be previewed, let alone saved.
|
||||
*/
|
||||
|
||||
/**
|
||||
* One reading, drawn.
|
||||
*
|
||||
* The `section` shape is assembled from the node rather than from a Markdown
|
||||
* definition, but it is the same shape `normalizeSection` produces — which is
|
||||
* why the resolver and the component take it without knowing which of the two
|
||||
* built it.
|
||||
*/
|
||||
function ReadingNode({
|
||||
node, attrs = {}, layout = null, presentation = null, title = null, description = null,
|
||||
attribution = null, editable = false, type,
|
||||
}) {
|
||||
const Component = SECTION_COMPONENTS[type];
|
||||
const context = useSkillDataContext(null);
|
||||
|
||||
const section = React.useMemo(() => ({
|
||||
id: node.id,
|
||||
type,
|
||||
title: title || null,
|
||||
description: description || null,
|
||||
source: node.data?.source || '',
|
||||
periods: node.data?.params?.periods || [],
|
||||
limit: node.data?.params?.limit || null,
|
||||
/* What the reading needs the page to have open. Read off the source, so a
|
||||
node cannot claim a context its source never declared. */
|
||||
context: dataSourceFor(node.data?.source)?.context,
|
||||
editable: Boolean(editable),
|
||||
}), [node.id, node.data, title, description, editable, type]);
|
||||
|
||||
const data = React.useMemo(() => resolveSkillData(section, context), [section, context]);
|
||||
|
||||
/* How this section writes back, if the page is offering that write at all —
|
||||
the same rule `SkillSurface` applies, so a section declared editable on a
|
||||
page that does not own the data stays an honest read-out. */
|
||||
const apply = usePageAction(section.editable ? section.source : null);
|
||||
|
||||
/* Validation refuses a binding-less reading long before this, so reaching here
|
||||
without one means a stored patch outlived a vocabulary change. Draw nothing
|
||||
rather than an empty panel with a title. */
|
||||
/* The closed map, read once. Not a hook, so it sits with the other derived
|
||||
values and changes nothing about when this component re-renders. */
|
||||
const look = presentationClasses(presentation);
|
||||
|
||||
if (!Component || !node.data?.source) return null;
|
||||
|
||||
/**
|
||||
* Two chromes, one component.
|
||||
*
|
||||
* A section contributed by a skill is drawn exactly as `SkillSurface` has
|
||||
* always drawn it — same panel, same heading, same attribution pill — because
|
||||
* moving a Board card into the node tree must not change how it looks. A node
|
||||
* a person added has no skill to attribute, so it gets the plain panel. The
|
||||
* difference is a property, not a branch on where the node came from.
|
||||
*/
|
||||
return (
|
||||
<section
|
||||
{...attrs}
|
||||
aria-label={section.title || attribution || undefined}
|
||||
/* The panel this draws, as its own presentation describes it, plus
|
||||
whatever its layout asks for. Both helpers return the existing values
|
||||
when a node asks for nothing, so a section nobody has customised
|
||||
renders exactly the markup it did before — which is what the migration
|
||||
baselines check. */
|
||||
className={cn('rounded-2xl border', look.variant, look.padding, layoutClasses(layout))}
|
||||
>
|
||||
{(section.title || attribution) && (
|
||||
<div className={cn(look.gap, 'flex flex-wrap items-start justify-between gap-2')}>
|
||||
<div className="min-w-0">
|
||||
<h3 className={cn('font-heading font-semibold text-ink-1', look.title)}>
|
||||
{section.title}
|
||||
</h3>
|
||||
{section.description && (
|
||||
<p className="mt-0.5 text-caption leading-relaxed text-ink-3">{section.description}</p>
|
||||
)}
|
||||
</div>
|
||||
{attribution && (
|
||||
<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" />
|
||||
{attribution}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Component data={data} section={section} onApply={apply} />
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
for (const { id, label, summary } of SECTION_TYPES) {
|
||||
registerNodeType({
|
||||
type: id,
|
||||
label,
|
||||
summary,
|
||||
/* Bound per type so the component does not have to read its own name back
|
||||
out of the node it was handed. */
|
||||
component: (props) => <ReadingNode {...props} type={id} />,
|
||||
/* All nine draw the same panel through `ReadingNode`, so all nine can
|
||||
honour the whole vocabulary. Declared from it rather than listed, so a
|
||||
value added to the product reaches every reading without an edit here —
|
||||
and a value removed cannot be left behind claiming support. */
|
||||
variants: VARIANT_VALUES,
|
||||
densities: DENSITY_VALUES,
|
||||
/* The one shape this component draws. Validation pairs it against what a
|
||||
source can fill, so `table` accepts only sources that have a table in
|
||||
them — the same rule `normalizeSection` already applies to a skill. */
|
||||
dataShapes: [id],
|
||||
dataRequired: true,
|
||||
propSchema: {
|
||||
title: { type: 'string', label: 'Title' },
|
||||
description: { type: 'string', label: 'Description' },
|
||||
/* The skill that contributed this section, when one did. Carried as a
|
||||
property rather than inferred from `origin`, so the renderer stays
|
||||
ignorant of provenance. */
|
||||
attribution: { type: 'string', label: 'Contributed by' },
|
||||
editable: { type: 'boolean', label: 'Editable' },
|
||||
},
|
||||
/* Everything, because unlike a built-in page section these are nodes a
|
||||
person put there: they can be removed as well as hidden. */
|
||||
capabilities: ['update', 'remove', 'move', 'replace', 'hide'],
|
||||
});
|
||||
}
|
||||
@@ -15,6 +15,8 @@ import {
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { Sheet, SheetContent, SheetHeader, SheetTitle } from '@/components/ui/sheet';
|
||||
import { AssistantPanel, AssistantPanelProvider } from '@/components/ai-assistant';
|
||||
import { UiEditingProvider } from '@/components/ui-tree/UiEditingProvider';
|
||||
import { pageKeyForRoute } from '@/lib/skills/registry';
|
||||
import { endAdminSession } from '@/lib/admin/session';
|
||||
import { useCurrentUser } from '@/lib/krowHooks';
|
||||
|
||||
@@ -177,8 +179,22 @@ export default function AdminLayout() {
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* The layout session for whichever page is open.
|
||||
*
|
||||
* Mounted here rather than inside a page because both the page and the panel
|
||||
* beside it need the same one: the page renders the tree, and Owliver
|
||||
* proposes changes to it. Two providers would be two trees, and the preview
|
||||
* shown in chat would be of a page nobody was looking at.
|
||||
*
|
||||
* A page that has registered no composition simply has an empty tree, so this
|
||||
* costs nothing on the surfaces that have not migrated yet.
|
||||
*/
|
||||
const uiPage = pageKeyForRoute(location.pathname) || '';
|
||||
|
||||
return (
|
||||
<AssistantPanelProvider role="admin" pathname={location.pathname}>
|
||||
<UiEditingProvider page={uiPage}>
|
||||
{/* Transparent so the ambient canvas painted behind the app reads through.
|
||||
An opaque shell here would cover it and every Admin page would lose the
|
||||
tint at once — which is exactly why the canvas is one layer and not a
|
||||
@@ -354,6 +370,7 @@ export default function AdminLayout() {
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</UiEditingProvider>
|
||||
</AssistantPanelProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -282,6 +282,40 @@ export function resolveAgentForTurn(agents = [], activeId, contextId) {
|
||||
};
|
||||
}
|
||||
|
||||
/* ── Skill ownership ────────────────────────────────────────────────────── */
|
||||
|
||||
/**
|
||||
* Whether this agent context permits a skill's UI.
|
||||
*
|
||||
* Ownership is **opt-in**, and the asymmetry is the whole rule:
|
||||
*
|
||||
* - a skill claimed by at least one agent belongs to those agents, and its
|
||||
* sections are drawn only where one of them is answering;
|
||||
* - a skill claimed by nobody is unowned, and unowned means unchanged — the
|
||||
* pages it declares and the account switch decide it, exactly as before.
|
||||
*
|
||||
* Enforcing ownership on every skill instead would have removed every skill
|
||||
* section in the product on the day it shipped: the definitions that draw page
|
||||
* UI are authored on an account and claimed by no agent, while every skill this
|
||||
* build ships is conversation-only. Attaching a skill to an agent is therefore
|
||||
* the act that brings it under that agent's control — a decision an author
|
||||
* makes in Agent Configure, not one taken on their behalf.
|
||||
*
|
||||
* With no agents loaded — outside a provider, or before the registry has
|
||||
* answered — nothing is owned and nothing is constrained, which is the safe
|
||||
* reading rather than a permissive one: it can only ever show what the page and
|
||||
* the account already allow.
|
||||
*
|
||||
* This answers one question only. What pages a skill declares and what the
|
||||
* account has switched off are separate rules, checked separately.
|
||||
*/
|
||||
export function agentPermitsSkill(skillId, { agents = [], agent = null } = {}) {
|
||||
if (!skillId) return false;
|
||||
const owned = (agents || []).some((a) => (a?.skills || []).includes(skillId));
|
||||
if (!owned) return true;
|
||||
return (agent?.skills || []).includes(skillId);
|
||||
}
|
||||
|
||||
/* ── Starters ───────────────────────────────────────────────────────────── */
|
||||
|
||||
/**
|
||||
|
||||
@@ -43,6 +43,36 @@ export function desiredPayLabel(record = {}) {
|
||||
return min ? `From $${min}/hr` : `Up to $${max}/hr`;
|
||||
}
|
||||
|
||||
/**
|
||||
* The body for creating a NEW employee together with their first declared role.
|
||||
*
|
||||
* Two nested records, because they are two rows and the endpoint writes them in
|
||||
* one transaction: the worker on the outside, the role under `role`. Nesting
|
||||
* rather than flattening is what stops a key meant for one landing on the
|
||||
* other — `experience_years` means something different on a profile than on a
|
||||
* declared role, and a flat body would have to guess.
|
||||
*
|
||||
* The email is passed through as the caller typed it. Nothing here derives one,
|
||||
* defaults one, or falls back to another record's; an absent email reaches the
|
||||
* server absent, and the server refuses it.
|
||||
*/
|
||||
export function toNewWorkerWithRolePayload(draft = {}) {
|
||||
const role = toEmployeeRolePayload(draft);
|
||||
/* The identity fields belong to the worker. The server copies them onto the
|
||||
role from the row it just created, so sending them twice would let the two
|
||||
disagree. */
|
||||
const { worker_email: email, worker_name: name, worker_profile_id: _ignored, ...roleOnly } = role;
|
||||
|
||||
return {
|
||||
full_name: name,
|
||||
email,
|
||||
availability: role.availability,
|
||||
certifications: role.certifications,
|
||||
experience_years: role.experience_years,
|
||||
role: roleOnly,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The record the API is asked to create.
|
||||
*
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import React from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { mergeLayouts, normalizeLayouts } from '@/lib/ui/patch';
|
||||
import { API_BASE_URL, base44 } from '@/api/base44Client';
|
||||
import { request } from '@/api/httpClient';
|
||||
import { generateJobDescription, screenCandidate, matchTalentForJob } from './krowAi';
|
||||
import { recalcProfilePatch } from './krowScore';
|
||||
import { logActivity } from './userTracking';
|
||||
@@ -62,6 +65,52 @@ export function useUpdatePreferences() {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* A person's own UI layout changes, per page.
|
||||
*
|
||||
* Stored beside `customSkills` in the account's preferences, which is a store
|
||||
* that already exists, is already per-user, and already reaches Postgres
|
||||
* through `PATCH /api/v1/me/preferences`. No new endpoint, no new table, and
|
||||
* nothing to deploy — which is the whole point: a layout change is a runtime
|
||||
* change, and a runtime change that needed a release would not be one.
|
||||
*
|
||||
* What is written is the **operation list** the UI engine validated, never a
|
||||
* copy of the rendered tree and never Markdown. A stored tree would be a
|
||||
* photograph of the page on the day it was saved; an operation still means what
|
||||
* it said after a release moves the built-ins around it.
|
||||
*
|
||||
* The whole `uiLayouts` object is sent on every write, because the endpoint
|
||||
* shallow-merges its top-level keys: sending one page would replace the map and
|
||||
* silently drop every other page's layout.
|
||||
*/
|
||||
export function useUiLayouts() {
|
||||
const preferences = usePreferences();
|
||||
const update = useUpdatePreferences();
|
||||
|
||||
const layouts = React.useMemo(
|
||||
() => normalizeLayouts(preferences.uiLayouts).layouts,
|
||||
[preferences.uiLayouts]
|
||||
);
|
||||
|
||||
/**
|
||||
* Store one page's patch.
|
||||
*
|
||||
* A patch with no operations is removed rather than stored empty — that is
|
||||
* the same state as never having customised the page, and keeping the key
|
||||
* would grow the blob with a record of every page somebody once opened.
|
||||
*/
|
||||
const save = React.useCallback(async (page, patch) => {
|
||||
const key = String(page || '').trim();
|
||||
if (!key) return null;
|
||||
/* Only `uiLayouts` is sent. Every other preference — `customSkills` above
|
||||
all — is left for the endpoint's shallow merge to preserve, so a layout
|
||||
change can never disturb an authored skill. */
|
||||
return update.mutateAsync({ uiLayouts: mergeLayouts(layouts, key, patch) });
|
||||
}, [layouts, update]);
|
||||
|
||||
return { layouts, save, saving: update.isPending };
|
||||
}
|
||||
|
||||
export function useJobPostings() {
|
||||
return useQuery({
|
||||
queryKey: ['jobPostings'],
|
||||
@@ -326,6 +375,29 @@ export function useCreateJobPosting() {
|
||||
* organization now has one more worker offering that role and what is worth
|
||||
* asking has changed with it.
|
||||
*/
|
||||
/**
|
||||
* Record a NEW employee and their first declared role, in one transaction.
|
||||
*
|
||||
* One request, not two. Creating the worker and then the role as separate calls
|
||||
* leaves a worker nobody meant to create when the second fails — indistinguishable
|
||||
* from a real one and with nothing to say why it is there. The endpoint writes
|
||||
* both inside a transaction and rolls the worker back if the role cannot be
|
||||
* written, so a refused create leaves the database exactly as it was.
|
||||
*/
|
||||
export function useCreateWorkerWithRole() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: /** @param {any} data */ (data) => request('POST', '/worker-profiles/with-role', { body: data }),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['employeeRoles'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['workerProfiles'] });
|
||||
queryClient.invalidateQueries({ queryKey: owliverContextKey });
|
||||
logActivity('create_employee_role');
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/** Record another role for somebody already on file. A different request. */
|
||||
export function useCreateEmployeeRole() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
|
||||
@@ -11,7 +11,65 @@
|
||||
* `useCreateJobPosting`, in both paths.
|
||||
*/
|
||||
|
||||
/** Certifications a position can require, in the order they are offered. */
|
||||
/**
|
||||
* Which certifications matter for a role, from what this organization has
|
||||
* actually asked for.
|
||||
*
|
||||
* THE SOURCE OF TRUTH, and it is worth being explicit about why it is this one.
|
||||
* The schema has no role-to-certification relationship at all: `role_categories`
|
||||
* and `certifications` are both bare `(id, org_id, name)` lists with nothing
|
||||
* joining them. So relevance cannot be looked up — but it can be OBSERVED, and
|
||||
* the observation is real organizational behaviour rather than a guess:
|
||||
* `job_postings` carries `role_category` and `certifications_required` on the
|
||||
* same row, so every posting this organization has written is a statement that
|
||||
* these certifications matter for that role.
|
||||
*
|
||||
* That makes the answer tenant-specific for free. A staffing company that puts
|
||||
* `Guard Card` on its Security postings gets Guard Card; one that does not,
|
||||
* does not. Nothing here carries a list of roles or a list of certifications,
|
||||
* and adding either to the product changes this function's output without
|
||||
* changing this function.
|
||||
*
|
||||
* `postings` is the caller's own already-loaded set, so this widens nobody's
|
||||
* view: the API scoped it before it reached the browser.
|
||||
*
|
||||
* Returns `null` when the postings have not loaded, and `[]` when they have and
|
||||
* the role genuinely has none. Those are different answers — "we do not know
|
||||
* yet" must not render as "there are none" — and the caller is expected to tell
|
||||
* them apart rather than treating both as empty.
|
||||
*/
|
||||
export function certificationsForRole(role, postings) {
|
||||
if (!Array.isArray(postings)) return null;
|
||||
|
||||
const want = String(role || '').trim().toLowerCase();
|
||||
if (!want) return [];
|
||||
|
||||
const found = new Set();
|
||||
for (const posting of postings) {
|
||||
if (String(posting?.role_category || '').trim().toLowerCase() !== want) continue;
|
||||
for (const cert of posting.certifications_required || []) {
|
||||
const name = String(cert || '').trim();
|
||||
if (name) found.add(name);
|
||||
}
|
||||
}
|
||||
return [...found];
|
||||
}
|
||||
|
||||
/**
|
||||
* Certifications the CREATE POSITION FORM offers, in the order they are offered.
|
||||
*
|
||||
* A fixed list, and it should not be one — the organization keeps its own in
|
||||
* the `certifications` table, which `useCertifications()` already reads and
|
||||
* which nothing in this product currently consults. Against the live tenant
|
||||
* this list is wrong twice over: it offers `ABC License`, which that
|
||||
* organization does not use, and spells `CPR/First Aid` where the record says
|
||||
* `CPR / First Aid`, so the two can never match.
|
||||
*
|
||||
* Left in place deliberately rather than quietly rewired: the form is a
|
||||
* multi-select over a fixed vocabulary and changing its source is a change to
|
||||
* how positions are authored, which is a product decision and not a bug fix.
|
||||
* The conversational flows no longer read it — see `certificationsForRole`.
|
||||
*/
|
||||
export const CERT_OPTIONS = [
|
||||
'Food Handler Card',
|
||||
'ServSafe',
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { toEmployeeRolePayload } from '@/lib/employeeRoleModel';
|
||||
import { toNewWorkerWithRolePayload } from '@/lib/employeeRoleModel';
|
||||
import { CERT_OPTIONS, ENGLISH_LEVELS, toPositionPayload } from '@/lib/positionModel';
|
||||
import { routeForPageKey } from './registry';
|
||||
|
||||
@@ -488,10 +488,21 @@ const HANDLERS = {
|
||||
* situation rather than how finished the record is, so the conversation's one
|
||||
* verb commits it as `seeking` — there is no draft of a person's own role.
|
||||
*/
|
||||
create_employee_role: ({ draft, status }) => ({
|
||||
type: 'create_employee_role',
|
||||
data: { ...toEmployeeRolePayload(draft || {}), status: status || 'seeking' },
|
||||
}),
|
||||
/**
|
||||
* Record a NEW employee and their first declared role.
|
||||
*
|
||||
* The payload is two nested records because the endpoint writes two rows in
|
||||
* one transaction. Refused outright without a name and an email: those are
|
||||
* the person, and neither is ever derived from the other or from the caller.
|
||||
*/
|
||||
create_employee_role: ({ draft, status }) => {
|
||||
const record = { ...(draft || {}) };
|
||||
if (!String(record.worker_name || '').trim() || !String(record.worker_email || '').trim()) return null;
|
||||
return {
|
||||
type: 'create_employee_role',
|
||||
data: toNewWorkerWithRolePayload({ ...record, status: status || 'seeking' }),
|
||||
};
|
||||
},
|
||||
|
||||
/**
|
||||
* Open the Add Skill Training flow, on the Forge page, with what the request
|
||||
|
||||
@@ -129,10 +129,16 @@ export const missingRequired = (registry, flow, steps) => steps
|
||||
* appearing as the literal "@workers", which is what it did before registries
|
||||
* existed and each resolver knew every token.
|
||||
*/
|
||||
function suggestionsFor(registry, step, ctx) {
|
||||
function suggestionsFor(registry, step, ctx, draft = {}) {
|
||||
const resolved = step.options.flatMap((option) => {
|
||||
if (!option.startsWith('@')) return [option];
|
||||
return registry.resolve?.(option, ctx) || [];
|
||||
/* The DRAFT is passed as well as the context, and it is what makes an
|
||||
option list able to depend on an earlier answer. Certifications are the
|
||||
case that forced it: which ones matter is a fact about the role chosen
|
||||
two questions ago, and a resolver that only saw the page's data could
|
||||
never know it. Recomputed on every ask, so changing the role from the
|
||||
change menu recomputes rather than reusing what the last role produced. */
|
||||
return registry.resolve?.(option, ctx, draft) || [];
|
||||
});
|
||||
|
||||
const capped = [...new Set(resolved.filter(Boolean))].slice(0, 6);
|
||||
@@ -158,7 +164,7 @@ function ask(registry, flow, steps, ctx, { preamble = null, retry = null } = {})
|
||||
text(step.question),
|
||||
retry ? note(retry) : null
|
||||
),
|
||||
followUp: suggestionsFor(registry, step, ctx),
|
||||
followUp: suggestionsFor(registry, step, ctx, flow.draft),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { doc, list, note, text } from '@/components/ai-assistant/blocks';
|
||||
import { CERT_OPTIONS, ENGLISH_LEVELS } from '@/lib/positionModel';
|
||||
import { ENGLISH_LEVELS, certificationsForRole } from '@/lib/positionModel';
|
||||
import {
|
||||
AVAILABILITY_OPTIONS, defaultEmployeeRole, desiredPayLabel,
|
||||
AVAILABILITY_OPTIONS, desiredPayLabel,
|
||||
} from '@/lib/employeeRoleModel';
|
||||
import {
|
||||
extractCertifications, extractEnglish, extractExperience, extractPay, extractRole,
|
||||
@@ -31,37 +31,54 @@ const fields = {
|
||||
* and what survives a worker profile being removed. A name with no email
|
||||
* behind it is not an answer, so `parse` refuses one it cannot resolve.
|
||||
*/
|
||||
worker: {
|
||||
label: 'Worker',
|
||||
settled: (draft) => Boolean(String(draft.worker_email || '').trim()),
|
||||
retry: 'Name the worker, or type their email address.',
|
||||
parse: (answer, { workers = [] }) => {
|
||||
const said = String(answer).trim();
|
||||
if (!said) return null;
|
||||
|
||||
/* An email typed outright is the worker, whether or not a profile
|
||||
exists — a role can be recorded before the profile is created. */
|
||||
const email = /\b[^\s@]+@[^\s@]+\.[^\s@]+\b/.exec(said)?.[0];
|
||||
const lower = said.toLowerCase();
|
||||
|
||||
const match = workers.find((w) => (email
|
||||
? String(w.email || '').toLowerCase() === email.toLowerCase()
|
||||
: String(w.name || '').toLowerCase() === lower))
|
||||
/* A chip carries the full name; a typed answer may be part of one. */
|
||||
|| (!email && workers.find((w) => String(w.name || '').toLowerCase().includes(lower)));
|
||||
|
||||
if (match) {
|
||||
return {
|
||||
worker_profile_id: match.id || null,
|
||||
worker_email: match.email,
|
||||
worker_name: match.name || '',
|
||||
};
|
||||
}
|
||||
return email ? { worker_profile_id: null, worker_email: email, worker_name: '' } : null;
|
||||
/**
|
||||
* The new employee's name.
|
||||
*
|
||||
* A name and nothing more. It is NOT looked up, because a name cannot select
|
||||
* anybody: an organization may employ any number of people who share one, and
|
||||
* the previous version of this field searched the existing workers for a name
|
||||
* match and attached the role to the first hit. With several people of the
|
||||
* same name that silently filed the role against the wrong person; with a
|
||||
* name nobody had, it understood nothing and asked the same question again,
|
||||
* which is the loop this flow was stuck in.
|
||||
*/
|
||||
worker_name: {
|
||||
label: 'Name',
|
||||
settled: (draft) => Boolean(String(draft.worker_name || '').trim()),
|
||||
retry: 'Type the new employee\u2019s full name.',
|
||||
parse: (answer) => {
|
||||
const name = String(answer).trim().replace(/\s+/g, ' ');
|
||||
return name.length >= 2 ? { worker_name: name } : null;
|
||||
},
|
||||
summary: (draft) => (draft.worker_name
|
||||
? `Worker: ${draft.worker_name} (${draft.worker_email})`
|
||||
: `Worker: ${draft.worker_email}`),
|
||||
summary: (draft) => `Name: ${draft.worker_name}`,
|
||||
},
|
||||
|
||||
/**
|
||||
* The new employee's email, which is their identity.
|
||||
*
|
||||
* Asked outright and never derived. There is no rule anywhere that turns a
|
||||
* name into an address, no fallback to the operator's own account, and no
|
||||
* reuse of anything an earlier conversation collected — an invented address
|
||||
* is a real person's record filed under something they do not own.
|
||||
*
|
||||
* `worker_profiles` carries UNIQUE (org_id, email) over a `citext` column, so
|
||||
* this value is what decides whether the person already exists. The check is
|
||||
* the database's, not this field's: two operators recording the same person
|
||||
* at the same moment cannot both win, whatever either browser believed.
|
||||
*/
|
||||
worker_email: {
|
||||
label: 'Email',
|
||||
settled: (draft) => Boolean(String(draft.worker_email || '').trim()),
|
||||
retry: 'Type the employee\u2019s email address — it is how the record is identified.',
|
||||
parse: (answer) => {
|
||||
const said = String(answer).trim();
|
||||
/* The whole answer must be the address. Pulling one out of a sentence
|
||||
would accept "I don't know, maybe bob@x.com" as a considered answer. */
|
||||
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(said)
|
||||
? { worker_email: said }
|
||||
: null;
|
||||
},
|
||||
summary: (draft) => `Email: ${draft.worker_email}`,
|
||||
},
|
||||
|
||||
role_category: {
|
||||
@@ -174,12 +191,25 @@ const fields = {
|
||||
* `@workers` is the worker profiles the panel has already loaded for this
|
||||
* caller — org-scoped by the API, and nothing here widens that view.
|
||||
*/
|
||||
const resolve = (token, { roles = [], workers = [] }) => {
|
||||
const resolve = (token, { roles = [], postings = null }, draft = {}) => {
|
||||
switch (token) {
|
||||
case '@workers': return workers.map((w) => w.name).filter(Boolean);
|
||||
case '@roles': return roles;
|
||||
case '@english': return ENGLISH_LEVELS.map((l) => l.label);
|
||||
case '@certifications': return CERT_OPTIONS;
|
||||
/**
|
||||
* Only the certifications that matter for the role just chosen.
|
||||
*
|
||||
* Never the global list. A worker declaring themselves a Chef is asked
|
||||
* about the certifications this organization puts on its Chef postings, and
|
||||
* about nothing else — offering `Guard Card` there is the assistant
|
||||
* inventing a requirement, and a chip is a suggestion the reader is
|
||||
* entitled to read as informed.
|
||||
*
|
||||
* `null` back from the helper means the postings have not arrived; `[]`
|
||||
* means this role genuinely has none. Both produce no chips here, and
|
||||
* neither falls back to a global list — the step is optional, so `Skip` is
|
||||
* offered by the engine either way and the reader can still type one.
|
||||
*/
|
||||
case '@certifications': return certificationsForRole(draft.role_category, postings) || [];
|
||||
case '@availability': return AVAILABILITY_OPTIONS;
|
||||
default: return [];
|
||||
}
|
||||
@@ -296,14 +326,28 @@ export const employeeRoleRegistry = {
|
||||
extract,
|
||||
|
||||
/**
|
||||
* No prefill from the opening request.
|
||||
* No prefill from the opening request, and an EMPTY draft rather than a
|
||||
* defaulted one.
|
||||
*
|
||||
* "Create an employee role" names nobody, and the posting flow's habit of
|
||||
* reading a role out of the request would settle `role_category` from the
|
||||
* word "role" in the phrase that started the conversation. The first question
|
||||
* is who this is about, and it is asked.
|
||||
*
|
||||
* Returning `defaultEmployeeRole()` here — the record's write-time defaults —
|
||||
* was worse than it looks. Every `settled` test asks whether a field HAS a
|
||||
* value, and the defaults give all of them one: `certifications: []` is an
|
||||
* array, `notes: ''` is a string, `experience_years: 0` is a number. So five
|
||||
* of the eight questions were answered before they were asked, and the
|
||||
* conversation went worker → role → pay and stopped. The certification step
|
||||
* could not be reached at all.
|
||||
*
|
||||
* The two are different things wearing the same shape: write-time defaults
|
||||
* are what a MISSING answer becomes, and a conversation must be able to tell
|
||||
* missing from answered. `toEmployeeRolePayload` still applies them at the
|
||||
* write, so nothing is lost by starting empty.
|
||||
*/
|
||||
prefill: () => defaultEmployeeRole(),
|
||||
prefill: () => ({}),
|
||||
|
||||
/**
|
||||
* One verb, because there is one outcome. A declared role has no draft state:
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { doc, list, note, text } from '@/components/ai-assistant/blocks';
|
||||
import { CERT_OPTIONS, ENGLISH_LEVELS, payLabel } from '@/lib/positionModel';
|
||||
import { ENGLISH_LEVELS, certificationsForRole, payLabel } from '@/lib/positionModel';
|
||||
import {
|
||||
buildPositionPrefill, extractCertifications, extractEnglish, extractExperience,
|
||||
extractLocation, extractPay, extractRole,
|
||||
@@ -143,12 +143,16 @@ const fields = {
|
||||
* provision a TENANT the operator cannot then see, because every read is
|
||||
* predicated on the session's own org_id.
|
||||
*/
|
||||
const resolve = (token, { roles = [], companies = [] }) => {
|
||||
const resolve = (token, { roles = [], companies = [], postings = null }, draft = {}) => {
|
||||
switch (token) {
|
||||
case '@roles': return roles;
|
||||
case '@companies': return companies;
|
||||
case '@english': return ENGLISH_LEVELS.map((l) => l.label);
|
||||
case '@certifications': return CERT_OPTIONS;
|
||||
/* The same rule as the role flow, from the same helper: what this
|
||||
organization already asks for on postings of this category. A position
|
||||
being written for a role it has never posted before offers none, which is
|
||||
honest — there is nothing to go on yet. */
|
||||
case '@certifications': return certificationsForRole(draft.role_category || draft.title, postings) || [];
|
||||
default: return [];
|
||||
}
|
||||
};
|
||||
|
||||
@@ -366,11 +366,36 @@ function blockEnd(lines, start, indent) {
|
||||
return end;
|
||||
}
|
||||
|
||||
/**
|
||||
* How wide a line's indentation is.
|
||||
*
|
||||
* The same measurement the parser makes — `yaml.js` reads a leading run of
|
||||
* whitespace and counts a tab as two — and it has to be, because a key the
|
||||
* parser can see and the
|
||||
* writer cannot is a key the writer will decide is missing and add a second
|
||||
* copy of.
|
||||
*
|
||||
* That is not hypothetical: a definition stored on this account indents with
|
||||
* U+00A0. JavaScript's `\s` matches it, so the parser read the file correctly
|
||||
* and every screen showed the right values; the writer compared against literal
|
||||
* spaces, found no `title:` inside `ui:`, and appended a whole second `ui:`
|
||||
* block on the first edit. The Go port agrees with the parser here too — see
|
||||
* `jsIsSpace` in `internal/definition/jsvalue.go`, which lists `0x00A0` — so the
|
||||
* writer was the only thing in the chain using a narrower idea of a space.
|
||||
*/
|
||||
const indentWidth = (line) => (line.match(/^\s*/)?.[0] || '').replace(/\t/g, ' ').length;
|
||||
|
||||
/** The index of `key` at `indent` within `[from, to)`, or -1. */
|
||||
function findKey(lines, key, indent, from, to) {
|
||||
const pattern = new RegExp(`^${' '.repeat(indent)}${key.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\s*:`);
|
||||
const pattern = new RegExp(`^${key.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\s*:`);
|
||||
for (let i = from; i < to; i += 1) {
|
||||
if (pattern.test(lines[i])) return i;
|
||||
const line = lines[i];
|
||||
if (line === undefined || indentWidth(line) !== indent) continue;
|
||||
/* Measured, then matched on what is left — so the comparison is about how
|
||||
deep the key sits, never about which characters were used to put it
|
||||
there. Identical for ASCII input, which is every definition this
|
||||
repository ships. */
|
||||
if (pattern.test(line.replace(/^\s*/, ''))) return i;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
@@ -187,3 +187,70 @@ export function toolsForContext(contextId, disabled = [], customSkills = []) {
|
||||
*/
|
||||
export const toolAllowed = (name, allowed = []) =>
|
||||
allowed.some((tool) => tool.name === name);
|
||||
|
||||
/* ── Typed action intent ────────────────────────────────────────────────── */
|
||||
|
||||
/**
|
||||
* How much has to be typed before a partial word counts as an intent.
|
||||
*
|
||||
* Three, and the number is the whole point of the rule. One or two characters
|
||||
* cannot say what somebody meant — "wh" is the start of four unrelated
|
||||
* questions — and offering anything on them is how the composer ended up
|
||||
* interrupting every reader who had already decided what to ask. Three is the
|
||||
* shortest prefix of a real verb, and it still matches nothing unless it is
|
||||
* genuinely the beginning of an action this page can perform.
|
||||
*/
|
||||
const ACTION_INTENT_MIN = 3;
|
||||
|
||||
/**
|
||||
* A phrase reduced to what it means, so matching is about intent not typing.
|
||||
*
|
||||
* Case, surrounding space and an article are not differences: "Create",
|
||||
* "create" and "create a" are all the beginning of the same request. Kept local
|
||||
* and deliberately tiny — it exists to compare two short phrases, and anything
|
||||
* cleverer would start deciding what a reader meant.
|
||||
*/
|
||||
const canon = (value) => String(value || '')
|
||||
.toLowerCase()
|
||||
.trim()
|
||||
.split(/\s+/)
|
||||
.filter((w) => w && !/^(?:a|an|the)$/.test(w))
|
||||
.join(' ');
|
||||
|
||||
/**
|
||||
* The actions this page can perform that the typed text is starting to name.
|
||||
*
|
||||
* Derived, never listed. The phrases come from the skills themselves — a
|
||||
* definition's `prompt:` is the sentence its author wrote for exactly this
|
||||
* purpose — and the candidate set is whatever `skillsForContext` already
|
||||
* resolved, which has the page filter and the agent's scoping applied to it.
|
||||
* So a skill added tomorrow is offered here without this file changing, a skill
|
||||
* switched off in Settings is not offered at all, and there is no second list
|
||||
* of action names to keep in step with the first.
|
||||
*
|
||||
* Only skills that DECLARE an action are eligible. A reading skill has nothing
|
||||
* to autocomplete towards: "which positions are in draft" is a question, and
|
||||
* offering it while somebody types is the generic-catalogue behaviour this
|
||||
* replaced.
|
||||
*
|
||||
* Matched on `canon`, so "Create" reaches "Create a position" — the article and
|
||||
* the case are not differences — and "create position" reaches it too. A prefix
|
||||
* rather than a substring: typing the middle of a phrase is not evidence of
|
||||
* intent, and substring matching is what made every keystroke produce chips.
|
||||
*/
|
||||
export function actionSuggestions(typed, skills = []) {
|
||||
const query = canon(typed);
|
||||
if (query.length < ACTION_INTENT_MIN) return [];
|
||||
|
||||
const seen = new Set();
|
||||
const out = [];
|
||||
for (const skill of skills) {
|
||||
if (!skill?.prompt || !skill.actions?.length) continue;
|
||||
const phrase = canon(skill.prompt);
|
||||
if (!phrase.startsWith(query)) continue;
|
||||
if (seen.has(phrase)) continue;
|
||||
seen.add(phrase);
|
||||
out.push({ label: skill.prompt, prompt: skill.prompt });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
@@ -32,13 +32,51 @@ const has = (q, ...terms) => terms.some((t) => q.includes(t));
|
||||
* enquiry. "Assign them" after a preview must not be read as a fresh request
|
||||
* for recommendations.
|
||||
*/
|
||||
/**
|
||||
* The words that name a GROUP of people rather than one person.
|
||||
*
|
||||
* A detail request is about somebody: "show Maria Gonzalez" opens a record.
|
||||
* A question naming a group is a search, and answering it with one person's
|
||||
* record is the wrong answer to a different question.
|
||||
*/
|
||||
const GROUP_WORDS = [
|
||||
'candidate', 'candidates', 'applicant', 'applicants',
|
||||
'employee', 'employees', 'worker', 'workers', 'people', 'talent',
|
||||
];
|
||||
|
||||
/**
|
||||
* Words that place a question in the WORKFORCE rather than the hiring pipeline.
|
||||
*
|
||||
* These are two different sources and the product keeps them apart: a candidate
|
||||
* is somebody in `job_applications`, and the talent pool is `worker_profiles`.
|
||||
* Every intent below answers from the pipeline — `poolFor` is built from
|
||||
* applications and a profile only ever enriches a candidate it already found —
|
||||
* so a question about employees must not reach any of them.
|
||||
*
|
||||
* Returned as NO workforce intent, which hands the question to the Talent Pool
|
||||
* responder to answer from the workforce. That is the correct source, and it is
|
||||
* the direction this file used to get wrong: "show employees who match this
|
||||
* role" was caught by a bare `show ` test and answered as a candidate lookup.
|
||||
*/
|
||||
const WORKFORCE_WORDS = ['talent pool', 'talent directory', 'employee', 'employees', 'worker', 'workers'];
|
||||
const PIPELINE_WORDS = ['candidate', 'applicant', 'application'];
|
||||
|
||||
export function matchWorkforceIntent(question) {
|
||||
const q = String(question).toLowerCase();
|
||||
|
||||
/* Source before intent. A question that names the workforce and not the
|
||||
pipeline is not answered from the pipeline, whatever else it says. */
|
||||
if (has(q, ...WORKFORCE_WORDS) && !has(q, ...PIPELINE_WORDS)) return null;
|
||||
|
||||
/* Inspection and record-opening are checked before assignment, so "show X"
|
||||
and "open the full profile for X" never read as a request to assign. */
|
||||
if (has(q, 'open the full profile', 'view full profile', 'full profile for')) return 'open_profile';
|
||||
if (has(q, 'show ', "'s details", 'candidate details', 'tell me about ')) return 'candidate_detail';
|
||||
if (has(q, "'s details", 'candidate details', 'tell me about ')) return 'candidate_detail';
|
||||
/* `show X` is a detail request only when X is a person. Bare `show ` was
|
||||
checked here unguarded and swallowed every collection query in the
|
||||
product — "show candidates matching bartender" resolved to one candidate's
|
||||
record rather than to a search. */
|
||||
if (has(q, 'show ') && !has(q, ...GROUP_WORDS)) return 'candidate_detail';
|
||||
if (has(q, 'can ', 'why not eligible', 'be assigned')) return 'eligibility';
|
||||
|
||||
if (has(q, 'confirm interview')) return 'confirm_interview';
|
||||
@@ -53,11 +91,17 @@ export function matchWorkforceIntent(question) {
|
||||
|
||||
if (has(q, 'confirm assignment', 'yes, assign', 'confirm the assignment')) return 'confirm_assign';
|
||||
if (has(q, 'assign ', 'fill this position', 'fill the position', 'assign the best', 'assign them')) return 'assign';
|
||||
if (has(q, 'ready for interview', 'who should i interview', 'interview ready')) return 'interview_ready';
|
||||
if (has(q, 'ready for interview', 'should i interview', 'interview ready')) return 'interview_ready';
|
||||
if (has(q, 'applied today', "today's applicant", 'new applicant', 'new application')) return 'applied_today';
|
||||
if (has(q, 'available', 'start earliest', 'can start', 'availability', 'free now')) return 'availability';
|
||||
/* Candidate matching, in the words people actually use for it. The list was
|
||||
narrow enough that "who is a match for this role", "find the best
|
||||
candidate" and "find strong candidates" all fell through to no intent at
|
||||
all. Every one of these is answered from the pipeline. */
|
||||
if (has(q, 'who matches', 'who can fill', 'find candidates', 'best match', 'strongest candidate',
|
||||
'recommend candidate', 'suitable candidate', 'who is suitable', 'candidates for')) return 'matches';
|
||||
'recommend candidate', 'suitable candidate', 'who is suitable', 'candidates for',
|
||||
'is a match', 'match for', 'best candidate', 'strong candidate', 'strongest match',
|
||||
'which candidate', 'candidates matching', 'candidate matching', 'matching ')) return 'matches';
|
||||
if (has(q, 'needs people first', 'biggest gap', 'largest gap', 'most understaffed',
|
||||
'which position should i fill', 'priority')) return 'priority';
|
||||
return null;
|
||||
|
||||
127
src/lib/ui/composition.js
Normal file
127
src/lib/ui/composition.js
Normal file
@@ -0,0 +1,127 @@
|
||||
/**
|
||||
* What a page is made of, and how a user's changes are folded into it.
|
||||
*
|
||||
* A page registers its own composition — the nodes it ships with, in order —
|
||||
* and this module holds the table. The engine reads the table; it never learns
|
||||
* a page name, and there is no switch here that would have to grow a case per
|
||||
* surface. Registering is how a page opts in, exactly as `registerNodeType` is
|
||||
* how a component does.
|
||||
*
|
||||
* The merge is the other half:
|
||||
*
|
||||
* built-in composition what the application ships
|
||||
* ⊕ saved user patch the person's own changes, persisted
|
||||
* ⊕ preview patch what they are trying, not yet saved
|
||||
* = the tree that renders
|
||||
*
|
||||
* Both patches are **lists of operations**, replayed onto a freshly computed
|
||||
* base. That is what makes a saved layout survive a release: a stored tree
|
||||
* would be a photograph of the page on the day it was saved, and every
|
||||
* improvement afterwards would be invisible to whoever had customised it.
|
||||
*
|
||||
* Skill sections are deliberately **not** merged here. A skill's `ui:` block is
|
||||
* still rendered by `SkillSurface`, exactly as it is today, and a surface is
|
||||
* simply one of the nodes a page composes. That keeps the existing Board-skill
|
||||
* behaviour byte-for-byte unchanged while still putting it in the tree, where
|
||||
* it can be hidden and reordered like anything else.
|
||||
*/
|
||||
|
||||
import { makeNode } from './node';
|
||||
import { applyPatch } from './patch';
|
||||
import { nodeRegistry } from './registry';
|
||||
|
||||
/** @type {Map<string, any[]>} */
|
||||
const compositions = new Map();
|
||||
|
||||
/**
|
||||
* A container node's placement, as its own registration declared it.
|
||||
*
|
||||
* Read off `props` rather than from a field the engine knows about, so the
|
||||
* composition layer needs no concept of what a placement is — only that a node
|
||||
* may name one and that skill sections are grouped by the same name.
|
||||
*/
|
||||
const placementOf = (node) => String(node?.props?.placement || '');
|
||||
|
||||
/**
|
||||
* The composition with each slot's skill sections hung underneath it.
|
||||
*
|
||||
* Done here, before any patch is replayed, so a person's saved operations act
|
||||
* on the same tree they were made against — including the skill sections. A
|
||||
* patch that hides a Board card keeps working; a patch naming a card whose
|
||||
* skill has since been switched off is skipped, like any other stale operation.
|
||||
*/
|
||||
function attachSkillNodes(base, byPlacement) {
|
||||
if (!byPlacement) return base;
|
||||
return base.map((node) => {
|
||||
const placement = placementOf(node);
|
||||
const children = placement ? byPlacement[placement] : null;
|
||||
if (!children?.length) return node;
|
||||
return { ...node, children };
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Declare the nodes a page ships with.
|
||||
*
|
||||
* Called once, at module scope, beside the page it describes — so a page and
|
||||
* its composition move together and neither can be deployed without the other.
|
||||
* Re-registering replaces, which is what a hot module reload needs; a duplicate
|
||||
* is not an error the way a duplicate *type* is, because the second call is the
|
||||
* same page saying the same thing again.
|
||||
*/
|
||||
export function registerPageComposition(page, nodes) {
|
||||
const key = String(page ?? '').trim();
|
||||
if (!key) throw new Error('registerPageComposition: a composition needs a page.');
|
||||
compositions.set(key, (nodes || []).map((node) => makeNode({ origin: 'builtin', ...node })));
|
||||
return compositions.get(key);
|
||||
}
|
||||
|
||||
/** The nodes a page ships with, or an empty list for a page that has not opted in. */
|
||||
export const compositionFor = (page) => compositions.get(String(page ?? '').trim()) || [];
|
||||
|
||||
/** Whether this page composes through the node system yet. */
|
||||
export const hasComposition = (page) => compositions.has(String(page ?? '').trim());
|
||||
|
||||
/** Every page that has registered. Used by tests and, later, by the editor. */
|
||||
export const composedPages = () => [...compositions.keys()];
|
||||
|
||||
/** Forget everything. Tests only. */
|
||||
export const resetCompositions = () => compositions.clear();
|
||||
|
||||
/**
|
||||
* The tree to render for a page.
|
||||
*
|
||||
* `skipped` carries the operations that no longer apply — a saved change naming
|
||||
* a node a release has since removed. They are reported rather than thrown:
|
||||
* that is not the user's mistake, and it must not cost them the rest of their
|
||||
* layout.
|
||||
*/
|
||||
export function composePage(page, {
|
||||
patch = null, preview = null, registry = nodeRegistry, role = null,
|
||||
/**
|
||||
* The sections this page's definitions contribute, grouped by placement.
|
||||
*
|
||||
* Passed in rather than read here, because resolving them needs the account's
|
||||
* custom skills and disabled list — React state, which this module must stay
|
||||
* free of to remain a pure function two callers can trust equally.
|
||||
*/
|
||||
skillNodes = null,
|
||||
} = {}) {
|
||||
const base = attachSkillNodes(compositionFor(page), skillNodes);
|
||||
const context = { registry, role };
|
||||
const skipped = [];
|
||||
|
||||
let tree = base;
|
||||
|
||||
/* Saved first, then preview. Order matters: a preview is composed against
|
||||
what the person has already saved, so what they see while deciding is what
|
||||
they will get if they keep it. */
|
||||
for (const layer of [patch, preview]) {
|
||||
if (!layer?.ops?.length) continue;
|
||||
const result = applyPatch(tree, layer, context);
|
||||
tree = result.tree;
|
||||
skipped.push(...result.skipped);
|
||||
}
|
||||
|
||||
return { tree, skipped };
|
||||
}
|
||||
259
src/lib/ui/inspect.js
Normal file
259
src/lib/ui/inspect.js
Normal file
@@ -0,0 +1,259 @@
|
||||
/**
|
||||
* What the UI looks like, described rather than drawn.
|
||||
*
|
||||
* The agent must never guess what "this card" or "that section" refers to. This
|
||||
* module turns a tree into a flat, addressable inventory — every node with its
|
||||
* id, what it is, what it is showing, and what may be done to it — so a request
|
||||
* is resolved against what is actually on the page rather than against what the
|
||||
* model remembers about the product.
|
||||
*
|
||||
* It is a *read*. Nothing here mutates, and nothing here decides: resolving an
|
||||
* ambiguous phrase to a single node is refused in favour of returning the
|
||||
* candidates, because picking one and being wrong edits the thing the user was
|
||||
* looking at while they were looking at something else.
|
||||
*
|
||||
* Everything is derived from the registry and the data vocabulary. No node
|
||||
* name, page name or component name appears below.
|
||||
*/
|
||||
|
||||
import { dataSourceFor, dataSourceLabel } from '@/lib/skills/surfaces';
|
||||
import { locate, walk } from './node';
|
||||
import { nodeRegistry } from './registry';
|
||||
|
||||
/**
|
||||
* One node, as the agent and the editor see it.
|
||||
*
|
||||
* `editable` is the answer to "what could I change here" — derived from the
|
||||
* type's declared prop schema rather than from a list kept in parallel, so a
|
||||
* property added to a registration becomes offerable without an edit here.
|
||||
*/
|
||||
export function describeNode(node, { registry = nodeRegistry, parent = null, index = 0 } = {}) {
|
||||
const entry = registry.get(node.type);
|
||||
|
||||
return {
|
||||
id: node.id,
|
||||
type: node.type,
|
||||
label: entry?.label || node.type,
|
||||
/**
|
||||
* The words on screen, which is how a person refers to a node out loud.
|
||||
*
|
||||
* Falls back to what the type can say about *this* node. Two skill slots on
|
||||
* one page both rendered as "Skill sections" — indistinguishable in the
|
||||
* outline, and unresolvable by name, because a slot carries no title of its
|
||||
* own and its type label is the same for every one of them. The type
|
||||
* supplies a `describe` and says where this one is; nothing here knows what
|
||||
* a placement is.
|
||||
*/
|
||||
title: String(node.props?.title || '').trim()
|
||||
|| (typeof entry?.describe === 'function' ? String(entry.describe(node) || '').trim() || null : null),
|
||||
known: Boolean(entry),
|
||||
container: Boolean(entry?.container),
|
||||
origin: node.origin,
|
||||
hidden: node.hidden === true,
|
||||
locked: node.locked === true,
|
||||
parent: parent?.id ?? null,
|
||||
index,
|
||||
data: node.data
|
||||
? {
|
||||
source: node.data.source,
|
||||
label: dataSourceLabel(node.data.source),
|
||||
params: node.data.params || {},
|
||||
known: Boolean(dataSourceFor(node.data.source)),
|
||||
}
|
||||
: null,
|
||||
layout: { ...(node.layout || {}) },
|
||||
/* What this node looks like, and what it *could* look like. Both, because
|
||||
every consumer needs the pair: the editor draws pickers from the second
|
||||
and marks the first, and the conversation refuses a value that is not in
|
||||
the second by name. One reading, so the two cannot disagree. */
|
||||
presentation: { ...(node.presentation || {}) },
|
||||
variants: entry ? [...entry.variants] : [],
|
||||
densities: entry ? [...entry.densities] : [],
|
||||
capabilities: entry ? [...entry.capabilities] : [],
|
||||
editable: entry
|
||||
? Object.entries(entry.propSchema).map(([key, rule]) => ({
|
||||
key,
|
||||
label: rule.label || key,
|
||||
kind: Array.isArray(rule.enum) ? 'enum' : rule.type || 'string',
|
||||
options: Array.isArray(rule.enum) ? [...rule.enum] : null,
|
||||
required: rule.required === true,
|
||||
value: node.props?.[key] ?? null,
|
||||
}))
|
||||
: [],
|
||||
children: (node.children || []).map((child) => child.id),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The whole tree, flattened.
|
||||
*
|
||||
* Flat rather than nested because every question the agent asks is "which node
|
||||
* is this" — and a nested shape makes that a traversal at every call site.
|
||||
* Parentage survives as `parent` and `index`, so the structure is still
|
||||
* recoverable.
|
||||
*/
|
||||
export function inspectTree(nodes, { registry = nodeRegistry } = {}) {
|
||||
const out = [];
|
||||
|
||||
const visit = (list, parent) => {
|
||||
(list || []).forEach((node, index) => {
|
||||
out.push(describeNode(node, { registry, parent, index }));
|
||||
if (node.children?.length) visit(node.children, node);
|
||||
});
|
||||
};
|
||||
|
||||
visit(nodes, null);
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* A short, readable rendering of the inventory.
|
||||
*
|
||||
* What a person is shown when they ask what is on the page, and what a
|
||||
* conversation quotes back when a phrase matched more than one node. Indented
|
||||
* by depth so the structure reads without drawing it.
|
||||
*/
|
||||
export function outlineTree(nodes, { registry = nodeRegistry } = {}) {
|
||||
const lines = [];
|
||||
|
||||
const visit = (list, depth) => {
|
||||
for (const node of list || []) {
|
||||
const entry = registry.get(node.type);
|
||||
const title = String(node.props?.title || '').trim();
|
||||
const bits = [
|
||||
`${' '.repeat(depth)}${title || entry?.label || node.type}`,
|
||||
`(${node.id})`,
|
||||
node.hidden ? '· hidden' : '',
|
||||
node.data?.source ? `· ${dataSourceLabel(node.data.source)}` : '',
|
||||
].filter(Boolean);
|
||||
lines.push(bits.join(' '));
|
||||
if (node.children?.length) visit(node.children, depth + 1);
|
||||
}
|
||||
};
|
||||
|
||||
visit(nodes, 0);
|
||||
return lines;
|
||||
}
|
||||
|
||||
/**
|
||||
* The nodes a phrase could mean, best first.
|
||||
*
|
||||
* Deliberately a *list*. The caller decides what to do with two candidates, and
|
||||
* the right answer in a conversation is to ask — so this never collapses a tie,
|
||||
* and never returns a node on no evidence at all.
|
||||
*
|
||||
* Scoring is over what the node itself says: its id, its title, its type label
|
||||
* and the label of the reading it shows. There is no dictionary of component
|
||||
* names here, so a type registered tomorrow is matchable by its own label
|
||||
* without this function changing.
|
||||
*/
|
||||
export function resolveTarget(nodes, phrase, {
|
||||
registry = nodeRegistry, type = null,
|
||||
/**
|
||||
* Narrow to nodes that are, or are not, currently hidden.
|
||||
*
|
||||
* A hidden node stays in the tree and stays addressable — the renderer skips
|
||||
* it, nothing else does. This filter exists so a caller can ask the question
|
||||
* that actually disambiguates "show the timeline": is there something by that
|
||||
* name which is currently not on screen? `null` means do not care.
|
||||
*/
|
||||
hidden = null,
|
||||
} = {}) {
|
||||
const want = canon(phrase);
|
||||
if (!want) return [];
|
||||
|
||||
/* Words that carry no evidence about which node is meant. Matching on them
|
||||
would make every phrase fit every node. */
|
||||
const tokens = want.split(' ').filter((word) => word.length > 2 && !STOP.has(word));
|
||||
|
||||
const candidates = walk(nodes)
|
||||
.filter((node) => (type ? node.type === type : true))
|
||||
.filter((node) => (hidden === null ? true : Boolean(node.hidden) === hidden))
|
||||
.map((node) => {
|
||||
const entry = registry.get(node.type);
|
||||
const title = canon(node.props?.title);
|
||||
const label = canon(entry?.label || node.type);
|
||||
const source = canon(node.data ? dataSourceLabel(node.data.source) : '');
|
||||
const id = canon(node.id);
|
||||
|
||||
let score = 0;
|
||||
/* An id said verbatim is not a guess — it is the address, and it wins. */
|
||||
if (id && id === want) score += 100;
|
||||
if (title && title === want) score += 60;
|
||||
if (title && want.includes(title)) score += 40;
|
||||
if (title && title.includes(want)) score += 24;
|
||||
if (label && want.includes(label)) score += 18;
|
||||
if (source && want.includes(source)) score += 14;
|
||||
if (id && want.includes(id)) score += 10;
|
||||
|
||||
/**
|
||||
* Part of a name is still a name.
|
||||
*
|
||||
* "the notice" has to reach "Privileged actions notice", and nobody says
|
||||
* a section's full label out loud. Scored per matching word and below
|
||||
* every whole-name rule above, so a partial match never outranks somebody
|
||||
* naming the thing properly — which is what keeps "the audit section"
|
||||
* pointing at the audit log rather than tying with "Skill sections".
|
||||
*/
|
||||
for (const token of tokens) {
|
||||
if (title && title.includes(token)) score += 8;
|
||||
else if (label && label.includes(token)) score += 6;
|
||||
else if (id && id.includes(token)) score += 4;
|
||||
}
|
||||
|
||||
return { node, score };
|
||||
})
|
||||
.filter((row) => row.score > 0)
|
||||
.sort((a, b) => b.score - a.score);
|
||||
|
||||
return candidates.map((row) => ({
|
||||
...describeNode(row.node, {
|
||||
registry,
|
||||
parent: locate(nodes, row.node.id)?.parent || null,
|
||||
index: locate(nodes, row.node.id)?.index || 0,
|
||||
}),
|
||||
/* Carried out so a caller can tell a clear winner from a tie. Deciding that
|
||||
here would be deciding what to do about ambiguity, which belongs to
|
||||
whoever has somebody to ask. */
|
||||
score: row.score,
|
||||
}));
|
||||
}
|
||||
|
||||
/** Words too common to distinguish one node from another. */
|
||||
const STOP = new Set([
|
||||
'the', 'this', 'that', 'these', 'those', 'and', 'for', 'with', 'from', 'into',
|
||||
'show', 'hide', 'move', 'make', 'add', 'put', 'change', 'turn', 'switch',
|
||||
'above', 'below', 'under', 'over', 'before', 'after', 'top', 'bottom',
|
||||
'please', 'section', 'sections', 'panel', 'panels', 'page', 'here',
|
||||
]);
|
||||
|
||||
/** Lower-case, punctuation-free, single-spaced. The one normaliser for matching. */
|
||||
const canon = (value) => String(value ?? '')
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, ' ')
|
||||
.trim();
|
||||
|
||||
/**
|
||||
* What could be added inside a container, and what each could show.
|
||||
*
|
||||
* The picker's source of truth, for the agent and the visual editor alike.
|
||||
* Reads the registry and the data vocabulary, so a newly registered type is
|
||||
* offered without this being touched.
|
||||
*/
|
||||
export function addableTypes(parentType, { registry = nodeRegistry, role = null, page = null } = {}) {
|
||||
return registry.all()
|
||||
/* `offersChild`, not `acceptsChild`: what may structurally sit here is only
|
||||
half the question. The other half — is this a type a person may add, and
|
||||
does it belong to this page — is what kept every page's private sections
|
||||
out of every other page's picker. */
|
||||
.filter((entry) => registry.offersChild(parentType, entry.type, { page }))
|
||||
.filter((entry) => !entry.roles || !role || entry.roles.includes(role))
|
||||
.map((entry) => ({
|
||||
type: entry.type,
|
||||
label: entry.label,
|
||||
summary: entry.summary,
|
||||
container: entry.container,
|
||||
dataShapes: [...entry.dataShapes],
|
||||
dataRequired: entry.dataRequired,
|
||||
}));
|
||||
}
|
||||
579
src/lib/ui/intent.js
Normal file
579
src/lib/ui/intent.js
Normal file
@@ -0,0 +1,579 @@
|
||||
/**
|
||||
* A request in words, turned into one validated operation.
|
||||
*
|
||||
* This is the whole of Owliver's UI-editing understanding, and it is
|
||||
* deliberately small. It reads the verbs from a table, the type names from the
|
||||
* node registry, the data sources from the closed vocabulary, and the targets
|
||||
* from the tree that is actually on screen. There is no page in it, no
|
||||
* component name, and no branch on what a node happens to be — a type
|
||||
* registered tomorrow is addressable tomorrow, by its own label, with this file
|
||||
* unchanged.
|
||||
*
|
||||
* What it can produce is an **operation**, never markup. The model — when there
|
||||
* is one — is not in this path at all: matching is deterministic, which is what
|
||||
* makes it impossible for a hallucinated component name or an invented data
|
||||
* source to reach the engine. The worst a request can do is fail to match.
|
||||
*
|
||||
* Every outcome is one of a small set, and two of them are questions rather
|
||||
* than actions:
|
||||
*
|
||||
* - `plan` an operation, ready to preview
|
||||
* - `inspect` a description of what is on the page
|
||||
* - `apply` / `discard` acting on a preview already shown
|
||||
* - `ambiguous` more than one node fits, so the caller must ask
|
||||
* - `unknown` a target that matches nothing on the page
|
||||
* - `refused` understood, and not allowed — with the reason
|
||||
*/
|
||||
|
||||
import { SUPPORTED_DATA_SOURCES, dataSourceFor, dataSourceLabel } from '@/lib/skills/surfaces';
|
||||
import { addableTypes, describeNode, resolveTarget } from './inspect';
|
||||
import { freeNodeId, walk } from './node';
|
||||
import { nodeRegistry } from './registry';
|
||||
|
||||
/** Lower-case, punctuation-free. The one normaliser, shared with `inspect`. */
|
||||
const canon = (value) => String(value ?? '')
|
||||
.toLowerCase().replace(/[^a-z0-9]+/g, ' ').trim();
|
||||
|
||||
const has = (text, ...words) => words.some((w) => text.includes(canon(w)));
|
||||
|
||||
/**
|
||||
* The verbs, as data.
|
||||
*
|
||||
* Each names an operation and the words that ask for it. Ordered: the first
|
||||
* match wins, so the more specific readings are declared before the general
|
||||
* ones. Nothing here names a component or a page.
|
||||
*/
|
||||
const VERBS = [
|
||||
{ op: 'inspect', words: ['what is on this page', 'whats on this page', 'what sections', 'list the sections', 'show me the layout', 'what can i change'] },
|
||||
{ op: 'apply', words: ['apply', 'keep it', 'keep that', 'save it', 'save that', 'yes apply'] },
|
||||
{ op: 'discard', words: ['discard', 'cancel that', 'undo that', 'never mind', 'nevermind', 'revert that'] },
|
||||
/* Unambiguously about the interface: nobody says "unhide" about data. */
|
||||
{ op: 'unhide', words: ['unhide', 'show again', 'bring back', 'bring it back', 'restore', 'put back', 'reveal'] },
|
||||
|
||||
{ op: 'hide', words: ['hide', 'remove', 'delete', 'get rid of', 'take off', 'take away'] },
|
||||
{ op: 'move', words: ['move', 'put', 'place', 'reorder', 'bring'] },
|
||||
/**
|
||||
* How something looks, rather than what it is.
|
||||
*
|
||||
* Before `replace`, because "change this card to compact" names a
|
||||
* presentation value and `replace` would read the same sentence as a request
|
||||
* for a different type. The words themselves are the gate: none of them is
|
||||
* something a person says about data.
|
||||
*/
|
||||
{ op: 'present', words: ['compact', 'comfortable', 'spacious', 'emphasis', 'emphasise', 'emphasize', 'subtle', 'denser', 'tighter', 'roomier'] },
|
||||
|
||||
{ op: 'replace', words: ['change', 'turn', 'switch', 'convert', 'make it a', 'show as', 'show it as'] },
|
||||
{ op: 'add', words: ['add', 'insert', 'create a', 'put a new'] },
|
||||
{ op: 'layout', words: ['columns', 'column', 'side by side', 'two up', 'wider', 'narrower'] },
|
||||
/**
|
||||
* Plain "show" is the hard one, and it is matched last.
|
||||
*
|
||||
* "Show me the candidates" is a reading; "show the timeline" — when the
|
||||
* timeline is hidden — is a layout change. The word cannot tell them apart,
|
||||
* so the *tree* does: this becomes a UI edit only when the phrase names
|
||||
* something currently hidden, and names it convincingly. Last in the list
|
||||
* because the word appears inside other requests — "add a card showing
|
||||
* candidate activity" is an `add`, and would be stolen by an earlier `show`.
|
||||
*/
|
||||
{ op: 'show', words: ['show', 'display'] },
|
||||
];
|
||||
|
||||
/**
|
||||
* Words that say the request is about the interface rather than about the data.
|
||||
*
|
||||
* "chart" used to be here and is not any more: it names a registered type now,
|
||||
* so `namedType` recognises it and the gate already opens on that. Keeping it
|
||||
* would have been the language layer holding a component name of its own —
|
||||
* which is the thing the check script greps for, and rightly.
|
||||
*/
|
||||
const UI_WORDS = [
|
||||
'section', 'sections', 'panel', 'panels', 'block', 'blocks', 'widget', 'widgets',
|
||||
'layout', 'page', 'column', 'columns',
|
||||
'above', 'below', 'top', 'bottom', 'first', 'last', 'order',
|
||||
];
|
||||
|
||||
const SHOW_CONFIDENCE = 18;
|
||||
|
||||
const NUMBER_WORDS = { one: 1, two: 2, three: 3, four: 4, six: 6, twelve: 12 };
|
||||
|
||||
/**
|
||||
* Read a request.
|
||||
*
|
||||
* `tree` is what is on screen. Nothing is matched against a remembered page —
|
||||
* a target is only resolvable if it is really there, which is what stops a
|
||||
* confident answer about a section that does not exist.
|
||||
*/
|
||||
export function matchUiEdit(question, {
|
||||
tree = [], registry = nodeRegistry, role = null, previewing = false,
|
||||
/* The page being edited. Only ever compared, never interpreted — it is what
|
||||
keeps "add a recent hiring timeline" from being answerable on a page that
|
||||
publishes none of the records such a section reads. */
|
||||
page = null,
|
||||
} = {}) {
|
||||
const text = canon(question);
|
||||
if (!text) return null;
|
||||
|
||||
const verb = VERBS.find((v) => has(text, ...v.words));
|
||||
if (!verb) return null;
|
||||
|
||||
/**
|
||||
* Apply and discard.
|
||||
*
|
||||
* While something is being previewed these are unambiguous. With nothing
|
||||
* previewed they are not: "apply" is an ordinary word — applying for a role,
|
||||
* applying a filter — and this must not swallow it.
|
||||
*
|
||||
* But it must not hand back the panel's *own* words either. The chips this
|
||||
* panel offers say "Apply the layout change" and "Discard the layout change",
|
||||
* and a person who clicks one a second time, or after a re-render has dropped
|
||||
* the preview, was previously answered by the model: an agent scoped to open
|
||||
* roles explaining that layout changes are not in its scope. A request about
|
||||
* the interface must never be answered by something that does not know the
|
||||
* interface exists.
|
||||
*
|
||||
* So the phrase is claimed when it names the interface — the same word gate
|
||||
* the other verbs use — and answered with the plain fact that there is
|
||||
* nothing to act on. Everything else still falls through untouched.
|
||||
*/
|
||||
if (verb.op === 'apply' || verb.op === 'discard') {
|
||||
if (previewing) return { kind: verb.op };
|
||||
return has(text, ...UI_WORDS) ? { kind: 'nothing-previewed', op: verb.op } : null;
|
||||
}
|
||||
|
||||
if (verb.op === 'inspect') return { kind: 'inspect' };
|
||||
|
||||
/**
|
||||
* The gate that keeps ordinary questions ordinary.
|
||||
*
|
||||
* A verb alone is not enough — "show me the candidates" is a reading, not a
|
||||
* layout change. The request has to also name something on this page, a type
|
||||
* the registry knows, or a word about the interface itself.
|
||||
*/
|
||||
/**
|
||||
* Bringing something back is decided by the tree, before the word gate.
|
||||
*
|
||||
* `show` deliberately does not consult the interface-words gate: the evidence
|
||||
* that it means the interface is that a hidden node answers to the phrase,
|
||||
* and requiring "section" or "panel" as well would make the only way to undo
|
||||
* a hide harder to say than the hide was.
|
||||
*/
|
||||
if (verb.op === 'show') return planShow(text, tree, registry);
|
||||
if (verb.op === 'unhide') return planUnhide(text, tree, registry);
|
||||
|
||||
const named = namedType(text, registry, page);
|
||||
const mentionsUi = has(text, ...UI_WORDS) || Boolean(named);
|
||||
const anyTarget = walk(tree).some((node) => resolveTarget(tree, text, { registry }).length > 0);
|
||||
if (!mentionsUi && !anyTarget) return null;
|
||||
|
||||
switch (verb.op) {
|
||||
case 'hide': return planVisibility(text, tree, registry, true);
|
||||
case 'move': return planMove(text, tree, registry);
|
||||
case 'replace': return planReplace(text, tree, registry, page);
|
||||
case 'add': return planAdd(text, tree, registry, named, role, page);
|
||||
case 'layout': return planLayout(text, tree, registry);
|
||||
case 'present': return planPresent(text, tree, registry);
|
||||
default: return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The node a phrase means, or the question to ask instead.
|
||||
*
|
||||
* Ambiguity is never resolved by picking the first: editing the wrong section
|
||||
* while somebody is looking at another one is the failure this exists to
|
||||
* prevent. Two candidates come back as a question with both named.
|
||||
*/
|
||||
function target(text, tree, registry, { exclude = [], preferHidden = false } = {}) {
|
||||
const all = resolveTarget(tree, text, { registry })
|
||||
.filter((node) => !exclude.includes(node.id));
|
||||
/* "Bring back the timeline" means the hidden one, when a hidden one fits.
|
||||
Only a preference: with nothing hidden, the phrase still resolves. */
|
||||
const hiddenOnly = all.filter((node) => node.hidden);
|
||||
const hits = preferHidden && hiddenOnly.length ? hiddenOnly : all;
|
||||
|
||||
if (!hits.length) return { kind: 'unknown', phrase: text };
|
||||
|
||||
/**
|
||||
* A tie is two nodes the phrase fits equally well.
|
||||
*
|
||||
* Judged on the score the resolver assigned rather than on a second reading
|
||||
* of the words here: one place decides how well a phrase fits a node, and
|
||||
* this only decides what to do when two fit the same. A clear winner is acted
|
||||
* on; anything else is a question back.
|
||||
*/
|
||||
if (hits.length > 1 && hits[0].score === hits[1].score) {
|
||||
return { kind: 'ambiguous', candidates: hits.filter((h) => h.score === hits[0].score).slice(0, 4) };
|
||||
}
|
||||
return { node: hits[0] };
|
||||
}
|
||||
|
||||
|
||||
/** Hide or show a node the phrase named. */
|
||||
function planVisibility(text, tree, registry, hidden) {
|
||||
const found = target(text, tree, registry);
|
||||
if (found.kind) return found;
|
||||
return visibilityPlan(found.node, hidden);
|
||||
}
|
||||
|
||||
/**
|
||||
* The plan, or the reason there is nothing to do.
|
||||
*
|
||||
* A node already in the state being asked for produces no operation. Saying so
|
||||
* is better than storing a second `hide` on something hidden: the patch stays
|
||||
* the record of decisions a person actually made, and undo steps back through
|
||||
* changes rather than through no-ops.
|
||||
*/
|
||||
function visibilityPlan(node, hidden) {
|
||||
if (!node.capabilities.includes('hide')) {
|
||||
return { kind: 'refused', message: `\`${node.label}\` cannot be ${hidden ? 'hidden' : 'shown'}.` };
|
||||
}
|
||||
if (Boolean(node.hidden) === hidden) {
|
||||
return {
|
||||
kind: 'refused',
|
||||
message: `\`${node.title || node.label}\` is already ${hidden ? 'hidden' : 'showing'}.`,
|
||||
};
|
||||
}
|
||||
return {
|
||||
kind: 'plan',
|
||||
op: { op: 'hide', target: node.id, hidden },
|
||||
summary: `${hidden ? 'Hide' : 'Show'} ${node.title || node.label}`,
|
||||
node,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Plain "show", resolved only against what is hidden.
|
||||
*
|
||||
* Returning null is the important branch: it is what leaves "show me the
|
||||
* candidates" to the rest of Owliver untouched. A hidden node answering to the
|
||||
* phrase is the whole of the evidence that the interface was meant.
|
||||
*/
|
||||
function planShow(text, tree, registry) {
|
||||
const hits = resolveTarget(tree, text, { registry, hidden: true })
|
||||
/**
|
||||
* Enough evidence to outweigh the ordinary meaning of the word.
|
||||
*
|
||||
* `SHOW_CONFIDENCE` is the score a phrase earns by naming a node properly —
|
||||
* containing its whole label or title. A single shared word does not reach
|
||||
* it, which is what keeps "show me the recent hires" a question about hires
|
||||
* rather than a request to reveal the Recent hiring timeline.
|
||||
*/
|
||||
.filter((node) => node.score >= SHOW_CONFIDENCE);
|
||||
if (!hits.length) return null;
|
||||
if (hits.length > 1 && hits[0].score === hits[1].score) {
|
||||
return { kind: 'ambiguous', candidates: hits.filter((h) => h.score === hits[0].score).slice(0, 4) };
|
||||
}
|
||||
return visibilityPlan(hits[0], false);
|
||||
}
|
||||
|
||||
/**
|
||||
* "Unhide", "bring it back", "restore".
|
||||
*
|
||||
* Always a layout request, so this resolves against the whole tree and reports
|
||||
* when the thing named is already on screen — which is more useful than
|
||||
* silently matching nothing.
|
||||
*/
|
||||
function planUnhide(text, tree, registry) {
|
||||
const found = target(text, tree, registry, { preferHidden: true });
|
||||
if (found.kind) return found;
|
||||
return visibilityPlan(found.node, false);
|
||||
}
|
||||
|
||||
/**
|
||||
* Move one node relative to another, or to an end of the page.
|
||||
*
|
||||
* Expressed as a `reorder` of the root rather than a `move`, because that is
|
||||
* what "above the notice" means on a flat page: the node stays where it lives
|
||||
* and the order around it changes.
|
||||
*/
|
||||
function planMove(text, tree, registry) {
|
||||
const roots = tree.map((node) => describeNode(node, { registry }));
|
||||
|
||||
const before = has(text, 'above', 'before', 'over', 'on top of', 'to the top', 'first');
|
||||
const after = has(text, 'below', 'under', 'beneath', 'after', 'to the bottom', 'last', 'end');
|
||||
|
||||
/* Split on the positional word so the two halves name two different nodes:
|
||||
"move timeline above the notice" is a subject and a reference. */
|
||||
const pivot = ['above', 'before', 'below', 'under', 'beneath', 'after', 'over']
|
||||
.map((w) => ({ w, at: text.indexOf(` ${w} `) }))
|
||||
.filter((p) => p.at > 0)
|
||||
.sort((a, b) => a.at - b.at)[0];
|
||||
|
||||
const subjectText = pivot ? text.slice(0, pivot.at) : text;
|
||||
const subject = target(subjectText, tree, registry);
|
||||
if (subject.kind) return subject;
|
||||
|
||||
const order = roots.map((n) => n.id);
|
||||
const from = order.indexOf(subject.node.id);
|
||||
if (from < 0) return { kind: 'unknown', phrase: subjectText };
|
||||
|
||||
if (!subject.node.capabilities.includes('move')) {
|
||||
return { kind: 'refused', message: `\`${subject.node.label}\` cannot be moved.` };
|
||||
}
|
||||
|
||||
let to;
|
||||
if (pivot) {
|
||||
const referenceText = text.slice(pivot.at + pivot.w.length + 2);
|
||||
const reference = target(referenceText, tree, registry, { exclude: [subject.node.id] });
|
||||
if (reference.kind) return reference;
|
||||
const at = order.indexOf(reference.node.id);
|
||||
if (at < 0) return { kind: 'unknown', phrase: referenceText };
|
||||
to = ['above', 'before', 'over'].includes(pivot.w) ? at : at + 1;
|
||||
} else if (before) {
|
||||
to = 0;
|
||||
} else if (after) {
|
||||
to = order.length;
|
||||
} else {
|
||||
return { kind: 'unknown', phrase: text };
|
||||
}
|
||||
|
||||
const next = [...order];
|
||||
next.splice(from, 1);
|
||||
next.splice(to > from ? to - 1 : to, 0, subject.node.id);
|
||||
|
||||
if (next.join() === order.join()) {
|
||||
return { kind: 'refused', message: `\`${subject.node.title || subject.node.label}\` is already there.` };
|
||||
}
|
||||
|
||||
return {
|
||||
kind: 'plan',
|
||||
op: { op: 'reorder', parent: null, order: next },
|
||||
summary: `Move ${subject.node.title || subject.node.label}`,
|
||||
node: subject.node,
|
||||
};
|
||||
}
|
||||
|
||||
/** The registered type a phrase names, by type id or by its label. */
|
||||
function namedType(text, registry, page = null) {
|
||||
const hit = registry.all()
|
||||
/* Only what this page could actually hold. A type named in a sentence that
|
||||
does not belong here is not a type this reader can mean. */
|
||||
.filter((entry) => registry.offersChild(null, entry.type, { page }))
|
||||
.map((entry) => ({ entry, word: canon(entry.label) }))
|
||||
.filter(({ entry, word }) => has(text, entry.type) || (word && has(text, word)))
|
||||
/* Longest label first, so "audit log" is not read as "log". */
|
||||
.sort((a, b) => b.word.length - a.word.length)[0];
|
||||
return hit?.entry || null;
|
||||
}
|
||||
|
||||
/** Turn one node into another type. */
|
||||
function planReplace(text, tree, registry, page = null) {
|
||||
/**
|
||||
* The type asked for is the one after the connector.
|
||||
*
|
||||
* "change the audit log to a table" names two things the registry knows — the
|
||||
* section being changed and the shape it should become — and reading the whole
|
||||
* sentence for a type finds whichever label happens to be longer. The
|
||||
* connector is what tells them apart, and it is how people say it.
|
||||
*/
|
||||
const split = /\b(?:in)?to\s+(?:an?\s+)?|\bas\s+(?:an?\s+)?/.exec(text);
|
||||
const wanted = split ? text.slice(split.index + split[0].length) : text;
|
||||
/* Page-scoped, like every other reading of a type name. Without it a request
|
||||
could name a section belonging to another page, and the refusal listed the
|
||||
whole registry back — every page's private sections, to a person who can
|
||||
use none of them. */
|
||||
const named = namedType(wanted, registry, page);
|
||||
|
||||
if (!named) {
|
||||
return {
|
||||
kind: 'unknown-type',
|
||||
phrase: text,
|
||||
offered: addableTypes(null, { registry, page }).map((t) => t.type),
|
||||
};
|
||||
}
|
||||
|
||||
/* The subject is whatever came before the connector; with no connector, the
|
||||
sentence minus the type name. */
|
||||
const subject = split ? text.slice(0, split.index) : text.replace(canon(named.label), ' ');
|
||||
const found = target(subject, tree, registry);
|
||||
if (found.kind) return found;
|
||||
|
||||
const node = found.node;
|
||||
if (!node.capabilities.includes('replace')) {
|
||||
return { kind: 'refused', message: `\`${node.label}\` cannot be changed into something else.` };
|
||||
}
|
||||
if (node.type === named.type) {
|
||||
return { kind: 'refused', message: `\`${node.title || node.label}\` is already a ${named.label}.` };
|
||||
}
|
||||
|
||||
/* The binding decides what it can become. Asking the registry rather than
|
||||
deciding here is what keeps this free of type knowledge. */
|
||||
const shapes = node.data ? dataSourceFor(node.data.source)?.shapes || [] : null;
|
||||
const allowed = registry.replacements(node.type, { shapes, page });
|
||||
if (!allowed.includes(named.type)) {
|
||||
return {
|
||||
kind: 'refused',
|
||||
message: node.data
|
||||
? `\`${dataSourceLabel(node.data.source)}\` cannot be shown as a ${named.label}.`
|
||||
: `\`${node.label}\` cannot become a ${named.label}.`,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
kind: 'plan',
|
||||
op: { op: 'replace', target: node.id, type: named.type },
|
||||
summary: `Change ${node.title || node.label} to a ${named.label}`,
|
||||
node,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a reading.
|
||||
*
|
||||
* A new node needs a type and a source, and both must be named — a source is
|
||||
* never guessed. Asked for without one, this returns the choices rather than
|
||||
* inventing a binding, which is the difference between a product that offers
|
||||
* what it has and one that makes something up.
|
||||
*/
|
||||
function planAdd(text, tree, registry, named, role, page = null) {
|
||||
if (!named) {
|
||||
return {
|
||||
kind: 'unknown-type',
|
||||
phrase: text,
|
||||
offered: addableTypes(null, { registry, role, page }).map((t) => t.type),
|
||||
};
|
||||
}
|
||||
if (!registry.offersChild(null, named.type, { page })) {
|
||||
return { kind: 'refused', message: `A ${named.label} cannot be added to this page.` };
|
||||
}
|
||||
if (named.roles && role && !named.roles.includes(role)) {
|
||||
return { kind: 'refused', message: `You do not have access to ${named.label}.` };
|
||||
}
|
||||
|
||||
/**
|
||||
* Which readings could fill this shape *here*.
|
||||
*
|
||||
* Filtered by what the page can answer, not just by shape. A reading that
|
||||
* needs a record — "Position activity" — placed on a page that is inside no
|
||||
* record renders "This section needs a position to read." forever, and
|
||||
* offering it is the same mistake the visual picker was making.
|
||||
*/
|
||||
const fits = (id) => {
|
||||
const entry = dataSourceFor(id);
|
||||
if (!entry || !(entry.shapes || []).includes(named.type)) return false;
|
||||
return !entry.context;
|
||||
};
|
||||
|
||||
const source = namedSource(text, named, fits);
|
||||
if (!source) {
|
||||
const options = SUPPORTED_DATA_SOURCES.filter(fits);
|
||||
return { kind: 'needs-source', type: named, options: options.slice(0, 6) };
|
||||
}
|
||||
if (!fits(source)) {
|
||||
return {
|
||||
kind: 'refused',
|
||||
message: `${dataSourceLabel(source)} needs a record this page is not showing.`,
|
||||
};
|
||||
}
|
||||
|
||||
const id = freeNodeId(tree, named.type);
|
||||
return {
|
||||
kind: 'plan',
|
||||
op: {
|
||||
op: 'add',
|
||||
parent: null,
|
||||
node: { id, type: named.type, data: { source }, props: { title: dataSourceLabel(source) } },
|
||||
},
|
||||
summary: `Add a ${named.label} showing ${dataSourceLabel(source)}`,
|
||||
};
|
||||
}
|
||||
|
||||
/** The data source a phrase names, restricted to what this type can draw. */
|
||||
function namedSource(text, named, answerable = null) {
|
||||
const hits = SUPPORTED_DATA_SOURCES
|
||||
.filter((id) => (dataSourceFor(id)?.shapes || []).includes(named.type))
|
||||
.map((id) => ({ id, words: canon(dataSourceLabel(id)) }))
|
||||
.filter(({ id, words }) => has(text, words) || has(text, canon(id)))
|
||||
.sort((a, b) => b.words.length - a.words.length);
|
||||
|
||||
if (!hits.length) return null;
|
||||
|
||||
/**
|
||||
* Two readings can answer to the same words.
|
||||
*
|
||||
* `candidate.activity` and `candidates.activity` are both labelled "Candidate
|
||||
* activity" — one is what happened on one candidate's record, the other is
|
||||
* applications across the workspace. Nothing in the phrase separates them, so
|
||||
* a sort decided, and on a page showing no candidate the sort could pick the
|
||||
* one that can never resolve: a panel reading "This section needs a candidate
|
||||
* to read." for as long as it is kept.
|
||||
*
|
||||
* So where the words do not decide, what the page can answer does. A reading
|
||||
* that fits is preferred over one that cannot; with nothing to choose
|
||||
* between, the longest match still wins and the caller refuses it by name.
|
||||
*/
|
||||
const fits = answerable || (() => true);
|
||||
return (hits.find((hit) => fits(hit.id)) || hits[0]).id;
|
||||
}
|
||||
|
||||
/**
|
||||
* How a node presents itself.
|
||||
*
|
||||
* The words map to values from the closed vocabulary and nothing else — there
|
||||
* is no path from a sentence to a class name. "Reset" is included because
|
||||
* putting something back is the request people actually make after trying
|
||||
* something, and it has to be sayable.
|
||||
*/
|
||||
function planPresent(text, tree, registry) {
|
||||
const wants = {};
|
||||
if (has(text, 'compact', 'denser', 'tighter')) wants.density = 'compact';
|
||||
if (has(text, 'comfortable', 'spacious', 'roomier')) wants.density = 'comfortable';
|
||||
if (has(text, 'emphasis', 'emphasise', 'emphasize')) wants.variant = 'emphasis';
|
||||
if (has(text, 'subtle')) wants.variant = 'subtle';
|
||||
if (has(text, 'default', 'reset', 'normal')) wants.variant = 'default';
|
||||
if (!Object.keys(wants).length) return { kind: 'unknown', phrase: text };
|
||||
|
||||
const found = target(text, tree, registry);
|
||||
if (found.kind) return found;
|
||||
|
||||
const node = found.node;
|
||||
if (!node.capabilities.includes('update')) {
|
||||
return { kind: 'refused', message: `\`${node.label}\` cannot be changed.` };
|
||||
}
|
||||
|
||||
/* Refused by name where the type cannot draw it, rather than stored as a
|
||||
setting the component ignores — the same rule the validator applies, said
|
||||
earlier so the person hears it instead of seeing nothing happen. */
|
||||
for (const [key, value] of Object.entries(wants)) {
|
||||
const supported = key === 'variant' ? node.variants : node.densities;
|
||||
if (!supported.includes(value)) {
|
||||
return {
|
||||
kind: 'refused',
|
||||
message: supported.length
|
||||
? `\`${node.title || node.label}\` supports ${key}: ${supported.join(', ')}.`
|
||||
: `\`${node.title || node.label}\` has no ${key} to set.`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const said = Object.entries(wants).map(([k, v]) => `${k} ${v}`).join(' and ');
|
||||
return {
|
||||
kind: 'plan',
|
||||
op: { op: 'update', target: node.id, presentation: wants },
|
||||
summary: `Set ${node.title || node.label} to ${said}`,
|
||||
node,
|
||||
};
|
||||
}
|
||||
|
||||
/** Column counts. */
|
||||
function planLayout(text, tree, registry) {
|
||||
const digit = /(\d+)\s*(?:column|col)/.exec(text)?.[1];
|
||||
const word = Object.keys(NUMBER_WORDS).find((w) => has(text, `${w} column`));
|
||||
const columns = Number(digit) || NUMBER_WORDS[word] || (has(text, 'side by side', 'two up') ? 2 : null);
|
||||
if (!columns) return { kind: 'unknown', phrase: text };
|
||||
|
||||
const found = target(text, tree, registry);
|
||||
if (found.kind) return found;
|
||||
|
||||
const node = found.node;
|
||||
if (!node.capabilities.includes('update')) {
|
||||
return { kind: 'refused', message: `\`${node.label}\` cannot be changed.` };
|
||||
}
|
||||
|
||||
return {
|
||||
kind: 'plan',
|
||||
op: { op: 'update', target: node.id, layout: { columns } },
|
||||
summary: `Make ${node.title || node.label} ${columns} columns`,
|
||||
node,
|
||||
};
|
||||
}
|
||||
315
src/lib/ui/node.js
Normal file
315
src/lib/ui/node.js
Normal file
@@ -0,0 +1,315 @@
|
||||
/**
|
||||
* A UI node — the unit the agent and the editor address.
|
||||
*
|
||||
* This is the whole shape of a piece of interface as far as the mutation engine
|
||||
* is concerned. It is deliberately dumb: a node names a *type* and carries
|
||||
* *configuration*, and it never carries a component, a class name, markup, or
|
||||
* anything that could be evaluated. Turning a node into pixels is the
|
||||
* renderer's job, and the renderer only ever looks the type up in a registry of
|
||||
* components the application already ships.
|
||||
*
|
||||
* That split is the security boundary. `type` is a key, not an import; `props`
|
||||
* are checked against a schema the type declares; `data` names a reading from
|
||||
* the closed vocabulary in `lib/skills/surfaces.js`. There is no field here
|
||||
* through which a definition can introduce code, and no field the engine passes
|
||||
* through without checking. It is the same guarantee `SkillSections.jsx` and
|
||||
* `uiConfig.js` already make for skill sections, widened from one slot to a
|
||||
* whole tree.
|
||||
*
|
||||
* Nothing in this module knows a page name, a component name, or a product
|
||||
* feature. Everything specific lives in the registry (`registry.js`) or in the
|
||||
* composition a page publishes.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Where a node came from, which is what keeps the three persistence tiers
|
||||
* separate.
|
||||
*
|
||||
* `builtin` is the application's own composition; `skill` is adapted from a
|
||||
* definition's `ui:` block; `user` is something a person added at runtime. The
|
||||
* distinction matters at save time — a user patch is stored as operations
|
||||
* against the other two, never as a copy of them — and at delete time, because
|
||||
* removing a built-in is hiding it, while removing a node a user added is
|
||||
* really removing it.
|
||||
*/
|
||||
export const NODE_ORIGINS = ['builtin', 'skill', 'user'];
|
||||
|
||||
/**
|
||||
* What may be done to a node, declared per type rather than assumed.
|
||||
*
|
||||
* A type opts in. The engine never infers a capability from a type's name or
|
||||
* shape, so a component that must not be moved says so once, in its
|
||||
* registration, and every operation honours it without knowing what it is.
|
||||
*
|
||||
* `add` and `reorder` are about a node's *children* and only mean anything on a
|
||||
* container. The rest are about the node itself.
|
||||
*/
|
||||
export const NODE_CAPABILITIES = [
|
||||
'add', 'update', 'remove', 'move', 'replace', 'hide', 'reorder',
|
||||
];
|
||||
|
||||
/** Ids are addresses. Same rule as a section id, so the two can never disagree. */
|
||||
export const NODE_ID_PATTERN = /^[a-z0-9][a-z0-9-]*$/;
|
||||
|
||||
/**
|
||||
* How wide a node may be, as a count of grid columns.
|
||||
*
|
||||
* Bounded here rather than per type so that "make this two columns" has one
|
||||
* answer everywhere; a type narrows it further through `constraints`.
|
||||
* Twelve because that is the largest arrangement the grid utilities express,
|
||||
* and an unbounded number is a layout that breaks below a phone width.
|
||||
*/
|
||||
export const MIN_COLUMNS = 1;
|
||||
export const MAX_COLUMNS = 12;
|
||||
|
||||
/** The layout keys a node may carry. Anything else an author writes is dropped. */
|
||||
export const LAYOUT_KEYS = ['columns', 'span', 'gap', 'align', 'spacingBefore', 'spacingAfter'];
|
||||
|
||||
export const GAP_VALUES = ['none', 'sm', 'md', 'lg'];
|
||||
export const ALIGN_VALUES = ['start', 'center', 'end', 'stretch'];
|
||||
|
||||
/**
|
||||
* Space above or below a node, as a step on a scale rather than a measurement.
|
||||
*
|
||||
* A closed set of four, each mapping to one class the design system already
|
||||
* ships. This exists because a page sometimes needs a node to sit further from
|
||||
* what precedes it than the page's own rhythm gives — and the alternative,
|
||||
* letting configuration carry a class name, would put arbitrary CSS into a
|
||||
* schema a person can edit. A step cannot say anything the product has not
|
||||
* already decided it can say.
|
||||
*/
|
||||
export const SPACING_VALUES = ['none', 'sm', 'md', 'lg'];
|
||||
|
||||
/**
|
||||
* How a node presents itself, as distinct from where it sits.
|
||||
*
|
||||
* `layout` answers *arrangement* — columns, span, the space around a node.
|
||||
* This answers *treatment*: how tight the node is and how much weight it
|
||||
* carries. They are kept apart because they are edited for different reasons
|
||||
* and because calling this "layout" would make the word mean everything.
|
||||
*
|
||||
* Two closed scales, and closed is the point. A person can say "compact" and
|
||||
* the product decides what compact means; there is no value here that carries a
|
||||
* class name, a measurement or a colour, so nothing a person or an agent writes
|
||||
* can reach the stylesheet. A type that has not declared it supports a value
|
||||
* refuses it — see `variants` and `densities` on a registration.
|
||||
*/
|
||||
export const PRESENTATION_KEYS = ['variant', 'density'];
|
||||
|
||||
/** The weight a node carries. `default` is the panel every section already draws. */
|
||||
export const VARIANT_VALUES = ['default', 'subtle', 'emphasis'];
|
||||
|
||||
/** How tightly a node is packed. `comfortable` is today's spacing, unchanged. */
|
||||
export const DENSITY_VALUES = ['comfortable', 'compact'];
|
||||
|
||||
/**
|
||||
* A node, with every field settled.
|
||||
*
|
||||
* Callers hand in whatever they have; this decides what the rest of the system
|
||||
* sees. It does **not** validate — `validate.js` does that, against the
|
||||
* registry, and keeping the two apart is what lets an invalid node exist long
|
||||
* enough to be reported with a message instead of vanishing.
|
||||
*
|
||||
* Unknown keys are dropped rather than carried. A node that survived with an
|
||||
* extra field would eventually have that field read by something, and then the
|
||||
* closed vocabulary would be closed only by convention.
|
||||
*/
|
||||
/** @param {any} raw @returns {any} */
|
||||
export function makeNode(raw = {}) {
|
||||
/** @type {any} */
|
||||
const source = raw && typeof raw === 'object' && !Array.isArray(raw) ? raw : {};
|
||||
|
||||
return {
|
||||
id: String(source.id ?? '').trim(),
|
||||
type: String(source.type ?? '').trim(),
|
||||
props: plainObject(source.props),
|
||||
data: normalizeBinding(source.data),
|
||||
layout: normalizeLayout(source.layout),
|
||||
presentation: normalizePresentation(source.presentation),
|
||||
children: Array.isArray(source.children) ? source.children.map(makeNode) : [],
|
||||
hidden: source.hidden === true,
|
||||
origin: NODE_ORIGINS.includes(source.origin) ? source.origin : 'builtin',
|
||||
/* Locked is the type's word, not the node's, but it is stored on the node so
|
||||
a composition can pin one instance — a page may legitimately want its
|
||||
header fixed while the same type is movable elsewhere. */
|
||||
locked: source.locked === true,
|
||||
};
|
||||
}
|
||||
|
||||
/** A shallow copy of a plain object, or an empty one. Never an array, never null. */
|
||||
function plainObject(value) {
|
||||
return value && typeof value === 'object' && !Array.isArray(value) ? { ...value } : {};
|
||||
}
|
||||
|
||||
/**
|
||||
* The data binding, or null.
|
||||
*
|
||||
* `source` is an id in the closed data-source vocabulary and is checked there,
|
||||
* not here. `params` is the small bag of options a source declares it reads —
|
||||
* periods, limit — and is likewise checked at validation. What this does is
|
||||
* make the shape predictable: a binding is either absent or an object with a
|
||||
* string source, so nothing downstream has to test both `data.source` and a
|
||||
* bare `source`.
|
||||
*/
|
||||
function normalizeBinding(value) {
|
||||
if (value == null) return null;
|
||||
if (typeof value === 'string') {
|
||||
const source = value.trim();
|
||||
return source ? { source, params: {} } : null;
|
||||
}
|
||||
if (typeof value !== 'object' || Array.isArray(value)) return null;
|
||||
const source = String(value.source ?? '').trim();
|
||||
if (!source) return null;
|
||||
return { source, params: plainObject(value.params) };
|
||||
}
|
||||
|
||||
/**
|
||||
* Layout, reduced to the four keys that mean something.
|
||||
*
|
||||
* Coerced rather than refused: `columns: "2"` is what a form control produces
|
||||
* and a conversation says, and treating that as an authoring error would make
|
||||
* the format precious about typing. Out-of-range values are clamped at
|
||||
* validation, where the type's own constraints are known — not here, where they
|
||||
* are not.
|
||||
*/
|
||||
function normalizeLayout(value) {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) return {};
|
||||
const layout = {};
|
||||
for (const key of LAYOUT_KEYS) {
|
||||
if (value[key] == null || value[key] === '') continue;
|
||||
if (key === 'columns' || key === 'span') {
|
||||
const n = Number(value[key]);
|
||||
if (Number.isFinite(n)) layout[key] = Math.round(n);
|
||||
continue;
|
||||
}
|
||||
layout[key] = String(value[key]).trim();
|
||||
}
|
||||
return layout;
|
||||
}
|
||||
|
||||
/**
|
||||
* The presentation keys a node may carry, and nothing else.
|
||||
*
|
||||
* Values are not checked here — `validate.js` does that against the type's own
|
||||
* declared support, so an unsupported value survives long enough to be reported
|
||||
* by name rather than disappearing silently.
|
||||
*/
|
||||
function normalizePresentation(value) {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) return {};
|
||||
const presentation = {};
|
||||
for (const key of PRESENTATION_KEYS) {
|
||||
if (value[key] == null || value[key] === '') continue;
|
||||
presentation[key] = String(value[key]).trim();
|
||||
}
|
||||
return presentation;
|
||||
}
|
||||
|
||||
/* ── Reading a tree ─────────────────────────────────────────────────────────
|
||||
A tree is an array of root nodes, not a single node with a synthetic root.
|
||||
A page is a list of things, and inventing a root would give the engine one
|
||||
node that every rule then has to except. */
|
||||
|
||||
/** Every node, parents before children. Order is the reading order of the page. */
|
||||
export function walk(nodes) {
|
||||
const out = [];
|
||||
const visit = (list) => {
|
||||
for (const node of list || []) {
|
||||
out.push(node);
|
||||
if (node.children?.length) visit(node.children);
|
||||
}
|
||||
};
|
||||
visit(nodes);
|
||||
return out;
|
||||
}
|
||||
|
||||
/** The node with this id, or null. */
|
||||
export function findNode(nodes, id) {
|
||||
const want = String(id ?? '').trim();
|
||||
if (!want) return null;
|
||||
return walk(nodes).find((node) => node.id === want) || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Where a node sits: its parent (null at the root) and its index among siblings.
|
||||
*
|
||||
* Every structural operation needs this, and every one of them computing it
|
||||
* separately is how `move` and `reorder` end up disagreeing about what an index
|
||||
* means.
|
||||
*/
|
||||
export function locate(nodes, id) {
|
||||
const want = String(id ?? '').trim();
|
||||
if (!want) return null;
|
||||
|
||||
const search = (list, parent) => {
|
||||
for (let index = 0; index < list.length; index += 1) {
|
||||
if (list[index].id === want) return { parent, siblings: list, index, node: list[index] };
|
||||
const deeper = list[index].children?.length ? search(list[index].children, list[index]) : null;
|
||||
if (deeper) return deeper;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
return search(nodes || [], null);
|
||||
}
|
||||
|
||||
/** Every id in the tree, including duplicates — the caller decides what that means. */
|
||||
export const idsOf = (nodes) => walk(nodes).map((node) => node.id);
|
||||
|
||||
/**
|
||||
* A tree with one node replaced by the result of `fn`, and everything else
|
||||
* shared.
|
||||
*
|
||||
* Structural sharing rather than a deep clone: React re-renders what changed,
|
||||
* and a wholesale copy would repaint a page for a hidden flag. Returning `null`
|
||||
* from `fn` removes the node, which is what makes `remove` a special case of
|
||||
* this rather than a second traversal.
|
||||
*/
|
||||
export function mapNode(nodes, id, fn) {
|
||||
const want = String(id ?? '').trim();
|
||||
let touched = false;
|
||||
|
||||
const visit = (list) => list.reduce((acc, node) => {
|
||||
if (node.id === want) {
|
||||
touched = true;
|
||||
const next = fn(node);
|
||||
if (next) acc.push(next);
|
||||
return acc;
|
||||
}
|
||||
if (node.children?.length) {
|
||||
const children = visit(node.children);
|
||||
acc.push(children === node.children ? node : { ...node, children });
|
||||
return acc;
|
||||
}
|
||||
acc.push(node);
|
||||
return acc;
|
||||
}, []);
|
||||
|
||||
const next = visit(nodes || []);
|
||||
return touched ? next : nodes;
|
||||
}
|
||||
|
||||
/** A tree with `fn` applied to one node's children list. */
|
||||
export function mapChildren(nodes, parentId, fn) {
|
||||
const want = String(parentId ?? '').trim();
|
||||
/* The root is addressed by a null parent, so a caller does not need a
|
||||
different function to reorder top-level nodes than nested ones. */
|
||||
if (!want) return fn(nodes || []);
|
||||
return mapNode(nodes, want, (node) => ({ ...node, children: fn(node.children || []) }));
|
||||
}
|
||||
|
||||
/**
|
||||
* An id nothing in the tree is using, derived from the type.
|
||||
*
|
||||
* Shared, because Owliver and the editor both add nodes and two id schemes
|
||||
* would mean the same action produced different addresses depending on which
|
||||
* surface asked for it.
|
||||
*/
|
||||
export function freeNodeId(nodes, type) {
|
||||
const taken = new Set(walk(nodes).map((node) => node.id));
|
||||
let n = 1;
|
||||
while (taken.has(`${type}-${n}`)) n += 1;
|
||||
return `${type}-${n}`;
|
||||
}
|
||||
|
||||
/** A node and everything under it, as a fresh tree. Used by move and by undo. */
|
||||
export const cloneNode = (node) => makeNode(node);
|
||||
475
src/lib/ui/operations.js
Normal file
475
src/lib/ui/operations.js
Normal file
@@ -0,0 +1,475 @@
|
||||
/**
|
||||
* The mutation engine.
|
||||
*
|
||||
* Eight operations over a UI tree, and **not one of them branches on a node
|
||||
* type**. Every decision an operation makes — may this move, may that hold a
|
||||
* child, is this column count legal, may this person do it at all — is read
|
||||
* from the type's registration or from the closed data vocabulary. That is the
|
||||
* property that makes a new UI type one `registerNodeType` call instead of an
|
||||
* edit here, and it is worth checking on any change to this file: a `card`,
|
||||
* `chart`, `table`, `positions` or `control-center` appearing below is a bug in
|
||||
* the design, not a special case.
|
||||
*
|
||||
* Every operation is pure — `(tree, op) → { ok, tree, problems, diff }` — and
|
||||
* returns a **new** tree, sharing everything it did not touch. Purity is what
|
||||
* lets preview and apply run the identical code path: a preview is the result
|
||||
* held in memory, an apply is the same result persisted. There is no second
|
||||
* implementation for either, so they cannot disagree.
|
||||
*
|
||||
* An operation that fails returns the tree it was given, unchanged, with the
|
||||
* problems that stopped it. Nothing is half-applied.
|
||||
*/
|
||||
|
||||
import { dataSourceFor, sourceSupportsShape } from '@/lib/skills/surfaces';
|
||||
import {
|
||||
cloneNode, findNode, locate, makeNode, mapChildren, mapNode,
|
||||
} from './node';
|
||||
import { nodeRegistry } from './registry';
|
||||
import { takenIds, validateTree } from './validate';
|
||||
|
||||
/** The operation names the engine understands. Anything else is refused by name. */
|
||||
export const OPERATIONS = [
|
||||
'add', 'update', 'remove', 'move', 'replace', 'hide', 'reorder',
|
||||
];
|
||||
|
||||
const fail = (tree, ...problems) => ({
|
||||
ok: false,
|
||||
tree,
|
||||
problems: problems.flat().map((p) => (typeof p === 'string' ? { at: null, message: p } : p)),
|
||||
diff: null,
|
||||
});
|
||||
|
||||
const done = (tree, diff) => ({ ok: true, tree, problems: [], diff });
|
||||
|
||||
/**
|
||||
* Apply one operation.
|
||||
*
|
||||
* The candidate tree is built first and validated whole, rather than each
|
||||
* operation checking its own effects. A structural change can invalidate a node
|
||||
* it did not touch — moving a chart into a container that does not accept it,
|
||||
* or leaving a container below its minimum — and only a whole-tree check sees
|
||||
* that.
|
||||
*/
|
||||
export function applyOperation(tree, op, { registry = nodeRegistry, role = null } = {}) {
|
||||
const nodes = Array.isArray(tree) ? tree : [];
|
||||
const name = String(op?.op ?? '').trim();
|
||||
|
||||
if (!OPERATIONS.includes(name)) {
|
||||
return fail(nodes, `Unknown operation: ${name || '(none)'}. Known: ${OPERATIONS.join(', ')}.`);
|
||||
}
|
||||
|
||||
const context = { registry, role };
|
||||
const built = BUILDERS[name](nodes, op, context);
|
||||
if (!built.ok) return built;
|
||||
|
||||
/* One gate, after the change is composed and before it is anybody's. */
|
||||
const { ok, problems } = validateTree(built.tree, context);
|
||||
if (!ok) return fail(nodes, problems);
|
||||
|
||||
return done(built.tree, built.diff);
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply a list, stopping at the first failure.
|
||||
*
|
||||
* All-or-nothing: a patch that applied its first three operations and refused
|
||||
* the fourth would leave a page in a state the user never asked for and cannot
|
||||
* name. The caller gets the original tree back and the problems from the one
|
||||
* that stopped it.
|
||||
*/
|
||||
export function applyOperations(tree, ops, context = {}) {
|
||||
const nodes = Array.isArray(tree) ? tree : [];
|
||||
let current = nodes;
|
||||
const diffs = [];
|
||||
|
||||
for (const [index, op] of (ops || []).entries()) {
|
||||
const result = applyOperation(current, op, context);
|
||||
if (!result.ok) {
|
||||
return {
|
||||
ok: false,
|
||||
tree: nodes,
|
||||
problems: result.problems.map((p) => ({ ...p, opIndex: index })),
|
||||
diff: null,
|
||||
};
|
||||
}
|
||||
current = result.tree;
|
||||
diffs.push(result.diff);
|
||||
}
|
||||
|
||||
return { ok: true, tree: current, problems: [], diff: diffs };
|
||||
}
|
||||
|
||||
/* ── Shared checks ──────────────────────────────────────────────────────────
|
||||
Written once because an operation that resolved its own target would be the
|
||||
place a rule silently differs. */
|
||||
|
||||
/** The node an operation names, or a refusal that says which id was not found. */
|
||||
function target(nodes, id) {
|
||||
const found = findNode(nodes, id);
|
||||
if (!found) return { problem: `No UI node with the id \`${id ?? '(none)'}\`.` };
|
||||
return { node: found };
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the type behind this node permits this capability.
|
||||
*
|
||||
* Two refusals, deliberately different: a node the registry does not know is an
|
||||
* unknown component, while a node whose type declines the capability is a
|
||||
* component that exists and will not do this. Collapsing them would tell a user
|
||||
* their chart does not exist.
|
||||
*/
|
||||
function permits(registry, node, capability) {
|
||||
const entry = registry.get(node.type);
|
||||
if (!entry) return `Unsupported UI type: ${node.type}.`;
|
||||
if (node.locked) return `\`${entry.label}\` is fixed here and cannot be changed.`;
|
||||
if (!entry.capabilities.includes(capability)) {
|
||||
return `\`${entry.label}\` cannot be ${PARTICIPLE[capability]}.`;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
const PARTICIPLE = {
|
||||
add: 'added to', update: 'changed', remove: 'removed', move: 'moved',
|
||||
replace: 'replaced', hide: 'hidden', reorder: 'reordered',
|
||||
};
|
||||
|
||||
/** The container an operation places into: the root, or a node that accepts children. */
|
||||
function container(nodes, parentId, registry) {
|
||||
if (parentId == null || parentId === '') return { parent: null, children: nodes };
|
||||
const found = findNode(nodes, parentId);
|
||||
if (!found) return { problem: `No UI node with the id \`${parentId}\`.` };
|
||||
const entry = registry.get(found.type);
|
||||
if (!entry?.container) return { problem: `\`${entry?.label || found.type}\` cannot hold other nodes.` };
|
||||
return { parent: found, children: found.children || [] };
|
||||
}
|
||||
|
||||
/**
|
||||
* Where a node's own origin says it may be deleted.
|
||||
*
|
||||
* Removing something the application ships is not removal, it is hiding: the
|
||||
* built-in comes back with the next release, and a patch that claimed to have
|
||||
* deleted it would silently stop matching. A node a person added is genuinely
|
||||
* theirs to remove.
|
||||
*
|
||||
* Origin, not type — which is what keeps this rule generic.
|
||||
*/
|
||||
const isRemovable = (node) => node.origin === 'user';
|
||||
|
||||
/** A record reduced to the keys a type declared. Anything else is left behind. */
|
||||
const keep = (source, allowed) => Object.fromEntries(
|
||||
Object.entries(source || {}).filter(([key]) => allowed.includes(key))
|
||||
);
|
||||
|
||||
/* ── The operations ─────────────────────────────────────────────────────── */
|
||||
|
||||
const BUILDERS = {
|
||||
/** Put a new node into a container, at an index or at the end. */
|
||||
add(nodes, op, { registry }) {
|
||||
const spot = container(nodes, op.parent, registry);
|
||||
if (spot.problem) return fail(nodes, spot.problem);
|
||||
|
||||
if (spot.parent) {
|
||||
const refusal = permits(registry, spot.parent, 'add');
|
||||
if (refusal) return fail(nodes, refusal);
|
||||
}
|
||||
|
||||
const node = makeNode({ ...op.node, origin: op.node?.origin || 'user' });
|
||||
|
||||
if (!node.id) return fail(nodes, 'A new node needs an `id`.');
|
||||
if (takenIds(nodes).has(node.id)) {
|
||||
return fail(nodes, `A node with the id \`${node.id}\` already exists.`);
|
||||
}
|
||||
if (!registry.has(node.type)) {
|
||||
return fail(
|
||||
nodes,
|
||||
`Unsupported UI type: ${node.type || '(none)'}. Supported types: ${registry.list().join(', ')}.`
|
||||
);
|
||||
}
|
||||
|
||||
const at = index(op.index, spot.children.length);
|
||||
const next = mapChildren(nodes, spot.parent?.id ?? null, (list) => [
|
||||
...list.slice(0, at), node, ...list.slice(at),
|
||||
]);
|
||||
|
||||
return done(next, {
|
||||
op: 'add', target: node.id, parent: spot.parent?.id ?? null, index: at,
|
||||
summary: `Add ${registry.get(node.type).label}`,
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Change a node's configuration in place.
|
||||
*
|
||||
* Props and layout merge rather than replace, so "make it two columns" does
|
||||
* not clear a title nobody mentioned. A key set to `null` is removed, which is
|
||||
* how a value gets *unset* — otherwise there would be no way to say "no
|
||||
* limit" that was distinguishable from not mentioning it.
|
||||
*
|
||||
* `data` is different: it replaces, because a binding is one decision and a
|
||||
* half-merged one would name a source with another source's options.
|
||||
*/
|
||||
update(nodes, op, { registry }) {
|
||||
const found = target(nodes, op.target);
|
||||
if (found.problem) return fail(nodes, found.problem);
|
||||
|
||||
const refusal = permits(registry, found.node, 'update');
|
||||
if (refusal) return fail(nodes, refusal);
|
||||
|
||||
const before = found.node;
|
||||
const next = mapNode(nodes, before.id, (node) => makeNode({
|
||||
...node,
|
||||
props: 'props' in op ? merge(node.props, op.props) : node.props,
|
||||
layout: 'layout' in op ? merge(node.layout, op.layout) : node.layout,
|
||||
/* Merged, not replaced, for the same reason layout is: setting a density
|
||||
must not clear a variant nobody mentioned. `null` still unsets. */
|
||||
presentation: 'presentation' in op ? merge(node.presentation, op.presentation) : node.presentation,
|
||||
data: 'data' in op ? op.data : node.data,
|
||||
}));
|
||||
|
||||
return done(next, {
|
||||
op: 'update', target: before.id,
|
||||
changed: [
|
||||
...('props' in op ? Object.keys(op.props || {}) : []),
|
||||
...('layout' in op ? Object.keys(op.layout || {}) : []),
|
||||
...('presentation' in op ? Object.keys(op.presentation || {}) : []),
|
||||
...('data' in op ? ['data'] : []),
|
||||
],
|
||||
summary: `Change ${registry.get(before.type)?.label || before.type}`,
|
||||
});
|
||||
},
|
||||
|
||||
/** Take a node out of the tree. Only where its origin allows it. */
|
||||
remove(nodes, op, { registry }) {
|
||||
const found = target(nodes, op.target);
|
||||
if (found.problem) return fail(nodes, found.problem);
|
||||
|
||||
const refusal = permits(registry, found.node, 'remove');
|
||||
if (refusal) return fail(nodes, refusal);
|
||||
|
||||
if (!isRemovable(found.node)) {
|
||||
return fail(
|
||||
nodes,
|
||||
`\`${registry.get(found.node.type)?.label || found.node.type}\` is part of the page and `
|
||||
+ 'cannot be deleted. Hide it instead.'
|
||||
);
|
||||
}
|
||||
|
||||
return done(mapNode(nodes, found.node.id, () => null), {
|
||||
op: 'remove', target: found.node.id,
|
||||
summary: `Remove ${registry.get(found.node.type)?.label || found.node.type}`,
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Move a node to another container, or to another position in its own.
|
||||
*
|
||||
* Detach then insert, computing the index against the list *after* removal so
|
||||
* that moving a node down within its own parent lands where a reader expects.
|
||||
* Getting this wrong is the classic off-by-one that makes "move it to the
|
||||
* end" stop one short.
|
||||
*/
|
||||
move(nodes, op, { registry }) {
|
||||
const found = target(nodes, op.target);
|
||||
if (found.problem) return fail(nodes, found.problem);
|
||||
|
||||
const refusal = permits(registry, found.node, 'move');
|
||||
if (refusal) return fail(nodes, refusal);
|
||||
|
||||
const parentId = op.parent === undefined ? locate(nodes, found.node.id)?.parent?.id ?? null : op.parent;
|
||||
|
||||
/* A container cannot be moved inside itself; the tree would stop being one. */
|
||||
if (parentId && findNode([found.node], parentId)) {
|
||||
return fail(nodes, 'A node cannot be moved inside itself.');
|
||||
}
|
||||
|
||||
const spot = container(nodes, parentId, registry);
|
||||
if (spot.problem) return fail(nodes, spot.problem);
|
||||
if (spot.parent) {
|
||||
const parentRefusal = permits(registry, spot.parent, 'add');
|
||||
if (parentRefusal) return fail(nodes, parentRefusal);
|
||||
}
|
||||
|
||||
const moved = cloneNode(found.node);
|
||||
const detached = mapNode(nodes, found.node.id, () => null);
|
||||
|
||||
const siblings = parentId == null
|
||||
? detached
|
||||
: findNode(detached, parentId)?.children || [];
|
||||
const at = index(op.index, siblings.length);
|
||||
|
||||
const next = mapChildren(detached, parentId, (list) => [
|
||||
...list.slice(0, at), moved, ...list.slice(at),
|
||||
]);
|
||||
|
||||
return done(next, {
|
||||
op: 'move', target: moved.id, parent: parentId, index: at,
|
||||
summary: `Move ${registry.get(moved.type)?.label || moved.type}`,
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Turn a node into another type, in place.
|
||||
*
|
||||
* The id, position and binding are kept — which is what makes "show this as a
|
||||
* table" mean *this* reading as a table, rather than a new empty table where
|
||||
* a chart used to be. Props are dropped unless the operation supplies new
|
||||
* ones, because props belong to a type and carrying a chart's variant onto a
|
||||
* table is how an invalid prop arrives without anyone writing one.
|
||||
*/
|
||||
replace(nodes, op, { registry }) {
|
||||
const found = target(nodes, op.target);
|
||||
if (found.problem) return fail(nodes, found.problem);
|
||||
|
||||
const refusal = permits(registry, found.node, 'replace');
|
||||
if (refusal) return fail(nodes, refusal);
|
||||
|
||||
const type = String(op.type ?? '').trim();
|
||||
const entry = registry.get(type);
|
||||
if (!entry) {
|
||||
return fail(
|
||||
nodes,
|
||||
`Unsupported UI type: ${type || '(none)'}. Supported types: ${registry.list().join(', ')}.`
|
||||
);
|
||||
}
|
||||
|
||||
const before = found.node;
|
||||
if (before.children?.length && !entry.container) {
|
||||
return fail(nodes, `\`${entry.label}\` cannot hold the nodes already inside \`${before.id}\`.`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Can the new type draw what this node is already reading?
|
||||
*
|
||||
* Asked here, and refused by name, rather than left to the tree validator.
|
||||
* Both would stop it, but only one can say *why*: "a Table cannot draw
|
||||
* Candidate activity" is a sentence a person can act on, and "this node's
|
||||
* binding is incoherent" is not. The binding itself is never touched — a
|
||||
* replacement that cannot read what the node reads is refused, never
|
||||
* repointed at a source that happens to fit.
|
||||
*/
|
||||
const binding = 'data' in op ? op.data : before.data;
|
||||
if (binding?.source && entry.dataShapes.length) {
|
||||
const drawable = entry.dataShapes.some((shape) => sourceSupportsShape(binding.source, shape));
|
||||
if (!drawable) {
|
||||
const label = dataSourceFor(binding.source)?.label || binding.source;
|
||||
return fail(nodes, `\`${entry.label}\` cannot show ${label}.`);
|
||||
}
|
||||
}
|
||||
if (!binding?.source && entry.dataRequired) {
|
||||
return fail(nodes, `\`${entry.label}\` needs a reading, and \`${before.id}\` has none.`);
|
||||
}
|
||||
|
||||
const next = mapNode(nodes, before.id, (node) => makeNode({
|
||||
...node,
|
||||
type,
|
||||
/**
|
||||
* What survives is what the new type has said it understands.
|
||||
*
|
||||
* Props were dropped wholesale, which is safe and also loses a title the
|
||||
* person typed even when the new type has the very same field. Carried
|
||||
* per key against the new type's own `propSchema` instead: a property both
|
||||
* types declare is theirs to keep, one that belonged to the old type
|
||||
* alone goes. Presentation is filtered the same way, so replacing into a
|
||||
* type that cannot draw `emphasis` quietly drops it rather than failing
|
||||
* the whole operation over a value nobody asked to keep.
|
||||
*
|
||||
* Id, layout, visibility, origin and children pass through untouched —
|
||||
* the node is the same node, drawn differently.
|
||||
*/
|
||||
props: keep('props' in op ? merge({}, op.props) : node.props, Object.keys(entry.propSchema)),
|
||||
presentation: {
|
||||
...(entry.variants.includes(node.presentation?.variant) ? { variant: node.presentation.variant } : {}),
|
||||
...(entry.densities.includes(node.presentation?.density) ? { density: node.presentation.density } : {}),
|
||||
},
|
||||
data: binding,
|
||||
}));
|
||||
|
||||
return done(next, {
|
||||
op: 'replace', target: before.id, from: before.type, to: type,
|
||||
summary: `Change ${registry.get(before.type)?.label || before.type} to ${entry.label}`,
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Hide or show a node.
|
||||
*
|
||||
* Its own operation rather than an `update` of a flag, because it is its own
|
||||
* capability: a component may reasonably be hideable and not otherwise
|
||||
* editable, and a page's header is the opposite. Idempotent — hiding what is
|
||||
* already hidden is not an error, it is the state the user asked for.
|
||||
*/
|
||||
hide(nodes, op, { registry }) {
|
||||
const found = target(nodes, op.target);
|
||||
if (found.problem) return fail(nodes, found.problem);
|
||||
|
||||
const refusal = permits(registry, found.node, 'hide');
|
||||
if (refusal) return fail(nodes, refusal);
|
||||
|
||||
const hidden = op.hidden !== false;
|
||||
const next = mapNode(nodes, found.node.id, (node) => ({ ...node, hidden }));
|
||||
|
||||
return done(next, {
|
||||
op: 'hide', target: found.node.id, hidden,
|
||||
summary: `${hidden ? 'Hide' : 'Show'} ${registry.get(found.node.type)?.label || found.node.type}`,
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Rearrange one container's children.
|
||||
*
|
||||
* Takes the ids in their new order. A partial list is honoured — the named
|
||||
* nodes take the order given, and anything unnamed keeps its relative
|
||||
* position after them — so "put the funnel first" does not require restating
|
||||
* the whole page.
|
||||
*/
|
||||
reorder(nodes, op, { registry }) {
|
||||
const spot = container(nodes, op.parent, registry);
|
||||
if (spot.problem) return fail(nodes, spot.problem);
|
||||
|
||||
if (spot.parent) {
|
||||
const refusal = permits(registry, spot.parent, 'reorder');
|
||||
if (refusal) return fail(nodes, refusal);
|
||||
}
|
||||
|
||||
const order = Array.isArray(op.order) ? op.order.map((id) => String(id).trim()) : null;
|
||||
if (!order?.length) return fail(nodes, '`reorder` needs an `order` of node ids.');
|
||||
|
||||
const present = new Set(spot.children.map((node) => node.id));
|
||||
const stranger = order.find((id) => !present.has(id));
|
||||
if (stranger) {
|
||||
return fail(nodes, `\`${stranger}\` is not inside \`${op.parent ?? 'the page'}\`.`);
|
||||
}
|
||||
if (new Set(order).size !== order.length) {
|
||||
return fail(nodes, '`order` names the same node twice.');
|
||||
}
|
||||
|
||||
const next = mapChildren(nodes, spot.parent?.id ?? null, (list) => {
|
||||
const named = order.map((id) => list.find((node) => node.id === id));
|
||||
const rest = list.filter((node) => !order.includes(node.id));
|
||||
return [...named, ...rest];
|
||||
});
|
||||
|
||||
return done(next, {
|
||||
op: 'reorder', parent: spot.parent?.id ?? null, order,
|
||||
summary: `Reorder ${spot.parent ? registry.get(spot.parent.type)?.label || spot.parent.type : 'the page'}`,
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
/** An insertion point, clamped. Absent means the end, which is what "add" means. */
|
||||
function index(value, length) {
|
||||
if (value == null) return length;
|
||||
const n = Number(value);
|
||||
if (!Number.isFinite(n)) return length;
|
||||
return Math.max(0, Math.min(length, Math.round(n)));
|
||||
}
|
||||
|
||||
/** Shallow merge where an explicit `null` deletes the key. */
|
||||
function merge(base, patch) {
|
||||
const next = { ...(base || {}) };
|
||||
for (const [key, value] of Object.entries(patch || {})) {
|
||||
if (value === null) delete next[key];
|
||||
else next[key] = value;
|
||||
}
|
||||
return next;
|
||||
}
|
||||
203
src/lib/ui/patch.js
Normal file
203
src/lib/ui/patch.js
Normal file
@@ -0,0 +1,203 @@
|
||||
/**
|
||||
* A user's UI changes, as an ordered list of operations.
|
||||
*
|
||||
* **Operations are stored; trees are not.** That is the decision this module
|
||||
* exists to hold, and it is what makes a saved layout survive the application
|
||||
* changing underneath it. A stored tree is a photograph: the day a release adds
|
||||
* a section to a page, every saved photograph is missing it, and the user's
|
||||
* page silently stops receiving product improvements. A stored operation is an
|
||||
* instruction — "hide `cc-activity`", "put `funnel` first" — which still means
|
||||
* what it said after the built-ins around it move.
|
||||
*
|
||||
* It also gives undo and rollback for free. Applying is `push`, undoing is
|
||||
* `pop`, and resetting is the empty list. There is no inverse-operation
|
||||
* machinery to get wrong, because the base tree is recomputed rather than
|
||||
* mutated.
|
||||
*
|
||||
* The stored shape is **normalized structured configuration** — never JSX,
|
||||
* never a component, never Markdown. What is written here is what
|
||||
* `operations.js` already validated.
|
||||
*/
|
||||
|
||||
import { applyOperations, OPERATIONS } from './operations';
|
||||
import { nodeRegistry } from './registry';
|
||||
|
||||
/**
|
||||
* The stored format's version.
|
||||
*
|
||||
* Read on load and written on save, so a future change to the operation
|
||||
* vocabulary can migrate rather than misread. A patch whose version this build
|
||||
* does not know is dropped with a reason rather than half-applied — a partly
|
||||
* understood layout is worse than the default one.
|
||||
*/
|
||||
export const PATCH_SCHEMA = 1;
|
||||
|
||||
/** An empty patch for a page. The shape a page gets when nobody has changed it. */
|
||||
export const emptyPatch = (page) => ({
|
||||
schema: PATCH_SCHEMA,
|
||||
page: String(page ?? '').trim(),
|
||||
ops: [],
|
||||
updatedAt: null,
|
||||
});
|
||||
|
||||
/**
|
||||
* Only the fields an operation is allowed to carry, per operation.
|
||||
*
|
||||
* A whitelist rather than a pass-through, because this is the boundary where
|
||||
* stored data becomes engine input. Anything else a client wrote — or anything
|
||||
* that arrived in `user_preferences.extra` from an older build or another tab —
|
||||
* is dropped before it reaches the engine.
|
||||
*/
|
||||
const OP_FIELDS = {
|
||||
add: ['parent', 'index', 'node'],
|
||||
update: ['target', 'props', 'layout', 'presentation', 'data'],
|
||||
remove: ['target'],
|
||||
move: ['target', 'parent', 'index'],
|
||||
replace: ['target', 'type', 'props', 'data'],
|
||||
hide: ['target', 'hidden'],
|
||||
reorder: ['parent', 'order'],
|
||||
};
|
||||
|
||||
/**
|
||||
* One stored operation, reduced to what the engine reads.
|
||||
*
|
||||
* Returns null for anything unrecognised. The caller reports how many were
|
||||
* dropped rather than failing the whole patch: one unreadable operation from a
|
||||
* newer build should not cost a user the other nine they made.
|
||||
*/
|
||||
export function normalizeOp(raw) {
|
||||
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null;
|
||||
const op = String(raw.op ?? '').trim();
|
||||
if (!OPERATIONS.includes(op)) return null;
|
||||
|
||||
const next = { op };
|
||||
for (const field of OP_FIELDS[op]) {
|
||||
if (raw[field] === undefined) continue;
|
||||
next[field] = raw[field];
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
/** A stored patch, checked and reduced. `dropped` says what was not understood. */
|
||||
export function normalizePatch(raw, page) {
|
||||
const base = emptyPatch(page);
|
||||
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return { patch: base, dropped: 0 };
|
||||
|
||||
if (Number(raw.schema) !== PATCH_SCHEMA) {
|
||||
/* Unknown version: keep nothing rather than guess. The page renders as the
|
||||
application ships it, which is a defensible state; a half-read layout is
|
||||
not. */
|
||||
return { patch: base, dropped: Array.isArray(raw.ops) ? raw.ops.length : 0 };
|
||||
}
|
||||
|
||||
const ops = [];
|
||||
let dropped = 0;
|
||||
for (const candidate of Array.isArray(raw.ops) ? raw.ops : []) {
|
||||
const op = normalizeOp(candidate);
|
||||
if (op) ops.push(op);
|
||||
else dropped += 1;
|
||||
}
|
||||
|
||||
return {
|
||||
patch: {
|
||||
schema: PATCH_SCHEMA,
|
||||
page: base.page,
|
||||
ops,
|
||||
updatedAt: raw.updatedAt || null,
|
||||
},
|
||||
dropped,
|
||||
};
|
||||
}
|
||||
|
||||
/** The whole store: `{ [page]: patch }`, as it sits in preferences. */
|
||||
export function normalizeLayouts(raw) {
|
||||
const layouts = {};
|
||||
let dropped = 0;
|
||||
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return { layouts, dropped };
|
||||
|
||||
for (const [page, value] of Object.entries(raw)) {
|
||||
const key = String(page ?? '').trim();
|
||||
if (!key) continue;
|
||||
const result = normalizePatch(value, key);
|
||||
dropped += result.dropped;
|
||||
/* An empty patch is not stored. A key mapping to no operations is the same
|
||||
state as no key, and keeping it would grow the preferences blob with a
|
||||
record of pages a user once looked at. */
|
||||
if (result.patch.ops.length) layouts[key] = result.patch;
|
||||
}
|
||||
|
||||
return { layouts, dropped };
|
||||
}
|
||||
|
||||
/**
|
||||
* Add an operation to a patch.
|
||||
*
|
||||
* Appends rather than folding into what is there. Two updates to the same node
|
||||
* stay two operations: replaying them costs nothing, and collapsing them would
|
||||
* mean undo skipped a step the user remembers making.
|
||||
*/
|
||||
export const pushOp = (patch, op) => ({
|
||||
...patch,
|
||||
ops: [...patch.ops, op],
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
|
||||
/** Undo: drop the last operation. The tree is recomputed, never reversed. */
|
||||
export const popOp = (patch) => ({
|
||||
...patch,
|
||||
ops: patch.ops.slice(0, -1),
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
|
||||
/** Reset: back to what the application ships. */
|
||||
export const clearOps = (patch) => ({ ...patch, ops: [], updatedAt: new Date().toISOString() });
|
||||
|
||||
/**
|
||||
* The whole layout store with one page's patch set, cleared, or replaced.
|
||||
*
|
||||
* Pure, and separate from the hook that calls it, because this is where a
|
||||
* mistake would be expensive and invisible: the preferences endpoint
|
||||
* shallow-merges its top-level keys, so writing `uiLayouts` replaces the entire
|
||||
* map. Sending one page's entry would silently delete every other page the
|
||||
* person had customised, and they would only find out by visiting one.
|
||||
*
|
||||
* A patch with no operations removes its key rather than storing an empty one:
|
||||
* that is the same state as never having customised the page.
|
||||
*/
|
||||
export function mergeLayouts(layouts, page, patch) {
|
||||
const key = String(page ?? '').trim();
|
||||
const next = { ...(layouts || {}) };
|
||||
if (!key) return next;
|
||||
if (patch?.ops?.length) next[key] = { ...patch, schema: PATCH_SCHEMA, page: key };
|
||||
else delete next[key];
|
||||
return next;
|
||||
}
|
||||
|
||||
/**
|
||||
* The tree a patch produces from a base.
|
||||
*
|
||||
* **Operations that no longer apply are skipped, not fatal.** A release that
|
||||
* removes a section leaves any patch that mentioned it naming a node that is
|
||||
* not there — which is not the user's mistake and must not cost them the rest
|
||||
* of their layout. `skipped` reports them so a surface can say so quietly.
|
||||
*
|
||||
* This is the one place tolerance is right. Everywhere else — composing a new
|
||||
* change, saving one — a refusal is correct, because there is a person present
|
||||
* to be told.
|
||||
*/
|
||||
export function applyPatch(base, patch, { registry = nodeRegistry, role = null } = {}) {
|
||||
const ops = patch?.ops || [];
|
||||
let tree = base;
|
||||
const skipped = [];
|
||||
|
||||
for (const [index, op] of ops.entries()) {
|
||||
const result = applyOperations(tree, [op], { registry, role });
|
||||
if (result.ok) {
|
||||
tree = result.tree;
|
||||
continue;
|
||||
}
|
||||
skipped.push({ index, op, problems: result.problems });
|
||||
}
|
||||
|
||||
return { tree, skipped };
|
||||
}
|
||||
377
src/lib/ui/registry.js
Normal file
377
src/lib/ui/registry.js
Normal file
@@ -0,0 +1,377 @@
|
||||
/**
|
||||
* The node type registry — the only extension point in the UI system.
|
||||
*
|
||||
* A type is a name, a component the application already ships, and the metadata
|
||||
* that says what may be done to it. Adding a UI type is one `register` call:
|
||||
* the mutation engine, the validator, the renderer and the agent's conversation
|
||||
* are untouched, because none of them contains a branch on a type. They ask
|
||||
* this table instead.
|
||||
*
|
||||
* That is the whole reason this file exists. The alternative — an engine that
|
||||
* knows `card` from `chart` — puts every future component into the engine, and
|
||||
* the engine then has to be edited to add a UI type, which is the thing the
|
||||
* design is meant to prevent.
|
||||
*
|
||||
* **The registry never receives a component name as a string.** A registration
|
||||
* hands over a component *reference*, resolved by the module graph at build
|
||||
* time. There is no dynamic import here and no lookup from configuration to
|
||||
* code — configuration only ever names a key that is already in this table.
|
||||
*/
|
||||
|
||||
import {
|
||||
DENSITY_VALUES, MAX_COLUMNS, MIN_COLUMNS, NODE_CAPABILITIES, NODE_ID_PATTERN, VARIANT_VALUES,
|
||||
} from './node';
|
||||
|
||||
/**
|
||||
* A registration, with every field settled.
|
||||
*
|
||||
* Defaults are deliberately conservative: a type that says nothing about its
|
||||
* capabilities gets the read-only set, because a component whose author has not
|
||||
* thought about being moved is one that should not be moved yet. Opting in is
|
||||
* one word; opting out after a bug is a migration.
|
||||
*/
|
||||
const DEFAULT_CAPABILITIES = ['update', 'remove', 'move', 'replace', 'hide'];
|
||||
|
||||
/** Containers can also be added to and reordered — that is what makes them containers. */
|
||||
const CONTAINER_CAPABILITIES = [...DEFAULT_CAPABILITIES, 'add', 'reorder'];
|
||||
|
||||
export class NodeTypeRegistry {
|
||||
constructor() {
|
||||
/** @type {Map<string, any>} */
|
||||
this.types = new Map();
|
||||
}
|
||||
|
||||
/**
|
||||
* Declare a node type.
|
||||
*
|
||||
* Refuses at registration rather than at render. A registry that accepted a
|
||||
* malformed type would fail later, inside a component, with a stack that
|
||||
* names React rather than the registration that caused it — and by then the
|
||||
* validator has already told a user their change was fine.
|
||||
*/
|
||||
register(definition) {
|
||||
const type = String(definition?.type ?? '').trim();
|
||||
if (!type) throw new Error('registerNodeType: a type needs a `type`.');
|
||||
if (!NODE_ID_PATTERN.test(type)) {
|
||||
throw new Error(`registerNodeType: \`${type}\` must be lower-case letters, numbers and dashes.`);
|
||||
}
|
||||
if (this.types.has(type)) {
|
||||
throw new Error(`registerNodeType: \`${type}\` is already registered.`);
|
||||
}
|
||||
if (typeof definition.component !== 'function' && typeof definition.component !== 'object') {
|
||||
throw new Error(`registerNodeType: \`${type}\` needs a \`component\`.`);
|
||||
}
|
||||
|
||||
const container = definition.container === true;
|
||||
const declared = Array.isArray(definition.capabilities) ? definition.capabilities : null;
|
||||
const capabilities = declared || (container ? CONTAINER_CAPABILITIES : DEFAULT_CAPABILITIES);
|
||||
|
||||
const unknown = capabilities.find((c) => !NODE_CAPABILITIES.includes(c));
|
||||
if (unknown) {
|
||||
throw new Error(
|
||||
`registerNodeType: \`${type}\` declares unknown capability \`${unknown}\`. `
|
||||
+ `Known: ${NODE_CAPABILITIES.join(', ')}.`
|
||||
);
|
||||
}
|
||||
/* A registration may choose from the vocabulary; it may not invent one.
|
||||
Caught at boot, where the author can see it, rather than at validation
|
||||
where it would look like the *user's* value was wrong. */
|
||||
const refuseUnknown = (word, declared, allowed) => {
|
||||
const bad = (declared || []).find((value) => !allowed.includes(value));
|
||||
if (bad) {
|
||||
throw new Error(
|
||||
`registerNodeType: \`${type}\` declares unknown ${word} \`${bad}\`. `
|
||||
+ `Known: ${allowed.join(', ')}.`
|
||||
);
|
||||
}
|
||||
};
|
||||
refuseUnknown('variant', definition.variants, VARIANT_VALUES);
|
||||
refuseUnknown('density', definition.densities, DENSITY_VALUES);
|
||||
|
||||
/* A type offered by a picker and unable to be removed is a dead end: a
|
||||
person creates a node and has no way to take it back. Said here rather
|
||||
than discovered in the editor. */
|
||||
if (definition.addable === true && !capabilities.includes('remove')) {
|
||||
throw new Error(
|
||||
`registerNodeType: \`${type}\` is declared addable but cannot be removed. `
|
||||
+ 'Anything a person can add, they must be able to remove.'
|
||||
);
|
||||
}
|
||||
|
||||
/* A non-container claiming `add` or `reorder` is a registration that will
|
||||
never do what its author expects: there is nowhere to put a child. */
|
||||
if (!container) {
|
||||
const childOnly = capabilities.find((c) => c === 'add' || c === 'reorder');
|
||||
if (childOnly) {
|
||||
throw new Error(
|
||||
`registerNodeType: \`${type}\` declares \`${childOnly}\` but is not a container.`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const entry = Object.freeze({
|
||||
type,
|
||||
label: String(definition.label ?? type).trim(),
|
||||
summary: String(definition.summary ?? '').trim(),
|
||||
component: definition.component,
|
||||
container,
|
||||
capabilities: Object.freeze([...capabilities]),
|
||||
/* `['*']` accepts anything, which is what a page-level container wants.
|
||||
An empty list on a container accepts nothing, which is a leaf that
|
||||
merely renders its own children — legitimate, and worth being able to
|
||||
say. */
|
||||
accepts: Object.freeze([...(definition.accepts || (container ? ['*'] : []))]),
|
||||
propSchema: Object.freeze({ ...(definition.propSchema || {}) }),
|
||||
/* Which readings this type can draw. Empty means the type takes no data
|
||||
at all — a layout container, a divider — and validation then refuses a
|
||||
binding rather than resolving one nothing will read. */
|
||||
dataShapes: Object.freeze([...(definition.dataShapes || [])]),
|
||||
dataRequired: definition.dataRequired === true,
|
||||
constraints: Object.freeze({
|
||||
minColumns: MIN_COLUMNS,
|
||||
maxColumns: MAX_COLUMNS,
|
||||
...(definition.constraints || {}),
|
||||
}),
|
||||
/**
|
||||
* Whether the renderer supplies identity through a wrapper element.
|
||||
*
|
||||
* Most components render a root they control and can spread the node's
|
||||
* attributes onto it. Some — the shared design-system primitives, which
|
||||
* destructure their props explicitly — cannot, and for those the renderer
|
||||
* puts a bare `div` around the component so the node is still addressable
|
||||
* in the DOM.
|
||||
*
|
||||
* Declared per type rather than detected, because there is no way to ask
|
||||
* a React component whether it forwards unknown props, and guessing wrong
|
||||
* either loses the identity or adds an element nobody asked for.
|
||||
*/
|
||||
wrap: definition.wrap === true,
|
||||
/* `null` means every role. A list narrows it, and is checked against the
|
||||
caller's role at validation — the same three roles the API enforces. */
|
||||
roles: definition.roles ? Object.freeze([...definition.roles]) : null,
|
||||
|
||||
/**
|
||||
* The presentation values this type can actually draw.
|
||||
*
|
||||
* Empty means the type supports none, and that is the default on purpose:
|
||||
* a presentation value is a promise that the component renders something
|
||||
* different, and a type that has not made that promise must refuse it
|
||||
* rather than store a setting nobody honours. Opting in is one list; the
|
||||
* cost of the other default would be a person setting "compact" on a
|
||||
* section that stays exactly as it was.
|
||||
*
|
||||
* Each value is also checked against the closed vocabulary in `node.js`,
|
||||
* so a registration cannot widen what the product can say — only choose
|
||||
* from it.
|
||||
*/
|
||||
variants: Object.freeze([...(definition.variants || [])]),
|
||||
densities: Object.freeze([...(definition.densities || [])]),
|
||||
|
||||
/**
|
||||
* The page this type belongs to, or `null` for one that belongs anywhere.
|
||||
*
|
||||
* The registry is global — one table, so a page can be composed before
|
||||
* anything about it is known here — but most types are not. A page's own
|
||||
* sections read that page's published render context: Hired History's
|
||||
* chronology destructures `hires` and `filtered`, and on any other page
|
||||
* those are simply absent. Offered there and added, it threw on
|
||||
* `undefined.length` and took the whole application down with it.
|
||||
*
|
||||
* So a registration says where it belongs, and the engine only ever
|
||||
* compares this string to the page being composed. It still knows no page
|
||||
* names — `null` here is "anywhere", which is what the nine reading types
|
||||
* and any future generic component declare by saying nothing.
|
||||
*/
|
||||
page: definition.page ? String(definition.page).trim() : null,
|
||||
|
||||
/**
|
||||
* What one *instance* of this type should be called.
|
||||
*
|
||||
* Optional. Most types are named well enough by their label — there is
|
||||
* one Hiring funnel on Analytics. Types a page mounts more than once are
|
||||
* not: every skill slot is a "Skill sections", and two of them in an
|
||||
* outline are two identical rows nobody can tell apart or name out loud.
|
||||
* The type answers for its own instances; the engine only calls it.
|
||||
*/
|
||||
describe: typeof definition.describe === 'function' ? definition.describe : null,
|
||||
|
||||
/**
|
||||
* Whether a person may add one of these.
|
||||
*
|
||||
* Two things have to be true, and the second is derived rather than
|
||||
* declared: **anything a person can add, they must be able to remove.**
|
||||
*
|
||||
* The editor offered a page's own sections, which declare `move` and
|
||||
* `hide` and not `remove` — so one could be added and then never deleted.
|
||||
* The placeholder for a failed node said it "can be hidden or removed"
|
||||
* and the Remove button was not there, because the inspector asks the
|
||||
* type while the engine asks the node's origin. Deriving it here settles
|
||||
* the disagreement in the one place both of them read.
|
||||
*
|
||||
* What is left addable is what a person actually adds: the readings. A
|
||||
* page's own sections are composed by the page and a skill surface is an
|
||||
* anchor the page owns — both are moved, hidden and reordered, never
|
||||
* conjured up by a picker.
|
||||
*/
|
||||
addable: definition.addable !== false && capabilities.includes('remove'),
|
||||
});
|
||||
|
||||
this.types.set(type, entry);
|
||||
return entry;
|
||||
}
|
||||
|
||||
/** The registration, or null. Every consumer goes through this. */
|
||||
get(type) {
|
||||
return this.types.get(String(type ?? '').trim()) || null;
|
||||
}
|
||||
|
||||
has(type) {
|
||||
return this.types.has(String(type ?? '').trim());
|
||||
}
|
||||
|
||||
/** Every registered type name, in registration order. */
|
||||
list() {
|
||||
return [...this.types.keys()];
|
||||
}
|
||||
|
||||
/** Every registration, for a picker that has to describe what it is offering. */
|
||||
all() {
|
||||
return [...this.types.values()];
|
||||
}
|
||||
|
||||
/**
|
||||
* Can this node type do this?
|
||||
*
|
||||
* The single question every operation asks. A type that is absent can do
|
||||
* nothing — an unregistered type is not a permissive default, it is an
|
||||
* unknown component, and the engine refuses it.
|
||||
*/
|
||||
allows(type, capability) {
|
||||
const entry = this.get(type);
|
||||
return Boolean(entry && entry.capabilities.includes(capability));
|
||||
}
|
||||
|
||||
/**
|
||||
* May a node of `childType` sit inside `parentType`?
|
||||
*
|
||||
* A null parent is the page root, which accepts anything registered — the
|
||||
* page's own composition decides what is actually there, and refusing at the
|
||||
* root would mean the root needed a registration of its own.
|
||||
*/
|
||||
acceptsChild(parentType, childType) {
|
||||
if (!this.has(childType)) return false;
|
||||
if (parentType == null) return true;
|
||||
const parent = this.get(parentType);
|
||||
if (!parent || !parent.container) return false;
|
||||
return parent.accepts.includes('*') || parent.accepts.includes(childType);
|
||||
}
|
||||
|
||||
/**
|
||||
* May a person put one of these on this page, here?
|
||||
*
|
||||
* `acceptsChild` answers the structural half — does this container hold that
|
||||
* kind of thing. This answers the rest, and the rest is what was missing:
|
||||
* whether the type is one a person may add at all, and whether it belongs to
|
||||
* the page they are standing on.
|
||||
*
|
||||
* Both halves have to hold. Without the first, the picker offered the slot it
|
||||
* renders into; without the second, Candidates Analysis offered every private
|
||||
* section of every other page in the application — forty-three types on a
|
||||
* page that has seven — and adding one crashed the app.
|
||||
*/
|
||||
offersChild(parentType, childType, { page = null } = {}) {
|
||||
if (!this.acceptsChild(parentType, childType)) return false;
|
||||
const child = this.get(childType);
|
||||
if (!child.addable) return false;
|
||||
/* A type that names no page belongs anywhere. A page that is not named
|
||||
cannot vouch for anything page-bound, so it is offered only the generic
|
||||
types — which is the safe reading, not a permissive one. */
|
||||
return child.page === null || child.page === page;
|
||||
}
|
||||
|
||||
/**
|
||||
* The types a node could be turned into, given what it is bound to.
|
||||
*
|
||||
* Derived rather than declared, so "change this chart to a table" is answered
|
||||
* by the same compatibility rule that refuses an impossible section — a type
|
||||
* is a candidate when it can draw at least one shape the current binding
|
||||
* supports. A node with no binding may become any type that needs no data.
|
||||
*
|
||||
* `shapes` is passed in rather than looked up because the shape vocabulary
|
||||
* belongs to `lib/skills/surfaces.js`, and this module deliberately does not
|
||||
* import the product's data vocabulary: the registry is about components, and
|
||||
* coupling it to data sources would make a UI type impossible to register
|
||||
* without one.
|
||||
*/
|
||||
replacements(type, { shapes = null, page = null } = {}) {
|
||||
const current = this.get(type);
|
||||
if (!current) return [];
|
||||
|
||||
/* What the candidate has to be able to draw: the binding's shapes when a
|
||||
binding was named, otherwise whatever this type itself draws. */
|
||||
const wanted = shapes || current.dataShapes;
|
||||
|
||||
return this.all()
|
||||
.filter((entry) => entry.type !== type)
|
||||
/**
|
||||
* The same scope that governs adding.
|
||||
*
|
||||
* Offering a replacement is offering to put that type on this page, so it
|
||||
* answers to the same two rules: a type bound to another page belongs to
|
||||
* that page, and a type nobody may add is not a thing to turn something
|
||||
* into. This was safe only by accident — every page-bound section happens
|
||||
* to declare no shapes, so the shape filter below excluded them — and an
|
||||
* accident is not a rule. A section that declared one would have appeared
|
||||
* in every page's "Show as" list.
|
||||
*/
|
||||
.filter((entry) => this.offersChild(null, entry.type, { page }))
|
||||
.filter((entry) => (
|
||||
/* A type that reads no data can only be swapped for another that reads
|
||||
none — a divider is not an alternative rendering of a chart. */
|
||||
wanted.length
|
||||
? entry.dataShapes.some((shape) => wanted.includes(shape))
|
||||
: entry.dataShapes.length === 0
|
||||
))
|
||||
.map((entry) => entry.type);
|
||||
}
|
||||
|
||||
/** Forget everything. Tests only — a fresh registry per case beats shared state. */
|
||||
reset() {
|
||||
this.types.clear();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The registry the application uses.
|
||||
*
|
||||
* A module singleton because the type table is the application's, not a
|
||||
* component's, and threading it through every call site would be ceremony. Every
|
||||
* function that reads it takes an override, so a test can register a throwaway
|
||||
* type without touching this one.
|
||||
*/
|
||||
export const nodeRegistry = new NodeTypeRegistry();
|
||||
|
||||
/**
|
||||
* Declare a node type on the application registry.
|
||||
*
|
||||
* Re-declaring **replaces**, which `register` itself refuses to do. The
|
||||
* difference is deliberate and the two are for different callers:
|
||||
*
|
||||
* - `nodeRegistry.register` is strict, because a registry that quietly
|
||||
* accepted two definitions of one type would render whichever module
|
||||
* happened to load second.
|
||||
* - This is what a module-scope registration wants. Types are declared as a
|
||||
* side effect of importing the module that owns them, and the dev server
|
||||
* re-runs that module every time the file is saved. Strictness there would
|
||||
* throw on every edit, and keeping the first registration instead would
|
||||
* leave the page drawing the component as it was before the edit — which is
|
||||
* worse, because it looks like the change did not work.
|
||||
*
|
||||
* The cost is that two modules claiming one type name silently agree on the
|
||||
* last one loaded. That is a real risk and the reason type names are prefixed
|
||||
* with the surface that owns them.
|
||||
*/
|
||||
export const registerNodeType = (definition) => {
|
||||
nodeRegistry.types.delete(String(definition?.type ?? '').trim());
|
||||
return nodeRegistry.register(definition);
|
||||
};
|
||||
83
src/lib/ui/skillNodes.js
Normal file
83
src/lib/ui/skillNodes.js
Normal file
@@ -0,0 +1,83 @@
|
||||
/**
|
||||
* A skill's declared section, as a node in the page tree.
|
||||
*
|
||||
* This is the join between the two halves of the UI system. A Board skill's
|
||||
* `ui:` block is still parsed by `uiConfig.normalizeSection`, still validated
|
||||
* against the closed vocabulary, and still drawn by the same nine components —
|
||||
* nothing about definitions changes. What this adds is *identity*: the section
|
||||
* becomes an addressable node, so it can be hidden, moved and reordered by the
|
||||
* same operations that move a built-in.
|
||||
*
|
||||
* **Nothing here writes back to Markdown.** A node carries `origin: 'skill'`,
|
||||
* and a change to it is stored as an operation in the person's own layout
|
||||
* patch. The definition on disk, and the definition in the account's custom
|
||||
* skills, are read-only from here — which is what keeps a layout preference
|
||||
* from silently editing something another user also sees.
|
||||
*/
|
||||
|
||||
import { dataSourceFor, sourceSupportsOption } from '@/lib/skills/surfaces';
|
||||
import { makeNode } from './node';
|
||||
|
||||
/** Ids are `skill-<skill>-<section>`, so provenance is legible in the DOM. */
|
||||
export const skillNodeId = (skillId, sectionId) => [
|
||||
'skill', slug(skillId), slug(sectionId),
|
||||
].filter(Boolean).join('-');
|
||||
|
||||
const slug = (value) => String(value || '')
|
||||
.toLowerCase().trim().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
|
||||
|
||||
/**
|
||||
* One normalized section, adapted.
|
||||
*
|
||||
* Only options the source actually declares are carried across. The skill
|
||||
* format checks that a period is a real period; this checks that the reading
|
||||
* takes periods at all — a stricter rule, and one a definition written against
|
||||
* the looser one could fail. Dropping the option is right where refusing the
|
||||
* node would not be: a Board card must never disappear because of a parameter
|
||||
* that was doing nothing anyway.
|
||||
*/
|
||||
export function skillSectionNode(skill, section) {
|
||||
const params = {};
|
||||
if (section.periods?.length && sourceSupportsOption(section.source, 'periods')) {
|
||||
params.periods = [...section.periods];
|
||||
}
|
||||
if (section.limit && sourceSupportsOption(section.source, 'limit')) {
|
||||
params.limit = section.limit;
|
||||
}
|
||||
|
||||
return makeNode({
|
||||
id: skillNodeId(skill.id, section.id),
|
||||
type: section.type,
|
||||
origin: 'skill',
|
||||
data: { source: section.source, params },
|
||||
props: {
|
||||
/* The same fallback the surface uses, so a section with no title of its
|
||||
own is still named by the skill that contributed it. */
|
||||
title: section.title || skill.name,
|
||||
...(section.description ? { description: section.description } : {}),
|
||||
/* Attribution is what lets a reader tell an extension from a built-in
|
||||
panel, and which skill to switch off. */
|
||||
attribution: skill.name,
|
||||
...(section.editable ? { editable: true } : {}),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Every section a page's definitions contribute, grouped by placement.
|
||||
*
|
||||
* Grouped because that is how the composition consumes them: each
|
||||
* `skill-surface` node names one placement and takes the sections that declared
|
||||
* it. A placement no slot offers simply has nowhere to render, which is the
|
||||
* same outcome as today.
|
||||
*/
|
||||
export function skillNodesByPlacement(sections = []) {
|
||||
const out = {};
|
||||
for (const { skill, section } of sections) {
|
||||
if (!dataSourceFor(section.source)) continue;
|
||||
const key = section.placement || '';
|
||||
if (!out[key]) out[key] = [];
|
||||
out[key].push(skillSectionNode(skill, section));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
393
src/lib/ui/validate.js
Normal file
393
src/lib/ui/validate.js
Normal file
@@ -0,0 +1,393 @@
|
||||
/**
|
||||
* What a UI tree has to satisfy before anything is applied or saved.
|
||||
*
|
||||
* This is the gate the plan runs through, and it is deliberately the *only*
|
||||
* place that decides whether a change is allowed. Operations build a candidate
|
||||
* tree; this says whether the product can honour it. Keeping the two apart is
|
||||
* what makes a preview trustworthy: the tree a user is shown is the tree that
|
||||
* passed, not a tree that will be checked again differently on the way to
|
||||
* storage.
|
||||
*
|
||||
* Every refusal names what it refused and why. A validator that returns a
|
||||
* boolean pushes the explaining into whichever caller happens to be nearest,
|
||||
* and the caller does not know which rule fired.
|
||||
*
|
||||
* The rules split in two:
|
||||
*
|
||||
* - **Structure**, which this module owns: ids, types, props, layout,
|
||||
* containment, capability, role.
|
||||
* - **Data**, which it borrows from `lib/skills/surfaces.js` — the same
|
||||
* closed source vocabulary and the same source/shape compatibility rule
|
||||
* that `uiConfig.normalizeSection` already refuses on. Borrowed rather than
|
||||
* restated: two readings of what a source can draw is how a form composes
|
||||
* what the normalizer rejects.
|
||||
*/
|
||||
|
||||
import {
|
||||
SUPPORTED_DATA_SOURCES, SUPPORTED_PERIODS, dataSourceFor, sourceSupportsOption,
|
||||
sourceSupportsShape,
|
||||
} from '@/lib/skills/surfaces';
|
||||
import {
|
||||
ALIGN_VALUES, DENSITY_VALUES, GAP_VALUES, MAX_COLUMNS, MIN_COLUMNS, NODE_ID_PATTERN,
|
||||
PRESENTATION_KEYS, SPACING_VALUES, VARIANT_VALUES, walk,
|
||||
} from './node';
|
||||
import { nodeRegistry } from './registry';
|
||||
|
||||
/** One refusal. `at` is the node it concerns, so an editor can point at it. */
|
||||
const problem = (at, message) => ({ at: at || null, message });
|
||||
|
||||
/**
|
||||
* Check one node in isolation, given where it sits.
|
||||
*
|
||||
* `parentType` is null at the root. Returns a list of problems, empty when the
|
||||
* node is fine — never throws, because a tree with three bad nodes should
|
||||
* report three, not the first.
|
||||
*/
|
||||
export function validateNode(node, {
|
||||
parentType = null, registry = nodeRegistry, role = null,
|
||||
} = {}) {
|
||||
const problems = [];
|
||||
const at = node?.id || null;
|
||||
|
||||
if (!node || typeof node !== 'object') {
|
||||
return [problem(null, 'A node must be a mapping of options.')];
|
||||
}
|
||||
|
||||
/* ── Identity ─────────────────────────────────────────────────────────── */
|
||||
if (!node.id) {
|
||||
problems.push(problem(null, 'A node needs an `id`.'));
|
||||
} else if (!NODE_ID_PATTERN.test(node.id)) {
|
||||
problems.push(problem(at, `\`${node.id}\`: an id must be lower-case letters, numbers and dashes.`));
|
||||
}
|
||||
|
||||
/* ── Type ─────────────────────────────────────────────────────────────── */
|
||||
if (!node.type) {
|
||||
problems.push(problem(at, 'A node needs a `type`.'));
|
||||
return problems;
|
||||
}
|
||||
|
||||
const entry = registry.get(node.type);
|
||||
if (!entry) {
|
||||
/* The hallucinated-component gate. A type the application does not ship
|
||||
cannot be rendered, and naming the supported set is what turns a refusal
|
||||
into something the asker can act on. */
|
||||
problems.push(problem(
|
||||
at,
|
||||
`Unsupported UI type: ${node.type}. Supported types: ${registry.list().join(', ') || 'none registered'}.`
|
||||
));
|
||||
return problems;
|
||||
}
|
||||
|
||||
/* ── Containment ──────────────────────────────────────────────────────── */
|
||||
if (!registry.acceptsChild(parentType, node.type)) {
|
||||
problems.push(problem(
|
||||
at,
|
||||
`\`${node.type}\` cannot sit inside \`${parentType}\`.`
|
||||
));
|
||||
}
|
||||
if (node.children?.length && !entry.container) {
|
||||
problems.push(problem(at, `\`${node.type}\` cannot hold other nodes.`));
|
||||
}
|
||||
|
||||
const { minChildren, maxChildren } = entry.constraints;
|
||||
const count = node.children?.length || 0;
|
||||
if (Number.isFinite(minChildren) && count < minChildren) {
|
||||
problems.push(problem(at, `\`${node.type}\` needs at least ${minChildren} node(s); it has ${count}.`));
|
||||
}
|
||||
if (Number.isFinite(maxChildren) && count > maxChildren) {
|
||||
problems.push(problem(at, `\`${node.type}\` holds at most ${maxChildren} node(s); it has ${count}.`));
|
||||
}
|
||||
|
||||
/* ── Permission ───────────────────────────────────────────────────────── */
|
||||
if (entry.roles && role && !entry.roles.includes(role)) {
|
||||
/* Named without describing the component, because the refusal is about the
|
||||
caller and not about what they are missing. */
|
||||
problems.push(problem(at, `You do not have access to \`${entry.label}\`.`));
|
||||
}
|
||||
|
||||
problems.push(...validateProps(node, entry));
|
||||
problems.push(...validateLayout(node, entry));
|
||||
problems.push(...validatePresentation(node, entry));
|
||||
problems.push(...validateBinding(node, entry));
|
||||
|
||||
return problems;
|
||||
}
|
||||
|
||||
/**
|
||||
* Props, against the schema the type declared.
|
||||
*
|
||||
* Unknown keys are refused rather than dropped. Dropping is right when reading a
|
||||
* definition an author wrote by hand — `uiConfig` does exactly that — but this
|
||||
* path is a *mutation*, and a silently discarded prop is a change the user
|
||||
* asked for, was told had been applied, and then did not happen.
|
||||
*/
|
||||
function validateProps(node, entry) {
|
||||
const problems = [];
|
||||
const schema = entry.propSchema || {};
|
||||
const at = node.id;
|
||||
|
||||
for (const [key, value] of Object.entries(node.props || {})) {
|
||||
const rule = schema[key];
|
||||
if (!rule) {
|
||||
const known = Object.keys(schema);
|
||||
problems.push(problem(
|
||||
at,
|
||||
`\`${entry.type}\` has no property \`${key}\`.`
|
||||
+ (known.length ? ` It accepts: ${known.join(', ')}.` : '')
|
||||
));
|
||||
continue;
|
||||
}
|
||||
problems.push(...checkValue(at, entry.type, key, value, rule));
|
||||
}
|
||||
|
||||
for (const [key, rule] of Object.entries(schema)) {
|
||||
if (rule?.required && node.props?.[key] == null) {
|
||||
problems.push(problem(at, `\`${entry.type}\` needs \`${key}\`.`));
|
||||
}
|
||||
}
|
||||
|
||||
return problems;
|
||||
}
|
||||
|
||||
/** One prop against one rule. Kept separate so the rule vocabulary has one reader. */
|
||||
function checkValue(at, type, key, value, rule) {
|
||||
const problems = [];
|
||||
|
||||
if (Array.isArray(rule.enum)) {
|
||||
if (!rule.enum.includes(value)) {
|
||||
problems.push(problem(
|
||||
at,
|
||||
`\`${type}.${key}\` must be one of: ${rule.enum.join(', ')}. Got \`${value}\`.`
|
||||
));
|
||||
}
|
||||
return problems;
|
||||
}
|
||||
|
||||
const kind = rule.type || 'string';
|
||||
const actual = Array.isArray(value) ? 'array' : typeof value;
|
||||
|
||||
if (kind === 'number') {
|
||||
if (!Number.isFinite(Number(value))) {
|
||||
problems.push(problem(at, `\`${type}.${key}\` must be a number. Got \`${value}\`.`));
|
||||
return problems;
|
||||
}
|
||||
const n = Number(value);
|
||||
if (Number.isFinite(rule.min) && n < rule.min) {
|
||||
problems.push(problem(at, `\`${type}.${key}\` must be at least ${rule.min}.`));
|
||||
}
|
||||
if (Number.isFinite(rule.max) && n > rule.max) {
|
||||
problems.push(problem(at, `\`${type}.${key}\` must be at most ${rule.max}.`));
|
||||
}
|
||||
return problems;
|
||||
}
|
||||
|
||||
if (kind !== actual) {
|
||||
problems.push(problem(at, `\`${type}.${key}\` must be a ${kind}. Got ${actual}.`));
|
||||
}
|
||||
return problems;
|
||||
}
|
||||
|
||||
/**
|
||||
* Layout, bounded globally and then by the type.
|
||||
*
|
||||
* The responsive gate. A column count is the one layout value that can make a
|
||||
* page unusable on a phone rather than merely ugly, so it is bounded twice —
|
||||
* once by the grid the application can express at all, and once by what this
|
||||
* particular component stays readable in.
|
||||
*/
|
||||
function validateLayout(node, entry) {
|
||||
const problems = [];
|
||||
const at = node.id;
|
||||
const layout = node.layout || {};
|
||||
const { minColumns, maxColumns } = entry.constraints;
|
||||
|
||||
for (const key of ['columns', 'span']) {
|
||||
if (layout[key] == null) continue;
|
||||
const n = Number(layout[key]);
|
||||
if (!Number.isInteger(n)) {
|
||||
problems.push(problem(at, `\`${key}\` must be a whole number. Got \`${layout[key]}\`.`));
|
||||
continue;
|
||||
}
|
||||
if (n < MIN_COLUMNS || n > MAX_COLUMNS) {
|
||||
problems.push(problem(at, `\`${key}\` must be between ${MIN_COLUMNS} and ${MAX_COLUMNS}.`));
|
||||
continue;
|
||||
}
|
||||
if (key === 'columns' && (n < minColumns || n > maxColumns)) {
|
||||
problems.push(problem(
|
||||
at,
|
||||
`\`${entry.type}\` supports ${minColumns}–${maxColumns} columns. Got ${n}.`
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
if (layout.gap != null && !GAP_VALUES.includes(layout.gap)) {
|
||||
problems.push(problem(at, `\`gap\` must be one of: ${GAP_VALUES.join(', ')}.`));
|
||||
}
|
||||
if (layout.align != null && !ALIGN_VALUES.includes(layout.align)) {
|
||||
problems.push(problem(at, `\`align\` must be one of: ${ALIGN_VALUES.join(', ')}.`));
|
||||
}
|
||||
|
||||
for (const key of ['spacingBefore', 'spacingAfter']) {
|
||||
if (layout[key] != null && !SPACING_VALUES.includes(layout[key])) {
|
||||
problems.push(problem(at, `\`${key}\` must be one of: ${SPACING_VALUES.join(', ')}.`));
|
||||
}
|
||||
}
|
||||
|
||||
return problems;
|
||||
}
|
||||
|
||||
/**
|
||||
* Presentation, checked twice.
|
||||
*
|
||||
* Once against the product's vocabulary — a value that is not a value cannot be
|
||||
* stored — and once against what *this type* declared it can draw. The second
|
||||
* check is the one that matters: a setting a component ignores is a change a
|
||||
* person made and cannot see, which is worse than being told no.
|
||||
*
|
||||
* The refusal names what is available, because the person is choosing from a
|
||||
* closed set and the set is short enough to say out loud.
|
||||
*/
|
||||
function validatePresentation(node, entry) {
|
||||
const problems = [];
|
||||
const at = node.id;
|
||||
const presentation = node.presentation || {};
|
||||
|
||||
for (const key of Object.keys(presentation)) {
|
||||
if (!PRESENTATION_KEYS.includes(key)) {
|
||||
problems.push(problem(at, `\`${key}\` is not a presentation setting.`));
|
||||
}
|
||||
}
|
||||
|
||||
for (const [key, allowed, supported] of [
|
||||
['variant', VARIANT_VALUES, entry.variants],
|
||||
['density', DENSITY_VALUES, entry.densities],
|
||||
]) {
|
||||
const value = presentation[key];
|
||||
if (value == null) continue;
|
||||
|
||||
if (!allowed.includes(value)) {
|
||||
problems.push(problem(at, `\`${key}\` must be one of: ${allowed.join(', ')}.`));
|
||||
continue;
|
||||
}
|
||||
if (!supported.includes(value)) {
|
||||
problems.push(problem(
|
||||
at,
|
||||
supported.length
|
||||
? `\`${entry.type}\` supports ${key}: ${supported.join(', ')}. Got \`${value}\`.`
|
||||
: `\`${entry.type}\` has no ${key} to set.`
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
return problems;
|
||||
}
|
||||
|
||||
/**
|
||||
* The data binding — the gate that stops invented figures.
|
||||
*
|
||||
* A node cannot carry data; it can only name a reading the application already
|
||||
* offers. So a fabricated metric has nowhere to live: the resolver either
|
||||
* returns real rows for a real source or the section draws its empty note. The
|
||||
* three checks are that the source exists, that this component can draw it, and
|
||||
* that any options named are ones the source actually reads.
|
||||
*/
|
||||
function validateBinding(node, entry) {
|
||||
const problems = [];
|
||||
const at = node.id;
|
||||
const binding = node.data;
|
||||
|
||||
if (!binding) {
|
||||
if (entry.dataRequired) {
|
||||
problems.push(problem(at, `\`${entry.type}\` needs a data source.`));
|
||||
}
|
||||
return problems;
|
||||
}
|
||||
|
||||
if (!entry.dataShapes.length) {
|
||||
problems.push(problem(at, `\`${entry.type}\` does not read data, so it cannot take a source.`));
|
||||
return problems;
|
||||
}
|
||||
|
||||
if (!SUPPORTED_DATA_SOURCES.includes(binding.source)) {
|
||||
problems.push(problem(
|
||||
at,
|
||||
`Unsupported data source: ${binding.source}. `
|
||||
+ `Supported sources: ${SUPPORTED_DATA_SOURCES.join(', ')}.`
|
||||
));
|
||||
return problems;
|
||||
}
|
||||
|
||||
/* A component draws one or more shapes; a source can fill some of them. The
|
||||
node is only coherent where the two overlap. */
|
||||
const drawable = entry.dataShapes.filter((shape) => sourceSupportsShape(binding.source, shape));
|
||||
if (!drawable.length) {
|
||||
const source = dataSourceFor(binding.source);
|
||||
problems.push(problem(
|
||||
at,
|
||||
`\`${binding.source}\` cannot be shown as \`${entry.type}\`. `
|
||||
+ `It supports: ${(source?.shapes || []).join(', ')}.`
|
||||
));
|
||||
}
|
||||
|
||||
const params = binding.params || {};
|
||||
|
||||
if (params.periods != null) {
|
||||
if (!Array.isArray(params.periods)) {
|
||||
problems.push(problem(at, '`periods` must be a list.'));
|
||||
} else {
|
||||
const unknown = params.periods.find((p) => !SUPPORTED_PERIODS.includes(p));
|
||||
if (unknown) {
|
||||
problems.push(problem(
|
||||
at,
|
||||
`Unsupported period: ${unknown}. Supported periods: ${SUPPORTED_PERIODS.join(', ')}.`
|
||||
));
|
||||
} else if (params.periods.length && !sourceSupportsOption(binding.source, 'periods')) {
|
||||
problems.push(problem(at, `\`${binding.source}\` does not read periods.`));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (params.limit != null) {
|
||||
const limit = Number(params.limit);
|
||||
if (!Number.isInteger(limit) || limit < 1 || limit > 50) {
|
||||
problems.push(problem(at, '`limit` must be a whole number between 1 and 50.'));
|
||||
} else if (!sourceSupportsOption(binding.source, 'limit')) {
|
||||
problems.push(problem(at, `\`${binding.source}\` does not read a limit.`));
|
||||
}
|
||||
}
|
||||
|
||||
return problems;
|
||||
}
|
||||
|
||||
/**
|
||||
* A whole tree.
|
||||
*
|
||||
* Two things can only be seen from up here: that no id is used twice, and that
|
||||
* containment holds all the way down. Both are checked once, over one walk, so
|
||||
* a large page is not traversed per rule.
|
||||
*/
|
||||
export function validateTree(nodes, { registry = nodeRegistry, role = null } = {}) {
|
||||
const problems = [];
|
||||
const seen = new Set();
|
||||
|
||||
const visit = (list, parentType) => {
|
||||
for (const node of list || []) {
|
||||
if (node?.id) {
|
||||
/* The duplicate-node gate. Two nodes with one id means every operation
|
||||
after this point addresses whichever the traversal reached first. */
|
||||
if (seen.has(node.id)) {
|
||||
problems.push(problem(node.id, `Two nodes share the id \`${node.id}\`.`));
|
||||
}
|
||||
seen.add(node.id);
|
||||
}
|
||||
problems.push(...validateNode(node, { parentType, registry, role }));
|
||||
if (node?.children?.length) visit(node.children, node.type);
|
||||
}
|
||||
};
|
||||
|
||||
visit(nodes, null);
|
||||
return { ok: problems.length === 0, problems };
|
||||
}
|
||||
|
||||
/** Every id already in use, so a new node can be given one that is not. */
|
||||
export const takenIds = (nodes) => new Set(walk(nodes).map((node) => node.id));
|
||||
@@ -1,15 +1,12 @@
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import {
|
||||
Activity, FilePlus2, LogIn, LogOut, Mic, ScanSearch, Send, ShieldAlert, UserCheck, UserPlus,
|
||||
Users,
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
Avatar, Badge, DataTable, MetricStrip, SearchInput, Surface, Timeline,
|
||||
} from '@/components/ds';
|
||||
import { useUserActivity } from '@/lib/krowHooks';
|
||||
import { AdminPage, SectionTitle, Toolbar } from '@/components/admin/PageShell';
|
||||
import { FilterSelect } from '@/pages/admin/Positions';
|
||||
import { SkillSurface } from '@/components/skills/SkillSurface';
|
||||
import { AdminPage } from '@/components/admin/PageShell';
|
||||
import { UiTreeRenderer } from '@/components/ui-tree/UiTreeRenderer';
|
||||
import { composePage } from '@/lib/ui/composition';
|
||||
import { useUiEditing } from '@/components/ui-tree/UiEditingProvider';
|
||||
import { UiEditor } from '@/components/ui-editor/UiEditor';
|
||||
import '@/components/ui-tree/nodeTypes';
|
||||
import '@/pages/admin/activity/nodes';
|
||||
|
||||
/**
|
||||
* Admin Activity — an audit centre.
|
||||
@@ -33,35 +30,9 @@ const SEVERITY = {
|
||||
apply_job: 'low',
|
||||
};
|
||||
|
||||
const SEVERITY_META = {
|
||||
high: { label: 'High', variant: 'destructive' },
|
||||
medium: { label: 'Medium', variant: 'warning' },
|
||||
low: { label: 'Low', variant: 'neutral' },
|
||||
};
|
||||
|
||||
/** Severity carried through to the timeline node, so the two views agree. */
|
||||
const TIMELINE_TONE = { high: 'destructive', medium: 'warning', low: 'neutral' };
|
||||
|
||||
/** How many events the timeline shows before the table takes over. */
|
||||
const TIMELINE_LIMIT = 12;
|
||||
|
||||
/**
|
||||
* An icon per event type. Worth the table: on a chronology the glyph is what
|
||||
* makes a run of hires distinguishable from a run of logins at a glance, which is
|
||||
* the whole reason to show a timeline rather than another list of rows.
|
||||
*/
|
||||
const EVENT_ICON = {
|
||||
hire_candidate: UserCheck,
|
||||
assign_employee: Users,
|
||||
create_position: FilePlus2,
|
||||
screen_candidate: ScanSearch,
|
||||
start_interview: Mic,
|
||||
apply_job: Send,
|
||||
signup: UserPlus,
|
||||
login: LogIn,
|
||||
logout: LogOut,
|
||||
};
|
||||
|
||||
/** Stable pseudo-IP from the email, so the demo shows metadata without inventing
|
||||
* a value that changes on every render. */
|
||||
const ipFor = (email) => {
|
||||
@@ -143,162 +114,42 @@ export default function AdminActivity() {
|
||||
return [...groups.entries()];
|
||||
}, [recent]);
|
||||
|
||||
/**
|
||||
* What this page's sections read.
|
||||
*
|
||||
* Published once, as one bag. The renderer passes it down untouched and never
|
||||
* looks inside, which is what keeps it ignorant of what an Activity page
|
||||
* contains — and what lets the next page publish something entirely
|
||||
* different without the renderer changing.
|
||||
*/
|
||||
const context = useMemo(() => ({
|
||||
isLoading, events, users, actions, filtered, isFiltered, clearFilters,
|
||||
search, setSearch, user, setUser, action, setAction, severity, setSeverity, range, setRange,
|
||||
highRisk, last24h, recent, byDay,
|
||||
}), [
|
||||
isLoading, events, users, actions, filtered, isFiltered,
|
||||
search, user, action, severity, range, highRisk, last24h, recent, byDay,
|
||||
]);
|
||||
|
||||
return (
|
||||
<AdminPage
|
||||
title="Activity"
|
||||
subtitle="Monitor workforce operations, user actions and system events."
|
||||
>
|
||||
<SkillSurface page="activity" placement="after-header" />
|
||||
<MetricStrip
|
||||
columns={4}
|
||||
loading={isLoading}
|
||||
items={[
|
||||
{ label: 'Total events', value: events.length },
|
||||
{ label: 'Last 24 hours', value: last24h.length, tone: last24h.length ? 'default' : 'warning' },
|
||||
{ label: 'Privileged actions', value: highRisk.length, tone: 'warning', sub: 'hires and position changes' },
|
||||
{ label: 'Distinct users', value: users.length },
|
||||
]}
|
||||
/>
|
||||
|
||||
{highRisk.length > 0 && (
|
||||
<Surface variant="solid" radius="lg" padding="sm" elevation="xs" className="border-warning/30 bg-warning-muted">
|
||||
<div className="flex items-start gap-2.5">
|
||||
<ShieldAlert className="mt-0.5 h-4 w-4 shrink-0 text-warning" aria-hidden="true" />
|
||||
<p className="text-body-sm text-ink-2">
|
||||
<span className="font-semibold text-ink-1">{highRisk.length} privileged actions</span> in this log —
|
||||
hires and position changes. These should always trace to a named person.
|
||||
</p>
|
||||
</div>
|
||||
</Surface>
|
||||
)}
|
||||
|
||||
{/* The recent timeline. Deliberately above the log and deliberately not
|
||||
filtered: this answers "what just happened", which is a different
|
||||
question from the one the toolbar below exists to ask. Reading a
|
||||
chronology is also how an auditor starts — sequence first, then
|
||||
interrogate the specifics. */}
|
||||
<section aria-labelledby="timeline" className="space-y-3">
|
||||
<SectionTitle
|
||||
id="timeline"
|
||||
title="Operational timeline"
|
||||
meta={`Most recent ${recent.length} events`}
|
||||
/>
|
||||
<Surface variant="solid" radius="lg" padding="none" elevation="xs" className="overflow-hidden">
|
||||
{recent.length ? (
|
||||
<div className="divide-y divide-border">
|
||||
{byDay.map(([day, dayEvents]) => (
|
||||
<div key={day} className="px-4 py-3.5">
|
||||
<p className="mb-3 text-[10px] font-semibold uppercase tracking-wide text-ink-4">
|
||||
{day}
|
||||
</p>
|
||||
<Timeline
|
||||
compact
|
||||
items={dayEvents.map((e) => ({
|
||||
id: e.id,
|
||||
icon: EVENT_ICON[e.event_type] || Activity,
|
||||
tone: TIMELINE_TONE[e.severity],
|
||||
title: e.event_type.replace(/_/g, ' '),
|
||||
description: e.details || undefined,
|
||||
meta: `${e.user_name || e.user_email}${e.account_type ? ` · ${e.account_type}` : ''}`,
|
||||
timestamp: new Date(e.created_date).toLocaleTimeString(undefined, {
|
||||
hour: 'numeric', minute: '2-digit',
|
||||
}),
|
||||
}))}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="px-4 py-8 text-center text-body-sm text-ink-3">
|
||||
No activity has been logged yet.
|
||||
</p>
|
||||
)}
|
||||
</Surface>
|
||||
</section>
|
||||
|
||||
<section aria-labelledby="audit" className="space-y-3">
|
||||
<SectionTitle id="audit" title="Audit log" meta={`${filtered.length} of ${events.length} events`} />
|
||||
|
||||
<Toolbar
|
||||
search={<SearchInput value={search} onChange={setSearch} placeholder="Search events" size="sm" />}
|
||||
filters={
|
||||
<>
|
||||
<FilterSelect value={user} onChange={setUser} label="User"
|
||||
options={[{ value: 'all', label: 'All users' }, ...users.map((u) => ({ value: u, label: u }))]} />
|
||||
<FilterSelect value={action} onChange={setAction} label="Action"
|
||||
options={[{ value: 'all', label: 'All actions' }, ...actions.map((a) => ({ value: a, label: a.replace(/_/g, ' ') }))]} />
|
||||
<FilterSelect value={severity} onChange={setSeverity} label="Severity" options={[
|
||||
{ value: 'all', label: 'All severities' }, { value: 'high', label: 'High' },
|
||||
{ value: 'medium', label: 'Medium' }, { value: 'low', label: 'Low' },
|
||||
]} />
|
||||
<FilterSelect value={range} onChange={setRange} label="Date" options={[
|
||||
{ value: 'all', label: 'All time' }, { value: '24h', label: 'Last 24 hours' },
|
||||
{ value: '7d', label: 'Last 7 days' }, { value: '30d', label: 'Last 30 days' },
|
||||
]} />
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
<DataTable
|
||||
loading={isLoading}
|
||||
rows={filtered}
|
||||
pageSize={20}
|
||||
isFiltered={isFiltered}
|
||||
onClearFilters={clearFilters}
|
||||
caption="Platform audit log"
|
||||
rowClassName={(e) => (e.severity === 'high' ? 'bg-warning-muted/40' : undefined)}
|
||||
columns={[
|
||||
{
|
||||
key: 'user_name', header: 'User', sortable: true,
|
||||
cell: (e) => (
|
||||
<div className="flex min-w-0 items-center gap-2.5">
|
||||
<Avatar name={e.user_name || e.user_email} size="xs" />
|
||||
<div className="min-w-0">
|
||||
<p className="truncate font-medium text-ink-1">{e.user_name || '—'}</p>
|
||||
<p className="truncate text-[10px] text-ink-4">{e.user_email}</p>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'event_type', header: 'Action', sortable: true,
|
||||
cell: (e) => <span className="whitespace-nowrap">{e.event_type.replace(/_/g, ' ')}</span>,
|
||||
},
|
||||
{
|
||||
key: 'details', header: 'Entity', hideBelow: 'md',
|
||||
cell: (e) => <span className="line-clamp-1 text-ink-3">{e.details || '—'}</span>,
|
||||
},
|
||||
{
|
||||
key: 'account_type', header: 'Role', hideBelow: 'lg',
|
||||
cell: (e) => <Badge variant={e.account_type === 'employer' ? 'info' : 'neutral'} size="sm">{e.account_type || 'unknown'}</Badge>,
|
||||
},
|
||||
{
|
||||
key: 'ip', header: 'Source IP', hideBelow: 'lg',
|
||||
cell: (e) => <span className="font-mono text-[11px] text-ink-4">{e.ip}</span>,
|
||||
},
|
||||
{
|
||||
key: 'created_date', header: 'Timestamp', align: 'right', sortable: true,
|
||||
sortValue: (e) => new Date(e.created_date).getTime(),
|
||||
cell: (e) => (
|
||||
<span className="whitespace-nowrap text-[11px] text-ink-4">
|
||||
{new Date(e.created_date).toLocaleString(undefined, {
|
||||
month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit',
|
||||
})}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'severity', header: 'Severity', align: 'right',
|
||||
cell: (e) => (
|
||||
<Badge variant={SEVERITY_META[e.severity].variant} size="sm">
|
||||
{SEVERITY_META[e.severity].label}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</section>
|
||||
<SkillSurface page="activity" placement="before-footer" />
|
||||
{/* The editing session is mounted by the layout, so the panel beside this
|
||||
page shares it. The page only renders what it is handed, which is why
|
||||
a preview in chat and the applied state cannot diverge. */}
|
||||
<UiEditor />
|
||||
<ActivityComposition context={context} />
|
||||
</AdminPage>
|
||||
);
|
||||
}
|
||||
|
||||
/** The composed page. Split out so it can read the tree the provider computed. */
|
||||
function ActivityComposition({ context }) {
|
||||
const editing = useUiEditing();
|
||||
/* Outside a layout — a test, a storybook — there is no session, and the page
|
||||
still has to draw. It falls back to the composition as shipped. */
|
||||
const tree = editing?.tree || composePage('activity').tree;
|
||||
return <UiTreeRenderer nodes={tree} context={context} />;
|
||||
}
|
||||
|
||||
@@ -165,6 +165,7 @@ export default function AdminAgentDetail() {
|
||||
/* A new agent needs an id before it has an address. Derived from the name
|
||||
so the author never has to invent one. */
|
||||
const composed = applyAgentFields(baseSource, fields);
|
||||
|
||||
const problem = validateAgentSource(composed);
|
||||
if (problem) { toast.error(problem); return null; }
|
||||
|
||||
|
||||
@@ -1,16 +1,13 @@
|
||||
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 '@/components/admin/PageShell';
|
||||
import { SkillSurface } from '@/components/skills/SkillSurface';
|
||||
import { AdminPage } from '@/components/admin/PageShell';
|
||||
import { UiTreeRenderer } from '@/components/ui-tree/UiTreeRenderer';
|
||||
import { useUiEditing } from '@/components/ui-tree/UiEditingProvider';
|
||||
import { UiEditor } from '@/components/ui-editor/UiEditor';
|
||||
import { composePage } from '@/lib/ui/composition';
|
||||
import '@/components/ui-tree/nodeTypes';
|
||||
import '@/pages/admin/analytics/nodes';
|
||||
import {
|
||||
buildEfficiency, buildFunnel, buildInsights, buildTrend, byDepartment, byPosition,
|
||||
hiresWithFill, summarise,
|
||||
@@ -38,101 +35,6 @@ import {
|
||||
* 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() {
|
||||
const { data: staff = [], isLoading } = useStaff();
|
||||
const { data: applications = [] } = useApplications();
|
||||
@@ -154,211 +56,24 @@ export default function AdminAnalytics() {
|
||||
[hires, departments, positions, funnel, efficiency]
|
||||
);
|
||||
|
||||
const context = useMemo(() => ({
|
||||
summary, funnel, trend, departments, positions, efficiency, insights, hires, isLoading, applications,
|
||||
}), [summary, funnel, trend, departments, positions, efficiency, insights, hires, isLoading, applications]);
|
||||
|
||||
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 min-w-[36rem] 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" />
|
||||
<UiEditor />
|
||||
<AnalyticsComposition context={context} />
|
||||
</AdminPage>
|
||||
);
|
||||
}
|
||||
|
||||
/** The composed page. */
|
||||
function AnalyticsComposition({ context }) {
|
||||
const editing = useUiEditing();
|
||||
const tree = editing?.tree || composePage('analytics').tree;
|
||||
return <UiTreeRenderer nodes={tree} context={context} />;
|
||||
}
|
||||
|
||||
@@ -1,12 +1,16 @@
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import { Button, SearchInput } from '@/components/ds';
|
||||
|
||||
import { useApplications, useInterviews, useJobPostings, useUpdateApplication, useHireCandidate } from '@/lib/krowHooks';
|
||||
import { base44 } from '@/api/base44Client';
|
||||
import { toast } from 'react-hot-toast';
|
||||
import { AdminPage, Toolbar } from '@/components/admin/PageShell';
|
||||
import { FilterSelect } from '@/pages/admin/Positions';
|
||||
import CandidateCard from '@/components/krow/CandidateCard';
|
||||
import { SkillSurface } from '@/components/skills/SkillSurface';
|
||||
import { AdminPage } from '@/components/admin/PageShell';
|
||||
import { UiTreeRenderer } from '@/components/ui-tree/UiTreeRenderer';
|
||||
import { useUiEditing } from '@/components/ui-tree/UiEditingProvider';
|
||||
import { UiEditor } from '@/components/ui-editor/UiEditor';
|
||||
import { composePage } from '@/lib/ui/composition';
|
||||
import { SORTS } from '@/pages/admin/candidates/nodes';
|
||||
import '@/components/ui-tree/nodeTypes';
|
||||
import '@/pages/admin/candidates/nodes';
|
||||
import AIInterviewModal from '@/components/krow/AIInterviewModal';
|
||||
import ScheduleInterviewModal from '@/components/krow/ScheduleInterviewModal';
|
||||
import MessageCandidateModal from '@/components/krow/MessageCandidateModal';
|
||||
@@ -19,12 +23,6 @@ const SCORE_BANDS = {
|
||||
unscored: (s) => !s,
|
||||
};
|
||||
|
||||
const SORTS = {
|
||||
score: { label: 'Highest score', compare: (a, b) => (b.ai_score || 0) - (a.ai_score || 0) },
|
||||
recent: { label: 'Recent activity', compare: (a, b) => new Date(b.updated_date).getTime() - new Date(a.updated_date).getTime() },
|
||||
name: { label: 'Name A–Z', compare: (a, b) => a.applicant_name.localeCompare(b.applicant_name) },
|
||||
experience: { label: 'Most experience', compare: (a, b) => (b.years_experience || 0) - (a.years_experience || 0) },
|
||||
};
|
||||
|
||||
export default function AdminCandidates() {
|
||||
const { data: applications = [], isLoading } = useApplications();
|
||||
@@ -103,74 +101,21 @@ export default function AdminCandidates() {
|
||||
const postingById = useMemo(() => Object.fromEntries(postings.map(p => [p.id, p])), [postings]);
|
||||
const resolveTitle = (a) => a.job_title || postingById[a.job_posting_id]?.title || '';
|
||||
|
||||
const context = useMemo(() => ({
|
||||
applications, filtered, isLoading, isFiltered, clearFilters, positions, resolveTitle,
|
||||
search, setSearch, position, setPosition, stage, setStage, band, setBand, sort, setSort,
|
||||
handleAction, setMessageApp, setScheduleApp, handleDecline, handleDelete, handleHire,
|
||||
}), [applications, filtered, isLoading, isFiltered, positions, search, position, stage, band, sort]);
|
||||
|
||||
return (
|
||||
<AdminPage
|
||||
title="Candidates"
|
||||
subtitle="Review, compare and manage the candidate pipeline."
|
||||
>
|
||||
<SkillSurface page="candidates" placement="after-header" />
|
||||
<UiEditor />
|
||||
<CandidatesComposition context={context} />
|
||||
|
||||
<Toolbar
|
||||
search={<SearchInput value={search} onChange={setSearch} placeholder="Search candidates..." size="sm" />}
|
||||
filters={
|
||||
<>
|
||||
<FilterSelect value={position} onChange={setPosition} label="Position"
|
||||
options={[{ value: 'all', label: 'All positions' }, ...positions.map((p) => ({ value: p, label: p }))]} />
|
||||
<FilterSelect value={stage} onChange={setStage} label="Stage" options={[
|
||||
{ value: 'all', label: 'All stages' }, { value: 'applied', label: 'Applied' },
|
||||
{ value: 'ai_screened', label: 'AI Screened' }, { value: 'interview', label: 'Interviewing' },
|
||||
{ value: 'hired', label: 'Hired' }, { value: 'rejected', label: 'Declined' },
|
||||
]} />
|
||||
<FilterSelect value={band} onChange={setBand} label="Score" options={[
|
||||
{ value: 'all', label: 'All scores' }, { value: 'top', label: '80+ Top' },
|
||||
{ value: 'strong', label: '60–79 Strong' }, { value: 'weak', label: 'Under 60' },
|
||||
{ value: 'unscored', label: 'Unscored' },
|
||||
]} />
|
||||
<FilterSelect value={sort} onChange={setSort} label="Sort"
|
||||
options={Object.entries(SORTS).map(([value, s]) => ({ value, label: s.label }))} />
|
||||
</>
|
||||
}
|
||||
meta={`${filtered.length} of ${applications.length} candidates${isFiltered ? ' · filtered' : ''}`}
|
||||
/>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="space-y-3">
|
||||
{[...Array(5)].map((_, i) => (
|
||||
<div key={i} className="h-24 bg-white border border-[#E5E7EB] rounded-xl animate-pulse" />
|
||||
))}
|
||||
</div>
|
||||
) : filtered.length === 0 ? (
|
||||
<div className="text-center py-16">
|
||||
<p className="text-body-sm text-ink-3">No candidates match your filters</p>
|
||||
{isFiltered && (
|
||||
<Button size="xs" variant="outline" className="mt-3" onClick={clearFilters}>
|
||||
Clear filters
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{filtered.map((app, idx) => (
|
||||
<CandidateCard
|
||||
key={app.id}
|
||||
application={app}
|
||||
jobTitle={resolveTitle(app)}
|
||||
rank={idx + 1}
|
||||
onAction={handleAction}
|
||||
onMessage={setMessageApp}
|
||||
onCall={setMessageApp}
|
||||
onSchedule={setScheduleApp}
|
||||
onDecline={handleDecline}
|
||||
onDelete={handleDelete}
|
||||
onHire={handleHire}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<SkillSurface page="candidates" placement="before-footer" />
|
||||
|
||||
{/* Modals */}
|
||||
{/* Modals — overlays rather than sections, so they stay outside the tree. */}
|
||||
{interviewApp && (
|
||||
<AIInterviewModal open={true} onClose={() => setInterviewApp(null)} application={interviewApp} job={postings.find(p => p.id === interviewApp.job_posting_id)} />
|
||||
)}
|
||||
@@ -183,3 +128,10 @@ export default function AdminCandidates() {
|
||||
</AdminPage>
|
||||
);
|
||||
}
|
||||
|
||||
/** The composed page. */
|
||||
function CandidatesComposition({ context }) {
|
||||
const editing = useUiEditing();
|
||||
const tree = editing?.tree || composePage('candidates').tree;
|
||||
return <UiTreeRenderer nodes={tree} context={context} />;
|
||||
}
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
Bar, BarChart, CartesianGrid, Cell, Pie, PieChart, ResponsiveContainer, Tooltip, XAxis, YAxis,
|
||||
} from 'recharts';
|
||||
import { ArrowRight } from 'lucide-react';
|
||||
import {
|
||||
AXIS_PROPS, Avatar, Badge, Button, CHART_TONES, ChartTooltip, InsightList, InsightRow,
|
||||
MetricStrip, ProgressBar, ProgressRing, Surface,
|
||||
|
||||
import { CHART_TONES,
|
||||
} from '@/components/ds';
|
||||
import { useApplications, useInterviews, useJobPostings, useStaff, useWorkerProfiles } from '@/lib/krowHooks';
|
||||
import { buildFacts } from '@/components/ai-assistant/insights';
|
||||
import { AdminPage, SectionTitle } from '@/components/admin/PageShell';
|
||||
import { SkillSurface } from '@/components/skills/SkillSurface';
|
||||
import { AdminPage } from '@/components/admin/PageShell';
|
||||
import { UiTreeRenderer } from '@/components/ui-tree/UiTreeRenderer';
|
||||
import { useUiEditing } from '@/components/ui-tree/UiEditingProvider';
|
||||
import { UiEditor } from '@/components/ui-editor/UiEditor';
|
||||
import { composePage } from '@/lib/ui/composition';
|
||||
import '@/components/ui-tree/nodeTypes';
|
||||
import '@/pages/admin/candidates-analysis/nodes';
|
||||
|
||||
/**
|
||||
* Admin Candidates Analysis — the analytical counterpart to Candidates.
|
||||
@@ -161,262 +161,24 @@ export default function AdminCandidatesAnalysis() {
|
||||
},
|
||||
].filter(Boolean), [f, skillGaps]);
|
||||
|
||||
const context = useMemo(() => ({ navigate, applications, postings, interviews, staff, profiles, f, bands, risks, top, skillSupply, skillGaps, positionFit, recommendations }), [
|
||||
navigate, applications, postings, interviews, staff, profiles, f, bands, risks, top, skillSupply, skillGaps, positionFit, recommendations,
|
||||
]);
|
||||
|
||||
return (
|
||||
<AdminPage
|
||||
title="Candidates Analysis"
|
||||
subtitle="Talent supply, quality distribution and pipeline risk."
|
||||
>
|
||||
<SkillSurface page="candidates-analysis" placement="after-header" />
|
||||
<MetricStrip
|
||||
columns={5}
|
||||
items={[
|
||||
{ label: 'In pipeline', value: f.total },
|
||||
{ label: 'Scored', value: f.scored.length, sub: `${f.standardizedPct}% coverage`, tone: f.standardizedPct >= 80 ? 'success' : 'warning' },
|
||||
{ label: 'At 80+', value: f.ranked.filter((a) => a.ai_score >= 80).length, tone: 'brand' },
|
||||
{ label: 'Avg score', value: f.avgScore || '—' },
|
||||
{ label: 'Risk flags', value: risks.length, tone: risks.length ? 'warning' : 'success' },
|
||||
]}
|
||||
/>
|
||||
|
||||
<div className="grid gap-6 lg:grid-cols-5">
|
||||
<section aria-labelledby="dist" className="space-y-3 lg:col-span-2">
|
||||
<SectionTitle id="dist" title="Quality distribution" meta={`${f.total} candidates`} />
|
||||
<ResponsiveContainer width="100%" height={200}>
|
||||
<PieChart>
|
||||
<Pie data={bands} dataKey="value" nameKey="name" innerRadius={48} outerRadius={78} paddingAngle={2} strokeWidth={0}>
|
||||
{bands.map((b) => <Cell key={b.name} fill={b.color} />)}
|
||||
</Pie>
|
||||
<Tooltip content={<ChartTooltip />} />
|
||||
</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" />
|
||||
<span className="flex-1 text-ink-3">{b.name}</span>
|
||||
<span className="font-semibold tabular-nums text-ink-1">{b.value}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section aria-labelledby="strongest" className="space-y-3 lg:col-span-3">
|
||||
<SectionTitle
|
||||
id="strongest"
|
||||
title="Strongest candidates"
|
||||
meta="By AI score"
|
||||
action={{ label: 'All candidates', onClick: () => navigate('/admin/candidates') }}
|
||||
/>
|
||||
<Surface variant="solid" radius="lg" padding="none" elevation="xs" className="divide-y divide-border overflow-hidden">
|
||||
{top.length ? top.map((c, i) => (
|
||||
<div key={c.id} className="flex items-center gap-3 px-4 py-2.5">
|
||||
<span className="w-4 shrink-0 text-caption font-bold text-ink-4">{i + 1}</span>
|
||||
<Avatar name={c.applicant_name} src={c.selfie_url} size="sm" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-body-sm font-medium text-ink-1">{c.applicant_name}</p>
|
||||
<p className="truncate text-[10px] text-ink-4">{c.job_title}</p>
|
||||
</div>
|
||||
{c.ai_match_label && (
|
||||
<Badge variant="soft" size="sm" className="hidden sm:inline-flex">{c.ai_match_label}</Badge>
|
||||
)}
|
||||
<div className="w-20 shrink-0">
|
||||
<ProgressBar value={c.ai_score} tone="score" size="xs" />
|
||||
</div>
|
||||
<ProgressRing value={c.ai_score} size={30} strokeWidth={3} tone="score" />
|
||||
</div>
|
||||
)) : (
|
||||
<p className="px-4 py-8 text-center text-body-sm text-ink-3">No scored candidates yet.</p>
|
||||
)}
|
||||
</Surface>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<section aria-labelledby="risk" className="space-y-3">
|
||||
<SectionTitle id="risk" title="Candidate risk" meta={risks.length ? `${risks.length} flags` : 'No flags'} />
|
||||
<Surface variant="solid" radius="lg" padding="none" elevation="xs" className="overflow-hidden">
|
||||
{risks.length ? (
|
||||
<InsightList>{risks.map((r, i) => <InsightRow key={i} {...r} />)}</InsightList>
|
||||
) : (
|
||||
<p className="px-4 py-8 text-center text-body-sm text-ink-3">
|
||||
No material risk flags. Credentials, availability and interview integrity all check out.
|
||||
</p>
|
||||
)}
|
||||
</Surface>
|
||||
<p className="text-caption text-ink-4">
|
||||
Flags are questions for a human, not rejections.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
{/* Skill supply and the gaps in it, side by side: what the pool has, and
|
||||
what the open roles ask for and nobody offers. */}
|
||||
<div className="grid gap-6 lg:grid-cols-5">
|
||||
<section aria-labelledby="skills" className="space-y-3 lg:col-span-3">
|
||||
<SectionTitle
|
||||
id="skills"
|
||||
title="Skill supply"
|
||||
meta={`Top ${skillSupply.length} across the scored pool`}
|
||||
/>
|
||||
{skillSupply.length ? (
|
||||
<ResponsiveContainer width="100%" height={Math.max(180, skillSupply.length * 30)}>
|
||||
<BarChart data={skillSupply} layout="vertical" margin={{ top: 0, right: 28, left: 0, bottom: 0 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke={CHART_TONES.grid} horizontal={false} />
|
||||
<XAxis type="number" {...AXIS_PROPS} allowDecimals={false} />
|
||||
<YAxis
|
||||
type="category"
|
||||
dataKey="skill"
|
||||
{...AXIS_PROPS}
|
||||
width={128}
|
||||
tick={{ fontSize: 11, fill: CHART_TONES.axis }}
|
||||
/>
|
||||
<Tooltip content={<ChartTooltip />} />
|
||||
<Bar dataKey="count" name="Candidates" radius={[0, 4, 4, 0]} maxBarSize={16}>
|
||||
{/* Tinted by the average score of the people holding the skill,
|
||||
so breadth and quality read together. */}
|
||||
{skillSupply.map((s) => (
|
||||
<Cell
|
||||
key={s.skill}
|
||||
fill={s.avgScore >= 75 ? CHART_TONES.brand : s.avgScore >= 60 ? CHART_TONES.accentPale : CHART_TONES.mint}
|
||||
/>
|
||||
))}
|
||||
</Bar>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
) : (
|
||||
<p className="text-body-sm text-ink-3">
|
||||
Nothing is scored yet, so there is no skill supply to read.
|
||||
</p>
|
||||
)}
|
||||
<p className="text-caption text-ink-4">
|
||||
Bar length is how many candidates claim the skill. Darker blue means the
|
||||
people holding it average 75 or above.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section aria-labelledby="gaps" className="space-y-3 lg:col-span-2">
|
||||
<SectionTitle
|
||||
id="gaps"
|
||||
title="Skill gaps"
|
||||
meta={skillGaps.length ? `${skillGaps.length} unmet` : 'None'}
|
||||
/>
|
||||
<Surface variant="solid" radius="lg" padding="none" elevation="xs" className="overflow-hidden">
|
||||
{skillGaps.length ? (
|
||||
<ul className="divide-y divide-border">
|
||||
{skillGaps.map((g) => (
|
||||
<li key={g.name} className="px-4 py-2.5">
|
||||
<p className="text-body-sm font-medium text-ink-1">{g.name}</p>
|
||||
<p className="mt-0.5 text-caption leading-relaxed text-ink-3">
|
||||
Required by {g.roles.join(', ')} — held by nobody in the scored pool.
|
||||
</p>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : (
|
||||
<p className="px-4 py-8 text-center text-body-sm text-ink-3">
|
||||
Every credential an open role requires is held by someone in the scored pool.
|
||||
</p>
|
||||
)}
|
||||
</Surface>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
{/* Position fit — which roles can actually be filled from current supply. */}
|
||||
<section aria-labelledby="fit" className="space-y-3">
|
||||
<SectionTitle id="fit" title="Position fit" meta={`${positionFit.length} open roles`} />
|
||||
<Surface variant="solid" radius="lg" padding="none" elevation="xs" className="overflow-hidden">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full min-w-[40rem] border-collapse text-body-sm">
|
||||
<caption className="sr-only">
|
||||
Applicant supply against candidates clearing the bar, per open role
|
||||
</caption>
|
||||
<thead>
|
||||
<tr className="border-b border-border bg-surface-subtle">
|
||||
{['Role', 'Applied', 'Qualified', 'Avg score', 'Fit'].map((h, i) => (
|
||||
<th
|
||||
key={h}
|
||||
scope="col"
|
||||
className={`whitespace-nowrap px-4 py-2.5 text-[10px] font-semibold uppercase tracking-wide text-ink-4 ${i ? 'text-right' : 'text-left'}`}
|
||||
>
|
||||
{h}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{positionFit.map((r) => (
|
||||
<tr key={r.title} className="border-b border-border last:border-0 hover:bg-surface-subtle">
|
||||
<td className="px-4 py-2.5 font-medium text-ink-1">{r.title}</td>
|
||||
<td className={`px-4 py-2.5 text-right tabular-nums ${r.applied ? 'text-ink-2' : 'font-semibold text-destructive'}`}>
|
||||
{r.applied}
|
||||
</td>
|
||||
<td className="px-4 py-2.5 text-right tabular-nums text-ink-2">{r.qualified}</td>
|
||||
<td className="px-4 py-2.5 text-right tabular-nums text-ink-2">{r.avgScore || '—'}</td>
|
||||
<td className="px-4 py-2.5">
|
||||
<div className="ml-auto w-24">
|
||||
<ProgressBar
|
||||
value={r.fit}
|
||||
tone={r.fit >= 50 ? 'success' : r.fit > 0 ? 'warning' : 'destructive'}
|
||||
size="xs"
|
||||
/>
|
||||
<p className="mt-1 text-right text-[10px] tabular-nums text-ink-4">{r.fit}% qualified</p>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Surface>
|
||||
</section>
|
||||
|
||||
{/* Recommendations — the actions the analysis above implies. */}
|
||||
{recommendations.length > 0 && (
|
||||
<section aria-labelledby="recommend" className="space-y-3">
|
||||
<SectionTitle
|
||||
id="recommend"
|
||||
title="Recommendations"
|
||||
meta={`${recommendations.length} ordered by impact`}
|
||||
/>
|
||||
<Surface variant="solid" radius="lg" padding="none" elevation="xs" className="overflow-hidden">
|
||||
<ol className="divide-y divide-border">
|
||||
{recommendations.map((r, i) => (
|
||||
<li key={r.title} className="flex gap-3 px-4 py-3">
|
||||
<span className="mt-0.5 grid h-5 w-5 shrink-0 place-items-center rounded-full bg-krow-blue-tint text-[10px] font-bold text-krow-blue">
|
||||
{i + 1}
|
||||
</span>
|
||||
<div className="min-w-0">
|
||||
<p className="text-body-sm font-medium text-ink-1">{r.title}</p>
|
||||
<p className="mt-0.5 text-caption leading-relaxed text-ink-3">{r.body}</p>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</Surface>
|
||||
</section>
|
||||
)}
|
||||
|
||||
<section aria-labelledby="screening" className="space-y-3">
|
||||
<SectionTitle id="screening" title="Screening efficiency" />
|
||||
<div className="grid gap-4 sm:grid-cols-3">
|
||||
{[
|
||||
{ label: 'Coverage', value: `${f.standardizedPct}%`, detail: `${f.scored.length} of ${f.total} scored`, tone: f.standardizedPct >= 80 ? 'success' : 'warning' },
|
||||
{ label: 'Interview completion', value: `${f.interviewCompletion}%`, detail: `${f.completedInterviews.length} of ${f.interviews.length} scored` },
|
||||
{ label: 'Quality lift', value: f.hiredAvgScore && f.avgScore ? `+${f.hiredAvgScore - f.avgScore}` : '—', detail: 'hires vs pool average', tone: 'success' },
|
||||
].map((s) => (
|
||||
<Surface key={s.label} variant="solid" radius="lg" padding="default" elevation="xs">
|
||||
<p className="text-[11px] font-medium uppercase tracking-wide text-ink-4">{s.label}</p>
|
||||
<p className={`mt-1 font-heading text-title-lg font-bold tabular-nums ${s.tone === 'success' ? 'text-success' : s.tone === 'warning' ? 'text-warning' : 'text-ink-1'}`}>
|
||||
{s.value}
|
||||
</p>
|
||||
<p className="mt-0.5 text-caption text-ink-3">{s.detail}</p>
|
||||
</Surface>
|
||||
))}
|
||||
</div>
|
||||
<Button variant="ghost" size="sm" onClick={() => navigate('/admin/analytics')} className="w-fit">
|
||||
Full analytics <ArrowRight aria-hidden="true" />
|
||||
</Button>
|
||||
</section>
|
||||
<SkillSurface page="candidates-analysis" placement="before-footer" />
|
||||
<UiEditor />
|
||||
<CandidatesAnalysisComposition context={context} />
|
||||
</AdminPage>
|
||||
);
|
||||
}
|
||||
|
||||
/** The composed page. */
|
||||
function CandidatesAnalysisComposition({ context }) {
|
||||
const editing = useUiEditing();
|
||||
const tree = editing?.tree || composePage('candidates-analysis').tree;
|
||||
return <UiTreeRenderer nodes={tree} context={context} />;
|
||||
}
|
||||
|
||||
@@ -1,23 +1,23 @@
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
Area, Bar, CartesianGrid, Cell, ComposedChart, Legend, Line, ResponsiveContainer,
|
||||
Tooltip, XAxis, YAxis,
|
||||
} from 'recharts';
|
||||
|
||||
import {
|
||||
AlertTriangle, ArrowRight, Building2, CalendarCheck, CheckCircle2, ChevronRight, Clock, Download, Filter, Sparkles, Users, Zap,
|
||||
} from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import {
|
||||
AXIS_PROPS, Avatar, Badge, Button, CHART_TONES, ChartTooltip, EmptyState, SegmentedToggle,
|
||||
Skeleton, StatusBadge, toast,
|
||||
import { Button, EmptyState, toast,
|
||||
} from '@/components/ds';
|
||||
import {
|
||||
useApplications, useInterviews, useJobPostings, useStaff, useUserActivity, useWorkerProfiles,
|
||||
} from '@/lib/krowHooks';
|
||||
import { buildFacts } from '@/components/ai-assistant/insights';
|
||||
import { AdminPage } from '@/components/admin/PageShell';
|
||||
import { SkillSurface } from '@/components/skills/SkillSurface';
|
||||
import { UiTreeRenderer } from '@/components/ui-tree/UiTreeRenderer';
|
||||
import { useUiEditing } from '@/components/ui-tree/UiEditingProvider';
|
||||
import { UiEditor } from '@/components/ui-editor/UiEditor';
|
||||
import { composePage } from '@/lib/ui/composition';
|
||||
import '@/components/ui-tree/nodeTypes';
|
||||
import '@/pages/admin/control-center/nodes';
|
||||
|
||||
/**
|
||||
* Control Center — the KROW Admin command centre.
|
||||
@@ -577,6 +577,10 @@ export default function ControlCenter() {
|
||||
toast.success('Operations snapshot exported');
|
||||
};
|
||||
|
||||
const context = useMemo(() => ({ navigate, range, setRange, applications, isLoading, postings, interviews, staff, profiles, activity, f, metrics, activitySeries, positionPerformance, strongest, weakestRole, actions, activePositions, topTalent, recent, exportSnapshot }), [
|
||||
navigate, range, setRange, applications, isLoading, postings, interviews, staff, profiles, activity, f, metrics, activitySeries, positionPerformance, strongest, weakestRole, actions, activePositions, topTalent, recent, exportSnapshot,
|
||||
]);
|
||||
|
||||
return (
|
||||
<AdminPage
|
||||
title="Control Center"
|
||||
@@ -587,397 +591,15 @@ export default function ControlCenter() {
|
||||
</Button>
|
||||
)}
|
||||
>
|
||||
<SkillSurface page="control-center" placement="after-header" />
|
||||
{/* 1 ── Operations snapshot */}
|
||||
<Snapshot metrics={metrics} loading={isLoading} />
|
||||
|
||||
{/* 2 ── Hiring activity */}
|
||||
<Band
|
||||
id="cc-activity"
|
||||
title="Hiring activity"
|
||||
meta={
|
||||
RANGES[range].step === 1
|
||||
? `Applications, screening, interviews and hires · last ${RANGES[range].days} days, daily`
|
||||
: `Applications, screening, interviews and hires · last ${RANGES[range].days} days, ${RANGES[range].step}-day totals`
|
||||
}
|
||||
action={
|
||||
<SegmentedToggle
|
||||
options={Object.entries(RANGES).map(([value, r]) => ({ value, label: r.label }))}
|
||||
value={range}
|
||||
onChange={setRange}
|
||||
size="sm"
|
||||
ariaLabel="Hiring activity range"
|
||||
/>
|
||||
}
|
||||
>
|
||||
<div className="rounded-xl border border-border bg-surface px-2 py-4">
|
||||
{isLoading ? (
|
||||
<Skeleton className="mx-2 h-[272px] rounded-lg" />
|
||||
) : f.total ? (
|
||||
<ResponsiveContainer width="100%" height={272}>
|
||||
<ComposedChart data={activitySeries} margin={{ top: 4, right: 12, left: 0, bottom: 0 }}>
|
||||
<defs>
|
||||
<linearGradient id="ccApplications" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stopColor={CHART_TONES.brand} stopOpacity={0.22} />
|
||||
<stop offset="100%" stopColor={CHART_TONES.brand} stopOpacity={0} />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
|
||||
<CartesianGrid strokeDasharray="3 3" stroke={CHART_TONES.grid} vertical={false} />
|
||||
<XAxis dataKey="label" {...AXIS_PROPS} interval="preserveStartEnd" minTickGap={20} />
|
||||
<YAxis {...AXIS_PROPS} allowDecimals={false} width={30} />
|
||||
<Tooltip content={<ChartTooltip />} />
|
||||
<Legend
|
||||
verticalAlign="top"
|
||||
align="right"
|
||||
height={28}
|
||||
iconType="circle"
|
||||
iconSize={8}
|
||||
wrapperStyle={{ fontSize: 12, color: CHART_TONES.axis }}
|
||||
/>
|
||||
|
||||
{/* Applications are the volume everything else is drawn from, so
|
||||
they take the filled area and the downstream stages are lines
|
||||
over it. */}
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="applications"
|
||||
name="Applications"
|
||||
stroke={CHART_TONES.brand}
|
||||
strokeWidth={2}
|
||||
fill="url(#ccApplications)"
|
||||
activeDot={{ r: 4, strokeWidth: 0 }}
|
||||
/>
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="screened"
|
||||
name="AI Screened"
|
||||
stroke={CHART_TONES.navy}
|
||||
strokeWidth={2}
|
||||
strokeDasharray="4 3"
|
||||
dot={false}
|
||||
activeDot={{ r: 4, strokeWidth: 0 }}
|
||||
/>
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="interviews"
|
||||
name="Interviews"
|
||||
stroke={CHART_TONES.navy}
|
||||
strokeWidth={1.5}
|
||||
strokeOpacity={0.55}
|
||||
dot={false}
|
||||
activeDot={{ r: 4, strokeWidth: 0 }}
|
||||
/>
|
||||
{/* Hires are sparse and are the outcome, so they take the accent
|
||||
and a bar — a line at this volume reads as flat. */}
|
||||
<Bar
|
||||
dataKey="hires"
|
||||
name="Hires"
|
||||
fill={CHART_TONES.accent}
|
||||
radius={[3, 3, 0, 0]}
|
||||
maxBarSize={14}
|
||||
/>
|
||||
</ComposedChart>
|
||||
</ResponsiveContainer>
|
||||
) : (
|
||||
<div className="px-2 py-8">
|
||||
<EmptyState
|
||||
title="No applications yet"
|
||||
description="Once candidates start applying, daily hiring activity appears here."
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Band>
|
||||
|
||||
{/* 3 ── Pipeline intelligence */}
|
||||
<Band
|
||||
id="cc-pipeline"
|
||||
title="Pipeline intelligence"
|
||||
meta={`${f.total} entered · conversion and drop-off by stage`}
|
||||
>
|
||||
<PipelineFunnel
|
||||
funnel={f.funnel}
|
||||
transitions={f.transitions}
|
||||
weakest={f.bottleneck && f.bottleneck.rate < WEAK_TRANSITION ? f.bottleneck : null}
|
||||
total={f.total}
|
||||
/>
|
||||
</Band>
|
||||
|
||||
{/* 4 ── Position performance */}
|
||||
<Band
|
||||
id="cc-positions"
|
||||
title="Position performance"
|
||||
meta={strongest ? `Strongest conversion: ${strongest}` : undefined}
|
||||
action={<ViewAll label="View all positions" onClick={() => navigate('/admin/positions')} />}
|
||||
>
|
||||
<div className="rounded-xl border border-border bg-surface px-2 py-4">
|
||||
{positionPerformance.length ? (
|
||||
<>
|
||||
<ResponsiveContainer width="100%" height={236}>
|
||||
<ComposedChart
|
||||
data={positionPerformance}
|
||||
barGap={3}
|
||||
margin={{ top: 4, right: 12, left: 0, bottom: 0 }}
|
||||
>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke={CHART_TONES.grid} vertical={false} />
|
||||
<XAxis
|
||||
dataKey="name"
|
||||
{...AXIS_PROPS}
|
||||
interval={0}
|
||||
tick={{ fontSize: 10, fill: CHART_TONES.axis }}
|
||||
/>
|
||||
<YAxis {...AXIS_PROPS} allowDecimals={false} width={30} />
|
||||
<Tooltip content={<ChartTooltip />} />
|
||||
<Legend
|
||||
verticalAlign="top"
|
||||
align="right"
|
||||
height={28}
|
||||
iconType="circle"
|
||||
iconSize={8}
|
||||
wrapperStyle={{ fontSize: 12, color: CHART_TONES.axis }}
|
||||
/>
|
||||
<Bar
|
||||
dataKey="applicants"
|
||||
name="Applicants"
|
||||
fill={CHART_TONES.brand}
|
||||
radius={[3, 3, 0, 0]}
|
||||
maxBarSize={24}
|
||||
/>
|
||||
<Bar dataKey="qualified" name="Qualified (70+)" radius={[3, 3, 0, 0]} maxBarSize={24}>
|
||||
{/* The best converter takes the accent at full strength and the
|
||||
worst is left pale, so both ends of the comparison are
|
||||
visible without a callout. */}
|
||||
{positionPerformance.map((r) => (
|
||||
<Cell
|
||||
key={r.name}
|
||||
fill={r.name === strongest ? CHART_TONES.accent
|
||||
: r.name === weakestRole ? CHART_TONES.mint : CHART_TONES.accentPale}
|
||||
/>
|
||||
))}
|
||||
</Bar>
|
||||
<Bar
|
||||
dataKey="hired"
|
||||
name="Hired"
|
||||
fill={CHART_TONES.navy}
|
||||
radius={[3, 3, 0, 0]}
|
||||
maxBarSize={24}
|
||||
/>
|
||||
</ComposedChart>
|
||||
</ResponsiveContainer>
|
||||
|
||||
{weakestRole && (
|
||||
<p className="px-2 pt-2 text-caption leading-relaxed text-ink-3">
|
||||
<span className="font-semibold text-ink-1">{strongest}</span> converts applicants
|
||||
into qualified candidates best;{' '}
|
||||
<span className="font-semibold text-ink-1">{weakestRole}</span> converts worst and
|
||||
is where sourcing — or the bar itself — is worth a look.
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<div className="px-2 py-8">
|
||||
<EmptyState
|
||||
title="No open positions"
|
||||
description="Publish a role and its hiring performance appears here."
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Band>
|
||||
|
||||
{/* 5 ── Action center */}
|
||||
<Band
|
||||
id="cc-actions"
|
||||
title="Action center"
|
||||
meta={actions.length ? `${actions.length} items, most consequential first` : 'All clear'}
|
||||
>
|
||||
<ActionQueue items={actions} />
|
||||
</Band>
|
||||
|
||||
{/* 6 / 7 ── Active positions and top talent. Two lists of comparable weight,
|
||||
so they share a row rather than each taking one. */}
|
||||
<div className="grid gap-6 xl:grid-cols-2">
|
||||
<Band
|
||||
id="cc-active"
|
||||
title="Active positions"
|
||||
meta={`${f.openPositions.length} hiring`}
|
||||
action={<ViewAll label="View all" onClick={() => navigate('/admin/positions')} />}
|
||||
>
|
||||
{activePositions.length ? (
|
||||
<ul className="divide-y divide-border overflow-hidden rounded-xl border border-border bg-surface">
|
||||
{activePositions.map((r) => {
|
||||
const coverage = r.applied ? Math.round((r.screened / r.applied) * 100) : 0;
|
||||
return (
|
||||
<li key={r.title}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => navigate('/admin/positions')}
|
||||
className="group flex w-full items-center gap-3 px-4 py-3 text-left transition-colors
|
||||
hover:bg-surface-subtle focus-visible:outline-none focus-visible:ring-2
|
||||
focus-visible:ring-inset focus-visible:ring-krow-blue/50"
|
||||
>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate text-body-sm font-medium text-ink-1">
|
||||
{r.title}
|
||||
</span>
|
||||
<span className="block truncate text-caption text-ink-3">
|
||||
{r.applied} applicant{r.applied === 1 ? '' : 's'} · {r.screened} screened
|
||||
{' · '}{r.hired} hired
|
||||
</span>
|
||||
</span>
|
||||
|
||||
{/* Screening coverage as a small bar — the one number that
|
||||
says whether this role is actually being worked. */}
|
||||
<span className="hidden w-16 shrink-0 sm:block">
|
||||
<span className="block h-1.5 overflow-hidden rounded-full bg-surface-sunken">
|
||||
<span
|
||||
className={cn(
|
||||
'block h-full rounded-full',
|
||||
coverage === 100 ? 'bg-success'
|
||||
: coverage >= 50 ? 'bg-krow-blue' : 'bg-warning'
|
||||
)}
|
||||
style={{ width: `${Math.max(coverage, r.applied ? 4 : 0)}%` }}
|
||||
/>
|
||||
</span>
|
||||
<span className="mt-1 block text-right text-[10px] tabular-nums text-ink-4">
|
||||
{coverage}%
|
||||
</span>
|
||||
</span>
|
||||
|
||||
<StatusBadge status={r.posting.status} size="sm" />
|
||||
<ChevronRight
|
||||
className="h-3.5 w-3.5 shrink-0 text-ink-4 transition-transform duration-base group-hover:translate-x-0.5"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
) : (
|
||||
<EmptyState title="No open positions" description="Publish a role to start hiring." />
|
||||
)}
|
||||
</Band>
|
||||
|
||||
<Band
|
||||
id="cc-talent"
|
||||
title="Top talent"
|
||||
meta={topTalent.length ? 'By KROW Score' : undefined}
|
||||
action={<ViewAll label="View all" onClick={() => navigate('/admin/candidates')} />}
|
||||
>
|
||||
{topTalent.length ? (
|
||||
<ul className="divide-y divide-border overflow-hidden rounded-xl border border-border bg-surface">
|
||||
{topTalent.map((c) => (
|
||||
<li key={c.id}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => navigate('/admin/candidates')}
|
||||
className="group flex w-full items-center gap-3 px-4 py-3 text-left transition-colors
|
||||
hover:bg-surface-subtle focus-visible:outline-none focus-visible:ring-2
|
||||
focus-visible:ring-inset focus-visible:ring-krow-blue/50"
|
||||
>
|
||||
{/* The seeded portrait where there is one, otherwise the design
|
||||
system's initials avatar. Never an invented face. */}
|
||||
<Avatar name={c.applicant_name} src={c.selfie_url} size="sm" />
|
||||
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate text-body-sm font-medium text-ink-1">
|
||||
{c.applicant_name}
|
||||
</span>
|
||||
<span className="block truncate text-caption text-ink-3">{c.job_title}</span>
|
||||
</span>
|
||||
|
||||
<span className="shrink-0 text-right">
|
||||
<span className="block font-heading text-body font-bold tabular-nums text-ink-1">
|
||||
{c.ai_score}
|
||||
</span>
|
||||
<span className="block text-[10px] text-ink-4">KROW</span>
|
||||
</span>
|
||||
|
||||
<StatusBadge status={c.status} size="sm" />
|
||||
<ChevronRight
|
||||
className="h-3.5 w-3.5 shrink-0 text-ink-4 transition-transform duration-base group-hover:translate-x-0.5"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : (
|
||||
<EmptyState
|
||||
title="Nobody scored yet"
|
||||
description="Screen the pipeline and the strongest candidates appear here."
|
||||
/>
|
||||
)}
|
||||
</Band>
|
||||
</div>
|
||||
|
||||
{/* 8 ── Recent activity */}
|
||||
<Band
|
||||
id="cc-recent"
|
||||
title="Recent activity"
|
||||
meta={`${activity.length} events logged`}
|
||||
action={<ViewAll label="View all" onClick={() => navigate('/admin/activity')} />}
|
||||
>
|
||||
<div className="overflow-hidden rounded-xl border border-border bg-surface">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full min-w-[36rem] border-collapse">
|
||||
<caption className="sr-only">Most recent platform activity</caption>
|
||||
<thead>
|
||||
<tr className="border-b border-border bg-surface-subtle">
|
||||
{['User', 'Action', 'Entity', 'Time', 'Role'].map((h) => (
|
||||
<th
|
||||
key={h}
|
||||
scope="col"
|
||||
className={cn(
|
||||
'whitespace-nowrap px-4 py-2.5 text-[10px] font-semibold uppercase tracking-wide text-ink-4',
|
||||
h === 'Role' ? 'text-right' : 'text-left'
|
||||
)}
|
||||
>
|
||||
{h}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{recent.map((event) => (
|
||||
<tr key={event.id} className="border-b border-border last:border-0 hover:bg-surface-subtle">
|
||||
<td className="px-4 py-2.5">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<Avatar name={event.user_name || event.user_email} size="xs" />
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-body-sm font-medium text-ink-1">
|
||||
{event.user_name || '—'}
|
||||
</p>
|
||||
<p className="truncate text-[10px] text-ink-4">{event.user_email}</p>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="whitespace-nowrap px-4 py-2.5 text-body-sm text-ink-2">
|
||||
{String(event.event_type).replace(/_/g, ' ')}
|
||||
</td>
|
||||
<td className="max-w-[24rem] px-4 py-2.5 text-body-sm text-ink-3">
|
||||
<span className="line-clamp-1">{event.details || '—'}</span>
|
||||
</td>
|
||||
<td className="whitespace-nowrap px-4 py-2.5 text-caption text-ink-4">
|
||||
{new Date(event.created_date).toLocaleDateString(undefined, {
|
||||
month: 'short', day: 'numeric',
|
||||
})}
|
||||
</td>
|
||||
<td className="px-4 py-2.5 text-right">
|
||||
<Badge variant={event.account_type === 'employer' ? 'info' : 'neutral'} size="sm">
|
||||
{event.account_type || 'unknown'}
|
||||
</Badge>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</Band>
|
||||
<SkillSurface page="control-center" placement="before-footer" />
|
||||
<UiEditor />
|
||||
<ControlCenterComposition context={context} />
|
||||
</AdminPage>
|
||||
);
|
||||
}
|
||||
|
||||
/** The composed page. */
|
||||
function ControlCenterComposition({ context }) {
|
||||
const editing = useUiEditing();
|
||||
const tree = editing?.tree || composePage('control-center').tree;
|
||||
return <UiTreeRenderer nodes={tree} context={context} />;
|
||||
}
|
||||
|
||||
@@ -1,14 +1,17 @@
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import { Award, Building2, CalendarDays, Clock, Mail, Phone, UserCheck } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import {
|
||||
Avatar, Badge, Button, DataTable, Drawer, EmptyState, FilterBar, MetricStrip, StatusBadge,
|
||||
Surface, Timeline,
|
||||
} from '@/components/ds';
|
||||
import { Mail, Phone, UserCheck } from 'lucide-react';
|
||||
import { Avatar, Badge, Drawer, StatusBadge, Surface } from '@/components/ds';
|
||||
import { useApplications, useJobPostings, useStaff } from '@/lib/krowHooks';
|
||||
import { AdminPage, SectionTitle } from '@/components/admin/PageShell';
|
||||
import { SkillSurface } from '@/components/skills/SkillSurface';
|
||||
import { hiresWithFill, summarise } from '@/lib/hiringRecords';
|
||||
import { UiTreeRenderer } from '@/components/ui-tree/UiTreeRenderer';
|
||||
import { useUiEditing } from '@/components/ui-tree/UiEditingProvider';
|
||||
import { UiEditor } from '@/components/ui-editor/UiEditor';
|
||||
import { composePage } from '@/lib/ui/composition';
|
||||
import { DAY, RANGES, formatDate } from '@/pages/admin/hired-history/nodes';
|
||||
import '@/components/ui-tree/nodeTypes';
|
||||
import '@/pages/admin/hired-history/nodes';
|
||||
|
||||
/**
|
||||
* Admin Hired History — the record of who was hired.
|
||||
@@ -29,20 +32,6 @@ import { hiresWithFill, summarise } from '@/lib/hiringRecords';
|
||||
* Both pages read `lib/hiringRecords.js`, so the totals cannot disagree.
|
||||
*/
|
||||
|
||||
/** 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 },
|
||||
];
|
||||
|
||||
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 (
|
||||
@@ -181,165 +170,39 @@ export default function AdminHiredHistory() {
|
||||
setFilters({ position: 'all', department: 'all', range: 'all' });
|
||||
};
|
||||
|
||||
/* What this page's sections read. Published once; the renderer passes it
|
||||
down untouched and never looks inside. */
|
||||
const context = useMemo(() => ({
|
||||
isLoading, hires, filtered, recent, summary, isFiltered,
|
||||
search, setSearch, filters, setFilters,
|
||||
positionOptions, departmentOptions, clearFilters, setSelected,
|
||||
}), [
|
||||
isLoading, hires, filtered, recent, summary, isFiltered,
|
||||
search, filters, positionOptions, departmentOptions,
|
||||
]);
|
||||
|
||||
return (
|
||||
<AdminPage
|
||||
title="Hired History"
|
||||
subtitle="Who was hired, for which position, and what has happened since."
|
||||
meta={`${hires.length} on record`}
|
||||
>
|
||||
<SkillSurface page="hired-history" placement="after-header" />
|
||||
<UiEditor />
|
||||
<HiredComposition context={context} />
|
||||
|
||||
{/* 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={3}
|
||||
loading={isLoading}
|
||||
items={[
|
||||
{
|
||||
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`,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
{/* 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>
|
||||
)}
|
||||
|
||||
{/* 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`}
|
||||
/>
|
||||
<DataTable
|
||||
loading={isLoading}
|
||||
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: 'Candidate', sortable: true,
|
||||
cell: (h) => (
|
||||
<div className="flex min-w-0 items-center gap-2.5">
|
||||
<Avatar name={h.name} size="sm" />
|
||||
<div className="min-w-0">
|
||||
<p className="truncate font-medium text-ink-1">{h.name}</p>
|
||||
<p className="truncate text-[10px] text-ink-4">{h.email}</p>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{ key: 'role', header: 'Position', sortable: true, hideBelow: 'md' },
|
||||
{
|
||||
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: '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>
|
||||
|
||||
{/* 5. One person, in full. */}
|
||||
{/* One person, in full. An overlay rather than a section of the page, so
|
||||
it stays outside the tree — there is nothing to reorder about a
|
||||
drawer. */}
|
||||
<HireDetail hire={selected} onClose={() => setSelected(null)} />
|
||||
</AdminPage>
|
||||
);
|
||||
}
|
||||
|
||||
/** The composed page. Split out so it can read the tree the provider computed. */
|
||||
function HiredComposition({ context }) {
|
||||
const editing = useUiEditing();
|
||||
/* Outside a layout — a test, a baseline capture — there is no session, and
|
||||
the page still has to draw. It falls back to the composition as shipped. */
|
||||
const tree = editing?.tree || composePage('hired-history').tree;
|
||||
return <UiTreeRenderer nodes={tree} context={context} />;
|
||||
}
|
||||
|
||||
@@ -19,6 +19,10 @@ import {
|
||||
PositionCustomRequirements, PositionOverview, PositionRequirements,
|
||||
} from '@/components/krow/PositionDetails';
|
||||
import { SkillSurface } from '@/components/skills/SkillSurface';
|
||||
import { UiNodeSlot } from '@/components/ui-tree/UiTreeRenderer';
|
||||
import { UiEditor } from '@/components/ui-editor/UiEditor';
|
||||
import '@/components/ui-tree/nodeTypes';
|
||||
import '@/pages/admin/positions/nodes';
|
||||
import { useAssistantPanel, usePublishPageContext } from '@/components/ai-assistant';
|
||||
import { continueDraftRequest } from '@/lib/skills/draftFlow';
|
||||
import { AdminPage, SectionTitle } from '@/components/admin/PageShell';
|
||||
@@ -1128,11 +1132,17 @@ export default function AdminPositions() {
|
||||
{isFiltered ? `${filtered.length} of ${rows.length} positions · filtered` : summary}
|
||||
</p>
|
||||
|
||||
{/* The editor for what this page composes. Positions is migrated at page
|
||||
level only, so the tree it offers is the two extension slots and
|
||||
whatever definitions have claimed them — which is exactly what a
|
||||
person needs in order to move or hide a Board card. */}
|
||||
<UiEditor />
|
||||
|
||||
{/* 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" />
|
||||
<UiNodeSlot page="positions" id="positions-extensions-summary" />
|
||||
|
||||
{/* Three columns on desktop, two on tablet, one on mobile. */}
|
||||
{isLoading ? (
|
||||
@@ -1173,7 +1183,7 @@ export default function AdminPositions() {
|
||||
/>
|
||||
)}
|
||||
|
||||
<SkillSurface page="positions" placement="after-position-list" className="mt-6" />
|
||||
<UiNodeSlot page="positions" id="positions-extensions-list" />
|
||||
|
||||
<PositionDrawer
|
||||
position={selected}
|
||||
|
||||
@@ -1,25 +1,19 @@
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import { Bookmark, Star, UserRound, Trophy, CheckCircle2, TrendingUp, Sparkles } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import {
|
||||
Avatar, Badge, DataTable, IconButton, ProgressRing, SearchInput, toast,
|
||||
} from '@/components/ds';
|
||||
import { toast } from '@/components/ds';
|
||||
import { useWorkerProfiles } from '@/lib/krowHooks';
|
||||
import { getScoreBand, toFICO } from '@/lib/talentHome';
|
||||
import { AdminPage, SectionTitle, Toolbar } from '@/components/admin/PageShell';
|
||||
import { FilterSelect } from '@/pages/admin/Positions';
|
||||
import { SkillSurface } from '@/components/skills/SkillSurface';
|
||||
import { AdminPage } from '@/components/admin/PageShell';
|
||||
import { UiTreeRenderer } from '@/components/ui-tree/UiTreeRenderer';
|
||||
import { useUiEditing } from '@/components/ui-tree/UiEditingProvider';
|
||||
import { UiEditor } from '@/components/ui-editor/UiEditor';
|
||||
import { composePage } from '@/lib/ui/composition';
|
||||
import { SORTS } from '@/pages/admin/talent-pool/nodes';
|
||||
import '@/components/ui-tree/nodeTypes';
|
||||
import '@/pages/admin/talent-pool/nodes';
|
||||
|
||||
/**
|
||||
* Admin Talent Pool — talent intelligence, not a card gallery.
|
||||
*/
|
||||
|
||||
const SORTS = {
|
||||
score: { label: 'Highest score', compare: (a, b) => (b.krow_score || 0) - (a.krow_score || 0) },
|
||||
experience: { label: 'Most experience', compare: (a, b) => (b.experience_years || 0) - (a.experience_years || 0) },
|
||||
name: { label: 'Name A–Z', compare: (a, b) => a.full_name.localeCompare(b.full_name) },
|
||||
recent: { label: 'Recently added', compare: (a, b) => new Date(b.created_date).getTime() - new Date(a.created_date).getTime() },
|
||||
};
|
||||
|
||||
const BANDS = {
|
||||
all: () => true,
|
||||
@@ -30,53 +24,6 @@ const BANDS = {
|
||||
unscored: (s) => !s,
|
||||
};
|
||||
|
||||
const SEGMENT_CONFIG = {
|
||||
Elite: {
|
||||
key: 'elite',
|
||||
icon: Trophy,
|
||||
bg: 'bg-blue-50/80 text-blue-600 dark:bg-blue-950/60 dark:text-blue-400',
|
||||
border: 'border-blue-200/50 dark:border-blue-800/40 hover:border-blue-400',
|
||||
activeBorder: 'border-blue-600 ring-2 ring-blue-500/20',
|
||||
text: 'text-blue-600 dark:text-blue-400',
|
||||
accent: 'from-blue-500 to-indigo-500',
|
||||
},
|
||||
Excellent: {
|
||||
key: 'excellent',
|
||||
icon: Star,
|
||||
bg: 'bg-emerald-50/80 text-emerald-600 dark:bg-emerald-950/60 dark:text-emerald-400',
|
||||
border: 'border-emerald-200/50 dark:border-emerald-800/40 hover:border-emerald-400',
|
||||
activeBorder: 'border-emerald-600 ring-2 ring-emerald-500/20',
|
||||
text: 'text-emerald-600 dark:text-emerald-400',
|
||||
accent: 'from-emerald-500 to-teal-500',
|
||||
},
|
||||
Solid: {
|
||||
key: 'solid',
|
||||
icon: CheckCircle2,
|
||||
bg: 'bg-indigo-50/80 text-indigo-600 dark:bg-indigo-950/60 dark:text-indigo-400',
|
||||
border: 'border-indigo-200/50 dark:border-indigo-800/40 hover:border-indigo-400',
|
||||
activeBorder: 'border-indigo-600 ring-2 ring-indigo-500/20',
|
||||
text: 'text-indigo-600 dark:text-indigo-400',
|
||||
accent: 'from-indigo-500 to-purple-500',
|
||||
},
|
||||
Building: {
|
||||
key: 'building',
|
||||
icon: TrendingUp,
|
||||
bg: 'bg-amber-50/80 text-amber-600 dark:bg-amber-950/60 dark:text-amber-400',
|
||||
border: 'border-amber-200/50 dark:border-amber-800/40 hover:border-amber-400',
|
||||
activeBorder: 'border-amber-600 ring-2 ring-amber-500/20',
|
||||
text: 'text-amber-600 dark:text-amber-400',
|
||||
accent: 'from-amber-500 to-orange-500',
|
||||
},
|
||||
Unscored: {
|
||||
key: 'unscored',
|
||||
icon: Sparkles,
|
||||
bg: 'bg-orange-50/80 text-orange-600 dark:bg-orange-950/60 dark:text-orange-400',
|
||||
border: 'border-orange-200/50 dark:border-orange-800/40 hover:border-orange-400',
|
||||
activeBorder: 'border-orange-600 ring-2 ring-orange-500/20',
|
||||
text: 'text-orange-600 dark:text-orange-400',
|
||||
accent: 'from-orange-500 to-red-500',
|
||||
},
|
||||
};
|
||||
|
||||
export default function AdminTalentPool() {
|
||||
const { data: profiles = [], isLoading } = useWorkerProfiles();
|
||||
@@ -159,214 +106,30 @@ export default function AdminTalentPool() {
|
||||
});
|
||||
};
|
||||
|
||||
const context = useMemo(() => ({
|
||||
profiles, segments, filtered, isLoading, isFiltered, clearFilters, saved, toggleSave,
|
||||
search, setSearch, skill, setSkill, experience, setExperience, location, setLocation,
|
||||
band, setBand, availability, setAvailability, sort, setSort,
|
||||
skills, locations, availabilities,
|
||||
}), [
|
||||
profiles, segments, filtered, isLoading, isFiltered, saved,
|
||||
search, skill, experience, location, band, availability, sort, skills, locations, availabilities,
|
||||
]);
|
||||
|
||||
return (
|
||||
<AdminPage
|
||||
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
|
||||
id="segments"
|
||||
title="Talent segments"
|
||||
meta={`${profiles.length} in the pool`}
|
||||
/>
|
||||
<div>
|
||||
<div className="grid grid-cols-1 gap-3.5 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-5">
|
||||
{segments.map((s) => {
|
||||
const cfg = SEGMENT_CONFIG[s.label] || SEGMENT_CONFIG.Elite;
|
||||
const Icon = cfg.icon;
|
||||
const isSelected = band === cfg.key;
|
||||
const empty = s.count === 0;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={s.label}
|
||||
onClick={() => setBand(isSelected ? 'all' : cfg.key)}
|
||||
className={cn(
|
||||
'group relative flex flex-col justify-between cursor-pointer rounded-xl border bg-surface p-4 shadow-xs backdrop-blur-xs transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md',
|
||||
isSelected ? cfg.activeBorder : cfg.border
|
||||
)}
|
||||
>
|
||||
<div>
|
||||
{/* Header Row: Label, Hint & Icon */}
|
||||
<div className="flex items-center justify-between gap-1.5">
|
||||
<div className="flex items-baseline gap-1.5 min-w-0">
|
||||
<span className="truncate text-[11px] font-bold uppercase tracking-wider text-ink-1">
|
||||
{s.label}
|
||||
</span>
|
||||
<span className="truncate text-[10px] font-medium text-ink-4">
|
||||
{s.hint}
|
||||
</span>
|
||||
</div>
|
||||
<div className={cn('flex h-7 w-7 shrink-0 items-center justify-center rounded-lg shadow-xs', cfg.bg)}>
|
||||
<Icon className="h-3.5 w-3.5" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Main count */}
|
||||
<div className="mt-2.5 flex items-baseline gap-1.5">
|
||||
<span
|
||||
className={cn(
|
||||
'font-heading text-title-xl font-bold leading-none tabular-nums',
|
||||
empty ? 'text-ink-4' : cfg.text
|
||||
)}
|
||||
>
|
||||
{s.count}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Sub pill & Accent bar */}
|
||||
<div className="mt-3.5 space-y-2">
|
||||
<span className="inline-flex items-center rounded-full bg-surface-subtle px-2 py-0.5 text-[10px] font-semibold text-ink-3 border border-border/60">
|
||||
{empty
|
||||
? 'No profiles'
|
||||
: [
|
||||
`${s.available} available`,
|
||||
s.label === 'Unscored'
|
||||
? 'needs verification'
|
||||
: s.avgExperience > 0
|
||||
? `${s.avgExperience}y avg`
|
||||
: null,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' · ')}
|
||||
</span>
|
||||
|
||||
<div className={cn('h-0.5 w-full rounded-full bg-gradient-to-r opacity-40 group-hover:opacity-100 transition-opacity', cfg.accent)} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<p className="mt-3 border-t border-border/60 pt-3 text-caption leading-relaxed text-ink-3">
|
||||
{segments[4].count > segments[0].count + segments[1].count
|
||||
? `${segments[4].count} workers carry no career score against ${segments[0].count + segments[1].count} at Excellent or above. Supply is not the constraint here — verification is.`
|
||||
: 'The scored part of the pool outweighs the unscored, so matching has enough to work with.'}
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<SectionTitle title="Talent directory" meta={`${profiles.length} profiles`} />
|
||||
<Toolbar
|
||||
search={<SearchInput value={search} onChange={setSearch} placeholder="Search name, role or skill" size="sm" />}
|
||||
filters={
|
||||
<>
|
||||
<FilterSelect value={skill} onChange={setSkill} label="Skills"
|
||||
options={[{ value: 'all', label: 'All skills' }, ...skills.map((s) => ({ value: s, label: s }))]} />
|
||||
<FilterSelect value={experience} onChange={setExperience} label="Experience" options={[
|
||||
{ value: 'all', label: 'Any experience' }, { value: '5plus', label: '5+ years' },
|
||||
{ value: '2to5', label: '2–5 years' }, { value: 'under2', label: 'Under 2 years' },
|
||||
]} />
|
||||
<FilterSelect value={location} onChange={setLocation} label="Location"
|
||||
options={[{ value: 'all', label: 'All locations' }, ...locations.map((l) => ({ value: l, label: l }))]} />
|
||||
<FilterSelect value={band} onChange={setBand} label="Score" options={[
|
||||
{ value: 'all', label: 'All scores' }, { value: 'elite', label: 'Elite 90+' },
|
||||
{ value: 'excellent', label: 'Excellent 75+' }, { value: 'solid', label: 'Solid 60+' },
|
||||
{ value: 'building', label: 'Building' }, { value: 'unscored', label: 'Unscored' },
|
||||
]} />
|
||||
<FilterSelect value={availability} onChange={setAvailability} label="Availability"
|
||||
options={[{ value: 'all', label: 'Any availability' }, ...availabilities.map((a) => ({ value: a, label: a }))]} />
|
||||
<FilterSelect value={sort} onChange={setSort} label="Sort"
|
||||
options={Object.entries(SORTS).map(([value, s]) => ({ value, label: s.label }))} />
|
||||
</>
|
||||
}
|
||||
meta={`${filtered.length} of ${profiles.length} talents${isFiltered ? ' · filtered' : ''}${saved.length ? ` · ${saved.length} saved` : ''}`}
|
||||
/>
|
||||
|
||||
<DataTable
|
||||
loading={isLoading}
|
||||
rows={filtered}
|
||||
pageSize={15}
|
||||
isFiltered={isFiltered}
|
||||
onClearFilters={clearFilters}
|
||||
caption="Talent pool ranked by career score"
|
||||
columns={[
|
||||
{
|
||||
key: 'full_name', header: 'Candidate', sortable: true,
|
||||
cell: (p) => (
|
||||
<div className="flex min-w-0 items-center gap-2.5">
|
||||
<Avatar name={p.full_name} src={p.selfie_url} size="sm" />
|
||||
<div className="min-w-0">
|
||||
<p className="truncate font-medium text-ink-1">{p.full_name}</p>
|
||||
<p className="truncate text-[10px] text-ink-4">{p.address || p.email}</p>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'role', header: 'Role', hideBelow: 'md',
|
||||
cell: (p) => (
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-ink-2">{p.current_position || p.desired_position || '—'}</p>
|
||||
{p.desired_position && p.current_position && (
|
||||
<p className="truncate text-[10px] text-ink-4">wants {p.desired_position}</p>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'skills', header: 'Skills', hideBelow: 'lg',
|
||||
cell: (p) => (p.skills?.length ? (
|
||||
<div className="flex items-center gap-1">
|
||||
<Badge variant="neutral" size="sm">{p.skills[0]}</Badge>
|
||||
{p.skills.length > 1 && <span className="text-[10px] text-ink-4">+{p.skills.length - 1}</span>}
|
||||
</div>
|
||||
) : <span className="text-[11px] text-ink-4">—</span>),
|
||||
},
|
||||
{
|
||||
key: 'experience_years', header: 'Exp', align: 'right', sortable: true, hideBelow: 'md',
|
||||
cell: (p) => (p.experience_years ? `${p.experience_years}y` : '—'),
|
||||
},
|
||||
{
|
||||
key: 'availability', header: 'Availability', hideBelow: 'lg',
|
||||
cell: (p) => (p.availability?.length
|
||||
? <span className="text-[11px] text-ink-3">{p.availability.join(', ')}</span>
|
||||
: <Badge variant="warning" size="sm">Not set</Badge>),
|
||||
},
|
||||
{
|
||||
key: 'krow_score', header: 'Career score', align: 'right', sortable: true,
|
||||
cell: (p) => {
|
||||
const score = p.krow_score || 0;
|
||||
const bandInfo = getScoreBand(score);
|
||||
return (
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<div className="text-right">
|
||||
<p className="font-heading text-body font-bold tabular-nums text-ink-1">{toFICO(score)}</p>
|
||||
<p className="text-[10px] text-ink-4">{bandInfo.label}</p>
|
||||
</div>
|
||||
<ProgressRing value={score} size={26} strokeWidth={3} tone="score" label="" />
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'actions', header: '', align: 'right', width: 92,
|
||||
cell: (p) => (
|
||||
<div className="flex items-center justify-end gap-0.5" onClick={(e) => e.stopPropagation()} role="presentation">
|
||||
<IconButton
|
||||
icon={saved.includes(p.id) ? Star : Bookmark}
|
||||
label={saved.includes(p.id) ? `Remove ${p.full_name} from saved` : `Save ${p.full_name}`}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => toggleSave(p.id, p.full_name)}
|
||||
/>
|
||||
<IconButton
|
||||
icon={UserRound}
|
||||
label={`View ${p.full_name}'s profile`}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => toast.info(`Opening ${p.full_name}'s KROW Identity`)}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
<SkillSurface page="talent-pool" placement="before-footer" />
|
||||
<UiEditor />
|
||||
<TalentPoolComposition context={context} />
|
||||
</AdminPage>
|
||||
);
|
||||
}
|
||||
|
||||
/** The composed page. */
|
||||
function TalentPoolComposition({ context }) {
|
||||
const editing = useUiEditing();
|
||||
const tree = editing?.tree || composePage('talent-pool').tree;
|
||||
return <UiTreeRenderer nodes={tree} context={context} />;
|
||||
}
|
||||
|
||||
313
src/pages/admin/activity/nodes.jsx
Normal file
313
src/pages/admin/activity/nodes.jsx
Normal file
@@ -0,0 +1,313 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
Activity as ActivityIcon, FilePlus2, LogIn, LogOut, Mic, ScanSearch, Send, ShieldAlert,
|
||||
UserCheck, UserPlus, Users,
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
Avatar, Badge, DataTable, MetricStrip, SearchInput, Surface, Timeline,
|
||||
} from '@/components/ds';
|
||||
import { SectionTitle, Toolbar } from '@/components/admin/PageShell';
|
||||
import { FilterSelect } from '@/pages/admin/Positions';
|
||||
import { registerNodeType } from '@/lib/ui/registry';
|
||||
import { registerPageComposition } from '@/lib/ui/composition';
|
||||
import { useUiContext } from '@/components/ui-tree/UiTreeRenderer';
|
||||
|
||||
/**
|
||||
* The Activity page, as addressable nodes.
|
||||
*
|
||||
* This is the first page composed through the UI node system, and it is the
|
||||
* pattern every other page follows. Nothing about the sections changed: the
|
||||
* markup below was moved here verbatim from `Activity.jsx`, which is what lets
|
||||
* the page render exactly as it did while becoming something a person can
|
||||
* reorder and hide.
|
||||
*
|
||||
* Two things are worth understanding before copying the pattern.
|
||||
*
|
||||
* **The page still owns its state.** Filters, the loading flag, the derived
|
||||
* rows — all of it stays in the page component and is published once through
|
||||
* `useUiContext`. A section reads what it needs. The renderer never looks
|
||||
* inside the bag, so it stays ignorant of what any page contains.
|
||||
*
|
||||
* **Each section is its own node type.** They are singletons — there is no
|
||||
* sense in which a page could have two audit logs — so they declare only the
|
||||
* capabilities that mean something for a built-in: `move` and `hide`. A page
|
||||
* section is not addable, removable or replaceable, and saying so in the
|
||||
* registration is what stops the engine from ever offering it.
|
||||
*/
|
||||
|
||||
/** Severity carried through to the timeline node, so the two views agree. */
|
||||
const TIMELINE_TONE = { high: 'destructive', medium: 'warning', low: 'neutral' };
|
||||
|
||||
const SEVERITY_META = {
|
||||
high: { label: 'High', variant: 'destructive' },
|
||||
medium: { label: 'Medium', variant: 'warning' },
|
||||
low: { label: 'Low', variant: 'neutral' },
|
||||
};
|
||||
|
||||
/**
|
||||
* An icon per event type. Worth the table: on a chronology the glyph is what
|
||||
* makes a run of hires distinguishable from a run of logins at a glance, which is
|
||||
* the whole reason to show a timeline rather than another list of rows.
|
||||
*/
|
||||
const EVENT_ICON = {
|
||||
hire_candidate: UserCheck,
|
||||
assign_employee: Users,
|
||||
create_position: FilePlus2,
|
||||
screen_candidate: ScanSearch,
|
||||
start_interview: Mic,
|
||||
apply_job: Send,
|
||||
signup: UserPlus,
|
||||
login: LogIn,
|
||||
logout: LogOut,
|
||||
};
|
||||
|
||||
/** The counted figures across the top. */
|
||||
function ActivitySummary() {
|
||||
const { isLoading, events, last24h, highRisk, users } = useUiContext();
|
||||
return (
|
||||
<MetricStrip
|
||||
columns={4}
|
||||
loading={isLoading}
|
||||
items={[
|
||||
{ label: 'Total events', value: events.length },
|
||||
{ label: 'Last 24 hours', value: last24h.length, tone: last24h.length ? 'default' : 'warning' },
|
||||
{ label: 'Privileged actions', value: highRisk.length, tone: 'warning', sub: 'hires and position changes' },
|
||||
{ label: 'Distinct users', value: users.length },
|
||||
]}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/** The privileged-action notice. Absent, rather than empty, when there are none. */
|
||||
function ActivityPrivilegedNotice({ attrs = {} }) {
|
||||
const { highRisk } = useUiContext();
|
||||
if (!highRisk.length) return null;
|
||||
|
||||
return (
|
||||
<Surface variant="solid" radius="lg" padding="sm" elevation="xs" className="border-warning/30 bg-warning-muted" {...attrs}>
|
||||
<div className="flex items-start gap-2.5">
|
||||
<ShieldAlert className="mt-0.5 h-4 w-4 shrink-0 text-warning" aria-hidden="true" />
|
||||
<p className="text-body-sm text-ink-2">
|
||||
<span className="font-semibold text-ink-1">{highRisk.length} privileged actions</span> in this log —
|
||||
hires and position changes. These should always trace to a named person.
|
||||
</p>
|
||||
</div>
|
||||
</Surface>
|
||||
);
|
||||
}
|
||||
|
||||
/* The recent timeline. Deliberately above the log and deliberately not
|
||||
filtered: this answers "what just happened", which is a different
|
||||
question from the one the toolbar below exists to ask. Reading a
|
||||
chronology is also how an auditor starts — sequence first, then
|
||||
interrogate the specifics. */
|
||||
function ActivityTimeline({ attrs = {} }) {
|
||||
const { recent, byDay } = useUiContext();
|
||||
|
||||
return (
|
||||
<section aria-labelledby="timeline" className="space-y-3" {...attrs}>
|
||||
<SectionTitle
|
||||
id="timeline"
|
||||
title="Operational timeline"
|
||||
meta={`Most recent ${recent.length} events`}
|
||||
/>
|
||||
<Surface variant="solid" radius="lg" padding="none" elevation="xs" className="overflow-hidden">
|
||||
{recent.length ? (
|
||||
<div className="divide-y divide-border">
|
||||
{byDay.map(([day, dayEvents]) => (
|
||||
<div key={day} className="px-4 py-3.5">
|
||||
<p className="mb-3 text-[10px] font-semibold uppercase tracking-wide text-ink-4">
|
||||
{day}
|
||||
</p>
|
||||
<Timeline
|
||||
compact
|
||||
items={dayEvents.map((e) => ({
|
||||
id: e.id,
|
||||
icon: EVENT_ICON[e.event_type] || ActivityIcon,
|
||||
tone: TIMELINE_TONE[e.severity],
|
||||
title: e.event_type.replace(/_/g, ' '),
|
||||
description: e.details || undefined,
|
||||
meta: `${e.user_name || e.user_email}${e.account_type ? ` · ${e.account_type}` : ''}`,
|
||||
timestamp: new Date(e.created_date).toLocaleTimeString(undefined, {
|
||||
hour: 'numeric', minute: '2-digit',
|
||||
}),
|
||||
}))}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="px-4 py-8 text-center text-body-sm text-ink-3">
|
||||
No activity has been logged yet.
|
||||
</p>
|
||||
)}
|
||||
</Surface>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
/** The filterable audit log. */
|
||||
function ActivityAuditLog({ attrs = {} }) {
|
||||
const {
|
||||
isLoading, events, filtered, users, actions, isFiltered, clearFilters,
|
||||
search, setSearch, user, setUser, action, setAction, severity, setSeverity, range, setRange,
|
||||
} = useUiContext();
|
||||
|
||||
return (
|
||||
<section aria-labelledby="audit" className="space-y-3" {...attrs}>
|
||||
<SectionTitle id="audit" title="Audit log" meta={`${filtered.length} of ${events.length} events`} />
|
||||
|
||||
<Toolbar
|
||||
search={<SearchInput value={search} onChange={setSearch} placeholder="Search events" size="sm" />}
|
||||
filters={
|
||||
<>
|
||||
<FilterSelect value={user} onChange={setUser} label="User"
|
||||
options={[{ value: 'all', label: 'All users' }, ...users.map((u) => ({ value: u, label: u }))]} />
|
||||
<FilterSelect value={action} onChange={setAction} label="Action"
|
||||
options={[{ value: 'all', label: 'All actions' }, ...actions.map((a) => ({ value: a, label: a.replace(/_/g, ' ') }))]} />
|
||||
<FilterSelect value={severity} onChange={setSeverity} label="Severity" options={[
|
||||
{ value: 'all', label: 'All severities' }, { value: 'high', label: 'High' },
|
||||
{ value: 'medium', label: 'Medium' }, { value: 'low', label: 'Low' },
|
||||
]} />
|
||||
<FilterSelect value={range} onChange={setRange} label="Date" options={[
|
||||
{ value: 'all', label: 'All time' }, { value: '24h', label: 'Last 24 hours' },
|
||||
{ value: '7d', label: 'Last 7 days' }, { value: '30d', label: 'Last 30 days' },
|
||||
]} />
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
<DataTable
|
||||
loading={isLoading}
|
||||
rows={filtered}
|
||||
pageSize={20}
|
||||
isFiltered={isFiltered}
|
||||
onClearFilters={clearFilters}
|
||||
caption="Platform audit log"
|
||||
rowClassName={(e) => (e.severity === 'high' ? 'bg-warning-muted/40' : undefined)}
|
||||
columns={[
|
||||
{
|
||||
key: 'user_name', header: 'User', sortable: true,
|
||||
cell: (e) => (
|
||||
<div className="flex min-w-0 items-center gap-2.5">
|
||||
<Avatar name={e.user_name || e.user_email} size="xs" />
|
||||
<div className="min-w-0">
|
||||
<p className="truncate font-medium text-ink-1">{e.user_name || '—'}</p>
|
||||
<p className="truncate text-[10px] text-ink-4">{e.user_email}</p>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'event_type', header: 'Action', sortable: true,
|
||||
cell: (e) => <span className="whitespace-nowrap">{e.event_type.replace(/_/g, ' ')}</span>,
|
||||
},
|
||||
{
|
||||
key: 'details', header: 'Entity', hideBelow: 'md',
|
||||
cell: (e) => <span className="line-clamp-1 text-ink-3">{e.details || '—'}</span>,
|
||||
},
|
||||
{
|
||||
key: 'account_type', header: 'Role', hideBelow: 'lg',
|
||||
cell: (e) => <Badge variant={e.account_type === 'employer' ? 'info' : 'neutral'} size="sm">{e.account_type || 'unknown'}</Badge>,
|
||||
},
|
||||
{
|
||||
key: 'ip', header: 'Source IP', hideBelow: 'lg',
|
||||
cell: (e) => <span className="font-mono text-[11px] text-ink-4">{e.ip}</span>,
|
||||
},
|
||||
{
|
||||
key: 'created_date', header: 'Timestamp', align: 'right', sortable: true,
|
||||
sortValue: (e) => new Date(e.created_date).getTime(),
|
||||
cell: (e) => (
|
||||
<span className="whitespace-nowrap text-[11px] text-ink-4">
|
||||
{new Date(e.created_date).toLocaleString(undefined, {
|
||||
month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit',
|
||||
})}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'severity', header: 'Severity', align: 'right',
|
||||
cell: (e) => (
|
||||
<Badge variant={SEVERITY_META[e.severity].variant} size="sm">
|
||||
{SEVERITY_META[e.severity].label}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Registration ───────────────────────────────────────────────────────────
|
||||
A page section is a singleton: `move` and `hide` are the only operations that
|
||||
mean anything for one, and declaring exactly those is what makes the engine
|
||||
refuse the rest without knowing what an audit log is. */
|
||||
|
||||
const SECTION = ['move', 'hide'];
|
||||
|
||||
registerNodeType({
|
||||
type: 'activity-summary',
|
||||
/* This page's own section: it reads what this page publishes, so it belongs
|
||||
nowhere else. See `page` in the registry. */
|
||||
page: 'activity',
|
||||
label: 'Activity summary',
|
||||
summary: 'Total events, recent volume, privileged actions and distinct users.',
|
||||
component: ActivitySummary,
|
||||
capabilities: SECTION,
|
||||
/* `MetricStrip` destructures its props, so it cannot carry the node's
|
||||
identity itself. The renderer supplies a bare wrapper instead — no styling,
|
||||
no layout of its own, and the page's own vertical rhythm applies to it
|
||||
exactly as it applied to the grid before. */
|
||||
wrap: true,
|
||||
});
|
||||
|
||||
registerNodeType({
|
||||
type: 'activity-privileged-notice',
|
||||
/* This page's own section: it reads what this page publishes, so it belongs
|
||||
nowhere else. See `page` in the registry. */
|
||||
page: 'activity',
|
||||
label: 'Privileged actions notice',
|
||||
summary: 'A warning banner when the log contains hires or position changes.',
|
||||
component: ActivityPrivilegedNotice,
|
||||
capabilities: SECTION,
|
||||
});
|
||||
|
||||
registerNodeType({
|
||||
type: 'activity-timeline',
|
||||
/* This page's own section: it reads what this page publishes, so it belongs
|
||||
nowhere else. See `page` in the registry. */
|
||||
page: 'activity',
|
||||
label: 'Operational timeline',
|
||||
summary: 'The most recent events, grouped by day.',
|
||||
component: ActivityTimeline,
|
||||
capabilities: SECTION,
|
||||
});
|
||||
|
||||
registerNodeType({
|
||||
type: 'activity-audit-log',
|
||||
/* This page's own section: it reads what this page publishes, so it belongs
|
||||
nowhere else. See `page` in the registry. */
|
||||
page: 'activity',
|
||||
label: 'Audit log',
|
||||
summary: 'The filterable table of every recorded event.',
|
||||
component: ActivityAuditLog,
|
||||
capabilities: SECTION,
|
||||
});
|
||||
|
||||
/**
|
||||
* The page as it ships.
|
||||
*
|
||||
* The order here is the order on screen, and the ids are the page's stable
|
||||
* addresses. `timeline` and `audit` are the ids the headings already carried in
|
||||
* the DOM, so a node's identity is continuous with what the page has always
|
||||
* said about itself rather than a second naming invented alongside it.
|
||||
*/
|
||||
registerPageComposition('activity', [
|
||||
{ id: 'activity-extensions-top', type: 'skill-surface', props: { page: 'activity', placement: 'after-header' } },
|
||||
{ id: 'activity-summary', type: 'activity-summary' },
|
||||
{ id: 'activity-privileged-notice', type: 'activity-privileged-notice' },
|
||||
{ id: 'timeline', type: 'activity-timeline' },
|
||||
{ id: 'audit', type: 'activity-audit-log' },
|
||||
{ id: 'activity-extensions-bottom', type: 'skill-surface', props: { page: 'activity', placement: 'before-footer' } },
|
||||
]);
|
||||
424
src/pages/admin/analytics/nodes.jsx
Normal file
424
src/pages/admin/analytics/nodes.jsx
Normal file
@@ -0,0 +1,424 @@
|
||||
import React 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 { DepartmentPerformance } from '@/components/charts/DepartmentPerformance';
|
||||
import { HiringFlow } from '@/components/charts/HiringFlow';
|
||||
import { HiringTrendChart } from '@/components/charts/HiringTrendChart';
|
||||
import { useSize } from '@/hooks/use-size';
|
||||
import { SectionTitle } from '@/components/admin/PageShell';
|
||||
|
||||
|
||||
/**
|
||||
* Analytics, as addressable nodes.
|
||||
*
|
||||
* Seven readings, moved verbatim. Each keeps the id its heading already carried
|
||||
* in the DOM, so what a person names in conversation and what the markup says
|
||||
* are the same string.
|
||||
*/
|
||||
import { registerNodeType } from '@/lib/ui/registry';
|
||||
import { registerPageComposition } from '@/lib/ui/composition';
|
||||
import { useUiContext } from '@/components/ui-tree/UiTreeRenderer';
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
function AnalyticsPerformance({ attrs = {} }) {
|
||||
const { summary, funnel, trend, departments, positions, efficiency, insights, hires, isLoading, applications } = useUiContext();
|
||||
return (
|
||||
<section aria-labelledby="performance" className="space-y-3" {...attrs}>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
function AnalyticsFunnel({ attrs = {} }) {
|
||||
const { summary, funnel, trend, departments, positions, efficiency, insights, hires, isLoading, applications } = useUiContext();
|
||||
return (
|
||||
<section aria-labelledby="funnel" className="space-y-3" {...attrs}>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
function AnalyticsTrend({ attrs = {} }) {
|
||||
const { summary, funnel, trend, departments, positions, efficiency, insights, hires, isLoading, applications } = useUiContext();
|
||||
return (
|
||||
<section aria-labelledby="trend" className="space-y-3" {...attrs}>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
function AnalyticsDepartments({ attrs = {} }) {
|
||||
const { summary, funnel, trend, departments, positions, efficiency, insights, hires, isLoading, applications } = useUiContext();
|
||||
return (
|
||||
<section aria-labelledby="departments" className="space-y-3" {...attrs}>
|
||||
<SectionTitle
|
||||
id="departments"
|
||||
title="Department performance"
|
||||
meta={`${departments.length} department${departments.length === 1 ? '' : 's'} compared`}
|
||||
/>
|
||||
<DepartmentPerformance items={departments} />
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function AnalyticsPositions({ attrs = {} }) {
|
||||
const { summary, funnel, trend, departments, positions, efficiency, insights, hires, isLoading, applications } = useUiContext();
|
||||
return (
|
||||
<section aria-labelledby="positions" className="space-y-3" {...attrs}>
|
||||
<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 min-w-[36rem] 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>
|
||||
);
|
||||
}
|
||||
|
||||
function AnalyticsEfficiency({ attrs = {} }) {
|
||||
const { summary, funnel, trend, departments, positions, efficiency, insights, hires, isLoading, applications } = useUiContext();
|
||||
return (
|
||||
<section aria-labelledby="efficiency" className="space-y-3" {...attrs}>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
function AnalyticsInsights({ attrs = {} }) {
|
||||
const { summary, funnel, trend, departments, positions, efficiency, insights, hires, isLoading, applications } = useUiContext();
|
||||
return (
|
||||
<section aria-labelledby="insights" className="space-y-3" {...attrs}>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
const SECTION = ['move', 'hide'];
|
||||
|
||||
registerNodeType({
|
||||
type: 'analytics-performance',
|
||||
/* This page's own section: it reads what this page publishes, so it belongs
|
||||
nowhere else. See `page` in the registry. */
|
||||
page: 'analytics',
|
||||
label: 'Hiring performance',
|
||||
component: AnalyticsPerformance,
|
||||
capabilities: SECTION,
|
||||
});
|
||||
|
||||
registerNodeType({
|
||||
type: 'analytics-funnel',
|
||||
/* This page's own section: it reads what this page publishes, so it belongs
|
||||
nowhere else. See `page` in the registry. */
|
||||
page: 'analytics',
|
||||
label: 'Hiring funnel',
|
||||
component: AnalyticsFunnel,
|
||||
capabilities: SECTION,
|
||||
});
|
||||
|
||||
registerNodeType({
|
||||
type: 'analytics-trend',
|
||||
/* This page's own section: it reads what this page publishes, so it belongs
|
||||
nowhere else. See `page` in the registry. */
|
||||
page: 'analytics',
|
||||
label: 'Hiring trend',
|
||||
component: AnalyticsTrend,
|
||||
capabilities: SECTION,
|
||||
});
|
||||
|
||||
registerNodeType({
|
||||
type: 'analytics-departments',
|
||||
/* This page's own section: it reads what this page publishes, so it belongs
|
||||
nowhere else. See `page` in the registry. */
|
||||
page: 'analytics',
|
||||
label: 'Departments',
|
||||
component: AnalyticsDepartments,
|
||||
capabilities: SECTION,
|
||||
});
|
||||
|
||||
registerNodeType({
|
||||
type: 'analytics-positions',
|
||||
/* This page's own section: it reads what this page publishes, so it belongs
|
||||
nowhere else. See `page` in the registry. */
|
||||
page: 'analytics',
|
||||
label: 'Position comparison',
|
||||
component: AnalyticsPositions,
|
||||
capabilities: SECTION,
|
||||
});
|
||||
|
||||
registerNodeType({
|
||||
type: 'analytics-efficiency',
|
||||
/* This page's own section: it reads what this page publishes, so it belongs
|
||||
nowhere else. See `page` in the registry. */
|
||||
page: 'analytics',
|
||||
label: 'Hiring efficiency',
|
||||
component: AnalyticsEfficiency,
|
||||
capabilities: SECTION,
|
||||
});
|
||||
|
||||
registerNodeType({
|
||||
type: 'analytics-insights',
|
||||
/* This page's own section: it reads what this page publishes, so it belongs
|
||||
nowhere else. See `page` in the registry. */
|
||||
page: 'analytics',
|
||||
label: 'Insights',
|
||||
component: AnalyticsInsights,
|
||||
capabilities: SECTION,
|
||||
});
|
||||
|
||||
registerPageComposition('analytics', [
|
||||
{ id: 'analytics-extensions-top', type: 'skill-surface', props: { page: 'analytics', placement: 'after-header' } },
|
||||
{ id: 'performance', type: 'analytics-performance' },
|
||||
{ id: 'funnel', type: 'analytics-funnel' },
|
||||
{ id: 'trend', type: 'analytics-trend' },
|
||||
{ id: 'departments', type: 'analytics-departments' },
|
||||
{ id: 'positions', type: 'analytics-positions' },
|
||||
{ id: 'efficiency', type: 'analytics-efficiency' },
|
||||
{ id: 'insights', type: 'analytics-insights' },
|
||||
{ id: 'analytics-extensions-bottom', type: 'skill-surface', props: { page: 'analytics', placement: 'before-footer' } },
|
||||
]);
|
||||
415
src/pages/admin/candidates-analysis/nodes.jsx
Normal file
415
src/pages/admin/candidates-analysis/nodes.jsx
Normal file
@@ -0,0 +1,415 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
Bar, BarChart, CartesianGrid, Cell, Pie, PieChart, ResponsiveContainer, Tooltip, XAxis, YAxis,
|
||||
} from 'recharts';
|
||||
import { ArrowRight } from 'lucide-react';
|
||||
import {
|
||||
AXIS_PROPS, Avatar, Badge, Button, CHART_TONES, ChartTooltip, InsightList, InsightRow,
|
||||
MetricStrip, ProgressBar, ProgressRing, Surface,
|
||||
} from '@/components/ds';
|
||||
import { SectionTitle } from '@/components/admin/PageShell';
|
||||
|
||||
/**
|
||||
* Admin Candidates Analysis — the analytical counterpart to Candidates.
|
||||
*
|
||||
* Candidates is for working through people one by one; this page is for reading
|
||||
* the pool as a whole: supply, quality distribution, and where the risk sits.
|
||||
* It is one of the three pages with the contextual Owliver panel, because these
|
||||
* are the questions worth asking in language rather than filters.
|
||||
*/
|
||||
import { registerNodeType } from '@/lib/ui/registry';
|
||||
import { registerPageComposition } from '@/lib/ui/composition';
|
||||
import { useUiContext } from '@/components/ui-tree/UiTreeRenderer';
|
||||
|
||||
/**
|
||||
* Candidates Analysis, as addressable nodes.
|
||||
*
|
||||
* Two of its readings are laid out side by side inside a grid, so each grid is
|
||||
* one node holding both — splitting them would let somebody move half a pair
|
||||
* out of its own layout.
|
||||
*/
|
||||
|
||||
function CASupply({ attrs = {} }) {
|
||||
const { navigate, applications, postings, interviews, staff, profiles, f, bands, risks, top, skillSupply, skillGaps, positionFit, recommendations } = useUiContext();
|
||||
return (
|
||||
<>
|
||||
<MetricStrip
|
||||
columns={5}
|
||||
items={[
|
||||
{ label: 'In pipeline', value: f.total },
|
||||
{ label: 'Scored', value: f.scored.length, sub: `${f.standardizedPct}% coverage`, tone: f.standardizedPct >= 80 ? 'success' : 'warning' },
|
||||
{ label: 'At 80+', value: f.ranked.filter((a) => a.ai_score >= 80).length, tone: 'brand' },
|
||||
{ label: 'Avg score', value: f.avgScore || '—' },
|
||||
{ label: 'Risk flags', value: risks.length, tone: risks.length ? 'warning' : 'success' },
|
||||
]}
|
||||
/>
|
||||
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function CADistribution({ attrs = {} }) {
|
||||
const { navigate, applications, postings, interviews, staff, profiles, f, bands, risks, top, skillSupply, skillGaps, positionFit, recommendations } = useUiContext();
|
||||
return (
|
||||
<>
|
||||
<div className="grid gap-6 lg:grid-cols-5">
|
||||
<section aria-labelledby="dist" className="space-y-3 lg:col-span-2">
|
||||
<SectionTitle id="dist" title="Quality distribution" meta={`${f.total} candidates`} />
|
||||
<ResponsiveContainer width="100%" height={200}>
|
||||
<PieChart>
|
||||
<Pie data={bands} dataKey="value" nameKey="name" innerRadius={48} outerRadius={78} paddingAngle={2} strokeWidth={0}>
|
||||
{bands.map((b) => <Cell key={b.name} fill={b.color} />)}
|
||||
</Pie>
|
||||
<Tooltip content={<ChartTooltip />} />
|
||||
</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" />
|
||||
<span className="flex-1 text-ink-3">{b.name}</span>
|
||||
<span className="font-semibold tabular-nums text-ink-1">{b.value}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section aria-labelledby="strongest" className="space-y-3 lg:col-span-3">
|
||||
<SectionTitle
|
||||
id="strongest"
|
||||
title="Strongest candidates"
|
||||
meta="By AI score"
|
||||
action={{ label: 'All candidates', onClick: () => navigate('/admin/candidates') }}
|
||||
/>
|
||||
<Surface variant="solid" radius="lg" padding="none" elevation="xs" className="divide-y divide-border overflow-hidden">
|
||||
{top.length ? top.map((c, i) => (
|
||||
<div key={c.id} className="flex items-center gap-3 px-4 py-2.5">
|
||||
<span className="w-4 shrink-0 text-caption font-bold text-ink-4">{i + 1}</span>
|
||||
<Avatar name={c.applicant_name} src={c.selfie_url} size="sm" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-body-sm font-medium text-ink-1">{c.applicant_name}</p>
|
||||
<p className="truncate text-[10px] text-ink-4">{c.job_title}</p>
|
||||
</div>
|
||||
{c.ai_match_label && (
|
||||
<Badge variant="soft" size="sm" className="hidden sm:inline-flex">{c.ai_match_label}</Badge>
|
||||
)}
|
||||
<div className="w-20 shrink-0">
|
||||
<ProgressBar value={c.ai_score} tone="score" size="xs" />
|
||||
</div>
|
||||
<ProgressRing value={c.ai_score} size={30} strokeWidth={3} tone="score" />
|
||||
</div>
|
||||
)) : (
|
||||
<p className="px-4 py-8 text-center text-body-sm text-ink-3">No scored candidates yet.</p>
|
||||
)}
|
||||
</Surface>
|
||||
</section>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function CARisk({ attrs = {} }) {
|
||||
const { navigate, applications, postings, interviews, staff, profiles, f, bands, risks, top, skillSupply, skillGaps, positionFit, recommendations } = useUiContext();
|
||||
return (
|
||||
<section aria-labelledby="risk" className="space-y-3" {...attrs}>
|
||||
<SectionTitle id="risk" title="Candidate risk" meta={risks.length ? `${risks.length} flags` : 'No flags'} />
|
||||
<Surface variant="solid" radius="lg" padding="none" elevation="xs" className="overflow-hidden">
|
||||
{risks.length ? (
|
||||
<InsightList>{risks.map((r, i) => <InsightRow key={i} {...r} />)}</InsightList>
|
||||
) : (
|
||||
<p className="px-4 py-8 text-center text-body-sm text-ink-3">
|
||||
No material risk flags. Credentials, availability and interview integrity all check out.
|
||||
</p>
|
||||
)}
|
||||
</Surface>
|
||||
<p className="text-caption text-ink-4">
|
||||
Flags are questions for a human, not rejections.
|
||||
</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function CASkills({ attrs = {} }) {
|
||||
const { navigate, applications, postings, interviews, staff, profiles, f, bands, risks, top, skillSupply, skillGaps, positionFit, recommendations } = useUiContext();
|
||||
return (
|
||||
<>
|
||||
{/* Skill supply and the gaps in it, side by side: what the pool has, and
|
||||
what the open roles ask for and nobody offers. */}
|
||||
<div className="grid gap-6 lg:grid-cols-5">
|
||||
<section aria-labelledby="skills" className="space-y-3 lg:col-span-3">
|
||||
<SectionTitle
|
||||
id="skills"
|
||||
title="Skill supply"
|
||||
meta={`Top ${skillSupply.length} across the scored pool`}
|
||||
/>
|
||||
{skillSupply.length ? (
|
||||
<ResponsiveContainer width="100%" height={Math.max(180, skillSupply.length * 30)}>
|
||||
<BarChart data={skillSupply} layout="vertical" margin={{ top: 0, right: 28, left: 0, bottom: 0 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke={CHART_TONES.grid} horizontal={false} />
|
||||
<XAxis type="number" {...AXIS_PROPS} allowDecimals={false} />
|
||||
<YAxis
|
||||
type="category"
|
||||
dataKey="skill"
|
||||
{...AXIS_PROPS}
|
||||
width={128}
|
||||
tick={{ fontSize: 11, fill: CHART_TONES.axis }}
|
||||
/>
|
||||
<Tooltip content={<ChartTooltip />} />
|
||||
<Bar dataKey="count" name="Candidates" radius={[0, 4, 4, 0]} maxBarSize={16}>
|
||||
{/* Tinted by the average score of the people holding the skill,
|
||||
so breadth and quality read together. */}
|
||||
{skillSupply.map((s) => (
|
||||
<Cell
|
||||
key={s.skill}
|
||||
fill={s.avgScore >= 75 ? CHART_TONES.brand : s.avgScore >= 60 ? CHART_TONES.accentPale : CHART_TONES.mint}
|
||||
/>
|
||||
))}
|
||||
</Bar>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
) : (
|
||||
<p className="text-body-sm text-ink-3">
|
||||
Nothing is scored yet, so there is no skill supply to read.
|
||||
</p>
|
||||
)}
|
||||
<p className="text-caption text-ink-4">
|
||||
Bar length is how many candidates claim the skill. Darker blue means the
|
||||
people holding it average 75 or above.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section aria-labelledby="gaps" className="space-y-3 lg:col-span-2">
|
||||
<SectionTitle
|
||||
id="gaps"
|
||||
title="Skill gaps"
|
||||
meta={skillGaps.length ? `${skillGaps.length} unmet` : 'None'}
|
||||
/>
|
||||
<Surface variant="solid" radius="lg" padding="none" elevation="xs" className="overflow-hidden">
|
||||
{skillGaps.length ? (
|
||||
<ul className="divide-y divide-border">
|
||||
{skillGaps.map((g) => (
|
||||
<li key={g.name} className="px-4 py-2.5">
|
||||
<p className="text-body-sm font-medium text-ink-1">{g.name}</p>
|
||||
<p className="mt-0.5 text-caption leading-relaxed text-ink-3">
|
||||
Required by {g.roles.join(', ')} — held by nobody in the scored pool.
|
||||
</p>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : (
|
||||
<p className="px-4 py-8 text-center text-body-sm text-ink-3">
|
||||
Every credential an open role requires is held by someone in the scored pool.
|
||||
</p>
|
||||
)}
|
||||
</Surface>
|
||||
</section>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function CAFit({ attrs = {} }) {
|
||||
const { navigate, applications, postings, interviews, staff, profiles, f, bands, risks, top, skillSupply, skillGaps, positionFit, recommendations } = useUiContext();
|
||||
return (
|
||||
<>
|
||||
{/* Position fit — which roles can actually be filled from current supply. */}
|
||||
<section aria-labelledby="fit" className="space-y-3" {...attrs}>
|
||||
<SectionTitle id="fit" title="Position fit" meta={`${positionFit.length} open roles`} />
|
||||
<Surface variant="solid" radius="lg" padding="none" elevation="xs" className="overflow-hidden">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full min-w-[40rem] border-collapse text-body-sm">
|
||||
<caption className="sr-only">
|
||||
Applicant supply against candidates clearing the bar, per open role
|
||||
</caption>
|
||||
<thead>
|
||||
<tr className="border-b border-border bg-surface-subtle">
|
||||
{['Role', 'Applied', 'Qualified', 'Avg score', 'Fit'].map((h, i) => (
|
||||
<th
|
||||
key={h}
|
||||
scope="col"
|
||||
className={`whitespace-nowrap px-4 py-2.5 text-[10px] font-semibold uppercase tracking-wide text-ink-4 ${i ? 'text-right' : 'text-left'}`}
|
||||
>
|
||||
{h}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{positionFit.map((r) => (
|
||||
<tr key={r.title} className="border-b border-border last:border-0 hover:bg-surface-subtle">
|
||||
<td className="px-4 py-2.5 font-medium text-ink-1">{r.title}</td>
|
||||
<td className={`px-4 py-2.5 text-right tabular-nums ${r.applied ? 'text-ink-2' : 'font-semibold text-destructive'}`}>
|
||||
{r.applied}
|
||||
</td>
|
||||
<td className="px-4 py-2.5 text-right tabular-nums text-ink-2">{r.qualified}</td>
|
||||
<td className="px-4 py-2.5 text-right tabular-nums text-ink-2">{r.avgScore || '—'}</td>
|
||||
<td className="px-4 py-2.5">
|
||||
<div className="ml-auto w-24">
|
||||
<ProgressBar
|
||||
value={r.fit}
|
||||
tone={r.fit >= 50 ? 'success' : r.fit > 0 ? 'warning' : 'destructive'}
|
||||
size="xs"
|
||||
/>
|
||||
<p className="mt-1 text-right text-[10px] tabular-nums text-ink-4">{r.fit}% qualified</p>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Surface>
|
||||
</section>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function CARecommend({ attrs = {} }) {
|
||||
const { navigate, applications, postings, interviews, staff, profiles, f, bands, risks, top, skillSupply, skillGaps, positionFit, recommendations } = useUiContext();
|
||||
return (
|
||||
<>
|
||||
{/* Recommendations — the actions the analysis above implies. */}
|
||||
{recommendations.length > 0 && (
|
||||
<section aria-labelledby="recommend" className="space-y-3">
|
||||
<SectionTitle
|
||||
id="recommend"
|
||||
title="Recommendations"
|
||||
meta={`${recommendations.length} ordered by impact`}
|
||||
/>
|
||||
<Surface variant="solid" radius="lg" padding="none" elevation="xs" className="overflow-hidden">
|
||||
<ol className="divide-y divide-border">
|
||||
{recommendations.map((r, i) => (
|
||||
<li key={r.title} className="flex gap-3 px-4 py-3">
|
||||
<span className="mt-0.5 grid h-5 w-5 shrink-0 place-items-center rounded-full bg-krow-blue-tint text-[10px] font-bold text-krow-blue">
|
||||
{i + 1}
|
||||
</span>
|
||||
<div className="min-w-0">
|
||||
<p className="text-body-sm font-medium text-ink-1">{r.title}</p>
|
||||
<p className="mt-0.5 text-caption leading-relaxed text-ink-3">{r.body}</p>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</Surface>
|
||||
</section>
|
||||
)}
|
||||
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function CAScreening({ attrs = {} }) {
|
||||
const { navigate, applications, postings, interviews, staff, profiles, f, bands, risks, top, skillSupply, skillGaps, positionFit, recommendations } = useUiContext();
|
||||
return (
|
||||
<section aria-labelledby="screening" className="space-y-3" {...attrs}>
|
||||
<SectionTitle id="screening" title="Screening efficiency" />
|
||||
<div className="grid gap-4 sm:grid-cols-3">
|
||||
{[
|
||||
{ label: 'Coverage', value: `${f.standardizedPct}%`, detail: `${f.scored.length} of ${f.total} scored`, tone: f.standardizedPct >= 80 ? 'success' : 'warning' },
|
||||
{ label: 'Interview completion', value: `${f.interviewCompletion}%`, detail: `${f.completedInterviews.length} of ${f.interviews.length} scored` },
|
||||
{ label: 'Quality lift', value: f.hiredAvgScore && f.avgScore ? `+${f.hiredAvgScore - f.avgScore}` : '—', detail: 'hires vs pool average', tone: 'success' },
|
||||
].map((s) => (
|
||||
<Surface key={s.label} variant="solid" radius="lg" padding="default" elevation="xs">
|
||||
<p className="text-[11px] font-medium uppercase tracking-wide text-ink-4">{s.label}</p>
|
||||
<p className={`mt-1 font-heading text-title-lg font-bold tabular-nums ${s.tone === 'success' ? 'text-success' : s.tone === 'warning' ? 'text-warning' : 'text-ink-1'}`}>
|
||||
{s.value}
|
||||
</p>
|
||||
<p className="mt-0.5 text-caption text-ink-3">{s.detail}</p>
|
||||
</Surface>
|
||||
))}
|
||||
</div>
|
||||
<Button variant="ghost" size="sm" onClick={() => navigate('/admin/analytics')} className="w-fit">
|
||||
Full analytics <ArrowRight aria-hidden="true" />
|
||||
</Button>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
const SECTION = ['move', 'hide'];
|
||||
|
||||
registerNodeType({
|
||||
type: 'ca-supply',
|
||||
/* This page's own section: it reads what this page publishes, so it belongs
|
||||
nowhere else. See `page` in the registry. */
|
||||
page: 'candidates-analysis',
|
||||
label: 'Supply summary',
|
||||
component: CASupply,
|
||||
capabilities: SECTION,
|
||||
wrap: true,
|
||||
});
|
||||
|
||||
registerNodeType({
|
||||
type: 'ca-distribution',
|
||||
/* This page's own section: it reads what this page publishes, so it belongs
|
||||
nowhere else. See `page` in the registry. */
|
||||
page: 'candidates-analysis',
|
||||
label: 'Score distribution',
|
||||
component: CADistribution,
|
||||
capabilities: SECTION,
|
||||
wrap: true,
|
||||
});
|
||||
|
||||
registerNodeType({
|
||||
type: 'ca-risk',
|
||||
/* This page's own section: it reads what this page publishes, so it belongs
|
||||
nowhere else. See `page` in the registry. */
|
||||
page: 'candidates-analysis',
|
||||
label: 'Pipeline risk',
|
||||
component: CARisk,
|
||||
capabilities: SECTION,
|
||||
});
|
||||
|
||||
registerNodeType({
|
||||
type: 'ca-skills',
|
||||
/* This page's own section: it reads what this page publishes, so it belongs
|
||||
nowhere else. See `page` in the registry. */
|
||||
page: 'candidates-analysis',
|
||||
label: 'Skill supply and gaps',
|
||||
component: CASkills,
|
||||
capabilities: SECTION,
|
||||
wrap: true,
|
||||
});
|
||||
|
||||
registerNodeType({
|
||||
type: 'ca-fit',
|
||||
/* This page's own section: it reads what this page publishes, so it belongs
|
||||
nowhere else. See `page` in the registry. */
|
||||
page: 'candidates-analysis',
|
||||
label: 'Position fit',
|
||||
component: CAFit,
|
||||
capabilities: SECTION,
|
||||
});
|
||||
|
||||
registerNodeType({
|
||||
type: 'ca-recommend',
|
||||
/* This page's own section: it reads what this page publishes, so it belongs
|
||||
nowhere else. See `page` in the registry. */
|
||||
page: 'candidates-analysis',
|
||||
label: 'Recommendations',
|
||||
component: CARecommend,
|
||||
capabilities: SECTION,
|
||||
wrap: true,
|
||||
});
|
||||
|
||||
registerNodeType({
|
||||
type: 'ca-screening',
|
||||
/* This page's own section: it reads what this page publishes, so it belongs
|
||||
nowhere else. See `page` in the registry. */
|
||||
page: 'candidates-analysis',
|
||||
label: 'Screening',
|
||||
component: CAScreening,
|
||||
capabilities: SECTION,
|
||||
});
|
||||
|
||||
registerPageComposition('candidates-analysis', [
|
||||
{ id: 'ca-extensions-top', type: 'skill-surface', props: { page: 'candidates-analysis', placement: 'after-header' } },
|
||||
{ id: 'supply', type: 'ca-supply' },
|
||||
{ id: 'distribution', type: 'ca-distribution' },
|
||||
{ id: 'risk', type: 'ca-risk' },
|
||||
{ id: 'skills', type: 'ca-skills' },
|
||||
{ id: 'fit', type: 'ca-fit' },
|
||||
{ id: 'recommend', type: 'ca-recommend' },
|
||||
{ id: 'screening', type: 'ca-screening' },
|
||||
{ id: 'ca-extensions-bottom', type: 'skill-surface', props: { page: 'candidates-analysis', placement: 'before-footer' } },
|
||||
]);
|
||||
137
src/pages/admin/candidates/nodes.jsx
Normal file
137
src/pages/admin/candidates/nodes.jsx
Normal file
@@ -0,0 +1,137 @@
|
||||
import React from 'react';
|
||||
import { Button, SearchInput } from '@/components/ds';
|
||||
import { Toolbar } from '@/components/admin/PageShell';
|
||||
import { FilterSelect } from '@/pages/admin/Positions';
|
||||
import CandidateCard from '@/components/krow/CandidateCard';
|
||||
import { registerNodeType } from '@/lib/ui/registry';
|
||||
import { registerPageComposition } from '@/lib/ui/composition';
|
||||
import { useUiContext } from '@/components/ui-tree/UiTreeRenderer';
|
||||
|
||||
/**
|
||||
* Candidates, as addressable nodes.
|
||||
*
|
||||
* The markup was moved here verbatim. The page keeps its state, its mutations
|
||||
* and its modals; what moved is only the part of it that is *layout*.
|
||||
*/
|
||||
|
||||
export const SORTS = {
|
||||
score: { label: 'Highest score', compare: (a, b) => (b.ai_score || 0) - (a.ai_score || 0) },
|
||||
recent: { label: 'Recent activity', compare: (a, b) => new Date(b.updated_date).getTime() - new Date(a.updated_date).getTime() },
|
||||
name: { label: 'Name A–Z', compare: (a, b) => a.applicant_name.localeCompare(b.applicant_name) },
|
||||
experience: { label: 'Most experience', compare: (a, b) => (b.years_experience || 0) - (a.years_experience || 0) },
|
||||
};
|
||||
|
||||
function CandidatesToolbar() {
|
||||
const {
|
||||
search, setSearch, position, setPosition, stage, setStage, band, setBand, sort, setSort,
|
||||
positions, filtered, applications, isFiltered,
|
||||
} = useUiContext();
|
||||
|
||||
return (
|
||||
<Toolbar
|
||||
search={<SearchInput value={search} onChange={setSearch} placeholder="Search candidates..." size="sm" />}
|
||||
filters={
|
||||
<>
|
||||
<FilterSelect value={position} onChange={setPosition} label="Position"
|
||||
options={[{ value: 'all', label: 'All positions' }, ...positions.map((p) => ({ value: p, label: p }))]} />
|
||||
<FilterSelect value={stage} onChange={setStage} label="Stage" options={[
|
||||
{ value: 'all', label: 'All stages' }, { value: 'applied', label: 'Applied' },
|
||||
{ value: 'ai_screened', label: 'AI Screened' }, { value: 'interview', label: 'Interviewing' },
|
||||
{ value: 'hired', label: 'Hired' }, { value: 'rejected', label: 'Declined' },
|
||||
]} />
|
||||
<FilterSelect value={band} onChange={setBand} label="Score" options={[
|
||||
{ value: 'all', label: 'All scores' }, { value: 'top', label: '80+ Top' },
|
||||
{ value: 'strong', label: '60–79 Strong' }, { value: 'weak', label: 'Under 60' },
|
||||
{ value: 'unscored', label: 'Unscored' },
|
||||
]} />
|
||||
<FilterSelect value={sort} onChange={setSort} label="Sort"
|
||||
options={Object.entries(SORTS).map(([value, s]) => ({ value, label: s.label }))} />
|
||||
</>
|
||||
}
|
||||
meta={`${filtered.length} of ${applications.length} candidates${isFiltered ? ' · filtered' : ''}`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function CandidatesList() {
|
||||
const {
|
||||
isLoading, filtered, isFiltered, clearFilters, resolveTitle,
|
||||
handleAction, setMessageApp, setScheduleApp, handleDecline, handleDelete, handleHire,
|
||||
} = useUiContext();
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{[...Array(5)].map((_, i) => (
|
||||
<div key={i} className="h-24 bg-white border border-[#E5E7EB] rounded-xl animate-pulse" />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (filtered.length === 0) {
|
||||
return (
|
||||
<div className="text-center py-16">
|
||||
<p className="text-body-sm text-ink-3">No candidates match your filters</p>
|
||||
{isFiltered && (
|
||||
<Button size="xs" variant="outline" className="mt-3" onClick={clearFilters}>
|
||||
Clear filters
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{filtered.map((app, idx) => (
|
||||
<CandidateCard
|
||||
key={app.id}
|
||||
application={app}
|
||||
jobTitle={resolveTitle(app)}
|
||||
rank={idx + 1}
|
||||
onAction={handleAction}
|
||||
onMessage={setMessageApp}
|
||||
onCall={setMessageApp}
|
||||
onSchedule={setScheduleApp}
|
||||
onDecline={handleDecline}
|
||||
onDelete={handleDelete}
|
||||
onHire={handleHire}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const SECTION = ['move', 'hide'];
|
||||
|
||||
registerNodeType({
|
||||
type: 'candidates-toolbar',
|
||||
/* This page's own section: it reads what this page publishes, so it belongs
|
||||
nowhere else. See `page` in the registry. */
|
||||
page: 'candidates',
|
||||
label: 'Search and filters',
|
||||
summary: 'Search, filter and sort the candidate pipeline.',
|
||||
component: CandidatesToolbar,
|
||||
capabilities: SECTION,
|
||||
wrap: true,
|
||||
});
|
||||
|
||||
registerNodeType({
|
||||
type: 'candidates-list',
|
||||
/* This page's own section: it reads what this page publishes, so it belongs
|
||||
nowhere else. See `page` in the registry. */
|
||||
page: 'candidates',
|
||||
label: 'Candidate list',
|
||||
summary: 'Every candidate matching the current filters, ranked.',
|
||||
component: CandidatesList,
|
||||
capabilities: SECTION,
|
||||
wrap: true,
|
||||
});
|
||||
|
||||
registerPageComposition('candidates', [
|
||||
{ id: 'candidates-extensions-top', type: 'skill-surface', props: { page: 'candidates', placement: 'after-header' } },
|
||||
{ id: 'candidates-toolbar', type: 'candidates-toolbar' },
|
||||
{ id: 'candidates-list', type: 'candidates-list' },
|
||||
{ id: 'candidates-extensions-bottom', type: 'skill-surface', props: { page: 'candidates', placement: 'before-footer' } },
|
||||
]);
|
||||
909
src/pages/admin/control-center/nodes.jsx
Normal file
909
src/pages/admin/control-center/nodes.jsx
Normal file
@@ -0,0 +1,909 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
Area, Bar, CartesianGrid, Cell, ComposedChart, Legend, Line, ResponsiveContainer,
|
||||
Tooltip, XAxis, YAxis,
|
||||
} from 'recharts';
|
||||
import {
|
||||
AlertTriangle, ArrowRight, Building2, CalendarCheck, CheckCircle2, ChevronRight, Clock, Filter, Sparkles, Users, Zap,
|
||||
} from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import {
|
||||
AXIS_PROPS, Avatar, Badge, CHART_TONES, ChartTooltip, EmptyState, SegmentedToggle,
|
||||
Skeleton, StatusBadge,
|
||||
} from '@/components/ds';
|
||||
|
||||
|
||||
/**
|
||||
* Control Center — the KROW Admin command centre.
|
||||
*/
|
||||
|
||||
const RANGES = {
|
||||
'7d': { label: '7D', days: 7, step: 1 },
|
||||
'30d': { label: '30D', days: 30, step: 3 },
|
||||
'90d': { label: '90D', days: 90, step: 9 },
|
||||
};
|
||||
|
||||
const WEAK_TRANSITION = 60;
|
||||
|
||||
/** @param {any} props */
|
||||
function Band({ id, title, meta = '', children, action = null }) {
|
||||
return (
|
||||
<section aria-labelledby={id} className="space-y-3">
|
||||
<div className="flex flex-wrap items-baseline justify-between gap-x-3 gap-y-2">
|
||||
<div className="flex items-baseline gap-2">
|
||||
<h2 id={id} className="font-heading text-body font-semibold text-ink-1">{title}</h2>
|
||||
{meta && <span className="text-caption text-ink-4">{meta}</span>}
|
||||
</div>
|
||||
{action}
|
||||
</div>
|
||||
{children}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function ViewAll({ label, onClick }) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className="inline-flex items-center gap-0.5 rounded text-caption font-semibold text-krow-blue transition-colors
|
||||
hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-krow-blue/50"
|
||||
>
|
||||
{label}
|
||||
<ArrowRight className="h-3 w-3" aria-hidden="true" />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── 1. Operations snapshot ─────────────────────────────────────────────── */
|
||||
|
||||
const snapshotConfig = {
|
||||
'Open positions': {
|
||||
icon: Building2,
|
||||
bg: 'bg-indigo-50/80 dark:bg-indigo-950/50',
|
||||
iconColor: 'text-indigo-600 dark:text-indigo-400',
|
||||
border: 'border-indigo-200/50 dark:border-indigo-800/40 hover:border-indigo-400',
|
||||
text: 'text-indigo-600 dark:text-indigo-400',
|
||||
accent: 'from-indigo-500 to-blue-500',
|
||||
},
|
||||
Candidates: {
|
||||
icon: Users,
|
||||
bg: 'bg-blue-50/80 dark:bg-blue-950/50',
|
||||
iconColor: 'text-blue-600 dark:text-blue-400',
|
||||
border: 'border-blue-200/50 dark:border-blue-800/40 hover:border-blue-400',
|
||||
text: 'text-blue-600 dark:text-blue-400',
|
||||
accent: 'from-blue-500 to-cyan-500',
|
||||
},
|
||||
'In review': {
|
||||
icon: Clock,
|
||||
bg: 'bg-amber-50/80 dark:bg-amber-950/50',
|
||||
iconColor: 'text-amber-600 dark:text-amber-400',
|
||||
border: 'border-amber-200/50 dark:border-amber-800/40 hover:border-amber-400',
|
||||
text: 'text-amber-600 dark:text-amber-400',
|
||||
accent: 'from-amber-500 to-orange-500',
|
||||
},
|
||||
'AI screened': {
|
||||
icon: Sparkles,
|
||||
bg: 'bg-purple-50/80 dark:bg-purple-950/50',
|
||||
iconColor: 'text-purple-600 dark:text-purple-400',
|
||||
border: 'border-purple-200/50 dark:border-purple-800/40 hover:border-purple-400',
|
||||
text: 'text-purple-600 dark:text-purple-400',
|
||||
accent: 'from-purple-500 to-indigo-500',
|
||||
},
|
||||
Hired: {
|
||||
icon: CheckCircle2,
|
||||
bg: 'bg-emerald-50/80 dark:bg-emerald-950/50',
|
||||
iconColor: 'text-emerald-600 dark:text-emerald-400',
|
||||
border: 'border-emerald-200/50 dark:border-emerald-800/40 hover:border-emerald-400',
|
||||
text: 'text-emerald-600 dark:text-emerald-400',
|
||||
accent: 'from-emerald-500 to-teal-500',
|
||||
},
|
||||
'Avg KROW score': {
|
||||
icon: Zap,
|
||||
bg: 'bg-blue-50/80 dark:bg-blue-950/50',
|
||||
iconColor: 'text-blue-600 dark:text-blue-400',
|
||||
border: 'border-blue-200/50 dark:border-blue-800/40 hover:border-blue-400',
|
||||
text: 'text-blue-600 dark:text-blue-400',
|
||||
accent: 'from-blue-600 to-indigo-600',
|
||||
},
|
||||
};
|
||||
|
||||
function Snapshot({ metrics, loading }) {
|
||||
return (
|
||||
<div className="grid grid-cols-2 gap-3.5 sm:grid-cols-3 xl:grid-cols-6">
|
||||
{metrics.map((m) => {
|
||||
const cfg = snapshotConfig[m.label] || snapshotConfig['Candidates'];
|
||||
const Icon = cfg.icon;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={m.label}
|
||||
className={cn(
|
||||
'group relative flex flex-col justify-between rounded-xl border bg-surface p-4 shadow-xs backdrop-blur-xs transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md',
|
||||
cfg.border
|
||||
)}
|
||||
>
|
||||
<div>
|
||||
<div className="flex items-center justify-between gap-1.5">
|
||||
<span className="truncate text-[10px] font-bold uppercase tracking-wider text-ink-4">
|
||||
{m.label}
|
||||
</span>
|
||||
<div className={cn('flex h-7 w-7 shrink-0 items-center justify-center rounded-lg shadow-xs', cfg.bg, cfg.iconColor)}>
|
||||
<Icon className="h-3.5 w-3.5" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="mt-2.5 h-8 w-16 animate-pulse rounded-lg bg-surface-sunken" />
|
||||
) : (
|
||||
<div className="mt-2 flex items-baseline gap-1.5">
|
||||
<span className={cn('font-heading text-title-xl font-bold leading-none tabular-nums', cfg.text)}>
|
||||
{m.value}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-3 space-y-2">
|
||||
{m.sub && (
|
||||
<span className="inline-flex items-center rounded-full bg-surface-subtle px-2 py-0.5 text-[10px] font-semibold text-ink-3 border border-border/60">
|
||||
{m.sub}
|
||||
</span>
|
||||
)}
|
||||
|
||||
<div className={cn('h-0.5 w-full rounded-full bg-gradient-to-r opacity-40 group-hover:opacity-100 transition-opacity', cfg.accent)} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── 3. Pipeline intelligence ───────────────────────────────────────────── */
|
||||
|
||||
function PipelineFunnel({ funnel, transitions, weakest, total }) {
|
||||
if (!total) {
|
||||
return (
|
||||
<EmptyState
|
||||
title="Nothing in the pipeline"
|
||||
description="Once candidates apply, stage conversion and drop-off appear here."
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const stageIcons = [Users, Sparkles, Filter, CalendarCheck, CheckCircle2];
|
||||
|
||||
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) => {
|
||||
const transition = i === 0 ? null : transitions[i - 1];
|
||||
const isWeak = transition && weakest
|
||||
&& transition.from === weakest.from && transition.to === weakest.to;
|
||||
const share = Math.round((stage.count / total) * 100);
|
||||
const StageIcon = stageIcons[i] || Users;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={stage.key}
|
||||
className={cn(
|
||||
'group relative flex flex-col justify-between rounded-xl border p-4 shadow-xs backdrop-blur-xs transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md',
|
||||
isWeak
|
||||
? 'border-amber-500/50 bg-gradient-to-br from-amber-50/70 via-surface to-amber-50/20 dark:from-amber-950/40 dark:to-surface'
|
||||
: 'border-border/80 bg-surface hover:border-blue-500/40'
|
||||
)}
|
||||
>
|
||||
<div>
|
||||
{/* Header: Stage Name & Icon */}
|
||||
<div className="flex items-center justify-between gap-1.5">
|
||||
<div className="flex items-center gap-1.5 min-w-0">
|
||||
<span className="text-[10px] font-extrabold text-ink-4 tabular-nums">0{i + 1}</span>
|
||||
<h4 className="truncate font-heading text-body-sm font-bold text-ink-1">
|
||||
{stage.label}
|
||||
</h4>
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
'flex h-7 w-7 shrink-0 items-center justify-center rounded-lg shadow-xs',
|
||||
isWeak
|
||||
? 'bg-amber-500 text-white shadow-amber-500/25'
|
||||
: i === funnel.length - 1
|
||||
? 'bg-emerald-600 text-white shadow-emerald-500/25'
|
||||
: 'bg-blue-50 text-blue-600 dark:bg-blue-950 dark:text-blue-400'
|
||||
)}
|
||||
>
|
||||
<StageIcon className="h-3.5 w-3.5" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Main Candidate Count & Pool Share */}
|
||||
<div className="mt-3 flex items-baseline justify-between">
|
||||
<span className="font-heading text-title-xl font-bold leading-none tabular-nums text-ink-1">
|
||||
{stage.count}
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
'rounded-full px-2 py-0.5 text-[10px] font-semibold tabular-nums border',
|
||||
isWeak
|
||||
? 'bg-amber-100 text-amber-800 border-amber-300 dark:bg-amber-950 dark:text-amber-300'
|
||||
: 'bg-surface-subtle text-ink-3 border-border/60'
|
||||
)}
|
||||
>
|
||||
{share}% pool
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stage Fill Bar & Transition Metrics */}
|
||||
<div className="mt-4 space-y-2">
|
||||
<div className="h-1.5 w-full overflow-hidden rounded-full bg-surface-sunken">
|
||||
<div
|
||||
className={cn(
|
||||
'h-full rounded-full transition-all duration-500',
|
||||
isWeak
|
||||
? 'bg-gradient-to-r from-amber-500 to-orange-500'
|
||||
: i === funnel.length - 1
|
||||
? 'bg-gradient-to-r from-emerald-500 to-teal-500'
|
||||
: 'bg-gradient-to-r from-blue-600 to-indigo-600'
|
||||
)}
|
||||
style={{ width: `${Math.max(share, stage.count ? 5 : 0)}%` }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between text-[10px] text-ink-4 pt-0.5">
|
||||
{transition ? (
|
||||
<>
|
||||
<span className={cn(isWeak ? 'font-bold text-amber-600 dark:text-amber-400' : 'text-ink-3')}>
|
||||
{transition.rate}% pass
|
||||
</span>
|
||||
<span>{transition.lost ? `−${transition.lost} lost` : '0 lost'}</span>
|
||||
</>
|
||||
) : (
|
||||
<span className="text-ink-4">Entry Stage</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Primary Pipeline Bottleneck Alert Banner */}
|
||||
{weakest && weakest.lost > 0 && (
|
||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3 rounded-xl border border-amber-500/40 bg-gradient-to-r from-amber-50/90 via-surface to-orange-50/40 dark:from-amber-950/40 dark:via-surface dark:to-orange-950/30 p-4 shadow-xs">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-xl bg-amber-500 text-white shadow-md shadow-amber-500/20">
|
||||
<AlertTriangle className="h-5 w-5" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-[10px] font-bold uppercase tracking-wider text-amber-700 dark:text-amber-400">
|
||||
Primary Pipeline Bottleneck
|
||||
</span>
|
||||
<span className="rounded-full bg-amber-200/60 dark:bg-amber-900/60 px-2 py-0.5 text-[10px] font-bold text-amber-800 dark:text-amber-300">
|
||||
{weakest.from} → {weakest.to}
|
||||
</span>
|
||||
</div>
|
||||
<p className="mt-0.5 text-body-sm text-ink-2">
|
||||
Only <span className="font-bold text-amber-700 dark:text-amber-400">{weakest.rate}%</span> pass through and{' '}
|
||||
<span className="font-bold text-ink-1">{weakest.lost} candidates</span> are lost at this transition. Recovering this conversion yields the highest ROI.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── 5. Action center ───────────────────────────────────────────────────── */
|
||||
|
||||
const SEVERITY = {
|
||||
critical: { dot: 'bg-destructive', label: 'Critical' },
|
||||
warning: { dot: 'bg-warning', label: 'Warning' },
|
||||
info: { dot: 'bg-krow-blue', label: 'For review' },
|
||||
};
|
||||
|
||||
/** One operational queue, divided rows. Not five cards in a grid. */
|
||||
function ActionQueue({ items }) {
|
||||
if (!items.length) {
|
||||
return (
|
||||
<div className="rounded-xl border border-border bg-surface px-4 py-8 text-center">
|
||||
<p className="text-body-sm text-ink-3">
|
||||
Nothing needs intervention. Every role has candidates, every applicant is scored,
|
||||
and no hire is awaiting review.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ul className="divide-y divide-border overflow-hidden rounded-xl border border-border bg-surface">
|
||||
{items.map((item) => (
|
||||
<li key={item.title}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={item.onClick}
|
||||
className="group flex w-full items-center gap-3.5 px-4 py-3 text-left transition-colors
|
||||
hover:bg-surface-subtle focus-visible:outline-none focus-visible:ring-2
|
||||
focus-visible:ring-inset focus-visible:ring-krow-blue/50"
|
||||
>
|
||||
<span
|
||||
className={cn('h-2 w-2 shrink-0 rounded-full', SEVERITY[item.severity].dot)}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span className="sr-only">{SEVERITY[item.severity].label}.</span>
|
||||
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate text-body-sm font-medium text-ink-1">{item.title}</span>
|
||||
<span className="block truncate text-caption text-ink-3">{item.detail}</span>
|
||||
</span>
|
||||
|
||||
<span className="shrink-0 text-right">
|
||||
<span className="block font-heading text-body font-bold tabular-nums text-ink-1">
|
||||
{item.metric}
|
||||
</span>
|
||||
<span className="block text-[10px] text-ink-4">{item.metricLabel}</span>
|
||||
</span>
|
||||
|
||||
<span className="hidden shrink-0 items-center gap-0.5 text-caption font-semibold text-krow-blue sm:inline-flex">
|
||||
Review
|
||||
<ChevronRight
|
||||
className="h-3 w-3 transition-transform duration-base group-hover:translate-x-0.5"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Page ───────────────────────────────────────────────────────────────── */
|
||||
|
||||
import { registerNodeType } from '@/lib/ui/registry';
|
||||
import { registerPageComposition } from '@/lib/ui/composition';
|
||||
import { useUiContext } from '@/components/ui-tree/UiTreeRenderer';
|
||||
|
||||
/**
|
||||
* Control Center, as addressable nodes.
|
||||
*
|
||||
* The largest composition in the product, and migrated last for that reason.
|
||||
* Every band was moved verbatim. Each is wrapped so it carries its node
|
||||
* identity: `Band` and `Snapshot` render their own roots and do not forward
|
||||
* unknown props, and the wrapper is a bare div that paints nothing.
|
||||
*/
|
||||
|
||||
function CCSnapshot({ attrs = {} }) {
|
||||
const { navigate, range, setRange, applications, isLoading, postings, interviews, staff, profiles, activity, f, metrics, activitySeries, positionPerformance, strongest, weakestRole, actions, activePositions, topTalent, recent, exportSnapshot } = useUiContext();
|
||||
return (
|
||||
<>
|
||||
{/* 1 ── Operations snapshot */}
|
||||
<Snapshot metrics={metrics} loading={isLoading} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function CCActivity({ attrs = {} }) {
|
||||
const { navigate, range, setRange, applications, isLoading, postings, interviews, staff, profiles, activity, f, metrics, activitySeries, positionPerformance, strongest, weakestRole, actions, activePositions, topTalent, recent, exportSnapshot } = useUiContext();
|
||||
return (
|
||||
<>
|
||||
{/* 2 ── Hiring activity */}
|
||||
<Band
|
||||
id="cc-activity"
|
||||
title="Hiring activity"
|
||||
meta={
|
||||
RANGES[range].step === 1
|
||||
? `Applications, screening, interviews and hires · last ${RANGES[range].days} days, daily`
|
||||
: `Applications, screening, interviews and hires · last ${RANGES[range].days} days, ${RANGES[range].step}-day totals`
|
||||
}
|
||||
action={
|
||||
<SegmentedToggle
|
||||
options={Object.entries(RANGES).map(([value, r]) => ({ value, label: r.label }))}
|
||||
value={range}
|
||||
onChange={setRange}
|
||||
size="sm"
|
||||
ariaLabel="Hiring activity range"
|
||||
/>
|
||||
}
|
||||
>
|
||||
<div className="rounded-xl border border-border bg-surface px-2 py-4">
|
||||
{isLoading ? (
|
||||
<Skeleton className="mx-2 h-[272px] rounded-lg" />
|
||||
) : f.total ? (
|
||||
<ResponsiveContainer width="100%" height={272}>
|
||||
<ComposedChart data={activitySeries} margin={{ top: 4, right: 12, left: 0, bottom: 0 }}>
|
||||
<defs>
|
||||
<linearGradient id="ccApplications" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stopColor={CHART_TONES.brand} stopOpacity={0.22} />
|
||||
<stop offset="100%" stopColor={CHART_TONES.brand} stopOpacity={0} />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
|
||||
<CartesianGrid strokeDasharray="3 3" stroke={CHART_TONES.grid} vertical={false} />
|
||||
<XAxis dataKey="label" {...AXIS_PROPS} interval="preserveStartEnd" minTickGap={20} />
|
||||
<YAxis {...AXIS_PROPS} allowDecimals={false} width={30} />
|
||||
<Tooltip content={<ChartTooltip />} />
|
||||
<Legend
|
||||
verticalAlign="top"
|
||||
align="right"
|
||||
height={28}
|
||||
iconType="circle"
|
||||
iconSize={8}
|
||||
wrapperStyle={{ fontSize: 12, color: CHART_TONES.axis }}
|
||||
/>
|
||||
|
||||
{/* Applications are the volume everything else is drawn from, so
|
||||
they take the filled area and the downstream stages are lines
|
||||
over it. */}
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="applications"
|
||||
name="Applications"
|
||||
stroke={CHART_TONES.brand}
|
||||
strokeWidth={2}
|
||||
fill="url(#ccApplications)"
|
||||
activeDot={{ r: 4, strokeWidth: 0 }}
|
||||
/>
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="screened"
|
||||
name="AI Screened"
|
||||
stroke={CHART_TONES.navy}
|
||||
strokeWidth={2}
|
||||
strokeDasharray="4 3"
|
||||
dot={false}
|
||||
activeDot={{ r: 4, strokeWidth: 0 }}
|
||||
/>
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="interviews"
|
||||
name="Interviews"
|
||||
stroke={CHART_TONES.navy}
|
||||
strokeWidth={1.5}
|
||||
strokeOpacity={0.55}
|
||||
dot={false}
|
||||
activeDot={{ r: 4, strokeWidth: 0 }}
|
||||
/>
|
||||
{/* Hires are sparse and are the outcome, so they take the accent
|
||||
and a bar — a line at this volume reads as flat. */}
|
||||
<Bar
|
||||
dataKey="hires"
|
||||
name="Hires"
|
||||
fill={CHART_TONES.accent}
|
||||
radius={[3, 3, 0, 0]}
|
||||
maxBarSize={14}
|
||||
/>
|
||||
</ComposedChart>
|
||||
</ResponsiveContainer>
|
||||
) : (
|
||||
<div className="px-2 py-8">
|
||||
<EmptyState
|
||||
title="No applications yet"
|
||||
description="Once candidates start applying, daily hiring activity appears here."
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Band>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function CCPipeline({ attrs = {} }) {
|
||||
const { navigate, range, setRange, applications, isLoading, postings, interviews, staff, profiles, activity, f, metrics, activitySeries, positionPerformance, strongest, weakestRole, actions, activePositions, topTalent, recent, exportSnapshot } = useUiContext();
|
||||
return (
|
||||
<>
|
||||
{/* 3 ── Pipeline intelligence */}
|
||||
<Band
|
||||
id="cc-pipeline"
|
||||
title="Pipeline intelligence"
|
||||
meta={`${f.total} entered · conversion and drop-off by stage`}
|
||||
>
|
||||
<PipelineFunnel
|
||||
funnel={f.funnel}
|
||||
transitions={f.transitions}
|
||||
weakest={f.bottleneck && f.bottleneck.rate < WEAK_TRANSITION ? f.bottleneck : null}
|
||||
total={f.total}
|
||||
/>
|
||||
</Band>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function CCPositions({ attrs = {} }) {
|
||||
const { navigate, range, setRange, applications, isLoading, postings, interviews, staff, profiles, activity, f, metrics, activitySeries, positionPerformance, strongest, weakestRole, actions, activePositions, topTalent, recent, exportSnapshot } = useUiContext();
|
||||
return (
|
||||
<>
|
||||
{/* 4 ── Position performance */}
|
||||
<Band
|
||||
id="cc-positions"
|
||||
title="Position performance"
|
||||
meta={strongest ? `Strongest conversion: ${strongest}` : undefined}
|
||||
action={<ViewAll label="View all positions" onClick={() => navigate('/admin/positions')} />}
|
||||
>
|
||||
<div className="rounded-xl border border-border bg-surface px-2 py-4">
|
||||
{positionPerformance.length ? (
|
||||
<>
|
||||
<ResponsiveContainer width="100%" height={236}>
|
||||
<ComposedChart
|
||||
data={positionPerformance}
|
||||
barGap={3}
|
||||
margin={{ top: 4, right: 12, left: 0, bottom: 0 }}
|
||||
>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke={CHART_TONES.grid} vertical={false} />
|
||||
<XAxis
|
||||
dataKey="name"
|
||||
{...AXIS_PROPS}
|
||||
interval={0}
|
||||
tick={{ fontSize: 10, fill: CHART_TONES.axis }}
|
||||
/>
|
||||
<YAxis {...AXIS_PROPS} allowDecimals={false} width={30} />
|
||||
<Tooltip content={<ChartTooltip />} />
|
||||
<Legend
|
||||
verticalAlign="top"
|
||||
align="right"
|
||||
height={28}
|
||||
iconType="circle"
|
||||
iconSize={8}
|
||||
wrapperStyle={{ fontSize: 12, color: CHART_TONES.axis }}
|
||||
/>
|
||||
<Bar
|
||||
dataKey="applicants"
|
||||
name="Applicants"
|
||||
fill={CHART_TONES.brand}
|
||||
radius={[3, 3, 0, 0]}
|
||||
maxBarSize={24}
|
||||
/>
|
||||
<Bar dataKey="qualified" name="Qualified (70+)" radius={[3, 3, 0, 0]} maxBarSize={24}>
|
||||
{/* The best converter takes the accent at full strength and the
|
||||
worst is left pale, so both ends of the comparison are
|
||||
visible without a callout. */}
|
||||
{positionPerformance.map((r) => (
|
||||
<Cell
|
||||
key={r.name}
|
||||
fill={r.name === strongest ? CHART_TONES.accent
|
||||
: r.name === weakestRole ? CHART_TONES.mint : CHART_TONES.accentPale}
|
||||
/>
|
||||
))}
|
||||
</Bar>
|
||||
<Bar
|
||||
dataKey="hired"
|
||||
name="Hired"
|
||||
fill={CHART_TONES.navy}
|
||||
radius={[3, 3, 0, 0]}
|
||||
maxBarSize={24}
|
||||
/>
|
||||
</ComposedChart>
|
||||
</ResponsiveContainer>
|
||||
|
||||
{weakestRole && (
|
||||
<p className="px-2 pt-2 text-caption leading-relaxed text-ink-3">
|
||||
<span className="font-semibold text-ink-1">{strongest}</span> converts applicants
|
||||
into qualified candidates best;{' '}
|
||||
<span className="font-semibold text-ink-1">{weakestRole}</span> converts worst and
|
||||
is where sourcing — or the bar itself — is worth a look.
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<div className="px-2 py-8">
|
||||
<EmptyState
|
||||
title="No open positions"
|
||||
description="Publish a role and its hiring performance appears here."
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Band>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function CCActions({ attrs = {} }) {
|
||||
const { navigate, range, setRange, applications, isLoading, postings, interviews, staff, profiles, activity, f, metrics, activitySeries, positionPerformance, strongest, weakestRole, actions, activePositions, topTalent, recent, exportSnapshot } = useUiContext();
|
||||
return (
|
||||
<>
|
||||
{/* 5 ── Action center */}
|
||||
<Band
|
||||
id="cc-actions"
|
||||
title="Action center"
|
||||
meta={actions.length ? `${actions.length} items, most consequential first` : 'All clear'}
|
||||
>
|
||||
<ActionQueue items={actions} />
|
||||
</Band>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function CCLists({ attrs = {} }) {
|
||||
const { navigate, range, setRange, applications, isLoading, postings, interviews, staff, profiles, activity, f, metrics, activitySeries, positionPerformance, strongest, weakestRole, actions, activePositions, topTalent, recent, exportSnapshot } = useUiContext();
|
||||
return (
|
||||
<>
|
||||
{/* 6 / 7 ── Active positions and top talent. Two lists of comparable weight,
|
||||
so they share a row rather than each taking one. */}
|
||||
<div className="grid gap-6 xl:grid-cols-2">
|
||||
<Band
|
||||
id="cc-active"
|
||||
title="Active positions"
|
||||
meta={`${f.openPositions.length} hiring`}
|
||||
action={<ViewAll label="View all" onClick={() => navigate('/admin/positions')} />}
|
||||
>
|
||||
{activePositions.length ? (
|
||||
<ul className="divide-y divide-border overflow-hidden rounded-xl border border-border bg-surface">
|
||||
{activePositions.map((r) => {
|
||||
const coverage = r.applied ? Math.round((r.screened / r.applied) * 100) : 0;
|
||||
return (
|
||||
<li key={r.title}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => navigate('/admin/positions')}
|
||||
className="group flex w-full items-center gap-3 px-4 py-3 text-left transition-colors
|
||||
hover:bg-surface-subtle focus-visible:outline-none focus-visible:ring-2
|
||||
focus-visible:ring-inset focus-visible:ring-krow-blue/50"
|
||||
>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate text-body-sm font-medium text-ink-1">
|
||||
{r.title}
|
||||
</span>
|
||||
<span className="block truncate text-caption text-ink-3">
|
||||
{r.applied} applicant{r.applied === 1 ? '' : 's'} · {r.screened} screened
|
||||
{' · '}{r.hired} hired
|
||||
</span>
|
||||
</span>
|
||||
|
||||
{/* Screening coverage as a small bar — the one number that
|
||||
says whether this role is actually being worked. */}
|
||||
<span className="hidden w-16 shrink-0 sm:block">
|
||||
<span className="block h-1.5 overflow-hidden rounded-full bg-surface-sunken">
|
||||
<span
|
||||
className={cn(
|
||||
'block h-full rounded-full',
|
||||
coverage === 100 ? 'bg-success'
|
||||
: coverage >= 50 ? 'bg-krow-blue' : 'bg-warning'
|
||||
)}
|
||||
style={{ width: `${Math.max(coverage, r.applied ? 4 : 0)}%` }}
|
||||
/>
|
||||
</span>
|
||||
<span className="mt-1 block text-right text-[10px] tabular-nums text-ink-4">
|
||||
{coverage}%
|
||||
</span>
|
||||
</span>
|
||||
|
||||
<StatusBadge status={r.posting.status} size="sm" />
|
||||
<ChevronRight
|
||||
className="h-3.5 w-3.5 shrink-0 text-ink-4 transition-transform duration-base group-hover:translate-x-0.5"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
) : (
|
||||
<EmptyState title="No open positions" description="Publish a role to start hiring." />
|
||||
)}
|
||||
</Band>
|
||||
|
||||
<Band
|
||||
id="cc-talent"
|
||||
title="Top talent"
|
||||
meta={topTalent.length ? 'By KROW Score' : undefined}
|
||||
action={<ViewAll label="View all" onClick={() => navigate('/admin/candidates')} />}
|
||||
>
|
||||
{topTalent.length ? (
|
||||
<ul className="divide-y divide-border overflow-hidden rounded-xl border border-border bg-surface">
|
||||
{topTalent.map((c) => (
|
||||
<li key={c.id}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => navigate('/admin/candidates')}
|
||||
className="group flex w-full items-center gap-3 px-4 py-3 text-left transition-colors
|
||||
hover:bg-surface-subtle focus-visible:outline-none focus-visible:ring-2
|
||||
focus-visible:ring-inset focus-visible:ring-krow-blue/50"
|
||||
>
|
||||
{/* The seeded portrait where there is one, otherwise the design
|
||||
system's initials avatar. Never an invented face. */}
|
||||
<Avatar name={c.applicant_name} src={c.selfie_url} size="sm" />
|
||||
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate text-body-sm font-medium text-ink-1">
|
||||
{c.applicant_name}
|
||||
</span>
|
||||
<span className="block truncate text-caption text-ink-3">{c.job_title}</span>
|
||||
</span>
|
||||
|
||||
<span className="shrink-0 text-right">
|
||||
<span className="block font-heading text-body font-bold tabular-nums text-ink-1">
|
||||
{c.ai_score}
|
||||
</span>
|
||||
<span className="block text-[10px] text-ink-4">KROW</span>
|
||||
</span>
|
||||
|
||||
<StatusBadge status={c.status} size="sm" />
|
||||
<ChevronRight
|
||||
className="h-3.5 w-3.5 shrink-0 text-ink-4 transition-transform duration-base group-hover:translate-x-0.5"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : (
|
||||
<EmptyState
|
||||
title="Nobody scored yet"
|
||||
description="Screen the pipeline and the strongest candidates appear here."
|
||||
/>
|
||||
)}
|
||||
</Band>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function CCRecent({ attrs = {} }) {
|
||||
const { navigate, range, setRange, applications, isLoading, postings, interviews, staff, profiles, activity, f, metrics, activitySeries, positionPerformance, strongest, weakestRole, actions, activePositions, topTalent, recent, exportSnapshot } = useUiContext();
|
||||
return (
|
||||
<>
|
||||
{/* 8 ── Recent activity */}
|
||||
<Band
|
||||
id="cc-recent"
|
||||
title="Recent activity"
|
||||
meta={`${activity.length} events logged`}
|
||||
action={<ViewAll label="View all" onClick={() => navigate('/admin/activity')} />}
|
||||
>
|
||||
<div className="overflow-hidden rounded-xl border border-border bg-surface">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full min-w-[36rem] border-collapse">
|
||||
<caption className="sr-only">Most recent platform activity</caption>
|
||||
<thead>
|
||||
<tr className="border-b border-border bg-surface-subtle">
|
||||
{['User', 'Action', 'Entity', 'Time', 'Role'].map((h) => (
|
||||
<th
|
||||
key={h}
|
||||
scope="col"
|
||||
className={cn(
|
||||
'whitespace-nowrap px-4 py-2.5 text-[10px] font-semibold uppercase tracking-wide text-ink-4',
|
||||
h === 'Role' ? 'text-right' : 'text-left'
|
||||
)}
|
||||
>
|
||||
{h}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{recent.map((event) => (
|
||||
<tr key={event.id} className="border-b border-border last:border-0 hover:bg-surface-subtle">
|
||||
<td className="px-4 py-2.5">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<Avatar name={event.user_name || event.user_email} size="xs" />
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-body-sm font-medium text-ink-1">
|
||||
{event.user_name || '—'}
|
||||
</p>
|
||||
<p className="truncate text-[10px] text-ink-4">{event.user_email}</p>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="whitespace-nowrap px-4 py-2.5 text-body-sm text-ink-2">
|
||||
{String(event.event_type).replace(/_/g, ' ')}
|
||||
</td>
|
||||
<td className="max-w-[24rem] px-4 py-2.5 text-body-sm text-ink-3">
|
||||
<span className="line-clamp-1">{event.details || '—'}</span>
|
||||
</td>
|
||||
<td className="whitespace-nowrap px-4 py-2.5 text-caption text-ink-4">
|
||||
{new Date(event.created_date).toLocaleDateString(undefined, {
|
||||
month: 'short', day: 'numeric',
|
||||
})}
|
||||
</td>
|
||||
<td className="px-4 py-2.5 text-right">
|
||||
<Badge variant={event.account_type === 'employer' ? 'info' : 'neutral'} size="sm">
|
||||
{event.account_type || 'unknown'}
|
||||
</Badge>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</Band>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const SECTION = ['move', 'hide'];
|
||||
|
||||
registerNodeType({
|
||||
type: 'cc-snapshot',
|
||||
/* This page's own section: it reads what this page publishes, so it belongs
|
||||
nowhere else. See `page` in the registry. */
|
||||
page: 'control-center',
|
||||
label: 'Operations snapshot',
|
||||
component: CCSnapshot,
|
||||
capabilities: SECTION,
|
||||
wrap: true,
|
||||
});
|
||||
|
||||
registerNodeType({
|
||||
type: 'cc-activity',
|
||||
/* This page's own section: it reads what this page publishes, so it belongs
|
||||
nowhere else. See `page` in the registry. */
|
||||
page: 'control-center',
|
||||
label: 'Hiring activity',
|
||||
component: CCActivity,
|
||||
capabilities: SECTION,
|
||||
wrap: true,
|
||||
});
|
||||
|
||||
registerNodeType({
|
||||
type: 'cc-pipeline',
|
||||
/* This page's own section: it reads what this page publishes, so it belongs
|
||||
nowhere else. See `page` in the registry. */
|
||||
page: 'control-center',
|
||||
label: 'Pipeline intelligence',
|
||||
component: CCPipeline,
|
||||
capabilities: SECTION,
|
||||
wrap: true,
|
||||
});
|
||||
|
||||
registerNodeType({
|
||||
type: 'cc-positions',
|
||||
/* This page's own section: it reads what this page publishes, so it belongs
|
||||
nowhere else. See `page` in the registry. */
|
||||
page: 'control-center',
|
||||
label: 'Position performance',
|
||||
component: CCPositions,
|
||||
capabilities: SECTION,
|
||||
wrap: true,
|
||||
});
|
||||
|
||||
registerNodeType({
|
||||
type: 'cc-actions',
|
||||
/* This page's own section: it reads what this page publishes, so it belongs
|
||||
nowhere else. See `page` in the registry. */
|
||||
page: 'control-center',
|
||||
label: 'Action center',
|
||||
component: CCActions,
|
||||
capabilities: SECTION,
|
||||
wrap: true,
|
||||
});
|
||||
|
||||
registerNodeType({
|
||||
type: 'cc-lists',
|
||||
/* This page's own section: it reads what this page publishes, so it belongs
|
||||
nowhere else. See `page` in the registry. */
|
||||
page: 'control-center',
|
||||
label: 'Active positions and top talent',
|
||||
component: CCLists,
|
||||
capabilities: SECTION,
|
||||
wrap: true,
|
||||
});
|
||||
|
||||
registerNodeType({
|
||||
type: 'cc-recent',
|
||||
/* This page's own section: it reads what this page publishes, so it belongs
|
||||
nowhere else. See `page` in the registry. */
|
||||
page: 'control-center',
|
||||
label: 'Recent activity',
|
||||
component: CCRecent,
|
||||
capabilities: SECTION,
|
||||
wrap: true,
|
||||
});
|
||||
|
||||
registerPageComposition('control-center', [
|
||||
{ id: 'cc-extensions-top', type: 'skill-surface', props: { page: 'control-center', placement: 'after-header' } },
|
||||
{ id: 'cc-snapshot', type: 'cc-snapshot' },
|
||||
{ id: 'cc-activity', type: 'cc-activity' },
|
||||
{ id: 'cc-pipeline', type: 'cc-pipeline' },
|
||||
{ id: 'cc-positions', type: 'cc-positions' },
|
||||
{ id: 'cc-actions', type: 'cc-actions' },
|
||||
{ id: 'cc-lists', type: 'cc-lists' },
|
||||
{ id: 'cc-recent', type: 'cc-recent' },
|
||||
{ id: 'cc-extensions-bottom', type: 'skill-surface', props: { page: 'control-center', placement: 'before-footer' } },
|
||||
]);
|
||||
275
src/pages/admin/hired-history/nodes.jsx
Normal file
275
src/pages/admin/hired-history/nodes.jsx
Normal file
@@ -0,0 +1,275 @@
|
||||
import React from 'react';
|
||||
import { Award, Building2, CalendarDays, Clock, UserCheck } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import {
|
||||
Avatar, Button, DataTable, EmptyState, FilterBar, MetricStrip, StatusBadge, Surface, Timeline,
|
||||
} from '@/components/ds';
|
||||
import { SectionTitle } from '@/components/admin/PageShell';
|
||||
import { registerNodeType } from '@/lib/ui/registry';
|
||||
import { registerPageComposition } from '@/lib/ui/composition';
|
||||
import { useUiContext } from '@/components/ui-tree/UiTreeRenderer';
|
||||
|
||||
/**
|
||||
* Hired History, as addressable nodes.
|
||||
*
|
||||
* The second page composed through the UI node tree, and it follows Activity's
|
||||
* pattern exactly: the markup below was moved here verbatim, the page keeps its
|
||||
* own state and publishes it once, and each section is a singleton type that
|
||||
* declares only the operations that mean something for a built-in.
|
||||
*
|
||||
* `chronology` is the Recent Hiring Timeline. It keeps the id its heading has
|
||||
* always carried in the DOM, so a person naming it in conversation and the
|
||||
* markup on screen refer to the same thing.
|
||||
*/
|
||||
|
||||
/** Date-range windows, expressed as days back from today. */
|
||||
export 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 const DAY = 24 * 60 * 60 * 1000;
|
||||
|
||||
export const formatDate = (value) => (value
|
||||
? new Date(value).toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' })
|
||||
: '—');
|
||||
|
||||
/* 1. Search and filters — the page is a record, so finding one is the
|
||||
first thing it has to do well. */
|
||||
function HiredFilters() {
|
||||
const { search, setSearch, filters, setFilters, positionOptions, departmentOptions } = useUiContext();
|
||||
return (
|
||||
<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'. */
|
||||
function HiredSummary() {
|
||||
const { isLoading, hires, summary, recent, isFiltered } = useUiContext();
|
||||
return (
|
||||
<MetricStrip
|
||||
columns={3}
|
||||
loading={isLoading}
|
||||
items={[
|
||||
{
|
||||
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`,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/* 3. The chronology. */
|
||||
function HiredChronology({ attrs = {} }) {
|
||||
const { recent, filtered } = useUiContext();
|
||||
if (!recent.length) return null;
|
||||
|
||||
return (
|
||||
<section aria-labelledby="chronology" className="space-y-3" {...attrs}>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
/* 4. The records themselves — every field a hire carries, one row each. */
|
||||
function HiredRecords({ attrs = {} }) {
|
||||
const { isLoading, hires, filtered, isFiltered, clearFilters, setSelected } = useUiContext();
|
||||
|
||||
return (
|
||||
<section aria-labelledby="records" className="space-y-3" {...attrs}>
|
||||
<SectionTitle
|
||||
id="records"
|
||||
title="Hired candidate records"
|
||||
meta={isFiltered ? `${filtered.length} of ${hires.length}` : `${hires.length} people`}
|
||||
/>
|
||||
<DataTable
|
||||
loading={isLoading}
|
||||
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: 'Candidate', sortable: true,
|
||||
cell: (h) => (
|
||||
<div className="flex min-w-0 items-center gap-2.5">
|
||||
<Avatar name={h.name} size="sm" />
|
||||
<div className="min-w-0">
|
||||
<p className="truncate font-medium text-ink-1">{h.name}</p>
|
||||
<p className="truncate text-[10px] text-ink-4">{h.email}</p>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{ key: 'role', header: 'Position', sortable: true, hideBelow: 'md' },
|
||||
{
|
||||
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: '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>
|
||||
);
|
||||
}
|
||||
|
||||
/* A page section is a singleton: `move` and `hide` are the only operations that
|
||||
mean anything for one. */
|
||||
const SECTION = ['move', 'hide'];
|
||||
|
||||
registerNodeType({
|
||||
type: 'hired-filters',
|
||||
/* This page's own section: it reads what this page publishes, so it belongs
|
||||
nowhere else. See `page` in the registry. */
|
||||
page: 'hired-history',
|
||||
label: 'Search and filters',
|
||||
summary: 'Search across every hire, and the fields worth filtering by.',
|
||||
component: HiredFilters,
|
||||
capabilities: SECTION,
|
||||
/* `FilterBar` destructures its props, so the renderer supplies a bare wrapper
|
||||
to carry the node's identity. No styling, no layout of its own. */
|
||||
wrap: true,
|
||||
});
|
||||
|
||||
registerNodeType({
|
||||
type: 'hired-summary',
|
||||
/* This page's own section: it reads what this page publishes, so it belongs
|
||||
nowhere else. See `page` in the registry. */
|
||||
page: 'hired-history',
|
||||
label: 'Hire summary',
|
||||
summary: 'How much of the record the current filters select.',
|
||||
component: HiredSummary,
|
||||
capabilities: SECTION,
|
||||
wrap: true,
|
||||
});
|
||||
|
||||
registerNodeType({
|
||||
type: 'hired-chronology',
|
||||
/* This page's own section: it reads what this page publishes, so it belongs
|
||||
nowhere else. See `page` in the registry. */
|
||||
page: 'hired-history',
|
||||
label: 'Recent hiring timeline',
|
||||
summary: 'The most recent hires, newest first.',
|
||||
component: HiredChronology,
|
||||
capabilities: SECTION,
|
||||
});
|
||||
|
||||
registerNodeType({
|
||||
type: 'hired-records',
|
||||
/* This page's own section: it reads what this page publishes, so it belongs
|
||||
nowhere else. See `page` in the registry. */
|
||||
page: 'hired-history',
|
||||
label: 'Hired candidate records',
|
||||
summary: 'Every hire, one row each, with position, client and score.',
|
||||
component: HiredRecords,
|
||||
capabilities: SECTION,
|
||||
});
|
||||
|
||||
/**
|
||||
* The page as it ships.
|
||||
*
|
||||
* `chronology` and `records` keep the ids their headings already carried, so a
|
||||
* node's identity is continuous with what the page has always said about
|
||||
* itself. The detail drawer is deliberately absent: it is an overlay, not a
|
||||
* section of the page, and nothing sensible would come of reordering it.
|
||||
*/
|
||||
registerPageComposition('hired-history', [
|
||||
{ id: 'hired-extensions-top', type: 'skill-surface', props: { page: 'hired-history', placement: 'after-header' } },
|
||||
{ id: 'hired-filters', type: 'hired-filters' },
|
||||
{ id: 'hired-summary', type: 'hired-summary' },
|
||||
{ id: 'chronology', type: 'hired-chronology' },
|
||||
{ id: 'records', type: 'hired-records' },
|
||||
]);
|
||||
36
src/pages/admin/positions/nodes.js
Normal file
36
src/pages/admin/positions/nodes.js
Normal file
@@ -0,0 +1,36 @@
|
||||
import { registerPageComposition } from '@/lib/ui/composition';
|
||||
|
||||
/**
|
||||
* Positions, page level only.
|
||||
*
|
||||
* Positions has three scopes and only one of them is flat. These two slots
|
||||
* render **once for the page**, with no position in context, which is what a
|
||||
* definition reporting across every role needs — and it is where the Board
|
||||
* skill's section lives.
|
||||
*
|
||||
* The other seven slots are *instanced*: `after-position-card` renders inside
|
||||
* each card, and five more render inside the drawer for one open position, each
|
||||
* with `context={{ position }}`. A single tree cannot address those, because
|
||||
* there is no one node for them — there are as many renderings as there are
|
||||
* records. Giving them identity needs a repeating-subtree model and a decision
|
||||
* about whether a change to one card means every card; that is deliberately not
|
||||
* attempted here, and the page keeps rendering them exactly as it always has.
|
||||
*
|
||||
* So this composition is honest about being partial: two nodes, drawn where
|
||||
* they have always been drawn, addressable like anything else.
|
||||
*/
|
||||
registerPageComposition('positions', [
|
||||
{
|
||||
id: 'positions-extensions-summary',
|
||||
type: 'skill-surface',
|
||||
props: { page: 'positions', placement: 'after-position-list-summary' },
|
||||
/* The separation this slot has always had from the grid below it. */
|
||||
layout: { spacingAfter: 'md' },
|
||||
},
|
||||
{
|
||||
id: 'positions-extensions-list',
|
||||
type: 'skill-surface',
|
||||
props: { page: 'positions', placement: 'after-position-list' },
|
||||
layout: { spacingBefore: 'lg' },
|
||||
},
|
||||
]);
|
||||
332
src/pages/admin/talent-pool/nodes.jsx
Normal file
332
src/pages/admin/talent-pool/nodes.jsx
Normal file
@@ -0,0 +1,332 @@
|
||||
import React from 'react';
|
||||
import { Bookmark, Star, UserRound, Trophy, CheckCircle2, TrendingUp, Sparkles } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import {
|
||||
Avatar, Badge, DataTable, IconButton, ProgressRing, SearchInput, toast,
|
||||
} from '@/components/ds';
|
||||
import { getScoreBand, toFICO } from '@/lib/talentHome';
|
||||
import { SectionTitle, Toolbar } from '@/components/admin/PageShell';
|
||||
import { FilterSelect } from '@/pages/admin/Positions';
|
||||
import { registerNodeType } from '@/lib/ui/registry';
|
||||
import { registerPageComposition } from '@/lib/ui/composition';
|
||||
import { useUiContext } from '@/components/ui-tree/UiTreeRenderer';
|
||||
|
||||
/**
|
||||
* Talent Pool, as addressable nodes.
|
||||
*
|
||||
* Moved verbatim from the page. The directory — its heading, its toolbar and
|
||||
* its table — is one node rather than three: hiding a table and leaving its
|
||||
* title behind is not a state anybody wants, and the three move together.
|
||||
*/
|
||||
|
||||
export const SORTS = {
|
||||
score: { label: 'Highest score', compare: (a, b) => (b.krow_score || 0) - (a.krow_score || 0) },
|
||||
experience: { label: 'Most experience', compare: (a, b) => (b.experience_years || 0) - (a.experience_years || 0) },
|
||||
name: { label: 'Name A\u2013Z', compare: (a, b) => a.full_name.localeCompare(b.full_name) },
|
||||
recent: { label: 'Recently added', compare: (a, b) => new Date(b.created_date).getTime() - new Date(a.created_date).getTime() },
|
||||
};
|
||||
|
||||
export const SEGMENT_CONFIG = {
|
||||
Elite: {
|
||||
key: 'elite',
|
||||
icon: Trophy,
|
||||
bg: 'bg-blue-50/80 text-blue-600 dark:bg-blue-950/60 dark:text-blue-400',
|
||||
border: 'border-blue-200/50 dark:border-blue-800/40 hover:border-blue-400',
|
||||
activeBorder: 'border-blue-600 ring-2 ring-blue-500/20',
|
||||
text: 'text-blue-600 dark:text-blue-400',
|
||||
accent: 'from-blue-500 to-indigo-500',
|
||||
},
|
||||
Excellent: {
|
||||
key: 'excellent',
|
||||
icon: Star,
|
||||
bg: 'bg-emerald-50/80 text-emerald-600 dark:bg-emerald-950/60 dark:text-emerald-400',
|
||||
border: 'border-emerald-200/50 dark:border-emerald-800/40 hover:border-emerald-400',
|
||||
activeBorder: 'border-emerald-600 ring-2 ring-emerald-500/20',
|
||||
text: 'text-emerald-600 dark:text-emerald-400',
|
||||
accent: 'from-emerald-500 to-teal-500',
|
||||
},
|
||||
Solid: {
|
||||
key: 'solid',
|
||||
icon: CheckCircle2,
|
||||
bg: 'bg-indigo-50/80 text-indigo-600 dark:bg-indigo-950/60 dark:text-indigo-400',
|
||||
border: 'border-indigo-200/50 dark:border-indigo-800/40 hover:border-indigo-400',
|
||||
activeBorder: 'border-indigo-600 ring-2 ring-indigo-500/20',
|
||||
text: 'text-indigo-600 dark:text-indigo-400',
|
||||
accent: 'from-indigo-500 to-purple-500',
|
||||
},
|
||||
Building: {
|
||||
key: 'building',
|
||||
icon: TrendingUp,
|
||||
bg: 'bg-amber-50/80 text-amber-600 dark:bg-amber-950/60 dark:text-amber-400',
|
||||
border: 'border-amber-200/50 dark:border-amber-800/40 hover:border-amber-400',
|
||||
activeBorder: 'border-amber-600 ring-2 ring-amber-500/20',
|
||||
text: 'text-amber-600 dark:text-amber-400',
|
||||
accent: 'from-amber-500 to-orange-500',
|
||||
},
|
||||
Unscored: {
|
||||
key: 'unscored',
|
||||
icon: Sparkles,
|
||||
bg: 'bg-orange-50/80 text-orange-600 dark:bg-orange-950/60 dark:text-orange-400',
|
||||
border: 'border-orange-200/50 dark:border-orange-800/40 hover:border-orange-400',
|
||||
activeBorder: 'border-orange-600 ring-2 ring-orange-500/20',
|
||||
text: 'text-orange-600 dark:text-orange-400',
|
||||
accent: 'from-orange-500 to-red-500',
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
/* Segments — executive metric cards */
|
||||
function TalentSegments({ attrs = {} }) {
|
||||
const { profiles, segments, band, setBand } = useUiContext();
|
||||
return (
|
||||
<section aria-labelledby="segments" className="space-y-3" {...attrs}>
|
||||
<SectionTitle
|
||||
id="segments"
|
||||
title="Talent segments"
|
||||
meta={`${profiles.length} in the pool`}
|
||||
/>
|
||||
<div>
|
||||
<div className="grid grid-cols-1 gap-3.5 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-5">
|
||||
{segments.map((s) => {
|
||||
const cfg = SEGMENT_CONFIG[s.label] || SEGMENT_CONFIG.Elite;
|
||||
const Icon = cfg.icon;
|
||||
const isSelected = band === cfg.key;
|
||||
const empty = s.count === 0;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={s.label}
|
||||
onClick={() => setBand(isSelected ? 'all' : cfg.key)}
|
||||
className={cn(
|
||||
'group relative flex flex-col justify-between cursor-pointer rounded-xl border bg-surface p-4 shadow-xs backdrop-blur-xs transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md',
|
||||
isSelected ? cfg.activeBorder : cfg.border
|
||||
)}
|
||||
>
|
||||
<div>
|
||||
{/* Header Row: Label, Hint & Icon */}
|
||||
<div className="flex items-center justify-between gap-1.5">
|
||||
<div className="flex items-baseline gap-1.5 min-w-0">
|
||||
<span className="truncate text-[11px] font-bold uppercase tracking-wider text-ink-1">
|
||||
{s.label}
|
||||
</span>
|
||||
<span className="truncate text-[10px] font-medium text-ink-4">
|
||||
{s.hint}
|
||||
</span>
|
||||
</div>
|
||||
<div className={cn('flex h-7 w-7 shrink-0 items-center justify-center rounded-lg shadow-xs', cfg.bg)}>
|
||||
<Icon className="h-3.5 w-3.5" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Main count */}
|
||||
<div className="mt-2.5 flex items-baseline gap-1.5">
|
||||
<span
|
||||
className={cn(
|
||||
'font-heading text-title-xl font-bold leading-none tabular-nums',
|
||||
empty ? 'text-ink-4' : cfg.text
|
||||
)}
|
||||
>
|
||||
{s.count}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Sub pill & Accent bar */}
|
||||
<div className="mt-3.5 space-y-2">
|
||||
<span className="inline-flex items-center rounded-full bg-surface-subtle px-2 py-0.5 text-[10px] font-semibold text-ink-3 border border-border/60">
|
||||
{empty
|
||||
? 'No profiles'
|
||||
: [
|
||||
`${s.available} available`,
|
||||
s.label === 'Unscored'
|
||||
? 'needs verification'
|
||||
: s.avgExperience > 0
|
||||
? `${s.avgExperience}y avg`
|
||||
: null,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' · ')}
|
||||
</span>
|
||||
|
||||
<div className={cn('h-0.5 w-full rounded-full bg-gradient-to-r opacity-40 group-hover:opacity-100 transition-opacity', cfg.accent)} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<p className="mt-3 border-t border-border/60 pt-3 text-caption leading-relaxed text-ink-3">
|
||||
{segments[4].count > segments[0].count + segments[1].count
|
||||
? `${segments[4].count} workers carry no career score against ${segments[0].count + segments[1].count} at Excellent or above. Supply is not the constraint here — verification is.`
|
||||
: 'The scored part of the pool outweighs the unscored, so matching has enough to work with.'}
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
/** The directory: its heading, its filters and the ranked table. */
|
||||
function TalentDirectory({ attrs = {} }) {
|
||||
const {
|
||||
profiles, filtered, isLoading, isFiltered, clearFilters, saved, toggleSave,
|
||||
search, setSearch, skill, setSkill, experience, setExperience, location, setLocation,
|
||||
band, setBand, availability, setAvailability, sort, setSort,
|
||||
skills, locations, availabilities,
|
||||
} = useUiContext();
|
||||
|
||||
/* `space-y-6` because these three were direct children of the page's own
|
||||
stack and drew their separation from it. Grouping them into one node has to
|
||||
carry that rhythm inward, or the heading, the filters and the table close
|
||||
up against each other. */
|
||||
return (
|
||||
<div className="space-y-6" {...attrs}>
|
||||
<SectionTitle title="Talent directory" meta={`${profiles.length} profiles`} />
|
||||
<Toolbar
|
||||
search={<SearchInput value={search} onChange={setSearch} placeholder="Search name, role or skill" size="sm" />}
|
||||
filters={
|
||||
<>
|
||||
<FilterSelect value={skill} onChange={setSkill} label="Skills"
|
||||
options={[{ value: 'all', label: 'All skills' }, ...skills.map((s) => ({ value: s, label: s }))]} />
|
||||
<FilterSelect value={experience} onChange={setExperience} label="Experience" options={[
|
||||
{ value: 'all', label: 'Any experience' }, { value: '5plus', label: '5+ years' },
|
||||
{ value: '2to5', label: '2–5 years' }, { value: 'under2', label: 'Under 2 years' },
|
||||
]} />
|
||||
<FilterSelect value={location} onChange={setLocation} label="Location"
|
||||
options={[{ value: 'all', label: 'All locations' }, ...locations.map((l) => ({ value: l, label: l }))]} />
|
||||
<FilterSelect value={band} onChange={setBand} label="Score" options={[
|
||||
{ value: 'all', label: 'All scores' }, { value: 'elite', label: 'Elite 90+' },
|
||||
{ value: 'excellent', label: 'Excellent 75+' }, { value: 'solid', label: 'Solid 60+' },
|
||||
{ value: 'building', label: 'Building' }, { value: 'unscored', label: 'Unscored' },
|
||||
]} />
|
||||
<FilterSelect value={availability} onChange={setAvailability} label="Availability"
|
||||
options={[{ value: 'all', label: 'Any availability' }, ...availabilities.map((a) => ({ value: a, label: a }))]} />
|
||||
<FilterSelect value={sort} onChange={setSort} label="Sort"
|
||||
options={Object.entries(SORTS).map(([value, s]) => ({ value, label: s.label }))} />
|
||||
</>
|
||||
}
|
||||
meta={`${filtered.length} of ${profiles.length} talents${isFiltered ? ' · filtered' : ''}${saved.length ? ` · ${saved.length} saved` : ''}`}
|
||||
/>
|
||||
|
||||
<DataTable
|
||||
loading={isLoading}
|
||||
rows={filtered}
|
||||
pageSize={15}
|
||||
isFiltered={isFiltered}
|
||||
onClearFilters={clearFilters}
|
||||
caption="Talent pool ranked by career score"
|
||||
columns={[
|
||||
{
|
||||
key: 'full_name', header: 'Candidate', sortable: true,
|
||||
cell: (p) => (
|
||||
<div className="flex min-w-0 items-center gap-2.5">
|
||||
<Avatar name={p.full_name} src={p.selfie_url} size="sm" />
|
||||
<div className="min-w-0">
|
||||
<p className="truncate font-medium text-ink-1">{p.full_name}</p>
|
||||
<p className="truncate text-[10px] text-ink-4">{p.address || p.email}</p>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'role', header: 'Role', hideBelow: 'md',
|
||||
cell: (p) => (
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-ink-2">{p.current_position || p.desired_position || '—'}</p>
|
||||
{p.desired_position && p.current_position && (
|
||||
<p className="truncate text-[10px] text-ink-4">wants {p.desired_position}</p>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'skills', header: 'Skills', hideBelow: 'lg',
|
||||
cell: (p) => (p.skills?.length ? (
|
||||
<div className="flex items-center gap-1">
|
||||
<Badge variant="neutral" size="sm">{p.skills[0]}</Badge>
|
||||
{p.skills.length > 1 && <span className="text-[10px] text-ink-4">+{p.skills.length - 1}</span>}
|
||||
</div>
|
||||
) : <span className="text-[11px] text-ink-4">—</span>),
|
||||
},
|
||||
{
|
||||
key: 'experience_years', header: 'Exp', align: 'right', sortable: true, hideBelow: 'md',
|
||||
cell: (p) => (p.experience_years ? `${p.experience_years}y` : '—'),
|
||||
},
|
||||
{
|
||||
key: 'availability', header: 'Availability', hideBelow: 'lg',
|
||||
cell: (p) => (p.availability?.length
|
||||
? <span className="text-[11px] text-ink-3">{p.availability.join(', ')}</span>
|
||||
: <Badge variant="warning" size="sm">Not set</Badge>),
|
||||
},
|
||||
{
|
||||
key: 'krow_score', header: 'Career score', align: 'right', sortable: true,
|
||||
cell: (p) => {
|
||||
const score = p.krow_score || 0;
|
||||
const bandInfo = getScoreBand(score);
|
||||
return (
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<div className="text-right">
|
||||
<p className="font-heading text-body font-bold tabular-nums text-ink-1">{toFICO(score)}</p>
|
||||
<p className="text-[10px] text-ink-4">{bandInfo.label}</p>
|
||||
</div>
|
||||
<ProgressRing value={score} size={26} strokeWidth={3} tone="score" label="" />
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'actions', header: '', align: 'right', width: 92,
|
||||
cell: (p) => (
|
||||
<div className="flex items-center justify-end gap-0.5" onClick={(e) => e.stopPropagation()} role="presentation">
|
||||
<IconButton
|
||||
icon={saved.includes(p.id) ? Star : Bookmark}
|
||||
label={saved.includes(p.id) ? `Remove ${p.full_name} from saved` : `Save ${p.full_name}`}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => toggleSave(p.id, p.full_name)}
|
||||
/>
|
||||
<IconButton
|
||||
icon={UserRound}
|
||||
label={`View ${p.full_name}'s profile`}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => toast.info(`Opening ${p.full_name}'s KROW Identity`)}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const SECTION = ['move', 'hide'];
|
||||
|
||||
registerNodeType({
|
||||
type: 'talent-segments',
|
||||
/* This page's own section: it reads what this page publishes, so it belongs
|
||||
nowhere else. See `page` in the registry. */
|
||||
page: 'talent-pool',
|
||||
label: 'Talent segments',
|
||||
summary: 'The pool split into score bands, each a filter.',
|
||||
component: TalentSegments,
|
||||
capabilities: SECTION,
|
||||
});
|
||||
|
||||
registerNodeType({
|
||||
type: 'talent-directory',
|
||||
/* This page's own section: it reads what this page publishes, so it belongs
|
||||
nowhere else. See `page` in the registry. */
|
||||
page: 'talent-pool',
|
||||
label: 'Talent directory',
|
||||
summary: 'Every profile, filtered and ranked by career score.',
|
||||
component: TalentDirectory,
|
||||
capabilities: SECTION,
|
||||
});
|
||||
|
||||
registerPageComposition('talent-pool', [
|
||||
{ id: 'talent-extensions-top', type: 'skill-surface', props: { page: 'talent-pool', placement: 'after-header' } },
|
||||
{ id: 'segments', type: 'talent-segments' },
|
||||
{ id: 'directory', type: 'talent-directory' },
|
||||
{ id: 'talent-extensions-bottom', type: 'skill-surface', props: { page: 'talent-pool', placement: 'before-footer' } },
|
||||
]);
|
||||
@@ -57,13 +57,18 @@ which already carry the funnel, the interview and the outcome.
|
||||
Each line is `field | question | suggestions | required?`. Suggestions beginning
|
||||
with `@` come from the application's own data.
|
||||
|
||||
`@workers` is the worker profiles already on screen for this organization.
|
||||
Picking one records the role against that person's profile and email; typing an
|
||||
email address that has no profile yet also works, because a role can be declared
|
||||
before a profile exists. The worker is always asked for and is never assumed to
|
||||
be whoever is typing — an operator records this on somebody's behalf.
|
||||
This creates a NEW employee. The name is not looked up — an organization may
|
||||
employ several people who share one, so a name selects nobody — and the email is
|
||||
asked for outright and never derived from the name, from the operator's account
|
||||
or from an earlier conversation. The email is the identity: `worker_profiles`
|
||||
carries UNIQUE (org_id, email), so the database decides whether this person
|
||||
already exists.
|
||||
|
||||
- worker | Which worker is this role for? Type their name or email. | @workers | required
|
||||
Recording another role for somebody who is already on file is a different
|
||||
request and is not this flow.
|
||||
|
||||
- worker_name | What is the new employee’s full name? | | required
|
||||
- worker_email | What is their email address? | | required
|
||||
- role_category | What role do they work as? | @roles | required
|
||||
- experience_years | How much experience do they have? | No experience; 1 year; 2 years; 3+ years | optional
|
||||
- english_level | What is their English level? | @english | optional
|
||||
|
||||
Reference in New Issue
Block a user