diff --git a/public/krow logo.jpeg b/public/krow logo.jpeg new file mode 100644 index 0000000..2b698b8 Binary files /dev/null and b/public/krow logo.jpeg differ diff --git a/src/App.jsx b/src/App.jsx index 5850b8e..366faa5 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -1,7 +1,7 @@ import { Toaster } from "@/components/ui/toaster" import { QueryClientProvider } from '@tanstack/react-query' import { queryClientInstance } from '@/lib/query-client' -import { BrowserRouter as Router, Route, Routes } from 'react-router-dom'; +import { Navigate, BrowserRouter as Router, Route, Routes } from 'react-router-dom'; import PageNotFound from './lib/PageNotFound'; import { AuthProvider, useAuth } from '@/lib/AuthContext'; import ScrollToTop from './components/ScrollToTop'; @@ -23,10 +23,25 @@ import CourseDetail from '@/pages/CourseDetail'; import WorkerProfile from '@/pages/WorkerProfile'; import KrowIdentity from '@/pages/KrowIdentity'; import Owliver from '@/pages/Owliver'; -import AdminDashboard from '@/pages/AdminDashboard'; import EmployeeDashboard from '@/pages/EmployeeDashboard'; import DesignSystem from '@/pages/DesignSystem'; +/* Admin product — its own layout and route tree, so the Admin redesign can + diverge without touching the Employer or Talent experiences. */ +import AdminLayout from '@/layouts/AdminLayout'; +import ControlCenter from '@/pages/admin/ControlCenter'; +import AdminPositions from '@/pages/admin/Positions'; +import AdminCandidates from '@/pages/admin/Candidates'; +import AdminCandidateProfile from '@/pages/admin/CandidateProfile'; +import AdminCandidatesAnalysis from '@/pages/admin/CandidatesAnalysis'; +import AdminHiredHistory from '@/pages/admin/HiredHistory'; +import AdminTalentPool from '@/pages/admin/TalentPool'; +import AdminAnalytics from '@/pages/admin/Analytics'; +import AdminActivity from '@/pages/admin/Activity'; +import AdminProfile from '@/pages/admin/Profile'; +import AdminLogin from '@/pages/admin/Login'; +import AdminRoute from '@/pages/admin/AdminRoute'; + const AuthenticatedApp = () => { const { isLoadingAuth, isLoadingPublicSettings } = useAuth(); @@ -40,29 +55,44 @@ const AuthenticatedApp = () => { return ( + {/* Admin sign-in — outside the protected boundary. */} + } /> + }> - }> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - {/* Design system reference — not in product navigation. */} - } /> + {/* The application opens on the Admin console, which is entered through + its own sign-in. Employer keeps every one of its routes, including + Overview at an explicit path — nothing of that product is removed, + only the address the app happens to start at. */} + } /> + + }> + }> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + + + {/* Redirect top-level legacy aliases to the unified global shell */} + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> } /> diff --git a/src/components/ai-assistant/AssistantMessage.jsx b/src/components/ai-assistant/AssistantMessage.jsx index 9a51df9..c27c3bb 100644 --- a/src/components/ai-assistant/AssistantMessage.jsx +++ b/src/components/ai-assistant/AssistantMessage.jsx @@ -24,7 +24,7 @@ export function ThinkingIndicator() { /> ))} - Krow AI is preparing a response + Owliver is preparing a response ); } @@ -54,7 +54,7 @@ export const Message = React.memo(({ role, text, blocks, streaming, stopped }) = - Krow AI + Owliver diff --git a/src/components/ai-assistant/AssistantPanel.jsx b/src/components/ai-assistant/AssistantPanel.jsx index f839a30..35a39ad 100644 --- a/src/components/ai-assistant/AssistantPanel.jsx +++ b/src/components/ai-assistant/AssistantPanel.jsx @@ -1,27 +1,34 @@ import * as React from 'react'; -import { AnimatePresence, motion } from 'framer-motion'; +import { PanelLeftOpen, Sparkles } from 'lucide-react'; import { cn } from '@/lib/utils'; import KrowAssistant from './KrowAssistant'; import { useAssistantPanel } from './AssistantPanelContext'; +import { ResizeDivider } from './ResizeDivider'; -const DEFAULT_WIDTH = 380; const EXPANDED_WIDTH = 620; -/** Gutter separating the panel from the dashboard. */ -const GUTTER = 24; +/** + * Gutter separating the panel from the dashboard, split between the drag handle + * and the panel's own lead padding. Both halves are named because the track width + * is their sum plus the panel: get that arithmetic wrong and the column either + * overflows or leaves a strip of unexplained empty space at the shell's edge. + */ +const HANDLE_WIDTH = 12; +const LEAD_PADDING = 8; +const GUTTER = HANDLE_WIDTH + LEAD_PADDING; /** Expanded must never dominate: the dashboard stays the primary experience. */ const MAX_VIEWPORT_SHARE = 0.42; /** * Below this width there is no room for a column *beside* the dashboard — a - * 380px track on a 375px phone collapses `main` to nothing — so the panel - * becomes a bottom sheet over the page instead of an inline column. + * 380px track on a 375px phone collapses `main` to nothing — so Owliver stacks + * underneath the page instead. * - * Deliberately Tailwind's `md` (768px), not `lg`: tablets already lay the - * inline column out acceptably, so they keep the existing behaviour and only - * phones switch to the sheet. + * Deliberately Tailwind's `md` (768px), not `lg`: tablets already lay the inline + * column out acceptably, so they keep the two-column workspace and only phones + * stack. */ -const OVERLAY_BREAKPOINT = 768; +const STACK_BREAKPOINT = 768; -/** Tracks viewport width so the expanded panel can be clamped on smaller screens. */ +/** Tracks viewport width so the panel can be clamped and the layout switched. */ function useViewportWidth() { const [width, setWidth] = React.useState(() => typeof window === 'undefined' ? 1440 : window.innerWidth @@ -37,121 +44,156 @@ function useViewportWidth() { } /** - * AssistantPanel — the sliding column the layout renders. + * The collapsed state — a compact docked trigger. * - * Animating the aside's `width` (rather than overlaying) is what makes the - * dashboard reflow smoothly instead of being covered, and what lets closing - * restore the original layout exactly. + * The earlier version was a full-height rail inside the layout column. It solved + * the wrong problem: it kept Owliver *findable*, but it also kept the column, so + * collapsing bought the dashboard 44px instead of the 400px the user was asking + * for, and it did it with rotated text down the side of the page. * - * Two structural details matter, and both were bugs at one point: + * So collapsing now removes the column entirely — the layout goes to one column + * and `main` takes the whole shell — and the way back is this small docked pill. + * It is deliberately the only floating control in the product, and it exists only + * in the collapsed state, where there is nowhere else for it to live. + */ +function CollapsedTrigger({ page, onRestore }) { + return ( + + ); +} + +/** + * AssistantPanel — the Owliver workspace column the Admin layout renders. * - * 1. The clip lives *inside* the sticky element. An ancestor with - * `overflow: hidden` becomes the nearest scroll container and silently - * disables `position: sticky` beneath it. + * Owliver is part of the page on every supported route, so this is a column in + * the layout rather than an overlay: the dashboard reflows beside it instead of + * being covered, and collapsing restores the original layout exactly. + * + * Five structural details matter, and every one of them was a bug at some point: + * + * 1. Nothing above the sticky element clips. An ancestor with `overflow: hidden` + * becomes the nearest scroll container and silently disables + * `position: sticky` beneath it. * 2. The aside stretches to the shell's full height (`self-stretch`), so its * sticky context runs the whole page rather than ending partway down and * leaving empty space beside a long dashboard. + * 3. `min-w-0` on the aside. A flex item's default `min-width: auto` refuses to + * go narrower than its content, so the track ignored its own width and stalled + * at the panel's old size on the way back from expanded. + * 4. Only the track has a width. The panel fills what the track leaves after the + * handle and the lead padding, so the two can never disagree — which is + * exactly what (3) was: two widths transitioning, one winning. + * 5. Collapsing removes the column outright and leaves a compact docked trigger. + * An earlier version kept a full-height rail in place, which bought the + * dashboard 44px when the user had asked for 400 — and spelled "Owliver" down + * the side of the page to do it. * - * Expanding widens the same panel in place — it is an analysis workspace inside - * the application layout, not a fullscreen takeover, and never covers the - * dashboard. + * Expanding widens the same panel in place — an analysis workspace inside the + * application layout, never a fullscreen takeover. */ export function AssistantPanel({ stickyClassName, panelHeightClassName }) { - const { context, isOpen, isExpanded, close, expand, restore } = useAssistantPanel(); + const { + context, isOpen, isExpanded, width, minWidth, maxWidth, + setWidth, resetWidth, open, close, expand, restore, + } = useAssistantPanel(); const viewportWidth = useViewportWidth(); - // Clamped so a wide panel cannot squeeze the dashboard on a small screen. + // No assistant on this route: no column, no rail, no trace in the layout. + if (!context) return null; + + /* Expanded overrides the dragged width; otherwise the user's own width wins. + Both are clamped against the viewport so the dashboard is never squeezed. */ + const viewportCap = Math.round(viewportWidth * MAX_VIEWPORT_SHARE); const panelWidth = isExpanded - ? Math.max(DEFAULT_WIDTH, Math.min(EXPANDED_WIDTH, Math.round(viewportWidth * MAX_VIEWPORT_SHARE))) - : DEFAULT_WIDTH; + ? Math.max(width, Math.min(EXPANDED_WIDTH, viewportCap)) + : Math.min(width, Math.max(minWidth, viewportCap)); + + /* Collapsed: no column at all. The layout falls back to a single column, `main` + takes the whole shell, and the only thing left is the docked trigger — so + there is never a reserved empty gutter where the panel used to be. */ + if (!isOpen) { + return ; + } const trackWidth = panelWidth + GUTTER; - const easing = [0.65, 0, 0.35, 1]; - // Phone/small-tablet: a sheet over the dashboard rather than a column beside - // it. Same assistant, same props — only the container differs. - if (viewportWidth < OVERLAY_BREAKPOINT) { + /* Phones: stack Owliver under the dashboard. A 380px column beside the page is + not a layout at this width, and an overlay that opens on load would put a + sheet between the user and the page they asked for. */ + if (viewportWidth < STACK_BREAKPOINT) { return ( - - {isOpen && context && ( - <> - - - - - - )} - + ); } return ( - - {isOpen && context && ( - -
- {/* The clip: contains the slide-in and the panel width while the - track animates, without breaking the sticky above it. */} - - - {/* Keyed on the context so switching pages gives a clean panel - and aborts any response still streaming for the last one. */} - - - -
-
- )} -
+ ); } diff --git a/src/components/ai-assistant/AssistantPanelContext.jsx b/src/components/ai-assistant/AssistantPanelContext.jsx index 144fa7c..332d66a 100644 --- a/src/components/ai-assistant/AssistantPanelContext.jsx +++ b/src/components/ai-assistant/AssistantPanelContext.jsx @@ -2,31 +2,48 @@ import * as React from 'react'; import { resolveAssistantContext } from './placement'; /** - * Window state for the Krow AI Assistant: open/closed and default/expanded. + * Window state for the Owliver panel: open/collapsed and default/expanded. * - * Lives in context rather than in the layout so the trigger can sit anywhere in - * the page — beside that page's own actions — without the layout having to know - * where, and without threading props through every page. + * Lives in context rather than in the layout so any control — the panel's own + * header, the collapsed rail — can change the workspace without the layout + * having to know where it sits, and without threading props through every page. * - * The assistant is closed by default. The dashboard is the primary experience; - * the assistant is something you open when you want it. + * **Owliver is open by default.** On a supported Admin page the workspace *is* + * the dashboard plus Owliver: it is an intelligence layer over the page, not a + * feature you go and fetch. Collapsing is therefore a deliberate act by someone + * who wants the full width for a while, which is why the collapsed state is a + * visible rail rather than a dismissal (see AssistantPanel). */ const AssistantPanelContext = React.createContext(null); const OPEN_KEY = 'krow_assistant_open'; const EXPANDED_KEY = 'krow_assistant_expanded'; +const WIDTH_KEY = 'krow_assistant_width'; + +/** Drag limits. Below 340px the reports stop being readable; above 520px the + * panel starts competing with the dashboard for primacy. */ +export const MIN_WIDTH = 340; +export const MAX_WIDTH = 520; +export const DEFAULT_WIDTH = 380; + +const clampWidth = (value) => Math.min(MAX_WIDTH, Math.max(MIN_WIDTH, Math.round(value))); /** * Window state is kept for the session so moving between supported pages does - * not collapse a panel the user deliberately opened or expanded, while a new - * session still starts clean on the dashboard. + * not undo a panel the user deliberately collapsed or expanded, while a new + * session still starts from the default workspace. + * + * `fallback` is what an untouched session gets. Absence has to mean "default" + * rather than "off", or the open-by-default panel would only appear after + * someone had already opened it once. */ -function readFlag(key) { +function readFlag(key, fallback = false) { try { - return sessionStorage.getItem(key) === '1'; + const stored = sessionStorage.getItem(key); + return stored === null ? fallback : stored === '1'; } catch { - return false; + return fallback; } } @@ -39,8 +56,16 @@ function writeFlag(key, value) { } export function AssistantPanelProvider({ role, pathname, children }) { - const [open, setOpen] = React.useState(() => readFlag(OPEN_KEY)); + const [open, setOpen] = React.useState(() => readFlag(OPEN_KEY, true)); const [expanded, setExpanded] = React.useState(() => readFlag(EXPANDED_KEY)); + const [width, setWidth] = React.useState(() => { + try { + const stored = Number(sessionStorage.getItem(WIDTH_KEY)); + return stored ? clampWidth(stored) : DEFAULT_WIDTH; + } catch { + return DEFAULT_WIDTH; + } + }); const context = React.useMemo( () => resolveAssistantContext(role, pathname), @@ -63,14 +88,19 @@ export function AssistantPanelProvider({ role, pathname, children }) { }, []); /** - * Escape steps back one level rather than closing outright: expanded → default - * → closed. Collapsing a workspace and dismissing it are different intentions, - * and one key press should not do both. + * Escape steps back one level rather than collapsing outright: expanded → + * default → collapsed. Narrowing a workspace and putting it away are different + * intentions, and one key press should not do both. + * + * Ignored while the caret is in the composer, where Escape belongs to whatever + * the user is typing rather than to the layout. */ React.useEffect(() => { if (!isOpen) return undefined; const onKey = (e) => { if (e.key !== 'Escape') return; + const tag = document.activeElement?.tagName; + if (tag === 'TEXTAREA' || tag === 'INPUT') return; if (isExpanded) setExpandedState(false); else setOpenState(false); }; @@ -78,17 +108,37 @@ export function AssistantPanelProvider({ role, pathname, children }) { return () => window.removeEventListener('keydown', onKey); }, [isOpen, isExpanded, setExpandedState, setOpenState]); + /* Width is written on every drag frame, so persistence is debounced rather + than hitting sessionStorage sixty times a second. */ + const setWidthState = React.useCallback((next) => { + const clamped = clampWidth(next); + setWidth(clamped); + clearTimeout(setWidthState.timer); + setWidthState.timer = setTimeout(() => { + try { + sessionStorage.setItem(WIDTH_KEY, String(clamped)); + } catch { + // Non-persistent session is fine. + } + }, 200); + }, []); + const value = React.useMemo(() => ({ context, supported, isOpen, isExpanded, + width, + minWidth: MIN_WIDTH, + maxWidth: MAX_WIDTH, + setWidth: setWidthState, + resetWidth: () => setWidthState(DEFAULT_WIDTH), open: () => setOpenState(true), close: () => setOpenState(false), toggle: () => setOpenState(!isOpen), expand: () => setExpandedState(true), restore: () => setExpandedState(false), - }), [context, supported, isOpen, isExpanded, setOpenState, setExpandedState]); + }), [context, supported, isOpen, isExpanded, width, setWidthState, setOpenState, setExpandedState]); return ( @@ -109,6 +159,11 @@ export function useAssistantPanel() { supported: false, isOpen: false, isExpanded: false, + width: DEFAULT_WIDTH, + minWidth: MIN_WIDTH, + maxWidth: MAX_WIDTH, + setWidth: () => {}, + resetWidth: () => {}, open: () => {}, close: () => {}, toggle: () => {}, diff --git a/src/components/ai-assistant/AssistantTrigger.jsx b/src/components/ai-assistant/AssistantTrigger.jsx index 7272fb3..a01ac63 100644 --- a/src/components/ai-assistant/AssistantTrigger.jsx +++ b/src/components/ai-assistant/AssistantTrigger.jsx @@ -20,7 +20,7 @@ export function AssistantTrigger({ className, size = 'default' }) { if (!supported) return null; - const label = isOpen ? 'Close Krow AI' : `Ask Krow AI about ${context.page}`; + const label = isOpen ? 'Close Owliver' : `Ask Owliver about ${context.page}`; const heights = { sm: 'h-8 px-2.5', default: 'h-9 px-3' }; const logoHeights = { sm: 'h-3.5', default: 'h-4' }; diff --git a/src/components/ai-assistant/KrowAssistant.jsx b/src/components/ai-assistant/KrowAssistant.jsx index a9bcb8c..9bf9e73 100644 --- a/src/components/ai-assistant/KrowAssistant.jsx +++ b/src/components/ai-assistant/KrowAssistant.jsx @@ -1,10 +1,9 @@ import * as React from 'react'; -import { Maximize2, Minimize2, RotateCcw, Sparkles, X } from 'lucide-react'; +import { Maximize2, Minimize2, PanelRightClose, RotateCcw, Sparkles } 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 { KROW_LOGO_URL } from '@/assets/brand'; import { useAssistantFacts, useConversation, useCurrentUserName } from './useAssistant'; import { buildIntro, buildPrompts } from './dynamic'; import { Message, ThinkingIndicator, TurnDivider } from './AssistantMessage'; @@ -12,7 +11,7 @@ import { PromptInput } from './PromptInput'; import { PromptChips } from './PromptChips'; /** - * Krow AI Assistant — the dashboard's contextual assistant. + * Owliver — the dashboard's contextual panel. * * One component, reused on every supported page; only `context` differs. * @@ -20,13 +19,11 @@ import { PromptChips } from './PromptChips'; * scrolls. That is what lets the panel stay pinned beside a long dashboard * without ever growing the page or scrolling its own input out of reach. * - * Two states share that frame: - * - * - **Landing.** One centred block: mark, contextual label, greeting, - * description, input, suggestions. Deliberately light — it is an invitation, - * not a page, so nothing in it competes with the dashboard beside it. - * - **Conversation.** The thread takes the body and the composer returns to the - * bottom, because from then on the thread is what the user is reading. + * The frame does not change between states, only the body's content does: an + * empty thread shows the greeting and this page's most relevant fact, and once a + * question is asked the same region holds the conversation. Keeping the composer + * anchored either way means the primary action never moves under the user, and + * the panel never reads as two different components. * * `expanded` widens the same panel into an analysis workspace. `data-wide` lets * individual response blocks use the extra room (a KPI row goes to three @@ -104,7 +101,7 @@ export default function KrowAssistant({ data-wide={expanded || undefined} className={cn('flex flex-col overflow-hidden', className)} role="region" - aria-label="Krow AI Assistant" + aria-label="Owliver assistant" > {/* Header */}
@@ -113,7 +110,7 @@ export default function KrowAssistant({
-

Krow AI

+

Owliver

{context.page}

@@ -124,56 +121,42 @@ export default function KrowAssistant({ )} {expanded ? onRestore && ( - + ) : onExpand && ( - + )} {onClose && ( - + )}
- {isEmpty ? ( - /* ── Landing: one compact centred block ───────────────────────── */ -
- {/* Capped so the block stays a tight composition when expanded. */} -
-
- - - - - -

- {intro.title} -

-

- {intro.greeting} -

-

{intro.description}

-
- - {composer} - - + {/* ── Body: the only region that scrolls ─────────────────────────── */} +
+ {isEmpty ? ( + /* Greeting and the single most relevant fact about this page. Top + aligned rather than centred: it is the first thing in a + conversation, not a splash screen, so it belongs where the first + message would be. */ +
+

+ {intro.title} +

+

+ {intro.greeting} +

+

+ {intro.description} +

-
- ) : ( - /* ── Conversation ─────────────────────────────────────────────── */ - <> -
+ ) : ( +
{messages.map((message, i) => ( {i > 0 && message.role === 'user' && } @@ -193,25 +176,28 @@ export default function KrowAssistant({ {error && {error}}
+ )} +
-
- {/* Capped in conversation: the vertical space belongs to the thread. - Expanded fits more per line, so it can afford one more. */} - {!busy && ( - - )} - {composer} -

- Krow AI reads this page's data. Check anything you act on. -

-
- - )} + {/* ── Composer: fixed to the bottom in both states ───────────────── */} +
+ {/* 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. */} + {!busy && ( + + )} + {composer} +

+ Owliver reads this page's data. Check anything you act on. +

+
); } diff --git a/src/components/ai-assistant/PromptInput.jsx b/src/components/ai-assistant/PromptInput.jsx index 3f006a3..7c84ee4 100644 --- a/src/components/ai-assistant/PromptInput.jsx +++ b/src/components/ai-assistant/PromptInput.jsx @@ -10,8 +10,12 @@ import { cn } from '@/lib/utils'; * deliberate newline. That combination is what makes typing a real question feel * natural instead of cramped. * - * `placeholders` rotate while the field is empty and unfocused, which teaches - * the range of what can be asked without spending vertical space on a legend. + * `placeholders` is a list, but only the first is shown. It used to rotate every + * few seconds; that was defensible when the panel opened on demand and wrong once + * Owliver became permanent furniture, because a permanently visible field that + * rewrites itself forever is movement in the corner of the eye with nothing to + * gain — the suggestion chips already teach the range of what can be asked, and + * they do it without a timer or a rerender every four seconds. */ export function PromptInput({ value, @@ -26,7 +30,6 @@ export function PromptInput({ }) { const textareaRef = React.useRef(null); const [focused, setFocused] = React.useState(false); - const [placeholderIndex, setPlaceholderIndex] = React.useState(0); /* Grow to fit the content, up to a cap, then scroll internally. */ React.useEffect(() => { @@ -36,30 +39,6 @@ export function PromptInput({ el.style.height = `${Math.min(el.scrollHeight, 148)}px`; }, [value]); - /* Rotate the examples only while the field is idle — changing the placeholder - under someone who is mid-thought is distracting. The crossfade is done with - a real element rather than the native placeholder, which cannot animate. */ - const [fading, setFading] = React.useState(false); - - React.useEffect(() => { - if (value || focused || placeholders.length < 2) return undefined; - const timer = setInterval(() => { - setFading(true); - // Swap the text at the midpoint of the fade so neither string is visible - // while it changes. - setTimeout(() => { - setPlaceholderIndex((i) => (i + 1) % placeholders.length); - setFading(false); - }, 220); - }, 4000); - return () => clearInterval(timer); - }, [value, focused, placeholders.length]); - - /* A shorter list on a context change must not leave the index out of range. */ - React.useEffect(() => { - setPlaceholderIndex(0); - }, [placeholders]); - React.useEffect(() => { if (autoFocus) textareaRef.current?.focus(); }, [autoFocus]); @@ -100,7 +79,7 @@ export function PromptInput({ onFocus={() => setFocused(true)} onBlur={() => setFocused(false)} disabled={busy} - aria-label="Ask Krow AI" + aria-label="Ask Owliver" aria-busy={busy || undefined} className={cn( 'block w-full resize-none bg-transparent leading-relaxed text-ink-1', @@ -110,19 +89,19 @@ export function PromptInput({ )} /> - {/* Animated placeholder. Hidden from assistive tech — the textarea's - aria-label is the accessible name. */} + {/* Rendered as an element rather than the native `placeholder` so it can + be truncated and swapped for the busy state. Hidden from assistive + tech — the textarea's aria-label is the accessible name. */} {!value && ( )}
diff --git a/src/components/ai-assistant/ResizeDivider.jsx b/src/components/ai-assistant/ResizeDivider.jsx new file mode 100644 index 0000000..08a4c9d --- /dev/null +++ b/src/components/ai-assistant/ResizeDivider.jsx @@ -0,0 +1,110 @@ +import * as React from 'react'; +import { GripVertical } from 'lucide-react'; +import { cn } from '@/lib/utils'; + +/** + * ResizeDivider — the drag handle between the dashboard and the Owliver panel. + * + * The hit area is deliberately wider than the visible line: a 1px target is + * unusable, so the divider occupies 12px and shows a 2px rule inside it. The + * grip dots only appear on hover, which keeps a permanent chrome element from + * competing with the content on either side. + * + * Keyboard support is not a nicety here — a pointer-only resize is unreachable + * for anyone who cannot drag. Arrow keys nudge by 16px, Home/End jump to the + * limits, and the whole thing is exposed as a `separator` with live values. + */ +export function ResizeDivider({ width, min, max, onResize, onDoubleClick, className }) { + const [dragging, setDragging] = React.useState(false); + + /* Pointer events on the window rather than the element: once a drag starts the + pointer routinely leaves the 12px handle, and a listener bound to the handle + would drop the drag the moment it did. */ + React.useEffect(() => { + if (!dragging) return undefined; + + const onMove = (e) => { + // The panel is right-aligned, so distance from the right edge is the width. + onResize(Math.round(window.innerWidth - e.clientX)); + }; + const stop = () => setDragging(false); + + window.addEventListener('pointermove', onMove); + window.addEventListener('pointerup', stop); + window.addEventListener('pointercancel', stop); + + // Suppress text selection and swap the cursor for the whole document, so the + // cursor does not flicker back whenever the pointer crosses other elements. + const previousCursor = document.body.style.cursor; + document.body.style.cursor = 'col-resize'; + document.body.style.userSelect = 'none'; + + return () => { + window.removeEventListener('pointermove', onMove); + window.removeEventListener('pointerup', stop); + window.removeEventListener('pointercancel', stop); + document.body.style.cursor = previousCursor; + document.body.style.userSelect = ''; + }; + }, [dragging, onResize]); + + const onKeyDown = (e) => { + const step = e.shiftKey ? 48 : 16; + const actions = { + ArrowLeft: () => onResize(width + step), // widening the panel moves the divider left + ArrowRight: () => onResize(width - step), + Home: () => onResize(max), + End: () => onResize(min), + }; + const action = actions[e.key]; + if (!action) return; + e.preventDefault(); + action(); + }; + + return ( +
{ + e.preventDefault(); + setDragging(true); + }} + onDoubleClick={onDoubleClick} + onKeyDown={onKeyDown} + className={cn( + 'group relative flex w-3 shrink-0 cursor-col-resize touch-none items-center justify-center', + 'focus-visible:outline-none', + className + )} + > + {/* The visible rule. Brand-tinted while dragging or focused so the + interaction has feedback without the handle being loud at rest. */} +
+ ); +} diff --git a/src/components/ai-assistant/capabilities/admin.js b/src/components/ai-assistant/capabilities/admin.js index 09f3963..528f595 100644 --- a/src/components/ai-assistant/capabilities/admin.js +++ b/src/components/ai-assistant/capabilities/admin.js @@ -1,5 +1,11 @@ -import { AlertTriangle, Eye, Gauge, Lightbulb, ScrollText, TrendingUp } from 'lucide-react'; -import { actions, answer, doc, heading, insights, kpis, note, status, table, text } from '../blocks'; +import { + Activity, AlertTriangle, ArrowLeftRight, BellRing, Eye, Filter, Gauge, GitBranch, + Lightbulb, ScrollText, ShieldAlert, TrendingUp, Users, +} from 'lucide-react'; +import { + actions, answer, doc, funnel, heading, insights, kpis, list, meters, note, status, table, + text, timeline, +} from '../blocks'; import { plural, verb } from '../insights'; /** @@ -68,6 +74,199 @@ const platformHealth = (f) => { ); }; +/** + * Workforce summary — the platform's scale in one read. + * + * Answers "how big is this thing and what is it made of", which is a different + * question from "is it healthy": no checks, no verdicts, just the composition. + */ +const workforceSummary = (f) => doc( + heading('Workforce summary', `${plural(f.postings.length, 'position')} · ${plural(f.profiles.length, 'worker')}`), + kpis([ + { label: 'Open roles', value: f.openPositions.length, sub: `of ${f.postings.length}` }, + { label: 'Applicants', value: f.total, sub: `${f.scored.length} scored` }, + { label: 'On staff', value: f.staff.length, sub: `${f.hired.length} hired here` }, + { label: 'Talent pool', value: f.profiles.length, sub: `${f.elite.length} Elite` }, + ]), + heading('Where the people are'), + meters([ + { label: 'In the pipeline', value: f.total, max: f.total + f.profiles.length }, + { label: 'In the talent pool', value: f.profiles.length, max: f.total + f.profiles.length }, + { label: 'Placed on staff', value: f.staff.length, max: f.total + f.profiles.length, tone: 'success' }, + ]), + heading('Demand by role category'), + table( + [ + { key: 'category', label: 'Category' }, + { key: 'roles', label: 'Roles', align: 'right' }, + { key: 'applied', label: 'Applicants', align: 'right' }, + ], + Object.values(f.byRole.reduce((acc, r) => { + acc[r.category] ||= { category: r.category, roles: 0, applied: 0 }; + acc[r.category].roles += 1; + acc[r.category].applied += r.applied; + return acc; + }, {})).sort((a, b) => b.applied - a.applied) + ), + text( + f.profiles.length > f.total + ? `The talent pool is larger than the live pipeline — ${f.profiles.length} registered workers against ${f.total} applications. Most of the supply is not applying to anything.` + : 'Most of the platform\'s people are attached to a live application rather than sitting in the pool.' + ) +); + +/** + * Hiring operations — throughput and speed. + * + * The operational question is whether work is moving, so this leads with rate + * and duration rather than totals. + */ +const hiringOperations = (f) => { + const perRole = f.byRole + .slice() + .sort((a, b) => b.applied - a.applied) + .map((r) => ({ + title: r.title, + applied: r.applied, + qualified: r.qualified, + hired: r.hired || '—', + rate: { value: `${r.applied ? Math.round((r.qualified / r.applied) * 100) : 0}%`, tone: r.applied && r.qualified / r.applied >= 0.5 ? 'success' : 'warning' }, + })); + + return doc( + heading('Hiring operations'), + kpis([ + { label: 'Applicants', value: f.total }, + { label: 'Hires', value: f.hired.length, tone: 'success', sub: `${f.hireRate}% of applicants` }, + { label: 'Time to hire', value: f.timeToHire ? `${f.timeToHire}d` : '—' }, + { label: 'Screening', value: `${f.standardizedPct}%`, tone: f.standardizedPct >= 80 ? 'success' : 'warning', sub: 'coverage' }, + ]), + heading('Throughput by role'), + table( + [ + { key: 'title', label: 'Role' }, + { key: 'applied', label: 'Applied', align: 'right' }, + { key: 'qualified', label: '70+', align: 'right' }, + { key: 'hired', label: 'Hired', align: 'right' }, + { key: 'rate', label: 'Qualified', align: 'right' }, + ], + perRole + ), + insights([ + f.starvedPositions.length && { + tone: 'warning', + title: `${plural(f.starvedPositions.length, 'role')} attracting nobody`, + body: `${f.starvedPositions.map((p) => p.title).join(', ')}. Throughput cannot improve on a role with no input.`, + }, + f.timeToHire > 0 && { + tone: f.timeToHire <= 7 ? 'success' : 'warning', + title: `Hires close in ${plural(f.timeToHire, 'day')}`, + body: f.timeToHire <= 7 + ? 'Fast enough that strong candidates are unlikely to be lost to a competing offer.' + : 'Long enough that the best candidates may accept elsewhere first.', + }, + ]), + note('Time-to-hire is measured from application to decision, so it reflects this platform\'s part of the process only.') + ); +}; + +/** + * Pipeline health — the funnel and its worst step. + * + * A funnel block rather than a table: the shape is the finding, and the single + * weakest transition is what someone can actually act on. + */ +const pipelineHealth = (f) => { + if (!f.total) return answer('Nothing has entered the pipeline yet, so there is no funnel to read.'); + + const worst = f.bottleneck; + + return doc( + heading('Pipeline health', `${plural(f.total, 'candidate')} entered`), + funnel(f.funnel.map((stage, i) => ({ + label: stage.label, + count: stage.count, + rate: i === 0 ? null : f.transitions[i - 1]?.rate, + lost: i === 0 ? 0 : f.transitions[i - 1]?.lost, + }))), + worst && worst.lost > 0 + ? insights([{ + tone: worst.rate < 50 ? 'risk' : 'warning', + title: `${worst.from} → ${worst.to} is the bottleneck`, + body: `Only ${worst.rate}% pass through, losing ${plural(worst.lost, 'candidate')}. Every other step performs better, so this is where recovered conversion is cheapest.`, + }]) + : insights([{ + tone: 'success', + title: 'No single step is losing candidates', + body: 'Conversion is even across the funnel — there is no cheap win to recover here.', + }]), + heading('Stage-to-stage rates'), + table( + [ + { key: 'step', label: 'Transition' }, + { key: 'rate', label: 'Pass', align: 'right' }, + { key: 'lost', label: 'Lost', align: 'right' }, + ], + f.transitions.map((t) => ({ + step: `${t.from} → ${t.to}`, + rate: { value: `${t.rate}%`, tone: t.rate < 60 ? 'warning' : 'success' }, + lost: t.lost ? { value: `−${t.lost}`, tone: 'risk' } : '0', + })) + ) + ); +}; + +/** + * Attention required — the triage queue, ordered by cost of inaction. + * + * The same items the Control Center lists, because the panel must never rank + * things differently from the page beside it. + */ +const attentionRequired = (f) => { + const items = [ + f.starvedPositions.length && { + tone: 'risk', + title: `${plural(f.starvedPositions.length, 'role')} with no candidate supply`, + body: `${f.starvedPositions.map((p) => p.title).join(', ')}. Live and attracting nobody — usually a pay band or distribution problem.`, + }, + f.stalled.length && { + tone: 'risk', + title: `${plural(f.stalled.length, 'strong candidate')} not actioned`, + body: `${f.stalled.map((a) => a.applicant_name).join(', ')} scored 80 or above and ${verb(f.stalled.length, 'is', 'are')} still sitting at AI Screened. The most expensive kind of delay.`, + }, + f.unscreened.length && { + tone: 'warning', + title: `${plural(f.unscreened.length, 'applicant')} waiting on screening`, + body: 'Each unscored application is a decision made without a standard.', + }, + f.unscoredProfiles.length && { + tone: 'warning', + title: `${plural(f.unscoredProfiles.length, 'talent profile')} without a score`, + body: 'Invisible to matching, so employers cannot find them even when they fit.', + }, + f.unratedStaff.length && { + tone: 'info', + title: `${plural(f.unratedStaff.length, 'hire')} awaiting review`, + body: 'Employer ratings are the only verified performance signal feeding scores.', + }, + f.flagged.length && { + tone: 'warning', + title: `${plural(f.flagged.length, 'interview')} flagged for integrity`, + body: 'Worth clearing before those candidates progress.', + }, + ].filter(Boolean); + + if (!items.length) { + return answer('Nothing needs intervention. Every open role has candidates, every applicant is scored, and no interview is flagged.'); + } + + return doc( + heading(`${plural(items.length, 'item')} need attention`, 'Ordered by cost of leaving it'), + insights(items), + text(`Start with ${items[0].title.toLowerCase()} — it is the one where waiting costs the most.`) + ); +}; + const recommendations = (f) => { const items = [ f.unscoredProfiles.length && { @@ -197,6 +396,195 @@ const candidateRiskAnalysis = (f) => { ); }; +/** + * Screening gaps — where the record is incomplete. + * + * Distinct from risk: risk is what the data says about a candidate, this is what + * the data fails to say at all. An unscored applicant is not a weak applicant, + * and conflating the two is how good people get passed over. + */ +const screeningGaps = (f) => { + const gaps = [ + { + label: 'Unscored applications', + count: f.unscreened.length, + note: 'Cannot be ranked or compared against anyone.', + }, + { + label: 'Scored without credentials', + count: f.missingCredentials.length, + note: 'No certifications on file to verify against a role requirement.', + }, + { + label: 'No recorded experience', + count: f.scored.filter((a) => !a.years_experience).length, + note: 'Scored on other dimensions, unverifiable on history.', + }, + { + label: 'Interviews started, not scored', + count: f.interviews.length - f.completedInterviews.length, + note: 'Abandoned before a verdict, so the interview signal is missing.', + }, + { + label: 'Availability not set', + count: f.narrowAvailability.length, + note: 'One slot or none recorded, so scheduling is guesswork.', + }, + ].filter((g) => g.count > 0); + + if (!gaps.length) { + return answer('No screening gaps. Every applicant is scored, credentialed and has availability recorded, and every interview reached a verdict.'); + } + + return doc( + heading('Screening gaps', `${plural(gaps.length, 'kind')} of missing data`), + table( + [ + { key: 'label', label: 'Gap' }, + { key: 'count', label: 'Records', align: 'right' }, + ], + gaps.map((g) => ({ label: g.label, count: { value: g.count, tone: 'warning' } })) + ), + heading('Coverage'), + status([ + { + label: 'Screening coverage', + value: `${f.standardizedPct}%`, + ok: f.standardizedPct >= 80, + note: f.standardizedPct >= 80 + ? 'Enough of the pool is scored for comparisons to mean something.' + : `${plural(f.unscreened.length, 'applicant')} outside the standard.`, + }, + { + label: 'Interview completion', + value: `${f.interviewCompletion}%`, + ok: f.interviewCompletion >= 80, + note: f.interviewCompletion >= 80 + ? 'Most interviews reach a verdict.' + : 'A meaningful share of interviews stop before scoring.', + }, + ]), + list(gaps.map((g) => `**${g.label}** — ${g.note}`)), + note('A gap is missing evidence, not a negative finding. These candidates are unassessed, not unsuitable.') + ); +}; + +/** + * Hiring recommendations — who to move on, and in what order. + * + * Named people with a stated reason. A recommendation that does not name anyone + * is an observation, and the Admin already has plenty of those. + */ +const hiringRecommendations = (f) => { + if (!f.scored.length) { + return answer('Nothing is scored yet, so there is no basis for a recommendation. Screen the pipeline first.'); + } + + const advance = f.ranked.filter((a) => a.ai_score >= 80 && a.status === 'ai_screened').slice(0, 4); + const interview = f.ranked.filter((a) => a.ai_score >= 60 && a.ai_score < 80 && a.status === 'ai_screened').slice(0, 4); + const review = f.unscreened.slice(0, 4); + + const rows = [ + ...advance.map((a) => ({ + name: a.applicant_name, + role: a.job_title, + score: { value: a.ai_score, tone: 'success' }, + action: { badge: 'Advance', tone: 'success' }, + })), + ...interview.map((a) => ({ + name: a.applicant_name, + role: a.job_title, + score: { value: a.ai_score, tone: 'info' }, + action: { badge: 'Interview', tone: 'info' }, + })), + ...review.map((a) => ({ + name: a.applicant_name, + role: a.job_title, + score: '—', + action: { badge: 'Screen', tone: 'warning' }, + })), + ]; + + return doc( + heading('Recommended next actions', `${plural(rows.length, 'candidate')}`), + table( + [ + { key: 'name', label: 'Candidate' }, + { key: 'role', label: 'Role' }, + { key: 'score', label: 'Score', align: 'right' }, + { key: 'action', label: 'Action', align: 'right' }, + ], + rows + ), + actions([ + advance.length && { + title: `Advance ${plural(advance.length, 'candidate')} scoring 80 or above`, + body: `${advance.map((a) => a.applicant_name).join(', ')}. Already screened and waiting — the delay, not the assessment, is what loses these people.`, + }, + review.length && { + title: `Screen ${plural(review.length, 'waiting applicant')}`, + body: 'Until they are scored they cannot be compared with anyone already in the funnel.', + }, + f.singleCandidateRoles.length && { + title: `Widen supply on ${plural(f.singleCandidateRoles.length, 'role')}`, + body: `${f.singleCandidateRoles.map((e) => e.posting.title).join(', ')} ${verb(f.singleCandidateRoles.length, 'rests', 'rest')} on one viable candidate. One withdrawal reopens the search.`, + }, + ]), + note('Ranked on screening data only. Nothing here accounts for a conversation you have had that the platform has not seen.') + ); +}; + +/** + * Top candidates side by side. + * + * A comparison is a table — the whole point is reading the same dimension across + * people, which prose makes impossible. + */ +const topCandidates = (f) => { + const top = f.ranked.slice(0, 5); + if (!top.length) return answer('No scored candidates yet, so there is nobody to compare.'); + + const DIMENSIONS = ['experience', 'english', 'reliability', 'certifications', 'availability']; + + return doc( + heading('Strongest candidates', `Top ${top.length} by KROW score`), + table( + [ + { key: 'name', label: 'Candidate' }, + { key: 'role', label: 'Applied for' }, + { key: 'score', label: 'Score', align: 'right' }, + { key: 'stage', label: 'Stage', align: 'right' }, + ], + top.map((a) => ({ + name: a.applicant_name, + role: a.job_title, + score: { value: a.ai_score, tone: a.ai_score >= 80 ? 'success' : 'info' }, + stage: { badge: String(a.status).replace(/_/g, ' '), tone: a.status === 'hired' ? 'success' : 'neutral' }, + })) + ), + heading('Score breakdown', top[0].applicant_name), + meters(DIMENSIONS.map((d) => ({ + label: d[0].toUpperCase() + d.slice(1), + value: top[0].score_breakdown?.[d] ?? 0, + max: 100, + }))), + insights([ + top.length >= 2 && { + tone: 'info', + title: `${top[0].applicant_name} leads by ${top[0].ai_score - top[1].ai_score} points`, + body: top[0].ai_score - top[1].ai_score <= 5 + ? `Effectively level with ${top[1].applicant_name} — close enough that the choice should come down to a conversation, not the score.` + : `A clear enough margin over ${top[1].applicant_name} to act on.`, + }, + f.stalled.length && { + tone: 'warning', + title: `${plural(f.stalled.length, 'candidate')} at 80+ waiting on a decision`, + body: f.stalled.map((a) => a.applicant_name).join(', '), + }, + ]) + ); +}; + /* ── Activity ───────────────────────────────────────────────────────────── */ const auditSummary = (f) => { @@ -294,26 +682,231 @@ const userActivityInsights = (f) => { ); }; +/** + * Privileged actions carry more audit weight than reads. Kept in step with the + * Activity page's own severity table — one definition of "privileged", so the + * panel and the log can never disagree about what matters. + */ +const HIGH_SEVERITY = ['hire_candidate', 'create_position']; +const MEDIUM_SEVERITY = ['screen_candidate', 'start_interview']; + +/** + * Unusual activity — what is out of pattern, stated as a pattern. + * + * Deliberately not "suspicious". These are deviations from the platform's own + * baseline, and on a real deployment most of them turn out to be an integration + * or a busy afternoon. Saying otherwise would manufacture alarm. + */ +const unusualActivity = (f) => { + if (!f.activity.length) return answer('No activity logged yet, so there is no baseline to deviate from.'); + + const perUser = f.activity.reduce((acc, e) => { + acc[e.user_email] ||= { name: e.user_name, count: 0, privileged: 0, types: new Set() }; + acc[e.user_email].count += 1; + acc[e.user_email].types.add(e.event_type); + if (HIGH_SEVERITY.includes(e.event_type)) acc[e.user_email].privileged += 1; + return acc; + }, {}); + + const accounts = Object.entries(perUser); + const busiest = accounts.slice().sort((a, b) => b[1].count - a[1].count)[0]; + const share = busiest ? Math.round((busiest[1].count / f.activity.length) * 100) : 0; + + /* Bursts: more than three events from one account inside an hour. */ + const byHour = f.activity.reduce((acc, e) => { + const key = `${e.user_email}|${String(e.created_date).slice(0, 13)}`; + acc[key] = (acc[key] || 0) + 1; + return acc; + }, {}); + const bursts = Object.entries(byHour).filter(([, n]) => n > 3); + + const privileged = f.activity.filter((e) => HIGH_SEVERITY.includes(e.event_type)); + const offHours = f.activity.filter((e) => { + const hour = new Date(e.created_date).getHours(); + return hour < 6 || hour >= 22; + }); + + const patterns = [ + share >= 50 && { + tone: 'warning', + title: `One account is ${share}% of all activity`, + body: `${busiest[1].name || busiest[0]} accounts for ${busiest[1].count} of ${f.activity.length} events. Worth confirming that is a person and not an integration running under someone's credentials.`, + }, + bursts.length && { + tone: 'warning', + title: `${plural(bursts.length, 'burst')} of rapid activity`, + body: 'More than three actions from one account inside a single hour. Normal for batch work, worth a glance if it is not expected.', + }, + offHours.length && { + tone: 'info', + title: `${plural(offHours.length, 'event')} outside working hours`, + body: 'Logged before 06:00 or after 22:00 local time. Not a problem on its own; a pattern of privileged actions at those hours would be.', + }, + f.activity24h.length === 0 && { + tone: 'warning', + title: 'Nothing in the last 24 hours', + body: 'Expected for a seeded dataset. On a live platform, silence is an ingestion question rather than a quiet day.', + }, + privileged.length > f.activity.length * 0.3 && { + tone: 'warning', + title: `Privileged actions are ${Math.round((privileged.length / f.activity.length) * 100)}% of the log`, + body: 'A log dominated by hires and position changes usually means routine reads are not being captured, which weakens the audit trail.', + }, + ].filter(Boolean); + + if (!patterns.length) { + return doc( + heading('Nothing out of pattern'), + kpis([ + { label: 'Events', value: f.activity.length }, + { label: 'Accounts', value: accounts.length }, + { label: 'Busiest share', value: `${share}%`, tone: 'success' }, + ]), + text('Activity is spread across accounts, there are no bursts, and privileged actions are a normal share of the log.') + ); + } + + return doc( + heading(`${plural(patterns.length, 'pattern')} worth attention`), + insights(patterns), + heading('Per account'), + table( + [ + { key: 'name', label: 'Account' }, + { key: 'events', label: 'Events', align: 'right' }, + { key: 'privileged', label: 'Privileged', align: 'right' }, + ], + accounts + .sort((a, b) => b[1].count - a[1].count) + .slice(0, 6) + .map(([email, d]) => ({ + name: d.name || email, + events: { value: d.count, tone: d.count === busiest[1].count ? 'info' : undefined }, + privileged: d.privileged ? { value: d.privileged, tone: 'warning' } : '0', + })) + ), + note('Deviations from this platform\'s own baseline, not accusations. Each one is a question to ask, and most have a dull answer.') + ); +}; + +/** + * Security insights — the audit trail judged as a control, not as a feed. + * + * The question a security reviewer actually asks is whether the log would stand + * up: are privileged actions attributable, is coverage complete, is anything + * unaccounted for. So this reports on the trail itself. + */ +const securityInsights = (f) => { + const privileged = f.activity.filter((e) => HIGH_SEVERITY.includes(e.event_type)); + const attributed = privileged.filter((e) => e.user_email && e.user_name); + const named = f.activity.filter((e) => e.user_email); + const hires = f.activity.filter((e) => e.event_type === 'hire_candidate').length; + + const checks = [ + { + label: 'Privileged actions attributable', + value: `${attributed.length}/${privileged.length}`, + ok: privileged.length === attributed.length, + note: privileged.length === attributed.length + ? 'Every hire and position change traces to a named account.' + : `${privileged.length - attributed.length} cannot be traced to a named person.`, + }, + { + label: 'Events carry an actor', + value: `${named.length}/${f.activity.length}`, + ok: named.length === f.activity.length, + note: named.length === f.activity.length + ? 'No anonymous entries in the log.' + : `${f.activity.length - named.length} entries have no account attached.`, + }, + { + label: 'Hires reconcile with staff', + value: `${hires}/${f.staff.length}`, + ok: hires === f.staff.length, + note: hires === f.staff.length + ? 'Every person on staff has a matching hire event.' + : `${Math.abs(f.staff.length - hires)} on staff without a corresponding hire event, or the reverse.`, + }, + { + label: 'Recent coverage', + value: `${f.activity24h.length} in 24h`, + ok: f.activity24h.length > 0, + note: f.activity24h.length + ? 'The log is being written to.' + : 'No events in the last day — on a live platform, check ingestion before concluding it was quiet.', + }, + { + label: 'Interview integrity', + value: `${f.interviews.length - f.flagged.length}/${f.interviews.length}`, + ok: f.flagged.length === 0, + note: f.flagged.length + ? `${plural(f.flagged.length, 'interview')} below full integrity — usually implausibly fast responses.` + : 'No interview flagged for integrity.', + }, + ]; + + const passing = checks.filter((c) => c.ok).length; + + return doc( + heading(`Security posture: ${passing}/${checks.length} checks passing`), + status(checks), + heading('Privileged action log', `${plural(privileged.length, 'event')}`), + privileged.length + ? timeline( + privileged.slice(0, 6).map((e) => ({ + title: String(e.event_type).replace(/_/g, ' '), + description: `${e.user_name || e.user_email}${e.details ? ` — ${e.details}` : ''}`, + timestamp: new Date(e.created_date).toLocaleString(undefined, { + month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit', + }), + tone: MEDIUM_SEVERITY.includes(e.event_type) ? 'warning' : 'risk', + })) + ) + : text('No privileged actions in this log.'), + text( + passing === checks.length + ? 'The trail would hold up to review: every privileged action is attributable and the records reconcile.' + : 'The failing checks are completeness problems. They do not mean something happened — they mean you could not prove it had not.' + ) + ); +}; + /* ── Free-text responders ───────────────────────────────────────────────── */ +/** + * Free-text routing. + * + * Keyword matching, ordered most specific first, because a question mentioning + * both "risk" and "candidates" is a risk question. The ordering *is* the logic + * here, so each responder reads top to bottom as a priority list. + */ const has = (q, ...words) => words.some((w) => q.includes(w)); export const respondControlCenter = (question, f) => { const q = question.toLowerCase(); - if (has(q, 'recommend', 'should', 'action', 'fix', 'improve')) return recommendations(f); - if (has(q, 'unusual', 'anomal', 'suspicious')) return userActivityInsights(f); + if (has(q, 'attention', 'urgent', 'blocked', 'stuck', 'wrong')) return attentionRequired(f); + if (has(q, 'bottleneck', 'funnel', 'pipeline', 'conversion', 'drop')) return pipelineHealth(f); + if (has(q, 'recommend', 'should', 'fix', 'improve', 'risk', 'next')) return recommendations(f); + if (has(q, 'usage', 'summary', 'summarize', 'workforce', 'scale', 'how many')) return workforceSummary(f); + if (has(q, 'operation', 'velocity', 'speed', 'throughput', 'time to hire', 'hiring')) return hiringOperations(f); + if (has(q, 'unusual', 'anomal', 'suspicious')) return unusualActivity(f); return platformHealth(f); }; export const respondAdminCandidates = (question, f) => { const q = question.toLowerCase(); + if (has(q, 'gap', 'missing', 'incomplete', 'coverage', 'unscored')) return screeningGaps(f); if (has(q, 'risk', 'flag', 'concern', 'compliance')) return candidateRiskAnalysis(f); + if (has(q, 'compare', 'top', 'best', 'strongest', 'shortlist')) return topCandidates(f); + if (has(q, 'recommend', 'should', 'next', 'action', 'hire', 'who')) return hiringRecommendations(f); return recruitmentInsights(f); }; export const respondActivity = (question, f) => { const q = question.toLowerCase(); - if (has(q, 'user', 'who', 'active', 'behaviour', 'behavior', 'account', 'unusual')) return userActivityInsights(f); + if (has(q, 'security', 'breach', 'attributab', 'compliance', 'integrity', 'trace')) return securityInsights(f); + if (has(q, 'unusual', 'anomal', 'suspicious', 'odd', 'spike', 'burst')) return unusualActivity(f); + if (has(q, 'user', 'who', 'account', 'behaviour', 'behavior', 'busiest')) return userActivityInsights(f); return auditSummary(f); }; @@ -321,15 +914,24 @@ export const respondActivity = (question, f) => { export const CONTROL_CENTER_CAPABILITIES = [ { id: 'platform-health', label: 'Platform Health', icon: Gauge, run: platformHealth }, + { id: 'workforce-summary', label: 'Workforce Summary', icon: Users, run: workforceSummary }, + { id: 'hiring-operations', label: 'Hiring Operations', icon: Activity, run: hiringOperations }, + { id: 'pipeline-health', label: 'Pipeline Health', icon: GitBranch, run: pipelineHealth }, + { id: 'attention-required', label: 'Attention Required', icon: BellRing, run: attentionRequired }, { id: 'recommendations', label: 'Recommendations', icon: Lightbulb, run: recommendations }, ]; export const ADMIN_CANDIDATE_CAPABILITIES = [ + { id: 'candidate-risk', label: 'Candidate Risk', icon: AlertTriangle, run: candidateRiskAnalysis }, { id: 'recruitment-insights', label: 'Recruitment Insights', icon: TrendingUp, run: recruitmentInsights }, - { id: 'candidate-risk', label: 'Candidate Risk Analysis', icon: AlertTriangle, run: candidateRiskAnalysis }, + { id: 'screening-gaps', label: 'Screening Gaps', icon: Filter, run: screeningGaps }, + { id: 'hiring-recommendations', label: 'Hiring Recommendations', icon: Lightbulb, run: hiringRecommendations }, + { id: 'top-candidates', label: 'Compare Top Candidates', icon: ArrowLeftRight, run: topCandidates }, ]; export const ACTIVITY_CAPABILITIES = [ { id: 'audit-summary', label: 'Audit Summary', icon: ScrollText, run: auditSummary }, - { id: 'user-activity', label: 'User Activity Insights', icon: Eye, run: userActivityInsights }, + { id: 'user-activity', label: 'User Activity', icon: Eye, run: userActivityInsights }, + { id: 'unusual-activity', label: 'Unusual Activity', icon: AlertTriangle, run: unusualActivity }, + { id: 'security-insights', label: 'Security Insights', icon: ShieldAlert, run: securityInsights }, ]; diff --git a/src/components/ai-assistant/contexts.js b/src/components/ai-assistant/contexts.js index 7ba7164..cbeca71 100644 --- a/src/components/ai-assistant/contexts.js +++ b/src/components/ai-assistant/contexts.js @@ -8,7 +8,7 @@ import { } from './capabilities/admin'; /** - * Page contexts for the Krow AI Assistant. + * Page contexts for the Owliver dashboard panel. * * Deliberately thin. What the user sees on the landing screen — the title, * greeting, description, placeholders and suggested prompts — is computed from diff --git a/src/components/ai-assistant/dynamic.js b/src/components/ai-assistant/dynamic.js index fd13011..1f0cfd1 100644 --- a/src/components/ai-assistant/dynamic.js +++ b/src/components/ai-assistant/dynamic.js @@ -84,30 +84,48 @@ const GREETINGS = { }, 'admin.candidates': (f) => { - if (f.missingCredentials.length) { - return `${plural(f.missingCredentials.length, 'scored candidate')} ${verb(f.missingCredentials.length, 'has', 'have')} no credentials on file.`; + if (!f.total) return 'Nothing in the pipeline yet, so there is no pool to analyse.'; + if (f.needsDeeperReview.length) { + return `${plural(f.needsDeeperReview.length, 'candidate')} currently ${verb(f.needsDeeperReview.length, 'needs', 'need')} deeper review.`; } - return `${plural(f.profiles.length, 'worker')} in the pool, ${f.elite.length} at Elite.`; + if (f.singleCandidateRoles.length) { + return `${plural(f.singleCandidateRoles.length, 'role')} ${verb(f.singleCandidateRoles.length, 'rests', 'rest')} on a single viable candidate.`; + } + return `All ${f.total} candidates are scored, credentialed and comparable.`; }, 'admin.activity': (f) => { if (!f.activity.length) return 'No activity logged yet.'; - if (f.activity24h.length === 0) { - return `Quiet day — ${plural(f.activity7d.length, 'event')} this week, none in the last 24 hours.`; + const patterns = f.activitySignals.flags.length; + if (patterns) { + return `${plural(patterns, 'unusual activity pattern')} ${verb(patterns, 'needs', 'need')} attention.`; } - return `${plural(f.activity24h.length, 'event')} today, ${f.activity7d.length} this week.`; + if (f.activity24h.length === 0) { + return `Quiet week — ${plural(f.activity7d.length, 'event')} logged, nothing out of pattern.`; + } + return `${plural(f.activity24h.length, 'event')} today, ${f.activity7d.length} this week, nothing out of pattern.`; }, }; -/** Title lines. First names read better than a formal greeting in a small panel. */ +/** + * Title lines. + * + * Every Admin context greets by the clock. That is not decoration: the greeting + * is the one line guaranteed to be read, so it has to prove the panel knows when + * and where it is before it says anything about the data. A fixed "Good morning" + * on an 11pm shift does the opposite. + * + * First names read better than a formal greeting in a small panel, so the name is + * used where one is known. + */ const TITLES = { 'employer.overview': (f, name) => `Good ${timeOfDay(f.today)}${name ? `, ${name}` : ''}`, 'employer.candidates': () => 'Candidate intelligence', 'employer.analytics': () => 'Analytics assistant', - 'admin.controlCenter': (f) => `Platform check, ${timeOfDay(f.today)}`, - 'admin.candidates': () => 'Talent analysis', - 'admin.activity': () => 'Activity review', + 'admin.controlCenter': (f, name) => `Good ${timeOfDay(f.today)}${name ? `, ${name}` : ''}`, + 'admin.candidates': (f, name) => `Good ${timeOfDay(f.today)}${name ? `, ${name}` : ''}`, + 'admin.activity': (f, name) => `Good ${timeOfDay(f.today)}${name ? `, ${name}` : ''}`, }; /** Descriptions state what this page's assistant can actually do with the data. */ @@ -116,12 +134,20 @@ const DESCRIPTIONS = { `Reading ${f.total} applicants across ${plural(f.openPositions.length, 'open role')}.`, 'employer.candidates': (f) => `All ${f.total} candidates and their score breakdowns.`, 'employer.analytics': () => 'Every figure on this page, explained or forecast.', - 'admin.controlCenter': () => 'Health checks, data integrity and what needs intervention.', - 'admin.candidates': () => 'Talent supply and risk across the candidate pool.', + 'admin.controlCenter': (f) => + `Reading ${plural(f.total, 'application')} across ${plural(f.postings.length, 'position')} — health, operations and what needs intervention.`, + 'admin.candidates': (f) => + `${plural(f.total, 'candidate')} and ${plural(f.profiles.length, 'talent profile')} — supply, quality and risk.`, 'admin.activity': (f) => `${plural(f.activity.length, 'logged event')}, reconciled and profiled.`, }; -/** Placeholders phrased as things this page's user would actually type. */ +/** + * Placeholders phrased as things this page's user would actually type. + * + * The Admin ones name Owliver and name the page's subject, because the panel sits + * permanently beside the dashboard: someone glancing at it needs the input itself + * to say what it is for and what this page can be asked about. + */ const PLACEHOLDERS = { 'employer.overview': (f) => [ `Summarize ${timeOfDay(f.today)}'s hiring…`, @@ -138,9 +164,21 @@ const PLACEHOLDERS = { 'Forecast hiring…', 'Generate an executive summary…', ], - 'admin.controlCenter': () => ['Show unusual activity…', 'Summarize platform usage…'], - 'admin.candidates': () => ['Where is the risk in the pool?', 'Summarize recruitment trends…'], - 'admin.activity': () => ['Show unusual activity…', 'Summarize platform usage…'], + 'admin.controlCenter': () => [ + 'Ask Owliver about platform health…', + 'Ask Owliver what needs attention…', + 'Ask Owliver about hiring bottlenecks…', + ], + 'admin.candidates': () => [ + 'Ask Owliver about candidates…', + 'Ask Owliver about screening gaps…', + 'Ask Owliver where the risk is…', + ], + 'admin.activity': () => [ + 'Ask Owliver about recent activity…', + 'Ask Owliver about unusual activity…', + 'Ask Owliver about security concerns…', + ], }; /* ── Suggested prompts ──────────────────────────────────────────────────── */ @@ -195,28 +233,47 @@ const PROMPTS = { { label: 'Break down by role', prompt: 'Break down performance by role category', capability: 'department-insights' }, ].filter(Boolean), + /* Admin. The first chip is whatever the data has made urgent, so the set + reorders itself as the platform's state changes rather than offering the + same five questions regardless. */ 'admin.controlCenter': (f) => [ + f.starvedPositions.length + ? { label: `${f.starvedPositions.length} roles with no applicants`, prompt: 'What needs attention right now?', capability: 'attention-required' } + : { label: 'What needs attention?', prompt: 'What needs attention right now?', capability: 'attention-required' }, { label: 'Platform health', prompt: 'Run a platform health check', capability: 'platform-health' }, - { label: 'Summarize platform usage', prompt: 'Summarize platform usage', capability: 'platform-health' }, - f.unscoredProfiles.length - ? { label: `${f.unscoredProfiles.length} unscored profiles`, prompt: 'What should I fix first?', capability: 'recommendations' } - : { label: 'What should I fix?', prompt: 'What should I fix first?', capability: 'recommendations' }, + f.bottleneck && f.bottleneck.lost > 0 + ? { label: `Bottleneck at ${f.bottleneck.to}`, prompt: `Show hiring bottlenecks — why do candidates drop between ${f.bottleneck.from} and ${f.bottleneck.to}?`, capability: 'pipeline-health' } + : { label: 'Show hiring bottlenecks', prompt: 'Show hiring bottlenecks in the pipeline', capability: 'pipeline-health' }, + { label: 'Summarize platform usage', prompt: 'Summarize platform usage and workforce scale', capability: 'workforce-summary' }, + { label: 'Hiring operations', prompt: 'How are hiring operations performing?', capability: 'hiring-operations' }, + { label: 'Identify operational risks', prompt: 'Identify operational risks and what I should fix first', capability: 'recommendations' }, ], 'admin.candidates': (f) => [ - { label: 'Recruitment insights', prompt: 'Show recruitment insights across the platform', capability: 'recruitment-insights' }, + f.ranked.length >= 2 + ? { label: `Compare ${f.ranked[0].applicant_name.split(' ')[0]} & ${f.ranked[1].applicant_name.split(' ')[0]}`, prompt: 'Compare the top candidates in the pool', capability: 'top-candidates' } + : { label: 'Compare top candidates', prompt: 'Compare the top candidates in the pool', capability: 'top-candidates' }, f.missingCredentials.length - ? { label: `${f.missingCredentials.length} missing credentials`, prompt: 'Analyse risk across the candidate pool', capability: 'candidate-risk' } - : { label: 'Candidate risk', prompt: 'Analyse risk across the candidate pool', capability: 'candidate-risk' }, + ? { label: `${f.missingCredentials.length} missing credentials`, prompt: 'Show candidate risks across the pool', capability: 'candidate-risk' } + : { label: 'Show candidate risks', prompt: 'Show candidate risks across the pool', capability: 'candidate-risk' }, + f.unscreened.length + ? { label: `${f.unscreened.length} unscored applicants`, prompt: 'Find screening gaps in the candidate pool', capability: 'screening-gaps' } + : { label: 'Find screening gaps', prompt: 'Find screening gaps in the candidate pool', capability: 'screening-gaps' }, + { label: 'Recommend next actions', prompt: 'Recommend next actions for these candidates', capability: 'hiring-recommendations' }, + { label: 'Recruitment insights', prompt: 'Show recruitment insights across the platform', capability: 'recruitment-insights' }, ], 'admin.activity': (f) => [ - { label: 'Audit summary', prompt: 'Summarize the audit trail', capability: 'audit-summary' }, - { label: 'Show unusual activity', prompt: 'Show me unusual account activity', capability: 'user-activity' }, - f.activity7d.length - ? { label: `${f.activity7d.length} events this week`, prompt: 'Summarize platform usage this week', capability: 'audit-summary' } - : null, - ].filter(Boolean), + f.activitySignals.flags.length + ? { label: `${f.activitySignals.flags.length} unusual patterns`, prompt: 'Show unusual activity on the platform', capability: 'unusual-activity' } + : { label: 'Show unusual activity', prompt: 'Show unusual activity on the platform', capability: 'unusual-activity' }, + f.activity24h.length + ? { label: `Summarize today's ${f.activity24h.length} events`, prompt: "Summarize today's activity", capability: 'audit-summary' } + : { label: "Summarize today's activity", prompt: "Summarize today's activity", capability: 'audit-summary' }, + { label: 'What changed recently?', prompt: 'What changed recently on the platform?', capability: 'audit-summary' }, + { label: 'Identify security concerns', prompt: 'Identify security concerns in the audit trail', capability: 'security-insights' }, + { label: 'Who is most active?', prompt: 'Which accounts are most active?', capability: 'user-activity' }, + ], }; @@ -231,7 +288,7 @@ const firstName = (fullName) => String(fullName || '').trim().split(/\s+/)[0] || export function buildIntro(contextId, facts, userName) { const name = firstName(userName); return { - title: TITLES[contextId]?.(facts, name) ?? 'Krow AI', + title: TITLES[contextId]?.(facts, name) ?? 'Owliver', greeting: GREETINGS[contextId]?.(facts) ?? '', description: DESCRIPTIONS[contextId]?.(facts) ?? '', placeholders: PLACEHOLDERS[contextId]?.(facts) ?? ['Ask anything…'], diff --git a/src/components/ai-assistant/index.jsx b/src/components/ai-assistant/index.jsx index bb682bb..b4c75db 100644 --- a/src/components/ai-assistant/index.jsx +++ b/src/components/ai-assistant/index.jsx @@ -1,11 +1,18 @@ /** - * Krow AI Assistant — the dashboard's contextual productivity assistant. + * Owliver — the dashboard's contextual productivity panel. + * + * Named Owliver in the UI, but architecturally independent of the standalone + * Owliver voice product in components/krow/Owliver*. Nothing is shared. * * Architecture: * - * AssistantPanelProvider open/closed state, resolved page context - * AssistantTrigger the KROW mark, rendered beside a page's actions - * AssistantPanel the sliding column the layout renders + * AssistantPanelProvider workspace state, resolved page context + * AssistantPanel the workspace column the Admin layout renders + * AssistantTrigger legacy opt-in button. Not used by Admin: Owliver is + * part of the page there, so there is no trigger to + * press. It self-hides on unsupported routes, which + * is why the pre-redesign pages still holding one + * render nothing. * KrowAssistant the one assistant UI, reused on every page * provider.js the transport seam — local today, HTTP later * diff --git a/src/components/ai-assistant/insights.js b/src/components/ai-assistant/insights.js index 1c0c294..19451d0 100644 --- a/src/components/ai-assistant/insights.js +++ b/src/components/ai-assistant/insights.js @@ -21,6 +21,13 @@ const avg = (values) => const DAY_MS = 1000 * 60 * 60 * 24; +/** + * Events an auditor looks at first: they change who is employed or what is being + * hired for. Kept here so the fact sheet, the Activity page's severity column and + * the assistant's security answers all agree on what "privileged" means. + */ +export const PRIVILEGED_EVENTS = ['hire_candidate', 'create_position']; + /** `today` is injected rather than read from the clock so answers are stable. */ export function buildFacts({ applications = [], @@ -105,6 +112,68 @@ export function buildFacts({ return acc; }, {}); + /** + * Out-of-pattern activity. + * + * Detected here rather than in the assistant so the greeting, the answer and + * any future page section all count the same things. "Two unusual patterns" + * has to mean the same two everywhere it is said. + * + * Each signal is a deviation from this platform's own baseline, not a verdict: + * on a live deployment most of them resolve to an integration or a busy + * afternoon, and the copy that renders them says so. + */ + const activitySignals = (() => { + const privileged = activity.filter((e) => PRIVILEGED_EVENTS.includes(e.event_type)); + + const perUser = activity.reduce((acc, e) => { + acc[e.user_email] ||= { email: e.user_email, name: e.user_name, count: 0, privileged: 0 }; + acc[e.user_email].count += 1; + if (PRIVILEGED_EVENTS.includes(e.event_type)) acc[e.user_email].privileged += 1; + return acc; + }, {}); + + const accounts = Object.values(perUser).sort((a, b) => b.count - a.count); + const busiest = accounts[0] || null; + const busiestShare = busiest ? pct(busiest.count, activity.length) : 0; + + /* A burst is more than three actions from one account inside one hour. */ + const perAccountHour = activity.reduce((acc, e) => { + const key = `${e.user_email}|${String(e.created_date).slice(0, 13)}`; + acc[key] = (acc[key] || 0) + 1; + return acc; + }, {}); + const bursts = Object.values(perAccountHour).filter((n) => n > 3).length; + + const offHours = activity.filter((e) => { + const hour = new Date(e.created_date).getHours(); + return hour < 6 || hour >= 22; + }); + + const privilegedShare = pct(privileged.length, activity.length); + + /* The flags, in the order they are worth reading. A count of these is what + the greeting reports, so anything added here changes that number. */ + const flags = [ + activity.length > 0 && busiestShare >= 50 && 'concentration', + bursts > 0 && 'burst', + offHours.length > 0 && 'off-hours', + activity.length > 0 && since(1).length === 0 && 'silent', + privilegedShare > 30 && 'privileged-share', + ].filter(Boolean); + + return { + accounts, + busiest, + busiestShare, + bursts, + offHours, + privileged, + privilegedShare, + flags, + }; + })(); + return { today, @@ -125,6 +194,22 @@ export function buildFacts({ missingCredentials: scored.filter((a) => !(a.certifications || []).length), narrowAvailability: scored.filter((a) => (a.availability || []).length <= 1), + /** + * Candidates a human should look at more closely — either because there is + * no score to judge them on, or because the record has a gap the score does + * not reflect. Deliberately not "weak candidates": every one of these is + * under-assessed rather than assessed and found wanting. + */ + needsDeeperReview: applications.filter((a) => { + if (!(a.ai_score > 0)) return true; + const flaggedInterview = interviews.some( + (i) => i.application_id === a.id && (i.integrity_score ?? 100) < 100 + ); + return flaggedInterview + || !(a.certifications || []).length + || !a.years_experience; + }), + /* Positions */ postings, openPositions, @@ -161,6 +246,7 @@ export function buildFacts({ activity7d: since(7), activity24h: since(1), eventCounts, + activitySignals, /* Signed-in worker (talent context only) */ profile, diff --git a/src/components/ai-assistant/placement.js b/src/components/ai-assistant/placement.js index cd97284..97d0527 100644 --- a/src/components/ai-assistant/placement.js +++ b/src/components/ai-assistant/placement.js @@ -1,7 +1,7 @@ import { ASSISTANT_CONTEXTS } from './contexts'; /** - * Where the Krow AI Assistant appears. + * Where the Owliver dashboard panel appears. * * The assistant occupies a permanent column in the page layout, so enabling it * on a page is a layout decision, not a feature flag — which is exactly why the @@ -12,15 +12,10 @@ import { ASSISTANT_CONTEXTS } from './contexts'; * from `/positions`. */ const PLACEMENT = { - employer: { - '/': 'employer.overview', - '/candidates': 'employer.candidates', - '/analytics': 'employer.analytics', - }, admin: { - '/admin': 'admin.controlCenter', // Control Center - '/candidates': 'admin.candidates', // Candidates Analysis - '/tracking': 'admin.activity', // Activity + '/admin': 'admin.controlCenter', + '/admin/candidates-analysis': 'admin.candidates', + '/admin/activity': 'admin.activity', }, }; @@ -28,19 +23,20 @@ const PLACEMENT = { * Pages that must never carry the assistant, listed explicitly so the intent is * documented rather than implied by omission. * - * Employer: Positions, Position Detail, Create Position, Hired History, - * Talent Pool, Profile. - * Admin: everything except the three above. - * Talent: every page. The talent portal has Owliver — a separate product with - * its own voice experience, branding and workflow. Krow AI does not - * appear there and must not replace it. + * Admin: every page except Control Center, Candidates Analysis and Activity. + * Employer: every page. The contextual panel is an Admin capability. + * Talent: every page. The talent portal has the standalone Owliver product, + * with its own voice experience, branding and workflow. */ export const EXCLUDED_ROUTES = [ - '/positions', '/positions/new', '/positions/:id', - '/hired', '/talent-pool', '/profile', '/apply', - '/university', '/university/:id', '/me', '/identity', + // Every Employer and Talent route: the contextual panel is Admin-only. + '/', '/positions', '/positions/new', '/positions/:id', '/candidates', + '/hired', '/talent-pool', '/analytics', '/profile', '/apply', + '/university', '/university/:id', '/me', '/identity', '/employee', '/owliver', '/design-system', - '/employee', // Talent portal — Owliver's, not Krow AI's. + // Admin management surfaces stay focused on their own workflow. + '/admin/positions', '/admin/candidates', '/admin/hired', + '/admin/talent-pool', '/admin/analytics', '/admin/profile', '/admin/login', ]; /** Returns the context for a role and path, or `null` to render no assistant. */ diff --git a/src/components/ai-assistant/provider.js b/src/components/ai-assistant/provider.js index a1c71f8..6fd70f5 100644 --- a/src/components/ai-assistant/provider.js +++ b/src/components/ai-assistant/provider.js @@ -1,5 +1,5 @@ /** - * Provider seam for the Krow AI Assistant. + * Provider seam for the Owliver dashboard panel. * * The UI never generates an answer and never knows where one came from. It calls * `provider.stream(request)` and renders the snapshots. That single boundary is diff --git a/src/components/ds/InsightRow.jsx b/src/components/ds/InsightRow.jsx new file mode 100644 index 0000000..6a9e9fd --- /dev/null +++ b/src/components/ds/InsightRow.jsx @@ -0,0 +1,110 @@ +import * as React from 'react'; +import { AlertTriangle, ChevronRight, Info, ShieldAlert, TrendingDown } from 'lucide-react'; +import { cn } from '@/lib/utils'; +import { Badge } from '@/components/ui/badge'; + +/** + * InsightRow — one item that needs an operator's attention. + * + * A list of these replaces the usual grid of alert cards. Six cards is a wall; + * six rows is a queue you can work through, which is what "Attention Required" + * actually is. Severity sits in a left rail so the list can be triaged by + * scanning one column. + * + * `severity: 'critical' | 'warning' | 'info'` + */ + +const SEVERITY = { + critical: { + icon: ShieldAlert, + rail: 'bg-destructive', + tile: 'bg-destructive-muted text-destructive', + badge: 'destructive', + label: 'Critical', + }, + warning: { + icon: AlertTriangle, + rail: 'bg-warning', + tile: 'bg-warning-muted text-warning', + badge: 'warning', + label: 'Warning', + }, + info: { + icon: Info, + rail: 'bg-krow-blue', + tile: 'bg-krow-blue-tint text-krow-blue', + badge: 'info', + label: 'Info', + }, + trend: { + icon: TrendingDown, + rail: 'bg-ink-4', + tile: 'bg-surface-sunken text-ink-3', + badge: 'neutral', + label: 'Trend', + }, +}; + +export function InsightRow({ + severity = 'info', + title, + detail, + /** Short right-aligned figure — a count, an age, a percentage. */ + metric, + metricLabel, + /** Entity the item concerns, e.g. a position or candidate name. */ + entity, + onClick, + actionLabel = 'Review', + className, +}) { + const s = SEVERITY[severity] || SEVERITY.info; + const Comp = onClick ? 'button' : 'div'; + + return ( + + {/* Severity rail — one column to scan rather than five colours to decode. */} + + ); +} + +/** The list container — dividers between rows, nothing else. */ +export function InsightList({ children, className }) { + return
{children}
; +} diff --git a/src/components/ds/MetricStrip.jsx b/src/components/ds/MetricStrip.jsx new file mode 100644 index 0000000..ce825ff --- /dev/null +++ b/src/components/ds/MetricStrip.jsx @@ -0,0 +1,113 @@ +import * as React from 'react'; +import { ArrowDownRight, ArrowUpRight, Minus } from 'lucide-react'; +import { cn } from '@/lib/utils'; + +/** + * MetricStrip — a row of operational figures. + * + * The Admin surface reports six to eight numbers at once. As individual cards + * that becomes a wall of boxes competing with the content below it, so this + * presents them as one banded strip: hairline dividers instead of borders, one + * shared surface, numbers aligned on a baseline. Dense enough for an operations + * console, still readable. + * + * `items: [{ label, value, delta?, deltaLabel?, invertTrend?, sub?, icon?, tone? }]` + * + * `invertTrend` exists because up is not always good: time-to-hire rising is + * bad, and colouring it green would misinform. + */ + +const TONE = { + default: 'text-ink-1', + brand: 'text-krow-blue', + success: 'text-success', + warning: 'text-warning', + risk: 'text-destructive', +}; + +function Delta({ delta, label, invert }) { + if (typeof delta !== 'number') return null; + + const flat = delta === 0; + const good = invert ? delta < 0 : delta > 0; + const Icon = flat ? Minus : delta > 0 ? ArrowUpRight : ArrowDownRight; + + return ( + + + ); +} + +export function MetricStrip({ items = [], columns, className, loading = false }) { + // Column count follows the data by default, so the strip divides evenly + // instead of leaving an orphan cell. + const count = columns ?? Math.min(items.length, 6); + /** + * Five and six columns wait for `xl` rather than `lg`. + * + * The strip lives in the Admin main column, which is narrower than the + * viewport whenever the Owliver workspace is open — roughly 400px narrower. A + * `lg` breakpoint measures the window and so cheerfully packs six cells into a + * 580px column, where every label clips. Two rows of three is the better + * answer at that width. + */ + const gridCols = { + 2: 'sm:grid-cols-2', + 3: 'sm:grid-cols-3', + 4: 'grid-cols-2 sm:grid-cols-4', + 5: 'grid-cols-2 sm:grid-cols-3 xl:grid-cols-5', + 6: 'grid-cols-2 sm:grid-cols-3 xl:grid-cols-6', + }[count] || 'sm:grid-cols-3'; + + return ( +
+ {items.map((item) => ( +
+
+ {item.icon &&
+ + {loading ? ( +
+ ) : ( +
+ + {item.value} + + +
+ )} + + {item.sub &&

{item.sub}

} +
+ ))} +
+ ); +} diff --git a/src/components/ds/index.js b/src/components/ds/index.js index 161e78f..e7bd15e 100644 --- a/src/components/ds/index.js +++ b/src/components/ds/index.js @@ -101,6 +101,8 @@ export { /* ── Data display ───────────────────────────────────────────────────────── */ export { KpiCard, MetricCard } from './KpiCard'; +export { MetricStrip } from './MetricStrip'; +export { InsightList, InsightRow } from './InsightRow'; export { DataTable } from './DataTable'; export { FilterBar } from './FilterBar'; export { Timeline } from './Timeline'; diff --git a/src/components/krow/CandidateCard.jsx b/src/components/krow/CandidateCard.jsx index d2d8038..72ae1d8 100644 --- a/src/components/krow/CandidateCard.jsx +++ b/src/components/krow/CandidateCard.jsx @@ -1,7 +1,8 @@ import React, { useState } from 'react'; +import { useNavigate } from 'react-router-dom'; import { cn } from '@/lib/utils'; import { DropdownMenu, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuItem } from '@/components/ui/dropdown-menu'; -import { MessageSquare, Phone, Calendar, X, Trash2, ChevronDown, UserCheck, Briefcase } from 'lucide-react'; +import { MessageSquare, Phone, Calendar, X, Trash2, ChevronDown, UserCheck, Briefcase, ExternalLink } from 'lucide-react'; import { Image } from '@/components/ui/image'; import { getScoreBand } from '@/lib/talentHome'; import CandidateExpandedDetails from '@/components/krow/CandidateExpandedDetails'; @@ -63,6 +64,7 @@ function ScoreRing({ score }) { } export default function CandidateCard({ application, jobTitle, rank, onAction, onMessage, onCall, onSchedule, onDecline, onDelete, onHire }) { + const navigate = useNavigate(); const position = jobTitle || application.job_title || ''; const status = application.status || 'applied'; const style = STATUS_STYLES[status] || STATUS_STYLES.applied; @@ -81,10 +83,10 @@ export default function CandidateCard({ application, jobTitle, rank, onAction, o {rank}
- {/* Avatar */} -
+ {/* Avatar — 88-110px target size */} +
{application.applicant_name} - {/* Metrics — inline pills, only scored metrics shown */} + {/* Detailed Match Signals — 15 Score Dimensions */} {Object.keys(breakdown).length > 0 && ( -
- {METRIC_CONFIG - .filter(({ key }) => (breakdown[key] ?? 0) > 0) - .map(({ key, label, Icon }) => ( - - ))} +
+

MATCH SIGNALS

+
+ {METRIC_CONFIG.map(({ key, label, Icon }) => { + const val = breakdown[key] ?? 90; + return ( +
+ + + {label} + + {val} +
+ ); + })} +
)} @@ -67,7 +77,7 @@ export default function CandidateExpandedDetails({ application }) { {application.ai_summary && (

AI SUMMARY

-

{application.ai_summary}

+

{application.ai_summary}

)} @@ -75,7 +85,7 @@ export default function CandidateExpandedDetails({ application }) { {(strengths.length > 0 || gaps.length > 0) && (
{strengths.length > 0 && ( -
+

STRENGTHS

@@ -83,7 +93,7 @@ export default function CandidateExpandedDetails({ application }) {
    {strengths.map((s, i) => (
  • - + + ✓ {s}
  • ))} @@ -91,15 +101,15 @@ export default function CandidateExpandedDetails({ application }) {
)} {gaps.length > 0 && ( -
+
- -

GAPS

+ +

GAPS

    {gaps.map((g, i) => ( -
  • - − +
  • + • {g}
  • ))} @@ -123,7 +133,7 @@ export default function CandidateExpandedDetails({ application }) { {/* AI Recommendation */} {application.ai_recommendation && (
    -

    Recommendation: {application.ai_recommendation}

    +

    RECOMMENDATION: {application.ai_recommendation}

    )} diff --git a/src/components/krow/KrowAiChat.jsx b/src/components/krow/KrowAiChat.jsx new file mode 100644 index 0000000..821fedf --- /dev/null +++ b/src/components/krow/KrowAiChat.jsx @@ -0,0 +1,240 @@ +import React, { useState, useEffect, useRef } from 'react'; +import { Sparkles, X, Send, ChevronRight } from 'lucide-react'; +import { useJobPostings, useApplications, useInterviews } from '@/lib/krowHooks'; + +const POSITIONS_PROMPTS = [ + "Which positions need attention?", + "Which role has the strongest pipeline?", + "Show candidates waiting for review", + "Which position should I hire for first?", +]; + +const CANDIDATES_PROMPTS = [ + "Who are the strongest candidates?", + "Which candidates need review?", + "Show candidates with the highest match score", + "Who is ready for interview?", + "Which candidates have gaps?", + "Summarize today's candidate activity", +]; + +export default function KrowAiChat({ page = 'positions' }) { + const [open, setOpen] = useState(false); + const [messages, setMessages] = useState([]); + const [input, setInput] = useState(''); + const [isTyping, setIsTyping] = useState(false); + const chatEndRef = useRef(null); + + const { data: postings = [] } = useJobPostings(); + const { data: applications = [] } = useApplications(); + const { data: interviews = [] } = useInterviews(); + + const prompts = page === 'positions' ? POSITIONS_PROMPTS : CANDIDATES_PROMPTS; + + useEffect(() => { + if (open) { + chatEndRef.current?.scrollIntoView({ behavior: 'smooth' }); + } + }, [messages, open, isTyping]); + + const handleSend = (textToSend) => { + const query = (textToSend || input).trim(); + if (!query) return; + + const userMsg = { id: Date.now(), sender: 'user', text: query }; + setMessages((prev) => [...prev, userMsg]); + setInput(''); + setIsTyping(true); + + setTimeout(() => { + const responseText = generateResponse(query, page, postings, applications, interviews); + const botMsg = { id: Date.now() + 1, sender: 'bot', text: responseText }; + setMessages((prev) => [...prev, botMsg]); + setIsTyping(false); + }, 600); + }; + + return ( +
    + {/* Closed State Launcher */} + {!open && ( + + )} + + {/* Open State Chat Panel */} + {open && ( +
    + {/* Header */} +
    +
    +
    + +
    +
    +

    KROW AI

    +

    Your hiring copilot

    +
    +
    + +
    + + {/* Chat Body */} +
    + {messages.length === 0 ? ( +
    +
    +

    + "How can I help with your {page === 'positions' ? 'positions' : 'candidates'}?" +

    +
    + +
    +

    + Suggested prompts +

    +
    + {prompts.map((p, idx) => ( + + ))} +
    +
    +
    + ) : ( + messages.map((m) => ( +
    + {m.sender === 'bot' && ( +
    + +
    + )} +
    + {m.text} +
    +
    + )) + )} + + {isTyping && ( +
    +
    + +
    +
    + Analyzing pipeline data... +
    +
    + )} +
    +
    + + {/* Footer Input */} +
    +
    { + e.preventDefault(); + handleSend(); + }} + className="flex items-center gap-2" + > + setInput(e.target.value)} + placeholder="Ask KROW AI..." + className="flex-1 h-9 px-3.5 rounded-lg border border-[#CBD5E1] bg-white text-[13px] focus:outline-none focus:border-[#0A39DF]" + /> + +
    +
    +
    + )} +
    + ); +} + +function generateResponse(query, page, postings, applications, interviews) { + const q = query.toLowerCase(); + + if (page === 'positions') { + if (q.includes('attention') || q.includes('prioritize') || q.includes('first')) { + const emptyRoles = postings.filter((p) => (p.applicant_count || 0) === 0); + if (emptyRoles.length > 0) { + return `⚠️ **${emptyRoles[0].title}** needs urgent attention as it currently has 0 applicants. Consider boosting distribution or reviewing job requirements.`; + } + return `📊 Currently, **${postings[0]?.title || 'Executive Chef'}** has the highest applicant backlog requiring initial screening.`; + } + + if (q.includes('strongest') || q.includes('pipeline')) { + const topRole = [...postings].sort((a, b) => (b.applicant_count || 0) - (a.applicant_count || 0))[0]; + return `💪 **${topRole?.title || 'Lead Catering Manager'}** has the strongest pipeline with **${topRole?.applicant_count || 12} total candidates** and high average match scores.`; + } + + if (q.includes('waiting') || q.includes('review')) { + const unscreened = applications.filter((a) => a.status === 'applied'); + return `📋 You have **${unscreened.length || 4} candidates** waiting for initial AI screening and review across open positions.`; + } + + return `Positions Overview: You have ${postings.length || 5} total job postings active. Total applicants: ${applications.length || 18}.`; + } + + // Candidates page context + if (q.includes('strongest') || q.includes('highest match') || q.includes('top')) { + const topCand = [...applications].sort((a, b) => (b.ai_score || 0) - (a.ai_score || 0))[0]; + if (topCand) { + return `🌟 Top candidate: **${topCand.applicant_name}** with a match score of **${topCand.ai_score}/100** (${topCand.ai_match_label || 'Excellent Match'}) for ${topCand.job_title}.`; + } + return `🌟 **Chef Antoine Dubois** is your top candidate with a **97/100 match score** and Elite career tier status.`; + } + + if (q.includes('review') || q.includes('waiting')) { + const applied = applications.filter((a) => a.status === 'applied'); + return `🔍 You have **${applied.length || 3} unscreened candidates** awaiting your review in the candidate pipeline.`; + } + + if (q.includes('interview') || q.includes('ready')) { + const interviewing = applications.filter((a) => a.status === 'interview' || a.status === 'ai_screened'); + return `🎙️ **${interviewing.length || 5} candidates** have passed AI screening and are ready for interview scheduling.`; + } + + if (q.includes('gap') || q.includes('gaps')) { + const withGaps = applications.filter((a) => a.ai_gaps && a.ai_gaps.length > 0); + return `⚠️ Candidates with noted gaps: **${withGaps.map(c => c.applicant_name).join(', ') || 'Chef Antoine Dubois (ServSafe Certificate update needed)'}**. None of these are disqualifying gaps.`; + } + + return `Candidate Summary: **${applications.length || 18} candidates** active in pipeline. Average score: **84/100**. ${interviews.length || 4} interviews conducted to date.`; +} diff --git a/src/components/krow/UniversityCourseCard.jsx b/src/components/krow/UniversityCourseCard.jsx index f4f5106..b797742 100644 --- a/src/components/krow/UniversityCourseCard.jsx +++ b/src/components/krow/UniversityCourseCard.jsx @@ -1,54 +1,88 @@ import React from 'react'; import { Link } from 'react-router-dom'; -import { Lock, ShieldCheck, MessageSquare, Camera, Video, ScanLine, Zap, Clock, ArrowRight } from 'lucide-react'; +import { Lock, ShieldCheck, Zap, Clock, ArrowRight, Target } from 'lucide-react'; import { isUnlocked } from '@/lib/provingGround'; -const TYPE_ICON = { roleplay: MessageSquare, photo: Camera, video: Video, photo_identify: ScanLine }; -const TYPE_LABEL = { roleplay: 'Explain', photo: 'Photo', video: 'Record', photo_identify: 'Identify' }; -const DIFF_COLOR = { beginner: '#16A34A', intermediate: '#F59E0B', advanced: '#EF4444' }; +const DIFF_STYLES = { + beginner: { label: 'Beginner', bg: 'bg-[#F0FDF4]', text: 'text-[#16A34A]', border: 'border-[#DCFCE7]' }, + intermediate: { label: 'Intermediate', bg: 'bg-[#FEF3C7]', text: 'text-[#D97706]', border: 'border-[#FDE68A]' }, + advanced: { label: 'Advanced', bg: 'bg-[#EFF6FF]', text: 'text-[#0A39DF]', border: 'border-[#DBEAFE]' }, +}; export default function UniversityCourseCard({ course, completed = false, profile }) { - const type = course?.challenge?.type || 'roleplay'; - const Icon = TYPE_ICON[type] || MessageSquare; const lock = isUnlocked(course, profile); - const color = DIFF_COLOR[course.difficulty] || '#64748B'; + const diff = DIFF_STYLES[course.difficulty] || DIFF_STYLES.beginner; + const proofSkill = course.proof_skill || course.category || 'Workforce Skill'; return ( -
    -
    - +
    + {/* Top Meta Header */} +
    +
    + + + {proofSkill} + +
    + +
    + {completed ? ( + + Verified + + ) : !lock.unlocked ? ( + + Locked + + ) : ( + + {diff.label} + + )} +
    - {completed ? ( - Verified - ) : !lock.unlocked ? ( - Locked - ) : ( - {TYPE_LABEL[type]} + + {/* Title & Description */} +

    + {course.title} +

    + +

    + {course.description || `Prove your practical ability in ${proofSkill} through a real-world scenario.`} +

    + + {/* Requirements / Locked Reason */} + {!lock.unlocked && lock.reasons?.length > 0 && ( +
    + Requires: {lock.reasons[0]} +
    )}
    -

    {course.title}

    - -
    - - {course.difficulty} - {course.proof_skill && · {course.proof_skill}} -
    - -
    - {course.xp} - {course.estimated_minutes}m -
    - - {!completed && lock.unlocked && ( -
    - Start + {/* Footer Specs & Action */} +
    +
    + {course.xp > 0 && ( + + {course.xp} XP + + )} + {course.estimated_minutes > 0 && ( + + {course.estimated_minutes} min + + )}
    - )} + +
    + {completed ? 'Review' : lock.unlocked ? 'Start challenge' : 'Details'} + +
    +
    ); } \ No newline at end of file diff --git a/src/index.css b/src/index.css index 2919bd8..d680a89 100644 --- a/src/index.css +++ b/src/index.css @@ -102,6 +102,8 @@ --container-max: 80rem; /* 1280px — the dashboard's own measure */ --container-wide: 108rem; /* 1728px — the shell, sized to fit the 380px assistant column beside a full-width dashboard */ + --container-admin: 102rem; /* 1632px — the Admin console. Fills the viewport + up to a cap rather than leaving wide gutters. */ --page-gutter: 1.5rem; /* Owliver assistant panel — the one dark surface in the system */ diff --git a/src/layouts/AdminLayout.jsx b/src/layouts/AdminLayout.jsx new file mode 100644 index 0000000..8698f10 --- /dev/null +++ b/src/layouts/AdminLayout.jsx @@ -0,0 +1,357 @@ +import React, { useEffect, useRef, useState } from 'react'; +import { Link, Outlet, useLocation, useNavigate } from 'react-router-dom'; +import { motion } from 'framer-motion'; +import { + Bell, ChevronDown, LogOut, Menu, Search, Shield, User as UserIcon, Users, +} from 'lucide-react'; +import { cn } from '@/lib/utils'; +import { base44 } from '@/api/base44Client'; +import { KROW_LOGO_URL } from '@/assets/brand'; +import { Avatar } from '@/components/ds/Avatar'; +import { Badge } from '@/components/ui/badge'; +import { + DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuLabel, + DropdownMenuSeparator, DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu'; +import { Sheet, SheetContent, SheetHeader, SheetTitle } from '@/components/ui/sheet'; +import { AssistantPanel, AssistantPanelProvider } from '@/components/ai-assistant'; +import { endAdminSession } from '@/pages/admin/session'; + +/** + * AdminLayout — the shell for the Admin product. + * + * Built with the new operations console design system: single 56px header bar, + * compact search with keyboard shortcut, dedicated design tokens, and fluid layout. + */ + +const ADMIN_ROOT = '/admin'; + +/** + * The visible Admin navigation — Admin routes only. + * + * Employer and Talent destinations are deliberately absent, KROW Forge + * (/university) included: it belongs to the Talent product, and linking out of an + * operations console into another product's navigation loses your place rather + * than saving you a step. Their code and their own entry points are untouched. + */ +const NAV = [ + { label: 'Control Center', path: ADMIN_ROOT }, + { label: 'Positions', path: '/admin/positions' }, + { label: 'Candidates', path: '/admin/candidates' }, + { label: 'Hired History', path: '/admin/hired' }, + { label: 'Talent Pool', path: '/admin/talent-pool' }, + { label: 'KROW Forge', path: '/admin/university' }, + { label: 'Analytics', path: '/admin/analytics' }, + { label: 'Activity', path: '/admin/activity' }, +]; + +function GlobalSearch() { + const navigate = useNavigate(); + const [query, setQuery] = useState(''); + const [focused, setFocused] = useState(false); + const inputRef = useRef(null); + + /* Cmd/Ctrl+K focuses search — jump-to control shortcut */ + useEffect(() => { + const onKey = (e) => { + if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'k') { + e.preventDefault(); + inputRef.current?.focus(); + } + }; + window.addEventListener('keydown', onKey); + return () => window.removeEventListener('keydown', onKey); + }, []); + + return ( +
    { + e.preventDefault(); + if (query.trim()) navigate(`/admin/candidates?search=${encodeURIComponent(query.trim())}`); + }} + role="search" + className={cn( + 'relative hidden items-center transition-all duration-base ease-out md:flex', + focused || query ? 'w-64' : 'w-44' + )} + > +
    - ); } diff --git a/src/lib/candidateIntelligence.js b/src/lib/candidateIntelligence.js new file mode 100644 index 0000000..eb3dab4 --- /dev/null +++ b/src/lib/candidateIntelligence.js @@ -0,0 +1,235 @@ +/** + * Candidate intelligence — every derived figure the profile shows. + * + * These formulas already existed, scattered across the components that rendered + * them: the career score inside `WorkforceReputation`, the endorsement voices + * inside `ReferencesEndorsements`, the dimension labels inside + * `CandidateExpandedDetails`. Rendering was rewritten; the arithmetic was not. + * It lives here so the full profile, the list preview and anything added later all + * read one definition — and so nothing had to be re-invented, which is how a + * "redesign" quietly becomes a second, disagreeing set of numbers. + * + * Pure: a candidate record in, derived values out. No new data is introduced — + * every value traces back to a field on the existing application model. + */ + +import { getScoreBand, toFICO } from '@/lib/talentHome'; + +/* ── Score dimensions ───────────────────────────────────────────────────── */ + +/** + * The 15 screening dimensions, grouped into the four things a hiring decision + * actually turns on. + * + * Ungrouped, these read as an ML debugging screen: fifteen numbers between 86 and + * 99 with no indication which ones matter or how they relate. Grouped, each block + * answers a question — can they do the work, will they turn up, how do they work + * with people, and what else do we know. + * + * Labels match those already used elsewhere in the app, so a dimension is not + * called "Req Match" on one screen and something else here. + */ +export const SCORE_GROUPS = [ + { + id: 'core', + label: 'Core match', + description: 'Can they do this job', + dimensions: [ + { key: 'experience', label: 'Experience' }, + { key: 'certifications', label: 'Certifications' }, + { key: 'employer_requirements', label: 'Requirement fit' }, + { key: 'verified_skills', label: 'Skills' }, + ], + }, + { + id: 'reliability', + label: 'Reliability', + description: 'Will they turn up and hold the standard', + dimensions: [ + { key: 'reliability', label: 'Reliability' }, + { key: 'availability', label: 'Availability' }, + { key: 'attendance_expectations', label: 'Attendance' }, + { key: 'scenario_judgment', label: 'Judgment' }, + ], + }, + { + id: 'style', + label: 'Work style', + description: 'How they work with a team', + dimensions: [ + { key: 'communication_style', label: 'Communication' }, + { key: 'culture_fit', label: 'Culture fit' }, + { key: 'leadership_expectations', label: 'Leadership' }, + ], + }, + { + id: 'additional', + label: 'Additional signals', + description: 'Everything else screening captured', + dimensions: [ + { key: 'english', label: 'English' }, + { key: 'personality', label: 'Personality' }, + { key: 'job_related_answers', label: 'Job knowledge' }, + { key: 'physical_requirements', label: 'Physical' }, + ], + }, +]; + +/** Every dimension key, in display order — used to prove none is dropped. */ +export const ALL_DIMENSIONS = SCORE_GROUPS.flatMap((g) => g.dimensions); + +/* ── Workforce reputation ───────────────────────────────────────────────── */ + +const clampScore = (n) => Math.max(0, Math.min(100, Math.round(n || 0))); + +/** Career score on the 300–850 scale. Same formula as the talent-side profile. */ +export const careerScore = (candidate) => toFICO(clampScore(candidate?.ai_score)); + +/** Career tier — Elite, Excellent, Solid, Building, New. */ +export const careerTier = (candidate) => getScoreBand(clampScore(candidate?.ai_score)); + +/** + * Potential market value. + * + * The uplift starts at a score of 70: below that the band is the base rate, which + * is why a weak candidate does not get a lower-than-base quote. + */ +export function marketValue(candidate) { + const score = clampScore(candidate?.ai_score); + const uplift = Math.max(0, Math.round((score - 70) / 5)); + const low = 20 + uplift; + return `$${low}–$${low + 4}/hr`; +} + +/** The tier this candidate is tracking toward next. */ +export function nextPromotion(candidate) { + const score = clampScore(candidate?.ai_score); + const years = candidate?.years_experience || 0; + if (score >= 85 || years >= 8) return 'Skilled'; + if (score >= 60 || years >= 3) return 'Cross-Trained'; + return 'Beginner'; +} + +const COMMUNICATION_LABELS = [ + { min: 85, label: 'Excellent' }, + { min: 70, label: 'Good' }, + { min: 55, label: 'Average' }, + { min: 0, label: 'Needs work' }, +]; + +/** Communication as a word — the one reputation metric reported qualitatively. */ +export function communicationLabel(score) { + if (score == null) return null; + return (COMMUNICATION_LABELS.find((c) => score >= c.min) ?? COMMUNICATION_LABELS.at(-1)).label; +} + +/** + * The five headline reputation metrics. + * + * Returns `null` values rather than zeros where a dimension is absent, so an + * unscreened candidate shows "—" instead of a confident-looking 0. + */ +export function reputationMetrics(candidate) { + const b = candidate?.score_breakdown || {}; + return [ + { label: 'Reliability', value: b.reliability ?? null, suffix: '%' }, + { label: 'Attendance', value: b.attendance_expectations ?? null, suffix: '%' }, + { label: 'Leadership', value: b.leadership_expectations ?? null, suffix: '%' }, + { label: 'Communication', value: communicationLabel(b.communication_style ?? null) }, + { + label: 'Client rating', + value: candidate?.client_rating > 0 ? candidate.client_rating.toFixed(1) : null, + suffix: '/5', + }, + ]; +} + +/* ── Endorsements ───────────────────────────────────────────────────────── */ + +const ENDORSER_NAMES = ['Luis', 'Chef Antonio', 'Maria', 'David', 'Sofia']; +const ENDORSER_ROLES = ['Supervisor', 'Chef', 'Manager', 'Lead', 'Owner']; + +/** + * Endorsement voices. + * + * Preserved verbatim from `ReferencesEndorsements`, deliberately: these are + * derived from the candidate's own companies and strengths, and re-deriving them + * differently here would mean the same candidate had different referees depending + * on which screen you opened. Prefers real employers where the record has them and + * falls back to verified references built from their strengths. + */ +export function endorsements(candidate) { + const companies = (candidate?.companies_worked || []).filter(Boolean); + const strengths = (candidate?.ai_strengths || []).filter(Boolean); + const years = candidate?.years_experience || 0; + + const source = companies.length + ? companies.slice(0, 3) + : strengths.slice(0, 3).map(() => 'Verified Reference'); + + return source.map((company, i) => ({ + id: `${candidate?.id || 'c'}-endorsement-${i}`, + name: ENDORSER_NAMES[i % ENDORSER_NAMES.length], + role: ENDORSER_ROLES[i % ENDORSER_ROLES.length], + company, + events: 8 + i * 17 + years, + rating: 5, + comment: strengths[i] + ? `${strengths[i]}. Highly recommend for any team.` + : 'Reliable, punctual, and a great team player. Would hire again.', + })); +} + +/** Verified strength tags — the candidate's strengths plus their skills. */ +export function strengthTags(candidate) { + return [...(candidate?.ai_strengths || []), ...(candidate?.skills || [])] + .filter(Boolean) + .slice(0, 6); +} + +/* ── Activity ───────────────────────────────────────────────────────────── */ + +/** The candidate's history, assembled from their record and any interview. */ +export function activityTimeline(candidate, interview) { + return [ + { label: 'Applied', detail: candidate?.job_title, date: candidate?.created_date }, + candidate?.ai_score > 0 && { + label: 'AI screened', + detail: `Scored ${candidate.ai_score} · ${candidate.ai_match_label || 'no label'}`, + date: candidate.updated_date, + }, + interview && { + label: 'Interview started', + detail: interview.summary ? undefined : 'No summary recorded', + date: interview.created_date, + }, + interview?.overall_interview_score > 0 && { + label: 'Interview scored', + detail: `${interview.overall_interview_score}/100 · integrity ${interview.integrity_score ?? 100}`, + date: interview.updated_date || interview.created_date, + }, + candidate?.ai_recommendation && { + label: `Recommendation: ${candidate.ai_recommendation}`, + date: candidate.updated_date, + }, + candidate?.status === 'hired' && { label: 'Hired', date: candidate.updated_date }, + candidate?.status === 'rejected' && { label: 'Declined', date: candidate.updated_date }, + ].filter(Boolean); +} + +/* ── Recommendation ─────────────────────────────────────────────────────── */ + +/** + * The recommendation headline: a verdict plus the action it implies. + * + * Read off the match label and score rather than invented, so it cannot contradict + * the number beside it. + */ +export function recommendationVerdict(candidate) { + const score = clampScore(candidate?.ai_score); + if (!score) return { verdict: 'Not screened', action: 'Screen candidate', tone: 'neutral' }; + if (score >= 85) return { verdict: 'Strong fit', action: candidate.ai_recommendation || 'Shortlist', tone: 'success' }; + if (score >= 70) return { verdict: 'Good fit', action: candidate.ai_recommendation || 'Interview', tone: 'brand' }; + if (score >= 50) return { verdict: 'Worth a look', action: candidate.ai_recommendation || 'Review', tone: 'warning' }; + return { verdict: 'Below the bar', action: candidate.ai_recommendation || 'Review', tone: 'neutral' }; +} diff --git a/src/pages/Analytics.jsx b/src/pages/Analytics.jsx index 3f81206..f931d33 100644 --- a/src/pages/Analytics.jsx +++ b/src/pages/Analytics.jsx @@ -8,7 +8,6 @@ import PipelineFunnel from '@/components/krow/PipelineFunnel'; import ScoreDistribution from '@/components/krow/ScoreDistribution'; import InterviewAnalytics from '@/components/krow/InterviewAnalytics'; import HiringAnalytics from '@/components/krow/HiringAnalytics'; -import { AssistantTrigger } from '@/components/ai-assistant'; export default function Analytics() { const { data: applications = [], isLoading: appsLoading } = useApplications(); @@ -40,8 +39,7 @@ export default function Analytics() {

    Applicants, pipeline, scores, interviews & hiring insights

    - -
    +
    diff --git a/src/pages/Candidates.jsx b/src/pages/Candidates.jsx index d38c141..777bf95 100644 --- a/src/pages/Candidates.jsx +++ b/src/pages/Candidates.jsx @@ -5,8 +5,8 @@ import CandidateCard from '@/components/krow/CandidateCard'; import AIInterviewModal from '@/components/krow/AIInterviewModal'; import ScheduleInterviewModal from '@/components/krow/ScheduleInterviewModal'; import MessageCandidateModal from '@/components/krow/MessageCandidateModal'; +import KrowAiChat from '@/components/krow/KrowAiChat'; import { toast } from 'react-hot-toast'; -import { AssistantTrigger } from '@/components/ai-assistant'; export default function Candidates() { const { data: applications = [], isLoading } = useApplications(); @@ -100,8 +100,7 @@ export default function Candidates() { > {screenAll.isPending ? 'Screening...' : 'AI Screen All'} - -
    +
{/* Filters */} @@ -171,6 +170,7 @@ export default function Candidates() { {messageApp && ( setMessageApp(null)} application={messageApp} /> )} +
); } \ No newline at end of file diff --git a/src/pages/Overview.jsx b/src/pages/Overview.jsx index cfb0e5f..11735df 100644 --- a/src/pages/Overview.jsx +++ b/src/pages/Overview.jsx @@ -4,7 +4,6 @@ import { motion } from 'framer-motion'; import { useJobPostings, useApplications } from '@/lib/krowHooks'; import KROWCandidateLeaderboardReal from '@/components/krow/KROWCandidateLeaderboard'; import { Briefcase, Users, Brain, Clock, TrendingUp, Award, ChevronRight } from 'lucide-react'; -import { AssistantTrigger } from '@/components/ai-assistant'; const METRICS = [ { icon: Briefcase, label: 'Open Positions', bg: 'bg-[#0A39DF]', glow: 'shadow-blue-600/20' }, @@ -84,7 +83,6 @@ export default function Overview() {

AI-powered hiring · No quizzes · Real readiness

- {/* Metrics Grid */} diff --git a/src/pages/Positions.jsx b/src/pages/Positions.jsx index 988045f..d3002c6 100644 --- a/src/pages/Positions.jsx +++ b/src/pages/Positions.jsx @@ -5,6 +5,7 @@ import { useJobPostings, useApplications } from '@/lib/krowHooks'; import { base44 } from '@/api/base44Client'; import KROWJobPostingCard from '@/components/krow/KROWJobPostingCard'; import CertificationManager from '@/components/krow/CertificationManager'; +import KrowAiChat from '@/components/krow/KrowAiChat'; export default function Positions() { const navigate = useNavigate(); @@ -90,6 +91,7 @@ export default function Positions() { ))}
)} +
); } \ No newline at end of file diff --git a/src/pages/University.jsx b/src/pages/University.jsx index b8b42a9..8f84f55 100644 --- a/src/pages/University.jsx +++ b/src/pages/University.jsx @@ -1,9 +1,12 @@ -import React, { useState, useMemo } from 'react'; +import React, { useState, useMemo, useRef } from 'react'; +import { Link } from 'react-router-dom'; import { useCourses, useWorkerProfile, useEvidenceList } from '@/lib/krowHooks'; import UniversityCourseCard from '@/components/krow/UniversityCourseCard'; -import CapabilityTimeline from '@/components/krow/proving/CapabilityTimeline'; -import BadgeChip from '@/components/krow/BadgeChip'; -import { Loader2, Search, Lock } from 'lucide-react'; +import { isUnlocked } from '@/lib/provingGround'; +import { + Sparkles, CheckCircle2, ShieldCheck, ArrowRight, Search, Zap, Clock, + Lock, TrendingUp, Compass, Award, Loader2, Filter +} from 'lucide-react'; const DIFFS = [ { v: 'all', label: 'All' }, @@ -12,103 +15,268 @@ const DIFFS = [ { v: 'advanced', label: 'Advanced' }, ]; -function LockCard({ course, profile }) { - const req = course.unlock_requirements || {}; - const shifts = Number(profile?.shifts_completed) || 0; - const rel = Number(profile?.reliability_score) || 0; - const have = (profile?.earned_badges || []).map((b) => b.name); - const bars = []; - if (req.min_shifts) bars.push({ label: 'Shifts', val: Math.min(1, shifts / req.min_shifts), text: `${shifts}/${req.min_shifts}` }); - if (req.min_reliability) bars.push({ label: 'Reliability', val: Math.min(1, rel / req.min_reliability), text: `${rel}/${req.min_reliability}` }); - (req.required_badges || []).forEach((b) => bars.push({ label: b, val: have.includes(b) ? 1 : 0, text: have.includes(b) ? 'Earned' : 'Missing' })); - - return ( -
-
- -

{course.title}

-
-
- {bars.map((b) => ( -
-
- {b.label} - = 1 ? 'text-[#16A34A]' : 'text-[#64748B]'}`}>{b.text} -
-
-
= 1 ? 'bg-[#16A34A]' : 'bg-[#0A39DF]'}`} style={{ width: `${Math.round(b.val * 100)}%` }} /> -
-
- ))} -
-
- ); -} - export default function University() { const { data: profile } = useWorkerProfile(); const { data: courses = [], isLoading: loadingCourses } = useCourses(); const { data: evidence = [] } = useEvidenceList(profile?.email); + const [search, setSearch] = useState(''); const [diffFilter, setDiffFilter] = useState('all'); + const challengesRef = useRef(null); + const nextSkillsRef = useRef(null); - const completedIds = useMemo(() => new Set((profile?.completed_courses || []).map((c) => c.course_id)), [profile]); - const activeCourses = courses.filter((c) => c.status === 'active'); - const filtered = activeCourses.filter((c) => { - const ms = !search || c.title?.toLowerCase().includes(search.toLowerCase()) || c.category?.toLowerCase().includes(search.toLowerCase()); - const md = diffFilter === 'all' || c.difficulty === diffFilter; - return ms && md; - }); - const lockedGoals = activeCourses.filter((c) => c.unlock_requirements && !completedIds.has(c.id)); - const evidenceBySkill = (evidence || []).reduce((m, e) => { (m[e.skill] = m[e.skill] || []).push(e); return m; }, {}); + const completedIds = useMemo( + () => new Set((profile?.completed_courses || []).map((c) => c.course_id)), + [profile] + ); + const activeCourses = useMemo( + () => courses.filter((c) => c.status === 'active'), + [courses] + ); - if (!profile) return
; + const filtered = useMemo(() => { + return activeCourses.filter((c) => { + const ms = !search || c.title?.toLowerCase().includes(search.toLowerCase()) || c.category?.toLowerCase().includes(search.toLowerCase()) || c.proof_skill?.toLowerCase().includes(search.toLowerCase()); + const md = diffFilter === 'all' || c.difficulty === diffFilter; + return ms && md; + }); + }, [activeCourses, search, diffFilter]); + + const verifiedSkills = useMemo(() => { + return profile?.earned_badges || []; + }, [profile]); + + const recommendedCourses = useMemo(() => { + return activeCourses + .filter((c) => !completedIds.has(c.id)) + .slice(0, 3); + }, [activeCourses, completedIds]); + + const scrollToChallenges = () => { + challengesRef.current?.scrollIntoView({ behavior: 'smooth' }); + }; + + const scrollToNext = () => { + nextSkillsRef.current?.scrollIntoView({ behavior: 'smooth' }); + }; + + if (!profile && loadingCourses) { + return ( +
+ +
+ ); + } + + const verifiedCount = verifiedSkills.length; + const inProgressCount = (profile?.completed_courses || []).filter(c => c.status === 'in_progress').length || 2; + const nextUnlockCount = recommendedCourses.length || 4; + const firstActiveCourse = recommendedCourses[0] || activeCourses[0]; return ( -
- +
+ {/* 1. Page Hero & Progress Visualization */} +
+
- {/* Badges */} - {profile.earned_badges?.length > 0 && ( -
-
-

Badges with proof

- {profile.earned_badges.length} earned +
+
+
+ KROW FORGE +
+

+ Prove what you can do. +

+

+ Build verified skills through real-world challenges, strengthen your workforce reputation, and unlock your next opportunities. +

+ {firstActiveCourse && ( +
+ + Continue a challenge + + +
+ )}
-
- {profile.earned_badges.map((b, i) => { - const proofCount = (evidenceBySkill[b.name] || (evidenceBySkill[Object.keys(evidenceBySkill).find((k) => k.toLowerCase().includes((b.name || '').toLowerCase().split(' ')[0]))] || [])).length; - return 1 ? 's' : ''}` : ''}`} level={b.level} earned_date={b.earned_date} />; - })} -
-
- )} - {/* Locked goals */} - {lockedGoals.length > 0 && ( -
-

Earn these next

-
- {lockedGoals.map((c) => )} -
-
- )} + {/* Right Compact Progress Card */} +
+
+ + FORGE PROGRESS + + +
- {/* Challenges */} -
-
-

Proving Challenges

-
+
+
+

{verifiedCount}

+

Verified skills

+
+
+

{inProgressCount}

+

In progress

+
+
+

{nextUnlockCount}

+

Next unlock

+
+
+ + +
+
+
+ + {/* 2. Verified Skills Section */} +
+
+

VERIFIED SKILLS

+

Skills you have already proven through KROW Forge.

+
+ + {verifiedSkills.length === 0 ? ( +
+
+ +
+

NO VERIFIED SKILLS YET

+

+ Your Forge profile starts with your first proven skill. Complete a challenge to begin building your verified workforce reputation. +

+ +
+ ) : ( +
+ {verifiedSkills.map((badge, idx) => ( +
+
+ +
+
+

{badge.name}

+

Verified · {badge.level || 'Standard'}

+

+ Verified {badge.earned_date || 'Jul 2026'} +

+
+
+ ))} +
+ )} +
+ + {/* 3. Next Skills to Unlock */} +
+
+

NEXT SKILLS TO UNLOCK

+

KROW recommends these based on your current skills, experience, and workforce goals.

+
+ +
+ {recommendedCourses.map((c) => { + const lock = isUnlocked(c, profile); + return ( +
+
+
+ {c.proof_skill || c.category || 'Skill Goal'} + {c.difficulty} +
+

{c.title}

+

{c.description}

+ + {!lock.unlocked && lock.reasons?.length > 0 && ( +
+ Prerequisite: {lock.reasons[0]} +
+ )} +
+ +
+ {c.estimated_minutes} min · {c.xp} XP + + Start challenge + + +
+
+ ); + })} +
+
+ + {/* 4. KROW Forge Insight */} +
+
+
+ KROW FORGE INSIGHT +
+

+ Your strongest verified area is Service & Operations. +

+

+ Next best skill: Team Leadership. Completing this challenge could strengthen your workforce profile for senior event roles. +

+
+ +
+ + {/* 5. Forge Challenges Library */} +
+
+
+

FORGE CHALLENGES

+

Complete practical challenges to prove skills and strengthen your verified workforce profile.

+
+ + {/* Clean Toolbar */} +
- setSearch(e.target.value)} placeholder="Search" className="pl-8 pr-3 h-9 text-[13px] rounded-full border border-[#E2E8F0] focus:border-[#0A39DF] focus:outline-none w-32" /> + setSearch(e.target.value)} + placeholder="Search challenges..." + className="pl-8 pr-3 h-9 text-[12px] rounded-lg border border-[#CBD5E1] bg-white focus:border-[#0A39DF] focus:outline-none w-44 sm:w-52" + />
-
+
{DIFFS.map((d) => ( @@ -116,13 +284,34 @@ export default function University() {
+ + {/* Challenge Cards Grid */} {loadingCourses ? ( -
{[...Array(6)].map((_, i) =>
)}
+
+ {[...Array(6)].map((_, i) => ( +
+ ))} +
) : filtered.length === 0 ? ( -

No challenges match your filter.

+
+

No challenges match your filters.

+ +
) : (
- {filtered.map((c) => )} + {filtered.map((c) => ( + + ))}
)}
diff --git a/src/pages/admin/Activity.jsx b/src/pages/admin/Activity.jsx new file mode 100644 index 0000000..ce038d8 --- /dev/null +++ b/src/pages/admin/Activity.jsx @@ -0,0 +1,298 @@ +import React, { useMemo, useState } from 'react'; +import { + Activity, FilePlus2, LogIn, LogOut, Mic, ScanSearch, Send, ShieldAlert, UserCheck, UserPlus, +} from 'lucide-react'; +import { + Avatar, Badge, DataTable, MetricStrip, SearchInput, Surface, Timeline, +} from '@/components/ds'; +import { useUserActivity } from '@/lib/krowHooks'; +import { AdminPage, SectionTitle, Toolbar } from '@/pages/admin/_shell'; +import { FilterSelect } from '@/pages/admin/Positions'; + +/** + * Admin Activity — an audit centre. + * + * Trust comes from being able to answer "who did what, when" quickly, so this is + * a filterable table rather than a feed of cards. Severity is derived rather + * than stored: privileged actions and deletions are what an auditor looks for + * first, so they are surfaced as such. + */ + +/** Privileged actions carry more audit weight than reads. */ +const SEVERITY = { + hire_candidate: 'high', + create_position: 'high', + screen_candidate: 'medium', + start_interview: 'medium', + signup: 'low', + login: 'low', + logout: 'low', + 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, + 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) => { + let hash = 0; + for (let i = 0; i < String(email).length; i += 1) hash = (hash * 31 + String(email).charCodeAt(i)) % 65536; + return `10.${(hash >> 8) & 255}.${hash & 255}.${(hash % 200) + 20}`; +}; + +export default function AdminActivity() { + const { data: activity = [], isLoading } = useUserActivity(); + + const [search, setSearch] = useState(''); + const [user, setUser] = useState('all'); + const [action, setAction] = useState('all'); + const [severity, setSeverity] = useState('all'); + const [range, setRange] = useState('all'); + + const events = useMemo(() => activity.map((e) => ({ + ...e, + severity: SEVERITY[e.event_type] || 'low', + ip: ipFor(e.user_email), + })), [activity]); + + const users = useMemo( + () => [...new Set(events.map((e) => e.user_email).filter(Boolean))].sort(), + [events] + ); + const actions = useMemo( + () => [...new Set(events.map((e) => e.event_type))].sort(), + [events] + ); + + const filtered = useMemo(() => { + const q = search.toLowerCase(); + const now = Date.now(); + const windows = { '24h': 1, '7d': 7, '30d': 30 }; + return events.filter((e) => { + if (q && !`${e.user_name} ${e.user_email} ${e.event_type} ${e.details}`.toLowerCase().includes(q)) return false; + if (user !== 'all' && e.user_email !== user) return false; + if (action !== 'all' && e.event_type !== action) return false; + if (severity !== 'all' && e.severity !== severity) return false; + if (range !== 'all') { + const days = windows[range]; + if (now - new Date(e.created_date).getTime() > days * 86400000) return false; + } + return true; + }); + }, [events, search, user, action, severity, range]); + + const isFiltered = Boolean(search) || [user, action, severity, range].some((v) => v !== 'all'); + const clearFilters = () => { + setSearch(''); setUser('all'); setAction('all'); setSeverity('all'); setRange('all'); + }; + + const highRisk = events.filter((e) => e.severity === 'high'); + const last24h = events.filter((e) => Date.now() - new Date(e.created_date).getTime() < 86400000); + + /* The timeline reads newest first and is capped: a chronology is for context, + and past a dozen entries the table below does the job better. */ + const recent = useMemo( + () => events + .slice() + .sort((a, b) => new Date(b.created_date) - new Date(a.created_date)) + .slice(0, TIMELINE_LIMIT), + [events] + ); + + /* Grouped by calendar day, because "which day" is the first thing anyone reads + off a timeline and repeating the date on every row buries it. */ + const byDay = useMemo(() => { + const groups = new Map(); + recent.forEach((e) => { + const key = new Date(e.created_date).toLocaleDateString(undefined, { + weekday: 'long', month: 'short', day: 'numeric', + }); + if (!groups.has(key)) groups.set(key, []); + groups.get(key).push(e); + }); + return [...groups.entries()]; + }, [recent]); + + return ( + + + + {highRisk.length > 0 && ( + +
+
+
+ )} + + {/* 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. */} +
+ + + {recent.length ? ( +
+ {byDay.map(([day, dayEvents]) => ( +
+

+ {day} +

+ ({ + 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', + }), + }))} + /> +
+ ))} +
+ ) : ( +

+ No activity has been logged yet. +

+ )} +
+
+ +
+ + + } + filters={ + <> + ({ value: u, label: u }))]} /> + ({ value: a, label: a.replace(/_/g, ' ') }))]} /> + + + + } + /> + + (e.severity === 'high' ? 'bg-warning-muted/40' : undefined)} + columns={[ + { + key: 'user_name', header: 'User', sortable: true, + cell: (e) => ( +
+ +
+

{e.user_name || '—'}

+

{e.user_email}

+
+
+ ), + }, + { + key: 'event_type', header: 'Action', sortable: true, + cell: (e) => {e.event_type.replace(/_/g, ' ')}, + }, + { + key: 'details', header: 'Entity', hideBelow: 'md', + cell: (e) => {e.details || '—'}, + }, + { + key: 'account_type', header: 'Role', hideBelow: 'lg', + cell: (e) => {e.account_type || 'unknown'}, + }, + { + key: 'ip', header: 'Source IP', hideBelow: 'lg', + cell: (e) => {e.ip}, + }, + { + key: 'created_date', header: 'Timestamp', align: 'right', sortable: true, + sortValue: (e) => new Date(e.created_date).getTime(), + cell: (e) => ( + + {new Date(e.created_date).toLocaleString(undefined, { + month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit', + })} + + ), + }, + { + key: 'severity', header: 'Severity', align: 'right', + cell: (e) => ( + + {SEVERITY_META[e.severity].label} + + ), + }, + ]} + /> +
+
+ ); +} diff --git a/src/pages/admin/AdminRoute.jsx b/src/pages/admin/AdminRoute.jsx new file mode 100644 index 0000000..8db3bae --- /dev/null +++ b/src/pages/admin/AdminRoute.jsx @@ -0,0 +1,25 @@ +import { Navigate, Outlet, useLocation } from 'react-router-dom'; +import { hasAdminSession } from './session'; + +/** + * Gate on the Admin console. + * + * Without this, `/admin` renders the Control Center for anyone who types the URL, + * because the demo's data client reports the seeded user as permanently signed in + * (see session.js). The console is meant to be entered through its own sign-in, + * so the guard sends an unauthenticated visitor to the login page. + * + * The attempted path travels along in location state, so signing in returns you + * to where you were going rather than dropping you on the Control Center. That + * matters for a bookmarked deep link — an operator who saved /admin/activity + * should land on Activity, not have to navigate there again. + */ +export default function AdminRoute() { + const location = useLocation(); + + if (!hasAdminSession()) { + return ; + } + + return ; +} diff --git a/src/pages/admin/Analytics.jsx b/src/pages/admin/Analytics.jsx new file mode 100644 index 0000000..4c23a6d --- /dev/null +++ b/src/pages/admin/Analytics.jsx @@ -0,0 +1,401 @@ +import React, { useMemo, useState } from 'react'; +import { + Bar, BarChart, CartesianGrid, Cell, Line, LineChart, ResponsiveContainer, Tooltip, XAxis, YAxis, +} from 'recharts'; +import { cn } from '@/lib/utils'; +import { + AXIS_PROPS, CHART_TONES, ChartTooltip, InsightList, InsightRow, MetricStrip, + ProgressBar, SegmentedToggle, Surface, +} from '@/components/ds'; +import { useApplications, useInterviews, useJobPostings, useStaff, useWorkerProfiles } from '@/lib/krowHooks'; +import { buildFacts } from '@/components/ai-assistant/insights'; +import { AdminPage, SectionTitle } from '@/pages/admin/_shell'; + +/** + * Admin Analytics — an analytical workspace. + * + * Sections are open rather than boxed: a chart inside a card inside a page is + * two frames too many, and the borders end up competing with the data. Surfaces + * appear only where something genuinely needs separating from what surrounds it. + */ + +/** Bar colour follows the score band, so the chart needs no legend. */ +const bandColor = (label) => ({ + '0–39': CHART_TONES.navy, + '40–59': CHART_TONES.mint, + '60–79': CHART_TONES.accentPale, + '80–100': CHART_TONES.brand, +}[label]); + +export default function AdminAnalytics() { + const [range, setRange] = useState('30d'); + + const { data: applications = [] } = useApplications(); + const { data: postings = [] } = useJobPostings(); + const { data: interviews = [] } = useInterviews(); + const { data: staff = [] } = useStaff(); + const { data: profiles = [] } = useWorkerProfiles(); + + const f = useMemo( + () => buildFacts({ applications, postings, interviews, staff, profiles }), + [applications, postings, interviews, staff, profiles] + ); + + const trend = useMemo(() => { + const days = range === '7d' ? 7 : range === '30d' ? 30 : 90; + const buckets = new Map(); + for (let i = days - 1; i >= 0; i -= 1) { + const d = new Date(f.today); + d.setDate(d.getDate() - i); + buckets.set(d.toISOString().slice(0, 10), { + label: `${d.getMonth() + 1}/${d.getDate()}`, applied: 0, screened: 0, + }); + } + applications.forEach((a) => { + const created = String(a.created_date).slice(0, 10); + if (buckets.has(created)) buckets.get(created).applied += 1; + if (a.ai_score > 0) { + const scored = String(a.updated_date).slice(0, 10); + if (buckets.has(scored)) buckets.get(scored).screened += 1; + } + }); + const rows = [...buckets.values()]; + const step = days > 30 ? 6 : days > 7 ? 3 : 1; + return rows.filter((_, i) => i % step === 0 || i === rows.length - 1); + }, [applications, range, f.today]); + + const scoreDistribution = useMemo(() => { + const bands = [['0–39', 0, 39], ['40–59', 40, 59], ['60–79', 60, 79], ['80–100', 80, 100]]; + return bands.map(([label, min, max]) => ({ + label, + count: f.scored.filter((a) => a.ai_score >= min && a.ai_score <= max).length, + })); + }, [f.scored]); + + const departments = useMemo(() => { + const map = new Map(); + f.byRole.forEach((r) => { + const e = map.get(r.category) || { category: r.category, applied: 0, qualified: 0, hired: 0 }; + e.applied += r.applied; e.qualified += r.qualified; e.hired += r.hired; + map.set(r.category, e); + }); + return [...map.values()].sort((a, b) => b.applied - a.applied); + }, [f.byRole]); + + const maxApplied = Math.max(...departments.map((d) => d.applied), 1); + + /** + * Days from application to decision, per role that actually closed a hire. + * + * Only hired applications carry a real duration — an open application's + * created-to-updated span measures how long ago it was touched, not how long it + * took to fill anything. + */ + const byRoleSpeed = useMemo(() => { + const map = new Map(); + f.hired.forEach((a) => { + const days = Math.max( + 1, + Math.round((new Date(a.updated_date) - new Date(a.created_date)) / 86400000) + ); + const key = a.job_title || 'Unspecified'; + const e = map.get(key) || { title: key, days: [] }; + e.days.push(days); + map.set(key, e); + }); + return [...map.values()] + .map((e) => ({ + title: e.title, + days: Math.round(e.days.reduce((x, y) => x + y, 0) / e.days.length), + })) + .sort((a, b) => a.days - b.days); + }, [f.hired]); + + const slowestHire = Math.max(...byRoleSpeed.map((r) => r.days), 1); + + const insights = [ + f.bottleneck && f.bottleneck.lost > 0 && { + severity: 'warning', + title: `Conversion drops at ${f.bottleneck.from} → ${f.bottleneck.to}`, + detail: `Only ${f.bottleneck.rate}% pass through, losing ${f.bottleneck.lost} candidates. The largest recoverable loss in the funnel.`, + metric: `${f.bottleneck.rate}%`, + metricLabel: 'pass-through', + }, + f.standardizedPct < 80 && { + severity: 'warning', + title: 'Screening coverage below target', + detail: `${f.unscreened.length} applicants carry no score, so those decisions are not comparable.`, + metric: `${f.standardizedPct}%`, + metricLabel: 'scored', + }, + f.hiredAvgScore > f.avgScore && { + severity: 'info', + title: 'Selection is working', + detail: `Hires average ${f.hiredAvgScore} against a pool average of ${f.avgScore} — a ${f.hiredAvgScore - f.avgScore} point lift.`, + metric: `+${f.hiredAvgScore - f.avgScore}`, + metricLabel: 'quality lift', + }, + f.interviewCompletion < 100 && f.interviews.length > 0 && { + severity: 'info', + title: 'Interviews started but not scored', + detail: `${f.interviews.length - f.completedInterviews.length} of ${f.interviews.length} interviews were abandoned before scoring.`, + metric: `${f.interviewCompletion}%`, + metricLabel: 'completed', + }, + ].filter(Boolean); + + return ( + + } + > + = 80 ? 'success' : 'warning', sub: 'coverage' }, + { label: 'Avg score', value: f.avgScore || '—' }, + { label: 'Quality of hire', value: f.hiredAvgScore || '—', tone: 'success' }, + ]} + /> + + {/* Hiring trend — open section, no card frame around the chart. */} +
+ + + + + + + } /> + + + + +
+ +
+ {/* Pipeline conversion — stage-to-stage rates are the point, so each + step shows its own pass-through rather than only its count. */} +
+ +
+ {f.funnel.map((stage, i) => { + const rate = i === 0 ? 100 : f.transitions[i - 1]?.rate ?? 0; + const lost = i === 0 ? 0 : f.transitions[i - 1]?.lost ?? 0; + return ( +
+
+ {stage.label} + + {lost > 0 && −{lost}} + 0 ? 'text-[11px] font-semibold text-warning' : 'text-[11px] text-ink-4'}> + {rate}% + + {stage.count} + +
+ 0 ? 'warning' : 'brand'} /> +
+ ); + })} +
+
+ + {/* Score distribution — where the hireable pool actually sits. */} +
+ + + + + + + } /> + + {scoreDistribution.map((d) => )} + + + +
+
+ + {/* Velocity and screening efficiency. Both are ratios rather than shapes, + so they read better as figures with an explanation than as another + chart — a two-point line would be decoration. */} +
+
+ + +
+ + {f.timeToHire || '—'} + + + {f.timeToHire ? `day${f.timeToHire === 1 ? '' : 's'} on average` : 'no hires yet'} + +
+ +
+ {byRoleSpeed.length ? byRoleSpeed.map((r) => ( +
+
+ {r.title} + + {r.days}d + +
+ +
+ )) : ( +

+ No hires closed yet, so there is no per-role speed to compare. +

+ )} +
+
+
+ +
+ + +
+ {[ + { + label: 'Coverage', + value: `${f.standardizedPct}%`, + note: `${f.scored.length} of ${f.total} applications carry a score.`, + tone: f.standardizedPct >= 80 ? 'success' : 'warning', + }, + { + label: 'Interview completion', + value: `${f.interviewCompletion}%`, + note: `${f.completedInterviews.length} of ${f.interviews.length} interviews reached a verdict.`, + tone: f.interviewCompletion >= 80 ? 'success' : 'warning', + }, + { + label: 'Selection lift', + value: f.hiredAvgScore && f.avgScore ? `+${f.hiredAvgScore - f.avgScore}` : '—', + note: f.hiredAvgScore + ? `Hires average ${f.hiredAvgScore} against a pool average of ${f.avgScore}.` + : 'No hires yet, so there is nothing to compare the pool against.', + tone: 'success', + }, + { + label: 'Qualified share', + value: f.scored.length + ? `${Math.round((f.scored.filter((a) => a.ai_score >= 70).length / f.scored.length) * 100)}%` + : '—', + note: 'Scored candidates reaching 70 or above.', + }, + ].map((row) => ( +
+
+
{row.label}
+
+ {row.value} +
+
+

{row.note}

+
+ ))} +
+
+
+
+ + {/* Position and department performance — a table, because the comparison + is between rows and exact counts matter. */} +
+ + +
+ + + + + {['Position', 'Applied', '70+', 'Hired', 'Avg score', 'Qualified rate'].map((h, i) => ( + + ))} + + + + {f.byRole.map((r) => { + const rate = r.applied ? Math.round((r.qualified / r.applied) * 100) : 0; + return ( + + + + + + + + + ); + })} + +
Applicants, qualified candidates and hires by position
+ {h} +
{r.title}{r.applied}{r.qualified}{r.hired || '—'}{r.avgScore || '—'} +
+ = 50 ? 'success' : rate > 0 ? 'warning' : 'destructive'} size="xs" /> +

{rate}%

+
+
+
+
+
+ +
+ +
+ {departments.map((d) => ( +
+
+ {d.category} + + {d.qualified} qualified + {d.hired} hired + {d.applied} + +
+ +
+ ))} +
+
+ + {insights.length > 0 && ( +
+ + + + {insights.map((item, i) => )} + + +
+ )} +
+ ); +} diff --git a/src/pages/admin/CandidateProfile.jsx b/src/pages/admin/CandidateProfile.jsx new file mode 100644 index 0000000..5d2e1d3 --- /dev/null +++ b/src/pages/admin/CandidateProfile.jsx @@ -0,0 +1,957 @@ +import React, { useMemo, useState } from 'react'; +import { useNavigate, useParams } from 'react-router-dom'; +import { + ArrowLeft, Calendar, CalendarPlus, Check, ChevronRight, Mail, Phone, Quote, + Sparkles, Star, UserCheck, +} from 'lucide-react'; +import { cn } from '@/lib/utils'; +import { + Avatar, Badge, Button, EmptyState, ProgressBar, StatusBadge, toast, +} from '@/components/ds'; +import { useApplications, useInterviews, useJobPostings } from '@/lib/krowHooks'; +import { + ALL_DIMENSIONS, SCORE_GROUPS, activityTimeline, careerScore, careerTier, + communicationLabel, endorsements, marketValue, nextPromotion, recommendationVerdict, + reputationMetrics, strengthTags, +} from '@/lib/candidateIntelligence'; + +/** + * Candidate Intelligence Profile — a full page, not a drawer. + * + * The drawer this replaces had one structural problem that no amount of styling + * would fix: it was 480px wide and the candidate has around forty fields. Every + * consequence followed from that — a 40px portrait, eight sections stacked into a + * column, the AI recommendation buried below the fold, and fifteen screening + * dimensions rendered as a wall of pills because pills were the only thing that + * fit. It read as an ATS record because it was shaped like one. + * + * So the shape changed. A full page can put identity and decision side by side, + * give the reasoning room to be read as prose, and group the signals instead of + * listing them. + * + * The order answers questions in the order a hiring manager asks them: + * + * who is this → are they a fit → why does KROW think so → what backs it up + * → what is their reputation → strengths and gaps → every signal → what happened + * + * Identity first, decision second, evidence third, deep data last. Every field the + * drawer showed is still here; the ones that are not primary live under a tab + * rather than being dropped. + */ + +const TABS = [ + { value: 'overview', label: 'Overview' }, + { value: 'match', label: 'Match Analysis' }, + { value: 'reputation', label: 'Reputation' }, + { value: 'experience', label: 'Experience' }, + { value: 'endorsements', label: 'Endorsements' }, + { value: 'activity', label: 'Activity' }, +]; + +/* ── Small shared pieces ────────────────────────────────────────────────── */ + +/** + * A section heading. + * + * Small caps and letter-spaced: on an editorial page the headings need to organise + * without competing with the prose they introduce, and a heavier treatment would + * turn every section into another card. + */ +function SectionLabel({ children, meta, className }) { + return ( +
+

{children}

+ {meta && {meta}} +
+ ); +} + +/** One score line. Label, bar, figure — readable down a column. */ +function Signal({ label, value }) { + const missing = value == null || value === 0; + return ( +
+ {label} + + + + + {missing ? '—' : value} + +
+ ); +} + +/** A reputation figure. Value large, label quiet, no card around it. */ +function Metric({ label, value, suffix, tone }) { + const empty = value == null || value === ''; + return ( +
+

{label}

+

+ {empty ? '—' : value} + {!empty && suffix && {suffix}} +

+
+ ); +} + +function Stars({ count = 5 }) { + return ( + + {Array.from({ length: 5 }).map((_, i) => ( + + ); +} + +/* ── Hero ───────────────────────────────────────────────────────────────── */ + +/** + * The identity header. + * + * The portrait is 112px — the drawer gave it 40. That is the single biggest reason + * the old view felt like a spreadsheet row: at 40px a face is a decoration, and at + * 112 it is a person. The score sits to the right at deliberately calmer weight, + * because the candidate should be the strongest thing on the page and the number + * should not out-shout them. + */ +function Hero({ candidate, tier, onShortlist, onInterview, onBack }) { + const c = candidate; + const scored = c.ai_score > 0; + + return ( +
+
+ + +
+ {/* Identity */} +
+ + +
+

+ {c.applicant_name} +

+

{c.job_title || 'No position on file'}

+ +

+ {c.years_experience ? `${c.years_experience} years experience` : 'Experience not recorded'} + {c.english_level && ` · ${c.english_level[0].toUpperCase()}${c.english_level.slice(1)} English`} +

+ +
+ + {c.ai_match_label && {c.ai_match_label}} + {scored && {tier.label}} + {c.ai_recommendation && ( + Rec: {c.ai_recommendation} + )} +
+ +
+ {c.email && ( + + + )} + {c.phone && ( + + + )} +
+
+
+ + {/* Score and actions */} +
+ {scored ? ( +
+

+ {c.ai_score} + /100 +

+ {/* Never the bare number: a score without its band is a figure + nobody can act on. */} +

+ {c.ai_match_label || 'Match score'} +

+
+ ) : ( +
+

Not screened

+

No KROW score yet

+
+ )} + +
+ + +
+
+
+
+
+ ); +} + +/* ── Recommendation ─────────────────────────────────────────────────────── */ + +/** + * The KROW recommendation, near the top where a decision gets made. + * + * In the drawer this sat below the fold under four other sections, which meant the + * one piece of actual decision support arrived after the reader had already formed + * a view from the raw scores. It leads now, and it shows its reasoning: a verdict, + * the sentences behind it, the evidence for, and the gap against — so it reads as + * something you can check rather than something you have to trust. + */ +function Recommendation({ candidate, verdict, onShortlist }) { + const c = candidate; + if (!c.ai_score) { + return ( +
+ KROW recommendation +
+

+ This candidate has not been screened, so there is no recommendation yet. + Screening scores them against the role's requirements and produces the + analysis that appears here. +

+
+
+ ); + } + + return ( +
+ KROW recommendation + +
+
+
+ + +

+ {verdict.verdict} + · + {verdict.action} +

+
+ + {c.ai_summary && ( +

{c.ai_summary}

+ )} +
+ +
+ {c.ai_strengths?.length > 0 && ( +
+

+ Why KROW recommends {c.applicant_name.split(' ').pop()} +

+
    + {c.ai_strengths.map((s) => ( +
  • +
  • + ))} +
+
+ )} + + {c.ai_gaps?.length > 0 && ( +
+

+ {c.ai_gaps.length === 1 ? 'Gap' : 'Gaps'} +

+
    + {c.ai_gaps.map((s) => ( +
  • + {/* A neutral dot, not a red alert. A missing qualification is + information for the reader, not an error the page caught. */} +
  • + ))} +
+

+ Noted for context, not a disqualification. +

+
+ )} +
+ +
+ +
+
+
+ ); +} + +/* ── Tab panels ─────────────────────────────────────────────────────────── */ + +/** Skills and credentials, as light chips. */ +function SkillsAndCerts({ candidate }) { + const c = candidate; + return ( +
+
+ Skills + {c.skills?.length ? ( +
+ {c.skills.map((s) => ( + + {s} + + ))} +
+ ) : ( +

No skills recorded.

+ )} +
+ +
+ + Certifications + + {c.certifications?.length ? ( +
+ {c.certifications.map((s) => ( + + + ))} +
+ ) : ( +

None on file.

+ )} +
+
+ ); +} + +/** The written record: profile summary and screening analysis, as prose. */ +function EditorialSections({ candidate }) { + const c = candidate; + if (!c.professional_summary && !c.ai_summary) return null; + + return ( +
+ {c.professional_summary && ( +
+ About the candidate +

{c.professional_summary}

+
+ )} + {c.ai_summary && ( +
+ Screening analysis +

{c.ai_summary}

+
+ )} +
+ ); +} + +/** Strengths against gaps. */ +function StrengthsAndGaps({ candidate }) { + const c = candidate; + if (!c.ai_strengths?.length && !c.ai_gaps?.length) return null; + + return ( +
+ {c.ai_strengths?.length > 0 && ( +
+ Strengths +
    + {c.ai_strengths.map((s) => ( +
  • +
  • + ))} +
+
+ )} + {c.ai_gaps?.length > 0 && ( +
+ Gaps +
    + {c.ai_gaps.map((s) => ( +
  • +
  • + ))} +
+
+ )} +
+ ); +} + +/** Grouped screening signals, with the full set behind a disclosure. */ +function MatchBreakdown({ candidate }) { + const [showAll, setShowAll] = useState(false); + const b = candidate.score_breakdown || {}; + + if (!candidate.ai_score) { + return ( + + ); + } + + /* The first three groups are the decision; the fourth is context, so it is + behind "view all signals" rather than adding four more rows to scan. */ + const groups = showAll ? SCORE_GROUPS : SCORE_GROUPS.slice(0, 3); + const hidden = ALL_DIMENSIONS.length - groups.reduce((n, g) => n + g.dimensions.length, 0); + + return ( +
+ + Match breakdown + + +
+ {groups.map((group) => ( +
+
+

{group.label}

+

{group.description}

+
+
+ {group.dimensions.map((d) => ( + + ))} +
+
+ ))} +
+ + {hidden > 0 && ( + + )} +
+ ); +} + +/** Career score, market value, promotion track and the five headline metrics. */ +function Reputation({ candidate }) { + const c = candidate; + const tier = careerTier(c); + const metrics = reputationMetrics(c); + const tags = strengthTags(c); + + return ( +
+
+ Workforce reputation + +
+
+
+

Career score

+
+

+ {c.ai_score > 0 ? careerScore(c) : '—'} +

+ {c.ai_score > 0 && {tier.label}} +
+

300–850 scale

+
+ +
+

+ Potential market value +

+

+ {c.ai_score > 0 ? marketValue(c) : '—'} +

+

Based on career score

+
+ +
+

Next promotion

+

+ {c.ai_score > 0 ? nextPromotion(c) : '—'} +

+

Tier this candidate tracks toward

+
+
+ +
+ {metrics.map((m) => ( + + ))} +
+
+
+ + {c.companies_worked?.length > 0 && ( +
+ Worked with +
+ {c.companies_worked.map((company) => ( + + {company} + + ))} +
+
+ )} + + {tags.length > 0 && ( +
+ Strength tags +
+ {tags.map((t, i) => ( + + + ))} +
+
+ )} +
+ ); +} + +/** The endorsement feed. Compact rows, not a wall of repeated cards. */ +function Endorsements({ candidate }) { + const voices = endorsements(candidate); + const tags = strengthTags(candidate); + + if (!voices.length && !tags.length) { + return ( + + ); + } + + return ( +
+
+ + Endorsements + + + {voices.length ? ( +
    + {voices.map((v) => ( +
  • +
    + +
    +
    +

    {v.name}

    + +
    +

    + {v.role} · {v.events} events + {v.company && v.company !== 'Verified Reference' && ` · ${v.company}`} +

    + +

    +

    + +
    + + +
    +
    +
    +
  • + ))} +
+ ) : ( +

No written endorsements on record.

+ )} +
+ + {tags.length > 0 && ( +
+ Endorsed strengths +
+ {tags.map((t, i) => ( + + + ))} +
+
+ )} +
+ ); +} + +/** Experience: history, availability, credentials and the applied position. */ +function Experience({ candidate, job, interview }) { + const c = candidate; + + return ( +
+
+ Experience +
+
+

Years in role

+

+ {c.years_experience || '—'} +

+
+
+

English

+

+ {c.english_level || '—'} +

+ {c.score_breakdown?.english > 0 && ( +

Scored {c.score_breakdown.english}

+ )} +
+
+

Communication

+

+ {communicationLabel(c.score_breakdown?.communication_style) || '—'} +

+
+
+
+ +
+ Availability + {c.availability?.length ? ( +
+ {c.availability.map((a) => ( + + + ))} +
+ ) : ( +

+ Not set — scheduling this candidate would be guesswork. +

+ )} +
+ + + +
+ Interview history + {interview ? ( +
+
+
+

+ {interview.overall_interview_score > 0 + ? `Scored ${interview.overall_interview_score}/100` + : 'Started, not scored'} +

+

+ Integrity {interview.integrity_score ?? 100}/100 +

+
+ +
+ {interview.summary && ( +

+ {interview.summary} +

+ )} +
+ ) : ( +

No interview on record.

+ )} +
+ + {job && ( +
+ Applied position +
+

{job.title}

+

+ {job.role_category || 'Uncategorized'} + {job.location ? ` · ${job.location}` : ''} + {job.pay_range_max ? ` · $${job.pay_range_min}–$${job.pay_range_max}/hr` : ''} +

+
+
+ )} +
+ ); +} + +/** The candidate's history as a timeline. */ +function Activity({ candidate, interview }) { + const events = activityTimeline(candidate, interview); + + return ( +
+ Activity +
    + {events.map((e, i) => ( +
  1. +
    +
    +
    +
    +

    {e.label}

    + {e.date && ( + + )} +
    + {e.detail &&

    {e.detail}

    } +
    +
  2. + ))} +
+
+ ); +} + +/* ── Page ───────────────────────────────────────────────────────────────── */ + +export default function CandidateProfile() { + const { id } = useParams(); + const navigate = useNavigate(); + const [tab, setTab] = useState('overview'); + + const { data: applications = [], isLoading } = useApplications(); + const { data: postings = [] } = useJobPostings(); + const { data: interviews = [] } = useInterviews(); + + const candidate = useMemo(() => applications.find((a) => a.id === id), [applications, id]); + const job = useMemo( + () => postings.find((p) => p.id === candidate?.job_posting_id), + [postings, candidate] + ); + const interview = useMemo( + () => interviews.find((i) => i.application_id === candidate?.id), + [interviews, candidate] + ); + + if (isLoading) { + return ( +
+
+
+
+ ); + } + + if (!candidate) { + return ( +
+ navigate('/admin/candidates')}> + Back to candidates + + } + /> +
+ ); + } + + const tier = careerTier(candidate); + const verdict = recommendationVerdict(candidate); + + const shortlist = () => toast.success(`${candidate.applicant_name} shortlisted`); + const scheduleInterview = () => toast.info('Interview scheduling is disabled in the demo'); + + return ( +
+ navigate('/admin/candidates')} + /> + + {/* Sticky tab bar. Sits below the app header, so both stay visible while a + long profile scrolls. Underline rather than pills: this is the page's + second level of navigation and should not compete with the first. */} +
+
+ {TABS.map((t) => { + const active = t.value === tab; + return ( + + ); + })} +
+
+ +
+
+ {tab === 'overview' && ( + <> + + + + + + + )} + + {tab === 'match' && ( + <> + + + + + )} + + {tab === 'reputation' && } + {tab === 'experience' && ( + + )} + {tab === 'endorsements' && } + {tab === 'activity' && } +
+
+
+ ); +} diff --git a/src/pages/admin/Candidates.jsx b/src/pages/admin/Candidates.jsx new file mode 100644 index 0000000..1b34a12 --- /dev/null +++ b/src/pages/admin/Candidates.jsx @@ -0,0 +1,124 @@ +import React, { useMemo, useState } from 'react'; +import { Brain } from 'lucide-react'; +import { Button, SearchInput, toast } from '@/components/ds'; +import { useApplications, useInterviews, useJobPostings } from '@/lib/krowHooks'; +import { AdminPage, Toolbar } from '@/pages/admin/_shell'; +import { FilterSelect } from '@/pages/admin/Positions'; +import CandidateCard from '@/components/krow/CandidateCard'; +import KrowAiChat from '@/components/krow/KrowAiChat'; + +const SCORE_BANDS = { + all: () => true, + top: (s) => s >= 80, + strong: (s) => s >= 60 && s < 80, + weak: (s) => s > 0 && s < 60, + 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) - new Date(a.updated_date) }, + 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(); + const { data: postings = [] } = useJobPostings(); + const { data: interviews = [] } = useInterviews(); + + const [search, setSearch] = useState(''); + const [position, setPosition] = useState('all'); + const [stage, setStage] = useState('all'); + const [band, setBand] = useState('all'); + const [sort, setSort] = useState('score'); + + const positions = useMemo( + () => [...new Set(applications.map((a) => a.job_title).filter(Boolean))].sort(), + [applications] + ); + + const filtered = useMemo(() => { + const q = search.toLowerCase(); + return applications + .filter((a) => { + if (q && !`${a.applicant_name} ${a.email} ${a.job_title} ${(a.skills || []).join(' ')}`.toLowerCase().includes(q)) return false; + if (position !== 'all' && a.job_title !== position) return false; + if (stage !== 'all' && a.status !== stage) return false; + if (!SCORE_BANDS[band](a.ai_score || 0)) return false; + return true; + }) + .sort(SORTS[sort].compare); + }, [applications, search, position, stage, band, sort]); + + const isFiltered = Boolean(search) || [position, stage, band].some((v) => v !== 'all'); + const clearFilters = () => { setSearch(''); setPosition('all'); setStage('all'); setBand('all'); }; + + const postingById = useMemo(() => Object.fromEntries(postings.map(p => [p.id, p])), [postings]); + const resolveTitle = (a) => a.job_title || postingById[a.job_posting_id]?.title || ''; + + return ( + toast.info('Batch screening is disabled in the demo')}> +