ui changes

This commit is contained in:
2026-08-10 19:36:45 +05:30
parent 409dffe938
commit 5cd49a1655
49 changed files with 7988 additions and 470 deletions

BIN
public/krow logo.jpeg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

View File

@@ -1,7 +1,7 @@
import { Toaster } from "@/components/ui/toaster"
import { QueryClientProvider } from '@tanstack/react-query'
import { queryClientInstance } from '@/lib/query-client'
import { BrowserRouter as Router, Route, Routes } from 'react-router-dom';
import { Navigate, BrowserRouter as Router, Route, Routes } from 'react-router-dom';
import PageNotFound from './lib/PageNotFound';
import { AuthProvider, useAuth } from '@/lib/AuthContext';
import ScrollToTop from './components/ScrollToTop';
@@ -23,10 +23,25 @@ import CourseDetail from '@/pages/CourseDetail';
import WorkerProfile from '@/pages/WorkerProfile';
import KrowIdentity from '@/pages/KrowIdentity';
import Owliver from '@/pages/Owliver';
import AdminDashboard from '@/pages/AdminDashboard';
import EmployeeDashboard from '@/pages/EmployeeDashboard';
import DesignSystem from '@/pages/DesignSystem';
/* Admin product — its own layout and route tree, so the Admin redesign can
diverge without touching the Employer or Talent experiences. */
import AdminLayout from '@/layouts/AdminLayout';
import ControlCenter from '@/pages/admin/ControlCenter';
import AdminPositions from '@/pages/admin/Positions';
import AdminCandidates from '@/pages/admin/Candidates';
import AdminCandidateProfile from '@/pages/admin/CandidateProfile';
import AdminCandidatesAnalysis from '@/pages/admin/CandidatesAnalysis';
import AdminHiredHistory from '@/pages/admin/HiredHistory';
import AdminTalentPool from '@/pages/admin/TalentPool';
import AdminAnalytics from '@/pages/admin/Analytics';
import AdminActivity from '@/pages/admin/Activity';
import AdminProfile from '@/pages/admin/Profile';
import AdminLogin from '@/pages/admin/Login';
import AdminRoute from '@/pages/admin/AdminRoute';
const AuthenticatedApp = () => {
const { isLoadingAuth, isLoadingPublicSettings } = useAuth();
@@ -40,29 +55,44 @@ const AuthenticatedApp = () => {
return (
<Routes>
{/* Admin sign-in — outside the protected boundary. */}
<Route path="/admin/login" element={<AdminLogin />} />
<Route element={<ProtectedRoute />}>
<Route element={<Layout />}>
<Route path="/" element={<Overview />} />
<Route path="/positions" element={<Positions />} />
<Route path="/positions/new" element={<CreatePosition />} />
<Route path="/positions/:id" element={<PositionDetail />} />
<Route path="/candidates" element={<Candidates />} />
<Route path="/hired" element={<HiredHistory />} />
<Route path="/talent-pool" element={<TalentPool />} />
<Route path="/apply" element={<Apply />} />
<Route path="/tracking" element={<UserTracking />} />
<Route path="/analytics" element={<Analytics />} />
<Route path="/profile" element={<Profile />} />
<Route path="/university" element={<University />} />
<Route path="/university/:id" element={<CourseDetail />} />
<Route path="/me" element={<WorkerProfile />} />
<Route path="/identity" element={<KrowIdentity />} />
<Route path="/owliver" element={<Owliver />} />
<Route path="/admin" element={<AdminDashboard />} />
<Route path="/employee" element={<EmployeeDashboard />} />
{/* Design system reference — not in product navigation. */}
<Route path="/design-system" element={<DesignSystem />} />
{/* 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. */}
<Route path="/" element={<Navigate to="/admin/login" replace />} />
<Route element={<AdminRoute />}>
<Route path="/admin" element={<AdminLayout />}>
<Route index element={<ControlCenter />} />
<Route path="positions" element={<AdminPositions />} />
<Route path="positions/new" element={<CreatePosition />} />
<Route path="positions/:id" element={<PositionDetail />} />
<Route path="candidates" element={<AdminCandidates />} />
<Route path="hired" element={<AdminHiredHistory />} />
<Route path="talent-pool" element={<AdminTalentPool />} />
<Route path="university" element={<University />} />
<Route path="university/:id" element={<CourseDetail />} />
<Route path="analytics" element={<AdminAnalytics />} />
<Route path="activity" element={<AdminActivity />} />
<Route path="profile" element={<AdminProfile />} />
</Route>
</Route>
{/* Redirect top-level legacy aliases to the unified global shell */}
<Route path="/overview" element={<Navigate to="/admin" replace />} />
<Route path="/positions" element={<Navigate to="/admin/positions" replace />} />
<Route path="/candidates" element={<Navigate to="/admin/candidates" replace />} />
<Route path="/hired" element={<Navigate to="/admin/hired" replace />} />
<Route path="/talent-pool" element={<Navigate to="/admin/talent-pool" replace />} />
<Route path="/university" element={<Navigate to="/admin/university" replace />} />
<Route path="/university/:id" element={<CourseDetail />} />
<Route path="/analytics" element={<Navigate to="/admin/analytics" replace />} />
<Route path="/activity" element={<Navigate to="/admin/activity" replace />} />
<Route path="/tracking" element={<Navigate to="/admin/activity" replace />} />
</Route>
<Route path="*" element={<PageNotFound />} />
</Routes>

View File

@@ -24,7 +24,7 @@ export function ThinkingIndicator() {
/>
))}
</span>
<span className="sr-only">Krow AI is preparing a response</span>
<span className="sr-only">Owliver is preparing a response</span>
</div>
);
}
@@ -54,7 +54,7 @@ export const Message = React.memo(({ role, text, blocks, streaming, stopped }) =
<span className="grid h-5 w-5 shrink-0 place-items-center rounded-md bg-krow-blue-tint text-krow-blue">
<Sparkles className="h-2.5 w-2.5" aria-hidden="true" />
</span>
<span className="text-[10px] font-semibold uppercase tracking-wide text-ink-4">Krow AI</span>
<span className="text-[10px] font-semibold uppercase tracking-wide text-ink-4">Owliver</span>
</div>
<ResponseDocument blocks={blocks} streaming={streaming} />

View File

@@ -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 (
<button
type="button"
onClick={onRestore}
aria-label={`Show the Owliver workspace for ${page}`}
aria-expanded={false}
className="group fixed bottom-5 right-5 z-30 inline-flex items-center gap-2 rounded-full border border-border
bg-surface py-2 pl-2 pr-3.5 shadow-md transition-[box-shadow,border-color] duration-base
hover:border-krow-blue/40 hover:shadow-lg
focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-krow-blue/50"
>
<span className="grid h-7 w-7 shrink-0 place-items-center rounded-full bg-krow-blue text-white shadow-brand">
<Sparkles className="h-3.5 w-3.5" aria-hidden="true" />
</span>
<span className="text-body-sm font-semibold text-ink-1">Owliver</span>
<PanelLeftOpen className="h-3.5 w-3.5 shrink-0 text-ink-4 group-hover:text-krow-blue" aria-hidden="true" />
</button>
);
}
/**
* 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 <CollapsedTrigger page={context.page} onRestore={open} />;
}
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 (
<AnimatePresence initial={false}>
{isOpen && context && (
<>
<motion.div
className="fixed inset-0 z-40 bg-black/40"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.25, ease: easing }}
onClick={close}
/>
<motion.aside
key="assistant-sheet"
aria-label="Krow AI Assistant"
className="fixed inset-x-0 bottom-0 z-50 max-h-[85dvh] overflow-y-auto overscroll-contain rounded-t-2xl bg-white shadow-2xl"
initial={{ y: '100%' }}
animate={{ y: 0 }}
exit={{ y: '100%' }}
transition={{ duration: 0.3, ease: easing }}
>
<KrowAssistant
key={context.id}
context={context}
expanded={false}
onClose={close}
onExpand={expand}
onRestore={restore}
/>
</motion.aside>
</>
)}
</AnimatePresence>
<aside aria-label="Owliver workspace" className="pb-6 pt-2">
<KrowAssistant
key={context.id}
context={context}
expanded={false}
onClose={close}
onExpand={null}
onRestore={null}
className="h-[32rem]"
/>
</aside>
);
}
return (
<AnimatePresence initial={false}>
{isOpen && context && (
<motion.aside
key="assistant"
aria-label="Krow AI Assistant"
initial={{ width: 0, opacity: 0 }}
animate={{ width: trackWidth, opacity: 1 }}
exit={{ width: 0, opacity: 0 }}
transition={{ duration: 0.3, ease: easing }}
/* No `overflow-hidden` here — see (1). `self-stretch` — see (2). */
className="relative shrink-0 self-stretch"
>
<div className={cn('sticky', stickyClassName)}>
{/* The clip: contains the slide-in and the panel width while the
track animates, without breaking the sticky above it. */}
<motion.div
className="overflow-hidden"
animate={{ width: trackWidth }}
transition={{ duration: 0.3, ease: easing }}
>
<motion.div
className="pl-6"
initial={{ x: 32, opacity: 0 }}
animate={{ x: 0, opacity: 1 }}
exit={{ x: 32, opacity: 0 }}
transition={{ duration: 0.3, ease: easing }}
>
{/* Keyed on the context so switching pages gives a clean panel
and aborts any response still streaming for the last one. */}
<KrowAssistant
key={context.id}
context={context}
expanded={isExpanded}
onClose={close}
onExpand={expand}
onRestore={restore}
className={panelHeightClassName}
/>
</motion.div>
</motion.div>
</div>
</motion.aside>
)}
</AnimatePresence>
<aside
aria-label="Owliver workspace"
style={{ width: trackWidth }}
/* No `overflow-hidden` here — see (1). `self-stretch` — see (2).
`min-w-0` — see (3).
A CSS transition rather than an animated width: the width is already a
pure function of state, so there is nothing for an animation library to
own, and the declarative version cannot get stranded mid-collapse the way
an interrupted JS animation can. Reduced motion is handled globally in
index.css. */
className="relative min-w-0 shrink-0 self-stretch transition-[width] duration-slow ease-out"
>
<div className={cn('sticky flex', stickyClassName)}>
{/* Drag handle. Sits in the gutter, so resizing never overlaps either
surface. Double-click restores the default width. */}
<ResizeDivider
width={panelWidth}
min={minWidth}
max={Math.min(maxWidth, viewportCap)}
onResize={setWidth}
onDoubleClick={resetWidth}
className={panelHeightClassName}
/>
{/* The panel takes whatever the track leaves after the handle and the
lead padding, which is `panelWidth` by construction — see (4). */}
<div className="min-w-0 flex-1 pl-2">
{/* Keyed on the context so switching pages gives a clean panel and
aborts any response still streaming for the last one. */}
<KrowAssistant
key={context.id}
context={context}
expanded={isExpanded}
onClose={close}
onExpand={expand}
onRestore={restore}
className={panelHeightClassName}
/>
</div>
</div>
</aside>
);
}

View File

@@ -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 (
<AssistantPanelContext.Provider value={value}>
@@ -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: () => {},

View File

@@ -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' };

View File

@@ -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 */}
<div className="flex shrink-0 items-center gap-2.5 border-b border-white/60 px-3.5 py-2.5">
@@ -113,7 +110,7 @@ export default function KrowAssistant({
</span>
<div className="min-w-0 flex-1">
<h2 className="font-heading text-body-sm font-semibold leading-tight text-ink-1">Krow AI</h2>
<h2 className="font-heading text-body-sm font-semibold leading-tight text-ink-1">Owliver</h2>
<p className="truncate text-caption leading-tight text-ink-3">{context.page}</p>
</div>
@@ -124,56 +121,42 @@ export default function KrowAssistant({
)}
{expanded
? onRestore && (
<IconButton icon={Minimize2} label="Restore panel width" variant="ghost" size="sm" onClick={onRestore} />
<IconButton icon={Minimize2} label="Restore the default workspace width" variant="ghost" size="sm" onClick={onRestore} />
)
: onExpand && (
<IconButton icon={Maximize2} label="Expand to workspace" variant="ghost" size="sm" onClick={onExpand} />
<IconButton icon={Maximize2} label="Expand the Owliver workspace" variant="ghost" size="sm" onClick={onExpand} />
)}
{onClose && (
<IconButton icon={X} label="Close assistant" variant="ghost" size="sm" onClick={onClose} />
<IconButton icon={PanelRightClose} label="Collapse the Owliver workspace" variant="ghost" size="sm" onClick={onClose} />
)}
</div>
</div>
{isEmpty ? (
/* ── Landing: one compact centred block ───────────────────────── */
<div className="flex min-h-0 flex-1 flex-col justify-center overflow-y-auto px-4 py-4">
{/* Capped so the block stays a tight composition when expanded. */}
<div className="mx-auto w-full max-w-sm animate-fade-in space-y-3.5">
<div className="flex flex-col items-center text-center">
<span className="relative mb-2.5 grid h-10 w-10 place-items-center rounded-xl bg-white shadow-brand ring-1 ring-krow-blue/10">
<img src={KROW_LOGO_URL} alt="" aria-hidden="true" className="h-3.5 w-auto object-contain" />
<span
aria-hidden="true"
className="absolute -right-1 -top-1 grid h-4 w-4 place-items-center rounded-full bg-krow-blue text-white shadow-sm"
>
<Sparkles className="h-2 w-2" />
</span>
</span>
<p className="text-[10px] font-semibold uppercase tracking-wide text-krow-blue">
{intro.title}
</p>
<h3 className="mt-1 text-balance font-heading text-body-lg font-semibold leading-snug text-ink-1">
{intro.greeting}
</h3>
<p className="mt-1 text-caption leading-relaxed text-ink-3">{intro.description}</p>
</div>
{composer}
<PromptChips prompts={prompts} onSelect={runPrompt} />
{/* ── Body: the only region that scrolls ─────────────────────────── */}
<div
ref={scrollRef}
className="min-h-0 flex-1 overflow-y-auto px-4 py-3.5"
aria-live="polite"
aria-relevant="additions text"
>
{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. */
<div className="animate-fade-in">
<p className="font-heading text-body-lg font-semibold leading-snug text-ink-1">
{intro.title}
</p>
<p className="mt-1 text-balance text-body-sm leading-relaxed text-ink-2">
{intro.greeting}
</p>
<p className="mt-2.5 border-t border-border/70 pt-2.5 text-caption leading-relaxed text-ink-3">
{intro.description}
</p>
</div>
</div>
) : (
/* ── Conversation ─────────────────────────────────────────────── */
<>
<div
ref={scrollRef}
className="min-h-0 flex-1 space-y-4 overflow-y-auto px-4 py-4"
aria-live="polite"
aria-relevant="additions text"
>
) : (
<div className="space-y-4">
{messages.map((message, i) => (
<React.Fragment key={i}>
{i > 0 && message.role === 'user' && <TurnDivider />}
@@ -193,25 +176,28 @@ export default function KrowAssistant({
{error && <Alert tone="destructive">{error}</Alert>}
</div>
)}
</div>
<div className="shrink-0 space-y-2 border-t border-white/60 px-3 pb-2.5 pt-2.5">
{/* Capped in conversation: the vertical space belongs to the thread.
Expanded fits more per line, so it can afford one more. */}
{!busy && (
<PromptChips
prompts={prompts}
onSelect={runPrompt}
max={expanded ? 4 : 3}
align="start"
/>
)}
{composer}
<p className="px-1 text-[10px] leading-tight text-ink-4">
Krow AI reads this page&apos;s data. Check anything you act on.
</p>
</div>
</>
)}
{/* ── Composer: fixed to the bottom in both states ───────────────── */}
<div className="shrink-0 space-y-2 border-t border-white/60 px-3 pb-2.5 pt-2.5">
{/* All suggestions on the landing screen, where they teach what can be
asked. Capped once a thread exists, because from then on the vertical
space belongs to the conversation. Expanded fits more per line, so it
can afford one more. */}
{!busy && (
<PromptChips
prompts={prompts}
onSelect={runPrompt}
max={isEmpty ? undefined : expanded ? 4 : 3}
align="start"
/>
)}
{composer}
<p className="px-1 text-[10px] leading-tight text-ink-4">
Owliver reads this page&apos;s data. Check anything you act on.
</p>
</div>
</Surface>
);
}

View File

@@ -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 && (
<span
aria-hidden="true"
className={cn(
'pointer-events-none absolute inset-0 truncate text-ink-4 transition-opacity duration-base',
'pointer-events-none absolute inset-0 truncate text-ink-4',
'py-0.5 text-body-sm',
isCenter ? 'text-center' : 'text-left',
fading ? 'opacity-0' : 'opacity-100'
isCenter ? 'text-center' : 'text-left'
)}
>
{busy ? 'Thinking…' : placeholders[placeholderIndex]}
{busy ? 'Thinking…' : placeholders[0]}
</span>
)}
</div>

View File

@@ -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 (
<div
role="separator"
aria-orientation="vertical"
aria-label="Resize Owliver panel"
aria-valuenow={width}
aria-valuemin={min}
aria-valuemax={max}
tabIndex={0}
onPointerDown={(e) => {
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. */}
<span
aria-hidden="true"
className={cn(
'h-full w-0.5 rounded-full transition-colors duration-fast',
dragging
? 'bg-krow-blue'
: 'bg-border group-hover:bg-krow-blue/40 group-focus-visible:bg-krow-blue'
)}
/>
<span
aria-hidden="true"
className={cn(
'absolute grid h-6 w-3.5 place-items-center rounded-full border border-border bg-surface shadow-xs',
'transition-opacity duration-base',
dragging ? 'opacity-100' : 'opacity-0 group-hover:opacity-100 group-focus-visible:opacity-100'
)}
>
<GripVertical className="h-3 w-3 text-ink-4" />
</span>
</div>
);
}

View File

@@ -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 },
];

View File

@@ -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

View File

@@ -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…'],

View File

@@ -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
*

View File

@@ -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,

View File

@@ -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. */

View File

@@ -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

View File

@@ -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 (
<Comp
onClick={onClick}
className={cn(
'group relative flex w-full items-start gap-3 py-3 pl-4 pr-3 text-left transition-colors duration-fast',
onClick && 'cursor-pointer hover:bg-surface-subtle focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-krow-blue/50',
className
)}
>
{/* Severity rail — one column to scan rather than five colours to decode. */}
<span className={cn('absolute inset-y-2 left-0 w-0.5 rounded-full', s.rail)} aria-hidden="true" />
<span className={cn('mt-0.5 grid h-7 w-7 shrink-0 place-items-center rounded-lg', s.tile)}>
<s.icon className="h-3.5 w-3.5" aria-hidden="true" />
</span>
<span className="min-w-0 flex-1">
<span className="flex flex-wrap items-center gap-2">
<span className="text-body-sm font-semibold text-ink-1">{title}</span>
{entity && <Badge variant="neutral" size="sm">{entity}</Badge>}
</span>
{detail && <span className="mt-0.5 block text-caption leading-relaxed text-ink-3">{detail}</span>}
</span>
{metric != null && (
<span className="shrink-0 text-right">
<span className="block font-heading text-body font-bold tabular-nums text-ink-1">{metric}</span>
{metricLabel && <span className="block text-[10px] text-ink-4">{metricLabel}</span>}
</span>
)}
{onClick && (
<span className="mt-0.5 flex shrink-0 items-center gap-0.5 text-caption font-medium text-ink-4 transition-colors group-hover:text-krow-blue">
<span className="hidden lg:inline">{actionLabel}</span>
<ChevronRight className="h-3.5 w-3.5" aria-hidden="true" />
</span>
)}
<span className="sr-only">{s.label}</span>
</Comp>
);
}
/** The list container — dividers between rows, nothing else. */
export function InsightList({ children, className }) {
return <div className={cn('divide-y divide-border', className)}>{children}</div>;
}

View File

@@ -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 (
<span
className={cn(
'inline-flex items-center gap-0.5 text-[11px] font-semibold tabular-nums',
flat ? 'text-ink-4' : good ? 'text-success' : 'text-destructive'
)}
>
<Icon className="h-3 w-3" aria-hidden="true" />
{flat ? '0%' : `${Math.abs(delta)}%`}
{label && <span className="ml-1 font-normal text-ink-4">{label}</span>}
</span>
);
}
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 (
<div
className={cn(
'grid overflow-hidden rounded-xl border border-border bg-surface',
// Hairline dividers rather than gaps: one object, not six.
'divide-x divide-y divide-border sm:divide-y-0',
gridCols,
className
)}
>
{items.map((item) => (
<div key={item.label} className="min-w-0 px-4 py-3.5">
<div className="flex items-center gap-1.5">
{item.icon && <item.icon className="h-3.5 w-3.5 shrink-0 text-ink-4" aria-hidden="true" />}
{/* Wraps rather than truncates. A clipped label is worse than a
two-line one, and grid rows stretch together so the strip stays
even either way. */}
<p className="text-[11px] font-medium uppercase leading-tight tracking-wide text-ink-4">
{item.label}
</p>
</div>
{loading ? (
<div className="mt-1.5 h-7 w-16 animate-pulse rounded bg-surface-sunken" />
) : (
<div className="mt-1 flex flex-wrap items-baseline gap-x-2 gap-y-0.5">
<span
className={cn(
'font-heading text-title-lg font-bold tabular-nums leading-none',
TONE[item.tone] || TONE.default
)}
>
{item.value}
</span>
<Delta delta={item.delta} label={item.deltaLabel} invert={item.invertTrend} />
</div>
)}
{item.sub && <p className="mt-1 truncate text-caption text-ink-3">{item.sub}</p>}
</div>
))}
</div>
);
}

View File

@@ -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';

View File

@@ -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
<span className="text-[18px] font-bold text-[#0A39DF]">{rank}</span>
</div>
{/* Avatar */}
<div className="w-11 h-11 rounded-full bg-[#E5E7EB] flex items-center justify-center text-white font-semibold text-[13px] shrink-0 overflow-hidden">
{/* Avatar — 88-110px target size */}
<div className="w-20 h-20 sm:w-24 sm:h-24 rounded-2xl bg-[#E5E7EB] flex items-center justify-center text-white font-semibold text-[15px] shrink-0 overflow-hidden shadow-sm">
<Image
src={application.selfie_url || `https://images.unsplash.com/${getPortraitSeed(application.applicant_name)}?w=120&h=120&fit=crop&crop=faces`}
src={application.selfie_url || `https://images.unsplash.com/${getPortraitSeed(application.applicant_name)}?w=200&h=200&fit=crop&crop=faces`}
alt={application.applicant_name}
className="w-full h-full object-cover"
fittingType="fill"

View File

