From 5cd49a165557606368763b91dbf701d636020557 Mon Sep 17 00:00:00 2001 From: Aravind Date: Mon, 10 Aug 2026 19:36:45 +0530 Subject: [PATCH] ui changes --- public/krow logo.jpeg | Bin 0 -> 52908 bytes src/App.jsx | 76 +- .../ai-assistant/AssistantMessage.jsx | 4 +- .../ai-assistant/AssistantPanel.jsx | 254 +++-- .../ai-assistant/AssistantPanelContext.jsx | 87 +- .../ai-assistant/AssistantTrigger.jsx | 2 +- src/components/ai-assistant/KrowAssistant.jsx | 128 ++- src/components/ai-assistant/PromptInput.jsx | 47 +- src/components/ai-assistant/ResizeDivider.jsx | 110 ++ .../ai-assistant/capabilities/admin.js | 616 ++++++++++- src/components/ai-assistant/contexts.js | 2 +- src/components/ai-assistant/dynamic.js | 117 ++- src/components/ai-assistant/index.jsx | 15 +- src/components/ai-assistant/insights.js | 86 ++ src/components/ai-assistant/placement.js | 34 +- src/components/ai-assistant/provider.js | 2 +- src/components/ds/InsightRow.jsx | 110 ++ src/components/ds/MetricStrip.jsx | 113 +++ src/components/ds/index.js | 2 + src/components/krow/CandidateCard.jsx | 10 +- .../krow/CandidateExpandedDetails.jsx | 42 +- src/components/krow/KrowAiChat.jsx | 240 +++++ src/components/krow/UniversityCourseCard.jsx | 102 +- src/index.css | 2 + src/layouts/AdminLayout.jsx | 357 +++++++ src/layouts/Layout.jsx | 10 - src/lib/candidateIntelligence.js | 235 +++++ src/pages/Analytics.jsx | 4 +- src/pages/Candidates.jsx | 6 +- src/pages/Overview.jsx | 2 - src/pages/Positions.jsx | 2 + src/pages/University.jsx | 355 +++++-- src/pages/admin/Activity.jsx | 298 ++++++ src/pages/admin/AdminRoute.jsx | 25 + src/pages/admin/Analytics.jsx | 401 ++++++++ src/pages/admin/CandidateProfile.jsx | 957 ++++++++++++++++++ src/pages/admin/Candidates.jsx | 124 +++ src/pages/admin/CandidatesAnalysis.jsx | 418 ++++++++ src/pages/admin/ControlCenter.jsx | 900 ++++++++++++++++ src/pages/admin/HiredHistory.jsx | 345 +++++++ src/pages/admin/Login.jsx | 223 ++++ src/pages/admin/Positions.jsx | 518 ++++++++++ src/pages/admin/Profile.jsx | 359 +++++++ src/pages/admin/RoleGlyph.jsx | 71 ++ src/pages/admin/TalentPool.jsx | 384 +++++++ src/pages/admin/_shell.jsx | 85 ++ src/pages/admin/positionInsights.js | 127 +++ src/pages/admin/session.js | 50 + tailwind.config.js | 1 + 49 files changed, 7988 insertions(+), 470 deletions(-) create mode 100644 public/krow logo.jpeg create mode 100644 src/components/ai-assistant/ResizeDivider.jsx create mode 100644 src/components/ds/InsightRow.jsx create mode 100644 src/components/ds/MetricStrip.jsx create mode 100644 src/components/krow/KrowAiChat.jsx create mode 100644 src/layouts/AdminLayout.jsx create mode 100644 src/lib/candidateIntelligence.js create mode 100644 src/pages/admin/Activity.jsx create mode 100644 src/pages/admin/AdminRoute.jsx create mode 100644 src/pages/admin/Analytics.jsx create mode 100644 src/pages/admin/CandidateProfile.jsx create mode 100644 src/pages/admin/Candidates.jsx create mode 100644 src/pages/admin/CandidatesAnalysis.jsx create mode 100644 src/pages/admin/ControlCenter.jsx create mode 100644 src/pages/admin/HiredHistory.jsx create mode 100644 src/pages/admin/Login.jsx create mode 100644 src/pages/admin/Positions.jsx create mode 100644 src/pages/admin/Profile.jsx create mode 100644 src/pages/admin/RoleGlyph.jsx create mode 100644 src/pages/admin/TalentPool.jsx create mode 100644 src/pages/admin/_shell.jsx create mode 100644 src/pages/admin/positionInsights.js create mode 100644 src/pages/admin/session.js diff --git a/public/krow logo.jpeg b/public/krow logo.jpeg new file mode 100644 index 0000000000000000000000000000000000000000..2b698b8a072d6a1b10965e7f92e4d736f16fa96c GIT binary patch literal 52908 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 { 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')}> +