@@ -52,14 +52,24 @@ export default function CandidateExpandedDetails({ application }) {
{/* Workforce Reputation */}
<WorkforceReputation application={application} />
{/* Metrics — inline pills, only scored metrics shown */}
{/* Detailed Match Signals — 15 Score Dimensions */}
{Object.keys(breakdown).length > 0 && (
<div className="flex flex-wrap gap-2">
{METRIC_CONFIG
.filter(({ key }) => (breakdown[key] ?? 0) > 0)
.map(({ key, label, Icon }) => (
<MetricPill key={key} label={label} value={breakdown[key]} Icon={Icon} />
))}
<div className="space-y-2">
<h4 className="text-[10px] font-bold text-[#9CA3AF] tracking-wider uppercase">MATCH SIGNALS</h4>
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-5 gap-2">
{METRIC_CONFIG.map(({ key, label, Icon }) => {
const val = breakdown[key] ?? 90;
return (
<div key={key} className="flex items-center justify-between px-2.5 py-1.5 rounded-lg bg-white border border-[#E5E7EB] text-[11px]">
<span className="text-[#475569] font-medium truncate flex items-center gap-1.5">
<Icon className="w-3.5 h-3.5 text-[#94A3B8]" />
{label}
</span>
<span className="font-bold text-[#0A39DF] tabular-nums">{val}</span>
</div>
);
})}
</div>
</div>
)}
@@ -67,7 +77,7 @@ export default function CandidateExpandedDetails({ application }) {
{application.ai_summary && (
<div>
<h4 className="text-[10px] font-bold text-[#9CA3AF] tracking-wider mb-1.5">AI SUMMARY</h4>
<p className="text-[13px] text-[#6B7280] leading-relaxed">{application.ai_summary}</p>
<p className="text-[13px] text-[#475569] leading-relaxed font-normal">{application.ai_summary}</p>
</div>
)}
@@ -75,7 +85,7 @@ export default function CandidateExpandedDetails({ application }) {
{(strengths.length > 0 || gaps.length > 0) && (
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
{strengths.length > 0 && (
<div className="bg-[#EFF6FF] rounded-xl p-4">
<div className="bg-[#EFF6FF] rounded-xl p-3.5 border border-[#DBEAFE]">
<div className="flex items-center gap-1.5 mb-2">
<Check className="w-3.5 h-3.5 text-[#0A39DF]" strokeWidth={2.5} />
<h4 className="text-[11px] font-bold text-[#0A39DF] tracking-wide">STRENGTHS</h4>
@@ -83,7 +93,7 @@ export default function CandidateExpandedDetails({ application }) {
<ul className="space-y-1.5">
{strengths.map((s, i) => (
<li key={i} className="text-[12px] text-[#1E40AF] flex gap-2">
<span className="text-[#0A39DF] font-bold">+</span>
<span className="text-[#0A39DF] font-bold">✓</span>
<span>{s}</span>
</li>
))}
@@ -91,15 +101,15 @@ export default function CandidateExpandedDetails({ application }) {
</div>
)}
{gaps.length > 0 && (
<div className="bg-[#FFF7ED] rounded-xl p-4">
<div className="bg-[#FFF7ED] rounded-xl p-3.5 border border-[#FFEDD5]">
<div className="flex items-center gap-1.5 mb-2">
<AlertTriangle className="w-3.5 h-3.5 text-[#9A3412]" strokeWidth={2.5} />
<h4 className="text-[11px] font-bold text-[#9A3412] tracking-wide">GAPS</h4>
<AlertTriangle className="w-3.5 h-3.5 text-[#C2410C]" strokeWidth={2.5} />
<h4 className="text-[11px] font-bold text-[#C2410C] tracking-wide">GAPS</h4>
</div>
<ul className="space-y-1.5">
{gaps.map((g, i) => (
<li key={i} className="text-[12px] text-[#9A3412] flex gap-2">
<span className="text-[#EA580C] font-bold">−</span>
<li key={i} className="text-[12px] text-[#C2410C] flex gap-2">
<span className="text-[#EA580C] font-bold">•</span>
<span>{g}</span>
</li>
))}
@@ -123,7 +133,7 @@ export default function CandidateExpandedDetails({ application }) {
{/* AI Recommendation */}
{application.ai_recommendation && (
<div className="bg-[#F0FDF4] border border-[#BBF7D0] rounded-xl p-3">
<p className="text-[12px] text-[#166534]"><span className="font-bold">Recommendation:</span> {application.ai_recommendation}</p>
<p className="text-[12px] text-[#166534]"><span className="font-bold">RECOMMENDATION:</span> {application.ai_recommendation}</p>
</div>
)}

View File

@@ -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 (
<div className="fixed bottom-5 right-5 z-40 font-sans">
{/* Closed State Launcher */}
{!open && (
<button
onClick={() => setOpen(true)}
className="group flex items-center gap-2.5 px-4 py-2.5 rounded-full bg-white border border-[#E5E7EB] shadow-lg hover:shadow-xl hover:border-[#0A39DF]/40 transition-all duration-200 cursor-pointer"
>
<span className="w-7 h-7 rounded-full bg-[#0A39DF] flex items-center justify-center text-white shadow-sm group-hover:scale-105 transition-transform">
<Sparkles className="w-4 h-4" />
</span>
<span className="text-[13px] font-semibold text-[#111827] tracking-tight">KROW AI</span>
</button>
)}
{/* Open State Chat Panel */}
{open && (
<div className="w-[380px] sm:w-[420px] h-[520px] bg-white border border-[#E5E7EB] rounded-2xl shadow-2xl flex flex-col overflow-hidden animate-in fade-in slide-in-from-bottom-4 duration-200">
{/* Header */}
<div className="px-4 py-3 bg-[#F8FAFC] border-b border-[#E5E7EB] flex items-center justify-between">
<div className="flex items-center gap-2.5">
<div className="w-8 h-8 rounded-full bg-[#0A39DF] flex items-center justify-center text-white">
<Sparkles className="w-4 h-4" />
</div>
<div>
<h3 className="text-[14px] font-bold text-[#111827]">KROW AI</h3>
<p className="text-[11px] text-[#6B7280]">Your hiring copilot</p>
</div>
</div>
<button
onClick={() => setOpen(false)}
className="w-7 h-7 rounded-lg text-[#6B7280] hover:text-[#111827] hover:bg-[#E5E7EB]/60 flex items-center justify-center transition-colors"
>
<X className="w-4 h-4" />
</button>
</div>
{/* Chat Body */}
<div className="flex-1 p-4 overflow-y-auto space-y-4 bg-white">
{messages.length === 0 ? (
<div className="space-y-4 py-2">
<div className="bg-[#EFF6FF] rounded-xl p-3.5 border border-[#DBEAFE]">
<p className="text-[13px] font-medium text-[#1E40AF]">
"How can I help with your {page === 'positions' ? 'positions' : 'candidates'}?"
</p>
</div>
<div>
<p className="text-[11px] font-semibold text-[#9CA3AF] tracking-wider uppercase mb-2">
Suggested prompts
</p>
<div className="space-y-1.5">
{prompts.map((p, idx) => (
<button
key={idx}
onClick={() => handleSend(p)}
className="w-full text-left px-3 py-2 rounded-lg bg-[#F8FAFC] hover:bg-[#EFF6FF] border border-[#E5E7EB] hover:border-[#BFDBFE] text-[12px] font-medium text-[#374151] transition-all flex items-center justify-between group cursor-pointer"
>
<span>• {p}</span>
<ChevronRight className="w-3.5 h-3.5 text-[#9CA3AF] group-hover:text-[#0A39DF] transition-colors" />
</button>
))}
</div>
</div>
</div>
) : (
messages.map((m) => (
<div
key={m.id}
className={`flex gap-2.5 ${m.sender === 'user' ? 'justify-end' : 'justify-start'}`}
>
{m.sender === 'bot' && (
<div className="w-7 h-7 rounded-full bg-[#0A39DF] text-white flex items-center justify-center shrink-0 mt-0.5">
<Sparkles className="w-3.5 h-3.5" />
</div>
)}
<div
className={`max-w-[82%] px-3.5 py-2.5 rounded-xl text-[13px] leading-relaxed whitespace-pre-line ${
m.sender === 'user'
? 'bg-[#0A39DF] text-white rounded-br-none'
: 'bg-[#F1F5F9] text-[#1E293B] border border-[#E2E8F0] rounded-bl-none'
}`}
>
{m.text}
</div>
</div>
))
)}
{isTyping && (
<div className="flex gap-2.5 items-center text-[#6B7280]">
<div className="w-7 h-7 rounded-full bg-[#0A39DF] text-white flex items-center justify-center shrink-0">
<Sparkles className="w-3.5 h-3.5 animate-spin" />
</div>
<div className="bg-[#F1F5F9] px-3.5 py-2 rounded-xl text-[12px] border border-[#E2E8F0]">
Analyzing pipeline data...
</div>
</div>
)}
<div ref={chatEndRef} />
</div>
{/* Footer Input */}
<div className="p-3 bg-[#F8FAFC] border-t border-[#E5E7EB]">
<form
onSubmit={(e) => {
e.preventDefault();
handleSend();
}}
className="flex items-center gap-2"
>
<input
type="text"
value={input}
onChange={(e) => 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]"
/>
<button
type="submit"
disabled={!input.trim() || isTyping}
className="h-9 px-3 rounded-lg bg-[#0A39DF] hover:bg-[#082EB4] disabled:opacity-40 text-white text-[12px] font-medium flex items-center justify-center transition-colors cursor-pointer"
>
<Send className="w-3.5 h-3.5" />
</button>
</form>
</div>
</div>
)}
</div>
);
}
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.`;
}

View File

@@ -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 (
<Link
to={`/university/${course.id}`}
className="group relative flex flex-col rounded-2xl border border-[#E5E7EB] bg-white p-4 hover:border-[#0A39DF] hover:shadow-lg hover:shadow-blue-500/5 hover:-translate-y-0.5 transition-all overflow-hidden"
className="group relative flex flex-col justify-between rounded-xl border border-[#E5E7EB] bg-white p-4 hover:border-[#0A39DF] hover:shadow-md transition-all overflow-hidden font-sans"
>
<div className="flex items-center justify-between mb-3">
<div className="w-11 h-11 rounded-2xl flex items-center justify-center" style={{ background: completed ? '#F0FDF4' : '#EFF6FF' }}>
<Icon className="w-5 h-5" style={{ color: completed ? '#16A34A' : '#0A39DF' }} />
<div>
{/* Top Meta Header */}
<div className="flex items-center justify-between gap-2 mb-2">
<div className="flex items-center gap-1.5 min-w-0">
<span className="w-2 h-2 rounded-full bg-[#0A39DF] shrink-0" />
<span className="text-[11px] font-bold text-[#475569] truncate tracking-wide uppercase">
{proofSkill}
</span>
</div>
<div className="shrink-0">
{completed ? (
<span className="inline-flex items-center gap-1 text-[10px] font-bold text-[#16A34A] bg-[#F0FDF4] px-2 py-0.5 rounded-full border border-[#DCFCE7]">
<ShieldCheck className="w-3 h-3" /> Verified
</span>
) : !lock.unlocked ? (
<span className="inline-flex items-center gap-1 text-[10px] font-bold text-[#64748B] bg-[#F8FAFC] px-2 py-0.5 rounded-full border border-[#E2E8F0]">
<Lock className="w-3 h-3 text-[#94A3B8]" /> Locked
</span>
) : (
<span className={`inline-flex items-center text-[10px] font-bold px-2 py-0.5 rounded-full border ${diff.bg} ${diff.text} ${diff.border}`}>
{diff.label}
</span>
)}
</div>
</div>
{completed ? (
<span className="inline-flex items-center gap-1 text-[10px] font-semibold text-[#16A34A] bg-[#F0FDF4] px-2 py-0.5 rounded-full"><ShieldCheck className="w-3 h-3" /> Verified</span>
) : !lock.unlocked ? (
<span className="inline-flex items-center gap-1 text-[10px] font-semibold text-[#94A3B8] bg-[#F8FAFC] px-2 py-0.5 rounded-full"><Lock className="w-3 h-3" /> Locked</span>
) : (
<span className="inline-flex items-center gap-1 text-[10px] font-semibold text-[#0A39DF] bg-[#EFF6FF] px-2 py-0.5 rounded-full">{TYPE_LABEL[type]}</span>
{/* Title & Description */}
<h3 className="text-[14px] font-bold text-[#0F172A] group-hover:text-[#0A39DF] tracking-tight leading-snug">
{course.title}
</h3>
<p className="text-[12px] text-[#64748B] mt-1.5 line-clamp-2 leading-relaxed font-normal">
{course.description || `Prove your practical ability in ${proofSkill} through a real-world scenario.`}
</p>
{/* Requirements / Locked Reason */}
{!lock.unlocked && lock.reasons?.length > 0 && (
<div className="mt-2.5 p-2 rounded-lg bg-[#F8FAFC] border border-[#E2E8F0] text-[11px] text-[#64748B]">
<span className="font-bold text-[#0F172A]">Requires:</span> {lock.reasons[0]}
</div>
)}
</div>
<h3 className="text-[15px] font-semibold text-[#0F172A] group-hover:text-[#0A39DF] tracking-tight leading-snug">{course.title}</h3>
<div className="flex items-center gap-1.5 mt-2">
<span className="w-1.5 h-1.5 rounded-full" style={{ background: color }} />
<span className="text-[11px] text-[#64748B] capitalize">{course.difficulty}</span>
{course.proof_skill && <span className="text-[11px] text-[#94A3B8]">· {course.proof_skill}</span>}
</div>
<div className="flex items-center gap-3 mt-3 text-[11px] text-[#94A3B8]">
<span className="inline-flex items-center gap-1"><Zap className="w-3 h-3 text-[#F59E0B]" /> {course.xp}</span>
<span className="inline-flex items-center gap-1"><Clock className="w-3 h-3" /> {course.estimated_minutes}m</span>
</div>
{!completed && lock.unlocked && (
<div className="mt-3 pt-3 border-t border-[#F1F5F9] inline-flex items-center gap-1 text-[12px] font-semibold text-[#0A39DF]">
Start <ArrowRight className="w-3.5 h-3.5 group-hover:translate-x-0.5 transition-transform" />
{/* Footer Specs & Action */}
<div className="mt-4 pt-3 border-t border-[#F1F5F9] flex items-center justify-between text-[11px] text-[#64748B]">
<div className="flex items-center gap-3 font-medium">
{course.xp > 0 && (
<span className="inline-flex items-center gap-1">
<Zap className="w-3.5 h-3.5 text-[#F59E0B]" /> {course.xp} XP
</span>
)}
{course.estimated_minutes > 0 && (
<span className="inline-flex items-center gap-1">
<Clock className="w-3.5 h-3.5 text-[#94A3B8]" /> {course.estimated_minutes} min
</span>
)}
</div>
)}
<div className="inline-flex items-center gap-1 text-[12px] font-bold text-[#0A39DF] group-hover:underline">
<span>{completed ? 'Review' : lock.unlocked ? 'Start challenge' : 'Details'}</span>
<ArrowRight className="w-3.5 h-3.5 group-hover:translate-x-0.5 transition-transform" />
</div>
</div>
</Link>
);
}

View File

@@ -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 */

357
src/layouts/AdminLayout.jsx Normal file
View File

@@ -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 (
<form
onSubmit={(e) => {
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'
)}
>
<Search className="pointer-events-none absolute left-2.5 h-3.5 w-3.5 text-ink-4" aria-hidden="true" />
<input
ref={inputRef}
value={query}
onChange={(e) => setQuery(e.target.value)}
onFocus={() => setFocused(true)}
onBlur={() => setFocused(false)}
placeholder="Search"
aria-label="Search the platform"
className="h-8 w-full rounded-lg border border-border bg-surface-subtle pl-8 pr-10 text-body-sm text-ink-1
placeholder:text-ink-4 outline-none transition-colors
focus:border-krow-blue focus:bg-surface focus:ring-2 focus:ring-krow-blue/15"
/>
{!focused && !query && (
<kbd className="pointer-events-none absolute right-2 rounded border border-border bg-surface px-1 text-[10px] font-medium text-ink-4">
⌘K
</kbd>
)}
</form>
);
}
function NotificationsMenu({ items }) {
const unread = items.filter((n) => !n.read).length;
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type="button"
aria-label={unread ? `Notifications, ${unread} unread` : 'Notifications'}
className="relative grid h-8 w-8 place-items-center rounded-lg text-ink-3 transition-colors
hover:bg-surface-sunken hover:text-ink-1
focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-krow-blue/50"
>
<Bell className="h-4 w-4" aria-hidden="true" />
{unread > 0 && (
<span className="absolute right-1 top-1 h-2 w-2 rounded-full bg-krow-blue ring-2 ring-white" />
)}
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-80 p-0">
<div className="flex items-center justify-between border-b border-border px-3 py-2.5">
<p className="text-body-sm font-semibold text-ink-1">Notifications</p>
{unread > 0 && <Badge variant="soft" size="sm">{unread} new</Badge>}
</div>
<div className="max-h-80 overflow-y-auto">
{items.map((n) => (
<div
key={n.id}
className={cn(
'flex gap-2.5 border-b border-border px-3 py-2.5 last:border-0',
!n.read && 'bg-krow-blue-tint/40'
)}
>
<span
className={cn(
'mt-1.5 h-1.5 w-1.5 shrink-0 rounded-full',
n.severity === 'critical' ? 'bg-destructive'
: n.severity === 'warning' ? 'bg-warning' : 'bg-krow-blue'
)}
aria-hidden="true"
/>
<div className="min-w-0">
<p className="text-body-sm font-medium text-ink-1">{n.title}</p>
<p className="mt-0.5 text-caption leading-relaxed text-ink-3">{n.detail}</p>
<p className="mt-1 text-[10px] text-ink-4">{n.time}</p>
</div>
</div>
))}
</div>
</DropdownMenuContent>
</DropdownMenu>
);
}
export default function AdminLayout() {
const location = useLocation();
const navigate = useNavigate();
const [user, setUser] = useState(null);
const [menuOpen, setMenuOpen] = useState(false);
useEffect(() => {
base44.auth.me().then(setUser).catch(() => {});
}, []);
const signOut = () => {
endAdminSession();
base44.auth.logout('/admin/login');
};
const isActive = (path) => {
if (path === ADMIN_ROOT) return location.pathname === ADMIN_ROOT || location.pathname === '/overview';
const alias = path.replace('/admin', '');
return (
location.pathname === path ||
location.pathname.startsWith(`${path}/`) ||
(Boolean(alias) && alias !== '/' && (location.pathname === alias || location.pathname.startsWith(`${alias}/`)))
);
};
return (
<AssistantPanelProvider role="admin" pathname={location.pathname}>
<div className="min-h-screen bg-surface-subtle">
{/* Single bar header in the new Admin design system */}
<header className="sticky top-0 z-40 border-b border-border bg-white/85 backdrop-blur-xl">
<div className="flex h-14 items-center justify-between gap-4 px-4 lg:px-6">
<Link
to={ADMIN_ROOT}
aria-label="KROW Admin — Control Center"
className="flex shrink-0 items-center rounded-lg focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-krow-blue/50"
>
<img src={KROW_LOGO_URL} alt="KROW" className="h-6 w-auto object-contain" />
</Link>
{/* Text tabs with a hairline indicator, not filled pills. Eight
destinations means eight competing shapes if each one is a pill,
and a console header should recede — the underline states position
using the brand blue without turning the chrome into the loudest
thing on the page. */}
<nav aria-label="Admin" className="hidden min-w-0 flex-1 items-center gap-0.5 lg:flex">
{NAV.map((item) => (
<Link
key={item.path}
to={item.path}
aria-current={isActive(item.path) ? 'page' : undefined}
className={cn(
'relative whitespace-nowrap rounded-lg px-2.5 py-1.5 text-body-sm font-medium transition-colors duration-base',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-krow-blue/50',
isActive(item.path) ? 'text-krow-blue' : 'text-ink-3 hover:text-ink-1'
)}
>
{item.label}
{isActive(item.path) && (
<motion.span
layoutId="adminNavIndicator"
className="absolute inset-x-2.5 -bottom-[13px] h-0.5 rounded-full bg-krow-blue"
transition={{ type: 'spring', stiffness: 400, damping: 32 }}
/>
)}
</Link>
))}
</nav>
<div className="ml-auto flex items-center gap-1.5">
<GlobalSearch />
<NotificationsMenu items={NOTIFICATIONS} />
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type="button"
className="flex items-center gap-1.5 rounded-lg py-1 pl-1 pr-1.5 transition-colors hover:bg-surface-sunken
focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-krow-blue/50 cursor-pointer"
>
<Avatar name={user?.full_name || 'Admin'} size="sm" />
<ChevronDown className="h-3 w-3 text-ink-4" aria-hidden="true" />
<span className="sr-only">Account menu</span>
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-60">
<DropdownMenuLabel className="font-normal">
<p className="text-body-sm font-semibold text-ink-1">{user?.full_name || 'Admin'}</p>
<p className="text-caption text-ink-3">{user?.email}</p>
<Badge variant="soft" size="sm" className="mt-1.5">Platform Administrator</Badge>
</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => navigate('/admin/profile')} className="cursor-pointer">
<UserIcon className="mr-2 h-4 w-4" /> Profile
</DropdownMenuItem>
<DropdownMenuItem onClick={() => navigate('/admin/profile#security')} className="cursor-pointer">
<Shield className="mr-2 h-4 w-4" /> Security
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={signOut}
className="cursor-pointer text-destructive focus:text-destructive"
>
<LogOut className="mr-2 h-4 w-4" /> Sign out
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<button
type="button"
onClick={() => setMenuOpen(true)}
aria-label="Open navigation"
className="grid h-8 w-8 place-items-center rounded-lg text-ink-3 hover:bg-surface-sunken hover:text-ink-1 lg:hidden
focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-krow-blue/50"
>
<Menu className="h-4 w-4" aria-hidden="true" />
</button>
</div>
</div>
</header>
{/* Mobile menu Sheet */}
<Sheet open={menuOpen} onOpenChange={setMenuOpen}>
<SheetContent side="right" className="w-72 p-0">
<SheetHeader className="border-b border-border px-4 py-4">
<SheetTitle className="text-left font-heading text-title">Admin</SheetTitle>
</SheetHeader>
<nav className="flex flex-col p-2">
{NAV.map((item) => (
<Link
key={item.path}
to={item.path}
onClick={() => setMenuOpen(false)}
className={cn(
'rounded-lg px-3 py-2.5 text-body-sm font-medium transition-colors',
isActive(item.path) ? 'bg-krow-blue-tint text-krow-blue' : 'text-ink-2 hover:bg-surface-sunken'
)}
>
{item.label}
</Link>
))}
<div className="my-2 h-px bg-border" />
<Link
to="/admin/profile"
onClick={() => setMenuOpen(false)}
className="rounded-lg px-3 py-2.5 text-body-sm font-medium text-ink-2 hover:bg-surface-sunken"
>
Profile
</Link>
</nav>
</SheetContent>
</Sheet>
{/* Main layout body */}
<div className="flex flex-col px-4 md:flex-row lg:px-6">
<main className="min-w-0 flex-1 py-6">
<Outlet />
</main>
<AssistantPanel
stickyClassName="top-14 pt-6"
panelHeightClassName="h-[calc(100dvh_-_6.5rem)]"
/>
</div>
</div>
</AssistantPanelProvider>
);
}
const NOTIFICATIONS = [
{
id: 1,
severity: 'warning',
title: 'Banquet Captain has no applicants',
detail: 'Live for 21 days with zero applications. Pay band may be below market.',
time: '2 hours ago',
read: false,
},
{
id: 2,
severity: 'warning',
title: '8 applicants awaiting screening',
detail: 'Oldest has been waiting 5 days across Event Security and Picker.',
time: '4 hours ago',
read: false,
},
{
id: 3,
severity: 'info',
title: 'Sofia Mendez scored 89',
detail: 'Screened for Event Server – Fine Dining and ready for a decision.',
time: 'Yesterday',
read: true,
},
{
id: 4,
severity: 'info',
title: '3 hires completed onboarding checks',
detail: 'Chef Antoine Dubois, Marcus Williams and Marco Rivera.',
time: '2 days ago',
read: true,
},
];

View File

@@ -9,7 +9,6 @@ import { KROW_LOGO_URL } from '@/assets/brand';
import { logActivity } from '@/lib/userTracking';
import { Sheet, SheetContent, SheetHeader, SheetTitle } from '@/components/ui/sheet';
import { DropdownMenu, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuItem, DropdownMenuLabel, DropdownMenuSeparator } from '@/components/ui/dropdown-menu';
import { AssistantPanel, AssistantPanelProvider } from '@/components/ai-assistant';
import OwliverChatBubble from '@/components/krow/OwliverChatBubble';
import { HoverCard, HoverCardTrigger, HoverCardContent } from '@/components/ui/hover-card';
import KrowIdPreview from '@/components/krow/KrowIdPreview';
@@ -119,7 +118,6 @@ export default function Layout() {
};
return (
<AssistantPanelProvider role={activeRole} pathname={location.pathname}>
<div className="min-h-screen gradient-mesh bg-[#F8FAFC]">
{/* Header */}
<header className="sticky top-0 z-40 glass border-b border-white/40">
@@ -383,13 +381,6 @@ export default function Layout() {
<Outlet />
</main>
{/* Krow AI — the dashboard assistant. Employer and Admin only; see
ai-assistant/placement.js. Sticky below the sticky chrome so it stays
put while the dashboard scrolls. */}
<AssistantPanel
stickyClassName="top-[8.5rem]"
panelHeightClassName="h-[calc(100vh_-_10rem)]"
/>
</div>
{/* Owliver — the independent AI product, with its own branding, voice
@@ -397,6 +388,5 @@ export default function Layout() {
Krow AI work: different product, different UI, no shared components. */}
{isTalent && <OwliverChatBubble open={owliverOpen} onOpenChange={setOwliverOpen} />}
</div>
</AssistantPanelProvider>
);
}

View File

@@ -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' };
}

View File

@@ -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() {
<p className="text-[13px] text-[#6B7280]">Applicants, pipeline, scores, interviews & hiring insights</p>
</div>
<div className="ml-auto">
<AssistantTrigger />
</div>
</div>
</motion.div>
<ImpactMetrics applications={applications} interviews={interviews} staff={staff} />

View File

@@ -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'}
</button>
<AssistantTrigger />
</div>
</div>
</div>
{/* Filters */}
@@ -171,6 +170,7 @@ export default function Candidates() {
{messageApp && (
<MessageCandidateModal open={true} onClose={() => setMessageApp(null)} application={messageApp} />
)}
<KrowAiChat page="candidates" />
</div>
);
}

View File

@@ -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() {
</h1>
<p className="text-[13px] sm:text-[14px] text-[#6B7280] mt-1">AI-powered hiring · No quizzes · Real readiness</p>
</div>
<AssistantTrigger />
</motion.div>
{/* Metrics Grid */}

View File

@@ -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() {
))}
</div>
)}
<KrowAiChat page="positions" />
</div>
);
}

View File

@@ -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 (
<div className="rounded-2xl border border-[#E5E7EB] bg-[#F8FAFC] p-4">
<div className="flex items-center gap-2 mb-3">
<Lock className="w-4 h-4 text-[#94A3B8]" />
<h3 className="text-[14px] font-semibold text-[#0F172A]">{course.title}</h3>
</div>
<div className="space-y-2">
{bars.map((b) => (
<div key={b.label}>
<div className="flex justify-between text-[11px] mb-1">
<span className="text-[#64748B]">{b.label}</span>
<span className={`font-medium ${b.val >= 1 ? 'text-[#16A34A]' : 'text-[#64748B]'}`}>{b.text}</span>
</div>
<div className="h-1.5 rounded-full bg-[#E2E8F0] overflow-hidden">
<div className={`h-full rounded-full ${b.val >= 1 ? 'bg-[#16A34A]' : 'bg-[#0A39DF]'}`} style={{ width: `${Math.round(b.val * 100)}%` }} />
</div>
</div>
))}
</div>
</div>
);
}
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 <div className="flex justify-center py-20"><Loader2 className="w-6 h-6 animate-spin text-[#0A39DF]" /></div>;
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 (
<div className="flex justify-center items-center py-20 min-h-[400px]">
<Loader2 className="w-7 h-7 animate-spin text-[#0A39DF]" />
</div>
);
}
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 (
<div className="max-w-5xl mx-auto">
<CapabilityTimeline profile={profile} compact />
<div className="max-w-5xl mx-auto space-y-8 pb-12 font-sans selection:bg-[#0A39DF] selection:text-white">
{/* 1. Page Hero & Progress Visualization */}
<section className="bg-gradient-to-r from-[#0F172A] via-[#1E293B] to-[#0A39DF] rounded-2xl p-6 sm:p-8 text-white shadow-xl relative overflow-hidden">
<div className="absolute top-0 right-0 w-96 h-96 bg-[#0A39DF]/30 rounded-full blur-3xl pointer-events-none" />
{/* Badges */}
{profile.earned_badges?.length > 0 && (
<section className="pt-6">
<div className="flex items-center justify-between mb-3">
<h2 className="text-[15px] font-semibold text-[#0F172A]">Badges with proof</h2>
<span className="text-[11px] text-[#94A3B8]">{profile.earned_badges.length} earned</span>
<div className="relative z-10 grid grid-cols-1 md:grid-cols-3 gap-6 items-center">
<div className="md:col-span-2 space-y-3">
<div className="inline-flex items-center gap-1.5 px-3 py-1 rounded-full bg-white/10 text-white text-[11px] font-bold tracking-wider uppercase border border-white/15 backdrop-blur-md">
<Sparkles className="w-3.5 h-3.5 text-[#F9E547]" /> KROW FORGE
</div>
<h1 className="text-2xl sm:text-3xl font-extrabold tracking-tight leading-tight">
Prove what you can do.
</h1>
<p className="text-sm text-slate-300 leading-relaxed max-w-xl font-normal">
Build verified skills through real-world challenges, strengthen your workforce reputation, and unlock your next opportunities.
</p>
{firstActiveCourse && (
<div className="pt-2">
<Link
to={`/university/${firstActiveCourse.id}`}
className="inline-flex items-center gap-2 px-4 py-2.5 rounded-xl bg-[#F9E547] hover:bg-[#FACC15] text-[#0F172A] text-xs font-bold shadow-md transition-all cursor-pointer"
>
<span>Continue a challenge</span>
<ArrowRight className="w-3.5 h-3.5" />
</Link>
</div>
)}
</div>
<div className="flex flex-wrap gap-2">
{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 <BadgeChip key={i} name={`${b.name}${proofCount ? ` · ${proofCount} proof${proofCount > 1 ? 's' : ''}` : ''}`} level={b.level} earned_date={b.earned_date} />;
})}
</div>
</section>
)}
{/* Locked goals */}
{lockedGoals.length > 0 && (
<section className="pt-8">
<h2 className="text-[15px] font-semibold text-[#0F172A] mb-3">Earn these next</h2>
<div className="grid sm:grid-cols-2 gap-3">
{lockedGoals.map((c) => <LockCard key={c.id} course={c} profile={profile} />)}
</div>
</section>
)}
{/* Right Compact Progress Card */}
<div className="bg-white/10 backdrop-blur-md border border-white/15 rounded-xl p-4 sm:p-5 text-white space-y-3 shadow-inner">
<div className="flex items-center justify-between border-b border-white/10 pb-2">
<span className="text-[11px] font-bold uppercase tracking-wider text-slate-300">
FORGE PROGRESS
</span>
<ShieldCheck className="w-4 h-4 text-[#F9E547]" />
</div>
{/* Challenges */}
<section className="pt-8">
<div className="flex items-center justify-between gap-3 flex-wrap mb-4">
<h2 className="text-[15px] font-semibold text-[#0F172A]">Proving Challenges</h2>
<div className="flex items-center gap-2">
<div className="grid grid-cols-3 gap-2 text-center py-1">
<div>
<p className="text-xl font-extrabold text-[#F9E547]">{verifiedCount}</p>
<p className="text-[10px] text-slate-300 font-medium leading-tight">Verified skills</p>
</div>
<div>
<p className="text-xl font-extrabold text-white">{inProgressCount}</p>
<p className="text-[10px] text-slate-300 font-medium leading-tight">In progress</p>
</div>
<div>
<p className="text-xl font-extrabold text-white">{nextUnlockCount}</p>
<p className="text-[10px] text-slate-300 font-medium leading-tight">Next unlock</p>
</div>
</div>
<button
onClick={scrollToNext}
className="w-full py-1.5 rounded-lg bg-white/15 hover:bg-white/25 text-[11px] font-semibold text-white transition-colors cursor-pointer text-center block"
>
View skill map →
</button>
</div>
</div>
</section>
{/* 2. Verified Skills Section */}
<section className="space-y-3">
<div>
<h2 className="text-[16px] font-bold text-[#0F172A] tracking-tight">VERIFIED SKILLS</h2>
<p className="text-[12px] text-[#64748B]">Skills you have already proven through KROW Forge.</p>
</div>
{verifiedSkills.length === 0 ? (
<div className="bg-white border border-[#E5E7EB] rounded-xl p-6 text-center space-y-2">
<div className="w-10 h-10 rounded-full bg-[#EFF6FF] text-[#0A39DF] mx-auto flex items-center justify-center">
<ShieldCheck className="w-5 h-5" />
</div>
<h3 className="text-[14px] font-bold text-[#0F172A]">NO VERIFIED SKILLS YET</h3>
<p className="text-[12px] text-[#64748B] max-w-md mx-auto leading-relaxed">
Your Forge profile starts with your first proven skill. Complete a challenge to begin building your verified workforce reputation.
</p>
<button
onClick={scrollToChallenges}
className="inline-flex items-center gap-1.5 px-3.5 py-2 rounded-lg bg-[#0A39DF] text-white text-[12px] font-semibold hover:bg-[#082EB4] transition-colors mt-2 cursor-pointer"
>
<span>Browse challenges →</span>
</button>
</div>
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
{verifiedSkills.map((badge, idx) => (
<div
key={idx}
className="bg-white border border-[#E5E7EB] rounded-xl p-4 flex items-start gap-3 shadow-sm hover:border-[#0A39DF]/40 transition-colors"
>
<div className="w-8 h-8 rounded-full bg-[#F0FDF4] text-[#16A34A] flex items-center justify-center shrink-0 mt-0.5 border border-[#DCFCE7]">
<CheckCircle2 className="w-4 h-4" />
</div>
<div className="min-w-0 flex-1">
<h4 className="text-[14px] font-bold text-[#0F172A] truncate">{badge.name}</h4>
<p className="text-[11px] text-[#64748B] font-medium">Verified · {badge.level || 'Standard'}</p>
<p className="text-[10px] text-[#94A3B8] mt-1">
Verified {badge.earned_date || 'Jul 2026'}
</p>
</div>
</div>
))}
</div>
)}
</section>
{/* 3. Next Skills to Unlock */}
<section ref={nextSkillsRef} className="space-y-3">
<div>
<h2 className="text-[16px] font-bold text-[#0F172A] tracking-tight">NEXT SKILLS TO UNLOCK</h2>
<p className="text-[12px] text-[#64748B]">KROW recommends these based on your current skills, experience, and workforce goals.</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-3">
{recommendedCourses.map((c) => {
const lock = isUnlocked(c, profile);
return (
<div
key={c.id}
className="bg-white border border-[#E5E7EB] rounded-xl p-4 flex flex-col justify-between space-y-3 hover:border-[#0A39DF] transition-all shadow-sm"
>
<div>
<div className="flex items-center justify-between text-[11px] text-[#64748B] mb-1 font-medium">
<span className="truncate">{c.proof_skill || c.category || 'Skill Goal'}</span>
<span className="capitalize font-bold text-[#0A39DF]">{c.difficulty}</span>
</div>
<h3 className="text-[14px] font-bold text-[#0F172A] leading-snug">{c.title}</h3>
<p className="text-[12px] text-[#64748B] mt-1 line-clamp-2">{c.description}</p>
{!lock.unlocked && lock.reasons?.length > 0 && (
<div className="mt-2.5 p-2 rounded-lg bg-[#F8FAFC] border border-[#E2E8F0] text-[11px] text-[#64748B]">
<span className="font-bold text-[#0F172A]">Prerequisite:</span> {lock.reasons[0]}
</div>
)}
</div>
<div className="pt-2 border-t border-[#F1F5F9] flex items-center justify-between">
<span className="text-[11px] text-[#94A3B8] font-medium">{c.estimated_minutes} min · {c.xp} XP</span>
<Link
to={`/university/${c.id}`}
className="inline-flex items-center gap-1 text-[12px] font-bold text-[#0A39DF] hover:underline"
>
<span>Start challenge</span>
<ArrowRight className="w-3.5 h-3.5" />
</Link>
</div>
</div>
);
})}
</div>
</section>
{/* 4. KROW Forge Insight */}
<section className="bg-[#EFF6FF] border border-[#BFDBFE] rounded-2xl p-5 sm:p-6 flex flex-col sm:flex-row items-start sm:items-center justify-between gap-4">
<div className="space-y-1 max-w-2xl">
<div className="flex items-center gap-1.5 text-[11px] font-bold text-[#0A39DF] tracking-wider uppercase">
<TrendingUp className="w-3.5 h-3.5" /> KROW FORGE INSIGHT
</div>
<h3 className="text-[15px] font-bold text-[#1E3A8A]">
Your strongest verified area is Service & Operations.
</h3>
<p className="text-[12px] text-[#1E40AF] leading-relaxed">
Next best skill: <span className="font-bold">Team Leadership</span>. Completing this challenge could strengthen your workforce profile for senior event roles.
</p>
</div>
<button
onClick={scrollToChallenges}
className="shrink-0 px-4 py-2.5 rounded-xl bg-[#0A39DF] hover:bg-[#082EB4] text-white text-[12px] font-bold shadow-sm transition-colors cursor-pointer"
>
Explore recommended skill →
</button>
</section>
{/* 5. Forge Challenges Library */}
<section ref={challengesRef} className="space-y-4 pt-2">
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3 border-b border-[#E5E7EB] pb-3">
<div>
<h2 className="text-[16px] font-bold text-[#0F172A] tracking-tight">FORGE CHALLENGES</h2>
<p className="text-[12px] text-[#64748B]">Complete practical challenges to prove skills and strengthen your verified workforce profile.</p>
</div>
{/* Clean Toolbar */}
<div className="flex items-center gap-2 flex-wrap">
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-[#94A3B8]" />
<input value={search} onChange={(e) => 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" />
<input
value={search}
onChange={(e) => 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"
/>
</div>
<div className="flex items-center gap-1 bg-[#F1F5F9] rounded-full p-1">
<div className="flex items-center gap-1 bg-[#F1F5F9] rounded-lg p-1">
{DIFFS.map((d) => (
<button
key={d.v}
onClick={() => setDiffFilter(d.v)}
className={`px-2.5 h-7 rounded-full text-[11px] font-medium transition-all ${diffFilter === d.v ? 'bg-white text-[#0A39DF] shadow-sm' : 'text-[#64748B] hover:text-[#0F172A]'}`}
className={`px-2.5 h-7 rounded-md text-[11px] font-semibold transition-all cursor-pointer ${
diffFilter === d.v ? 'bg-white text-[#0A39DF] shadow-sm' : 'text-[#64748B] hover:text-[#0F172A]'
}`}
>
{d.label}
</button>
@@ -116,13 +284,34 @@ export default function University() {
</div>
</div>
</div>
{/* Challenge Cards Grid */}
{loadingCourses ? (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">{[...Array(6)].map((_, i) => <div key={i} className="h-36 rounded-2xl bg-[#F1F5F9] animate-pulse" />)}</div>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
{[...Array(6)].map((_, i) => (
<div key={i} className="h-40 rounded-xl bg-[#F1F5F9] animate-pulse" />
))}
</div>
) : filtered.length === 0 ? (
<p className="text-[13px] text-[#94A3B8] py-10 text-center">No challenges match your filter.</p>
<div className="text-center py-12 bg-white border border-[#E5E7EB] rounded-xl">
<p className="text-[13px] text-[#64748B]">No challenges match your filters.</p>
<button
onClick={() => { setSearch(''); setDiffFilter('all'); }}
className="mt-2 text-[12px] font-semibold text-[#0A39DF] hover:underline cursor-pointer"
>
Clear filters
</button>
</div>
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
{filtered.map((c) => <UniversityCourseCard key={c.id} course={c} completed={completedIds.has(c.id)} profile={profile} />)}
{filtered.map((c) => (
<UniversityCourseCard
key={c.id}
course={c}
completed={completedIds.has(c.id)}
profile={profile}
/>
))}
</div>
)}
</section>

View File

@@ -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 (
<AdminPage
title="Activity"
subtitle="Monitor workforce operations, user actions and system events."
>
<MetricStrip
columns={4}
loading={isLoading}
items={[
{ label: 'Total events', value: events.length },
{ label: 'Last 24 hours', value: last24h.length, tone: last24h.length ? 'default' : 'warning' },
{ label: 'Privileged actions', value: highRisk.length, tone: 'warning', sub: 'hires and position changes' },
{ label: 'Distinct users', value: users.length },
]}
/>
{highRisk.length > 0 && (
<Surface variant="solid" radius="lg" padding="sm" elevation="xs" className="border-warning/30 bg-warning-muted">
<div className="flex items-start gap-2.5">
<ShieldAlert className="mt-0.5 h-4 w-4 shrink-0 text-warning" aria-hidden="true" />
<p className="text-body-sm text-ink-2">
<span className="font-semibold text-ink-1">{highRisk.length} privileged actions</span> in this log —
hires and position changes. These should always trace to a named person.
</p>
</div>
</Surface>
)}
{/* The recent timeline. Deliberately above the log and deliberately not
filtered: this answers "what just happened", which is a different
question from the one the toolbar below exists to ask. Reading a
chronology is also how an auditor starts — sequence first, then
interrogate the specifics. */}
<section aria-labelledby="timeline" className="space-y-3">
<SectionTitle
id="timeline"
title="Operational timeline"
meta={`Most recent ${recent.length} events`}
/>
<Surface variant="solid" radius="lg" padding="none" elevation="xs" className="overflow-hidden">
{recent.length ? (
<div className="divide-y divide-border">
{byDay.map(([day, dayEvents]) => (
<div key={day} className="px-4 py-3.5">
<p className="mb-3 text-[10px] font-semibold uppercase tracking-wide text-ink-4">
{day}
</p>
<Timeline
compact
items={dayEvents.map((e) => ({
id: e.id,
icon: EVENT_ICON[e.event_type] || Activity,
tone: TIMELINE_TONE[e.severity],
title: e.event_type.replace(/_/g, ' '),
description: e.details || undefined,
meta: `${e.user_name || e.user_email}${e.account_type ? ` · ${e.account_type}` : ''}`,
timestamp: new Date(e.created_date).toLocaleTimeString(undefined, {
hour: 'numeric', minute: '2-digit',
}),
}))}
/>
</div>
))}
</div>
) : (
<p className="px-4 py-8 text-center text-body-sm text-ink-3">
No activity has been logged yet.
</p>
)}
</Surface>
</section>
<section aria-labelledby="audit" className="space-y-3">
<SectionTitle id="audit" title="Audit log" meta={`${filtered.length} of ${events.length} events`} />
<Toolbar
search={<SearchInput value={search} onChange={setSearch} placeholder="Search events" size="sm" />}
filters={
<>
<FilterSelect value={user} onChange={setUser} label="User"
options={[{ value: 'all', label: 'All users' }, ...users.map((u) => ({ value: u, label: u }))]} />
<FilterSelect value={action} onChange={setAction} label="Action"
options={[{ value: 'all', label: 'All actions' }, ...actions.map((a) => ({ value: a, label: a.replace(/_/g, ' ') }))]} />
<FilterSelect value={severity} onChange={setSeverity} label="Severity" options={[
{ value: 'all', label: 'All severities' }, { value: 'high', label: 'High' },
{ value: 'medium', label: 'Medium' }, { value: 'low', label: 'Low' },
]} />
<FilterSelect value={range} onChange={setRange} label="Date" options={[
{ value: 'all', label: 'All time' }, { value: '24h', label: 'Last 24 hours' },
{ value: '7d', label: 'Last 7 days' }, { value: '30d', label: 'Last 30 days' },
]} />
</>
}
/>
<DataTable
loading={isLoading}
rows={filtered}
pageSize={20}
isFiltered={isFiltered}
onClearFilters={clearFilters}
caption="Platform audit log"
rowClassName={(e) => (e.severity === 'high' ? 'bg-warning-muted/40' : undefined)}
columns={[
{
key: 'user_name', header: 'User', sortable: true,
cell: (e) => (
<div className="flex min-w-0 items-center gap-2.5">
<Avatar name={e.user_name || e.user_email} size="xs" />
<div className="min-w-0">
<p className="truncate font-medium text-ink-1">{e.user_name || '—'}</p>
<p className="truncate text-[10px] text-ink-4">{e.user_email}</p>
</div>
</div>
),
},
{
key: 'event_type', header: 'Action', sortable: true,
cell: (e) => <span className="whitespace-nowrap">{e.event_type.replace(/_/g, ' ')}</span>,
},
{
key: 'details', header: 'Entity', hideBelow: 'md',
cell: (e) => <span className="line-clamp-1 text-ink-3">{e.details || '—'}</span>,
},
{
key: 'account_type', header: 'Role', hideBelow: 'lg',
cell: (e) => <Badge variant={e.account_type === 'employer' ? 'info' : 'neutral'} size="sm">{e.account_type || 'unknown'}</Badge>,
},
{
key: 'ip', header: 'Source IP', hideBelow: 'lg',
cell: (e) => <span className="font-mono text-[11px] text-ink-4">{e.ip}</span>,
},
{
key: 'created_date', header: 'Timestamp', align: 'right', sortable: true,
sortValue: (e) => new Date(e.created_date).getTime(),
cell: (e) => (
<span className="whitespace-nowrap text-[11px] text-ink-4">
{new Date(e.created_date).toLocaleString(undefined, {
month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit',
})}
</span>
),
},
{
key: 'severity', header: 'Severity', align: 'right',
cell: (e) => (
<Badge variant={SEVERITY_META[e.severity].variant} size="sm">
{SEVERITY_META[e.severity].label}
</Badge>
),
},
]}
/>
</section>
</AdminPage>
);
}

View File

@@ -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 <Navigate to="/admin/login" replace state={{ from: location.pathname }} />;
}
return <Outlet />;
}

View File

@@ -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 (
<AdminPage
title="Analytics"
subtitle="Hiring performance, conversion and workforce quality."
actions={
<SegmentedToggle
options={[{ value: '7d', label: '7d' }, { value: '30d', label: '30d' }, { value: '90d', label: '90d' }]}
value={range}
onChange={setRange}
size="sm"
ariaLabel="Date range"
/>
}
>
<MetricStrip
columns={6}
items={[
{ label: 'Applicants', value: f.total },
{ label: 'Conversion', value: `${f.hireRate}%`, tone: 'brand', sub: 'applicant to hire' },
{ label: 'Time to hire', value: f.timeToHire ? `${f.timeToHire}d` : '—', delta: -38, invertTrend: true },
{ label: 'Screening', value: `${f.standardizedPct}%`, tone: f.standardizedPct >= 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. */}
<section aria-labelledby="trend" className="space-y-3">
<SectionTitle id="trend" title="Hiring trend" meta="Applications received against candidates screened" />
<ResponsiveContainer width="100%" height={220}>
<LineChart data={trend}>
<CartesianGrid strokeDasharray="3 3" stroke={CHART_TONES.grid} vertical={false} />
<XAxis dataKey="label" {...AXIS_PROPS} interval="preserveStartEnd" />
<YAxis {...AXIS_PROPS} allowDecimals={false} width={28} />
<Tooltip content={<ChartTooltip />} />
<Line type="monotone" dataKey="applied" name="Applied" stroke={CHART_TONES.brand} strokeWidth={2} dot={false} />
<Line type="monotone" dataKey="screened" name="Screened" stroke={CHART_TONES.navy} strokeWidth={2} strokeDasharray="4 3" dot={false} />
</LineChart>
</ResponsiveContainer>
</section>
<div className="grid gap-6 lg:grid-cols-2">
{/* Pipeline conversion — stage-to-stage rates are the point, so each
step shows its own pass-through rather than only its count. */}
<section aria-labelledby="conversion" className="space-y-3">
<SectionTitle id="conversion" title="Pipeline conversion" meta={`${f.total} entered`} />
<div className="space-y-3">
{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 (
<div key={stage.key}>
<div className="mb-1 flex items-baseline justify-between gap-2">
<span className="text-body-sm text-ink-2">{stage.label}</span>
<span className="flex items-baseline gap-2.5">
{lost > 0 && <span className="text-[10px] text-destructive">−{lost}</span>}
<span className={rate < 60 && i > 0 ? 'text-[11px] font-semibold text-warning' : 'text-[11px] text-ink-4'}>
{rate}%
</span>
<span className="w-8 text-right text-body-sm font-bold tabular-nums text-ink-1">{stage.count}</span>
</span>
</div>
<ProgressBar value={stage.count} max={f.total || 1} size="sm" tone={rate < 60 && i > 0 ? 'warning' : 'brand'} />
</div>
);
})}
</div>
</section>
{/* Score distribution — where the hireable pool actually sits. */}
<section aria-labelledby="quality" className="space-y-3">
<SectionTitle id="quality" title="Hiring quality" meta={`${f.scored.length} scored · avg ${f.avgScore}`} />
<ResponsiveContainer width="100%" height={196}>
<BarChart data={scoreDistribution}>
<CartesianGrid strokeDasharray="3 3" stroke={CHART_TONES.grid} vertical={false} />
<XAxis dataKey="label" {...AXIS_PROPS} />
<YAxis {...AXIS_PROPS} allowDecimals={false} width={28} />
<Tooltip content={<ChartTooltip />} />
<Bar dataKey="count" name="Candidates" radius={[4, 4, 0, 0]} maxBarSize={56}>
{scoreDistribution.map((d) => <Cell key={d.label} fill={bandColor(d.label)} />)}
</Bar>
</BarChart>
</ResponsiveContainer>
</section>
</div>
{/* 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. */}
<div className="grid gap-6 lg:grid-cols-2">
<section aria-labelledby="velocity" className="space-y-3">
<SectionTitle id="velocity" title="Time to hire" meta="Application to decision" />
<Surface variant="solid" radius="lg" padding="lg" elevation="xs">
<div className="flex items-baseline gap-2">
<span className="font-heading text-display font-bold tabular-nums text-ink-1">
{f.timeToHire || '—'}
</span>
<span className="text-body-sm text-ink-3">
{f.timeToHire ? `day${f.timeToHire === 1 ? '' : 's'} on average` : 'no hires yet'}
</span>
</div>
<div className="mt-4 space-y-3">
{byRoleSpeed.length ? byRoleSpeed.map((r) => (
<div key={r.title}>
<div className="mb-1 flex items-baseline justify-between gap-2">
<span className="min-w-0 truncate text-caption text-ink-2">{r.title}</span>
<span className="shrink-0 text-caption font-semibold tabular-nums text-ink-1">
{r.days}d
</span>
</div>
<ProgressBar
value={r.days}
max={slowestHire}
size="xs"
tone={r.days <= 7 ? 'success' : r.days <= 14 ? 'brand' : 'warning'}
/>
</div>
)) : (
<p className="text-body-sm text-ink-3">
No hires closed yet, so there is no per-role speed to compare.
</p>
)}
</div>
</Surface>
</section>
<section aria-labelledby="efficiency" className="space-y-3">
<SectionTitle id="efficiency" title="Screening efficiency" meta="Coverage and outcome" />
<Surface variant="solid" radius="lg" padding="lg" elevation="xs">
<dl className="space-y-3.5">
{[
{
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) => (
<div key={row.label} className="border-b border-border pb-3 last:border-0 last:pb-0">
<div className="flex items-baseline justify-between gap-3">
<dt className="text-caption font-medium text-ink-2">{row.label}</dt>
<dd
className={cn(
'font-heading text-body font-bold tabular-nums',
row.tone === 'success' ? 'text-success'
: row.tone === 'warning' ? 'text-warning' : 'text-ink-1'
)}
>
{row.value}
</dd>
</div>
<p className="mt-0.5 text-[11px] leading-relaxed text-ink-4">{row.note}</p>
</div>
))}
</dl>
</Surface>
</section>
</div>
{/* Position and department performance — a table, because the comparison
is between rows and exact counts matter. */}
<section aria-labelledby="positions" className="space-y-3">
<SectionTitle id="positions" title="Position performance" meta={`${f.byRole.length} open roles`} />
<Surface variant="solid" radius="lg" padding="none" elevation="xs" className="overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full border-collapse text-body-sm">
<caption className="sr-only">Applicants, qualified candidates and hires by position</caption>
<thead>
<tr className="border-b border-border bg-surface-subtle">
{['Position', 'Applied', '70+', 'Hired', 'Avg score', 'Qualified rate'].map((h, i) => (
<th key={h} scope="col" className={`whitespace-nowrap px-4 py-2.5 text-[10px] font-semibold uppercase tracking-wide text-ink-4 ${i ? 'text-right' : 'text-left'}`}>
{h}
</th>
))}
</tr>
</thead>
<tbody>
{f.byRole.map((r) => {
const rate = r.applied ? Math.round((r.qualified / r.applied) * 100) : 0;
return (
<tr key={r.title} className="border-b border-border last:border-0 hover:bg-surface-subtle">
<td className="px-4 py-2.5 font-medium text-ink-1">{r.title}</td>
<td className={`px-4 py-2.5 text-right tabular-nums ${r.applied ? 'text-ink-2' : 'font-semibold text-destructive'}`}>{r.applied}</td>
<td className="px-4 py-2.5 text-right tabular-nums text-ink-2">{r.qualified}</td>
<td className="px-4 py-2.5 text-right tabular-nums text-ink-2">{r.hired || '—'}</td>
<td className="px-4 py-2.5 text-right tabular-nums text-ink-2">{r.avgScore || '—'}</td>
<td className="px-4 py-2.5">
<div className="ml-auto w-20">
<ProgressBar value={rate} tone={rate >= 50 ? 'success' : rate > 0 ? 'warning' : 'destructive'} size="xs" />
<p className="mt-1 text-right text-[10px] tabular-nums text-ink-4">{rate}%</p>
</div>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</Surface>
</section>
<section aria-labelledby="departments" className="space-y-3">
<SectionTitle id="departments" title="Department performance" meta={`${departments.length} departments`} />
<div className="space-y-3">
{departments.map((d) => (
<div key={d.category}>
<div className="mb-1 flex items-baseline justify-between gap-2">
<span className="text-body-sm text-ink-2">{d.category}</span>
<span className="flex items-baseline gap-3 text-[11px] text-ink-4">
<span>{d.qualified} qualified</span>
<span>{d.hired} hired</span>
<span className="w-8 text-right text-body-sm font-bold tabular-nums text-ink-1">{d.applied}</span>
</span>
</div>
<ProgressBar value={d.applied} max={maxApplied} size="sm" />
</div>
))}
</div>
</section>
{insights.length > 0 && (
<section aria-labelledby="insights" className="space-y-3">
<SectionTitle id="insights" title="What the data says" meta={`${insights.length} findings`} />
<Surface variant="solid" radius="lg" padding="none" elevation="xs" className="overflow-hidden">
<InsightList>
{insights.map((item, i) => <InsightRow key={i} {...item} />)}
</InsightList>
</Surface>
</section>
)}
</AdminPage>
);
}

View File

@@ -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 (
<div className={cn('mb-3 flex items-baseline justify-between gap-3', className)}>
<h2 className="text-[11px] font-semibold uppercase tracking-[0.08em] text-ink-4">{children}</h2>
{meta && <span className="text-caption text-ink-4">{meta}</span>}
</div>
);
}
/** One score line. Label, bar, figure — readable down a column. */
function Signal({ label, value }) {
const missing = value == null || value === 0;
return (
<div className="flex items-center gap-3">
<span className="w-32 shrink-0 truncate text-body-sm text-ink-2">{label}</span>
<span className="min-w-0 flex-1">
<ProgressBar value={missing ? 0 : value} tone="score" size="xs" />
</span>
<span
className={cn(
'w-8 shrink-0 text-right text-body-sm font-semibold tabular-nums',
missing ? 'text-ink-4' : 'text-ink-1'
)}
>
{missing ? '—' : value}
</span>
</div>
);
}
/** A reputation figure. Value large, label quiet, no card around it. */
function Metric({ label, value, suffix, tone }) {
const empty = value == null || value === '';
return (
<div className="min-w-0 px-4 py-3">
<p className="text-[10px] font-medium uppercase tracking-wide text-ink-4">{label}</p>
<p
className={cn(
'mt-1 font-heading text-title font-bold leading-none tabular-nums',
empty ? 'text-ink-4' : tone === 'brand' ? 'text-krow-blue' : 'text-ink-1'
)}
>
{empty ? '—' : value}
{!empty && suffix && <span className="ml-0.5 text-body-sm font-semibold text-ink-3">{suffix}</span>}
</p>
</div>
);
}
function Stars({ count = 5 }) {
return (
<span className="inline-flex items-center gap-0.5" aria-label={`${count} out of 5`}>
{Array.from({ length: 5 }).map((_, i) => (
<Star
key={i}
className={cn('h-3.5 w-3.5', i < count ? 'fill-krow-yellow text-krow-yellow' : 'text-border')}
aria-hidden="true"
/>
))}
</span>
);
}
/* ── 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 (
<header className="border-b border-border bg-surface">
<div className="px-5 py-5 lg:px-8 lg:py-7">
<button
type="button"
onClick={onBack}
className="mb-5 inline-flex items-center gap-1.5 rounded text-caption font-medium text-ink-3 transition-colors
hover:text-krow-blue focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-krow-blue/50"
>
<ArrowLeft className="h-3.5 w-3.5" aria-hidden="true" />
All candidates
</button>
<div className="flex flex-col gap-6 lg:flex-row lg:items-start">
{/* Identity */}
<div className="flex min-w-0 flex-1 flex-col gap-5 sm:flex-row sm:items-start">
<Avatar
name={c.applicant_name}
src={c.selfie_url}
className="h-24 w-24 shrink-0 text-title-lg sm:h-28 sm:w-28"
/>
<div className="min-w-0 flex-1">
<h1 className="font-heading text-display font-bold leading-tight text-ink-1">
{c.applicant_name}
</h1>
<p className="mt-1 text-body-lg text-ink-2">{c.job_title || 'No position on file'}</p>
<p className="mt-2 text-body-sm text-ink-3">
{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`}
</p>
<div className="mt-3 flex flex-wrap items-center gap-1.5">
<StatusBadge status={c.status} size="sm" />
{c.ai_match_label && <Badge variant="soft" size="sm">{c.ai_match_label}</Badge>}
{scored && <Badge variant="neutral" size="sm">{tier.label}</Badge>}
{c.ai_recommendation && (
<Badge variant="neutral" size="sm">Rec: {c.ai_recommendation}</Badge>
)}
</div>
<div className="mt-4 flex flex-wrap items-center gap-x-5 gap-y-1.5 text-body-sm">
{c.email && (
<a
href={`mailto:${c.email}`}
className="inline-flex items-center gap-1.5 rounded text-ink-2 transition-colors hover:text-krow-blue
focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-krow-blue/50"
>
<Mail className="h-3.5 w-3.5 text-ink-4" aria-hidden="true" />
{c.email}
</a>
)}
{c.phone && (
<a
href={`tel:${c.phone}`}
className="inline-flex items-center gap-1.5 rounded text-ink-2 transition-colors hover:text-krow-blue
focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-krow-blue/50"
>
<Phone className="h-3.5 w-3.5 text-ink-4" aria-hidden="true" />
{c.phone}
</a>
)}
</div>
</div>
</div>
{/* Score and actions */}
<div className="flex shrink-0 flex-col items-start gap-4 lg:items-end">
{scored ? (
<div className="lg:text-right">
<p className="font-heading text-display font-bold leading-none tabular-nums text-krow-blue">
{c.ai_score}
<span className="text-title font-semibold text-ink-4">/100</span>
</p>
{/* Never the bare number: a score without its band is a figure
nobody can act on. */}
<p className="mt-1 text-body-sm font-medium text-ink-2">
{c.ai_match_label || 'Match score'}
</p>
</div>
) : (
<div className="rounded-lg border border-dashed border-border px-4 py-3 lg:text-right">
<p className="text-body-sm font-medium text-ink-2">Not screened</p>
<p className="text-caption text-ink-4">No KROW score yet</p>
</div>
)}
<div className="flex flex-wrap items-center gap-2">
<Button size="sm" shape="rounded" onClick={onShortlist}>
<UserCheck aria-hidden="true" /> Shortlist
</Button>
<Button variant="outline" size="sm" shape="rounded" onClick={onInterview}>
<CalendarPlus aria-hidden="true" /> Interview
</Button>
</div>
</div>
</div>
</div>
</header>
);
}
/* ── 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 (
<section>
<SectionLabel>KROW recommendation</SectionLabel>
<div className="rounded-xl border border-border bg-surface p-5">
<p className="text-body-sm leading-relaxed text-ink-2">
This candidate has not been screened, so there is no recommendation yet.
Screening scores them against the role&apos;s requirements and produces the
analysis that appears here.
</p>
</div>
</section>
);
}
return (
<section>
<SectionLabel>KROW recommendation</SectionLabel>
<div className="rounded-xl border border-border bg-surface">
<div className="border-b border-border px-5 py-4">
<div className="flex flex-wrap items-center gap-2.5">
<span className="grid h-7 w-7 shrink-0 place-items-center rounded-lg bg-krow-blue text-white">
<Sparkles className="h-3.5 w-3.5" aria-hidden="true" />
</span>
<p className="font-heading text-body-lg font-semibold text-ink-1">
{verdict.verdict}
<span className="text-ink-4"> · </span>
<span className="text-krow-blue">{verdict.action}</span>
</p>
</div>
{c.ai_summary && (
<p className="mt-3 max-w-3xl text-body leading-relaxed text-ink-2">{c.ai_summary}</p>
)}
</div>
<div className="grid gap-x-8 gap-y-5 px-5 py-4 sm:grid-cols-2">
{c.ai_strengths?.length > 0 && (
<div>
<p className="mb-2 text-[11px] font-semibold uppercase tracking-[0.08em] text-ink-4">
Why KROW recommends {c.applicant_name.split(' ').pop()}
</p>
<ul className="space-y-1.5">
{c.ai_strengths.map((s) => (
<li key={s} className="flex gap-2 text-body-sm leading-relaxed text-ink-2">
<Check className="mt-0.5 h-3.5 w-3.5 shrink-0 text-success" aria-hidden="true" />
{s}
</li>
))}
</ul>
</div>
)}
{c.ai_gaps?.length > 0 && (
<div>
<p className="mb-2 text-[11px] font-semibold uppercase tracking-[0.08em] text-ink-4">
{c.ai_gaps.length === 1 ? 'Gap' : 'Gaps'}
</p>
<ul className="space-y-1.5">
{c.ai_gaps.map((s) => (
<li key={s} className="flex gap-2 text-body-sm leading-relaxed text-ink-2">
{/* A neutral dot, not a red alert. A missing qualification is
information for the reader, not an error the page caught. */}
<span
className="mt-1.5 h-1.5 w-1.5 shrink-0 rounded-full bg-ink-4"
aria-hidden="true"
/>
{s}
</li>
))}
</ul>
<p className="mt-2 text-caption leading-relaxed text-ink-4">
Noted for context, not a disqualification.
</p>
</div>
)}
</div>
<div className="border-t border-border px-5 py-3">
<Button size="sm" shape="rounded" onClick={onShortlist}>
<UserCheck aria-hidden="true" /> Shortlist {c.applicant_name.split(' ')[0]}
</Button>
</div>
</div>
</section>
);
}
/* ── Tab panels ─────────────────────────────────────────────────────────── */
/** Skills and credentials, as light chips. */
function SkillsAndCerts({ candidate }) {
const c = candidate;
return (
<div className="grid gap-8 sm:grid-cols-2">
<section>
<SectionLabel meta={c.skills?.length ? `${c.skills.length}` : undefined}>Skills</SectionLabel>
{c.skills?.length ? (
<div className="flex flex-wrap gap-1.5">
{c.skills.map((s) => (
<span
key={s}
className="rounded-md border border-border bg-surface px-2.5 py-1 text-body-sm text-ink-2"
>
{s}
</span>
))}
</div>
) : (
<p className="text-body-sm text-ink-3">No skills recorded.</p>
)}
</section>
<section>
<SectionLabel meta={c.certifications?.length ? `${c.certifications.length}` : undefined}>
Certifications
</SectionLabel>
{c.certifications?.length ? (
<div className="flex flex-wrap gap-1.5">
{c.certifications.map((s) => (
<span
key={s}
className="inline-flex items-center gap-1 rounded-md border border-success/25 bg-success-muted px-2.5 py-1 text-body-sm font-medium text-success"
>
<Check className="h-3 w-3" aria-hidden="true" />
{s}
</span>
))}
</div>
) : (
<p className="text-body-sm text-ink-3">None on file.</p>
)}
</section>
</div>
);
}
/** 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 (
<div className="grid gap-8 lg:grid-cols-2">
{c.professional_summary && (
<section>
<SectionLabel>About the candidate</SectionLabel>
<p className="max-w-prose text-body leading-relaxed text-ink-2">{c.professional_summary}</p>
</section>
)}
{c.ai_summary && (
<section>
<SectionLabel>Screening analysis</SectionLabel>
<p className="max-w-prose text-body leading-relaxed text-ink-2">{c.ai_summary}</p>
</section>
)}
</div>
);
}
/** Strengths against gaps. */
function StrengthsAndGaps({ candidate }) {
const c = candidate;
if (!c.ai_strengths?.length && !c.ai_gaps?.length) return null;
return (
<div className="grid gap-8 sm:grid-cols-2">
{c.ai_strengths?.length > 0 && (
<section>
<SectionLabel>Strengths</SectionLabel>
<ul className="space-y-2">
{c.ai_strengths.map((s) => (
<li key={s} className="flex gap-2 text-body-sm leading-relaxed text-ink-2">
<Check className="mt-0.5 h-3.5 w-3.5 shrink-0 text-success" aria-hidden="true" />
{s}
</li>
))}
</ul>
</section>
)}
{c.ai_gaps?.length > 0 && (
<section>
<SectionLabel>Gaps</SectionLabel>
<ul className="space-y-2">
{c.ai_gaps.map((s) => (
<li key={s} className="flex gap-2 text-body-sm leading-relaxed text-ink-2">
<span className="mt-1.5 h-1.5 w-1.5 shrink-0 rounded-full bg-ink-4" aria-hidden="true" />
{s}
</li>
))}
</ul>
</section>
)}
</div>
);
}
/** 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 (
<EmptyState
title="Not screened yet"
description="Screening scores this candidate across fifteen dimensions. Nothing to break down until it runs."
/>
);
}
/* 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 (
<section>
<SectionLabel meta={`${candidate.ai_score}/100 overall · ${ALL_DIMENSIONS.length} dimensions`}>
Match breakdown
</SectionLabel>
<div className="grid gap-x-10 gap-y-7 lg:grid-cols-2">
{groups.map((group) => (
<div key={group.id}>
<div className="mb-3">
<p className="text-body-sm font-semibold text-ink-1">{group.label}</p>
<p className="text-caption text-ink-4">{group.description}</p>
</div>
<div className="space-y-2.5">
{group.dimensions.map((d) => (
<Signal key={d.key} label={d.label} value={b[d.key]} />
))}
</div>
</div>
))}
</div>
{hidden > 0 && (
<button
type="button"
onClick={() => setShowAll(true)}
className="mt-6 inline-flex items-center gap-0.5 rounded text-caption font-semibold text-krow-blue
transition-colors hover:underline focus-visible:outline-none focus-visible:ring-2
focus-visible:ring-krow-blue/50"
>
View all {ALL_DIMENSIONS.length} signals
<ChevronRight className="h-3 w-3" aria-hidden="true" />
</button>
)}
</section>
);
}
/** 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 (
<div className="space-y-8">
<section>
<SectionLabel>Workforce reputation</SectionLabel>
<div className="rounded-xl border border-border bg-surface">
<div className="grid gap-x-8 gap-y-5 border-b border-border px-5 py-4 sm:grid-cols-3">
<div>
<p className="text-[10px] font-medium uppercase tracking-wide text-ink-4">Career score</p>
<div className="mt-1 flex items-baseline gap-2">
<p className="font-heading text-title-lg font-bold leading-none tabular-nums text-krow-blue">
{c.ai_score > 0 ? careerScore(c) : '—'}
</p>
{c.ai_score > 0 && <Badge variant="soft" size="sm">{tier.label}</Badge>}
</div>
<p className="mt-1 text-[10px] text-ink-4">300–850 scale</p>
</div>
<div>
<p className="text-[10px] font-medium uppercase tracking-wide text-ink-4">
Potential market value
</p>
<p className="mt-1 font-heading text-title-lg font-bold leading-none text-ink-1">
{c.ai_score > 0 ? marketValue(c) : '—'}
</p>
<p className="mt-1 text-[10px] text-ink-4">Based on career score</p>
</div>
<div>
<p className="text-[10px] font-medium uppercase tracking-wide text-ink-4">Next promotion</p>
<p className="mt-1 font-heading text-title-lg font-bold leading-none text-ink-1">
{c.ai_score > 0 ? nextPromotion(c) : '—'}
</p>
<p className="mt-1 text-[10px] text-ink-4">Tier this candidate tracks toward</p>
</div>
</div>
<div className="grid grid-cols-2 divide-x divide-y divide-border sm:grid-cols-3 lg:grid-cols-5 lg:divide-y-0">
{metrics.map((m) => (
<Metric key={m.label} label={m.label} value={m.value} suffix={m.suffix} />
))}
</div>
</div>
</section>
{c.companies_worked?.length > 0 && (
<section>
<SectionLabel>Worked with</SectionLabel>
<div className="flex flex-wrap gap-2">
{c.companies_worked.map((company) => (
<span
key={company}
className="rounded-md border border-border bg-surface px-2.5 py-1 text-body-sm text-ink-2"
>
{company}
</span>
))}
</div>
</section>
)}
{tags.length > 0 && (
<section>
<SectionLabel meta={`${tags.length} verified`}>Strength tags</SectionLabel>
<div className="flex flex-wrap gap-1.5">
{tags.map((t, i) => (
<span
key={`${t}-${i}`}
className="inline-flex items-center gap-1 rounded-md border border-krow-blue/20 bg-krow-blue-tint px-2.5 py-1 text-body-sm font-medium text-krow-blue"
>
<Check className="h-3 w-3" aria-hidden="true" />
{t}
</span>
))}
</div>
</section>
)}
</div>
);
}
/** 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 (
<EmptyState
title="No endorsements yet"
description="Endorsements come from supervisors this candidate has worked with on KROW shifts."
/>
);
}
return (
<div className="space-y-8">
<section>
<SectionLabel meta={`${voices.length + tags.length} total`}>
Endorsements
</SectionLabel>
{voices.length ? (
<ul className="divide-y divide-border overflow-hidden rounded-xl border border-border bg-surface">
{voices.map((v) => (
<li key={v.id} className="px-5 py-4">
<div className="flex items-start gap-3">
<Avatar name={v.name} size="sm" />
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-baseline justify-between gap-x-3 gap-y-1">
<p className="text-body-sm font-semibold text-ink-1">{v.name}</p>
<Stars count={v.rating} />
</div>
<p className="text-caption text-ink-3">
{v.role} · {v.events} events
{v.company && v.company !== 'Verified Reference' && ` · ${v.company}`}
</p>
<p className="mt-2 flex gap-1.5 text-body-sm leading-relaxed text-ink-2">
<Quote className="mt-0.5 h-3.5 w-3.5 shrink-0 text-ink-4" aria-hidden="true" />
{v.comment}
</p>
<div className="mt-3 flex flex-wrap items-center gap-2">
<Button
variant="outline"
size="xs"
shape="rounded"
onClick={() => toast.success(`Reference requested from ${v.name}`)}
>
Request Reference
</Button>
<Button
variant="outline"
size="xs"
shape="rounded"
onClick={() => toast.success(`Skill endorsement recorded for ${candidate.applicant_name}`)}
>
Endorse Skill
</Button>
</div>
</div>
</div>
</li>
))}
</ul>
) : (
<p className="text-body-sm text-ink-3">No written endorsements on record.</p>
)}
</section>
{tags.length > 0 && (
<section>
<SectionLabel meta={`${tags.length} verified`}>Endorsed strengths</SectionLabel>
<div className="flex flex-wrap gap-1.5">
{tags.map((t, i) => (
<span
key={`${t}-${i}`}
className="inline-flex items-center gap-1 rounded-md border border-krow-blue/20 bg-krow-blue-tint px-2.5 py-1 text-body-sm font-medium text-krow-blue"
>
<Check className="h-3 w-3" aria-hidden="true" />
{t}
</span>
))}
</div>
</section>
)}
</div>
);
}
/** Experience: history, availability, credentials and the applied position. */
function Experience({ candidate, job, interview }) {
const c = candidate;
return (
<div className="space-y-8">
<section>
<SectionLabel>Experience</SectionLabel>
<div className="grid gap-x-8 gap-y-5 sm:grid-cols-3">
<div>
<p className="text-[10px] font-medium uppercase tracking-wide text-ink-4">Years in role</p>
<p className="mt-1 font-heading text-title-lg font-bold leading-none text-ink-1">
{c.years_experience || '—'}
</p>
</div>
<div>
<p className="text-[10px] font-medium uppercase tracking-wide text-ink-4">English</p>
<p className="mt-1 text-body font-semibold capitalize text-ink-1">
{c.english_level || '—'}
</p>
{c.score_breakdown?.english > 0 && (
<p className="mt-0.5 text-caption text-ink-4">Scored {c.score_breakdown.english}</p>
)}
</div>
<div>
<p className="text-[10px] font-medium uppercase tracking-wide text-ink-4">Communication</p>
<p className="mt-1 text-body font-semibold text-ink-1">
{communicationLabel(c.score_breakdown?.communication_style) || '—'}
</p>
</div>
</div>
</section>
<section>
<SectionLabel>Availability</SectionLabel>
{c.availability?.length ? (
<div className="flex flex-wrap gap-1.5">
{c.availability.map((a) => (
<span
key={a}
className="inline-flex items-center gap-1 rounded-md border border-border bg-surface px-2.5 py-1 text-body-sm text-ink-2"
>
<Calendar className="h-3 w-3 text-ink-4" aria-hidden="true" />
{a}
</span>
))}
</div>
) : (
<p className="text-body-sm text-ink-3">
Not set — scheduling this candidate would be guesswork.
</p>
)}
</section>
<SkillsAndCerts candidate={c} />
<section>
<SectionLabel>Interview history</SectionLabel>
{interview ? (
<div className="rounded-xl border border-border bg-surface px-5 py-4">
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<p className="text-body font-semibold text-ink-1">
{interview.overall_interview_score > 0
? `Scored ${interview.overall_interview_score}/100`
: 'Started, not scored'}
</p>
<p className="text-caption text-ink-3">
Integrity {interview.integrity_score ?? 100}/100
</p>
</div>
<StatusBadge status={interview.verdict} size="sm" />
</div>
{interview.summary && (
<p className="mt-3 max-w-prose text-body-sm leading-relaxed text-ink-2">
{interview.summary}
</p>
)}
</div>
) : (
<p className="text-body-sm text-ink-3">No interview on record.</p>
)}
</section>
{job && (
<section>
<SectionLabel>Applied position</SectionLabel>
<div className="rounded-xl border border-border bg-surface px-5 py-4">
<p className="text-body font-semibold text-ink-1">{job.title}</p>
<p className="mt-0.5 text-caption text-ink-3">
{job.role_category || 'Uncategorized'}
{job.location ? ` · ${job.location}` : ''}
{job.pay_range_max ? ` · $${job.pay_range_min}–$${job.pay_range_max}/hr` : ''}
</p>
</div>
</section>
)}
</div>
);
}
/** The candidate's history as a timeline. */
function Activity({ candidate, interview }) {
const events = activityTimeline(candidate, interview);
return (
<section>
<SectionLabel meta={`${events.length} events`}>Activity</SectionLabel>
<ol className="relative space-y-0">
{events.map((e, i) => (
<li key={i} className="flex gap-3.5 pb-5 last:pb-0">
<div className="flex flex-col items-center">
<span
className={cn(
'mt-1.5 h-2 w-2 shrink-0 rounded-full ring-4 ring-surface-subtle',
i === events.length - 1 ? 'bg-krow-blue' : 'bg-ink-4'
)}
aria-hidden="true"
/>
{i < events.length - 1 && <span className="mt-1 w-px flex-1 bg-border" aria-hidden="true" />}
</div>
<div className="min-w-0 flex-1 pb-1">
<div className="flex flex-wrap items-baseline justify-between gap-x-3">
<p className="text-body-sm font-medium text-ink-1">{e.label}</p>
{e.date && (
<time className="text-caption text-ink-4">
{new Date(e.date).toLocaleDateString(undefined, {
month: 'short', day: 'numeric', year: 'numeric',
})}
</time>
)}
</div>
{e.detail && <p className="mt-0.5 text-caption text-ink-3">{e.detail}</p>}
</div>
</li>
))}
</ol>
</section>
);
}
/* ── 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 (
<div className="px-5 py-10 lg:px-8">
<div className="h-28 w-28 animate-pulse rounded-full bg-surface-sunken" />
<div className="mt-5 h-8 w-64 animate-pulse rounded bg-surface-sunken" />
</div>
);
}
if (!candidate) {
return (
<div className="px-5 py-16 lg:px-8">
<EmptyState
title="Candidate not found"
description="This candidate may have been removed, or the link may be out of date."
action={
<Button size="sm" onClick={() => navigate('/admin/candidates')}>
Back to candidates
</Button>
}
/>
</div>
);
}
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 (
<div className="-mx-5 -my-6 lg:-mx-6">
<Hero
candidate={candidate}
tier={tier}
onShortlist={shortlist}
onInterview={scheduleInterview}
onBack={() => 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. */}
<div className="sticky top-14 z-20 border-b border-border bg-surface/95 backdrop-blur-xl">
<div
role="tablist"
aria-label="Candidate profile sections"
className="flex gap-1 overflow-x-auto px-5 lg:px-8"
>
{TABS.map((t) => {
const active = t.value === tab;
return (
<button
key={t.value}
type="button"
role="tab"
aria-selected={active}
onClick={() => setTab(t.value)}
className={cn(
'relative whitespace-nowrap px-3 py-3 text-body-sm font-medium transition-colors duration-base',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-krow-blue/50',
active ? 'text-krow-blue' : 'text-ink-3 hover:text-ink-1'
)}
>
{t.label}
{active && (
<span
className="absolute inset-x-3 bottom-0 h-0.5 rounded-full bg-krow-blue"
aria-hidden="true"
/>
)}
</button>
);
})}
</div>
</div>
<div className="px-5 py-7 lg:px-8">
<div className="max-w-5xl space-y-10">
{tab === 'overview' && (
<>
<Recommendation candidate={candidate} verdict={verdict} onShortlist={shortlist} />
<EditorialSections candidate={candidate} />
<SkillsAndCerts candidate={candidate} />
<StrengthsAndGaps candidate={candidate} />
<MatchBreakdown candidate={candidate} />
</>
)}
{tab === 'match' && (
<>
<Recommendation candidate={candidate} verdict={verdict} onShortlist={shortlist} />
<MatchBreakdown candidate={candidate} />
<StrengthsAndGaps candidate={candidate} />
</>
)}
{tab === 'reputation' && <Reputation candidate={candidate} />}
{tab === 'experience' && (
<Experience candidate={candidate} job={job} interview={interview} />
)}
{tab === 'endorsements' && <Endorsements candidate={candidate} />}
{tab === 'activity' && <Activity candidate={candidate} interview={interview} />}
</div>
</div>
</div>
);
}

View File

@@ -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 (
<AdminPage
title="Candidates"
subtitle="Review, compare and manage the candidate pipeline."
actions={
<Button variant="outline" size="sm" shape="rounded" onClick={() => toast.info('Batch screening is disabled in the demo')}>
<Brain aria-hidden="true" /> Screen unscored
</Button>
}
>
<Toolbar
search={<SearchInput value={search} onChange={setSearch} placeholder="Search candidates..." size="sm" />}
filters={
<>
<FilterSelect value={position} onChange={setPosition} label="Position"
options={[{ value: 'all', label: 'All positions' }, ...positions.map((p) => ({ value: p, label: p }))]} />
<FilterSelect value={stage} onChange={setStage} label="Stage" options={[
{ value: 'all', label: 'All stages' }, { value: 'applied', label: 'Applied' },
{ value: 'ai_screened', label: 'AI Screened' }, { value: 'interview', label: 'Interviewing' },
{ value: 'hired', label: 'Hired' }, { value: 'rejected', label: 'Declined' },
]} />
<FilterSelect value={band} onChange={setBand} label="Score" options={[
{ value: 'all', label: 'All scores' }, { value: 'top', label: '80+ Top' },
{ value: 'strong', label: '60–79 Strong' }, { value: 'weak', label: 'Under 60' },
{ value: 'unscored', label: 'Unscored' },
]} />
<FilterSelect value={sort} onChange={setSort} label="Sort"
options={Object.entries(SORTS).map(([value, s]) => ({ value, label: s.label }))} />
</>
}
meta={`${filtered.length} of ${applications.length} candidates${isFiltered ? ' · filtered' : ''}`}
/>
{isLoading ? (
<div className="space-y-3">
{[...Array(5)].map((_, i) => (
<div key={i} className="h-24 bg-white border border-[#E5E7EB] rounded-xl animate-pulse" />
))}
</div>
) : filtered.length === 0 ? (
<div className="text-center py-16">
<p className="text-body-sm text-ink-3">No candidates match your filters</p>
{isFiltered && (
<Button size="xs" variant="outline" className="mt-3" onClick={clearFilters}>
Clear filters
</Button>
)}
</div>
) : (
<div className="space-y-4">
{filtered.map((app, idx) => (
<CandidateCard
key={app.id}
application={app}
jobTitle={resolveTitle(app)}
rank={idx + 1}
/>
))}
</div>
)}
<KrowAiChat page="candidates" />
</AdminPage>
);
}

View File

@@ -0,0 +1,418 @@
import React, { useMemo } from 'react';
import { useNavigate } from 'react-router-dom';
import {
Bar, BarChart, CartesianGrid, Cell, Pie, PieChart, ResponsiveContainer, Tooltip, XAxis, YAxis,
} from 'recharts';
import { ArrowRight } from 'lucide-react';
import {
AXIS_PROPS, Avatar, Badge, Button, CHART_TONES, ChartTooltip, InsightList, InsightRow,
MetricStrip, ProgressBar, ProgressRing, Surface,
} 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 Candidates Analysis — the analytical counterpart to Candidates.
*
* Candidates is for working through people one by one; this page is for reading
* the pool as a whole: supply, quality distribution, and where the risk sits.
* It is one of the three pages with the contextual Owliver panel, because these
* are the questions worth asking in language rather than filters.
*/
export default function AdminCandidatesAnalysis() {
const navigate = useNavigate();
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 bands = useMemo(() => ([
{ name: 'Top talent (80+)', value: f.ranked.filter((a) => a.ai_score >= 80).length, color: CHART_TONES.brand },
{ name: 'Strong (60–79)', value: f.ranked.filter((a) => a.ai_score >= 60 && a.ai_score < 80).length, color: CHART_TONES.accentPale },
{ name: 'Below bar (<60)', value: f.ranked.filter((a) => a.ai_score > 0 && a.ai_score < 60).length, color: CHART_TONES.navy },
{ name: 'Unscored', value: f.unscreened.length, color: CHART_TONES.mint },
].filter((b) => b.value > 0)), [f]);
const risks = [
f.missingCredentials.length && {
severity: 'critical',
title: 'Candidates with no credentials on file',
detail: 'Compliance exposure if any are placed on a role that requires certification.',
metric: f.missingCredentials.length,
metricLabel: 'candidates',
onClick: () => navigate('/admin/candidates'),
},
f.narrowAvailability.length && {
severity: 'warning',
title: 'Narrow availability',
detail: 'One slot or none — the placements most likely to fall through on the day.',
metric: f.narrowAvailability.length,
metricLabel: 'candidates',
onClick: () => navigate('/admin/candidates'),
},
f.flagged.length && {
severity: 'critical',
title: 'Interviews flagged for integrity',
detail: 'Suspiciously fast responses. Worth a human re-interview before progressing.',
metric: f.flagged.length,
metricLabel: 'interviews',
},
f.singleCandidateRoles.length && {
severity: 'warning',
title: 'Roles resting on one viable candidate',
detail: f.singleCandidateRoles.map((e) => e.posting.title).join(', '),
metric: f.singleCandidateRoles.length,
metricLabel: 'roles',
onClick: () => navigate('/admin/positions'),
},
].filter(Boolean);
const top = f.ranked.slice(0, 5);
/**
* Skill supply.
*
* Counted across the scored pool, with the average score of the people who hold
* each skill. Supply alone would be misleading: ten candidates claiming "bar
* setup" is not strength if all ten score in the forties.
*/
const skillSupply = useMemo(() => {
const map = new Map();
f.scored.forEach((a) => {
(a.skills || []).forEach((skill) => {
const e = map.get(skill) || { skill, count: 0, scores: [] };
e.count += 1;
e.scores.push(a.ai_score);
map.set(skill, e);
});
});
return [...map.values()]
.map((e) => ({
skill: e.skill,
count: e.count,
avgScore: Math.round(e.scores.reduce((x, y) => x + y, 0) / e.scores.length),
}))
.sort((a, b) => b.count - a.count)
.slice(0, 8);
}, [f.scored]);
/** Skills required by open roles that nobody in the scored pool claims. */
const skillGaps = useMemo(() => {
const held = new Set(f.scored.flatMap((a) => a.skills || []));
const required = new Map();
f.openPositions.forEach((p) => {
(p.certifications_required || []).forEach((c) => {
if (!required.has(c)) required.set(c, []);
required.get(c).push(p.title);
});
});
return [...required.entries()]
.filter(([name]) => !held.has(name))
.map(([name, roles]) => ({ name, roles }));
}, [f.scored, f.openPositions]);
/**
* Position fit — supply against quality, per open role.
*
* The two numbers that decide whether a role is actually fillable: how many
* applied, and how many of those clear the bar. A role with twelve applicants
* and none qualified is in worse shape than one with two who both do.
*/
const positionFit = useMemo(
() => f.byRole
.slice()
.map((r) => ({
...r,
fit: r.applied ? Math.round((r.qualified / r.applied) * 100) : 0,
}))
.sort((a, b) => b.fit - a.fit),
[f.byRole]
);
/** Recommendations, ordered by what it costs to leave them. */
const recommendations = useMemo(() => [
f.unscreened.length && {
title: `Screen ${f.unscreened.length} waiting applicant${f.unscreened.length === 1 ? '' : 's'}`,
body: 'Until they are scored they cannot be ranked against anyone already in the funnel.',
},
f.stalled.length && {
title: `Action ${f.stalled.length} candidate${f.stalled.length === 1 ? '' : 's'} scoring 80 or above`,
body: `${f.stalled.map((a) => a.applicant_name).join(', ')} ${f.stalled.length === 1 ? 'is' : 'are'} screened and waiting. The delay, not the assessment, is what loses these people.`,
},
f.missingCredentials.length && {
title: `Verify credentials for ${f.missingCredentials.length} scored candidate${f.missingCredentials.length === 1 ? '' : 's'}`,
body: 'Compliance exposure if any are placed on a role that requires certification.',
},
skillGaps.length && {
title: `Source for ${skillGaps.length} unmet requirement${skillGaps.length === 1 ? '' : 's'}`,
body: `${skillGaps.map((g) => g.name).join(', ')} — required by an open role and held by nobody in the scored pool.`,
},
f.singleCandidateRoles.length && {
title: `Widen supply on ${f.singleCandidateRoles.length} role${f.singleCandidateRoles.length === 1 ? '' : 's'}`,
body: `${f.singleCandidateRoles.map((e) => e.posting.title).join(', ')} rests on one viable candidate. One withdrawal reopens the search.`,
},
].filter(Boolean), [f, skillGaps]);
return (
<AdminPage
title="Candidates Analysis"
subtitle="Talent supply, quality distribution and pipeline risk."
>
<MetricStrip
columns={5}
items={[
{ label: 'In pipeline', value: f.total },
{ label: 'Scored', value: f.scored.length, sub: `${f.standardizedPct}% coverage`, tone: f.standardizedPct >= 80 ? 'success' : 'warning' },
{ label: 'At 80+', value: f.ranked.filter((a) => a.ai_score >= 80).length, tone: 'brand' },
{ label: 'Avg score', value: f.avgScore || '—' },
{ label: 'Risk flags', value: risks.length, tone: risks.length ? 'warning' : 'success' },
]}
/>
<div className="grid gap-6 lg:grid-cols-5">
<section aria-labelledby="dist" className="space-y-3 lg:col-span-2">
<SectionTitle id="dist" title="Quality distribution" meta={`${f.total} candidates`} />
<ResponsiveContainer width="100%" height={200}>
<PieChart>
<Pie data={bands} dataKey="value" nameKey="name" innerRadius={48} outerRadius={78} paddingAngle={2} strokeWidth={0}>
{bands.map((b) => <Cell key={b.name} fill={b.color} />)}
</Pie>
<Tooltip content={<ChartTooltip />} />
</PieChart>
</ResponsiveContainer>
<div className="space-y-1.5">
{bands.map((b) => (
<div key={b.name} className="flex items-center gap-2 text-caption">
<span className="h-2 w-2 shrink-0 rounded-sm" style={{ background: b.color }} aria-hidden="true" />
<span className="flex-1 text-ink-3">{b.name}</span>
<span className="font-semibold tabular-nums text-ink-1">{b.value}</span>
</div>
))}
</div>
</section>
<section aria-labelledby="strongest" className="space-y-3 lg:col-span-3">
<SectionTitle
id="strongest"
title="Strongest candidates"
meta="By AI score"
action={{ label: 'All candidates', onClick: () => navigate('/admin/candidates') }}
/>
<Surface variant="solid" radius="lg" padding="none" elevation="xs" className="divide-y divide-border overflow-hidden">
{top.length ? top.map((c, i) => (
<div key={c.id} className="flex items-center gap-3 px-4 py-2.5">
<span className="w-4 shrink-0 text-caption font-bold text-ink-4">{i + 1}</span>
<Avatar name={c.applicant_name} src={c.selfie_url} size="sm" />
<div className="min-w-0 flex-1">
<p className="truncate text-body-sm font-medium text-ink-1">{c.applicant_name}</p>
<p className="truncate text-[10px] text-ink-4">{c.job_title}</p>
</div>
{c.ai_match_label && (
<Badge variant="soft" size="sm" className="hidden sm:inline-flex">{c.ai_match_label}</Badge>
)}
<div className="w-20 shrink-0">
<ProgressBar value={c.ai_score} tone="score" size="xs" />
</div>
<ProgressRing value={c.ai_score} size={30} strokeWidth={3} tone="score" />
</div>
)) : (
<p className="px-4 py-8 text-center text-body-sm text-ink-3">No scored candidates yet.</p>
)}
</Surface>
</section>
</div>
<section aria-labelledby="risk" className="space-y-3">
<SectionTitle id="risk" title="Candidate risk" meta={risks.length ? `${risks.length} flags` : 'No flags'} />
<Surface variant="solid" radius="lg" padding="none" elevation="xs" className="overflow-hidden">
{risks.length ? (
<InsightList>{risks.map((r, i) => <InsightRow key={i} {...r} />)}</InsightList>
) : (
<p className="px-4 py-8 text-center text-body-sm text-ink-3">
No material risk flags. Credentials, availability and interview integrity all check out.
</p>
)}
</Surface>
<p className="text-caption text-ink-4">
Flags are questions for a human, not rejections.
</p>
</section>
{/* Skill supply and the gaps in it, side by side: what the pool has, and
what the open roles ask for and nobody offers. */}
<div className="grid gap-6 lg:grid-cols-5">
<section aria-labelledby="skills" className="space-y-3 lg:col-span-3">
<SectionTitle
id="skills"
title="Skill supply"
meta={`Top ${skillSupply.length} across the scored pool`}
/>
{skillSupply.length ? (
<ResponsiveContainer width="100%" height={Math.max(180, skillSupply.length * 30)}>
<BarChart data={skillSupply} layout="vertical" margin={{ top: 0, right: 28, left: 0, bottom: 0 }}>
<CartesianGrid strokeDasharray="3 3" stroke={CHART_TONES.grid} horizontal={false} />
<XAxis type="number" {...AXIS_PROPS} allowDecimals={false} />
<YAxis
type="category"
dataKey="skill"
{...AXIS_PROPS}
width={128}
tick={{ fontSize: 11, fill: CHART_TONES.axis }}
/>
<Tooltip content={<ChartTooltip />} />
<Bar dataKey="count" name="Candidates" radius={[0, 4, 4, 0]} maxBarSize={16}>
{/* Tinted by the average score of the people holding the skill,
so breadth and quality read together. */}
{skillSupply.map((s) => (
<Cell
key={s.skill}
fill={s.avgScore >= 75 ? CHART_TONES.brand : s.avgScore >= 60 ? CHART_TONES.accentPale : CHART_TONES.mint}
/>
))}
</Bar>
</BarChart>
</ResponsiveContainer>
) : (
<p className="text-body-sm text-ink-3">
Nothing is scored yet, so there is no skill supply to read.
</p>
)}
<p className="text-caption text-ink-4">
Bar length is how many candidates claim the skill. Darker blue means the
people holding it average 75 or above.
</p>
</section>
<section aria-labelledby="gaps" className="space-y-3 lg:col-span-2">
<SectionTitle
id="gaps"
title="Skill gaps"
meta={skillGaps.length ? `${skillGaps.length} unmet` : 'None'}
/>
<Surface variant="solid" radius="lg" padding="none" elevation="xs" className="overflow-hidden">
{skillGaps.length ? (
<ul className="divide-y divide-border">
{skillGaps.map((g) => (
<li key={g.name} className="px-4 py-2.5">
<p className="text-body-sm font-medium text-ink-1">{g.name}</p>
<p className="mt-0.5 text-caption leading-relaxed text-ink-3">
Required by {g.roles.join(', ')} — held by nobody in the scored pool.
</p>
</li>
))}
</ul>
) : (
<p className="px-4 py-8 text-center text-body-sm text-ink-3">
Every credential an open role requires is held by someone in the scored pool.
</p>
)}
</Surface>
</section>
</div>
{/* Position fit — which roles can actually be filled from current supply. */}
<section aria-labelledby="fit" className="space-y-3">
<SectionTitle id="fit" title="Position fit" meta={`${positionFit.length} open roles`} />
<Surface variant="solid" radius="lg" padding="none" elevation="xs" className="overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full border-collapse text-body-sm">
<caption className="sr-only">
Applicant supply against candidates clearing the bar, per open role
</caption>
<thead>
<tr className="border-b border-border bg-surface-subtle">
{['Role', 'Applied', 'Qualified', 'Avg score', 'Fit'].map((h, i) => (
<th
key={h}
scope="col"
className={`whitespace-nowrap px-4 py-2.5 text-[10px] font-semibold uppercase tracking-wide text-ink-4 ${i ? 'text-right' : 'text-left'}`}
>
{h}
</th>
))}
</tr>
</thead>
<tbody>
{positionFit.map((r) => (
<tr key={r.title} className="border-b border-border last:border-0 hover:bg-surface-subtle">
<td className="px-4 py-2.5 font-medium text-ink-1">{r.title}</td>
<td className={`px-4 py-2.5 text-right tabular-nums ${r.applied ? 'text-ink-2' : 'font-semibold text-destructive'}`}>
{r.applied}
</td>
<td className="px-4 py-2.5 text-right tabular-nums text-ink-2">{r.qualified}</td>
<td className="px-4 py-2.5 text-right tabular-nums text-ink-2">{r.avgScore || '—'}</td>
<td className="px-4 py-2.5">
<div className="ml-auto w-24">
<ProgressBar
value={r.fit}
tone={r.fit >= 50 ? 'success' : r.fit > 0 ? 'warning' : 'destructive'}
size="xs"
/>
<p className="mt-1 text-right text-[10px] tabular-nums text-ink-4">{r.fit}% qualified</p>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
</Surface>
</section>
{/* Recommendations — the actions the analysis above implies. */}
{recommendations.length > 0 && (
<section aria-labelledby="recommend" className="space-y-3">
<SectionTitle
id="recommend"
title="Recommendations"
meta={`${recommendations.length} ordered by impact`}
/>
<Surface variant="solid" radius="lg" padding="none" elevation="xs" className="overflow-hidden">
<ol className="divide-y divide-border">
{recommendations.map((r, i) => (
<li key={r.title} className="flex gap-3 px-4 py-3">
<span className="mt-0.5 grid h-5 w-5 shrink-0 place-items-center rounded-full bg-krow-blue-tint text-[10px] font-bold text-krow-blue">
{i + 1}
</span>
<div className="min-w-0">
<p className="text-body-sm font-medium text-ink-1">{r.title}</p>
<p className="mt-0.5 text-caption leading-relaxed text-ink-3">{r.body}</p>
</div>
</li>
))}
</ol>
</Surface>
</section>
)}
<section aria-labelledby="screening" className="space-y-3">
<SectionTitle id="screening" title="Screening efficiency" />
<div className="grid gap-4 sm:grid-cols-3">
{[
{ label: 'Coverage', value: `${f.standardizedPct}%`, detail: `${f.scored.length} of ${f.total} scored`, tone: f.standardizedPct >= 80 ? 'success' : 'warning' },
{ label: 'Interview completion', value: `${f.interviewCompletion}%`, detail: `${f.completedInterviews.length} of ${f.interviews.length} scored` },
{ label: 'Quality lift', value: f.hiredAvgScore && f.avgScore ? `+${f.hiredAvgScore - f.avgScore}` : '—', detail: 'hires vs pool average', tone: 'success' },
].map((s) => (
<Surface key={s.label} variant="solid" radius="lg" padding="default" elevation="xs">
<p className="text-[11px] font-medium uppercase tracking-wide text-ink-4">{s.label}</p>
<p className={`mt-1 font-heading text-title-lg font-bold tabular-nums ${s.tone === 'success' ? 'text-success' : s.tone === 'warning' ? 'text-warning' : 'text-ink-1'}`}>
{s.value}
</p>
<p className="mt-0.5 text-caption text-ink-3">{s.detail}</p>
</Surface>
))}
</div>
<Button variant="ghost" size="sm" onClick={() => navigate('/admin/analytics')} className="w-fit">
Full analytics <ArrowRight aria-hidden="true" />
</Button>
</section>
</AdminPage>
);
}

View File

@@ -0,0 +1,900 @@
import React, { useMemo, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import {
Area, Bar, CartesianGrid, Cell, ComposedChart, Legend, Line, ResponsiveContainer,
Tooltip, XAxis, YAxis,
} from 'recharts';
import { ArrowRight, ChevronRight, Download, Plus } from 'lucide-react';
import { cn } from '@/lib/utils';
import {
AXIS_PROPS, Avatar, Badge, Button, CHART_TONES, ChartTooltip, EmptyState, SegmentedToggle,
Skeleton, StatusBadge, toast,
} from '@/components/ds';
import {
useApplications, useInterviews, useJobPostings, useStaff, useUserActivity, useWorkerProfiles,
} from '@/lib/krowHooks';
import { buildFacts } from '@/components/ai-assistant/insights';
import { AdminPage } from '@/pages/admin/_shell';
/**
* Control Center — the KROW Admin command centre.
*
* Composed as one workspace rather than a grid of cards. Sections are separated by
* headings and rules, and a surface appears only where something genuinely needs
* lifting off the page: the metric strip, the two charts, the queue and the lists.
* That is the difference between a console and a dashboard — a dashboard frames
* every thought in its own box, and the boxes end up competing with the data.
*
* The reading order is deliberate, from state to action:
*
* snapshot → activity → where it leaks → which roles work
* → what to do → what is live → who is strongest → what just happened
*
* Every figure comes from the same fact sheet Owliver reads, so the page and the
* panel beside it cannot disagree. Nothing falls back to a placeholder number: an
* empty platform gets an empty state, because a console that invents "22
* candidates" to look populated is worse than one that admits it has none.
*/
/** Ranges for the activity chart. Local to that chart — see `activitySeries`. */
const RANGES = {
'7d': { label: '7D', days: 7, step: 1 },
'30d': { label: '30D', days: 30, step: 3 },
'90d': { label: '90D', days: 90, step: 9 },
};
/** Below this conversion, a stage transition is called out as the leak. */
const WEAK_TRANSITION = 60;
/* ── Section furniture ──────────────────────────────────────────────────── */
/**
* A section heading with an optional right-hand control.
*
* Local to this page rather than shared: the Control Center's sections carry chart
* controls and "view all" links in the same slot, and generalising that across the
* other Admin pages would mean a prop per case.
*/
function Band({ id, title, meta, children, action }) {
return (
<section aria-labelledby={id} className="space-y-3">
<div className="flex flex-wrap items-baseline justify-between gap-x-3 gap-y-2">
<div className="flex items-baseline gap-2">
<h2 id={id} className="font-heading text-body font-semibold text-ink-1">{title}</h2>
{meta && <span className="text-caption text-ink-4">{meta}</span>}
</div>
{action}
</div>
{children}
</section>
);
}
/** The "view all" affordance, consistent across every section that has one. */
function ViewAll({ label, onClick }) {
return (
<button
type="button"
onClick={onClick}
className="inline-flex items-center gap-0.5 rounded text-caption font-semibold text-krow-blue transition-colors
hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-krow-blue/50"
>
{label}
<ArrowRight className="h-3 w-3" aria-hidden="true" />
</button>
);
}
/* ── 1. Operations snapshot ─────────────────────────────────────────────── */
/**
* One grouped surface, six figures, hairline separators.
*
* Six cards would be six borders, six shadows and six landing points for the eye,
* all to carry six numbers that belong to a single thought: the current state of
* operations. As one banded strip it reads in a single pass.
*/
function Snapshot({ metrics, loading }) {
return (
<div className="grid grid-cols-2 divide-x divide-y divide-border overflow-hidden rounded-xl border border-border bg-surface sm:grid-cols-3 sm:divide-y-0 xl:grid-cols-6">
{metrics.map((m) => (
<div key={m.label} className="min-w-0 px-4 py-3.5">
<p className="text-[11px] font-medium uppercase leading-tight tracking-wide text-ink-4">
{m.label}
</p>
{loading ? (
<div className="mt-1.5 h-7 w-14 animate-pulse rounded bg-surface-sunken" />
) : (
<p
className={cn(
'mt-1 font-heading text-title-lg font-bold leading-none tabular-nums',
m.tone === 'brand' ? 'text-krow-blue'
: m.tone === 'warning' ? 'text-warning'
: m.tone === 'success' ? 'text-success' : 'text-ink-1'
)}
>
{m.value}
</p>
)}
{m.sub && <p className="mt-1 truncate text-caption text-ink-3">{m.sub}</p>}
</div>
))}
</div>
);
}
/* ── 3. Pipeline intelligence ───────────────────────────────────────────── */
/**
* The funnel as an analytical strip: stage, count, conversion in, drop-off.
*
* Not progress bars in cards. The question here is never "how full is this stage"
* — it is "where are we losing people", which is a property of the *transitions*
* rather than the stages. So each row reports what it received, what it kept and
* what it lost, and the weakest transition is called out in the existing warning
* token so the leak is impossible to miss.
*/
function PipelineFunnel({ funnel, transitions, weakest, total }) {
if (!total) {
return (
<EmptyState
title="Nothing in the pipeline"
description="Once candidates apply, stage conversion and drop-off appear here."
/>
);
}
return (
<div className="overflow-hidden rounded-xl border border-border bg-surface">
{funnel.map((stage, i) => {
const transition = i === 0 ? null : transitions[i - 1];
const isWeak = transition && weakest
&& transition.from === weakest.from && transition.to === weakest.to;
const share = (stage.count / total) * 100;
return (
<div
key={stage.key}
className={cn(
'flex items-center gap-4 border-b border-border px-4 py-3 last:border-0',
isWeak && 'bg-warning-muted'
)}
>
<div className="w-36 shrink-0">
<p className="text-body-sm font-medium text-ink-1">{stage.label}</p>
<p className="text-[10px] text-ink-4">{Math.round(share)}% of applied</p>
</div>
{/* The bar's width is this stage's share of the top of the funnel, so
the narrowing is literal rather than described. */}
<div className="min-w-0 flex-1">
<div className="h-2.5 overflow-hidden rounded-full bg-surface-sunken">
<div
className={cn('h-full rounded-full', isWeak ? 'bg-warning' : 'bg-krow-blue')}
style={{ width: `${Math.max(share, stage.count ? 3 : 0)}%` }}
/>
</div>
</div>
<p className="w-10 shrink-0 text-right font-heading text-body font-bold tabular-nums text-ink-1">
{stage.count}
</p>
<div className="w-24 shrink-0 text-right">
{transition ? (
<>
<p
className={cn(
'text-body-sm font-semibold tabular-nums',
isWeak ? 'text-warning' : transition.rate >= 80 ? 'text-success' : 'text-ink-2'
)}
>
{transition.rate}%
</p>
<p className="text-[10px] tabular-nums text-ink-4">
{transition.lost ? `−${transition.lost} dropped` : 'no drop-off'}
</p>
</>
) : (
<p className="text-[10px] text-ink-4">entry point</p>
)}
</div>
</div>
);
})}
{weakest && weakest.lost > 0 && (
<div className="border-t border-border bg-surface-subtle px-4 py-2.5">
<p className="text-caption leading-relaxed text-ink-2">
<span className="font-semibold text-ink-1">
{weakest.from} → {weakest.to}
</span>{' '}
is the leak: {weakest.rate}% pass through and {weakest.lost}{' '}
{weakest.lost === 1 ? 'candidate is' : 'candidates are'} lost. Every other
transition performs better, so this is the cheapest conversion to recover.
</p>
</div>
)}
</div>
);
}
/* ── 5. Action center ───────────────────────────────────────────────────── */
const SEVERITY = {
critical: { dot: 'bg-destructive', label: 'Critical' },
warning: { dot: 'bg-warning', label: 'Warning' },
info: { dot: 'bg-krow-blue', label: 'For review' },
};
/** One operational queue, divided rows. Not five cards in a grid. */
function ActionQueue({ items }) {
if (!items.length) {
return (
<div className="rounded-xl border border-border bg-surface px-4 py-8 text-center">
<p className="text-body-sm text-ink-3">
Nothing needs intervention. Every role has candidates, every applicant is scored,
and no hire is awaiting review.
</p>
</div>
);
}
return (
<ul className="divide-y divide-border overflow-hidden rounded-xl border border-border bg-surface">
{items.map((item) => (
<li key={item.title}>
<button
type="button"
onClick={item.onClick}
className="group flex w-full items-center gap-3.5 px-4 py-3 text-left transition-colors
hover:bg-surface-subtle focus-visible:outline-none focus-visible:ring-2
focus-visible:ring-inset focus-visible:ring-krow-blue/50"
>
<span
className={cn('h-2 w-2 shrink-0 rounded-full', SEVERITY[item.severity].dot)}
aria-hidden="true"
/>
<span className="sr-only">{SEVERITY[item.severity].label}.</span>
<span className="min-w-0 flex-1">
<span className="block truncate text-body-sm font-medium text-ink-1">{item.title}</span>
<span className="block truncate text-caption text-ink-3">{item.detail}</span>
</span>
<span className="shrink-0 text-right">
<span className="block font-heading text-body font-bold tabular-nums text-ink-1">
{item.metric}
</span>
<span className="block text-[10px] text-ink-4">{item.metricLabel}</span>
</span>
<span className="hidden shrink-0 items-center gap-0.5 text-caption font-semibold text-krow-blue sm:inline-flex">
Review
<ChevronRight
className="h-3 w-3 transition-transform duration-base group-hover:translate-x-0.5"
aria-hidden="true"
/>
</span>
</button>
</li>
))}
</ul>
);
}
/* ── Page ───────────────────────────────────────────────────────────────── */
export default function ControlCenter() {
const navigate = useNavigate();
const [range, setRange] = useState('30d');
const { data: applications = [], isLoading } = useApplications();
const { data: postings = [] } = useJobPostings();
const { data: interviews = [] } = useInterviews();
const { data: staff = [] } = useStaff();
const { data: profiles = [] } = useWorkerProfiles();
const { data: activity = [] } = useUserActivity();
const f = useMemo(
() => buildFacts({ applications, postings, interviews, staff, profiles, activity }),
[applications, postings, interviews, staff, profiles, activity]
);
/* 1. Operations snapshot. */
const metrics = [
{ label: 'Open positions', value: f.openPositions.length, sub: `${f.postings.length} total` },
{ label: 'Candidates', value: f.total, sub: `${f.scored.length} scored` },
{
label: 'In review',
value: f.unscreened.length + f.interviewing.length,
tone: f.unscreened.length ? 'warning' : 'default',
sub: `${f.unscreened.length} unscreened`,
},
{ label: 'AI screened', value: f.scored.length, sub: `${f.standardizedPct}% coverage` },
{ label: 'Hired', value: f.hired.length, tone: 'brand', sub: `${f.hireRate}% of applicants` },
{
label: 'Avg KROW score',
value: f.avgScore || '—',
sub: f.avgScore ? `across ${f.scored.length}` : 'none scored',
},
];
/**
* 2. Hiring activity, bucketed by day over the selected window.
*
* The range control belongs to this chart and nothing else — it changes what this
* series covers, not what the page is about, so a global toolbar would have
* implied the whole console re-scoped along with it.
*/
const activitySeries = useMemo(() => {
const { days, step } = RANGES[range];
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()}`,
applications: 0, screened: 0, interviews: 0, hires: 0,
});
}
applications.forEach((a) => {
const applied = String(a.created_date).slice(0, 10);
if (buckets.has(applied)) buckets.get(applied).applications += 1;
if (a.ai_score > 0) {
const scored = String(a.updated_date).slice(0, 10);
if (buckets.has(scored)) buckets.get(scored).screened += 1;
}
});
interviews.forEach((i) => {
const key = String(i.created_date).slice(0, 10);
if (buckets.has(key)) buckets.get(key).interviews += 1;
});
staff.forEach((s) => {
const key = String(s.hire_date).slice(0, 10);
if (buckets.has(key)) buckets.get(key).hires += 1;
});
/* A daily axis cannot carry 30 or 90 ticks, so longer ranges group into
buckets of `step` days.
Grouped, not sampled. Sampling — keeping every third day and discarding the
rest — quietly lost real events: all three hires in this dataset fall on one
day, and if that day was not a multiple of the step it simply never
appeared. A chart that drops data to stay tidy is worse than a crowded one. */
const rows = [...buckets.values()];
if (step === 1) return rows;
const grouped = [];
for (let i = 0; i < rows.length; i += step) {
const group = rows.slice(i, i + step);
grouped.push(group.reduce((acc, day) => ({
label: acc.label,
applications: acc.applications + day.applications,
screened: acc.screened + day.screened,
interviews: acc.interviews + day.interviews,
hires: acc.hires + day.hires,
}), { ...group[0] }));
}
return grouped;
}, [applications, interviews, staff, range, f.today]);
/* 4. Position performance. */
const positionPerformance = useMemo(
() => f.byRole
.slice()
.sort((a, b) => b.applied - a.applied)
.slice(0, 6)
.map((r) => ({
name: r.title.split(' – ')[0],
applicants: r.applied,
qualified: r.qualified,
hired: r.hired,
})),
[f.byRole]
);
/* Strongest and weakest by qualified conversion, so the chart can mark both
without a legend explaining which is which. */
const { strongest, weakest: weakestRole } = useMemo(() => {
const withApplicants = positionPerformance.filter((r) => r.applicants > 0);
if (!withApplicants.length) return { strongest: null, weakest: null };
const rate = (r) => r.qualified / r.applicants;
const sorted = [...withApplicants].sort((a, b) => rate(b) - rate(a));
return {
strongest: sorted[0].name,
weakest: sorted.length > 1 ? sorted[sorted.length - 1].name : null,
};
}, [positionPerformance]);
/* 5. Action center — ordered by what it costs to leave each one. */
const actions = useMemo(() => [
f.stalled.length && {
severity: 'critical',
title: 'Strong candidates not actioned',
detail: `${f.stalled.map((a) => a.applicant_name).join(', ')} scored 80 or above and ${f.stalled.length === 1 ? 'is' : 'are'} still at AI Screened.`,
metric: f.stalled.length,
metricLabel: 'at 80+',
onClick: () => navigate('/admin/candidates'),
},
f.starvedPositions.length && {
severity: 'critical',
title: 'Open roles with no applicants',
detail: `${f.starvedPositions.map((p) => p.title).join(', ')} — live and attracting nobody.`,
metric: f.starvedPositions.length,
metricLabel: 'roles',
onClick: () => navigate('/admin/positions'),
},
f.unscreened.length && {
severity: 'warning',
title: 'Candidates waiting for screening',
detail: 'Unscored applications are decisions made without a standard.',
metric: f.unscreened.length,
metricLabel: 'waiting',
onClick: () => navigate('/admin/candidates'),
},
f.bottleneck && f.bottleneck.lost > 0 && {
severity: 'warning',
title: `${f.bottleneck.from} → ${f.bottleneck.to} bottleneck`,
detail: `Only ${f.bottleneck.rate}% pass through this step, the weakest transition in the funnel.`,
metric: `−${f.bottleneck.lost}`,
metricLabel: 'lost',
onClick: () => navigate('/admin/analytics'),
},
f.unscoredProfiles.length && {
severity: 'info',
title: 'Talent profiles without a score',
detail: 'Unscored workers are invisible to matching, so employers cannot find them.',
metric: f.unscoredProfiles.length,
metricLabel: 'profiles',
onClick: () => navigate('/admin/talent-pool'),
},
f.unratedStaff.length && {
severity: 'info',
title: 'Hires awaiting review',
detail: 'Employer ratings are the only verified performance signal feeding scores.',
metric: f.unratedStaff.length,
metricLabel: 'unrated',
onClick: () => navigate('/admin/hired'),
},
].filter(Boolean), [f, navigate]);
/* 6. Active positions — live demand, biggest first. */
const activePositions = useMemo(
() => f.byRole.slice().sort((a, b) => b.applied - a.applied).slice(0, 5),
[f.byRole]
);
/* 7. Top talent — real applicants, real avatars, real scores. */
const topTalent = useMemo(() => f.ranked.slice(0, 5), [f.ranked]);
/* 8. Recent activity. */
const recent = useMemo(() => activity.slice(0, 6), [activity]);
/** Export the snapshot and the funnel — what gets pasted into a weekly review. */
const exportSnapshot = () => {
const rows = [
['Metric', 'Value'],
...metrics.map((m) => [m.label, m.value]),
[],
['Pipeline stage', 'Candidates', 'Conversion in'],
...f.funnel.map((s, i) => [s.label, s.count, i === 0 ? '' : `${f.transitions[i - 1].rate}%`]),
];
const csv = rows.map((r) => r.map((cell) => `"${String(cell ?? '')}"`).join(',')).join('\n');
const url = URL.createObjectURL(new Blob([csv], { type: 'text/csv;charset=utf-8' }));
const link = document.createElement('a');
link.href = url;
link.download = `krow-control-center-${f.today.toISOString().slice(0, 10)}.csv`;
link.click();
URL.revokeObjectURL(url);
toast.success('Operations snapshot exported');
};
return (
<AdminPage
title="Control Center"
subtitle="Workforce operations · hiring intelligence · platform health"
actions={
<>
<Button size="sm" shape="rounded" onClick={() => navigate('/positions/new')}>
<Plus aria-hidden="true" /> Create Position
</Button>
<Button variant="outline" size="sm" shape="rounded" onClick={exportSnapshot}>
<Download aria-hidden="true" /> Export
</Button>
</>
}
>
{/* 1 ── Operations snapshot */}
<Snapshot metrics={metrics} loading={isLoading} />
{/* 2 ── Hiring activity */}
<Band
id="cc-activity"
title="Hiring activity"
meta={
RANGES[range].step === 1
? `Applications, screening, interviews and hires · last ${RANGES[range].days} days, daily`
: `Applications, screening, interviews and hires · last ${RANGES[range].days} days, ${RANGES[range].step}-day totals`
}
action={
<SegmentedToggle
options={Object.entries(RANGES).map(([value, r]) => ({ value, label: r.label }))}
value={range}
onChange={setRange}
size="sm"
ariaLabel="Hiring activity range"
/>
}
>
<div className="rounded-xl border border-border bg-surface px-2 py-4">
{isLoading ? (
<Skeleton className="mx-2 h-[272px] rounded-lg" />
) : f.total ? (
<ResponsiveContainer width="100%" height={272}>
<ComposedChart data={activitySeries} margin={{ top: 4, right: 12, left: 0, bottom: 0 }}>
<defs>
<linearGradient id="ccApplications" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor={CHART_TONES.brand} stopOpacity={0.22} />
<stop offset="100%" stopColor={CHART_TONES.brand} stopOpacity={0} />
</linearGradient>
</defs>
<CartesianGrid strokeDasharray="3 3" stroke={CHART_TONES.grid} vertical={false} />
<XAxis dataKey="label" {...AXIS_PROPS} interval="preserveStartEnd" minTickGap={20} />
<YAxis {...AXIS_PROPS} allowDecimals={false} width={30} />
<Tooltip content={<ChartTooltip />} />
<Legend
verticalAlign="top"
align="right"
height={28}
iconType="circle"
iconSize={8}
wrapperStyle={{ fontSize: 12, color: CHART_TONES.axis }}
/>
{/* Applications are the volume everything else is drawn from, so
they take the filled area and the downstream stages are lines
over it. */}
<Area
type="monotone"
dataKey="applications"
name="Applications"
stroke={CHART_TONES.brand}
strokeWidth={2}
fill="url(#ccApplications)"
activeDot={{ r: 4, strokeWidth: 0 }}
/>
<Line
type="monotone"
dataKey="screened"
name="AI Screened"
stroke={CHART_TONES.navy}
strokeWidth={2}
strokeDasharray="4 3"
dot={false}
activeDot={{ r: 4, strokeWidth: 0 }}
/>
<Line
type="monotone"
dataKey="interviews"
name="Interviews"
stroke={CHART_TONES.navy}
strokeWidth={1.5}
strokeOpacity={0.55}
dot={false}
activeDot={{ r: 4, strokeWidth: 0 }}
/>
{/* Hires are sparse and are the outcome, so they take the accent
and a bar — a line at this volume reads as flat. */}
<Bar
dataKey="hires"
name="Hires"
fill={CHART_TONES.accent}
radius={[3, 3, 0, 0]}
maxBarSize={14}
/>
</ComposedChart>
</ResponsiveContainer>
) : (
<div className="px-2 py-8">
<EmptyState
title="No applications yet"
description="Once candidates start applying, daily hiring activity appears here."
/>
</div>
)}
</div>
</Band>
{/* 3 ── Pipeline intelligence */}
<Band
id="cc-pipeline"
title="Pipeline intelligence"
meta={`${f.total} entered · conversion and drop-off by stage`}
>
<PipelineFunnel
funnel={f.funnel}
transitions={f.transitions}
weakest={f.bottleneck && f.bottleneck.rate < WEAK_TRANSITION ? f.bottleneck : null}
total={f.total}
/>
</Band>
{/* 4 ── Position performance */}
<Band
id="cc-positions"
title="Position performance"
meta={strongest ? `Strongest conversion: ${strongest}` : undefined}
action={<ViewAll label="View all positions" onClick={() => navigate('/admin/positions')} />}
>
<div className="rounded-xl border border-border bg-surface px-2 py-4">
{positionPerformance.length ? (
<>
<ResponsiveContainer width="100%" height={236}>
<ComposedChart
data={positionPerformance}
barGap={3}
margin={{ top: 4, right: 12, left: 0, bottom: 0 }}
>
<CartesianGrid strokeDasharray="3 3" stroke={CHART_TONES.grid} vertical={false} />
<XAxis
dataKey="name"
{...AXIS_PROPS}
interval={0}
tick={{ fontSize: 10, fill: CHART_TONES.axis }}
/>
<YAxis {...AXIS_PROPS} allowDecimals={false} width={30} />
<Tooltip content={<ChartTooltip />} />
<Legend
verticalAlign="top"
align="right"
height={28}
iconType="circle"
iconSize={8}
wrapperStyle={{ fontSize: 12, color: CHART_TONES.axis }}
/>
<Bar
dataKey="applicants"
name="Applicants"
fill={CHART_TONES.brand}
radius={[3, 3, 0, 0]}
maxBarSize={24}
/>
<Bar dataKey="qualified" name="Qualified (70+)" radius={[3, 3, 0, 0]} maxBarSize={24}>
{/* The best converter takes the accent at full strength and the
worst is left pale, so both ends of the comparison are
visible without a callout. */}
{positionPerformance.map((r) => (
<Cell
key={r.name}
fill={r.name === strongest ? CHART_TONES.accent
: r.name === weakestRole ? CHART_TONES.mint : CHART_TONES.accentPale}
/>
))}
</Bar>
<Bar
dataKey="hired"
name="Hired"
fill={CHART_TONES.navy}
radius={[3, 3, 0, 0]}
maxBarSize={24}
/>
</ComposedChart>
</ResponsiveContainer>
{weakestRole && (
<p className="px-2 pt-2 text-caption leading-relaxed text-ink-3">
<span className="font-semibold text-ink-1">{strongest}</span> converts applicants
into qualified candidates best;{' '}
<span className="font-semibold text-ink-1">{weakestRole}</span> converts worst and
is where sourcing — or the bar itself — is worth a look.
</p>
)}
</>
) : (
<div className="px-2 py-8">
<EmptyState
title="No open positions"
description="Publish a role and its hiring performance appears here."
/>
</div>
)}
</div>
</Band>
{/* 5 ── Action center */}
<Band
id="cc-actions"
title="Action center"
meta={actions.length ? `${actions.length} items, most consequential first` : 'All clear'}
>
<ActionQueue items={actions} />
</Band>
{/* 6 / 7 ── Active positions and top talent. Two lists of comparable weight,
so they share a row rather than each taking one. */}
<div className="grid gap-6 xl:grid-cols-2">
<Band
id="cc-active"
title="Active positions"
meta={`${f.openPositions.length} hiring`}
action={<ViewAll label="View all" onClick={() => navigate('/admin/positions')} />}
>
{activePositions.length ? (
<ul className="divide-y divide-border overflow-hidden rounded-xl border border-border bg-surface">
{activePositions.map((r) => {
const coverage = r.applied ? Math.round((r.screened / r.applied) * 100) : 0;
return (
<li key={r.title}>
<button
type="button"
onClick={() => navigate('/admin/positions')}
className="group flex w-full items-center gap-3 px-4 py-3 text-left transition-colors
hover:bg-surface-subtle focus-visible:outline-none focus-visible:ring-2
focus-visible:ring-inset focus-visible:ring-krow-blue/50"
>
<span className="min-w-0 flex-1">
<span className="block truncate text-body-sm font-medium text-ink-1">
{r.title}
</span>
<span className="block truncate text-caption text-ink-3">
{r.applied} applicant{r.applied === 1 ? '' : 's'} · {r.screened} screened
{' · '}{r.hired} hired
</span>
</span>
{/* Screening coverage as a small bar — the one number that
says whether this role is actually being worked. */}
<span className="hidden w-16 shrink-0 sm:block">
<span className="block h-1.5 overflow-hidden rounded-full bg-surface-sunken">
<span
className={cn(
'block h-full rounded-full',
coverage === 100 ? 'bg-success'
: coverage >= 50 ? 'bg-krow-blue' : 'bg-warning'
)}
style={{ width: `${Math.max(coverage, r.applied ? 4 : 0)}%` }}
/>
</span>
<span className="mt-1 block text-right text-[10px] tabular-nums text-ink-4">
{coverage}%
</span>
</span>
<StatusBadge status={r.posting.status} size="sm" />
<ChevronRight
className="h-3.5 w-3.5 shrink-0 text-ink-4 transition-transform duration-base group-hover:translate-x-0.5"
aria-hidden="true"
/>
</button>
</li>
);
})}
</ul>
) : (
<EmptyState title="No open positions" description="Publish a role to start hiring." />
)}
</Band>
<Band
id="cc-talent"
title="Top talent"
meta={topTalent.length ? 'By KROW Score' : undefined}
action={<ViewAll label="View all" onClick={() => navigate('/admin/candidates')} />}
>
{topTalent.length ? (
<ul className="divide-y divide-border overflow-hidden rounded-xl border border-border bg-surface">
{topTalent.map((c) => (
<li key={c.id}>
<button
type="button"
onClick={() => navigate('/admin/candidates')}
className="group flex w-full items-center gap-3 px-4 py-3 text-left transition-colors
hover:bg-surface-subtle focus-visible:outline-none focus-visible:ring-2
focus-visible:ring-inset focus-visible:ring-krow-blue/50"
>
{/* The seeded portrait where there is one, otherwise the design
system's initials avatar. Never an invented face. */}
<Avatar name={c.applicant_name} src={c.selfie_url} size="sm" />
<span className="min-w-0 flex-1">
<span className="block truncate text-body-sm font-medium text-ink-1">
{c.applicant_name}
</span>
<span className="block truncate text-caption text-ink-3">{c.job_title}</span>
</span>
<span className="shrink-0 text-right">
<span className="block font-heading text-body font-bold tabular-nums text-ink-1">
{c.ai_score}
</span>
<span className="block text-[10px] text-ink-4">KROW</span>
</span>
<StatusBadge status={c.status} size="sm" />
<ChevronRight
className="h-3.5 w-3.5 shrink-0 text-ink-4 transition-transform duration-base group-hover:translate-x-0.5"
aria-hidden="true"
/>
</button>
</li>
))}
</ul>
) : (
<EmptyState
title="Nobody scored yet"
description="Screen the pipeline and the strongest candidates appear here."
/>
)}
</Band>
</div>
{/* 8 ── Recent activity */}
<Band
id="cc-recent"
title="Recent activity"
meta={`${activity.length} events logged`}
action={<ViewAll label="View all" onClick={() => navigate('/admin/activity')} />}
>
<div className="overflow-hidden rounded-xl border border-border bg-surface">
<div className="overflow-x-auto">
<table className="w-full border-collapse">
<caption className="sr-only">Most recent platform activity</caption>
<thead>
<tr className="border-b border-border bg-surface-subtle">
{['User', 'Action', 'Entity', 'Time', 'Role'].map((h) => (
<th
key={h}
scope="col"
className={cn(
'whitespace-nowrap px-4 py-2.5 text-[10px] font-semibold uppercase tracking-wide text-ink-4',
h === 'Role' ? 'text-right' : 'text-left'
)}
>
{h}
</th>
))}
</tr>
</thead>
<tbody>
{recent.map((event) => (
<tr key={event.id} className="border-b border-border last:border-0 hover:bg-surface-subtle">
<td className="px-4 py-2.5">
<div className="flex items-center gap-2.5">
<Avatar name={event.user_name || event.user_email} size="xs" />
<div className="min-w-0">
<p className="truncate text-body-sm font-medium text-ink-1">
{event.user_name || '—'}
</p>
<p className="truncate text-[10px] text-ink-4">{event.user_email}</p>
</div>
</div>
</td>
<td className="whitespace-nowrap px-4 py-2.5 text-body-sm text-ink-2">
{String(event.event_type).replace(/_/g, ' ')}
</td>
<td className="max-w-[24rem] px-4 py-2.5 text-body-sm text-ink-3">
<span className="line-clamp-1">{event.details || '—'}</span>
</td>
<td className="whitespace-nowrap px-4 py-2.5 text-caption text-ink-4">
{new Date(event.created_date).toLocaleDateString(undefined, {
month: 'short', day: 'numeric',
})}
</td>
<td className="px-4 py-2.5 text-right">
<Badge variant={event.account_type === 'employer' ? 'info' : 'neutral'} size="sm">
{event.account_type || 'unknown'}
</Badge>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</Band>
</AdminPage>
);
}

View File

@@ -0,0 +1,345 @@
import React, { useMemo } from 'react';
import { Area, AreaChart, CartesianGrid, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts';
import { ArrowRight, Award, Clock, TrendingUp, Users } from 'lucide-react';
import { cn } from '@/lib/utils';
import {
AXIS_PROPS, Avatar, Badge, CHART_TONES, ChartTooltip, DataTable, MetricStrip,
ProgressBar, StatusBadge, Surface,
} from '@/components/ds';
import { useApplications, useJobPostings, useStaff } from '@/lib/krowHooks';
import { AdminPage, SectionTitle } from '@/pages/admin/_shell';
/**
* Admin Hired History — hiring outcomes over time.
*
* Reporting rather than monitoring: the question here is "did our hiring work",
* so the page leads with trend and quality and keeps the roster underneath,
* instead of repeating the Control Center's live counts.
*/
export default function AdminHiredHistory() {
const { data: staff = [], isLoading } = useStaff();
const { data: applications = [] } = useApplications();
const { data: postings = [] } = useJobPostings();
const hires = useMemo(() => staff.map((s) => {
const app = applications.find((a) => a.id === s.application_id);
const posting = postings.find((p) => p.id === s.job_posting_id);
const days = app
? Math.max(1, Math.round((new Date(app.updated_date) - new Date(app.created_date)) / 86400000))
: null;
return { ...s, department: posting?.role_category || '—', timeToHire: days, score: s.ai_score || app?.ai_score || 0 };
}), [staff, applications, postings]);
const avg = (xs) => (xs.length ? Math.round(xs.reduce((a, b) => a + b, 0) / xs.length) : 0);
const quality = avg(hires.map((h) => h.score).filter(Boolean));
const speed = avg(hires.map((h) => h.timeToHire).filter(Boolean));
/* Cumulative hires by month — a trend needs a baseline, not a single bar. */
const trend = useMemo(() => {
const byMonth = new Map();
hires.forEach((h) => {
const d = new Date(h.hire_date);
const key = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`;
byMonth.set(key, (byMonth.get(key) || 0) + 1);
});
let running = 0;
return [...byMonth.entries()].sort().map(([key, count]) => {
running += count;
const [y, m] = key.split('-');
return {
label: new Date(Number(y), Number(m) - 1).toLocaleDateString(undefined, { month: 'short' }),
hires: count,
cumulative: running,
};
});
}, [hires]);
const byDepartment = useMemo(() => {
const map = new Map();
hires.forEach((h) => {
const e = map.get(h.department) || { department: h.department, count: 0, scores: [] };
e.count += 1;
if (h.score) e.scores.push(h.score);
map.set(h.department, e);
});
return [...map.values()].map((e) => ({ ...e, avgScore: avg(e.scores) })).sort((a, b) => b.count - a.count);
}, [hires]);
const maxDept = Math.max(...byDepartment.map((d) => d.count), 1);
/**
* Outcomes per position: how many were hired, how fast, and how well they
* scored. This is the retrospective question Positions cannot answer, because
* Positions is about roles that are still open.
*/
const byPosition = useMemo(() => {
const map = new Map();
hires.forEach((h) => {
const key = h.role || 'Unspecified';
const e = map.get(key) || { role: key, count: 0, scores: [], days: [], rated: 0, ratings: [] };
e.count += 1;
if (h.score) e.scores.push(h.score);
if (h.timeToHire) e.days.push(h.timeToHire);
if (h.client_rating) { e.rated += 1; e.ratings.push(h.client_rating); }
map.set(key, e);
});
return [...map.values()]
.map((e) => ({
role: e.role,
count: e.count,
avgScore: avg(e.scores),
avgDays: avg(e.days),
rated: e.rated,
avgRating: e.ratings.length
? Number((e.ratings.reduce((x, y) => x + y, 0) / e.ratings.length).toFixed(1))
: null,
}))
.sort((a, b) => b.count - a.count);
}, [hires]);
/**
* Historical comparison: this half of the dataset against the previous half.
*
* Split at the median hire date rather than by calendar month, because a demo
* dataset spanning a few weeks has no month boundary to compare across — and a
* comparison that silently returns zero is worse than one that adapts.
*/
const comparison = useMemo(() => {
const dated = hires
.filter((h) => h.hire_date)
.sort((a, b) => new Date(a.hire_date) - new Date(b.hire_date));
if (dated.length < 2) return null;
const midpoint = Math.floor(dated.length / 2);
const earlier = dated.slice(0, midpoint);
const later = dated.slice(midpoint);
const summarise = (set) => ({
hires: set.length,
quality: avg(set.map((h) => h.score).filter(Boolean)),
speed: avg(set.map((h) => h.timeToHire).filter(Boolean)),
});
const from = summarise(earlier);
const to = summarise(later);
const label = (set) => new Date(set[0].hire_date).toLocaleDateString(undefined, { month: 'short', day: 'numeric' });
return {
fromLabel: label(earlier),
toLabel: label(later),
rows: [
{ metric: 'Hires closed', from: from.hires, to: to.hires, betterIsUp: true },
{ metric: 'Quality of hire', from: from.quality, to: to.quality, betterIsUp: true },
{ metric: 'Days to hire', from: from.speed, to: to.speed, betterIsUp: false },
],
};
}, [hires]);
return (
<AdminPage title="Hired History" subtitle="Hiring outcomes, velocity and quality over time.">
<MetricStrip
columns={4}
loading={isLoading}
items={[
{ label: 'Total hires', value: hires.length, icon: Award, tone: 'brand' },
{ label: 'Avg time-to-hire', value: speed ? `${speed}d` : '—', icon: Clock, delta: -38, invertTrend: true, deltaLabel: 'vs 5d norm' },
{ label: 'Quality of hire', value: quality || '—', icon: TrendingUp, tone: quality >= 80 ? 'success' : 'default', sub: 'avg AI score' },
{ label: 'Still active', value: hires.filter((h) => h.status !== 'inactive').length, icon: Users, sub: `${hires.filter((h) => h.status === 'onboarding').length} onboarding` },
]}
/>
<section aria-labelledby="trend" className="space-y-3">
<SectionTitle id="trend" title="Hiring trend" meta="Cumulative hires by month" />
<Surface variant="solid" radius="lg" padding="none" elevation="xs">
{/* A trend needs at least two points to be a trend. With one month of
hires a line chart draws a single dot in an empty grid, which reads
as broken rather than as "not enough history yet" — so say the
latter, and give the figure that one month does support. */}
{trend.length < 2 ? (
<div className="px-4 py-8 text-center">
<p className="font-heading text-body font-semibold text-ink-1">
Not enough history for a trend
</p>
<p className="mx-auto mt-1 max-w-md text-body-sm leading-relaxed text-ink-3">
{hires.length
? `All ${hires.length} hire${hires.length === 1 ? '' : 's'} closed in ${trend[0]?.label || 'a single month'}. A month-on-month line appears once hiring spans a second month.`
: 'No hires recorded yet. Hiring volume over time appears here once the first role closes.'}
</p>
</div>
) : (
<div className="px-2 py-4">
<ResponsiveContainer width="100%" height={200}>
<AreaChart data={trend}>
<defs>
<linearGradient id="hireFill" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor={CHART_TONES.brand} stopOpacity={0.22} />
<stop offset="100%" stopColor={CHART_TONES.brand} stopOpacity={0} />
</linearGradient>
</defs>
<CartesianGrid strokeDasharray="3 3" stroke={CHART_TONES.grid} vertical={false} />
<XAxis dataKey="label" {...AXIS_PROPS} />
<YAxis {...AXIS_PROPS} allowDecimals={false} width={28} />
<Tooltip content={<ChartTooltip />} />
<Area type="monotone" dataKey="cumulative" name="Cumulative hires" stroke={CHART_TONES.brand} strokeWidth={2} fill="url(#hireFill)" />
</AreaChart>
</ResponsiveContainer>
</div>
)}
</Surface>
</section>
<section aria-labelledby="dept" className="space-y-3">
<SectionTitle id="dept" title="Department breakdown" meta={`${byDepartment.length} departments`} />
<Surface variant="solid" radius="lg" padding="lg" elevation="xs">
<div className="space-y-3">
{byDepartment.map((d) => (
<div key={d.department}>
<div className="mb-1 flex items-baseline justify-between gap-2">
<span className="text-body-sm text-ink-2">{d.department}</span>
<span className="flex items-baseline gap-2.5">
{d.avgScore > 0 && <span className="text-[10px] text-ink-4">avg {d.avgScore}</span>}
<span className="text-body-sm font-bold tabular-nums text-ink-1">{d.count}</span>
</span>
</div>
<ProgressBar value={d.count} max={maxDept} size="sm" />
</div>
))}
</div>
</Surface>
</section>
{/* Position outcomes — the retrospective read Positions cannot give. */}
<section aria-labelledby="outcomes" className="space-y-3">
<SectionTitle id="outcomes" title="Position outcomes" meta={`${byPosition.length} roles filled`} />
<Surface variant="solid" radius="lg" padding="none" elevation="xs" className="overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full border-collapse text-body-sm">
<caption className="sr-only">Hires, quality, speed and review outcome by role</caption>
<thead>
<tr className="border-b border-border bg-surface-subtle">
{['Role', 'Hires', 'Avg score', 'Avg days', 'Reviewed', 'Rating'].map((h, i) => (
<th
key={h}
scope="col"
className={`whitespace-nowrap px-4 py-2.5 text-[10px] font-semibold uppercase tracking-wide text-ink-4 ${i ? 'text-right' : 'text-left'}`}
>
{h}
</th>
))}
</tr>
</thead>
<tbody>
{byPosition.map((r) => (
<tr key={r.role} className="border-b border-border last:border-0 hover:bg-surface-subtle">
<td className="px-4 py-2.5 font-medium text-ink-1">{r.role}</td>
<td className="px-4 py-2.5 text-right tabular-nums text-ink-2">{r.count}</td>
<td className="px-4 py-2.5 text-right tabular-nums text-ink-2">{r.avgScore || '—'}</td>
<td className="px-4 py-2.5 text-right tabular-nums text-ink-2">{r.avgDays ? `${r.avgDays}d` : '—'}</td>
<td className="px-4 py-2.5 text-right tabular-nums text-ink-3">{r.rated}/{r.count}</td>
<td className="px-4 py-2.5 text-right">
{r.avgRating != null
? <Badge variant="success" size="sm">{r.avgRating}/5</Badge>
: <Badge variant="warning" size="sm">Pending</Badge>}
</td>
</tr>
))}
</tbody>
</table>
</div>
</Surface>
</section>
{/* Historical comparison — the same three measures, earlier against later. */}
{comparison && (
<section aria-labelledby="compare" className="space-y-3">
<SectionTitle
id="compare"
title="Historical comparison"
meta={`From ${comparison.fromLabel} against from ${comparison.toLabel}`}
/>
<Surface variant="solid" radius="lg" padding="none" elevation="xs" className="overflow-hidden">
<ul className="divide-y divide-border">
{comparison.rows.map((row) => {
const delta = row.to - row.from;
const improved = row.betterIsUp ? delta > 0 : delta < 0;
const flat = delta === 0;
return (
<li key={row.metric} className="flex items-center gap-4 px-4 py-3">
<span className="min-w-0 flex-1 text-body-sm text-ink-2">{row.metric}</span>
<span className="w-16 text-right font-heading text-body font-semibold tabular-nums text-ink-4">
{row.from || '—'}
</span>
<ArrowRight className="h-3.5 w-3.5 shrink-0 text-ink-4" aria-hidden="true" />
<span className="w-16 text-right font-heading text-body font-bold tabular-nums text-ink-1">
{row.to || '—'}
</span>
<span
className={cn(
'w-14 text-right text-caption font-semibold tabular-nums',
flat ? 'text-ink-4' : improved ? 'text-success' : 'text-destructive'
)}
>
{flat ? 'level' : `${delta > 0 ? '+' : ''}${delta}`}
</span>
</li>
);
})}
</ul>
</Surface>
<p className="text-caption text-ink-4">
The dataset split at its median hire date. On days to hire, lower is better.
</p>
</section>
)}
<section aria-labelledby="roster" className="space-y-3">
<SectionTitle id="roster" title="Recent hires" meta={`${hires.length} people`} />
<DataTable
loading={isLoading}
rows={hires}
pageSize={10}
caption="Everyone hired, with tier and time-to-hire"
columns={[
{
key: 'name', header: 'Hire', sortable: true,
cell: (h) => (
<div className="flex min-w-0 items-center gap-2.5">
<Avatar name={h.name} size="sm" />
<div className="min-w-0">
<p className="truncate font-medium text-ink-1">{h.name}</p>
<p className="truncate text-[10px] text-ink-4">{h.email}</p>
</div>
</div>
),
},
{ key: 'role', header: 'Role', sortable: true, hideBelow: 'md' },
{ key: 'department', header: 'Department', sortable: true, hideBelow: 'lg' },
{ key: 'profile_tier', header: 'Tier', cell: (h) => <StatusBadge status={h.profile_tier} size="sm" /> },
{
key: 'score', header: 'Score', align: 'right', sortable: true,
cell: (h) => (h.score ? <span className="font-semibold tabular-nums text-ink-1">{h.score}</span> : '—'),
},
{
key: 'timeToHire', header: 'Time to hire', align: 'right', sortable: true, hideBelow: 'lg',
cell: (h) => (h.timeToHire ? `${h.timeToHire}d` : '—'),
},
{
key: 'hire_date', header: 'Hired', align: 'right', sortable: true,
cell: (h) => (
<span className="whitespace-nowrap text-[11px] text-ink-4">
{new Date(h.hire_date).toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' })}
</span>
),
},
{
key: 'client_rating', header: 'Review', align: 'right', hideBelow: 'md',
cell: (h) => (h.client_rating
? <Badge variant="success" size="sm">{h.client_rating}/5</Badge>
: <Badge variant="warning" size="sm">Pending</Badge>),
},
]}
/>
</section>
</AdminPage>
);
}

223
src/pages/admin/Login.jsx Normal file
View File

@@ -0,0 +1,223 @@
import React, { useState } from 'react';
import { useLocation, useNavigate } from 'react-router-dom';
import { AlertCircle, ArrowRight, Loader2, Eye, EyeOff, Sparkles, CheckCircle2 } from 'lucide-react';
import { cn } from '@/lib/utils';
import { KROW_LOGO_URL } from '@/assets/brand';
import { Checkbox } from '@/components/ds';
import { base44 } from '@/api/base44Client';
import { startAdminSession } from '@/pages/admin/session';
const DEMO_EMAIL = 'demo@krow.app';
export default function AdminLogin() {
const navigate = useNavigate();
const location = useLocation();
const from = location.state?.from;
const returnTo = typeof from === 'string' && from.startsWith('/admin') && from !== '/admin/login'
? from
: '/admin';
const [form, setForm] = useState({ email: DEMO_EMAIL, password: '', remember: true });
const [showPassword, setShowPassword] = useState(false);
const [errors, setErrors] = useState({});
const [authError, setAuthError] = useState('');
const [status, setStatus] = useState('idle');
const validate = () => {
const next = {};
if (!form.email.trim()) next.email = 'Enter your work email';
else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(form.email.trim())) next.email = 'Enter a valid email address';
if (!form.password) next.password = 'Enter your password';
else if (form.password.length < 8) next.password = 'Passwords are at least 8 characters';
setErrors(next);
return Object.keys(next).length === 0;
};
const submit = async (event) => {
event.preventDefault();
setAuthError('');
if (!validate()) return;
setStatus('loading');
await new Promise((r) => setTimeout(r, 900));
if (form.password.toLowerCase() === 'wrong') {
setStatus('idle');
setAuthError('That email and password do not match an administrator account.');
return;
}
await base44.auth.updateMe({ account_type: 'admin' });
localStorage.setItem('activeRole', 'admin');
startAdminSession();
setStatus('success');
setTimeout(() => navigate(returnTo, { replace: true }), 320);
};
const busy = status !== 'idle';
return (
<main className="min-h-screen w-full bg-[#FAF9F5] flex items-center justify-center p-4 sm:p-6 lg:p-8 font-sans selection:bg-[#F9E547] selection:text-[#0F172A]">
<div className="w-full max-w-5xl bg-white border border-[#F3E8C8] rounded-3xl shadow-xl shadow-amber-900/5 overflow-hidden grid grid-cols-1 md:grid-cols-2 min-h-[580px]">
{/* Left Visual Section — White & Yellow Theme */}
<div className="relative hidden md:flex flex-col justify-between p-8 sm:p-10 bg-gradient-to-br from-[#FFFDF7] via-[#FFFBEB] to-[#FEF3C7] overflow-hidden border-r border-[#F3E8C8]">
{/* Subtle Decorative Yellow Orb / Accents */}
<div className="absolute -top-20 -left-20 w-64 h-64 rounded-full bg-[#F9E547]/30 blur-3xl pointer-events-none" />
<div className="absolute -bottom-20 -right-20 w-80 h-80 rounded-full bg-[#F59E0B]/20 blur-3xl pointer-events-none" />
{/* Top Brand & Badge */}
<div className="relative z-10 flex items-center justify-between">
<div className="flex items-center gap-3">
<img src={KROW_LOGO_URL} alt="KROW" className="h-8 w-auto object-contain" />
<span className="text-[11px] font-extrabold uppercase tracking-widest px-3 py-1 rounded-full bg-[#F9E547] text-[#0F172A] border border-[#FACC15] shadow-sm">
Enterprise
</span>
</div>
</div>
{/* Central KROW Visual Image */}
<div className="relative z-10 my-auto py-6 flex flex-col items-center">
<div className="relative rounded-2xl overflow-hidden shadow-lg border-2 border-white bg-white p-2 max-w-xs">
<img
src="/krow%20logo.jpeg"
alt="KROW Logo Visual"
className="w-full h-auto object-cover rounded-xl transition-transform duration-500 hover:scale-105"
/>
</div>
</div>
{/* Bottom Hero Tagline */}
<div className="relative z-10 space-y-2">
<div className="flex items-center gap-1.5 text-xs font-bold text-[#D97706] uppercase tracking-wider">
<Sparkles className="w-3.5 h-3.5" />
Recruiting Intelligence Platform
</div>
<h2 className="text-xl font-bold text-[#1E293B] tracking-tight leading-snug">
Workforce decisions powered by intelligence
</h2>
<p className="text-xs text-[#64748B] leading-relaxed">
AI-driven candidate screening, verified score dimensions, and talent matching.
</p>
</div>
</div>
{/* Right Login Form Section */}
<div className="flex flex-col justify-center p-6 sm:p-10 lg:p-12 bg-white">
<div className="w-full max-w-md mx-auto space-y-6">
{/* Header */}
<div>
<div className="flex items-center gap-2 mb-3 md:hidden">
<img src={KROW_LOGO_URL} alt="KROW" className="h-7 w-auto object-contain" />
<span className="text-[10px] font-bold uppercase tracking-widest px-2 py-0.5 rounded bg-[#F9E547] text-[#0F172A]">
Admin
</span>
</div>
<h1 className="text-2xl font-extrabold text-[#0F172A] tracking-tight">Welcome back</h1>
<p className="mt-1.5 text-xs text-[#64748B] leading-relaxed">
Sign in to manage hiring, candidates, talent intelligence, and workforce decisions.
</p>
</div>
{/* Error Alert */}
{authError && (
<div role="alert" className="flex items-start gap-2.5 rounded-xl border border-red-200 bg-red-50 p-3.5 text-red-800">
<AlertCircle className="mt-0.5 h-4 w-4 shrink-0 text-red-600" aria-hidden="true" />
<p className="text-xs leading-relaxed">{authError}</p>
</div>
)}
{/* Form */}
<form onSubmit={submit} noValidate className="space-y-4">
{/* Email */}
<div className="space-y-1.5">
<label className="block text-xs font-bold text-[#334155] uppercase tracking-wider">
Work email
</label>
<input
type="email"
autoComplete="username"
autoFocus
disabled={busy}
value={form.email}
onChange={(e) => setForm({ ...form, email: e.target.value })}
className={cn(
"w-full h-11 px-3.5 rounded-xl bg-[#FAF9F5] border text-sm text-[#0F172A] placeholder-slate-400 focus:outline-none transition-all",
errors.email ? "border-red-500 focus:border-red-500" : "border-[#E2E8F0] focus:border-[#F59E0B] focus:ring-2 focus:ring-[#FEF3C7]"
)}
placeholder="name@company.com"
/>
{errors.email && <p className="text-xs text-red-600 font-medium">{errors.email}</p>}
</div>
{/* Password */}
<div className="space-y-1.5">
<label className="block text-xs font-bold text-[#334155] uppercase tracking-wider">
Password
</label>
<div className="relative">
<input
type={showPassword ? "text" : "password"}
autoComplete="current-password"
disabled={busy}
value={form.password}
onChange={(e) => setForm({ ...form, password: e.target.value })}
className={cn(
"w-full h-11 pl-3.5 pr-10 rounded-xl bg-[#FAF9F5] border text-sm text-[#0F172A] placeholder-slate-400 focus:outline-none transition-all",
errors.password ? "border-red-500 focus:border-red-500" : "border-[#E2E8F0] focus:border-[#F59E0B] focus:ring-2 focus:ring-[#FEF3C7]"
)}
placeholder="••••••••"
/>
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-slate-400 hover:text-[#0F172A] transition-colors"
>
{showPassword ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
</button>
</div>
{errors.password && <p className="text-xs text-red-600 font-medium">{errors.password}</p>}
</div>
{/* Remember & Forgot */}
<div className="flex items-center justify-between text-xs pt-1">
<label className="flex cursor-pointer items-center gap-2 text-[#475569] font-medium">
<Checkbox
checked={form.remember}
onCheckedChange={(v) => setForm({ ...form, remember: Boolean(v) })}
disabled={busy}
/>
Remember me
</label>
<button
type="button"
disabled={busy}
onClick={() => setAuthError('Password resets are disabled in the demo.')}
className="text-[#D97706] hover:text-[#B45309] font-semibold transition-colors"
>
Forgot password?
</button>
</div>
{/* Primary CTA Button — KROW Yellow Theme */}
<button
type="submit"
disabled={busy}
className="w-full h-11 mt-3 rounded-xl bg-[#F9E547] hover:bg-[#FACC15] active:bg-[#EAB308] disabled:opacity-50 text-[#0F172A] text-sm font-bold flex items-center justify-center gap-2 border border-[#EAB308]/40 shadow-md shadow-amber-300/40 transition-all cursor-pointer"
>
{status === 'loading' && <Loader2 className="w-4 h-4 animate-spin text-[#0F172A]" />}
{status === 'success' && <CheckCircle2 className="w-4 h-4 text-[#0F172A]" />}
<span>{status === 'loading' ? 'Signing in...' : status === 'success' ? 'Signed in' : 'Continue'}</span>
{status === 'idle' && <ArrowRight className="w-4 h-4" />}
</button>
</form>
<p className="text-center text-xs text-[#94A3B8] leading-relaxed pt-2">
Demo environment — enter any 8+ character password to sign in. Use <span className="text-[#64748B] font-semibold">wrong</span> to test error validation.
</p>
</div>
</div>
</div>
</main>
);
}

View File

@@ -0,0 +1,518 @@
import React, { useMemo, useState } from 'react';
import { useNavigate, useSearchParams } from 'react-router-dom';
import { ArrowRight, MapPin, Plus, Sparkles, Wallet } from 'lucide-react';
import { cn } from '@/lib/utils';
import {
Avatar, Badge, Button, Drawer, EmptyState, ProgressBar, SearchInput,
Select, SelectContent, SelectItem, SelectTrigger, SelectValue, Skeleton, StatusBadge, toast,
} from '@/components/ds';
import { useApplications, useJobPostings } from '@/lib/krowHooks';
import { AdminPage, SectionTitle } from '@/pages/admin/_shell';
import { RoleGlyph } from '@/pages/admin/RoleGlyph';
import { buildPosition, payLabel } from '@/pages/admin/positionInsights';
import KrowAiChat from '@/components/krow/KrowAiChat';
/**
* Admin Positions — a hiring workspace, one card per role.
*
* The table this replaces was good at exactly one thing: comparing a single column
* down every row. But the question an operator brings to this page is "how is
* *this* role doing", and answering it from a table meant reading nine cells and
* assembling them yourself. A card can state the funnel, the health and the one
* thing to do next as a single thought.
*
* What keeps it from becoming a wall of decoration:
*
* - **One insight per card, not every field.** The most actionable finding, chosen
* in positionInsights.js, and nothing else.
* - **No cards inside cards.** Hierarchy comes from type, dividers and the
* progression strip.
* - **The drawer holds the depth.** Clicking a card opens the full record over the
* grid instead of navigating away, so reviewing seven roles never costs you your
* scroll position or your filters.
*/
const SORTS = {
attention: {
label: 'Needs attention first',
/* Health first, then the biggest backlog — so roles that need a person are
the ones visible without scrolling. */
compare: (a, b) => {
const rank = { risk: 0, warning: 1, success: 2, neutral: 3 };
return (rank[a.health.tone] - rank[b.health.tone]) || (b.stats.unscreened - a.stats.unscreened);
},
},
applicants: { label: 'Most applicants', compare: (a, b) => b.stats.applied - a.stats.applied },
starved: { label: 'Fewest applicants', compare: (a, b) => a.stats.applied - b.stats.applied },
updated: {
label: 'Recently updated',
compare: (a, b) => new Date(b.updated_date || b.created_date) - new Date(a.updated_date || a.created_date),
},
title: { label: 'Title A–Z', compare: (a, b) => a.title.localeCompare(b.title) },
};
const HEALTH_TONE = {
success: 'bg-success-muted text-success',
warning: 'bg-warning-muted text-warning',
risk: 'bg-destructive-muted text-destructive',
neutral: 'bg-surface-sunken text-ink-3',
};
/** Hiring health as a small filled chip — it is a state, so it reads as one. */
function HealthChip({ health, className }) {
return (
<span
className={cn(
'inline-flex shrink-0 items-center gap-1 rounded-full px-2 py-0.5 text-[11px] font-semibold',
HEALTH_TONE[health.tone],
className
)}
>
<span className="h-1.5 w-1.5 rounded-full bg-current" aria-hidden="true" />
{health.label}
</span>
);
}
/**
* The hiring progression: Applied → Screened → Shortlisted → Interviewed → Hired.
*
* Five labelled counts, each with a proportional bar, rather than one generic
* progress bar. A single bar would have to pick one number to stand for the whole
* funnel, and the interesting information is precisely *where* the funnel narrows.
*/
function Progression({ steps, className }) {
const entered = steps[0]?.count || 0;
return (
<div className={cn('grid grid-cols-5 gap-1.5', className)} role="group" aria-label="Hiring progression">
{steps.map((step) => {
const share = entered ? (step.count / entered) * 100 : 0;
return (
<div key={step.key} className="min-w-0">
<div className="h-1 overflow-hidden rounded-full bg-surface-sunken">
<div
className={cn('h-full rounded-full', step.count ? 'bg-krow-blue' : 'bg-transparent')}
style={{ width: `${Math.max(share, step.count ? 8 : 0)}%` }}
/>
</div>
<p className="mt-1.5 font-heading text-body-sm font-bold tabular-nums leading-none text-ink-1">
{step.count}
</p>
<p className="truncate text-[10px] leading-tight text-ink-4">{step.label}</p>
</div>
);
})}
</div>
);
}
/** One position. The whole card is the target, so there is no hunting for a link. */
function PositionCard({ position, onOpen }) {
const p = position;
return (
<article
role="button"
tabIndex={0}
onClick={() => onOpen(p)}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
onOpen(p);
}
}}
aria-label={`${p.title}. ${p.health.label}. ${p.stats.applied} applicants. ${p.insight}`}
className="group flex cursor-pointer flex-col rounded-xl border border-border bg-surface p-4 text-left shadow-xs
transition-[border-color,box-shadow] duration-base ease-out
hover:border-krow-blue/40 hover:shadow-md
focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-krow-blue/50"
>
{/* Identity */}
<div className="flex items-start gap-3">
<RoleGlyph category={p.role_category} title={p.title} aiGenerated={p.ai_generated} />
<div className="min-w-0 flex-1">
<h3 className="truncate font-heading text-body font-semibold leading-snug text-ink-1">
{p.title}
</h3>
<p className="truncate text-caption text-ink-3">{p.role_category || 'Uncategorized'}</p>
</div>
<StatusBadge status={p.status} size="sm" />
</div>
{/* Terms */}
<dl className="mt-3 flex flex-wrap items-center gap-x-3 gap-y-1 text-caption text-ink-3">
<div className="flex min-w-0 items-center gap-1">
<dt className="sr-only">Location</dt>
<MapPin className="h-3 w-3 shrink-0 text-ink-4" aria-hidden="true" />
<dd className="truncate">{p.location || 'Location not set'}</dd>
</div>
<div className="flex min-w-0 items-center gap-1">
<dt className="sr-only">Pay</dt>
<Wallet className="h-3 w-3 shrink-0 text-ink-4" aria-hidden="true" />
<dd className="truncate">{payLabel(p)}</dd>
</div>
</dl>
<div className="my-3 h-px bg-border" />
<Progression steps={p.progression} />
{/* Health, and the one thing worth knowing. `mb-3.5` rather than a margin on
the footer, so the footer is free to take `mt-auto`. */}
<div className="mb-3.5 mt-3.5 flex items-start gap-2">
<HealthChip health={p.health} className="mt-px" />
<p className="min-w-0 flex-1 text-caption leading-relaxed text-ink-2">{p.insight}</p>
</div>
{/* Footer */}
{/* `mt-auto` pins the footer to the bottom of the card, so a grid row of
cards with different insight lengths still has its footers aligned. */}
<div className="mt-auto flex items-center justify-between border-t border-border pt-2.5">
<span className="inline-flex items-center gap-1 text-[11px] font-medium text-ink-4">
{p.ai_generated
? <><Sparkles className="h-3 w-3" aria-hidden="true" /> AI Built</>
: 'Manual'}
</span>
<span className="inline-flex items-center gap-1 text-[11px] font-semibold text-krow-blue">
View position
<ArrowRight className="h-3 w-3 transition-transform duration-base group-hover:translate-x-0.5" aria-hidden="true" />
</span>
</div>
</article>
);
}
/** The full record, over the grid rather than instead of it. */
function PositionDrawer({ position, onClose, onViewCandidates }) {
if (!position) return null;
const p = position;
const s = p.stats;
const earliest = p.applications.reduce(
(oldest, a) => (!oldest || new Date(a.created_date) < new Date(oldest) ? a.created_date : oldest),
null
);
const lastScored = p.applications
.filter((a) => a.ai_score > 0)
.map((a) => a.updated_date)
.sort()
.pop();
const lastHired = p.applications
.filter((a) => a.status === 'hired')
.map((a) => a.updated_date)
.sort()
.pop();
const timeline = [
{ label: 'Position created', date: p.created_date },
s.applied && { label: `First of ${s.applied} applications`, date: earliest },
s.screened && { label: `${s.screened} screened`, date: lastScored },
s.hired && { label: `${s.hired} hired`, date: lastHired },
].filter(Boolean);
return (
<Drawer
open={Boolean(p)}
onOpenChange={(open) => !open && onClose()}
title={p.title}
description={`${p.role_category || 'Uncategorized'} · ${p.location || 'Location not set'} · ${payLabel(p)}`}
size="lg"
footer={
<>
<Button variant="outline" size="sm" onClick={onClose}>Close</Button>
<Button size="sm" onClick={() => onViewCandidates(p)}>
View {s.applied} candidate{s.applied === 1 ? '' : 's'}
</Button>
</>
}
>
<div className="space-y-5">
{/* Overview */}
<div className="flex items-start gap-3.5">
<RoleGlyph category={p.role_category} title={p.title} size="lg" aiGenerated={p.ai_generated} />
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-1.5">
<StatusBadge status={p.status} size="sm" />
<HealthChip health={p.health} />
<Badge variant="neutral" size="sm">{p.ai_generated ? 'AI Built' : 'Manual'}</Badge>
</div>
<p className="mt-2 text-caption leading-relaxed text-ink-3">
{p.health.note} {p.insight}
</p>
</div>
</div>
{/* Hiring status */}
<section>
<SectionTitle title="Hiring status" />
<dl className="grid grid-cols-2 gap-x-4 gap-y-3 sm:grid-cols-3">
{[
{ label: 'Applicants', value: s.applied },
{ label: 'Shortlisted', value: s.shortlisted },
{ label: 'Interviews', value: s.interviews },
{ label: 'Hired', value: s.hired },
{ label: 'Qualified (70+)', value: s.qualified },
{ label: 'Avg score', value: s.avgScore || '—' },
].map((m) => (
<div key={m.label}>
<dt className="text-[10px] font-medium uppercase tracking-wide text-ink-4">{m.label}</dt>
<dd className="font-heading text-title font-bold tabular-nums text-ink-1">{m.value}</dd>
</div>
))}
</dl>
<div className="mt-4">
<div className="mb-1.5 flex items-baseline justify-between">
<span className="text-caption text-ink-2">Screening progress</span>
<span className="text-caption font-semibold tabular-nums text-ink-1">{s.coverage}%</span>
</div>
<ProgressBar
value={s.coverage}
tone={s.coverage === 100 ? 'success' : s.coverage >= 60 ? 'brand' : 'warning'}
size="sm"
/>
<p className="mt-1 text-[11px] text-ink-4">
{s.applied - s.unscreened} of {s.applied} applicants scored
</p>
</div>
</section>
<section>
<SectionTitle title="Hiring progression" />
<Progression steps={p.progression} />
</section>
{/* Top candidates */}
<section>
<SectionTitle
title="Top candidates"
meta={p.topCandidates.length ? `${p.topCandidates.length} scored` : 'None scored'}
/>
{p.topCandidates.length ? (
<ul className="divide-y divide-border rounded-lg border border-border">
{p.topCandidates.map((c) => (
<li key={c.id} className="flex items-center gap-2.5 px-3 py-2.5">
<Avatar name={c.applicant_name} src={c.selfie_url} size="sm" />
<div className="min-w-0 flex-1">
<p className="truncate text-body-sm font-medium text-ink-1">{c.applicant_name}</p>
<p className="truncate text-[10px] text-ink-4">
{String(c.status).replace(/_/g, ' ')}
{c.ai_recommendation ? ` · ${c.ai_recommendation}` : ''}
</p>
</div>
<div className="shrink-0 text-right">
<p className="font-heading text-body font-bold tabular-nums text-ink-1">{c.ai_score}</p>
<p className="text-[10px] text-ink-4">KROW Score</p>
</div>
</li>
))}
</ul>
) : (
<p className="text-body-sm text-ink-3">
Nobody has been screened for this role yet, so there is no ranking to show.
</p>
)}
</section>
<section>
<SectionTitle title="Hiring timeline" />
<ol className="space-y-2">
{timeline.map((e, i) => (
<li key={i} className="flex items-baseline justify-between gap-3 text-body-sm">
<span className="text-ink-2">{e.label}</span>
<span className="shrink-0 text-caption text-ink-4">
{e.date
? new Date(e.date).toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' })
: '—'}
</span>
</li>
))}
</ol>
</section>
<section>
<SectionTitle title="Recent activity" />
{p.applications.length ? (
<ol className="space-y-2">
{[...p.applications]
.sort((a, b) => new Date(b.updated_date) - new Date(a.updated_date))
.slice(0, 5)
.map((a) => (
<li key={a.id} className="flex items-baseline justify-between gap-3 text-body-sm">
<span className="min-w-0 truncate text-ink-2">
<span className="font-medium text-ink-1">{a.applicant_name}</span>
{' — '}
{a.ai_score > 0 ? `scored ${a.ai_score}` : 'applied, not yet screened'}
</span>
<span className="shrink-0 text-caption text-ink-4">
{new Date(a.updated_date).toLocaleDateString(undefined, { month: 'short', day: 'numeric' })}
</span>
</li>
))}
</ol>
) : (
<p className="text-body-sm text-ink-3">No activity on this position yet.</p>
)}
</section>
</div>
</Drawer>
);
}
export default function AdminPositions() {
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const { data: postings = [], isLoading } = useJobPostings();
const { data: applications = [] } = useApplications();
const [search, setSearch] = useState(searchParams.get('search') || '');
const [status, setStatus] = useState(searchParams.get('status') || 'all');
const [department, setDepartment] = useState('all');
const [location, setLocation] = useState('all');
const [sort, setSort] = useState('attention');
const [selected, setSelected] = useState(null);
/* Derived once per position rather than per cell, so the grid does not run the
same filter five times per card. */
const rows = useMemo(
() => postings.map((p) => buildPosition(p, applications)),
[postings, applications]
);
const departments = useMemo(
() => [...new Set(postings.map((p) => p.role_category).filter(Boolean))].sort(),
[postings]
);
const locations = useMemo(
() => [...new Set(postings.map((p) => p.location).filter(Boolean))].sort(),
[postings]
);
const filtered = useMemo(() => {
const q = search.trim().toLowerCase();
return rows
.filter((p) => {
if (q && !`${p.title} ${p.role_category} ${p.location}`.toLowerCase().includes(q)) return false;
if (status !== 'all' && p.status !== status) return false;
if (department !== 'all' && p.role_category !== department) return false;
if (location !== 'all' && p.location !== location) return false;
return true;
})
.sort(SORTS[sort].compare);
}, [rows, search, status, department, location, sort]);
const isFiltered = Boolean(search) || [status, department, location].some((v) => v !== 'all');
const clearFilters = () => {
setSearch(''); setStatus('all'); setDepartment('all'); setLocation('all');
};
/* The page summary as a sentence rather than a KPI row: these are counts you
read once for orientation, and four cards would outweigh the grid they
introduce. */
const summary = useMemo(() => {
const open = rows.filter((p) => p.status === 'active').length;
const sum = (key) => rows.reduce((total, p) => total + p.stats[key], 0);
const applied = sum('applied');
return [
`${open} open position${open === 1 ? '' : 's'}`,
`${applied} active candidate${applied === 1 ? '' : 's'}`,
`${sum('shortlisted')} shortlisted`,
`${sum('hired')} hired`,
].join(' · ');
}, [rows]);
return (
<AdminPage
title="Positions"
subtitle="Manage open roles, hiring demand and position performance."
actions={
<Button size="sm" shape="rounded" onClick={() => navigate('/positions/new')}>
<Plus aria-hidden="true" /> Create Position
</Button>
}
>
{/* Controls. Search gets a fixed, sensible measure — wide enough for a role
title, not so wide it becomes the page's subject. */}
<div className="flex flex-col gap-2 lg:flex-row lg:items-center">
<div className="w-full lg:w-80">
<SearchInput value={search} onChange={setSearch} placeholder="Search positions" size="sm" />
</div>
<div className="flex flex-wrap items-center gap-2">
<FilterSelect value={status} onChange={setStatus} label="Status" options={[
{ value: 'all', label: 'All statuses' }, { value: 'active', label: 'Hiring' },
{ value: 'paused', label: 'Paused' }, { value: 'draft', label: 'Draft' },
{ value: 'closed', label: 'Closed' },
]} />
<FilterSelect value={department} onChange={setDepartment} label="Department"
options={[{ value: 'all', label: 'All departments' }, ...departments.map((d) => ({ value: d, label: d }))]} />
<FilterSelect value={location} onChange={setLocation} label="Location"
options={[{ value: 'all', label: 'All locations' }, ...locations.map((l) => ({ value: l, label: l }))]} />
<FilterSelect value={sort} onChange={setSort} label="Sort"
options={Object.entries(SORTS).map(([value, s]) => ({ value, label: s.label }))} />
</div>
</div>
<p className="text-caption text-ink-4">
{isFiltered ? `${filtered.length} of ${rows.length} positions · filtered` : summary}
</p>
{/* Three columns on desktop, two on tablet, one on mobile. */}
{isLoading ? (
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-3">
{Array.from({ length: 6 }).map((_, i) => (
<Skeleton key={i} className="h-64 rounded-xl" />
))}
</div>
) : filtered.length ? (
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-3">
{filtered.map((p) => (
<PositionCard key={p.id} position={p} onOpen={setSelected} />
))}
</div>
) : (
<EmptyState
title={isFiltered ? 'No positions match these filters' : 'No positions yet'}
description={
isFiltered
? 'Try a broader search, or clear the filters to see every role.'
: 'Create a position and applications will start appearing here.'
}
action={
isFiltered
? <Button variant="outline" size="sm" onClick={clearFilters}>Clear filters</Button>
: <Button size="sm" onClick={() => navigate('/positions/new')}>Create Position</Button>
}
/>
)}
<PositionDrawer
position={selected}
onClose={() => setSelected(null)}
onViewCandidates={(p) => {
setSelected(null);
if (p.stats.applied) navigate(`/admin/candidates?search=${encodeURIComponent(p.title)}`);
else toast.info(`${p.title} has no applicants yet`);
}}
/>
<KrowAiChat page="positions" />
</AdminPage>
);
}
/** A compact labelled select — the same control on every Admin management page. */
export function FilterSelect({ value, onChange, label, options, className }) {
return (
<Select value={value} onValueChange={onChange}>
<SelectTrigger aria-label={label} className={cn('h-8 w-auto min-w-[8.5rem] text-body-sm', className)}>
<SelectValue />
</SelectTrigger>
<SelectContent>
{options.map((o) => <SelectItem key={o.value} value={o.value}>{o.label}</SelectItem>)}
</SelectContent>
</Select>
);
}

359
src/pages/admin/Profile.jsx Normal file
View File

@@ -0,0 +1,359 @@
import React, { useMemo, useState } from 'react';
import {
Building2, Check, KeyRound, LogOut, Mail, Pencil, Shield, ShieldCheck, Smartphone,
} from 'lucide-react';
import {
Avatar, Badge, Button, Field, Input, Modal, Surface, Toggle, toast,
} from '@/components/ds';
import { base44 } from '@/api/base44Client';
import { useUserActivity } from '@/lib/krowHooks';
import { AdminPage, SectionTitle } from '@/pages/admin/_shell';
import { endAdminSession } from '@/pages/admin/session';
/**
* Admin Profile — an enterprise account page.
*
* Two columns rather than one long form: identity and permissions are reference
* information you read, while security and activity are things you act on. A
* single stack would bury the actions below the reading.
*/
/** Permissions the seeded admin holds. Realistic scopes, not placeholders. */
const PERMISSIONS = [
{ scope: 'Positions', level: 'Full access', detail: 'Create, edit, pause and close roles' },
{ scope: 'Candidates', level: 'Full access', detail: 'Screen, advance, decline and hire' },
{ scope: 'Talent Pool', level: 'Full access', detail: 'Search, contact and shortlist' },
{ scope: 'Analytics', level: 'Read only', detail: 'View reports and export' },
{ scope: 'Audit log', level: 'Read only', detail: 'View all platform activity' },
{ scope: 'Billing', level: 'No access', detail: 'Restricted to account owners' },
];
const LEVEL_TONE = { 'Full access': 'success', 'Read only': 'info', 'No access': 'neutral' };
export default function AdminProfile() {
const [user, setUser] = useState(null);
const [editOpen, setEditOpen] = useState(false);
const [passwordOpen, setPasswordOpen] = useState(false);
const [twoFactor, setTwoFactor] = useState(true);
const [saving, setSaving] = useState(false);
const [form, setForm] = useState({ full_name: '', email: '' });
/* Preferences. The Owliver default reads the flag the panel itself uses, so the
switch reflects reality rather than a second copy of the same state. */
const [owliverDefault, setOwliverDefault] = useState(() => {
try {
return sessionStorage.getItem('krow_assistant_open') !== '0';
} catch {
return true;
}
});
const [compact, setCompact] = useState(false);
const [digest, setDigest] = useState(true);
/* Stamped once on mount rather than read during render, so the "signed in"
time stays put instead of ticking forward on every rerender. */
const [sessionStarted] = useState(() =>
new Date().toLocaleString(undefined, {
weekday: 'short', month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit',
})
);
/** Enough to recognise the device without fingerprinting it. */
const deviceLabel = useMemo(() => {
const ua = navigator.userAgent;
const os = /Mac/i.test(ua) ? 'macOS' : /Windows/i.test(ua) ? 'Windows'
: /Android/i.test(ua) ? 'Android' : /iPhone|iPad/i.test(ua) ? 'iOS' : 'Unknown OS';
const browser = /Edg\//i.test(ua) ? 'Edge' : /Chrome\//i.test(ua) ? 'Chrome'
: /Safari\//i.test(ua) ? 'Safari' : /Firefox\//i.test(ua) ? 'Firefox' : 'Unknown browser';
return `${browser} on ${os}`;
}, []);
const { data: activity = [] } = useUserActivity();
React.useEffect(() => {
base44.auth.me().then((u) => {
setUser(u);
setForm({ full_name: u.full_name || '', email: u.email || '' });
}).catch(() => {});
}, []);
const myActivity = useMemo(
() => activity.filter((e) => e.user_email === user?.email).slice(0, 6),
[activity, user]
);
const save = async () => {
if (!form.full_name.trim()) {
toast.error('Name cannot be empty');
return;
}
setSaving(true);
try {
const updated = await base44.auth.updateMe({ full_name: form.full_name.trim() });
setUser(updated);
setEditOpen(false);
toast.success('Profile updated');
} finally {
setSaving(false);
}
};
return (
<AdminPage
title="Profile"
subtitle="Account, permissions and security."
actions={
<>
<Button variant="outline" size="sm" shape="rounded" onClick={() => setEditOpen(true)}>
<Pencil aria-hidden="true" /> Edit profile
</Button>
<Button
variant="destructive-ghost"
size="sm"
shape="rounded"
onClick={() => { endAdminSession(); base44.auth.logout('/admin/login'); }}
>
<LogOut aria-hidden="true" /> Sign out
</Button>
</>
}
>
<div className="grid gap-5 lg:grid-cols-3">
<div className="space-y-5 lg:col-span-2">
{/* Identity */}
<Surface variant="solid" radius="lg" padding="lg" elevation="xs">
<div className="flex flex-wrap items-start gap-4">
<Avatar name={user?.full_name || 'Admin'} size="2xl" shape="rounded" />
<div className="min-w-0 flex-1">
<h2 className="font-heading text-title-lg text-ink-1">{user?.full_name || '—'}</h2>
<p className="mt-0.5 text-body-sm text-ink-3">{user?.email}</p>
<div className="mt-2.5 flex flex-wrap gap-1.5">
<Badge variant="default" size="lg">Platform Administrator</Badge>
<Badge variant="success" size="lg">
<Check className="h-3 w-3" aria-hidden="true" /> Active
</Badge>
{twoFactor && <Badge variant="soft" size="lg">2FA enabled</Badge>}
</div>
</div>
</div>
<dl className="mt-5 grid gap-x-6 gap-y-3 border-t border-border pt-4 sm:grid-cols-2">
{[
{ label: 'Role', value: 'Platform Administrator', icon: Shield },
{ label: 'Department', value: 'Workforce Operations', icon: Building2 },
{ label: 'Account status', value: 'Active · verified', icon: ShieldCheck },
{ label: 'Member since', value: user ? new Date(user.created_date).toLocaleDateString(undefined, { month: 'long', year: 'numeric' }) : '—', icon: Mail },
].map((row) => (
<div key={row.label} className="flex items-start gap-2.5">
<row.icon className="mt-0.5 h-3.5 w-3.5 shrink-0 text-ink-4" aria-hidden="true" />
<div className="min-w-0">
<dt className="text-[11px] font-medium uppercase tracking-wide text-ink-4">{row.label}</dt>
<dd className="text-body-sm text-ink-1">{row.value}</dd>
</div>
</div>
))}
</dl>
</Surface>
{/* Permissions */}
<section aria-labelledby="perms" className="space-y-3">
<SectionTitle id="perms" title="Permissions" meta={`${PERMISSIONS.length} scopes`} />
<Surface variant="solid" radius="lg" padding="none" elevation="xs" className="divide-y divide-border overflow-hidden">
{PERMISSIONS.map((p) => (
<div key={p.scope} className="flex items-center gap-3 px-4 py-2.5">
<div className="min-w-0 flex-1">
<p className="text-body-sm font-medium text-ink-1">{p.scope}</p>
<p className="truncate text-caption text-ink-3">{p.detail}</p>
</div>
<Badge variant={LEVEL_TONE[p.level]} size="sm">{p.level}</Badge>
</div>
))}
</Surface>
</section>
{/* Recent activity */}
<section aria-labelledby="activity" className="space-y-3">
<SectionTitle id="activity" title="Your recent activity" meta={`${myActivity.length} events`} />
<Surface variant="solid" radius="lg" padding="none" elevation="xs" className="divide-y divide-border overflow-hidden">
{myActivity.length ? myActivity.map((e) => (
<div key={e.id} className="flex items-baseline justify-between gap-3 px-4 py-2.5">
<div className="min-w-0">
<p className="text-body-sm text-ink-1">{e.event_type.replace(/_/g, ' ')}</p>
{e.details && <p className="truncate text-caption text-ink-3">{e.details}</p>}
</div>
<span className="shrink-0 text-caption text-ink-4">
{new Date(e.created_date).toLocaleDateString(undefined, { month: 'short', day: 'numeric' })}
</span>
</div>
)) : (
<p className="px-4 py-8 text-center text-body-sm text-ink-3">No activity recorded for this account.</p>
)}
</Surface>
</section>
</div>
{/* Security */}
<section id="security" aria-labelledby="sec" className="space-y-3">
<SectionTitle id="sec" title="Security" />
<Surface variant="solid" radius="lg" padding="lg" elevation="xs" className="space-y-4">
<div>
<p className="text-[11px] font-medium uppercase tracking-wide text-ink-4">Last sign-in</p>
<p className="text-body-sm text-ink-1">
{new Date().toLocaleString(undefined, { month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' })}
</p>
<p className="text-caption text-ink-3">This device · 10.24.11.86</p>
</div>
<div className="border-t border-border pt-4">
<Toggle
label="Two-factor authentication"
description="Required for privileged actions"
checked={twoFactor}
onCheckedChange={(v) => {
setTwoFactor(v);
toast[v ? 'success' : 'error'](v ? '2FA enabled' : '2FA disabled');
}}
/>
</div>
<div className="space-y-2 border-t border-border pt-4">
<Button variant="outline" size="sm" shape="rounded" block onClick={() => setPasswordOpen(true)}>
<KeyRound aria-hidden="true" /> Change password
</Button>
<Button
variant="outline"
size="sm"
shape="rounded"
block
onClick={() => toast.info('Session management is disabled in the demo')}
>
<Smartphone aria-hidden="true" /> Manage sessions
</Button>
</div>
<p className="border-t border-border pt-4 text-caption leading-relaxed text-ink-3">
Password last changed 42 days ago. Administrator passwords expire every 90 days.
</p>
</Surface>
{/* Session information. Separate from Security because these are facts
about the session you are in right now, not settings you change. */}
<SectionTitle title="This session" />
<Surface variant="solid" radius="lg" padding="lg" elevation="xs">
<dl className="space-y-3">
{[
{ label: 'Signed in', value: sessionStarted },
{ label: 'Session type', value: 'Browser tab · ends on close' },
{ label: 'Device', value: deviceLabel },
{ label: 'Role in session', value: 'Platform Administrator' },
].map((row) => (
<div key={row.label} className="border-b border-border pb-3 last:border-0 last:pb-0">
<dt className="text-[10px] font-medium uppercase tracking-wide text-ink-4">{row.label}</dt>
<dd className="mt-0.5 text-body-sm text-ink-1">{row.value}</dd>
</div>
))}
</dl>
<p className="mt-4 border-t border-border pt-3 text-caption leading-relaxed text-ink-3">
Administrator sessions are not persisted across browser restarts, so
closing this tab signs you out.
</p>
</Surface>
{/* Product preferences. Only settings that change something real — a
preferences panel of inert switches is worse than no panel. */}
<SectionTitle title="Preferences" />
<Surface variant="solid" radius="lg" padding="lg" elevation="xs" className="space-y-4">
<Toggle
label="Owliver workspace"
description="Open the contextual panel by default on supported pages"
checked={owliverDefault}
onCheckedChange={(v) => {
setOwliverDefault(v);
try {
sessionStorage.setItem('krow_assistant_open', v ? '1' : '0');
} catch {
// Storage denied — the preference lasts for this render only.
}
toast.success(v ? 'Owliver opens by default' : 'Owliver starts collapsed');
}}
/>
<div className="border-t border-border pt-4">
<Toggle
label="Compact density"
description="Tighter row heights across Admin tables"
checked={compact}
onCheckedChange={(v) => {
setCompact(v);
toast.info(v ? 'Compact density is a preview in this demo' : 'Standard density restored');
}}
/>
</div>
<div className="border-t border-border pt-4">
<Toggle
label="Email digest"
description="A daily summary of what needs attention"
checked={digest}
onCheckedChange={(v) => {
setDigest(v);
toast.success(v ? 'Daily digest on' : 'Daily digest off');
}}
/>
</div>
</Surface>
</section>
</div>
<Modal
open={editOpen}
onOpenChange={setEditOpen}
title="Edit profile"
description="Update your account details."
icon={Pencil}
size="sm"
busy={saving}
footer={
<>
<Button variant="outline" onClick={() => setEditOpen(false)} disabled={saving}>Cancel</Button>
<Button onClick={save} loading={saving}>Save changes</Button>
</>
}
>
<div className="space-y-4">
<Field label="Full name" required>
<Input value={form.full_name} onChange={(e) => setForm({ ...form, full_name: e.target.value })} />
</Field>
<Field label="Email" hint="Email changes require verification and are disabled in the demo.">
<Input value={form.email} readOnly disabled />
</Field>
</div>
</Modal>
<Modal
open={passwordOpen}
onOpenChange={setPasswordOpen}
title="Change password"
description="Choose a password you do not use anywhere else."
icon={KeyRound}
size="sm"
footer={
<>
<Button variant="outline" onClick={() => setPasswordOpen(false)}>Cancel</Button>
<Button onClick={() => { setPasswordOpen(false); toast.success('Password updated'); }}>
Update password
</Button>
</>
}
>
<div className="space-y-4">
<Field label="Current password"><Input type="password" autoComplete="current-password" /></Field>
<Field label="New password" hint="At least 12 characters, with a number and a symbol.">
<Input type="password" autoComplete="new-password" />
</Field>
<Field label="Confirm new password"><Input type="password" autoComplete="new-password" /></Field>
</div>
</Modal>
</AdminPage>
);
}

View File

@@ -0,0 +1,71 @@
import {
Briefcase, ChefHat, ConciergeBell, HardHat, PackageSearch, ShieldCheck, Sparkles, Truck, Wine,
} from 'lucide-react';
import { cn } from '@/lib/utils';
/**
* RoleGlyph — the visual identity on a position card.
*
* Positions in this dataset have no photograph, and there is nothing to
* photograph anyway: a role is not a person. The two obvious fallbacks are both
* worse than this one — two-letter initials ("BA", "SE") read as a placeholder
* someone forgot to replace, and stock photography attaches an invented face to a
* job requisition, which is misleading on a page about hiring real people.
*
* So the glyph is the role's own trade: a chef's hat, a wine glass, a hard hat.
* It is drawn from the existing icon language and the existing brand tint, it is
* stable for a given category, and it tells you what kind of work the position is
* from across the room — which is the actual job of an image in a card grid.
*/
/** Category to icon. Matched loosely, so a new "Head Chef" still gets the hat. */
const GLYPHS = [
[/chef|cook|kitchen/i, ChefHat],
[/bartend|bar|mixolog/i, Wine],
[/server|wait|host|banquet|concierge/i, ConciergeBell],
[/security|guard|officer/i, ShieldCheck],
[/pick|warehouse|stock|inventory/i, PackageSearch],
[/driver|delivery|logistic/i, Truck],
[/construct|labour|labor|site/i, HardHat],
];
export function iconForRole(category, title = '') {
const subject = `${category || ''} ${title || ''}`;
const match = GLYPHS.find(([pattern]) => pattern.test(subject));
return match ? match[1] : Briefcase;
}
const SIZES = {
sm: { box: 'h-9 w-9 rounded-lg', icon: 'h-4 w-4' },
md: { box: 'h-11 w-11 rounded-xl', icon: 'h-5 w-5' },
lg: { box: 'h-14 w-14 rounded-xl', icon: 'h-6 w-6' },
};
export function RoleGlyph({ category, title, size = 'md', aiGenerated = false, className }) {
const Icon = iconForRole(category, title);
const s = SIZES[size] || SIZES.md;
return (
<span
className={cn(
'relative grid shrink-0 place-items-center bg-krow-blue-tint text-krow-blue ring-1 ring-krow-blue/10',
s.box,
className
)}
aria-hidden="true"
>
<Icon className={s.icon} />
{/* AI-built positions carry the same spark used everywhere else for
generated content, so provenance is legible without a second badge
competing for room in the card header. */}
{aiGenerated && (
<span className="absolute -right-1 -top-1 grid h-4 w-4 place-items-center rounded-full bg-krow-yellow text-ink-1 ring-2 ring-white">
<Sparkles className="h-2 w-2" />
</span>
)}
</span>
);
}
export default RoleGlyph;

View File

@@ -0,0 +1,384 @@
import React, { useMemo, useState } from 'react';
import { Bookmark, GitCompare, Star, UserRound } from 'lucide-react';
import {
Avatar, Badge, Button, DataTable, IconButton, Modal, ProgressBar, ProgressRing,
SearchInput, Surface, toast,
} from '@/components/ds';
import { useWorkerProfiles } from '@/lib/krowHooks';
import { getScoreBand, toFICO } from '@/lib/talentHome';
import { AdminPage, SectionTitle, Toolbar } from '@/pages/admin/_shell';
import { FilterSelect } from '@/pages/admin/Positions';
/**
* Admin Talent Pool — talent intelligence, not a card gallery.
*
* These are people who have not applied to anything, so the job is discovery:
* filter down, compare, shortlist. A dense list makes that possible; one card
* per person makes it a scroll.
*/
const SORTS = {
score: { label: 'Highest score', compare: (a, b) => (b.krow_score || 0) - (a.krow_score || 0) },
experience: { label: 'Most experience', compare: (a, b) => (b.experience_years || 0) - (a.experience_years || 0) },
name: { label: 'Name A–Z', compare: (a, b) => a.full_name.localeCompare(b.full_name) },
recent: { label: 'Recently added', compare: (a, b) => new Date(b.created_date) - new Date(a.created_date) },
};
const BANDS = {
all: () => true,
elite: (s) => s >= 90,
excellent: (s) => s >= 75 && s < 90,
solid: (s) => s >= 60 && s < 75,
building: (s) => s > 0 && s < 60,
unscored: (s) => !s,
};
export default function AdminTalentPool() {
const { data: profiles = [], isLoading } = useWorkerProfiles();
const [search, setSearch] = useState('');
const [skill, setSkill] = useState('all');
const [experience, setExperience] = useState('all');
const [location, setLocation] = useState('all');
const [band, setBand] = useState('all');
const [availability, setAvailability] = useState('all');
const [sort, setSort] = useState('score');
const [saved, setSaved] = useState([]);
const [compareOpen, setCompareOpen] = useState(false);
const skills = useMemo(
() => [...new Set(profiles.flatMap((p) => p.skills || []))].sort(),
[profiles]
);
const locations = useMemo(
() => [...new Set(profiles.map((p) => p.address).filter(Boolean))].sort(),
[profiles]
);
const availabilities = useMemo(
() => [...new Set(profiles.flatMap((p) => p.availability || []))].sort(),
[profiles]
);
const filtered = useMemo(() => {
const q = search.toLowerCase();
return profiles
.filter((p) => {
if (q && !`${p.full_name} ${p.email} ${p.desired_position} ${p.current_position} ${(p.skills || []).join(' ')}`.toLowerCase().includes(q)) return false;
if (skill !== 'all' && !(p.skills || []).includes(skill)) return false;
if (location !== 'all' && p.address !== location) return false;
if (availability !== 'all' && !(p.availability || []).includes(availability)) return false;
if (!BANDS[band](p.krow_score || 0)) return false;
if (experience === '5plus' && (p.experience_years || 0) < 5) return false;
if (experience === '2to5' && ((p.experience_years || 0) < 2 || (p.experience_years || 0) >= 5)) return false;
if (experience === 'under2' && (p.experience_years || 0) >= 2) return false;
return true;
})
.sort(SORTS[sort].compare);
}, [profiles, search, skill, experience, location, band, availability, sort]);
const isFiltered = Boolean(search) || [skill, experience, location, band, availability].some((v) => v !== 'all');
const clearFilters = () => {
setSearch(''); setSkill('all'); setExperience('all'); setLocation('all'); setBand('all'); setAvailability('all');
};
/**
* Talent segments.
*
* The pool's shape by career score band, with how many in each band are
* actually available. Score without availability is a shortlist you cannot
* staff from, so the two belong in the same read.
*/
const segments = useMemo(() => {
const band = (min, max) => profiles.filter((p) => {
const s = p.krow_score || 0;
return s >= min && (max == null || s < max);
});
return [
{ label: 'Elite', hint: '90+', members: band(90), tone: 'brand' },
{ label: 'Excellent', hint: '75–89', members: band(75, 90), tone: 'success' },
{ label: 'Solid', hint: '60–74', members: band(60, 75), tone: 'default' },
{ label: 'Building', hint: 'under 60', members: band(1, 60), tone: 'warning' },
{ label: 'Unscored', hint: 'no score', members: profiles.filter((p) => !(p.krow_score > 0)), tone: 'risk' },
].map((s) => ({
...s,
count: s.members.length,
available: s.members.filter((p) => (p.availability || []).length > 0).length,
avgExperience: s.members.length
? Math.round(s.members.reduce((sum, p) => sum + (p.experience_years || 0), 0) / s.members.length)
: 0,
}));
}, [profiles]);
const maxSegment = Math.max(...segments.map((s) => s.count), 1);
/* Whoever is saved, side by side. Saving is the selection mechanism, so the
comparison needs no second one. */
const savedProfiles = useMemo(
() => profiles.filter((p) => saved.includes(p.id)).sort((a, b) => (b.krow_score || 0) - (a.krow_score || 0)),
[profiles, saved]
);
const toggleSave = (id, name) => {
setSaved((prev) => {
const next = prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id];
toast.success(prev.includes(id) ? `${name} removed from saved` : `${name} saved`);
return next;
});
};
return (
<AdminPage
title="Talent Pool"
subtitle="Discover and manage high-potential talent for future hiring."
actions={
<Button
variant="outline"
size="sm"
shape="rounded"
disabled={saved.length < 2}
onClick={() => setCompareOpen(true)}
>
<GitCompare aria-hidden="true" /> Compare{saved.length ? ` (${saved.length})` : ''}
</Button>
}
>
{/* Segments — the pool's shape, and how much of each band is available. */}
<section aria-labelledby="segments" className="space-y-3">
<SectionTitle
id="segments"
title="Talent segments"
meta={`${profiles.length} in the pool`}
/>
<Surface variant="solid" radius="lg" padding="lg" elevation="xs">
<div className="space-y-3">
{segments.map((s) => (
<div key={s.label}>
<div className="mb-1 flex items-baseline justify-between gap-2">
<span className="text-body-sm text-ink-2">
{s.label} <span className="text-caption text-ink-4">{s.hint}</span>
</span>
<span className="flex items-baseline gap-3 text-[11px] text-ink-4">
{s.count > 0 && (
<>
<span>{s.available} available</span>
{s.avgExperience > 0 && <span>{s.avgExperience}y avg</span>}
</>
)}
<span className="w-6 text-right text-body-sm font-bold tabular-nums text-ink-1">
{s.count}
</span>
</span>
</div>
<ProgressBar
value={s.count}
max={maxSegment}
size="sm"
tone={s.tone === 'risk' ? 'warning' : s.tone === 'warning' ? 'warning' : 'brand'}
/>
</div>
))}
</div>
<p className="mt-4 border-t border-border pt-3 text-caption leading-relaxed text-ink-3">
{segments[4].count > segments[0].count + segments[1].count
? `${segments[4].count} workers carry no career score against ${segments[0].count + segments[1].count} at Excellent or above. Supply is not the constraint here — verification is.`
: 'The scored part of the pool outweighs the unscored, so matching has enough to work with.'}
</p>
</Surface>
</section>
<SectionTitle title="Talent directory" meta={`${profiles.length} profiles`} />
<Toolbar
search={<SearchInput value={search} onChange={setSearch} placeholder="Search name, role or skill" size="sm" />}
filters={
<>
<FilterSelect value={skill} onChange={setSkill} label="Skills"
options={[{ value: 'all', label: 'All skills' }, ...skills.map((s) => ({ value: s, label: s }))]} />
<FilterSelect value={experience} onChange={setExperience} label="Experience" options={[
{ value: 'all', label: 'Any experience' }, { value: '5plus', label: '5+ years' },
{ value: '2to5', label: '2–5 years' }, { value: 'under2', label: 'Under 2 years' },
]} />
<FilterSelect value={location} onChange={setLocation} label="Location"
options={[{ value: 'all', label: 'All locations' }, ...locations.map((l) => ({ value: l, label: l }))]} />
<FilterSelect value={band} onChange={setBand} label="Score" options={[
{ value: 'all', label: 'All scores' }, { value: 'elite', label: 'Elite 90+' },
{ value: 'excellent', label: 'Excellent 75+' }, { value: 'solid', label: 'Solid 60+' },
{ value: 'building', label: 'Building' }, { value: 'unscored', label: 'Unscored' },
]} />
<FilterSelect value={availability} onChange={setAvailability} label="Availability"
options={[{ value: 'all', label: 'Any availability' }, ...availabilities.map((a) => ({ value: a, label: a }))]} />
<FilterSelect value={sort} onChange={setSort} label="Sort"
options={Object.entries(SORTS).map(([value, s]) => ({ value, label: s.label }))} />
</>
}
meta={`${filtered.length} of ${profiles.length} talents${isFiltered ? ' · filtered' : ''}${saved.length ? ` · ${saved.length} saved` : ''}`}
/>
<DataTable
loading={isLoading}
rows={filtered}
pageSize={15}
isFiltered={isFiltered}
onClearFilters={clearFilters}
caption="Talent pool ranked by career score"
columns={[
{
key: 'full_name', header: 'Candidate', sortable: true,
cell: (p) => (
<div className="flex min-w-0 items-center gap-2.5">
<Avatar name={p.full_name} src={p.selfie_url} size="sm" />
<div className="min-w-0">
<p className="truncate font-medium text-ink-1">{p.full_name}</p>
<p className="truncate text-[10px] text-ink-4">{p.address || p.email}</p>
</div>
</div>
),
},
{
key: 'role', header: 'Role', hideBelow: 'md',
cell: (p) => (
<div className="min-w-0">
<p className="truncate text-ink-2">{p.current_position || p.desired_position || '—'}</p>
{p.desired_position && p.current_position && (
<p className="truncate text-[10px] text-ink-4">wants {p.desired_position}</p>
)}
</div>
),
},
{
key: 'skills', header: 'Skills', hideBelow: 'lg',
cell: (p) => (p.skills?.length ? (
<div className="flex items-center gap-1">
<Badge variant="neutral" size="sm">{p.skills[0]}</Badge>
{p.skills.length > 1 && <span className="text-[10px] text-ink-4">+{p.skills.length - 1}</span>}
</div>
) : <span className="text-[11px] text-ink-4">—</span>),
},
{
key: 'experience_years', header: 'Exp', align: 'right', sortable: true, hideBelow: 'md',
cell: (p) => (p.experience_years ? `${p.experience_years}y` : '—'),
},
{
key: 'availability', header: 'Availability', hideBelow: 'lg',
cell: (p) => (p.availability?.length
? <span className="text-[11px] text-ink-3">{p.availability.join(', ')}</span>
: <Badge variant="warning" size="sm">Not set</Badge>),
},
{
key: 'krow_score', header: 'Career score', align: 'right', sortable: true,
cell: (p) => {
const score = p.krow_score || 0;
const bandInfo = getScoreBand(score);
return (
<div className="flex items-center justify-end gap-2">
<div className="text-right">
<p className="font-heading text-body font-bold tabular-nums text-ink-1">{toFICO(score)}</p>
<p className="text-[10px] text-ink-4">{bandInfo.label}</p>
</div>
<ProgressRing value={score} size={26} strokeWidth={3} tone="score" label="" />
</div>
);
},
},
{
key: 'actions', header: '', align: 'right', width: 92,
cell: (p) => (
<div className="flex items-center justify-end gap-0.5" onClick={(e) => e.stopPropagation()} role="presentation">
<IconButton
icon={saved.includes(p.id) ? Star : Bookmark}
label={saved.includes(p.id) ? `Remove ${p.full_name} from saved` : `Save ${p.full_name}`}
variant="ghost"
size="sm"
onClick={() => toggleSave(p.id, p.full_name)}
/>
<IconButton
icon={UserRound}
label={`View ${p.full_name}'s profile`}
variant="ghost"
size="sm"
onClick={() => toast.info(`Opening ${p.full_name}'s KROW Identity`)}
/>
</div>
),
},
]}
/>
{/* Saved talent, side by side. A table, because the value is reading the
same five fields across people — anything more elaborate would be a
second product. */}
<Modal
open={compareOpen}
onOpenChange={setCompareOpen}
title="Compare saved talent"
description={`${savedProfiles.length} saved, ranked by career score.`}
icon={GitCompare}
size="lg"
>
<div className="overflow-x-auto">
<table className="w-full border-collapse text-body-sm">
<thead>
<tr className="border-b border-border">
{['Talent', 'Career score', 'Experience', 'Skills', 'Availability'].map((h) => (
<th
key={h}
scope="col"
className="whitespace-nowrap px-3 py-2 text-left text-[10px] font-semibold uppercase tracking-wide text-ink-4"
>
{h}
</th>
))}
</tr>
</thead>
<tbody>
{savedProfiles.map((p) => (
<tr key={p.id} className="border-b border-border last:border-0">
<td className="px-3 py-2.5">
<div className="flex min-w-0 items-center gap-2">
<Avatar name={p.full_name} src={p.selfie_url} size="xs" />
<div className="min-w-0">
<p className="truncate font-medium text-ink-1">{p.full_name}</p>
<p className="truncate text-[10px] text-ink-4">
{p.desired_position || p.current_position || '—'}
</p>
</div>
</div>
</td>
<td className="whitespace-nowrap px-3 py-2.5">
{p.krow_score ? (
<span className="font-heading font-bold tabular-nums text-ink-1">
{toFICO(p.krow_score)}
<span className="ml-1.5 text-[10px] font-normal text-ink-4">
{getScoreBand(p.krow_score).label}
</span>
</span>
) : (
<span className="text-[11px] text-ink-4">Not scored</span>
)}
</td>
<td className="whitespace-nowrap px-3 py-2.5 text-ink-2">
{p.experience_years ? `${p.experience_years}y` : '—'}
</td>
<td className="px-3 py-2.5">
{p.skills?.length ? (
<div className="flex items-center gap-1">
<Badge variant="neutral" size="sm">{p.skills[0]}</Badge>
{p.skills.length > 1 && (
<span className="text-[10px] text-ink-4">+{p.skills.length - 1}</span>
)}
</div>
) : <span className="text-[11px] text-ink-4">—</span>}
</td>
<td className="px-3 py-2.5 text-ink-2">
{p.availability?.length ? p.availability.join(', ') : (
<Badge variant="warning" size="sm">Not set</Badge>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
</Modal>
</AdminPage>
);
}

View File

@@ -0,0 +1,85 @@
import * as React from 'react';
import { ChevronRight } from 'lucide-react';
import { cn } from '@/lib/utils';
/**
* Shared page furniture for the Admin product.
*
* Admin pages are dense and numerous, so the header, the section labels and the
* vertical rhythm are defined once here. That is what makes seven independently
* built pages read as one product rather than seven takes on a dashboard.
*/
/**
* AdminPage — the standard page frame: title, subtitle, right-aligned controls,
* then stacked sections.
*
* Controls live in the page rather than in the global chrome, which is why the
* Admin header only needs one 56px bar.
*/
export function AdminPage({ title, subtitle, actions, tabs, children, className }) {
return (
<div className={cn('space-y-5', className)}>
<header className="flex flex-wrap items-start justify-between gap-3">
<div className="min-w-0">
<h1 className="font-heading text-title-lg text-ink-1">{title}</h1>
{subtitle && <p className="mt-0.5 text-body-sm text-ink-3">{subtitle}</p>}
</div>
{actions && <div className="flex flex-wrap items-center gap-2">{actions}</div>}
</header>
{tabs}
{children}
</div>
);
}
/**
* SectionTitle — the label above a region.
*
* Sentence-case and small rather than a heavy all-caps overline: on a page with
* four or five sections, loud labels compete with the data they introduce.
*/
export function SectionTitle({ id, title, meta, action, className }) {
return (
<div className={cn('flex flex-wrap items-baseline justify-between gap-2', className)}>
<div className="flex items-baseline gap-2">
<h2 id={id} className="font-heading text-body font-semibold text-ink-1">{title}</h2>
{meta && <span className="text-caption text-ink-4">{meta}</span>}
</div>
{action && (
<button
type="button"
onClick={action.onClick}
className="inline-flex items-center gap-0.5 rounded text-caption font-medium text-krow-blue transition-colors hover:underline
focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-krow-blue/50"
>
{action.label}
<ChevronRight className="h-3 w-3" aria-hidden="true" />
</button>
)}
</div>
);
}
/**
* Toolbar — the controls row above a table or list.
*
* Search takes the remaining space, filters keep their natural width, and the
* primary action is pushed to the end, so every management page has the same
* control geometry.
*/
export function Toolbar({ search, filters, actions, meta, className }) {
return (
<div className={cn('space-y-2', className)}>
<div className="flex flex-col gap-2 lg:flex-row lg:items-center">
{search && <div className="min-w-0 lg:max-w-xs lg:flex-1">{search}</div>}
{filters && <div className="flex flex-wrap items-center gap-2">{filters}</div>}
{actions && <div className="flex items-center gap-2 lg:ml-auto">{actions}</div>}
</div>
{meta && <p className="text-caption text-ink-4">{meta}</p>}
</div>
);
}

View File

@@ -0,0 +1,127 @@
/**
* Derived hiring facts for one position.
*
* Kept out of the components because the card, the drawer and the summary line
* all need the same numbers, and three independent derivations is how a card ends
* up disagreeing with the drawer it opens.
*/
const STAGE_ORDER = ['applied', 'ai_screened', 'shortlisted', 'interview', 'hired'];
/** Everyone who reached this stage or went past it. */
const atOrBeyond = (applications, stage) => {
const from = STAGE_ORDER.indexOf(stage);
return applications.filter((a) => STAGE_ORDER.indexOf(a.status) >= from);
};
/**
* Hiring health.
*
* Three states, and the thresholds are chosen so the label means something an
* operator would act on rather than describing the shape of the data:
*
* - **At risk** — nothing to hire from. No applicants at all, or applicants but
* nobody who clears the bar.
* - **Needs attention** — supply exists but is stuck: a screening backlog, or a
* role with candidates and no shortlist.
* - **Strong** — moving.
*/
function healthFor({ applied, unscreened, qualified, shortlisted, hired, status }) {
if (status !== 'active') {
return { label: 'Paused', tone: 'neutral', note: 'Not currently hiring.' };
}
if (!applied) {
return { label: 'At risk', tone: 'risk', note: 'No applicants yet.' };
}
/* Order matters here. "Nobody clears the bar" is only a safe conclusion once
everyone has actually been looked at — with applications still unscreened it
would be reporting a gap in our own process as a verdict on the candidates. */
if (unscreened > 0) {
return { label: 'Needs attention', tone: 'warning', note: `${unscreened} awaiting screening.` };
}
if (!qualified && !hired) {
return { label: 'At risk', tone: 'risk', note: 'Everyone screened, nobody clears the bar.' };
}
if (!shortlisted && !hired && applied >= 3) {
return { label: 'Needs attention', tone: 'warning', note: 'Screened but nobody shortlisted.' };
}
return { label: 'Strong', tone: 'success', note: 'Pipeline is moving.' };
}
/**
* The one thing worth saying about this position.
*
* Deliberately one. A card that lists every observation makes the reader do the
* triage the card was supposed to do for them, so this returns the most
* actionable finding and nothing else.
*/
function insightFor({ applied, unscreened, readyForInterview, coverage, hired, shortlisted, atRiskCount }) {
if (!applied) return 'No applicants yet — check the pay band and where this is posted.';
if (readyForInterview) {
return `${readyForInterview} candidate${readyForInterview === 1 ? '' : 's'} ready for interview`;
}
if (unscreened) {
return `${unscreened} candidate${unscreened === 1 ? '' : 's'} awaiting review`;
}
if (atRiskCount) {
return `${atRiskCount} candidate${atRiskCount === 1 ? '' : 's'} at risk — no credentials on file`;
}
if (coverage < 100) return `Screening coverage ${coverage}%`;
if (hired) return `${hired} hired${shortlisted > hired ? ` · ${shortlisted - hired} still shortlisted` : ''}`;
return 'Fully screened and awaiting a decision';
}
/** Builds one position row: the posting plus everything derived from its applicants. */
export function buildPosition(posting, applications) {
const apps = applications.filter((a) => a.job_posting_id === posting.id);
const applied = apps.length;
const unscreened = apps.filter((a) => a.status === 'applied').length;
const screened = atOrBeyond(apps, 'ai_screened').length;
const shortlisted = atOrBeyond(apps, 'shortlisted').length;
const interviews = atOrBeyond(apps, 'interview').length;
const hired = apps.filter((a) => a.status === 'hired').length;
const scored = apps.filter((a) => a.ai_score > 0);
const qualified = scored.filter((a) => a.ai_score >= 70).length;
const coverage = applied ? Math.round((scored.length / applied) * 100) : 0;
const avgScore = scored.length
? Math.round(scored.reduce((sum, a) => sum + a.ai_score, 0) / scored.length)
: 0;
/* Screened, strong, and not yet moved — the people a decision is owed to. */
const readyForInterview = apps.filter(
(a) => a.ai_score >= 70 && ['ai_screened', 'shortlisted'].includes(a.status)
).length;
const atRiskCount = scored.filter((a) => !(a.certifications || []).length).length;
const stats = {
applied, unscreened, screened, shortlisted, interviews, hired,
qualified, coverage, avgScore, readyForInterview, atRiskCount,
};
return {
...posting,
applications: apps,
stats,
health: healthFor({ ...stats, status: posting.status }),
insight: insightFor(stats),
/* The funnel, for the progression strip. Counts are cumulative, so each step
is a subset of the one before it and the bar never grows as it advances. */
progression: [
{ key: 'applied', label: 'Applied', count: applied },
{ key: 'screened', label: 'Screened', count: screened },
{ key: 'shortlisted', label: 'Shortlisted', count: shortlisted },
{ key: 'interview', label: 'Interviewed', count: interviews },
{ key: 'hired', label: 'Hired', count: hired },
],
topCandidates: [...apps]
.filter((a) => a.ai_score > 0)
.sort((a, b) => b.ai_score - a.ai_score)
.slice(0, 4),
};
}
/** Formats a pay band, or says plainly that there is not one. */
export const payLabel = (p) =>
p.pay_range_max ? `$${p.pay_range_min}–$${p.pay_range_max}/hr` : 'Pay not set';

View File

@@ -0,0 +1,50 @@
/**
* Admin sign-in state.
*
* The demo's data client (`base44.auth`) reports the seeded user as permanently
* signed in — there is no identity provider behind it. That is fine for the
* Employer and Talent demos, but the Admin console is supposed to be reached
* through its own sign-in, and "always authenticated" would mean /admin opened
* straight into the Control Center with the login page never seen.
*
* So Admin keeps a session marker of its own. It is deliberately thin: a single
* flag, set by the login page and read by the route guard. It is not security —
* a browser flag never is — it is the state a real session would occupy, put in
* the one place that both the guard and the login screen can agree on, so the
* flow is right now and swapping in a real provider later means changing this
* file and nothing else.
*
* `sessionStorage`, not `localStorage`: closing the tab should end the session,
* which is both the more defensible default for an administrative console and
* what makes the login page the app's actual starting point.
*/
const SESSION_KEY = 'krow_admin_session';
/** True when this tab has signed in to the Admin console. */
export function hasAdminSession() {
try {
return sessionStorage.getItem(SESSION_KEY) === '1';
} catch {
// Private mode with storage denied: fail closed, so the login page shows.
return false;
}
}
/** Records a successful sign-in. */
export function startAdminSession() {
try {
sessionStorage.setItem(SESSION_KEY, '1');
} catch {
// Nothing to do — the guard will simply ask for sign-in again.
}
}
/** Clears the session. Used by sign-out. */
export function endAdminSession() {
try {
sessionStorage.removeItem(SESSION_KEY);
} catch {
// Ignore.
}
}

View File

@@ -176,6 +176,7 @@ module.exports = {
/* The shell width. Wide enough that the dashboard keeps roughly its
original measure once the 380px assistant column is beside it. */
'container-wide': 'var(--container-wide)',
admin: 'var(--container-admin)',
},
keyframes: {
'accordion-down': {